From db3ab0bd4e05b9051295ecb8522a23d19fd5971d Mon Sep 17 00:00:00 2001 From: Luca Giordano Date: Sun, 28 Jun 2026 10:36:51 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20cockpit=20mode=20=E2=80=94=20detached?= =?UTF-8?q?=20/afk=20+=20/hitl=20with=20run-mode=20resolver=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a pure TypeScript `resolveRunMode(env)` function that returns 'detached' when AGENTIC_IN_CONTAINER is set (cockpit) and 'foreground' otherwise. The launcher (run.sh) stays thin — it always execs tsx main.ts and the TypeScript entrypoint owns the decision. When cockpit mode is detected, `main()` calls `detachAndRelaunch()` which: - spawns the orchestrator as a detached background process (detached: true, stdio piped to .sandcastle/logs/-.log) - strips AGENTIC_IN_CONTAINER from the child's env so it resolves 'foreground' and runs the orchestrator loop normally - writes the PID to .sandcastle/.pid - prints tail/stop instructions and returns, freeing the cockpit session Host-driven /afk and /hitl are unaffected (resolveRunMode → 'foreground'). Docs: CLAUDE.md's cockpit section now covers kick off, monitor, and stop. Unit tests cover all four resolveRunMode cases (detached / foreground / empty-string / any-truthy-value). --- .sandcastle/main.ts | 69 ++++++++++++++++++++++++++++++++++++-- .sandcastle/reduce.test.ts | 21 +++++++++++- CLAUDE.md | 26 ++++++++++++++ 3 files changed, 113 insertions(+), 3 deletions(-) diff --git a/.sandcastle/main.ts b/.sandcastle/main.ts index d7ec499..dc2d6bc 100644 --- a/.sandcastle/main.ts +++ b/.sandcastle/main.ts @@ -25,9 +25,11 @@ import { createHmac } from "node:crypto"; import * as https from "node:https"; import * as http from "node:http"; -import { execFile } from "node:child_process"; +import { execFile, spawn } from "node:child_process"; +import { mkdirSync, openSync, closeSync, writeFileSync } from "node:fs"; import { promisify } from "node:util"; import { fileURLToPath } from "node:url"; +import { join, dirname } from "node:path"; import { reduce, READY_LABEL, type State, type Pr, type Policy, type Mode } from "./reduce.ts"; import { IssueSource } from "./issue-source.ts"; import { SandboxRunner, SANDBOX_LABEL, PROJECT_LABEL_KEY, deriveProject } from "./sandbox-runner.ts"; @@ -93,6 +95,63 @@ export function resolveCredentials( }; } +/** + * Resolve how the orchestrator process should be launched. Pure function — no + * I/O. Returns 'detached' when the cockpit marker is present (AGENTIC_IN_CONTAINER + * is set to a non-empty value, baked into the devcontainer image by ADR-0018), + * 'foreground' otherwise (host-driven mode, /exec runs the orchestrator directly). + * + * The launcher (run.sh) always execs `tsx main.ts`; this function is the sole + * decision point so detachment logic lives in TypeScript, not in the shell. + */ +export function resolveRunMode(env: Record): 'detached' | 'foreground' { + return env['AGENTIC_IN_CONTAINER'] ? 'detached' : 'foreground'; +} + +/** + * Relaunch the orchestrator as a detached background process inside the + * container. Called when resolveRunMode() returns 'detached'. Spawns a new + * tsx process with AGENTIC_IN_CONTAINER removed (so the child resolves + * 'foreground' and runs the orchestrator loop normally), redirects stdio to a + * timestamped log file under .sandcastle/logs/, saves the PID, prints + * monitoring hints, and returns — the caller exits immediately after. + */ +function detachAndRelaunch(mode: string): void { + const cwd = process.cwd(); + const logsDir = join(cwd, '.sandcastle', 'logs'); + mkdirSync(logsDir, { recursive: true }); + + const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); + const logPath = join(logsDir, `${mode}-${ts}.log`); + const pidPath = join(cwd, '.sandcastle', `${mode}.pid`); + + const logFd = openSync(logPath, 'w'); + + const tsxBin = join(dirname(fileURLToPath(import.meta.url)), 'node_modules', '.bin', 'tsx'); + const mainTs = fileURLToPath(import.meta.url); + + // Strip the cockpit marker so the child resolves 'foreground' and runs the + // orchestrator loop directly without triggering another detach. + const childEnv: Record = {}; + for (const [k, v] of Object.entries(process.env)) { + if (v !== undefined && k !== 'AGENTIC_IN_CONTAINER') childEnv[k] = v; + } + + const child = spawn(tsxBin, [mainTs], { + detached: true, + stdio: ['ignore', logFd, logFd], + env: childEnv, + }); + child.unref(); + closeSync(logFd); + + writeFileSync(pidPath, String(child.pid)); + console.log(`[cockpit] ${mode} started in background (PID ${child.pid})`); + console.log(`[cockpit] log: ${logPath}`); + console.log(`[cockpit] tail: tail -f ${logPath}`); + console.log(`[cockpit] stop: kill $(cat ${pidPath}) && rm ${pidPath}`); +} + /** Read the concurrency cap from AGENTIC_CONCURRENCY (default 1, serial). */ export function parseConcurrency(): number { return Math.max(1, Number(process.env.AGENTIC_CONCURRENCY ?? "1") || 1); @@ -477,6 +536,13 @@ export function startSmeeListener( } async function main(): Promise { + const mode = (process.env.AGENTIC_MODE ?? "afk") as Mode; + + if (resolveRunMode(process.env) === 'detached') { + detachAndRelaunch(mode); + return; + } + const repo = process.env.AGENTIC_REPO; const base = process.env.AGENTIC_BASE_BRANCH ?? "main"; const repoRoot = process.cwd(); @@ -513,7 +579,6 @@ async function main(): Promise { // GitHub X-GitHub-Delivery ids seen via smee — coarse delivery-level de-dupe. const seenDeliveries = new Set(); - const mode = (process.env.AGENTIC_MODE ?? "afk") as Mode; const policy: Policy = { concurrency: parseConcurrency(), mode }; // Dispatch a PrMerged event and execute the resulting Relabel actions. diff --git a/.sandcastle/reduce.test.ts b/.sandcastle/reduce.test.ts index 85c4f14..5fc9935 100644 --- a/.sandcastle/reduce.test.ts +++ b/.sandcastle/reduce.test.ts @@ -10,7 +10,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { reduce, READY_LABEL, type State, type CiStatus, type Pr } from "./reduce.ts"; import { parseBlockedBy } from "./issue-source.ts"; -import { sweepOrphanedSandboxes, ensureSandboxNetwork, parseConcurrency, withRetry, resetAgentBranch, refreshBase, validateSignature, classifyDelivery, parseSmeeEvent, parseOrchEnv, resolveCredentials } from "./main.ts"; +import { sweepOrphanedSandboxes, ensureSandboxNetwork, parseConcurrency, withRetry, resetAgentBranch, refreshBase, validateSignature, classifyDelivery, parseSmeeEvent, parseOrchEnv, resolveCredentials, resolveRunMode } from "./main.ts"; import { createHmac } from "node:crypto"; import { SANDBOX_LABEL, PROJECT_LABEL_KEY, deriveProject } from "./sandbox-runner.ts"; @@ -914,3 +914,22 @@ test("resolveCredentials: resolves all four credential keys independently", () = assert.equal(creds.GITHUB_TOKEN, "ght-orch"); assert.equal(creds.CLAUDE_CODE_OAUTH_TOKEN, "cco-orch"); }); + +// ─── resolveRunMode ─────────────────────────────────────────────────────────── + +test("resolveRunMode: AGENTIC_IN_CONTAINER set → detached", () => { + assert.equal(resolveRunMode({ AGENTIC_IN_CONTAINER: "1" }), "detached"); +}); + +test("resolveRunMode: AGENTIC_IN_CONTAINER absent → foreground", () => { + assert.equal(resolveRunMode({}), "foreground"); +}); + +test("resolveRunMode: AGENTIC_IN_CONTAINER empty string → foreground", () => { + assert.equal(resolveRunMode({ AGENTIC_IN_CONTAINER: "" }), "foreground"); +}); + +test("resolveRunMode: any truthy value for AGENTIC_IN_CONTAINER → detached", () => { + assert.equal(resolveRunMode({ AGENTIC_IN_CONTAINER: "true" }), "detached"); + assert.equal(resolveRunMode({ AGENTIC_IN_CONTAINER: "yes" }), "detached"); +}); diff --git a/CLAUDE.md b/CLAUDE.md index 0953b04..e857472 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,6 +46,32 @@ docker compose -f .devcontainer/docker-compose.yml exec -it devcontainer cockpit The workflow slash commands (`/grill-me-with-docs`, `/to-prd`, `/to-issues`, `/afk`, `/hitl`) are baked into the published image and available immediately (ADR-0017/0018). +### Autonomous cockpit: kick off, monitor, stop + +Inside cockpit, `/afk` (and `/hitl`) **detach automatically** — the orchestrator +starts as a background job, freeing the interactive Claude session immediately: + +```sh +/afk # kicks off, prints PID + log path, returns to prompt +``` + +The orchestrator writes to `.sandcastle/logs/-.log` and saves +its PID in `.sandcastle/.pid`. To monitor or stop it: + +```sh +# Tail the live log (Ctrl-C to stop tailing; orchestrator keeps running) +tail -f .sandcastle/logs/afk-*.log + +# Check the PID +cat .sandcastle/afk.pid + +# Stop the orchestrator +kill $(cat .sandcastle/afk.pid) && rm .sandcastle/afk.pid +``` + +Host-driven `/afk`/`/hitl` (via `/exec`) are unaffected — they run in the +foreground as before. + ## Development workflow ```