@deksden-com/dd-flow-cli 0.9.0-beta.97 → 0.9.0-beta.99
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/build-info.json +3 -3
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +17 -6
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +6 -5
- package/dist/harness-runtime/lib/delegation-instructions.mjs +2 -2
- package/dist/services/controller-fanout.js +3 -1
- package/dist/services/eval-snapshots.js +114 -32
- package/dist/services/run-controller-capture.js +6 -4
- package/dist/services/vnext-fanout.js +5 -1
- package/dist/services/work-registry.js +2 -0
- package/package.json +1 -1
package/dist/build-info.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"cli_package": "@deksden-com/dd-flow-cli",
|
|
3
|
-
"cli_version": "0.9.0-beta.
|
|
4
|
-
"cli_commit": "
|
|
5
|
-
"built_at": "2026-09-
|
|
3
|
+
"cli_version": "0.9.0-beta.99",
|
|
4
|
+
"cli_commit": "a68a8ac243ceef7806094964106ebe38c5d384fd",
|
|
5
|
+
"built_at": "2026-09-22T21:30:25.559Z",
|
|
6
6
|
"built_with_canon": {
|
|
7
7
|
"version": "4.1.1",
|
|
8
8
|
"commit": "678daa038287c948ada5b2d785a6dcc925c7b891",
|
|
@@ -276,7 +276,7 @@ export class Runtime {
|
|
|
276
276
|
// child can close while the asynchronous write is still pending.
|
|
277
277
|
if (!this.init && event.event === "result" && event.result?.status === "ERROR") this.startupError = new DaemonError("agy_provider_rejected", String(event.result.error ?? "Antigravity rejected the Session before initialization"), false, { provider_result: event.result });
|
|
278
278
|
this.observeProviderActivity(event); await this.journal("provider_event", { event }); if (event.event === "init") { this.init = { conversation_id: event.conversation_id, ...(event.init ?? {}) }; if (!this.init.conversation_id) throw new DaemonError("agy_init_missing", "Antigravity init has no conversation ID"); const requested = { provider: this.config.provider, model: this.config.model, reasoning: this.config.reasoning, mode: this.config.mode, permission_mode: "always-proceed" }; this.profile = assertProfile(requested, observedProfile(this.init, requested)); await observeModel({ journal: this.config.journal, harness: "antigravity-cli", sessionId: this.init.conversation_id, requested, observed: this.profile.observed, source: "native.init" }); await this.persist({ shutdown_state: "running", active_tree: false }); readyResolve(this.init); } else if (event.event === "step_update") this.observeStep(event.step_update ?? {}); else if (event.event === "result") { if (this.startupError) throw this.startupError; await this.finishTurn(event.result ?? {}); } } }
|
|
279
|
-
observeStep(step) { const sessionId = step.conversation_id ?? this.init?.conversation_id; const observation = this.sessionObservations.get(sessionId) ?? {}; if (Number.isInteger(step.step_index) && step.step_index > (observation.last_step_index ?? -1)) this.childActivity(sessionId); if (Number.isInteger(step.step_index)) this.sessionObservations.set(sessionId, { ...observation, last_step_index: Math.max(observation.last_step_index ?? -1, step.step_index) }); this.toolObservations.add(agyToolObservation(step, this.init?.conversation_id)); for (const child of step.subagent_info?.subagents ?? []) if (child.conversation_id) { const prior = this.descendants.get(child.conversation_id); this.descendants.set(child.conversation_id, { ...prior, provider_session_id: child.conversation_id, parent_provider_session_id:
|
|
279
|
+
observeStep(step) { const sessionId = step.conversation_id ?? this.init?.conversation_id; const observation = this.sessionObservations.get(sessionId) ?? {}; if (Number.isInteger(step.step_index) && step.step_index > (observation.last_step_index ?? -1)) this.childActivity(sessionId); if (Number.isInteger(step.step_index)) this.sessionObservations.set(sessionId, { ...observation, last_step_index: Math.max(observation.last_step_index ?? -1, step.step_index) }); this.toolObservations.add(agyToolObservation(step, this.init?.conversation_id)); for (const child of step.subagent_info?.subagents ?? []) if (child.conversation_id) { const prior = this.descendants.get(child.conversation_id); this.descendants.set(child.conversation_id, { ...prior, provider_session_id: child.conversation_id, parent_provider_session_id: sessionId ?? null, role: child.role ?? null, subagent_type: child.type_name ?? null, log_uri: child.log_uri ?? null, workspace_uris: child.workspace_uris ?? [], status: prior?.status ?? "unknown" }); } }
|
|
280
280
|
toolSnapshot() { const summary = this.toolObservations.summary(); const failures_by_name = {}; for (const event of this.toolObservations.calls.values()) if (event.status === "failed") failures_by_name[event.name] = (failures_by_name[event.name] ?? 0) + 1; return { ...summary, by_name: summary.by_tool, failures_by_name }; }
|
|
281
281
|
async finishTurn(result) {
|
|
282
282
|
if (result.status === "RUNNING") { await this.persist({ active_tree: true }); return; }
|
|
@@ -341,7 +341,7 @@ export class Runtime {
|
|
|
341
341
|
return { harness: "antigravity-cli", runtime_family: "antigravity", provider_session_id: root ?? null, adapter_session_id: root ?? null, cwd: this.config.cwd, profile: this.profile, result, ...(assistantText === null ? {} : { assistant_text: assistantText }), usage: usageSnapshot(result ?? {}, this.toolSnapshot()), usage_ingest: this.usageIngest, descendants: [...this.descendants.values()], transcript_path: observation?.transcript_path ?? null, settled: Boolean(!this.state.unclaimed_activity && (!this.active || terminalPending) && result && ["SUCCESS", "ERROR", "CANCELLED"].includes(result.status) && treeSettled && rootSettled) };
|
|
342
342
|
}
|
|
343
343
|
async zeroUsage() { await forwardUsage(this.config, this.init.conversation_id, { usage: {} }, this.toolSnapshot(), []); }
|
|
344
|
-
async prompt(text, assertDispatch = captureDispatchGuard(this)) {
|
|
344
|
+
async prompt(text, assertDispatch = captureDispatchGuard(this), operationId = null) {
|
|
345
345
|
if (!text) throw new DaemonError("prompt_required", "prompt is required");
|
|
346
346
|
await this.start(); await this.draining;
|
|
347
347
|
if (this.active) throw new DaemonError("operation_in_progress", "an Antigravity turn is already running");
|
|
@@ -350,14 +350,14 @@ export class Runtime {
|
|
|
350
350
|
const reply = new Promise((resolve, reject) => { resolveTurn = resolve; rejectTurn = reject; });
|
|
351
351
|
// Cancellation can reject while admission persistence is still pending.
|
|
352
352
|
void reply.catch(() => {});
|
|
353
|
-
const current = { resolve: resolveTurn, reject: rejectTurn, generation: ++this.turnGeneration, previousError: this.lastResult?.error ?? null };
|
|
353
|
+
const current = { resolve: resolveTurn, reject: rejectTurn, generation: ++this.turnGeneration, operationId, previousError: this.lastResult?.error ?? null };
|
|
354
354
|
// Reserve before persistence so different operation IDs cannot overlap.
|
|
355
355
|
this.active = current;
|
|
356
356
|
const root = this.init?.conversation_id, prior = this.sessionObservations.get(root) ?? {};
|
|
357
357
|
this.sessionObservations.set(root, { ...prior, step_floor: prior.last_step_index ?? null, stop: null, last_hook: null });
|
|
358
358
|
this.lastProviderEvent = null;
|
|
359
359
|
try {
|
|
360
|
-
await this.persist({ active_tree: true });
|
|
360
|
+
await this.persist({ active_tree: true, last_hook_rejection: null });
|
|
361
361
|
assertDispatch();
|
|
362
362
|
if (this.active !== current) throw new DaemonError("operation_cancelled", "prompt reservation was superseded");
|
|
363
363
|
this.child.stdin.write(`${JSON.stringify({ event: "user", message: { content: text } })}\n`, error => {
|
|
@@ -378,8 +378,16 @@ export class Runtime {
|
|
|
378
378
|
while (!this.descendants.has(conversationId) && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 10));
|
|
379
379
|
const child = this.descendants.get(conversationId);
|
|
380
380
|
if (!child) throw new DaemonError("agy_child_identity_unconfirmed", "Antigravity hook has no confirmed native parent", true, { conversation_id: conversationId });
|
|
381
|
+
if (child.parent_provider_session_id !== this.init?.conversation_id) throw new DaemonError("agy_child_parent_unqualified", "Nested Antigravity worker is not a direct child of the controlled Session", false, { conversation_id: conversationId, parent_conversation_id: child.parent_provider_session_id });
|
|
381
382
|
return child;
|
|
382
383
|
}
|
|
384
|
+
async rejectHook(error, event, payload) {
|
|
385
|
+
const current = this.active;
|
|
386
|
+
const rejection = { code: error.code ?? "agy_hook_rejected", message: error.message ?? String(error), event, conversation_id: payload.conversationId ?? null, tool: payload.toolCall?.name ?? null, daemon_id: this.config.daemonId, operation_id: current?.operationId ?? null, turn_generation: current?.generation ?? null };
|
|
387
|
+
try { await this.journal("hook_rejected", { rejection }); await this.persist({ last_hook_rejection: rejection }); }
|
|
388
|
+
catch (storageError) { if (current && this.active === current) { this.active = null; current.reject(storageError); } throw storageError; }
|
|
389
|
+
if (current && this.active === current) { this.active = null; current.reject(new DaemonError(rejection.code, rejection.message, false, rejection)); }
|
|
390
|
+
}
|
|
383
391
|
async observeHook(event, payload) { const conversationId = payload.conversationId ?? this.init?.conversation_id; const child = await this.childForHook(conversationId); const prior = this.sessionObservations.get(conversationId) ?? {}; const execution = Number.isInteger(payload.executionNum) ? payload.executionNum : null; const step = Number.isInteger(payload.stepIdx) ? payload.stepIdx : null; const floor = prior.step_floor; const stale = step !== null && ((Number.isInteger(floor) && step <= floor) || (child && Number.isInteger(prior.last_step_index) && step < prior.last_step_index)); if (stale) { await this.journal("stale_hook", { hook_event: event, conversation_id: conversationId, step_index: step }); return { stale: true }; } const fingerprint = createHash("sha256").update(JSON.stringify({ event, payload })).digest("hex"); if (!stale && prior.last_hook !== fingerprint) this.markActivity(); if (conversationId && !stale) this.sessionObservations.set(conversationId, { ...prior, ...(step !== null ? { last_step_index: Math.max(prior.last_step_index ?? -1, step) } : {}), last_hook: fingerprint, ...(event === "PreToolUse" ? { last_pre_hook: fingerprint } : {}), ...(payload.transcriptPath ? { transcript_path: payload.transcriptPath } : {}), ...(event === "Stop" ? { stop: { fullyIdle: payload.fullyIdle === true, terminationReason: payload.terminationReason ?? null, executionNum: payload.executionNum ?? null } } : {}) }); if (!stale && event === "PreToolUse" && prior.last_pre_hook !== fingerprint) this.childActivity(conversationId); if (child && !stale && event === "Stop" && payload.fullyIdle === true) this.descendants.set(conversationId, { ...this.descendants.get(conversationId), tree_settled: true, settlement_evidence: "child_fully_idle_stop" }); const terminalPending = this.active?.terminalResult === this.lastResult; if (!child && !stale && event === "Stop" && payload.fullyIdle === true && (!this.active || terminalPending) && ["SUCCESS", "ERROR", "CANCELLED"].includes(this.lastResult?.status)) for (const [id, descendant] of this.descendants) if (descendant.status === "unknown") this.descendants.set(id, { ...descendant, status: "settled_by_root", settlement_evidence: "root_fully_idle_stop" }); const parent = child?.parent_provider_session_id ?? null;
|
|
384
392
|
const observed = payload.model ? observedProfile(payload) : {};
|
|
385
393
|
await observeModel({ journal: this.config.journal, harness: "antigravity-cli", sessionId: conversationId, parentSessionId: parent, requested: this.config, observed, source: "native.hook", evidence: payload.model ? "configured" : "unavailable", reason: payload.model ? null : "hook_omits_current_model" });
|
|
@@ -427,11 +435,14 @@ async function dispatch(runtime, request) {
|
|
|
427
435
|
if (request.schema_id !== REQUEST_SCHEMA) throw new DaemonError("daemon_protocol_mismatch", "unsupported dd-agy request schema"); const params = request.params ?? {};
|
|
428
436
|
if (request.operation === "daemon.status") return { daemon_id: runtime.config.daemonId, shutdown_state: runtime.state.shutdown_state, active_tree: runtime.state.active_tree, last_activity_at: runtime.lastActivityAt, provider_ready: Boolean(runtime.init && !runtime.exited), pid: process.pid, provider_pid: runtime.child?.pid ?? null, versions: runtime.state.versions, config: runtime.config, sessions: runtime.state.sessions ?? [], receipt: runtime.receipt() };
|
|
429
437
|
if (request.operation === "session.create") { if (params.sessionId) throw new DaemonError("invalid_create", "create does not accept a Session ID"); if (params.prompt) throw new DaemonError("invalid_create", "create does not execute a prompt; use session.prompt"); await runtime.start(); await runtime.zeroUsage(); return runtime.receipt(); }
|
|
430
|
-
if (request.operation === "session.prompt") { const assertDispatch = captureDispatchGuard(runtime); await runtime.start(params.sessionId ?? null); runtime.requireSessionIdentity(params.sessionId); return await runtime.prompt(params.prompt, assertDispatch); }
|
|
438
|
+
if (request.operation === "session.prompt") { const assertDispatch = captureDispatchGuard(runtime); await runtime.start(params.sessionId ?? null); runtime.requireSessionIdentity(params.sessionId); return await runtime.prompt(params.prompt, assertDispatch, request.id); }
|
|
431
439
|
if (request.operation === "session.inspect") { runtime.requireSessionIdentity(params.sessionId); return runtime.receipt(); }
|
|
432
440
|
if (request.operation === "session.resume") { await runtime.start(params.sessionId ?? runtime.init?.conversation_id ?? null); runtime.requireSessionIdentity(params.sessionId); return runtime.receipt(); }
|
|
433
441
|
if (request.operation === "session.cancel") { runtime.requireSessionIdentity(params.sessionId); return await runtime.cancel(); }
|
|
434
|
-
if (request.operation === "hook.observe")
|
|
442
|
+
if (request.operation === "hook.observe") {
|
|
443
|
+
try { return await runtime.observeHook(params.event, params.payload ?? {}); }
|
|
444
|
+
catch (error) { await runtime.rejectHook(error, params.event, params.payload ?? {}); throw error; }
|
|
445
|
+
}
|
|
435
446
|
if (request.operation === "daemon.stop") { await runtime.close(params.cancelTree === true); await runtime.persist({ shutdown_state: "clean", active_tree: false }); await runtime.finishResource(); setImmediate(() => runtime.server?.close()); return { stopped: true, clean: true, settled: true, daemon_id: runtime.config.daemonId }; }
|
|
436
447
|
throw new DaemonError("unknown_operation", `unknown dd-agy daemon operation: ${request.operation}`);
|
|
437
448
|
}
|
|
@@ -163,19 +163,19 @@ export async function startDaemon(options) {
|
|
|
163
163
|
await prepareRuntimeOwner(config, paths.dir);
|
|
164
164
|
if (!config.model || !config.reasoning) throw new DaemonError("profile_required", "--model and --reasoning are required");
|
|
165
165
|
await mkdir(paths.dir, { recursive: true, mode: 0o700 }); await chmod(paths.dir, 0o700);
|
|
166
|
-
try { const status = await callDaemon(paths.dir, "daemon.
|
|
166
|
+
try { const status = await callDaemon(paths.dir, "daemon.ready", {}, 5000); if (!sameConfig(status, config)) throw new DaemonError("daemon_config_mismatch", "a daemon is already running with different configuration"); if (!status.ready) throw new DaemonError("daemon_start_in_progress", "an existing daemon is still starting", true); return { ...status, already_running: true }; } catch (error) { if (error.code !== "daemon_not_running") throw error; }
|
|
167
167
|
const previous = await readState(paths.dir); if (previous?.shutdown_state === "clean") await authorizeRetainedDaemonResume(paths.dir, previous, options.sessionId, config); if (previous?.shutdown_state === "running" && previous.active_tree) throw new DaemonError("invalid_harness_crash", "previous daemon died with an active or unproven Session tree", false, { daemon_id: previous.daemon_id });
|
|
168
168
|
await removeSocket(paths.socket, paths.dir); await mkdir(paths.home, { recursive: true, mode: 0o700 }); const importedSession = await materializeSessionArchive(paths, config.sessionArchive); const copiedAuth = await copyAuth(paths, authSource); await writeIsolatedConfig(paths); if (!noFlow) await writeHook(paths, config, absolute(options.entryPath, "dd-grok entry")); const versions = await doctor({ bin: config.bin }); if (importedSession && importedSession.grok_version !== versions.versions.grok) throw new DaemonError("session_archive_version_mismatch", "Grok Build session archive was created by a different CLI version", false, { archive_version: importedSession.grok_version, current_version: versions.versions.grok }); const configIsolation = await inspectIsolatedConfig(config);
|
|
169
169
|
const resourceProcess = await registerDaemonProcess(config, { kind: "grok-daemon", owner: `grok:${path.basename(paths.dir)}`, operation: `grok-daemon:${path.basename(paths.dir)}`, stdout: paths.log, stderr: paths.log });
|
|
170
170
|
const state = { schema_id: STATE_SCHEMA, daemon_id: randomUUID(), pid: null, socket: paths.socket, started_at: new Date().toISOString(), shutdown_state: "starting", active_tree: false, recovery_status: previous?.shutdown_state === "running" ? "recovered_idle" : "clean_start", auth_status: copiedAuth ? "copied" : "absent", ...(importedSession ? { imported_session: importedSession } : {}), versions: versions.versions, config_isolation: configIsolation, config, sessions: importedSession ? [{ provider_session_id: importedSession.provider_session_id, adapter_session_id: importedSession.provider_session_id, parent_provider_session_id: null, root_provider_session_id: importedSession.provider_session_id, cwd: importedSession.source_cwd }] : (previous?.sessions ?? []), resource_process: resourceProcess };
|
|
171
171
|
await writeState(paths.state, state); const log = await open(paths.log, "a", 0o600); const child = spawn(process.execPath, [absolute(options.entryPath, "dd-grok entry"), "daemon", "serve", "--state-dir", paths.dir], { cwd: config.cwd, env: isolatedEnv(config), detached: true, stdio: ["ignore", log.fd, log.fd] }); child.unref(); await log.close();
|
|
172
|
-
try { await confirmDaemonProcess(config, resourceProcess, child); const deadline = Date.now() +
|
|
172
|
+
try { await confirmDaemonProcess(config, resourceProcess, child); const deadline = Date.now() + 30_000; while (Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 100)); try { const status = await callDaemon(paths.dir, "daemon.ready", {}, 5000); if (status.ready) return status; } catch (error) { if (error.code !== "daemon_not_running" && error.code !== "daemon_timeout") throw error; } } throw new DaemonError("daemon_start_failed", "daemon did not become ready", true); }
|
|
173
173
|
catch (error) { await cleanupFailedStart(config, resourceProcess, child, error); throw error; }
|
|
174
174
|
}
|
|
175
175
|
export async function stopDaemon(options) { const timeoutMs = options.timeoutMs ?? 30_000; return await confirmDaemonStopped(() => callDaemon(options.stateDir, "daemon.stop", { cancelTree: options.cancelTree === true }, timeoutMs), locations(options.stateDir).socket, timeoutMs); }
|
|
176
176
|
|
|
177
177
|
export class Runtime {
|
|
178
|
-
constructor(paths, state, bridge, initialized) { this.paths = paths; this.state = state; this.bridge = bridge; this.initialized = initialized; this.sessions = new Map((state.sessions ?? []).map((item) => [item.provider_session_id, item])); this.descendants = new Map(); this.toolUsage = new Map(); this.loadedSessionId = null; this.active = null; this.server = null; this.persisting = Promise.resolve(); }
|
|
178
|
+
constructor(paths, state, bridge, initialized) { this.paths = paths; this.state = state; this.bridge = bridge; this.initialized = initialized; this.sessions = new Map((state.sessions ?? []).map((item) => [item.provider_session_id, item])); this.descendants = new Map(); this.toolUsage = new Map(); this.loadedSessionId = null; this.active = null; this.server = null; this.ready = false; this.persisting = Promise.resolve(); }
|
|
179
179
|
persist(patch = {}) { Object.assign(this.state, patch, { pid: process.pid, updated_at: new Date().toISOString(), sessions: [...this.sessions.values()] }); const snapshot = structuredClone(this.state); this.persisting = this.persisting.then(() => writeState(this.paths.state, snapshot)); if (snapshot.resource_process) this.persisting = this.persisting.then(() => heartbeatDaemonProcess(snapshot.config, snapshot.resource_process).catch(() => {})); return this.persisting; }
|
|
180
180
|
options(params) { const sessionId = params.sessionId; const session = sessionId ? this.sessions.get(sessionId) : null; const root = session?.root_provider_session_id ?? sessionId; return { ...this.state.config, ...params, cwd: params.cwd ?? session?.cwd ?? this.state.config.cwd, initialized: this.initialized, daemonId: this.state.daemon_id, rootProviderSessionId: root, toolUsage: this.toolUsage, allowBackground: true, liveSession: params.liveSession ?? Boolean(sessionId && sessionId === this.loadedSessionId) }; }
|
|
181
181
|
track(result) { if (!result?.provider_session_id) return; const session = this.sessions.get(result.provider_session_id); const root = result.parent_provider_session_id ? (this.sessions.get(result.parent_provider_session_id)?.root_provider_session_id ?? result.parent_provider_session_id) : (session?.root_provider_session_id ?? result.provider_session_id); this.sessions.set(result.provider_session_id, { native_root_receipt: session?.native_root_receipt ?? null, provider_session_id: result.provider_session_id, adapter_session_id: result.adapter_session_id ?? result.provider_session_id, parent_provider_session_id: result.parent_provider_session_id ?? session?.parent_provider_session_id ?? null, root_provider_session_id: root, cwd: result.cwd ?? result.target?.newCwd ?? result.info?.cwd ?? session?.cwd ?? null }); for (const child of this.running(result)) this.recordDescendant(child, root, "x.ai/subagent/list_running", "running"); for (const child of result.descendants ?? []) this.recordDescendant(child, root, child.source ?? "x.ai/subagent/event", child.status ?? "unknown"); }
|
|
@@ -261,7 +261,8 @@ export class Runtime {
|
|
|
261
261
|
}
|
|
262
262
|
async dispatch(operation, params) {
|
|
263
263
|
const assertDispatch = captureDispatchGuard(this);
|
|
264
|
-
if (operation === "daemon.
|
|
264
|
+
if (operation === "daemon.ready") return { ready: this.ready, daemon_id: this.state.daemon_id, pid: process.pid, socket: this.paths.socket, versions: this.state.versions, config_isolation: this.state.config_isolation, shutdown_state: this.state.shutdown_state, recovery_status: this.state.recovery_status, auth_status: this.state.auth_status, active_tree: this.state.active_tree, active_operation: this.active, sessions: [...this.sessions.values()], config: this.state.config };
|
|
265
|
+
if (operation === "daemon.status") { let observationError; if (!this.active) try { await this.refreshTree(); } catch (error) { observationError = errorPayload(error); } return { ...await this.dispatch("daemon.ready", {}), observation_error: observationError ?? null }; }
|
|
265
266
|
if (operation === "hook.resolve") { const sessionId = String(params.sessionId ?? ""); if (!sessionId) throw new DaemonError("hook_identity_missing", "hook has no sessionId"); let session = this.sessions.get(sessionId); if (!session) { const roots = [...this.sessions.values()].filter((item) => !item.parent_provider_session_id); if (roots.length !== 1) throw new DaemonError("hook_identity_unknown", "unknown hook Session has no unique root"); const observed = await inspectSessionWithBridge(this.bridge, this.options({ sessionId, liveSession: true })); this.track(observed); session = this.sessions.get(sessionId); if (!session || session.root_provider_session_id !== roots[0].provider_session_id) throw new DaemonError("hook_identity_unknown", "hook Session is not a member of the retained native tree"); await this.persist(); } return { daemonId: this.state.daemon_id, rootProviderSessionId: session.root_provider_session_id, parentProviderSessionId: session.parent_provider_session_id ?? null, observedProfile: this.bridge.modelProfiles?.get(sessionId) ?? {} }; }
|
|
266
267
|
if (operation === "session.create") return await this.productive(operation, async () => { const result = await createSessionWithBridge(this.bridge, { ...this.options(params), onSessionCreated: async (session) => { this.track(session); this.loadedSessionId = session.provider_session_id; await this.persist(); } }, this.initialized); this.loadedSessionId = result.provider_session_id; return result; });
|
|
267
268
|
if (operation === "session.prompt") { await this.requireSettled(); return await this.productive(operation, async () => { const result = await promptSessionWithBridge(this.bridge, { ...this.options(params), assertDispatch }); this.loadedSessionId = params.sessionId; return result; }, params.sessionId); }
|
|
@@ -294,6 +295,6 @@ export async function serveDaemon(stateDir) {
|
|
|
294
295
|
const rootProviderSessionId = session?.root_provider_session_id ?? sessionId;
|
|
295
296
|
await observeAcpToolCall(state.config, { daemonId: state.daemon_id, rootProviderSessionId, parentProviderSessionId: session?.parent_provider_session_id ?? null, observedProfile: bridge.modelProfiles?.get(sessionId) ?? {} }, message);
|
|
296
297
|
} }); const initialized = await bridge.start(); runtime = new Runtime(paths, state, bridge, initialized);
|
|
297
|
-
await removeSocket(paths.socket, paths.dir); const server = net.createServer((connection) => { connection.setEncoding("utf8"); let buffer = ""; connection.on("data", (chunk) => { buffer += chunk; const newline = buffer.indexOf("\n"); if (newline < 0) return; const line = buffer.slice(0, newline); buffer = buffer.slice(newline + 1); void (async () => { let request; try { request = JSON.parse(line); if (request.schema_id !== REQUEST_SCHEMA || !request.id || !request.operation) throw new DaemonError("daemon_protocol_mismatch", "invalid daemon request"); const result = await durableDaemonDispatch(paths.dir, request, () => runtime.dispatch(request.operation, request.params ?? {}), () => runtime.dispatch("session.inspect", request.params ?? {})); connection.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, id: request.id, ok: true, result: { ...result, _shutdown: undefined } })}\n`); if (result?._shutdown) setImmediate(() => void runtime.shutdown().then(() => process.exit(0)).catch(error => runtime.persist({ shutdown_state: "cleanup_failed", cleanup_error: errorPayload(error) }))); } catch (error) { connection.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, id: request?.id ?? null, ok: false, error: errorPayload(error) })}\n`); } })(); }); }); runtime.server = server; await new Promise((resolve, reject) => { server.once("error", reject); server.listen(paths.socket, resolve); }); await chmod(paths.socket, 0o600); await runtime.persist({ shutdown_state: "running", active_tree: false }); return await new Promise(() => {});
|
|
298
|
+
await removeSocket(paths.socket, paths.dir); const server = net.createServer((connection) => { connection.setEncoding("utf8"); let buffer = ""; connection.on("data", (chunk) => { buffer += chunk; const newline = buffer.indexOf("\n"); if (newline < 0) return; const line = buffer.slice(0, newline); buffer = buffer.slice(newline + 1); void (async () => { let request; try { request = JSON.parse(line); if (request.schema_id !== REQUEST_SCHEMA || !request.id || !request.operation) throw new DaemonError("daemon_protocol_mismatch", "invalid daemon request"); const result = await durableDaemonDispatch(paths.dir, request, () => runtime.dispatch(request.operation, request.params ?? {}), () => runtime.dispatch("session.inspect", request.params ?? {})); connection.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, id: request.id, ok: true, result: { ...result, _shutdown: undefined } })}\n`); if (result?._shutdown) setImmediate(() => void runtime.shutdown().then(() => process.exit(0)).catch(error => runtime.persist({ shutdown_state: "cleanup_failed", cleanup_error: errorPayload(error) }))); } catch (error) { connection.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, id: request?.id ?? null, ok: false, error: errorPayload(error) })}\n`); } })(); }); }); runtime.server = server; await new Promise((resolve, reject) => { server.once("error", reject); server.listen(paths.socket, resolve); }); await chmod(paths.socket, 0o600); await runtime.persist({ shutdown_state: "running", active_tree: false }); runtime.ready = true; return await new Promise(() => {});
|
|
298
299
|
}
|
|
299
300
|
import { authorizeRetainedDaemonResume } from "./driver-recovery.mjs";
|
|
@@ -54,8 +54,8 @@ export const lifecycleRetryInstruction = "If the CLI rejects the call with effec
|
|
|
54
54
|
export function workerDelegationTask({ workId, startCommand, launchPolicy }) {
|
|
55
55
|
if (!workId || !startCommand) throw new Error("ready fan-out Work lacks its exact start command");
|
|
56
56
|
const context = launchPolicy === "fresh_agent_required"
|
|
57
|
-
? "
|
|
58
|
-
: "
|
|
57
|
+
? "You are the already-selected fresh leaf worker for this Work. Execute its lifecycle start yourself; do not delegate it to another child."
|
|
58
|
+
: "You are the already-selected worker. Execute this Work yourself; do not delegate it to another child.";
|
|
59
59
|
return [
|
|
60
60
|
`Complete one already-declared Work: ${workId}.`,
|
|
61
61
|
context,
|
|
@@ -31,7 +31,9 @@ export function controllerStageEntryPrompt(stage, command) {
|
|
|
31
31
|
}
|
|
32
32
|
/** The shared controller owns this former eval-only Work-graph decision. */
|
|
33
33
|
export async function nextControllerFanout(context, input) {
|
|
34
|
-
|
|
34
|
+
// Tree settlement is not a Work result. Keep these children visible to the
|
|
35
|
+
// association check, especially when a hook rejected work start before CLI.
|
|
36
|
+
const children = input.children;
|
|
35
37
|
const observations = { harness_id: input.harness, parent_session_id: input.sessionId, children };
|
|
36
38
|
if (children.length) {
|
|
37
39
|
const observed = observeVnextNativeChildren(context, { ...input, observations });
|
|
@@ -75,12 +75,13 @@ export function restoreEvalBootstrapSnapshot(_context, input, prepared = prepare
|
|
|
75
75
|
export function createEvalRunSnapshot(context, input) {
|
|
76
76
|
const { output } = prepareEvalRunSnapshotCreation(context, input);
|
|
77
77
|
const temporary = `${output}.partial-${crypto.randomUUID()}`;
|
|
78
|
+
let sourceVersion = null;
|
|
78
79
|
try {
|
|
79
80
|
input.boundary?.verify();
|
|
80
|
-
|
|
81
|
+
sourceVersion = input.boundary ? snapshotSourceVersion(context, input) : null;
|
|
81
82
|
const result = captureEvalRunSnapshot(context, { ...input, output: temporary });
|
|
82
83
|
if (input.boundary && sourceVersion) {
|
|
83
|
-
Object.assign(result, { consistency: "controller_boundary_writer_barrier@1", boundary_capture: { controller_id: input.boundary.controllerId, operation_id: input.boundary.operationId, boundary_key: input.boundary.boundaryKey, source_sha256: sourceVersion, observation: input.boundary.observation } });
|
|
84
|
+
Object.assign(result, { consistency: "controller_boundary_writer_barrier@1", boundary_capture: { controller_id: input.boundary.controllerId, operation_id: input.boundary.operationId, boundary_key: input.boundary.boundaryKey, source_sha256: sourceVersion.sha256, observation: input.boundary.observation } });
|
|
84
85
|
writeJson(path.join(temporary, "snapshot.json"), Object.fromEntries(Object.entries(result).filter(([key]) => key !== "ok" && key !== "snapshot")));
|
|
85
86
|
}
|
|
86
87
|
if (input.recoveryId) {
|
|
@@ -93,8 +94,20 @@ export function createEvalRunSnapshot(context, input) {
|
|
|
93
94
|
throw new AppError("snapshot_exists", "Snapshot output already exists", 1, { output });
|
|
94
95
|
syncSnapshotTree(temporary);
|
|
95
96
|
if (input.boundary) {
|
|
96
|
-
if (sourceVersion
|
|
97
|
-
|
|
97
|
+
if (sourceVersion) {
|
|
98
|
+
let after;
|
|
99
|
+
try {
|
|
100
|
+
after = snapshotSourceVersion(context, input, sourceVersion.databases);
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
if (error.code !== "ENOENT")
|
|
104
|
+
throw error;
|
|
105
|
+
const missing = error.path;
|
|
106
|
+
throw new AppError("snapshot_source_changed", "Snapshot source disappeared during boundary capture", 1, { components: [missing && path.isAbsolute(missing) && isInside(context.ddFlowHome, missing) ? `runtime/${path.relative(context.ddFlowHome, missing)}` : "source_path_disappeared"] });
|
|
107
|
+
}
|
|
108
|
+
if (sourceVersion.sha256 !== after.sha256)
|
|
109
|
+
throw new AppError("snapshot_source_changed", "Snapshot source files or Git state changed during boundary capture", 1, { components: snapshotSourceChanges(sourceVersion.entries, after.entries), changes: snapshotSourceChangeProofs(sourceVersion.entries, after.entries) });
|
|
110
|
+
}
|
|
98
111
|
input.boundary.verify();
|
|
99
112
|
}
|
|
100
113
|
fs.renameSync(temporary, output);
|
|
@@ -105,6 +118,10 @@ export function createEvalRunSnapshot(context, input) {
|
|
|
105
118
|
fs.rmSync(temporary, { recursive: true, force: true });
|
|
106
119
|
throw error;
|
|
107
120
|
}
|
|
121
|
+
finally {
|
|
122
|
+
for (const db of sourceVersion?.databases.values() ?? [])
|
|
123
|
+
db.close();
|
|
124
|
+
}
|
|
108
125
|
}
|
|
109
126
|
export function prepareEvalRunSnapshotCreation(context, input) {
|
|
110
127
|
if (Number(Boolean(input.candidate)) + Number(Boolean(input.incomplete)) + Number(input.stageEntry !== undefined) + Number(input.recoveryId !== undefined) !== 1)
|
|
@@ -801,23 +818,101 @@ function snapshotPayloadAllowed(source) {
|
|
|
801
818
|
function runtimePayloadAllowed(source) {
|
|
802
819
|
return snapshotPayloadAllowed(source) && !["auth.json", "auth.bk", "credentials.json", ".credentials.json", "credentials", ".credentials", "id_rsa", "id_ed25519", "engines", "checkouts"].includes(path.basename(source));
|
|
803
820
|
}
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
821
|
+
function sqliteFile(file) {
|
|
822
|
+
if (!fs.existsSync(file) || !fs.statSync(file).isFile())
|
|
823
|
+
return false;
|
|
824
|
+
const descriptor = fs.openSync(file, "r");
|
|
825
|
+
try {
|
|
826
|
+
const header = Buffer.alloc(16);
|
|
827
|
+
return fs.readSync(descriptor, header, 0, 16, 0) === 16 && header.toString() === "SQLite format 3\0";
|
|
828
|
+
}
|
|
829
|
+
finally {
|
|
830
|
+
fs.closeSync(descriptor);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
/** A WAL is copied through its SQLite read snapshot, not as raw bytes. */
|
|
834
|
+
function runtimeSnapshotKind(file, excluded) {
|
|
835
|
+
if (!runtimePayloadAllowed(file) || excluded.includes(file))
|
|
836
|
+
return "excluded";
|
|
837
|
+
if (fs.lstatSync(file).isFile()) {
|
|
838
|
+
if (/-(?:wal|shm|journal)$/.test(file) && sqliteFile(file.replace(/-(?:wal|shm|journal)$/, "")))
|
|
839
|
+
return "sidecar";
|
|
840
|
+
if (sqliteFile(file))
|
|
841
|
+
return "sqlite";
|
|
842
|
+
}
|
|
843
|
+
return "ordinary";
|
|
844
|
+
}
|
|
845
|
+
/** Productive writers are fenced separately. Compare exactly the copied
|
|
846
|
+
* source set; SQLite serialization includes committed WAL pages without
|
|
847
|
+
* mistaking a physical checkpoint for new logical content. */
|
|
848
|
+
function snapshotSourceVersion(context, input, retainedDatabases) {
|
|
849
|
+
const databases = retainedDatabases ?? new Map();
|
|
807
850
|
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
808
851
|
const project = requireProjectByRoot(context, projectRoot);
|
|
809
852
|
const run = context.db.get("SELECT workspace_root FROM runs WHERE project_id = ? AND id = ?", [project.id, input.runId]);
|
|
810
853
|
if (!run)
|
|
811
854
|
throw new AppError("not_found", "Snapshot RUN is not registered", 1);
|
|
812
|
-
const
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
855
|
+
const entries = new Map();
|
|
856
|
+
const excluded = codexHomeSnapshotExclusions(context, project.id, input.runId);
|
|
857
|
+
const visit = (directory) => {
|
|
858
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
859
|
+
const file = path.join(directory, entry.name);
|
|
860
|
+
const kind = runtimeSnapshotKind(file, excluded);
|
|
861
|
+
if (kind === "excluded" || kind === "sidecar")
|
|
862
|
+
continue;
|
|
863
|
+
const key = `runtime/${path.relative(context.ddFlowHome, file)}`;
|
|
864
|
+
const stat = fs.lstatSync(file);
|
|
865
|
+
if (stat.isDirectory()) {
|
|
866
|
+
entries.set(key, "directory");
|
|
867
|
+
visit(file);
|
|
868
|
+
}
|
|
869
|
+
else if (stat.isSymbolicLink())
|
|
870
|
+
entries.set(key, `link:${fs.readlinkSync(file)}`);
|
|
871
|
+
else if (kind === "sqlite") {
|
|
872
|
+
// Resource heartbeats are copied consistently but are not productive
|
|
873
|
+
// RUN writers; owner identity is fenced by boundary.verify().
|
|
874
|
+
if (file === path.join(context.ddFlowHome, "runtime.sqlite")) {
|
|
875
|
+
entries.set(key, `resource-sqlite:${stat.dev}:${stat.ino}:${stat.mode & 0o111}`);
|
|
876
|
+
continue;
|
|
877
|
+
}
|
|
878
|
+
let db = databases.get(file);
|
|
879
|
+
if (!db) {
|
|
880
|
+
db = new DatabaseSync(file, { readOnly: true });
|
|
881
|
+
databases.set(file, db);
|
|
882
|
+
}
|
|
883
|
+
entries.set(key, `sqlite:${crypto.createHash("sha256").update(db.serialize()).digest("hex")}:${stat.dev}:${stat.ino}:${stat.mode & 0o111}`);
|
|
884
|
+
}
|
|
885
|
+
else if (stat.isFile())
|
|
886
|
+
entries.set(key, `file:${crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex")}:${stat.mode & 0o111}`);
|
|
887
|
+
}
|
|
888
|
+
};
|
|
889
|
+
try {
|
|
890
|
+
visit(context.ddFlowHome);
|
|
891
|
+
for (const root of new Set([projectRoot, run.workspace_root]))
|
|
892
|
+
entries.set(`source/${root}`, JSON.stringify([
|
|
893
|
+
treeHash(root, true), treeModesHash(root, true), git(root, ["symbolic-ref", "--quiet", "HEAD"]), gitBytes(root, ["show-ref", "--head"]).toString("base64"), indexEntries(root).toString("base64")
|
|
894
|
+
]));
|
|
895
|
+
return { entries, databases, sha256: crypto.createHash("sha256").update(JSON.stringify([...entries].sort(([a], [b]) => a.localeCompare(b)))).digest("hex") };
|
|
896
|
+
}
|
|
897
|
+
catch (error) {
|
|
898
|
+
if (!retainedDatabases)
|
|
899
|
+
for (const db of databases.values())
|
|
900
|
+
try {
|
|
901
|
+
db.close();
|
|
902
|
+
}
|
|
903
|
+
catch { /* Preserve the original source error. */ }
|
|
904
|
+
throw error;
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
function snapshotSourceChanges(before, after) {
|
|
908
|
+
return [...new Set([...before.keys(), ...after.keys()])].filter(key => before.get(key) !== after.get(key)).sort().slice(0, 8);
|
|
909
|
+
}
|
|
910
|
+
function snapshotSourceChangeProofs(before, after) {
|
|
911
|
+
return snapshotSourceChanges(before, after).map(component => ({
|
|
912
|
+
component,
|
|
913
|
+
before: before.has(component) ? crypto.createHash("sha256").update(before.get(component)).digest("hex") : null,
|
|
914
|
+
after: after.has(component) ? crypto.createHash("sha256").update(after.get(component)).digest("hex") : null
|
|
915
|
+
}));
|
|
821
916
|
}
|
|
822
917
|
/** Managed Codex homes share host configuration, not owned snapshot payload. */
|
|
823
918
|
function codexHomeSnapshotExclusions(context, projectId, runId) {
|
|
@@ -856,23 +951,12 @@ function copyRuntimeTree(source, destination, scoped = true, excluded = []) {
|
|
|
856
951
|
if (isInside(root, physicalPath(destination)))
|
|
857
952
|
throw new AppError("snapshot_output_inside_runtime", "Recovery output must be outside its source artifact tree", 1);
|
|
858
953
|
const databases = [];
|
|
859
|
-
const sqlite = (file) => {
|
|
860
|
-
if (!fs.existsSync(file) || !fs.statSync(file).isFile())
|
|
861
|
-
return false;
|
|
862
|
-
const descriptor = fs.openSync(file, "r");
|
|
863
|
-
try {
|
|
864
|
-
const header = Buffer.alloc(16);
|
|
865
|
-
return fs.readSync(descriptor, header, 0, 16, 0) === 16 && header.toString() === "SQLite format 3\0";
|
|
866
|
-
}
|
|
867
|
-
finally {
|
|
868
|
-
fs.closeSync(descriptor);
|
|
869
|
-
}
|
|
870
|
-
};
|
|
871
954
|
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
872
955
|
fs.cpSync(source, destination, {
|
|
873
956
|
recursive: true, verbatimSymlinks: true,
|
|
874
957
|
filter: file => {
|
|
875
|
-
|
|
958
|
+
const kind = runtimeSnapshotKind(file, excluded);
|
|
959
|
+
if (kind === "excluded" || kind === "sidecar")
|
|
876
960
|
return false;
|
|
877
961
|
const stat = fs.lstatSync(file);
|
|
878
962
|
if (stat.isSymbolicLink()) {
|
|
@@ -883,9 +967,7 @@ function copyRuntimeTree(source, destination, scoped = true, excluded = []) {
|
|
|
883
967
|
if (!stat.isDirectory() && !stat.isFile())
|
|
884
968
|
return false;
|
|
885
969
|
if (stat.isFile()) {
|
|
886
|
-
if (
|
|
887
|
-
return false;
|
|
888
|
-
if (sqlite(file)) {
|
|
970
|
+
if (kind === "sqlite") {
|
|
889
971
|
databases.push(file);
|
|
890
972
|
return false;
|
|
891
973
|
}
|
|
@@ -80,10 +80,11 @@ export async function captureControllerBoundary(context, row, manifest, stage, n
|
|
|
80
80
|
}
|
|
81
81
|
else {
|
|
82
82
|
let lastPending = "";
|
|
83
|
+
let lastCaptureMismatch = null;
|
|
83
84
|
for (;;) {
|
|
84
85
|
if (observationBudget.exhausted()) {
|
|
85
|
-
context.db.run("UPDATE run_controller_operations SET receipt_json = json_set(COALESCE(receipt_json, '{}'), '$.recovery_observation', json(?)), updated_at = ? WHERE operation_id = ?", [JSON.stringify(observationBudget.state()), context.now(), operationId]);
|
|
86
|
-
throw new AppError("recovery_observation_budget_exhausted", "Boundary capture has no stable settlement within the active observation budget", 1, { recovery_observation: observationBudget.state() });
|
|
86
|
+
context.db.run("UPDATE run_controller_operations SET receipt_json = json_set(json_set(COALESCE(receipt_json, '{}'), '$.recovery_observation', json(?)), '$.last_capture_mismatch', json(?)), updated_at = ? WHERE operation_id = ?", [JSON.stringify(observationBudget.state()), JSON.stringify(lastCaptureMismatch), context.now(), operationId]);
|
|
87
|
+
throw new AppError("recovery_observation_budget_exhausted", "Boundary capture has no stable settlement within the active observation budget", 1, { recovery_observation: observationBudget.state(), last_capture_mismatch: lastCaptureMismatch });
|
|
87
88
|
}
|
|
88
89
|
assertCurrent();
|
|
89
90
|
let verify = () => { throw new AppError("controller_boundary_unsettled", "Boundary has no stable writer observation", 1); };
|
|
@@ -99,14 +100,15 @@ export async function captureControllerBoundary(context, row, manifest, stage, n
|
|
|
99
100
|
throw error;
|
|
100
101
|
observation.settled = false;
|
|
101
102
|
observation.pending_reasons = [error.code];
|
|
103
|
+
lastCaptureMismatch = { code: error.code, components: error.details?.components ?? [], changes: error.details?.changes ?? [] };
|
|
102
104
|
}
|
|
103
105
|
}
|
|
104
106
|
observationBudget.exhausted();
|
|
105
|
-
const published = { ...observation, recovery_observation: observationBudget.state() };
|
|
107
|
+
const published = { ...observation, recovery_observation: observationBudget.state(), last_capture_mismatch: lastCaptureMismatch };
|
|
106
108
|
const pending = digest(observation.pending_reasons);
|
|
107
109
|
context.db.run("UPDATE run_controller_operations SET receipt_json = ?, updated_at = ? WHERE operation_id = ?", [JSON.stringify(published), context.now(), operationId]);
|
|
108
110
|
if (pending !== lastPending) {
|
|
109
|
-
appendEvent(context, row.controller_id, "boundary_capture_pending", { operation_id: operationId, pending_reasons: observation.pending_reasons });
|
|
111
|
+
appendEvent(context, row.controller_id, "boundary_capture_pending", { operation_id: operationId, pending_reasons: observation.pending_reasons, last_capture_mismatch: lastCaptureMismatch });
|
|
110
112
|
lastPending = pending;
|
|
111
113
|
}
|
|
112
114
|
await delay(observationBudget.nextDelay());
|
|
@@ -158,6 +158,10 @@ export async function reconcileVnextFanout(context, input) {
|
|
|
158
158
|
issues.push({ code: "child_descendants_unsettled", work_id: link.work_id });
|
|
159
159
|
continue;
|
|
160
160
|
}
|
|
161
|
+
if (child.status === "settled_by_root") {
|
|
162
|
+
issues.push({ code: "child_execution_outcome_unconfirmed", work_id: link.work_id, session_id: child.session_id });
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
161
165
|
if (child.status === "completed") {
|
|
162
166
|
issues.push({ code: "execution_ended_without_work_result", work_id: link.work_id, session_id: child.session_id });
|
|
163
167
|
continue;
|
|
@@ -178,7 +182,7 @@ export async function reconcileVnextFanout(context, input) {
|
|
|
178
182
|
export function prepareNativeObservations(observations) {
|
|
179
183
|
const value = observations;
|
|
180
184
|
if (!value || typeof value.harness_id !== "string" || !value.harness_id.trim() || typeof value.parent_session_id !== "string" || !value.parent_session_id.trim() || !Array.isArray(value.children)
|
|
181
|
-
|| value.children.some(child => !child || typeof child.session_id !== "string" || !child.session_id.trim() || typeof child.status !== "string" || !["running", "unknown", "completed", "failed", "cancelled"].includes(child.status)))
|
|
185
|
+
|| value.children.some(child => !child || typeof child.session_id !== "string" || !child.session_id.trim() || typeof child.status !== "string" || !["running", "unknown", "completed", "failed", "cancelled", "settled_by_root"].includes(child.status)))
|
|
182
186
|
throw new AppError("usage", "Expected native observations: harness_id, parent_session_id and children[{session_id,status}]", 2);
|
|
183
187
|
if (new Set(value.children.map(child => child.session_id)).size !== value.children.length)
|
|
184
188
|
throw new AppError("usage", "Native observations must contain each child Session exactly once", 2);
|
|
@@ -880,6 +880,8 @@ function bindSession(context, work, run, identity, now) {
|
|
|
880
880
|
});
|
|
881
881
|
if (work.parent_work_id && !parentSession)
|
|
882
882
|
throw new AppError("parent_session_required", "Child Work requires a confirmed parent Work/Session link", 1, { work_id: work.work_id, parent_work_id: work.parent_work_id });
|
|
883
|
+
if (work.launch_policy === "fresh_agent_required" && inferredParentSession && identity.parentSessionId && identity.parentSessionId !== inferredParentSession)
|
|
884
|
+
throw new AppError("native_work_parent_mismatch", "Fresh native Work must be started by a direct child of its assigned parent Session", 1, { work_id: work.work_id, effect: "no_effect", ...identityDetails(), observed_provider_parent_session_key: identity.parentSessionId });
|
|
883
885
|
if (work.launch_policy === "fresh_agent_required" && (identity.sessionId === parentSession || context.db.get("SELECT 1 FROM work_sessions ws JOIN works w ON w.work_id = ws.work_id WHERE w.project_id = ? AND w.run_id = ? AND ws.session_id = ? LIMIT 1", [work.project_id, work.run_id, identity.sessionId])))
|
|
884
886
|
throw new AppError("fresh_session_required", "This Work requires a fresh Session in this RUN", 1, { work_id: work.work_id, effect: "no_effect", recoverable: true, handoff_required: true, ...identityDetails() });
|
|
885
887
|
const existing = context.db.get("SELECT session_id, parent_session_id, provider_parent_session_id FROM sessions WHERE project_id = ? AND session_id = ?", [work.project_id, identity.sessionId]);
|