@deksden-com/dd-flow-cli 0.9.0-beta.17 → 0.9.0-beta.19
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/CHANGELOG.md +12 -0
- package/README.md +21 -0
- package/dist/build-info.json +5 -5
- package/dist/cli/help.js +16 -2
- package/dist/cli/run-cli.js +77 -14
- package/dist/schemas/agent-profile.schema.json +1 -1
- package/dist/services/cli-operation-classifier.js +2 -0
- package/dist/services/eval-snapshots.js +14 -6
- package/dist/services/harness-config.js +2 -0
- package/dist/services/hooks.js +121 -30
- package/dist/services/lifecycle-command.js +8 -0
- package/dist/services/run-recovery.js +104 -0
- package/dist/services/stage-lifecycle.js +3 -0
- package/dist/services/usage.js +13 -3
- package/dist/services/vnext-fanout.js +40 -0
- package/dist/services/work-registry.js +80 -7
- package/dist/storage/database.js +27 -1
- package/package.json +1 -1
package/dist/services/hooks.js
CHANGED
|
@@ -285,7 +285,7 @@ export function handleCodexHook(context, input) {
|
|
|
285
285
|
return { ok: true, observed: false, reason: "event_not_participating", event: eventName };
|
|
286
286
|
// Resume legitimately receives an answer through stdin. It is matched by
|
|
287
287
|
// immutable lifecycle arguments below; never reject or rewrite that pipe.
|
|
288
|
-
if (lifecycle.analysis.kind === "compound") {
|
|
288
|
+
if (lifecycle.analysis.kind === "compound" && !allowsStdinLifecycleCompound(lifecycle)) {
|
|
289
289
|
return {
|
|
290
290
|
ok: false,
|
|
291
291
|
observed: false,
|
|
@@ -299,14 +299,25 @@ export function handleCodexHook(context, input) {
|
|
|
299
299
|
}
|
|
300
300
|
};
|
|
301
301
|
}
|
|
302
|
-
const { flowPayload, stageStart, stageResume, workStart, bootstrapStageStart, commandProjectRoot } = lifecycle;
|
|
302
|
+
const { flowPayload, stageStart, stageFinish, stagePause, stageResume, workStart, workFinish, workFail, bootstrapStageStart, commandProjectRoot } = lifecycle;
|
|
303
303
|
const matchKey = commandProjectRoot ? lifecycleMatchKey(lifecycle, commandProjectRoot) : null;
|
|
304
304
|
// A bootstrap stage is the first stateful command for a new materialized project.
|
|
305
305
|
// Its PreToolUse event arrives before stage start can register that project.
|
|
306
306
|
if (bootstrapStageStart && commandProjectRoot) {
|
|
307
307
|
registerProject(context, { root: commandProjectRoot });
|
|
308
308
|
}
|
|
309
|
-
|
|
309
|
+
let project;
|
|
310
|
+
try {
|
|
311
|
+
project = projectForHook(context, input.projectRoot ?? commandProjectRoot, stringValue(payload.cwd));
|
|
312
|
+
}
|
|
313
|
+
catch (error) {
|
|
314
|
+
// A terminal command outside a registered flow gets no trusted receipt;
|
|
315
|
+
// the CLI will fail closed if it is actually executed. This keeps hooks
|
|
316
|
+
// inert for unrelated shell-parser probes and stale project directories.
|
|
317
|
+
if (stageFinish || stagePause || workFinish || workFail)
|
|
318
|
+
return { ok: true, observed: false, reason: "project_not_registered" };
|
|
319
|
+
throw error;
|
|
320
|
+
}
|
|
310
321
|
if (!project)
|
|
311
322
|
return { ok: true, observed: false, reason: "unrelated_cwd" };
|
|
312
323
|
// Codex retains the root session_id for a thread-spawned child, while the
|
|
@@ -364,7 +375,7 @@ export function handleCodexHook(context, input) {
|
|
|
364
375
|
session: providerSessionId ? nativeSessionIdentity("codex-desktop", providerSessionId) : null,
|
|
365
376
|
...(parentProviderSessionId ? { parent_session: nativeSessionIdentity("codex-desktop", parentProviderSessionId) } : {}),
|
|
366
377
|
protocol_id: protocolId,
|
|
367
|
-
...(providerSessionId && (flowPayload || stageStart || stageResume || workStart)
|
|
378
|
+
...(providerSessionId && (flowPayload || stageStart || stageFinish || stagePause || stageResume || workStart || workFinish || workFail)
|
|
368
379
|
? {
|
|
369
380
|
hookSpecificOutput: {
|
|
370
381
|
hookEventName: "PreToolUse",
|
|
@@ -414,7 +425,7 @@ export function handleZcodeEvent(context, input) {
|
|
|
414
425
|
const lifecycle = lifecycleFacts(command);
|
|
415
426
|
if (!lifecycle)
|
|
416
427
|
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
417
|
-
if (lifecycle.analysis.kind === "compound") {
|
|
428
|
+
if (lifecycle.analysis.kind === "compound" && !allowsStdinLifecycleCompound(lifecycle)) {
|
|
418
429
|
throw new AppError("compound_lifecycle_command", "dd-flow lifecycle commands must be a standalone ZCode Bash tool call", 1, {
|
|
419
430
|
standalone_command: lifecycle.invocation.command
|
|
420
431
|
});
|
|
@@ -521,12 +532,12 @@ export function handleGrokEvent(context, input) {
|
|
|
521
532
|
const lifecycle = lifecycleFacts(command);
|
|
522
533
|
if (!lifecycle)
|
|
523
534
|
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
524
|
-
if (lifecycle.analysis.kind === "compound") {
|
|
535
|
+
if (lifecycle.analysis.kind === "compound" && !allowsStdinLifecycleCompound(lifecycle)) {
|
|
525
536
|
throw new AppError("compound_lifecycle_command", "dd-flow lifecycle commands must be a standalone Grok Build tool call", 1, {
|
|
526
537
|
standalone_command: lifecycle.invocation.command
|
|
527
538
|
});
|
|
528
539
|
}
|
|
529
|
-
const { flowPayload, stageStart, stageResume, workStart, bootstrapStageStart, commandProjectRoot } = lifecycle;
|
|
540
|
+
const { flowPayload, stageStart, stageFinish, stagePause, stageResume, workStart, workFinish, workFail, bootstrapStageStart, commandProjectRoot } = lifecycle;
|
|
530
541
|
const expectedRoot = resolveProjectRoot(input.projectRoot);
|
|
531
542
|
if (commandProjectRoot && resolveProjectRoot(commandProjectRoot) !== expectedRoot) {
|
|
532
543
|
throw new AppError("project_mismatch", "Grok Build lifecycle command project root does not match the controlled workspace", 1, {
|
|
@@ -570,20 +581,32 @@ export function handleGrokEvent(context, input) {
|
|
|
570
581
|
});
|
|
571
582
|
const result = { ok: true, observed: inserted, duplicate: !inserted, event_key: eventKey, session: nativeSessionIdentity("grok-acp", providerSessionId),
|
|
572
583
|
...(isChild ? { parent_session: nativeSessionIdentity("grok-acp", stringValue(ddGrok.parentProviderSessionId) ?? rootProviderSessionId) } : {}), daemon_id: daemonId ?? null };
|
|
573
|
-
return (flowPayload || stageStart || stageResume || workStart)
|
|
584
|
+
return (flowPayload || stageStart || stageFinish || stagePause || stageResume || workStart || workFinish || workFail)
|
|
574
585
|
? { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...(rawInput ?? {}), command: commandWithHookEvent(command, eventKey) } } }
|
|
575
586
|
: result;
|
|
576
587
|
}
|
|
577
588
|
/** Convert a controlled OpenCode tool hook into the shared lifecycle receipt. */
|
|
578
589
|
export function handleOpenCodeEvent(context, input) {
|
|
579
590
|
const event = parseJsonObject(input.stdin || "{}", "OpenCode tool event");
|
|
580
|
-
const agy = event.source_harness === "antigravity-cli";
|
|
581
|
-
const harness = agy ? "antigravity-cli" : "opencode-server";
|
|
582
591
|
if (event.schema_id !== "dd-flow/opencode-tool-event@1")
|
|
583
592
|
throw new AppError("unsupported_opencode_event_schema", "Unsupported OpenCode event schema", 1);
|
|
593
|
+
return handleControlledToolEvent(context, input, event, event.source_harness === "antigravity-cli" ? "antigravity-cli" : "opencode-server");
|
|
594
|
+
}
|
|
595
|
+
/** Accept adapter-observed Factory identity, never a model-supplied Session ID. */
|
|
596
|
+
export function handleDroidEvent(context, input) {
|
|
597
|
+
const event = parseJsonObject(input.stdin || "{}", "Droid tool event");
|
|
598
|
+
if (event.schema_id !== "dd-flow/droid-tool-event@1")
|
|
599
|
+
throw new AppError("unsupported_droid_event_schema", "Unsupported Droid event schema", 1);
|
|
600
|
+
return handleControlledToolEvent(context, input, event, "droid-cli");
|
|
601
|
+
}
|
|
602
|
+
function handleControlledToolEvent(context, input, event, harness) {
|
|
603
|
+
const agy = harness === "antigravity-cli";
|
|
604
|
+
const droid = harness === "droid-cli";
|
|
605
|
+
const label = droid ? "Droid" : agy ? "Antigravity" : "OpenCode";
|
|
606
|
+
const errorPrefix = droid ? "droid" : agy ? "agy" : "opencode";
|
|
584
607
|
const phase = stringValue(event.phase);
|
|
585
608
|
if (phase !== "before" && phase !== "after")
|
|
586
|
-
throw new AppError(
|
|
609
|
+
throw new AppError(`unsupported_${errorPrefix}_event_schema`, `${label} event phase must be before or after`, 1);
|
|
587
610
|
const session = objectRecord(event.session);
|
|
588
611
|
const profile = objectRecord(event.profile);
|
|
589
612
|
const rawInput = objectRecord(event.input);
|
|
@@ -591,16 +614,18 @@ export function handleOpenCodeEvent(context, input) {
|
|
|
591
614
|
const nativeParentId = stringValue(session.parent_provider_session_id);
|
|
592
615
|
const daemonId = stringValue(event.daemon_id);
|
|
593
616
|
const toolCallId = stringValue(event.tool_call_id);
|
|
594
|
-
const eventId = stringValue(event.event_id) ?? toolCallId;
|
|
617
|
+
const eventId = stringValue(event.event_id) ?? (droid ? undefined : toolCallId);
|
|
595
618
|
const toolName = stringValue(event.tool);
|
|
596
619
|
const directory = stringValue(session.directory);
|
|
597
|
-
if (!providerSessionId || !daemonId || !toolCallId || !eventId || !toolName || !directory || !path.isAbsolute(directory)) {
|
|
598
|
-
throw new AppError(
|
|
620
|
+
if (!providerSessionId || !daemonId || (!droid && !toolCallId) || !eventId || !toolName || !directory || !path.isAbsolute(directory)) {
|
|
621
|
+
throw new AppError(`${errorPrefix}_session_identity_invalid`, `${label} event is missing trusted physical identity`, 1);
|
|
599
622
|
}
|
|
600
623
|
const expectedRoot = resolveProjectRoot(input.projectRoot);
|
|
601
624
|
if (resolveProjectRoot(directory) !== expectedRoot) {
|
|
602
|
-
throw new AppError(
|
|
625
|
+
throw new AppError(`${errorPrefix}_directory_mismatch`, `${label} Session directory does not match the controlled workspace`, 1, { expected: expectedRoot, actual: directory });
|
|
603
626
|
}
|
|
627
|
+
if (droid && nativeParentId === providerSessionId)
|
|
628
|
+
throw new AppError("droid_session_identity_invalid", "A Droid Session cannot parent itself", 1);
|
|
604
629
|
const sessionId = storageSessionId(nativeSessionIdentity(harness, providerSessionId));
|
|
605
630
|
const parentSessionId = nativeParentId ? storageSessionId(nativeSessionIdentity(harness, nativeParentId)) : null;
|
|
606
631
|
const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd) ?? stringValue(rawInput.CommandLine);
|
|
@@ -611,43 +636,49 @@ export function handleOpenCodeEvent(context, input) {
|
|
|
611
636
|
projectId: project.id, protocolId: null, harness, providerSessionId, parentSessionId,
|
|
612
637
|
daemonId, sessionId, agentId: stringValue(session.agent_id) ?? null, turnId: stringValue(event.message_id) ?? null,
|
|
613
638
|
eventName: "PostToolUse", toolName, status: "observed", payload: { session_id: providerSessionId, cwd: expectedRoot, tool_name: toolName, status: objectRecord(event.outcome).status },
|
|
614
|
-
eventKey: baseKey, matchKey: null, transcriptPath: null, cwd: expectedRoot
|
|
639
|
+
eventKey: baseKey, matchKey: null, transcriptPath: droid ? stringValue(event.transcript_path) ?? null : null, cwd: expectedRoot
|
|
615
640
|
});
|
|
616
641
|
return { ok: true, observed: inserted, duplicate: !inserted, event_key: baseKey, phase, harness, session: nativeSessionIdentity(harness, providerSessionId), ...(nativeParentId ? { parent_session: nativeSessionIdentity(harness, nativeParentId) } : {}) };
|
|
617
642
|
}
|
|
618
|
-
if (!command || !["bash", "Bash", "run_command", "run_terminal_command", "RunTerminalCommand"].includes(toolName)) {
|
|
643
|
+
if (!command || !(droid ? ["Execute"] : ["bash", "Bash", "run_command", "run_terminal_command", "RunTerminalCommand"]).includes(toolName)) {
|
|
619
644
|
return { ok: true, observed: false, reason: command ? "non_bash_tool" : "event_not_participating" };
|
|
620
645
|
}
|
|
621
646
|
const lifecycle = lifecycleFacts(command);
|
|
622
647
|
if (!lifecycle)
|
|
623
648
|
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
624
|
-
if (lifecycle.analysis.kind === "compound") {
|
|
625
|
-
throw new AppError("compound_lifecycle_command", `dd-flow lifecycle commands must be a standalone ${
|
|
649
|
+
if (lifecycle.analysis.kind === "compound" && !allowsStdinLifecycleCompound(lifecycle)) {
|
|
650
|
+
throw new AppError("compound_lifecycle_command", `dd-flow lifecycle commands must be a standalone ${label} shell tool call`, 1, { standalone_command: lifecycle.invocation.command });
|
|
626
651
|
}
|
|
627
652
|
const { flowPayload, bootstrapStageStart, commandProjectRoot } = lifecycle;
|
|
628
653
|
if (commandProjectRoot && resolveProjectRoot(commandProjectRoot) !== expectedRoot) {
|
|
629
|
-
throw new AppError("project_mismatch",
|
|
654
|
+
throw new AppError("project_mismatch", `${label} lifecycle command project root does not match the controlled workspace`, 1, { expected: expectedRoot, actual: commandProjectRoot });
|
|
630
655
|
}
|
|
631
656
|
const matchKey = lifecycleMatchKey(lifecycle, expectedRoot);
|
|
632
657
|
if (bootstrapStageStart)
|
|
633
658
|
registerProject(context, { root: expectedRoot });
|
|
634
659
|
const project = requireProjectByRoot(context, expectedRoot);
|
|
660
|
+
if (droid) {
|
|
661
|
+
const existing = context.db.get("SELECT harness, provider_session_id, parent_session_id, daemon_id, match_key, tool_name FROM hook_events WHERE project_id = ? AND event_key = ?", [project.id, baseKey]);
|
|
662
|
+
if (existing && (existing.harness !== harness || existing.provider_session_id !== providerSessionId || existing.parent_session_id !== parentSessionId || existing.daemon_id !== daemonId || existing.match_key !== matchKey || existing.tool_name !== toolName)) {
|
|
663
|
+
throw new AppError("droid_event_replay_mismatch", "Droid event ID was already observed with different lifecycle identity", 1);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
635
666
|
const agentId = stringValue(session.agent_id);
|
|
636
667
|
const observedSession = flowPayload ? bindObservedFlowSession(context, project, {
|
|
637
668
|
...flowPayload, harness, provider_session_id: providerSessionId, agent_id: agentId ?? null,
|
|
638
669
|
parent_session_id: parentSessionId, provider: stringValue(profile.provider) ?? null, model: stringValue(profile.model) ?? null,
|
|
639
|
-
reasoning: stringValue(profile.variant) ?? null, mode: stringValue(profile.agent) ?? null, agent_type: stringValue(profile.agent) ?? null,
|
|
640
|
-
metadata: { opencode_version: stringValue(event.server_version) ?? null, api_fingerprint: stringValue(event.api_fingerprint) ?? null, plugin_sha256: stringValue(event.plugin_sha256) ?? null, native_project_id: stringValue(session.project_id) ?? null }
|
|
670
|
+
reasoning: stringValue(droid ? profile.reasoning : profile.variant) ?? null, mode: stringValue(droid ? profile.mode : profile.agent) ?? null, agent_type: stringValue(droid ? profile.agent_type : profile.agent) ?? null,
|
|
671
|
+
metadata: droid ? { droid_version: stringValue(event.runtime_version) ?? null } : { opencode_version: stringValue(event.server_version) ?? null, api_fingerprint: stringValue(event.api_fingerprint) ?? null, plugin_sha256: stringValue(event.plugin_sha256) ?? null, native_project_id: stringValue(session.project_id) ?? null }
|
|
641
672
|
}, sessionId) : undefined;
|
|
642
673
|
const payload = {
|
|
643
674
|
session_id: providerSessionId, agent_id: agentId, cwd: expectedRoot, tool_name: toolName, tool_input: rawInput, command,
|
|
644
|
-
provider: stringValue(profile.provider), model: stringValue(profile.model), reasoning: stringValue(profile.variant),
|
|
645
|
-
mode: stringValue(profile.agent), agent_type: stringValue(profile.agent)
|
|
675
|
+
provider: stringValue(profile.provider), model: stringValue(profile.model), reasoning: stringValue(droid ? profile.reasoning : profile.variant),
|
|
676
|
+
mode: stringValue(droid ? profile.mode : profile.agent), agent_type: stringValue(droid ? profile.agent_type : profile.agent)
|
|
646
677
|
};
|
|
647
678
|
const inserted = recordHookEvent(context, {
|
|
648
679
|
projectId: project.id, protocolId: observedSession?.protocol_id ?? null, harness, providerSessionId, parentSessionId,
|
|
649
680
|
daemonId, sessionId, agentId: agentId ?? null, turnId: stringValue(event.message_id) ?? null, eventName: "PreToolUse", toolName,
|
|
650
|
-
status: "observed", payload, eventKey: baseKey, matchKey, transcriptPath: null, cwd: expectedRoot
|
|
681
|
+
status: "observed", payload, eventKey: baseKey, matchKey, transcriptPath: droid ? stringValue(event.transcript_path) ?? null : null, cwd: expectedRoot
|
|
651
682
|
});
|
|
652
683
|
recordFlowSessionObservation(context, { projectId: project.id, sessionId, runId: observedSession?.run_id ?? null, protocolId: observedSession?.protocol_id ?? null, cwd: expectedRoot, toolName, eventKey: baseKey });
|
|
653
684
|
const result = { ok: true, observed: inserted, duplicate: !inserted, event_key: baseKey, phase, harness, session: nativeSessionIdentity(harness, providerSessionId), ...(nativeParentId ? { parent_session: nativeSessionIdentity(harness, nativeParentId) } : {}), daemon_id: daemonId };
|
|
@@ -727,25 +758,39 @@ function lifecycleFacts(command) {
|
|
|
727
758
|
return null;
|
|
728
759
|
const invocation = analysis.invocation;
|
|
729
760
|
const stageStart = invocation.operation === "stage_start";
|
|
761
|
+
const stageFinish = invocation.operation === "stage_finish";
|
|
762
|
+
const stagePause = invocation.operation === "stage_pause";
|
|
730
763
|
const stageResume = invocation.operation === "stage_resume";
|
|
731
764
|
const workStart = invocation.operation === "work_start";
|
|
765
|
+
const workFinish = invocation.operation === "work_finish";
|
|
766
|
+
const workFail = invocation.operation === "work_fail";
|
|
732
767
|
return {
|
|
733
768
|
analysis,
|
|
734
769
|
invocation,
|
|
735
770
|
flowPayload: flowSessionPayloadFromRegisterCommand(invocation),
|
|
736
771
|
stageStart,
|
|
772
|
+
stageFinish,
|
|
773
|
+
stagePause,
|
|
737
774
|
stageResume,
|
|
738
|
-
workStart,
|
|
775
|
+
workStart, workFinish, workFail,
|
|
739
776
|
bootstrapStageStart: stageStart && commandHasOption(invocation, "bootstrap"),
|
|
740
777
|
commandProjectRoot: commandOption(invocation, "project-root"),
|
|
741
|
-
stageName: stageStart || stageResume ? commandOption(invocation, "stage") : undefined,
|
|
742
|
-
runId: stageStart || stageResume ? commandPosition(invocation, 0) : undefined,
|
|
743
|
-
workId: stageResume ? commandOption(invocation, "work") : workStart ? commandPosition(invocation, 0) : undefined,
|
|
778
|
+
stageName: stageStart || stageFinish || stagePause || stageResume ? commandOption(invocation, "stage") : undefined,
|
|
779
|
+
runId: stageStart || stageFinish || stagePause || stageResume ? commandPosition(invocation, 0) : undefined,
|
|
780
|
+
workId: stagePause || stageResume ? commandOption(invocation, "work") : workStart || workFinish || workFail ? commandPosition(invocation, 0) : undefined,
|
|
744
781
|
contextSha256: commandOption(invocation, "context-sha256")
|
|
745
782
|
};
|
|
746
783
|
}
|
|
784
|
+
// Worker completion and the two HITL commands intentionally take their
|
|
785
|
+
// payload from stdin. Their generated pipe/heredoc is one lifecycle command,
|
|
786
|
+
// not a second authority. Everything else remains standalone-only.
|
|
787
|
+
function allowsStdinLifecycleCompound(facts) {
|
|
788
|
+
const operation = facts.invocation.operation;
|
|
789
|
+
return (operation === "work_finish" && commandHasOption(facts.invocation, "result-stdin"))
|
|
790
|
+
|| ((operation === "stage_pause" || operation === "stage_resume") && facts.invocation.heredoc);
|
|
791
|
+
}
|
|
747
792
|
function lifecycleMatchKey(facts, projectRoot) {
|
|
748
|
-
const { invocation, bootstrapStageStart, stageStart, stageResume, workStart, stageName, runId, workId, contextSha256 } = facts;
|
|
793
|
+
const { invocation, bootstrapStageStart, stageStart, stageFinish, stagePause, stageResume, workStart, workFinish, workFail, stageName, runId, workId, contextSha256 } = facts;
|
|
749
794
|
if (bootstrapStageStart)
|
|
750
795
|
return bootstrapMatchKey({
|
|
751
796
|
projectRoot,
|
|
@@ -755,12 +800,58 @@ function lifecycleMatchKey(facts, projectRoot) {
|
|
|
755
800
|
});
|
|
756
801
|
if (stageStart && runId && stageName)
|
|
757
802
|
return stageStartMatchKey(runId, stageName, projectRoot, contextSha256);
|
|
803
|
+
if (stageFinish && runId && stageName)
|
|
804
|
+
return stageLifecycleMatchKey("stage_finish", runId, stageName, projectRoot);
|
|
805
|
+
if (stagePause && runId && stageName && workId)
|
|
806
|
+
return stageLifecycleMatchKey("stage_pause", runId, stageName, projectRoot, workId);
|
|
758
807
|
if (stageResume && runId && stageName && workId)
|
|
759
808
|
return stageResumeMatchKey(runId, stageName, workId, projectRoot);
|
|
760
809
|
if (workStart && workId)
|
|
761
810
|
return workStartMatchKey(workId, projectRoot);
|
|
811
|
+
if (workFinish && workId)
|
|
812
|
+
return workLifecycleMatchKey("work_finish", workId, projectRoot);
|
|
813
|
+
if (workFail && workId)
|
|
814
|
+
return workLifecycleMatchKey("work_fail", workId, projectRoot);
|
|
762
815
|
return null;
|
|
763
816
|
}
|
|
817
|
+
export function stageLifecycleMatchKey(operation, runId, stage, projectRoot, workId) {
|
|
818
|
+
return crypto.createHash("sha256").update(JSON.stringify({ operation, run_id: runId, stage, project_root: resolveProjectRoot(projectRoot), ...(workId ? { work_id: workId } : {}) })).digest("hex");
|
|
819
|
+
}
|
|
820
|
+
export function workLifecycleMatchKey(operation, workId, projectRoot) {
|
|
821
|
+
return crypto.createHash("sha256").update(JSON.stringify({ operation, work_id: workId, project_root: resolveProjectRoot(projectRoot) })).digest("hex");
|
|
822
|
+
}
|
|
823
|
+
/** Claims a Work terminal command only after its exact hook receipt has proved
|
|
824
|
+
* the physical Session that issued it. Work code performs the owner check. */
|
|
825
|
+
export function claimWorkLifecycleHookEvent(context, input) {
|
|
826
|
+
const matchKey = workLifecycleMatchKey(input.operation, input.workId, input.projectRoot);
|
|
827
|
+
const eventKey = input.eventKey ?? findRecentMatchingHookEvent(context, {
|
|
828
|
+
projectId: input.projectId, matchKey, errorCode: "trusted_session_binding_required", operation: input.operation.replace("_", " ")
|
|
829
|
+
}).eventKey;
|
|
830
|
+
const event = context.db.get("SELECT id, session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, eventKey]);
|
|
831
|
+
if (!event?.session_id || event.status !== "observed" || event.match_key !== matchKey) {
|
|
832
|
+
throw new AppError("trusted_session_binding_required", `${input.operation.replace("_", " ")} requires one fresh matching PreToolUse hook event`, 1, { work_id: input.workId, event_key: eventKey });
|
|
833
|
+
}
|
|
834
|
+
const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
|
|
835
|
+
if (claimed.changes !== 1)
|
|
836
|
+
throw new AppError("trusted_session_binding_required", "matching Work lifecycle hook event was already claimed", 1, { work_id: input.workId });
|
|
837
|
+
return hookSessionIdentity(context, input.projectId, eventKey);
|
|
838
|
+
}
|
|
839
|
+
/** Claims a lifecycle command only after its exact hook receipt has proved the
|
|
840
|
+
* physical Session that issued it. Stage code performs the Work-owner check. */
|
|
841
|
+
export function claimStageLifecycleHookEvent(context, input) {
|
|
842
|
+
const matchKey = stageLifecycleMatchKey(input.operation, input.runId, input.stage, input.projectRoot, input.workId);
|
|
843
|
+
const eventKey = input.eventKey ?? findRecentMatchingHookEvent(context, {
|
|
844
|
+
projectId: input.projectId, matchKey, errorCode: "trusted_session_binding_required", operation: input.operation.replace("_", " ")
|
|
845
|
+
}).eventKey;
|
|
846
|
+
const event = context.db.get("SELECT id, session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, eventKey]);
|
|
847
|
+
if (!event?.session_id || event.status !== "observed" || event.match_key !== matchKey) {
|
|
848
|
+
throw new AppError("trusted_session_binding_required", `${input.operation.replace("_", " ")} requires one fresh matching PreToolUse hook event`, 1, { run_id: input.runId, stage: input.stage, event_key: eventKey });
|
|
849
|
+
}
|
|
850
|
+
const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
|
|
851
|
+
if (claimed.changes !== 1)
|
|
852
|
+
throw new AppError("trusted_session_binding_required", "matching lifecycle hook event was already claimed", 1, { run_id: input.runId, stage: input.stage });
|
|
853
|
+
return hookSessionIdentity(context, input.projectId, eventKey);
|
|
854
|
+
}
|
|
764
855
|
/** Trusted session identity is created by PreToolUse, never supplied by an agent. */
|
|
765
856
|
export function sessionIdForHookEvent(context, projectId, eventKey) {
|
|
766
857
|
const event = context.db.get("SELECT session_id FROM hook_events WHERE project_id = ? AND event_key = ?", [projectId, eventKey]);
|
|
@@ -175,10 +175,18 @@ function lifecycleOperation(argv) {
|
|
|
175
175
|
return "session_register";
|
|
176
176
|
if (key === "stage start")
|
|
177
177
|
return "stage_start";
|
|
178
|
+
if (key === "stage finish")
|
|
179
|
+
return "stage_finish";
|
|
180
|
+
if (key === "stage pause")
|
|
181
|
+
return "stage_pause";
|
|
178
182
|
if (key === "stage resume")
|
|
179
183
|
return "stage_resume";
|
|
180
184
|
if (key === "work start")
|
|
181
185
|
return "work_start";
|
|
186
|
+
if (key === "work finish")
|
|
187
|
+
return "work_finish";
|
|
188
|
+
if (key === "work fail")
|
|
189
|
+
return "work_fail";
|
|
182
190
|
return null;
|
|
183
191
|
}
|
|
184
192
|
export function parseCommandArgs(argv) {
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { AppError } from "../shared/errors.js";
|
|
5
|
+
import { resolveProjectRoot } from "../storage/paths.js";
|
|
6
|
+
import { requireProjectByRoot } from "./projects.js";
|
|
7
|
+
import { appendFlowRunTimelineEvent } from "./runs.js";
|
|
8
|
+
function requireRun(context, projectId, runId) {
|
|
9
|
+
const run = context.db.get("SELECT r.id, r.run_root, r.workspace_root, p.root AS project_root FROM runs r JOIN projects p ON p.id = r.project_id WHERE r.project_id = ? AND r.id = ?", [projectId, runId]);
|
|
10
|
+
if (!run?.run_root)
|
|
11
|
+
throw new AppError("runtime_missing", "RUN artifact root is unavailable", 1, { run_id: runId });
|
|
12
|
+
return run;
|
|
13
|
+
}
|
|
14
|
+
function parseObject(value, label) { try {
|
|
15
|
+
const parsed = JSON.parse(value);
|
|
16
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
|
|
17
|
+
return parsed;
|
|
18
|
+
}
|
|
19
|
+
catch { /* validated below */ } throw new AppError("validation", `${label} must be a JSON object`, 2); }
|
|
20
|
+
export function recoveryGuard(context, projectId, runId) { return context.db.get("SELECT recovery_id, project_id, run_id, generation, status, interruption_json, settlement_json, capture_path, created_at, updated_at, sealed_at, resumed_at FROM run_recovery_guards WHERE project_id = ? AND run_id = ? ORDER BY generation DESC LIMIT 1", [projectId, runId]) ?? null; }
|
|
21
|
+
export function assertRunMutationAllowed(context, projectId, runId) { const guard = recoveryGuard(context, projectId, runId); if (guard && ["draining", "sealed", "resuming"].includes(guard.status))
|
|
22
|
+
throw new AppError("run_recovery_guarded", "RUN is retained for recovery; reconcile or resume its recovery operation before accepting a new lifecycle mutation", 1, { run_id: runId, recovery_id: guard.recovery_id, generation: guard.generation, status: guard.status }); }
|
|
23
|
+
export function beginRunRecovery(context, input) {
|
|
24
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
25
|
+
const run = requireRun(context, project.id, input.runId);
|
|
26
|
+
const interruption = parseObject(input.interruption, "interruption");
|
|
27
|
+
const prior = recoveryGuard(context, project.id, run.id);
|
|
28
|
+
if (prior && ["draining", "sealed", "resuming"].includes(prior.status))
|
|
29
|
+
return { ok: true, recovery: { ...prior, interruption: JSON.parse(prior.interruption_json) }, reused: true };
|
|
30
|
+
const recoveryId = `RCV-${crypto.randomUUID()}`, generation = (prior?.generation ?? 0) + 1, now = context.now();
|
|
31
|
+
context.db.run("INSERT INTO run_recovery_guards (recovery_id, project_id, run_id, generation, status, interruption_json, settlement_json, capture_path, created_at, updated_at, sealed_at, resumed_at) VALUES (?, ?, ?, ?, 'draining', ?, NULL, NULL, ?, ?, NULL, NULL)", [recoveryId, project.id, run.id, generation, JSON.stringify(interruption), now, now]);
|
|
32
|
+
appendFlowRunTimelineEvent(context, project.id, run.id, { type: "run_recovery_draining", recovery_id: recoveryId, generation, interruption });
|
|
33
|
+
return { ok: true, recovery: { recovery_id: recoveryId, run_id: run.id, generation, status: "draining", interruption } };
|
|
34
|
+
}
|
|
35
|
+
export function sealRunRecovery(context, input) {
|
|
36
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
37
|
+
const run = requireRun(context, project.id, input.runId);
|
|
38
|
+
const guard = recoveryGuard(context, project.id, run.id);
|
|
39
|
+
if (!guard || guard.recovery_id !== input.recoveryId)
|
|
40
|
+
throw new AppError("recovery_not_found", "Recovery operation is not registered for this RUN", 1, { recovery_id: input.recoveryId, run_id: run.id });
|
|
41
|
+
const settlement = parseObject(input.settlement, "settlement");
|
|
42
|
+
if (settlement.settled !== true)
|
|
43
|
+
throw new AppError("recovery_not_settled", "Recovery capture requires an explicit settled tree receipt", 1, { recovery_id: guard.recovery_id });
|
|
44
|
+
if (guard.status === "sealed")
|
|
45
|
+
return { ok: true, recovery: { ...guard, settlement: JSON.parse(guard.settlement_json ?? "{}") }, reused: true };
|
|
46
|
+
if (guard.status !== "draining")
|
|
47
|
+
throw new AppError("recovery_invalid_state", "Recovery is not draining", 1, { recovery_id: guard.recovery_id, status: guard.status });
|
|
48
|
+
const now = context.now();
|
|
49
|
+
context.db.run("UPDATE run_recovery_guards SET status = 'sealed', settlement_json = ?, sealed_at = ?, updated_at = ? WHERE recovery_id = ? AND status = 'draining'", [JSON.stringify(settlement), now, now, guard.recovery_id]);
|
|
50
|
+
appendFlowRunTimelineEvent(context, project.id, run.id, { type: "run_recovery_sealed", recovery_id: guard.recovery_id, generation: guard.generation, settlement });
|
|
51
|
+
return { ok: true, recovery: { recovery_id: guard.recovery_id, run_id: run.id, generation: guard.generation, status: "sealed", settlement } };
|
|
52
|
+
}
|
|
53
|
+
export function recordRecoveryCapture(context, input) {
|
|
54
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
55
|
+
const run = requireRun(context, project.id, input.runId);
|
|
56
|
+
const guard = recoveryGuard(context, project.id, run.id);
|
|
57
|
+
if (!guard || guard.recovery_id !== input.recoveryId || guard.status !== "sealed")
|
|
58
|
+
throw new AppError("recovery_invalid_state", "Recovery capture requires a sealed recovery operation", 1, { recovery_id: input.recoveryId, run_id: run.id });
|
|
59
|
+
const capture = path.resolve(input.capturePath);
|
|
60
|
+
if (!fs.existsSync(capture))
|
|
61
|
+
throw new AppError("recovery_capture_missing", "Recovery capture path is unavailable", 1, { capture });
|
|
62
|
+
context.db.run("UPDATE run_recovery_guards SET capture_path = ?, updated_at = ? WHERE recovery_id = ?", [capture, context.now(), guard.recovery_id]);
|
|
63
|
+
appendFlowRunTimelineEvent(context, project.id, run.id, { type: "run_recovery_capture_ready", recovery_id: guard.recovery_id, capture_path: capture });
|
|
64
|
+
return { ok: true, recovery_id: guard.recovery_id, capture_path: capture };
|
|
65
|
+
}
|
|
66
|
+
export function resumeRunRecovery(context, input) {
|
|
67
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
68
|
+
const run = requireRun(context, project.id, input.runId);
|
|
69
|
+
const guard = recoveryGuard(context, project.id, run.id);
|
|
70
|
+
if (!guard || guard.recovery_id !== input.recoveryId)
|
|
71
|
+
throw new AppError("recovery_not_found", "Recovery operation is not registered for this RUN", 1, { recovery_id: input.recoveryId, run_id: run.id });
|
|
72
|
+
if (guard.status === "recovered")
|
|
73
|
+
return { ok: true, recovery_id: guard.recovery_id, generation: guard.generation, resumed: true, reused: true };
|
|
74
|
+
if (guard.status !== "sealed" || !guard.capture_path)
|
|
75
|
+
throw new AppError("recovery_capture_required", "Recovery must be sealed and captured before it can resume", 1, { recovery_id: guard.recovery_id, status: guard.status });
|
|
76
|
+
// The root coordinator keeps its same native Session and receives a recovery
|
|
77
|
+
// prompt from the eval runner. Only delegated Work is returned to the ready
|
|
78
|
+
// graph and therefore gets a fresh, addressable recovery packet.
|
|
79
|
+
const now = context.now(), running = context.db.all("SELECT work_id, started_at, status FROM works WHERE project_id = ? AND run_id = ? AND status = 'running' AND parent_work_id IS NOT NULL ORDER BY work_id", [project.id, run.id]);
|
|
80
|
+
context.db.exec("BEGIN IMMEDIATE");
|
|
81
|
+
try {
|
|
82
|
+
for (const work of running) {
|
|
83
|
+
const evidence = path.join(run.run_root, "recoveries", guard.recovery_id, "works", work.work_id), source = path.join(run.run_root, "works", work.work_id);
|
|
84
|
+
fs.mkdirSync(path.dirname(evidence), { recursive: true });
|
|
85
|
+
if (fs.existsSync(source) && !fs.existsSync(evidence))
|
|
86
|
+
fs.cpSync(source, evidence, { recursive: true, verbatimSymlinks: true });
|
|
87
|
+
context.db.run("INSERT INTO work_recovery_segments (recovery_id, work_id, generation, prior_started_at, evidence_path, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'prepared', ?, ?)", [guard.recovery_id, work.work_id, guard.generation, work.started_at, evidence, now, now]);
|
|
88
|
+
context.db.run("UPDATE work_sessions SET status = 'interrupted', completed_at = ?, updated_at = ? WHERE work_id = ? AND status = 'running'", [now, now, work.work_id]);
|
|
89
|
+
context.db.run("UPDATE works SET status = 'created', result = NULL, started_at = NULL, completed_at = NULL, updated_at = ? WHERE work_id = ? AND status = 'running'", [now, work.work_id]);
|
|
90
|
+
}
|
|
91
|
+
context.db.run("UPDATE run_recovery_guards SET status = 'recovered', resumed_at = ?, updated_at = ? WHERE recovery_id = ? AND status = 'sealed'", [now, now, guard.recovery_id]);
|
|
92
|
+
context.db.exec("COMMIT");
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
context.db.exec("ROLLBACK");
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
appendFlowRunTimelineEvent(context, project.id, run.id, { type: "run_recovery_resumed", recovery_id: guard.recovery_id, generation: guard.generation, interrupted_works: running.map((work) => work.work_id) });
|
|
99
|
+
return { ok: true, recovery_id: guard.recovery_id, generation: guard.generation, resumed: true, interrupted_works: running.map((work) => work.work_id) };
|
|
100
|
+
}
|
|
101
|
+
export function recoveryPromptForWork(context, workId) { const row = context.db.get("SELECT recovery_id, generation, evidence_path FROM work_recovery_segments WHERE work_id = ? AND status = 'prepared' ORDER BY generation DESC LIMIT 1", [workId]); return row ? { recoveryId: row.recovery_id, generation: row.generation, evidencePath: row.evidence_path } : null; }
|
|
102
|
+
export function activateRecoveryWork(context, workId) { context.db.run("UPDATE work_recovery_segments SET status = 'running', updated_at = ? WHERE work_id = ? AND status = 'prepared'", [context.now(), workId]); }
|
|
103
|
+
export function settleRecoveryWork(context, workId, status) { context.db.run("UPDATE work_recovery_segments SET status = ?, updated_at = ? WHERE work_id = ? AND status = 'running'", [status, context.now(), workId]); }
|
|
104
|
+
export function inspectRunRecovery(context, input) { const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot)); const run = requireRun(context, project.id, input.runId); const guard = recoveryGuard(context, project.id, run.id); const works = context.db.all("SELECT w.work_id, w.status, s.recovery_id, s.status AS segment_status FROM works w LEFT JOIN work_recovery_segments s ON s.work_id = w.work_id AND s.generation = (SELECT MAX(generation) FROM work_recovery_segments latest WHERE latest.work_id = w.work_id) WHERE w.project_id = ? AND w.run_id = ? ORDER BY w.work_id", [project.id, run.id]); return { ok: true, run_id: run.id, recovery: guard ? { ...guard, interruption: JSON.parse(guard.interruption_json), settlement: guard.settlement_json ? JSON.parse(guard.settlement_json) : null } : null, works }; }
|
|
@@ -18,6 +18,7 @@ import { registerProject } from "./projects.js";
|
|
|
18
18
|
import { registerProtocol } from "./protocols.js";
|
|
19
19
|
import { previewNextEntityId } from "./ids.js";
|
|
20
20
|
import { hookSessionIdentity, sessionIdForHookEvent } from "./hooks.js";
|
|
21
|
+
import { assertRunMutationAllowed } from "./run-recovery.js";
|
|
21
22
|
const stagePromptSections = [
|
|
22
23
|
"stage_identity",
|
|
23
24
|
"authoritative_runtime_facts",
|
|
@@ -119,6 +120,7 @@ export function startStage(context, input) {
|
|
|
119
120
|
if (!runId)
|
|
120
121
|
throw new AppError("usage", "stage start requires a RUN id or --bootstrap", 2);
|
|
121
122
|
const before = runView(getFlowRunStatus(context, { projectRoot, runId }));
|
|
123
|
+
assertRunMutationAllowed(context, before.run.project_id, before.run.id);
|
|
122
124
|
const dir = input.dir ?? defaultStageDir(input.stage, before.run.flow_kind);
|
|
123
125
|
const attached = runView(attachFlowRunStage(context, {
|
|
124
126
|
projectRoot,
|
|
@@ -263,6 +265,7 @@ export function finishStage(context, input) {
|
|
|
263
265
|
throw new AppError("usage", "stage finish requires a RUN id", 2);
|
|
264
266
|
const runId = input.runId;
|
|
265
267
|
const view = runView(getFlowRunStatus(context, { projectRoot, runId }));
|
|
268
|
+
assertRunMutationAllowed(context, view.run.project_id, view.run.id);
|
|
266
269
|
const existing = view.index.stage_runs?.find((stage) => stage.stage === input.stage);
|
|
267
270
|
if (!existing) {
|
|
268
271
|
throw new AppError("not_found", `Run stage is not attached: ${input.stage}`, 1, { run_id: view.run.id, stage: input.stage });
|
package/dist/services/usage.js
CHANGED
|
@@ -19,6 +19,14 @@ export function ingestOpenCodeUsage(context, input) {
|
|
|
19
19
|
export function ingestAgyUsage(context, input) {
|
|
20
20
|
return ingestHarnessUsage(context, input, { harness: "antigravity-cli", label: "Antigravity", idPrefix: "AUSG", sourceKind: "antigravity_cli_session_usage_v1" });
|
|
21
21
|
}
|
|
22
|
+
/** Droid counters must describe one physical Session; inclusive roots double-count children. */
|
|
23
|
+
export function ingestDroidUsage(context, input) {
|
|
24
|
+
const payload = parseJsonObject(input.stdin || "{}", "Droid usage snapshot");
|
|
25
|
+
if (payload.usage_scope !== "physical_session" || !["complete", "partial"].includes(String(payload.completeness))) {
|
|
26
|
+
throw new Error("Droid usage requires physical_session scope and complete or partial completeness");
|
|
27
|
+
}
|
|
28
|
+
return ingestHarnessUsage(context, input, { harness: "droid-cli", label: "Droid", idPrefix: "DUSG", sourceKind: "droid_session_usage_v1" });
|
|
29
|
+
}
|
|
22
30
|
function ingestHarnessUsage(context, input, harness) {
|
|
23
31
|
const payload = parseJsonObject(input.stdin || "{}", `${harness.label} usage snapshot`);
|
|
24
32
|
const usage = object(payload.usage);
|
|
@@ -262,9 +270,9 @@ function toolCallsForLinks(context, links, observedAt) {
|
|
|
262
270
|
result.status = result.observed_sessions === 0 ? "unavailable" : result.unavailable_sessions.length ? "partial" : "measured";
|
|
263
271
|
return result;
|
|
264
272
|
}
|
|
265
|
-
function isExternalHarness(harness) { return harness === "zcode-acp" || harness === "grok-acp" || harness === "opencode-server" || harness === "antigravity-cli"; }
|
|
273
|
+
function isExternalHarness(harness) { return harness === "droid-cli" || harness === "zcode-acp" || harness === "grok-acp" || harness === "opencode-server" || harness === "antigravity-cli"; }
|
|
266
274
|
function harnessSourceKind(harness) {
|
|
267
|
-
return harness === "grok-acp" ? "grok_session_usage_v1" : harness === "zcode-acp" ? "zcode_session_usage_v1" : harness === "antigravity-cli" ? "antigravity_cli_session_usage_v1" : "opencode_session_usage_v1";
|
|
275
|
+
return harness === "droid-cli" ? "droid_session_usage_v1" : harness === "grok-acp" ? "grok_session_usage_v1" : harness === "zcode-acp" ? "zcode_session_usage_v1" : harness === "antigravity-cli" ? "antigravity_cli_session_usage_v1" : "opencode_session_usage_v1";
|
|
268
276
|
}
|
|
269
277
|
function readHarnessToolCallsWindow(context, harness, projectId, providerSessionId, startedAt, endedAt, observedAt) {
|
|
270
278
|
if (!providerSessionId)
|
|
@@ -425,7 +433,7 @@ function hasInclusiveUsageSnapshot(context, projectId, harness, providerSessionI
|
|
|
425
433
|
function readHarnessUsageWindow(context, harness, projectId, providerSessionId, startedAt, endedAt, observedAt) {
|
|
426
434
|
if (!providerSessionId)
|
|
427
435
|
return { status: "not_observable", diagnostic: "provider_session_id_missing" };
|
|
428
|
-
const snapshots = context.db.all(`SELECT observed_at, total_tokens, input_tokens, cache_read_input_tokens, cache_write_input_tokens, output_tokens, reasoning_output_tokens
|
|
436
|
+
const snapshots = context.db.all(`SELECT completeness, observed_at, total_tokens, input_tokens, cache_read_input_tokens, cache_write_input_tokens, output_tokens, reasoning_output_tokens
|
|
429
437
|
FROM harness_usage_snapshots
|
|
430
438
|
WHERE project_id = ? AND harness = ? AND provider_session_id = ? AND observed_at <= ?
|
|
431
439
|
ORDER BY observed_at, id`, [projectId, harness, providerSessionId, observedAt]);
|
|
@@ -434,6 +442,8 @@ function readHarnessUsageWindow(context, harness, projectId, providerSessionId,
|
|
|
434
442
|
const before = snapshots.filter((snapshot) => Date.parse(snapshot.observed_at) <= Date.parse(startedAt)).at(-1);
|
|
435
443
|
if (!last || !before || last === before)
|
|
436
444
|
return { status: "not_yet_emitted", diagnostic: `${harness}_usage_window_incomplete` };
|
|
445
|
+
if (harness === "droid-cli" && (last.completeness !== "complete" || before.completeness !== "complete"))
|
|
446
|
+
return { status: "not_observable", diagnostic: "droid-cli_usage_window_partial" };
|
|
437
447
|
const delta = (value, baseline) => value === null || baseline === null ? null : Math.max(0, value - baseline);
|
|
438
448
|
const input = delta(last.input_tokens, before.input_tokens);
|
|
439
449
|
const cacheRead = delta(last.cache_read_input_tokens, before.cache_read_input_tokens);
|
|
@@ -135,3 +135,43 @@ export async function reconcileVnextFanout(context, input) {
|
|
|
135
135
|
appendFlowRunTimelineEvent(context, project.id, input.runId, { type: "native_child_reconciliation", stage: input.stage, observations: value, issues, reconciled });
|
|
136
136
|
return { ok: true, issues, reconciled };
|
|
137
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* Records every terminal native child at the Stage where it is first observed.
|
|
140
|
+
* This is separate from fan-out settlement: a historical child can be valid
|
|
141
|
+
* evidence for an earlier Work, while an unbound terminal child is retained
|
|
142
|
+
* as an inconsistency instead of being rediscovered in a later review wave.
|
|
143
|
+
*/
|
|
144
|
+
export function observeVnextNativeChildren(context, input) {
|
|
145
|
+
const value = input.observations;
|
|
146
|
+
if (!value || typeof value.harness_id !== "string" || !value.harness_id || typeof value.parent_session_id !== "string" || !Array.isArray(value.children)
|
|
147
|
+
|| value.children.some(child => !child || typeof child.session_id !== "string" || !["running", "unknown", "completed", "failed", "cancelled"].includes(String(child.status))))
|
|
148
|
+
throw new AppError("usage", "Expected native observations: harness_id, parent_session_id and children[{session_id,status}]", 2);
|
|
149
|
+
if (new Set(value.children.map(child => child.session_id)).size !== value.children.length)
|
|
150
|
+
throw new AppError("usage", "Native observations must contain each child Session exactly once", 2);
|
|
151
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
152
|
+
const parent = storageSessionId({ harness_id: value.harness_id, session_id: value.parent_session_id });
|
|
153
|
+
const issues = [], history = [], pending = [];
|
|
154
|
+
for (const child of value.children) {
|
|
155
|
+
const sessionId = storageSessionId({ harness_id: value.harness_id, session_id: String(child.session_id) });
|
|
156
|
+
const links = context.db.all(`SELECT ws.id, ws.work_id, ws.status, w.status AS work_status, w.parent_work_id, s.current_stage
|
|
157
|
+
FROM work_sessions ws JOIN works w ON w.work_id = ws.work_id
|
|
158
|
+
JOIN sessions s ON s.project_id = w.project_id AND s.session_id = ws.session_id
|
|
159
|
+
WHERE w.project_id = ? AND w.run_id = ? AND ws.session_id = ? AND s.parent_session_id = ? ORDER BY ws.created_at`, [project.id, input.runId, sessionId, parent]);
|
|
160
|
+
if (child.status === "running" || child.status === "unknown") {
|
|
161
|
+
pending.push({ session_id: child.session_id, outcome: child.status, links: links.map(link => ({ work_id: link.work_id, work_session_id: link.id })) });
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (!links.length) {
|
|
165
|
+
issues.push({ code: "unbound_native_delegation_detected", session_id: child.session_id, outcome: child.status, detected_at_stage: input.stage, origin_stage: "unknown" });
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (links.length !== 1) {
|
|
169
|
+
issues.push({ code: "child_execution_ambiguous", session_id: child.session_id, outcome: child.status, detected_at_stage: input.stage, work_ids: links.map(link => link.work_id) });
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const link = links[0];
|
|
173
|
+
history.push({ session_id: child.session_id, outcome: child.status, work_id: link.work_id, work_session_id: link.id, work_status: link.work_status, origin_stage: link.current_stage ?? "unknown", detected_at_stage: input.stage });
|
|
174
|
+
}
|
|
175
|
+
appendFlowRunTimelineEvent(context, project.id, input.runId, { type: "native_child_observation", stage: input.stage, observations: value, issues, history, pending });
|
|
176
|
+
return { ok: true, issues, history, pending };
|
|
177
|
+
}
|