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
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});

});
43 changes: 38 additions & 5 deletions .gaai/core/adapters/claude-code/nested-claude-spawn.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}`);

Expand Down Expand Up @@ -785,21 +796,34 @@ 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,
});

function refreshHandle(state, extra = {}) {
_writeHandle(traceId, {
...identity,
command,
last_activity_at: new Date(lastActivityAt).toISOString(),
pid: child.pid ?? null,
session_id: streamState.sessionId,
state,
Expand Down Expand Up @@ -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();
});
Expand Down Expand Up @@ -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({
Expand All @@ -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({
Expand Down Expand Up @@ -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({
Expand Down
14 changes: 12 additions & 2 deletions .gaai/core/scripts/delivery-daemon.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading