diff --git a/host/skills/corral-api/SKILL.md b/host/skills/corral-api/SKILL.md index 59f11e3..8cf729b 100644 --- a/host/skills/corral-api/SKILL.md +++ b/host/skills/corral-api/SKILL.md @@ -95,9 +95,16 @@ corral api POST /projects/create -d '{ "branch": "", # optional; defaults to the repo default branch "prompt": "" }' -# → { "id": "", ... } — a sandbox is now running Claude on the task +# → { "id": "", ... } +corral api POST /p//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//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 diff --git a/internal/config/config.go b/internal/config/config.go index 550b727..b933897 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 diff --git a/internal/dashboard/dashboard.go b/internal/dashboard/dashboard.go index 7d0c2cd..8f7d305 100644 --- a/internal/dashboard/dashboard.go +++ b/internal/dashboard/dashboard.go @@ -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 diff --git a/internal/dashboard/pending_prompt_test.go b/internal/dashboard/pending_prompt_test.go new file mode 100644 index 0000000..6a0fa35 --- /dev/null +++ b/internal/dashboard/pending_prompt_test.go @@ -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 /.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) + } +} diff --git a/internal/dashboard/projects_create.go b/internal/dashboard/projects_create.go index 7fbbacb..c909272 100644 --- a/internal/dashboard/projects_create.go +++ b/internal/dashboard/projects_create.go @@ -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 @@ -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 diff --git a/internal/dashboard/projects_start.go b/internal/dashboard/projects_start.go index dd421ed..bef0343 100644 --- a/internal/dashboard/projects_start.go +++ b/internal/dashboard/projects_start.go @@ -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" @@ -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. @@ -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; @@ -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"}) + } }