diff --git a/.gaai/core/adapters/claude-code/__tests__/nested-claude-spawn.test.js b/.gaai/core/adapters/claude-code/__tests__/nested-claude-spawn.test.js index 9d014665..accac001 100644 --- a/.gaai/core/adapters/claude-code/__tests__/nested-claude-spawn.test.js +++ b/.gaai/core/adapters/claude-code/__tests__/nested-claude-spawn.test.js @@ -1189,3 +1189,76 @@ describe('nested-claude-spawn — hard ceiling', () => { function _isAliveSelfCheck() { try { process.kill(process.pid, 0); return true; } catch { return false; } } + +// --------------------------------------------------------------------------- +// Handle identity +// +// A handle that cannot name its own story forces consumers to infer identity +// from the worktree path. These pin the recorded fields, and pin the one thing +// that must never appear in them. +// --------------------------------------------------------------------------- + +describe('nested-claude-spawn — handle identity', () => { + + afterEach(() => { + _resetSpawnFn(); + _resetLogPath(); + clearEnv(); + delete process.env.GAAI_STORY_ID; + delete process.env.GAAI_WORKSPACE_ID; + }); + + test('the in-flight handle names its story, workspace, phase and command', async () => { + setValidEnv(); + const handleDir = useTmpHandleDir(); + process.env.GAAI_STORY_ID = 'EXAMPLE-STORY'; + process.env.GAAI_WORKSPACE_ID = 'ws-example'; + + _setSpawnFn(() => createMockChild({ exitCode: 0, stdoutData: '## Implementation\n', delay: 400 })); + + // Deliberately not awaited: the record is written synchronously at spawn, and + // it is removed once the run concludes — mid-flight is the only time to read it. + const pending = _spawnWithTimerOverride( + 'a-prompt-that-must-not-be-recorded', '', [], + { globalTimeoutMs: 60_000, heartbeatTimeoutMs: 60_000 } + ); + + const files = readdirSync(handleDir).filter(f => f.endsWith('.json')); + assert.equal(files.length, 1, 'exactly one in-flight handle'); + const raw = readFileSync(join(handleDir, files[0]), 'utf8'); + const rec = JSON.parse(raw); + + assert.equal(rec.story_id, 'EXAMPLE-STORY'); + assert.equal(rec.workspace_id, 'ws-example'); + assert.equal(rec.phase, 'impl'); + assert.equal(typeof rec.command?.bin, 'string'); + assert.ok(rec.started_at, 'started_at recorded'); + assert.ok(rec.last_activity_at, 'last_activity_at recorded'); + + // The argv carries the prompt. A handle file is not a place for prompt content. + assert.equal(raw.includes('a-prompt-that-must-not-be-recorded'), false, + 'the prompt must never be persisted into the handle'); + + await pending; + assert.deepEqual(readdirSync(handleDir).filter(f => f.endsWith('.json')), [], + 'the handle is cleared once the run concludes'); + }); + + test('reconcile reports the story so callers need not parse a path', () => { + const handleDir = useTmpHandleDir(); + writeFileSync(join(handleDir, 'h.json'), JSON.stringify({ + trace_id: 'h', + pid: process.pid, + story_id: 'EXAMPLE-STORY', + cwd: '/tmp/some-unrelated-path', + started_at: new Date().toISOString(), + state: 'running', + }), 'utf8'); + + const report = reconcileHandles(); + assert.equal(report.live.length, 1); + assert.equal(report.live[0].story_id, 'EXAMPLE-STORY', + 'story identity survives reconciliation without path inference'); + }); + +}); diff --git a/.gaai/core/adapters/claude-code/nested-claude-spawn.js b/.gaai/core/adapters/claude-code/nested-claude-spawn.js index df9bd7d9..af0f9ca2 100644 --- a/.gaai/core/adapters/claude-code/nested-claude-spawn.js +++ b/.gaai/core/adapters/claude-code/nested-claude-spawn.js @@ -40,7 +40,7 @@ import { existsSync, appendFileSync, mkdirSync, writeFileSync, readFileSync, readdirSync, unlinkSync, } from 'node:fs'; -import { dirname, join } from 'node:path'; +import { basename, dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { randomUUID } from 'node:crypto'; import { logPhase, formatPhaseStdout } from './runtime-routing-logger.js'; @@ -78,6 +78,7 @@ const MAX_TURNS = 150; // reported successful long runs as failures, discarded the session_id that would // have allowed correlation, and truncated the output to whatever had arrived. const POLL_INTERVAL_MS = 30_000; // handle probe cadence while in poll mode +const ACTIVITY_FLUSH_MS = 10_000; // how often last_activity_at is persisted /** * Poll budget once an observation window expires. Defaults to one extra heartbeat @@ -720,12 +721,22 @@ export function reconcileHandles({ reap = false } = {}) { */ function spawnCore(prompt, implReportPath, extraArgs, globalTimeoutMs, heartbeatTimeoutMs, logFile, envFn = buildChildEnv, model = 'opus', includeFallbackModel = true, collectTelemetry = false, - cwd = '') { + cwd = '', context = {}) { const traceId = randomUUID(); const startMs = Date.now(); const modelReq = process.env.GAAI_IMPL_MODEL || ''; const baseUrl = process.env.GAAI_IMPL_BASE_URL || ''; + // What this run belongs to. A handle that cannot name its own story forces + // consumers to infer identity from the worktree path, which is a string-shape + // guess rather than a fact. Explicit context wins; the daemon exports the same + // values into the child env, which covers callers that pass nothing. + const identity = { + story_id: context.storyId || process.env.GAAI_STORY_ID || null, + workspace_id: context.workspaceId || process.env.GAAI_WORKSPACE_ID || null, + phase: context.phase || 'impl', + }; + // Log spawn start — never log token values console.log(`[nested-claude-spawn] spawn trace_id=${traceId} model=${modelReq} url=${baseUrl}`); @@ -785,14 +796,24 @@ function spawnCore(prompt, implReportPath, extraArgs, globalTimeoutMs, heartbeat const streamState = { sessionId: null, terminalReceipt: null }; const pollBudgetMs = _pollBudgetMs(heartbeatTimeoutMs); let scanBuffer = ''; + let lastActivityAt = startMs; + let activityFlushAt = 0; + + // Command identity, deliberately narrow: the resolved binary and the model. + // The argv is never recorded — it carries the prompt, and a handle file is + // not a place to put prompt content. + const command = { bin: basename(claudePath), model }; // Handle record — written before any window can expire, so a run is never // unrecoverable, and refreshed as soon as the session_id is announced. _writeHandle(traceId, { + ...identity, + command, pid: child.pid ?? null, session_id: null, state: 'running', started_at: new Date(startMs).toISOString(), + last_activity_at: new Date(startMs).toISOString(), log_file: logFile || null, report_path: implReportPath || null, cwd: cwd || null, @@ -800,6 +821,9 @@ function spawnCore(prompt, implReportPath, extraArgs, globalTimeoutMs, heartbeat function refreshHandle(state, extra = {}) { _writeHandle(traceId, { + ...identity, + command, + last_activity_at: new Date(lastActivityAt).toISOString(), pid: child.pid ?? null, session_id: streamState.sessionId, state, @@ -926,6 +950,15 @@ function spawnCore(prompt, implReportPath, extraArgs, globalTimeoutMs, heartbeat scanBuffer = scanBuffer.slice(lastNewline + 1); if (!hadSession && streamState.sessionId) refreshHandle(inPollMode ? 'polling' : 'running'); } + // Liveness as an observed fact, not an inference from the file's mtime. + // Flushed on an interval rather than per chunk: a chatty run emits far too + // often to justify a write each time, and consumers only need the field + // accurate to the order of the windows they compare it against. + lastActivityAt = Date.now(); + if (lastActivityAt - activityFlushAt >= ACTIVITY_FLUSH_MS) { + activityFlushAt = lastActivityAt; + refreshHandle(inPollMode ? 'polling' : 'running'); + } exitPollMode(); resetHeartbeat(); }); @@ -1183,7 +1216,7 @@ export async function runImpl({ implModelTag, prompt, reportPath, storyId, extra prompt, reportPath, extraArgs, GLOBAL_TIMEOUT_MS, HEARTBEAT_TIMEOUT_MS, logFile, buildChildEnv, 'opus', /* includeFallbackModel */ true, /* collectTelemetry */ true, - worktreePath + worktreePath, { storyId } ); const logFailed = _emitLog({ @@ -1209,7 +1242,7 @@ export async function runImpl({ implModelTag, prompt, reportPath, storyId, extra prompt, reportPath, extraArgs, GLOBAL_TIMEOUT_MS, HEARTBEAT_TIMEOUT_MS, logFile, buildPrimaryChildEnv, 'sonnet', /* includeFallbackModel */ false, - /* collectTelemetry */ false, worktreePath + /* collectTelemetry */ false, worktreePath, { storyId } ); const primaryLogFailed = _emitLog({ @@ -1242,7 +1275,7 @@ export async function runImpl({ implModelTag, prompt, reportPath, storyId, extra prompt, reportPath, extraArgs, GLOBAL_TIMEOUT_MS, HEARTBEAT_TIMEOUT_MS, logFile, buildPrimaryChildEnv, 'sonnet', /* includeFallbackModel */ false, - /* collectTelemetry */ false, worktreePath + /* collectTelemetry */ false, worktreePath, { storyId } ); const logFailed = _emitLog({ diff --git a/.gaai/core/scripts/delivery-daemon.sh b/.gaai/core/scripts/delivery-daemon.sh index 20ea76dd..85dc0540 100644 --- a/.gaai/core/scripts/delivery-daemon.sh +++ b/.gaai/core/scripts/delivery-daemon.sh @@ -2103,6 +2103,11 @@ _recovery_relaunch() { # running; a live handle rooted in this story's worktree means there is nothing # to relaunch yet. Best-effort: any failure here falls through to the relaunch, # preserving prior behaviour rather than stalling recovery. + # + # Matching is on the handle's own story_id. The worktree-path substring below is + # only a fallback for handles written before that field existed — inferring a + # story from a path shape is a guess, and it is wrong for any layout that does + # not embed the id. local _handles_json _live_here="" _handles_json=$(node "$PROJECT_DIR/.gaai/core/adapters/claude-code/nested-claude-spawn.js" \ --reconcile-handles 2>/dev/null || true) @@ -2113,8 +2118,13 @@ try: live = json.load(sys.stdin).get("live", []) except Exception: sys.exit(0) -needle = sys.argv[1] + "-workspace" -print("1" if any(needle in (h.get("cwd") or "") for h in live) else "") +sid = sys.argv[1] +legacy = sid + "-workspace" +def matches(h): + if h.get("story_id"): + return h["story_id"] == sid + return legacy in (h.get("cwd") or "") +print("1" if any(matches(h) for h in live) else "") ' "$sid" 2>/dev/null || true) fi if [[ "$_live_here" == "1" ]]; then diff --git a/CHANGELOG.md b/CHANGELOG.md index 946f0080..3ba149c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Changed +- fix: record run identity on the handle instead of inferring it - fix: keep and poll the handle until a terminal receipt - fix: make generated execution plans carry artefact frontmatter -: Make hosted CI the sole daemon merge authority