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
69 changes: 67 additions & 2 deletions .sandcastle/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string, string | undefined>): '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<string, string> = {};
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);
Expand Down Expand Up @@ -477,6 +536,13 @@ export function startSmeeListener(
}

async function main(): Promise<void> {
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();
Expand Down Expand Up @@ -513,7 +579,6 @@ async function main(): Promise<void> {
// GitHub X-GitHub-Delivery ids seen via smee — coarse delivery-level de-dupe.
const seenDeliveries = new Set<string>();

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.
Expand Down
21 changes: 20 additions & 1 deletion .sandcastle/reduce.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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");
});
26 changes: 26 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<mode>-<timestamp>.log` and saves
its PID in `.sandcastle/<mode>.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

```
Expand Down
Loading