Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion host/skills/corral-api/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,16 @@ corral api POST /projects/create -d '{
"branch": "<branch>", # optional; defaults to the repo default branch
"prompt": "<the full coding task, with all context the sandbox Claude needs>"
}'
# → { "id": "<projectId>", ... } — a sandbox is now running Claude on the task
# → { "id": "<projectId>", ... }
corral api POST /p/<projectId>/start # boots the container AND auto-submits the
# prompt into the sandbox's Claude
```

Create only clones + records the task; **you must then `POST /p/<id>/start`** —
that boots the container and auto-delivers the `prompt` into the sandbox's Claude
(no further call needed). Then supervise via `GET /status` + the conversations
API. (If you forget to start, the project just sits idle with the task pending.)

So the usual shape for a code request is: **conductor** spawns a **worker** whose
prompt says "create a sandbox project for repo X on this task and drive it to
done"; the **worker** calls `POST /projects/create` (with the coding task as the
Expand Down
8 changes: 8 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ type ProjectConfig struct {
DindPorts []string `json:"dind_ports,omitempty"`
LaunchTmux bool `json:"launch_tmux,omitempty"`

// PendingPrompt is the project's first-turn task prompt, awaiting delivery
// into Claude's TUI. The browser flow types it via POST /populate-prompt right
// after create; but a HEADLESSLY-created project (e.g. the conductor via the
// API) has no browser, so the backend delivers it on start instead (see
// handleStartProject) and CLEARS this so it fires exactly once. "" = nothing
// pending (already delivered, or a flow that pre-populates via ISSUE.md).
PendingPrompt string `json:"pending_prompt,omitempty"`

// DindCache, when set, makes this project start its inner-docker data root
// FROM a reusable named cache (see internal/dindcache) instead of an empty
// per-workspace volume. Only meaningful when DindEnabled. Nil = no cache
Expand Down
8 changes: 7 additions & 1 deletion internal/dashboard/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,13 @@ func lookupWorkspaceByID(id string) (string, error) {

type dashboardServer struct {
mu sync.Mutex
terms map[*termSession]struct{} // live browser-terminal PTYs (see terminal.go)
// promptClaimMu serializes the read-and-clear of a project's PendingPrompt so
// the first-turn task is delivered EXACTLY once. The browser fires /start and
// /populate-prompt near-simultaneously; both try to claim it. Without this
// lock the task could be typed twice (or, if both read "" after a clear,
// zero times). Tiny critical section (one config read + write).
promptClaimMu sync.Mutex
terms map[*termSession]struct{} // live browser-terminal PTYs (see terminal.go)
token string
// apiToken is a SECOND credential, distinct from the browser's session token,
// carried by the `corral api` CLI and the host Claude skill. Requests
Expand Down
96 changes: 96 additions & 0 deletions internal/dashboard/pending_prompt_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package dashboard

import (
"os"
"path/filepath"
"sync"
"testing"

"github.com/scoutapp/corral/internal/config"
)

// writePendingConfig materializes a minimal project config carrying a pending
// prompt, at <workspace>/.corral/project/config.json.
func writePendingConfig(t *testing.T, workspace, prompt string) {
t.Helper()
dir := config.ProjectDirFor(workspace)
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatal(err)
}
if err := config.WriteConfig(dir, &config.ProjectConfig{Workspace: workspace, PendingPrompt: prompt}); err != nil {
t.Fatal(err)
}
}

// TestClaimPendingPromptFiresOnce is the correctness core of headless prompt
// delivery: claimPendingPrompt must return the prompt exactly once and clear it,
// so a project's first-turn task is typed into Claude a single time even though
// the browser fires /start and /populate-prompt near-simultaneously.
func TestClaimPendingPromptFiresOnce(t *testing.T) {
t.Setenv("CORRAL_HOME", t.TempDir())
d := newDashboardServer("tok")
ws := filepath.Join(t.TempDir(), "proj")
writePendingConfig(t, ws, "do the task")

// First claim wins.
if got := d.claimPendingPrompt(ws); got != "do the task" {
t.Fatalf("first claim = %q, want the prompt", got)
}
// Second claim gets nothing (cleared).
if got := d.claimPendingPrompt(ws); got != "" {
t.Errorf("second claim = %q, want empty (fire-once)", got)
}
// The cleared value is persisted.
cfg, err := config.ReadConfig(config.ProjectDirFor(ws))
if err != nil {
t.Fatal(err)
}
if cfg.PendingPrompt != "" {
t.Errorf("PendingPrompt not cleared on disk: %q", cfg.PendingPrompt)
}
}

// TestClaimPendingPromptConcurrent proves the lock: N racing claims yield the
// prompt to EXACTLY one caller (never twice → no double-typed task; never zero).
func TestClaimPendingPromptConcurrent(t *testing.T) {
t.Setenv("CORRAL_HOME", t.TempDir())
d := newDashboardServer("tok")
ws := filepath.Join(t.TempDir(), "proj")
writePendingConfig(t, ws, "task")

const N = 16
var wg sync.WaitGroup
var mu sync.Mutex
wins := 0
wg.Add(N)
for i := 0; i < N; i++ {
go func() {
defer wg.Done()
if d.claimPendingPrompt(ws) != "" {
mu.Lock()
wins++
mu.Unlock()
}
}()
}
wg.Wait()
if wins != 1 {
t.Errorf("concurrent claims won %d times, want exactly 1", wins)
}
}

// TestClaimPendingPromptMissing: no config / no pending prompt → empty, no panic.
func TestClaimPendingPromptMissing(t *testing.T) {
t.Setenv("CORRAL_HOME", t.TempDir())
d := newDashboardServer("tok")
// Nonexistent workspace.
if got := d.claimPendingPrompt(filepath.Join(t.TempDir(), "nope")); got != "" {
t.Errorf("missing config claim = %q, want empty", got)
}
// Existing config with no pending prompt.
ws := filepath.Join(t.TempDir(), "empty")
writePendingConfig(t, ws, "")
if got := d.claimPendingPrompt(ws); got != "" {
t.Errorf("empty pending claim = %q, want empty", got)
}
}
35 changes: 34 additions & 1 deletion internal/dashboard/projects_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,12 @@ func (d *dashboardServer) handleCreateProject(w http.ResponseWriter, r *http.Req
Dind *bool `json:"dind"`
Tmux bool `json:"tmux"`
Ports []string `json:"ports"`
// Prompt is the first-turn task handed to the sandbox's own Claude. Headless
// callers (the conductor via the API) set this to route a coding task INTO
// the sandbox; the backend persists it and auto-submits it on start (no
// browser needed). When set it OVERRIDES the generic project.start template.
// The corral-api skill documents this field — it must be honored here.
Prompt string `json:"prompt"`
// Source records a PR/issue this project was spawned from (back-link).
Source *config.ProjectSource `json:"source"`
// Repo-scoped DinD auto-cache controls. By default a repo-derived project
Expand Down Expand Up @@ -247,11 +253,38 @@ func (d *dashboardServer) handleCreateProject(w http.ResponseWriter, r *http.Req
// project-start prompt so the UI can auto-submit it — this is how the editable
// project.start prompt (with SSH guidance when a key is loaded) reaches a plain
// project. Issue-seeded projects use issuePrompt instead.
// A caller-supplied prompt (headless conductor routing a task into the sandbox)
// wins over the generic project.start template. Otherwise, for a plain new/clone
// project (not issue-seeded, not "existing"), build the editable project.start
// prompt so the UI can auto-submit it. Issue-seeded projects use issuePrompt.
var startPrompt string
if issuePrompt == "" && (body.Mode == "new" || body.Mode == "clone") && len(body.Repos) >= 1 {
if strings.TrimSpace(body.Prompt) != "" {
startPrompt = body.Prompt
} else if issuePrompt == "" && (body.Mode == "new" || body.Mode == "clone") && len(body.Repos) >= 1 {
startPrompt = d.buildStartPrompt(body.Repos[0])
}

// Persist the first-turn prompt so it can be delivered on START. The browser
// types it via POST /populate-prompt right after create, but a HEADLESSLY
// created project (the conductor via the API — no browser) relies on the
// backend delivering it on start (handleStartProject). Delivery needs the
// container to run with tmux, so force LaunchTmux whenever a prompt is pending
// — otherwise the launcher starts a bare interactive Claude with no pane to
// send-keys into, and the task never arrives (the conv-160 idle-forever bug).
// The issue-seeded flow already writes ISSUE.md; we still pending-deliver its
// prompt so a headless start kicks Claude off, same as the browser would.
if pending := issuePrompt; pending != "" || startPrompt != "" {
if startPrompt != "" {
pending = startPrompt // caller prompt / project.start beats issuePrompt when both exist
}
projectDir := config.ProjectDirFor(workspace)
if cfg, err := config.ReadConfig(projectDir); err == nil {
cfg.PendingPrompt = pending
cfg.LaunchTmux = true
_ = config.WriteConfig(projectDir, cfg)
}
}

writeFilesJSON(w, map[string]any{
"id": ProjectID(workspace), "workspace": workspace,
"issue_prompt": issuePrompt, // "" unless spawned from an issue
Expand Down
108 changes: 78 additions & 30 deletions internal/dashboard/projects_start.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"time"

"github.com/scoutapp/corral/internal/applog"
"github.com/scoutapp/corral/internal/config"
"github.com/scoutapp/corral/internal/dindcache"
"github.com/scoutapp/corral/internal/session"
sshagent "github.com/scoutapp/corral/internal/ssh"
Expand Down Expand Up @@ -113,6 +114,15 @@ func (d *dashboardServer) handleStartProject(w http.ResponseWriter, r *http.Requ
// Detach: we don't wait. The dashboard's status poll will show it come up.
go func() { _ = cmd.Wait() }()

// Auto-deliver the first-turn task prompt if one is pending. This is what makes
// a HEADLESSLY-created project (the conductor via the API — no browser to call
// POST /populate-prompt) actually start working instead of sitting at an idle
// Claude TUI (the conv-160 bug). claimPendingPrompt atomically reads-and-clears
// so delivery is fire-once even against the browser's concurrent /populate-prompt.
if prompt := d.claimPendingPrompt(workspace); prompt != "" {
go deliverPromptToClaude(workspace, prompt, true /* submit */)
}

// Record project.start as a span so the project.start hooks it fires nest
// under it in the trace. The launch itself is detached (we don't wait), so the
// span times just the synchronous start + hook dispatch.
Expand Down Expand Up @@ -218,6 +228,24 @@ func claudeReady(pane string) bool {
strings.Contains(pane, "shift+tab to cycle")
}

// claimPendingPrompt atomically reads-and-clears a project's PendingPrompt,
// returning what it claimed (or "" if nothing was pending / read failed). The
// lock makes first-turn delivery fire EXACTLY once across the browser's
// concurrent /start + /populate-prompt calls (see promptClaimMu).
func (d *dashboardServer) claimPendingPrompt(workspace string) string {
d.promptClaimMu.Lock()
defer d.promptClaimMu.Unlock()
projectDir := config.ProjectDirFor(workspace)
cfg, err := config.ReadConfig(projectDir)
if err != nil || strings.TrimSpace(cfg.PendingPrompt) == "" {
return ""
}
prompt := cfg.PendingPrompt
cfg.PendingPrompt = ""
_ = config.WriteConfig(projectDir, cfg)
return prompt
}

// handlePopulatePrompt types a prompt INTO the project's Claude input once the
// container's dev session is up. By default it does NOT submit (send-keys, no
// Enter) so the user reviews the pre-typed prompt and presses Enter themselves;
Expand Down Expand Up @@ -247,37 +275,57 @@ func (d *dashboardServer) handlePopulatePrompt(w http.ResponseWriter, r *http.Re
return
}

// The browser is explicitly driving delivery — CLAIM the pending prompt so the
// auto-deliver-on-start path (handleStartProject) won't ALSO type it (the
// browser calls both /start and /populate-prompt; without this the task lands
// twice). We ignore the claimed value here and use the request's body.Prompt
// (identical for the create flow, but the browser may override it, and honors
// submit:false which auto-delivery can't).
d.claimPendingPrompt(workspace)

go deliverPromptToClaude(workspace, body.Prompt, body.Submit)

writeFilesJSON(w, map[string]any{"ok": true, "message": "prompt will be typed into Claude once its input is ready"})
}

// deliverPromptToClaude types a prompt into the project's tmux-hosted Claude TUI
// once its input is ready, optionally submitting it. It BLOCKS (poll loop up to
// ~5 min) — callers run it in a goroutine.
//
// This is the one delivery path shared by the browser (POST /populate-prompt)
// and the backend auto-delivery on start (handleStartProject), so a project
// created HEADLESSLY — e.g. by the conductor via the API, with no browser to
// drive populate-prompt — still receives its task. It REQUIRES the container to
// run with tmux (LAUNCH_TMUX=1): the pane is what we capture/send-keys against.
func deliverPromptToClaude(workspace, prompt string, submit bool) {
if strings.TrimSpace(prompt) == "" {
return
}
sess := session.TmuxSessionNameForWorkspace(workspace)
prompt := body.Prompt
submit := body.Submit
go func() {
// The session existing is NOT enough: it's created by `docker run` in tmux,
// but the container then boots (proxy, dockerd, launcher) for a while before
// Claude's TUI actually appears and can accept input. Typing during that
// window is lost. So poll the PANE CONTENT until Claude's input prompt is
// drawn (its `❯` prompt / "bypass permissions" footer), then type.
//
// Up to ~5 min (cold boot + image build can be slow). Once ready, a short
// settle, then send-keys WITHOUT Enter so the prompt sits unsubmitted.
for i := 0; i < 300; i++ {
time.Sleep(1 * time.Second)
if !session.TmuxSessionExists(sess) {
continue
}
out, _ := exec.Command("tmux", "capture-pane", "-t", sess, "-p").Output()
if claudeReady(string(out)) {
time.Sleep(1500 * time.Millisecond) // let the input line settle
_ = exec.Command("tmux", "send-keys", "-t", sess, "--", prompt).Run()
if submit {
// Enter must be a SEPARATE send-keys after a short beat, or the
// TUI can swallow it before the prompt text registers.
time.Sleep(400 * time.Millisecond)
_ = exec.Command("tmux", "send-keys", "-t", sess, "Enter").Run()
}
return
// The session existing is NOT enough: it's created by `docker run` in tmux,
// but the container then boots (proxy, dockerd, launcher) for a while before
// Claude's TUI actually appears and can accept input. Typing during that
// window is lost. So poll the PANE CONTENT until Claude's input prompt is
// drawn (its `❯` prompt / "bypass permissions" footer), then type.
//
// Up to ~5 min (cold boot + image build can be slow). Once ready, a short
// settle, then send-keys.
for i := 0; i < 300; i++ {
time.Sleep(1 * time.Second)
if !session.TmuxSessionExists(sess) {
continue
}
out, _ := exec.Command("tmux", "capture-pane", "-t", sess, "-p").Output()
if claudeReady(string(out)) {
time.Sleep(1500 * time.Millisecond) // let the input line settle
_ = exec.Command("tmux", "send-keys", "-t", sess, "--", prompt).Run()
if submit {
// Enter must be a SEPARATE send-keys after a short beat, or the
// TUI can swallow it before the prompt text registers.
time.Sleep(400 * time.Millisecond)
_ = exec.Command("tmux", "send-keys", "-t", sess, "Enter").Run()
}
return
}
}()

writeFilesJSON(w, map[string]any{"ok": true, "message": "prompt will be typed into Claude once its input is ready"})
}
}
Loading