@deksden-com/dd-flow-cli 0.9.0-beta.152 → 0.9.0-beta.156
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 +31 -0
- package/dist/build-info.json +8 -8
- package/dist/cli/command-inputs.js +33 -3
- package/dist/cli/command-metadata.js +73 -2
- package/dist/cli/delivery.js +108 -0
- package/dist/cli/diagnostic-invocation.js +23 -7
- package/dist/cli/diagnostic.js +14 -3
- package/dist/cli/help.js +117 -1
- package/dist/cli/hook-ingress.js +36 -49
- package/dist/cli/interactive-invocation.js +33 -0
- package/dist/cli/interactive.js +114 -0
- package/dist/cli/memory-fix-invocation.js +52 -0
- package/dist/cli/memory-fix.js +100 -0
- package/dist/cli/memory-flow-admission.js +7 -3
- package/dist/cli/memory-init.js +5 -4
- package/dist/cli/native-hook-ingress.js +5 -1
- package/dist/cli/native-hook-preparation.js +31 -4
- package/dist/cli/run-cli.js +274 -19
- package/dist/cli.js +17 -3
- package/dist/domain/delivery-flows.js +8 -0
- package/dist/domain/distill-flow.js +20 -0
- package/dist/domain/interactive-flow.js +1 -0
- package/dist/domain/memory-fix-flow.js +2 -0
- package/dist/domain/review-fix-flow.js +2 -0
- package/dist/domain/source-snapshot-flows.js +4 -0
- package/dist/harness-runtime/bin/dd-agy.mjs +18 -3
- package/dist/harness-runtime/bin/dd-grok.mjs +15 -5
- package/dist/harness-runtime/bin/dd-zcode.mjs +15 -7
- package/dist/harness-runtime/lib/agy-error-boundary.mjs +18 -10
- package/dist/harness-runtime/lib/agy-native-inspection.mjs +12 -3
- package/dist/harness-runtime/lib/agy-native-inventory.mjs +19 -0
- package/dist/harness-runtime/lib/agy-terminal-envelope.mjs +13 -6
- package/dist/harness-runtime/lib/agy-transcript-stream.mjs +54 -0
- package/dist/harness-runtime/lib/daemon-observation.d.mts +1 -0
- package/dist/harness-runtime/lib/daemon-observation.mjs +12 -4
- package/dist/harness-runtime/lib/daemon-operations.mjs +20 -5
- package/dist/harness-runtime/lib/dd-agy-daemon.d.mts +1 -0
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +371 -114
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +43 -3
- package/dist/harness-runtime/lib/dd-codex.mjs +36 -4
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +25 -11
- package/dist/harness-runtime/lib/dd-droid.mjs +124 -11
- package/dist/harness-runtime/lib/dd-grok-daemon.d.mts +1 -0
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +137 -25
- package/dist/harness-runtime/lib/dd-grok.mjs +4 -2
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +115 -17
- package/dist/harness-runtime/lib/dd-opencode.mjs +10 -7
- package/dist/harness-runtime/lib/dd-zcode-daemon.d.mts +1 -0
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +115 -22
- package/dist/harness-runtime/lib/dd-zcode.mjs +75 -36
- package/dist/harness-runtime/lib/delegation-instructions.d.mts +2 -1
- package/dist/harness-runtime/lib/delegation-instructions.mjs +15 -5
- package/dist/harness-runtime/lib/delivery-command.mjs +84 -0
- package/dist/harness-runtime/lib/driver-recovery.mjs +0 -19
- package/dist/harness-runtime/lib/droid-observation.mjs +9 -5
- package/dist/harness-runtime/lib/lifecycle-contract.d.mts +1 -0
- package/dist/harness-runtime/lib/lifecycle-contract.mjs +2 -1
- package/dist/harness-runtime/lib/native-hook-command.d.mts +3 -2
- package/dist/harness-runtime/lib/native-hook-command.mjs +63 -8
- package/dist/harness-runtime/lib/native-streams.mjs +3 -10
- package/dist/harness-runtime/lib/opencode-plugin.mjs +36 -9
- package/dist/harness-runtime/lib/operation-errors.mjs +13 -0
- package/dist/harness-runtime/lib/operation-progress.mjs +6 -2
- package/dist/harness-runtime/lib/process-json.mjs +4 -5
- package/dist/harness-runtime/lib/tool-observations.mjs +20 -10
- package/dist/harness-runtime/lib/zcode-native-hook-ownership.mjs +109 -0
- package/dist/schemas/code-work-result.schema.json +2 -1
- package/dist/schemas/delivery-stage-result.schema.json +286 -0
- package/dist/schemas/distill-stage-result.schema.json +667 -0
- package/dist/schemas/distill-work-result.schema.json +428 -0
- package/dist/schemas/execution-bundle.schema.json +6 -2
- package/dist/schemas/interactive-stage-result.schema.json +47 -0
- package/dist/schemas/interactive-work-result.schema.json +63 -0
- package/dist/schemas/mb-fix-stage-result.schema.json +93 -0
- package/dist/schemas/project-flow-pack-manifest.schema.json +2 -2
- package/dist/schemas/review-fix-stage-result.schema.json +210 -0
- package/dist/schemas/run-control-receipt.schema.json +1 -0
- package/dist/services/assigned-lifecycle.js +382 -0
- package/dist/services/check-runner-evidence.js +45 -8
- package/dist/services/cli-operation-classifier.js +6 -1
- package/dist/services/code-checks.js +46 -29
- package/dist/services/codex-hook-delivery.js +11 -2
- package/dist/services/controller-child-repair.js +95 -0
- package/dist/services/controller-execution.js +4 -1
- package/dist/services/controller-fanout.js +19 -9
- package/dist/services/controller-memory-worker.js +6 -4
- package/dist/services/controller-result-authority.js +122 -0
- package/dist/services/dashboard.js +4 -2
- package/dist/services/delivery-access.js +123 -0
- package/dist/services/delivery-approval.js +68 -0
- package/dist/services/delivery-artifact.js +86 -0
- package/dist/services/delivery-checks.js +178 -0
- package/dist/services/delivery-execution.js +181 -0
- package/dist/services/delivery-intents.js +212 -0
- package/dist/services/delivery-operation.js +81 -0
- package/dist/services/delivery-policy.js +91 -0
- package/dist/services/delivery-procedures.js +43 -0
- package/dist/services/delivery-request.js +99 -0
- package/dist/services/delivery-result.js +144 -0
- package/dist/services/delivery-stage-commands.js +26 -0
- package/dist/services/delivery-stage-entry.js +56 -0
- package/dist/services/delivery-stage-handlers.js +135 -0
- package/dist/services/delivery-stage-results.js +94 -0
- package/dist/services/descriptor-coordinator-session.js +3 -1
- package/dist/services/descriptor-run-start.js +24 -4
- package/dist/services/descriptor-stage-coordinator.js +78 -10
- package/dist/services/descriptor-stage-settlement.js +59 -14
- package/dist/services/diagnostic-repair-intake.js +123 -0
- package/dist/services/diagnostic-repair-source-intake.js +102 -0
- package/dist/services/diagnostic-report.js +30 -0
- package/dist/services/diagnostic-run-bundle.js +48 -9
- package/dist/services/diagnostic-snapshot.js +2 -2
- package/dist/services/diagnostic-stage-handlers.js +3 -2
- package/dist/services/diagnostic-stage-results.js +26 -0
- package/dist/services/diagnostic-start.js +56 -6
- package/dist/services/diagnostic-work-collection.js +3 -3
- package/dist/services/distill-document-publication.js +280 -0
- package/dist/services/distill-promotion.js +197 -0
- package/dist/services/distill-publication-custody.js +162 -0
- package/dist/services/distill-stage-handlers.js +249 -0
- package/dist/services/distill-stage-results.js +105 -0
- package/dist/services/distill-user-inputs.js +4 -0
- package/dist/services/distill-work-batch.js +42 -0
- package/dist/services/distill-work-collection.js +74 -0
- package/dist/services/distill-work-registration.js +93 -0
- package/dist/services/distill-work-results.js +98 -0
- package/dist/services/eval-snapshots.js +17 -7
- package/dist/services/external-work-launch.js +8 -2
- package/dist/services/flow-catalog.js +24 -0
- package/dist/services/frozen-engine.js +19 -2
- package/dist/services/generic-execution.js +4 -3
- package/dist/services/harness-adapter.js +2 -14
- package/dist/services/hook-memory-routing.js +1 -1
- package/dist/services/hooks.js +31 -6
- package/dist/services/hybrid-contracts.js +6 -1
- package/dist/services/installed-flow-source.js +37 -2
- package/dist/services/interactive-attached-session.js +215 -0
- package/dist/services/interactive-baseline-custody.js +54 -0
- package/dist/services/interactive-before-versions.js +148 -0
- package/dist/services/interactive-bundle-data.js +85 -0
- package/dist/services/interactive-checks.js +179 -0
- package/dist/services/interactive-execution.js +33 -0
- package/dist/services/interactive-finish-integration.js +236 -0
- package/dist/services/interactive-git-baseline.js +230 -0
- package/dist/services/interactive-git-dispatch.js +278 -0
- package/dist/services/interactive-input-delivery.js +82 -0
- package/dist/services/interactive-mutation-scope.js +231 -0
- package/dist/services/interactive-request.js +287 -0
- package/dist/services/interactive-run-bundle.js +109 -0
- package/dist/services/interactive-stage-entry.js +41 -0
- package/dist/services/interactive-stage-handlers.js +289 -0
- package/dist/services/interactive-stage-observation.js +136 -0
- package/dist/services/interactive-stage-results.js +111 -0
- package/dist/services/interactive-user-inputs.js +142 -0
- package/dist/services/interactive-work-batch.js +43 -0
- package/dist/services/interactive-work-collection.js +119 -0
- package/dist/services/interactive-work-completion.js +180 -0
- package/dist/services/interactive-work-context.js +249 -0
- package/dist/services/interactive-work-history.js +112 -0
- package/dist/services/interactive-work-registration.js +124 -0
- package/dist/services/interactive-work-results.js +115 -0
- package/dist/services/interactive-work-start.js +155 -0
- package/dist/services/lifecycle-invocations.js +250 -33
- package/dist/services/managed-daemon-binding.js +52 -1
- package/dist/services/memory-compose-handler.js +2 -22
- package/dist/services/memory-finish-integration.js +58 -17
- package/dist/services/memory-fix-finish-integration.js +129 -0
- package/dist/services/memory-fix-git-dispatch.js +139 -0
- package/dist/services/memory-fix-intake-custody.js +11 -0
- package/dist/services/memory-fix-intake-settlement.js +127 -0
- package/dist/services/memory-fix-publication.js +196 -0
- package/dist/services/memory-fix-repair-admission.js +116 -0
- package/dist/services/memory-fix-request.js +141 -0
- package/dist/services/memory-fix-run-bundle.js +83 -0
- package/dist/services/memory-fix-scope.js +78 -0
- package/dist/services/memory-fix-sources.js +85 -0
- package/dist/services/memory-fix-stage-entry.js +24 -0
- package/dist/services/memory-fix-stage-handlers.js +282 -0
- package/dist/services/memory-fix-stage-results.js +106 -0
- package/dist/services/memory-fix-workspace.js +127 -0
- package/dist/services/memory-flow-user-inputs.js +88 -0
- package/dist/services/memory-init-contexts.js +55 -5
- package/dist/services/memory-init-controller-start.js +20 -4
- package/dist/services/memory-layer.js +7 -3
- package/dist/services/memory-native-review-copy.js +27 -3
- package/dist/services/memory-repair-document-delta.js +1 -0
- package/dist/services/project-flow-source.js +195 -0
- package/dist/services/project-git-integration.js +92 -0
- package/dist/services/repair-intents.js +2 -7
- package/dist/services/retained-native-hook.js +274 -0
- package/dist/services/review-fix-handoff.js +82 -0
- package/dist/services/review-fix-intake-custody.js +17 -0
- package/dist/services/review-fix-stage-entry.js +43 -0
- package/dist/services/review-fix-stage-handlers.js +176 -0
- package/dist/services/review-fix-stage-results.js +86 -0
- package/dist/services/run-control-worker.js +42 -1
- package/dist/services/run-control.js +40 -5
- package/dist/services/run-controller-adapter.js +37 -3
- package/dist/services/run-controller-process.js +25 -4
- package/dist/services/run-controller-recovery.js +46 -6
- package/dist/services/run-controller-state.js +3 -2
- package/dist/services/run-controller.js +257 -44
- package/dist/services/run-recovery.js +31 -2
- package/dist/services/run-timeline.js +59 -0
- package/dist/services/runs.js +18 -31
- package/dist/services/sessions.js +2 -1
- package/dist/services/stage-handlers.js +55 -3
- package/dist/services/stage-lifecycle.js +4 -3
- package/dist/services/stage-pause.js +26 -9
- package/dist/services/stage-report-renderer.js +2 -6
- package/dist/services/technical-notes.js +31 -0
- package/dist/services/verification-matrix.js +1 -1
- package/dist/services/version-status.js +1 -1
- package/dist/services/vnext-code-review.js +74 -5
- package/dist/services/vnext-code.js +8 -32
- package/dist/services/vnext-fanout.js +53 -8
- package/dist/services/vnext-merge.js +10 -5
- package/dist/services/vnext-plan-review.js +1 -1
- package/dist/services/vnext-plan.js +1 -1
- package/dist/services/vnext-protocolize.js +1 -1
- package/dist/services/vnext-specify.js +10 -5
- package/dist/services/work-registry.js +255 -69
- package/dist/services/worktrees.js +7 -4
- package/dist/storage/database.js +2 -1
- package/dist/verification-components.json +82 -0
- package/package.json +3 -2
- package/tools/verification-jobs.mjs +35 -10
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { AppError } from "../shared/errors.js";
|
|
3
|
+
import { hybridDigest } from "./hybrid-contracts.js";
|
|
4
|
+
import { readFlowRunExecutionBundle, assertPreparedRunBundleSqlAuthority } from "./runs.js";
|
|
5
|
+
import { readRegisteredInteractiveWork } from "./interactive-work-registration.js";
|
|
6
|
+
import { PreparedInteractiveWorkspaceChanges } from "./interactive-mutation-scope.js";
|
|
7
|
+
import { PreparedInteractiveGitBaseline } from "./interactive-git-baseline.js";
|
|
8
|
+
import { readDescriptorStageInput } from "./descriptor-stage-coordinator.js";
|
|
9
|
+
import { stageGraphScope } from "./stage-work-graph.js";
|
|
10
|
+
import { assertRunMutationAllowed } from "./run-recovery-guard.js";
|
|
11
|
+
import { readBoundedReleaseFile } from "./release-source.js";
|
|
12
|
+
const event = "interactive.work_start";
|
|
13
|
+
const hash = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
14
|
+
function refuse(reason) {
|
|
15
|
+
throw new AppError("interactive_work_start_invalid", "Interactive Work requires its issued input and actual physical start", 2, { reason, effect: "no_effect" });
|
|
16
|
+
}
|
|
17
|
+
export function readInteractiveWorkGit(bundle) {
|
|
18
|
+
const git = JSON.parse(bundle.blob("runtime/git-policy.json").toString("utf8"));
|
|
19
|
+
if (!git || typeof git.git_executable !== "string" || !/^[a-f0-9]{64}$/.test(git.git_sha256)
|
|
20
|
+
|| hash(readBoundedReleaseFile(git.git_executable, 64 * 1024 * 1024)) !== git.git_sha256)
|
|
21
|
+
refuse("selected_git_changed");
|
|
22
|
+
return { git_executable: git.git_executable, git_sha256: git.git_sha256 };
|
|
23
|
+
}
|
|
24
|
+
/** Historical SQL-only start custody. Never recapture already edited sources. */
|
|
25
|
+
export function readInteractiveWorkStart(context, scope, bundle, workSessionId) {
|
|
26
|
+
const assignment = readRegisteredInteractiveWork(context, scope, bundle, scope.workId);
|
|
27
|
+
const rows = context.db.all("SELECT payload_json FROM audit_events WHERE project_id=? AND event_type=? AND json_extract(payload_json,'$.work_session_id')=? LIMIT 2", [scope.projectId, event, workSessionId]);
|
|
28
|
+
if (rows.length !== 1 || Buffer.byteLength(rows[0].payload_json) > 33 * 1024 * 1024)
|
|
29
|
+
refuse("start_custody_missing");
|
|
30
|
+
const receipt = JSON.parse(rows[0].payload_json);
|
|
31
|
+
if (!receipt || Object.keys(receipt).sort().join() !== "actor,attempt,bundle_sha256,hook_event_id,hook_event_key,packet_sha256,run_id,schema_id,snapshot,snapshot_sha256,stage,work_id,work_session_id"
|
|
32
|
+
|| receipt.schema_id !== "dd-flow/interactive-work-start@1" || receipt.run_id !== scope.runId || receipt.work_id !== scope.workId
|
|
33
|
+
|| receipt.work_session_id !== workSessionId || receipt.stage !== assignment.packet.stage || receipt.attempt !== assignment.receipt.attempt
|
|
34
|
+
|| receipt.packet_sha256 !== assignment.packet.packet_sha256 || receipt.bundle_sha256 !== bundle.sha256
|
|
35
|
+
|| !receipt.actor || Object.keys(receipt.actor).sort().join() !== "harness,provider_session_id,session_id")
|
|
36
|
+
refuse("start_binding_changed");
|
|
37
|
+
const actor = receipt.actor;
|
|
38
|
+
if (!context.db.get("SELECT 1 FROM work_sessions ws JOIN works w ON w.work_id=ws.work_id JOIN hook_events h ON h.project_id=w.project_id AND h.event_key=ws.hook_event_id WHERE w.project_id=? AND w.run_id=? AND w.work_id=? AND ws.id=? AND ws.session_id=? AND ws.hook_event_id=? AND h.id=? AND h.status='claimed' AND h.session_id=ws.session_id AND h.harness=? AND h.provider_session_id=?", [scope.projectId, scope.runId, scope.workId, workSessionId, actor.session_id, receipt.hook_event_key, receipt.hook_event_id, actor.harness, actor.provider_session_id]))
|
|
39
|
+
refuse("physical_start_custody_changed");
|
|
40
|
+
const baseline = PreparedInteractiveGitBaseline.restore(Buffer.from(JSON.stringify(receipt.snapshot)), receipt.snapshot_sha256);
|
|
41
|
+
const start = context.db.get("SELECT start_receipt_json FROM work_sessions WHERE id=? AND work_id=?", [workSessionId, scope.workId]);
|
|
42
|
+
if (!start?.start_receipt_json || Buffer.byteLength(start.start_receipt_json) > 33 * 1024 * 1024)
|
|
43
|
+
refuse("start_packet_custody_missing");
|
|
44
|
+
const packetReceipt = JSON.parse(start.start_receipt_json);
|
|
45
|
+
if (typeof packetReceipt.context !== "string" || hash(Buffer.from(packetReceipt.context)) !== packetReceipt.context_sha256)
|
|
46
|
+
refuse("start_packet_changed");
|
|
47
|
+
const startContext = JSON.parse(packetReceipt.context);
|
|
48
|
+
if (startContext.run_id !== scope.runId || startContext.work_id !== scope.workId
|
|
49
|
+
|| hybridDigest(startContext.interactive_start) !== hybridDigest({ schema_id: "dd-flow/interactive-work-start-binding@1",
|
|
50
|
+
packet_sha256: assignment.packet.packet_sha256, snapshot_sha256: baseline.sha256 }))
|
|
51
|
+
refuse("start_snapshot_binding_changed");
|
|
52
|
+
const frozen = bundle.execution.snapshot().scope;
|
|
53
|
+
if (baseline.snapshot().project_root !== frozen.projectRoot || baseline.snapshot().workspace !== frozen.workspace)
|
|
54
|
+
refuse("physical_start_scope_changed");
|
|
55
|
+
return { receipt, baseline, assignment };
|
|
56
|
+
}
|
|
57
|
+
/** Physical preparation precedes the ordinary hook/Session claim writer.
|
|
58
|
+
* The original Work snapshot is retained with that claim, never after edits. */
|
|
59
|
+
export class PreparedInteractiveWorkStart {
|
|
60
|
+
#scope;
|
|
61
|
+
#bundle;
|
|
62
|
+
#assignment;
|
|
63
|
+
#changes;
|
|
64
|
+
#baseline;
|
|
65
|
+
#git;
|
|
66
|
+
constructor(scope, bundle, assignment, changes, git) {
|
|
67
|
+
this.#scope = { ...scope };
|
|
68
|
+
this.#bundle = bundle;
|
|
69
|
+
this.#assignment = assignment;
|
|
70
|
+
this.#changes = changes;
|
|
71
|
+
const bytes = Buffer.from(JSON.stringify(changes.currentSnapshot()));
|
|
72
|
+
this.#baseline = PreparedInteractiveGitBaseline.restore(bytes, hash(bytes));
|
|
73
|
+
this.#git = { ...git };
|
|
74
|
+
Object.freeze(this);
|
|
75
|
+
}
|
|
76
|
+
static prepare(context, input) {
|
|
77
|
+
if (context.db.isOwnedWriteTransaction?.())
|
|
78
|
+
refuse("preparation_outside_writer_required");
|
|
79
|
+
const bundle = readFlowRunExecutionBundle(context, input);
|
|
80
|
+
if (!bundle)
|
|
81
|
+
refuse("bundle_required");
|
|
82
|
+
const assignment = readRegisteredInteractiveWork(context, input, bundle, input.workId), frozen = bundle.execution.snapshot().scope;
|
|
83
|
+
const git = readInteractiveWorkGit(bundle);
|
|
84
|
+
const changes = PreparedInteractiveWorkspaceChanges.prepare(context, input, bundle, { projectRoot: frozen.projectRoot,
|
|
85
|
+
workspace: frozen.workspace, gitExecutable: git.git_executable, env: context.env });
|
|
86
|
+
const prepared = new PreparedInteractiveWorkStart(input, bundle, assignment, changes, git);
|
|
87
|
+
const patch = changes.snapshot(), packet = assignment.packet;
|
|
88
|
+
if (hybridDigest(patch.delta.changes) !== packet.file_changes_sha256 || patch.scope_revision !== packet.scope_revision
|
|
89
|
+
|| patch.user_inputs_sha256 !== packet.user_inputs_sha256)
|
|
90
|
+
refuse("issued_context_changed_before_start");
|
|
91
|
+
// Unchanged files are also selected reviewer/writer inputs, not just patch paths.
|
|
92
|
+
const files = new Map(changes.currentSnapshot().entries.filter(item => item.kind === "file").map(item => [item.path, item.sha256]));
|
|
93
|
+
if (packet.read_inputs.some(ref => ref.root === "workspace" && files.get(ref.path) !== ref.sha256))
|
|
94
|
+
refuse("issued_source_changed_before_start");
|
|
95
|
+
context.db.readSnapshot(() => prepared.assertSelection(context));
|
|
96
|
+
prepared.revalidate();
|
|
97
|
+
return prepared;
|
|
98
|
+
}
|
|
99
|
+
revalidate() {
|
|
100
|
+
if (hash(readBoundedReleaseFile(this.#git.git_executable, 64 * 1024 * 1024)) !== this.#git.git_sha256)
|
|
101
|
+
refuse("selected_git_changed");
|
|
102
|
+
this.#changes.revalidate();
|
|
103
|
+
}
|
|
104
|
+
binding() {
|
|
105
|
+
return { schema_id: "dd-flow/interactive-work-start-binding@1", packet_sha256: this.#assignment.packet.packet_sha256, snapshot_sha256: this.#baseline.sha256 };
|
|
106
|
+
}
|
|
107
|
+
assertSelection(context) {
|
|
108
|
+
const { projectId, runId, workId } = this.#scope, packet = this.#assignment.packet;
|
|
109
|
+
const current = readRegisteredInteractiveWork(context, this.#scope, this.#bundle, workId);
|
|
110
|
+
if (hybridDigest(current.receipt) !== hybridDigest(this.#assignment.receipt)
|
|
111
|
+
|| hybridDigest(current.custody) !== hybridDigest(this.#assignment.custody)
|
|
112
|
+
|| current.packet.packet_sha256 !== this.#assignment.packet.packet_sha256)
|
|
113
|
+
refuse("assignment_changed_before_start");
|
|
114
|
+
const run = assertPreparedRunBundleSqlAuthority(context, this.#scope, this.#bundle), index = JSON.parse(run.index_json);
|
|
115
|
+
const graph = stageGraphScope(context, projectId, runId, packet.owner_stage), stage = readDescriptorStageInput(context, { ...this.#scope, stage: packet.owner_stage }, this.#bundle);
|
|
116
|
+
if (run.status !== "running" || index.current_stage !== packet.owner_stage
|
|
117
|
+
|| !index.stage_runs?.some((item) => item.stage === packet.owner_stage && item.status === "running" && item.attempt === graph.attempt)
|
|
118
|
+
|| graph.attempt !== current.custody.scope.attempt || graph.cycle !== current.custody.scope.cycle
|
|
119
|
+
|| stage.attempt !== graph.attempt || stage.inputSha256 !== packet.input_sha256)
|
|
120
|
+
refuse("active_assignment_required");
|
|
121
|
+
assertRunMutationAllowed(context, projectId, runId);
|
|
122
|
+
}
|
|
123
|
+
assertCustody(context) {
|
|
124
|
+
if (!context.db.isOwnedWriteTransaction?.())
|
|
125
|
+
refuse("owned_writer_required");
|
|
126
|
+
this.assertSelection(context);
|
|
127
|
+
this.#changes.assertCustody(context);
|
|
128
|
+
}
|
|
129
|
+
recordStart(context, workSessionId, hookEventKey, identity) {
|
|
130
|
+
this.assertCustody(context);
|
|
131
|
+
const { projectId, runId, workId } = this.#scope;
|
|
132
|
+
const link = context.db.get("SELECT 1 FROM work_sessions ws JOIN works w ON w.work_id=ws.work_id JOIN sessions s ON s.project_id=w.project_id AND s.session_id=ws.session_id JOIN hook_events h ON h.project_id=w.project_id AND h.event_key=ws.hook_event_id WHERE w.project_id=? AND w.run_id=? AND w.work_id=? AND w.status='running' AND ws.id=? AND ws.status='running' AND ws.session_id=? AND ws.hook_event_id=? AND h.status='claimed' AND h.id=? AND h.session_id=ws.session_id AND s.harness=? AND s.provider_session_id=? AND h.harness=s.harness AND h.provider_session_id=s.provider_session_id", [projectId, runId, workId, workSessionId, identity.sessionId, hookEventKey, identity.hookEventId, identity.harness, identity.providerSessionId]);
|
|
133
|
+
if (!link)
|
|
134
|
+
refuse("claimed_work_session_required");
|
|
135
|
+
if (this.#assignment.retained.body.work_history.works.some(prior => prior.actor?.harness === identity.harness
|
|
136
|
+
&& prior.actor.provider_session_id === identity.providerSessionId))
|
|
137
|
+
refuse("independent_physical_session_required");
|
|
138
|
+
if (context.db.get("SELECT 1 FROM work_sessions ws JOIN works w ON w.work_id=ws.work_id JOIN sessions s ON s.project_id=w.project_id AND s.session_id=ws.session_id WHERE w.project_id=? AND w.run_id=? AND ws.id<>? AND s.harness=? AND s.provider_session_id=? LIMIT 1", [projectId, runId, workSessionId, identity.harness, identity.providerSessionId]))
|
|
139
|
+
refuse("independent_physical_session_required");
|
|
140
|
+
if (context.db.get("SELECT 1 FROM audit_events WHERE project_id=? AND event_type=? AND json_extract(payload_json,'$.work_session_id')=?", [projectId, event, workSessionId]))
|
|
141
|
+
refuse("start_already_retained");
|
|
142
|
+
const packet = this.#assignment.packet;
|
|
143
|
+
const receipt = { schema_id: "dd-flow/interactive-work-start@1", run_id: runId, work_id: workId, work_session_id: workSessionId,
|
|
144
|
+
hook_event_key: hookEventKey, hook_event_id: identity.hookEventId, stage: packet.stage, attempt: this.#assignment.receipt.attempt,
|
|
145
|
+
packet_sha256: packet.packet_sha256, bundle_sha256: this.#bundle.sha256,
|
|
146
|
+
actor: { session_id: identity.sessionId, harness: identity.harness, provider_session_id: identity.providerSessionId },
|
|
147
|
+
snapshot_sha256: this.#baseline.sha256, snapshot: this.#baseline.snapshot() };
|
|
148
|
+
// Same bounded snapshot as the RUN baseline; SQL custody is not worker transport.
|
|
149
|
+
const json = JSON.stringify(receipt);
|
|
150
|
+
if (Buffer.byteLength(json) > 33 * 1024 * 1024)
|
|
151
|
+
refuse("unbounded_start_receipt");
|
|
152
|
+
context.db.run("INSERT INTO audit_events(project_id,event_type,payload_json,created_at) VALUES(?,?,?,?)", [projectId, event, json, context.now()]);
|
|
153
|
+
readInteractiveWorkStart(context, this.#scope, this.#bundle, workSessionId);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { checkDiagnosisDisposition } from "./check-runner-evidence.js";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import fs from "node:fs";
|
|
5
5
|
import { quote } from "shell-quote";
|
|
@@ -21,6 +21,12 @@ import { stageAttempt } from "./stage-work-graph.js";
|
|
|
21
21
|
import { contextualPathOptionsForLifecycle } from "../cli/command-inputs.js";
|
|
22
22
|
import { canonicalPath } from "../storage/paths.js";
|
|
23
23
|
import { runtimeCommand, shellArgument } from "./runtime-command.js";
|
|
24
|
+
import { assignedLifecycleBinding, claimAssignedLifecycle, currentAssignedLifecycle, bindAssignedCoordinatorPacket, inheritAssignedPackets, retainControllerStageAssignment, admitAssignedAuxiliary, bindAssignedStageResume, readAcceptedAssignedWork, assertTerminalSettlementUnreserved } from "./assigned-lifecycle.js";
|
|
25
|
+
import { appendAudit } from "./audit.js";
|
|
26
|
+
import { recordTechnicalNote } from "./technical-notes.js";
|
|
27
|
+
import { ObservationClock } from "../harness-runtime/lib/observation-clock.mjs";
|
|
28
|
+
export { currentAssignedLifecycle, assignedSessionIdentity, assertAssignedSettlementCurrent } from "./assigned-lifecycle.js";
|
|
29
|
+
export const bindControllerStageAssignment = retainControllerStageAssignment;
|
|
24
30
|
// Process-local custody: an executing SQL row or ambient variable alone cannot
|
|
25
31
|
// recreate admission after a CLI restart, or in another resource database.
|
|
26
32
|
const currentAdmissions = new WeakMap();
|
|
@@ -48,6 +54,11 @@ export function assertAuxiliaryCommandScope(context, command, projectRoot) {
|
|
|
48
54
|
const daemonId = context.env.DD_FLOW_DAEMON_ID;
|
|
49
55
|
if (!auxiliary || !daemonId)
|
|
50
56
|
return;
|
|
57
|
+
const assignedScope = cliManagedInvocationScope(context);
|
|
58
|
+
if (assignedScope && admitAssignedAuxiliary(context, command, assignedScope, projectRoot)) {
|
|
59
|
+
assertRunMutationAllowed(context, requireProjectByRoot(context, assignedScope.projectRoot).id, assignedScope.runId, assignedScope.generation);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
51
62
|
const event = observedLifecycleNativeEvent(context, command, daemonId);
|
|
52
63
|
if (!event)
|
|
53
64
|
throw new AppError("invocation_receipt_missing", "Auxiliary command has no exact native receipt", 1, { phase: "prepare", effect: "no_effect" });
|
|
@@ -96,8 +107,8 @@ export function isRetryableLifecycleRejection(code, details = {}) {
|
|
|
96
107
|
if (["invocation_command_mismatch", "invocation_argument_mismatch"].includes(code))
|
|
97
108
|
return details.recoverable !== false;
|
|
98
109
|
if (["work_checks_failed", "code_gate_failed", "code_review_gate_failed", "merge_gate_failed"].includes(code))
|
|
99
|
-
return Array.isArray(details.failures) && details.failures.length > 0 && details.failures.every(receipt =>
|
|
100
|
-
return details.recoverable === true || ["review_repair_incomplete", "plan_review_incomplete", "semantic_result_invalid",
|
|
110
|
+
return Array.isArray(details.failures) && details.failures.length > 0 && details.failures.every(receipt => checkDiagnosisDisposition(receipt) === "diagnosis_required");
|
|
111
|
+
return details.recoverable === true || ["work_contract_incomplete", "review_repair_incomplete", "plan_review_incomplete", "semantic_result_invalid",
|
|
101
112
|
"review_evidence_invalid", "evidence_obligation_unknown", "invalid_evidence_ref",
|
|
102
113
|
"evidence_ref_missing", "evidence_line_out_of_range", "document_update_missing",
|
|
103
114
|
"document_update_not_materialized", "review_repair_no_change"].includes(code)
|
|
@@ -289,6 +300,7 @@ export function bindLifecyclePublishedPacket(context, prompt) {
|
|
|
289
300
|
const issued = context.db.all("SELECT * FROM lifecycle_invocations WHERE scope_json = ? AND status = 'issued' ORDER BY rowid", [scope]);
|
|
290
301
|
const ids = issued.filter(row => prompt.includes(publicInvocationCommand(context, row, retainedCommandWithoutInvocationId(row)))).map(row => row.id);
|
|
291
302
|
bindLifecyclePacketAssignments(context, prompt, ids);
|
|
303
|
+
bindAssignedCoordinatorPacket(context, prompt);
|
|
292
304
|
}
|
|
293
305
|
function retainedCommandWithoutInvocationId(row) {
|
|
294
306
|
const marker = ` --invocation-id ${row.id}`;
|
|
@@ -391,6 +403,24 @@ export function assertLifecycleOutcomes(context, scope) {
|
|
|
391
403
|
/** Hooks already own native identity. Retain command outcomes there, with an
|
|
392
404
|
* atomic controller notification; missing notifications reconcile from the row. */
|
|
393
405
|
export function observeLifecycleCommand(context, command, nativeEvent) {
|
|
406
|
+
const assigned = currentAssignedLifecycle(context);
|
|
407
|
+
if (assigned)
|
|
408
|
+
return outcome => {
|
|
409
|
+
try {
|
|
410
|
+
const project = requireProjectByRoot(context, assigned.scope.projectRoot);
|
|
411
|
+
try {
|
|
412
|
+
appendAudit(context, { projectId: project.id, eventType: "lifecycle.assigned_outcome", payload: { run_id: assigned.scope.runId,
|
|
413
|
+
generation: assigned.scope.generation, invocation_id: assigned.invocation_id, operation: assigned.operation,
|
|
414
|
+
work_id: assigned.work_id, ...(outcome.error ? { error: { code: outcome.error.code, message: outcome.error.message } } : { accepted: true }) } });
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
recordTechnicalNote(context, project.id, assigned.scope.runId, { code: "assigned_outcome_audit_unavailable", message: "Optional assigned-operation audit could not be written; invocation outcome remains authoritative", phase: "settlement", generation: assigned.scope.generation, source_operation_id: assigned.invocation_id, consequence: "none" });
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
catch {
|
|
421
|
+
process.stderr.write(`${JSON.stringify({ event: "assigned_outcome_audit_unavailable", invocation_id: assigned.invocation_id })}\n`);
|
|
422
|
+
}
|
|
423
|
+
};
|
|
394
424
|
const parsed = parseLifecycleCommand(command);
|
|
395
425
|
const auxiliary = continuationCommand(command);
|
|
396
426
|
if (parsed.kind !== "standalone" && !auxiliary)
|
|
@@ -677,9 +707,41 @@ export function lifecycleRetryCommands(context, stage) {
|
|
|
677
707
|
}
|
|
678
708
|
}))];
|
|
679
709
|
}
|
|
680
|
-
|
|
710
|
+
/** A resumed coordinator retains intent, not the old route's mutation authority. */
|
|
711
|
+
export function rebindAssignedCoordinatorCommands(context) {
|
|
712
|
+
const assigned = currentAssignedLifecycle(context);
|
|
713
|
+
if (assigned?.operation !== "stage_resume" || !assigned.work_session_id)
|
|
714
|
+
return [];
|
|
715
|
+
const rows = context.db.all(`SELECT DISTINCT li.* FROM lifecycle_invocations li JOIN lifecycle_packet_assignments pa ON pa.invocation_id=li.id
|
|
716
|
+
WHERE li.status='issued' AND (pa.packet_sha256=(SELECT json_extract(start_receipt_json,'$.prompt_sha256') FROM work_sessions WHERE id=?)
|
|
717
|
+
OR EXISTS (SELECT 1 FROM audit_events a WHERE a.event_type='lifecycle.coordinator_packet'
|
|
718
|
+
AND json_extract(a.payload_json,'$.work_session_id')=? AND json_extract(a.payload_json,'$.packet_sha256')=pa.packet_sha256))`, [assigned.work_session_id, assigned.work_session_id]);
|
|
719
|
+
const commands = [];
|
|
720
|
+
context.db.writeTransaction(() => {
|
|
721
|
+
for (const row of rows) {
|
|
722
|
+
const parsed = invocation(row.command), scope = JSON.parse(row.scope_json);
|
|
723
|
+
if (!["stage_finish", "stage_pause"].includes(parsed.operation) || commandOption(parsed, "stage") !== assigned.stage
|
|
724
|
+
|| scope.projectRoot !== assigned.scope.projectRoot || scope.runId !== assigned.scope.runId || scope.generation > assigned.scope.generation)
|
|
725
|
+
continue;
|
|
726
|
+
if (row.scope_json === JSON.stringify(assigned.scope)) {
|
|
727
|
+
commands.push(publicInvocationCommand(context, row, row.command));
|
|
728
|
+
continue;
|
|
729
|
+
}
|
|
730
|
+
const command = canonicalManagedCommand(context, retainedCommandWithoutInvocationId(row), assigned.scope);
|
|
731
|
+
const next = managedLifecycleCommand(context, command);
|
|
732
|
+
const successor = currentIssuedCommand(context, JSON.stringify(assigned.scope), next);
|
|
733
|
+
if (!successor)
|
|
734
|
+
throw new AppError("invocation_assignment_unproven", "Resume has no exact current coordinator successor", 1, { effect: "no_effect", reconciliation_required: true });
|
|
735
|
+
inheritAssignedPackets(context, row.id, successor.id);
|
|
736
|
+
context.db.run("UPDATE lifecycle_invocations SET status='expired',updated_at=? WHERE id=? AND status='issued'", [context.now(), row.id]);
|
|
737
|
+
commands.push(next);
|
|
738
|
+
}
|
|
739
|
+
});
|
|
740
|
+
return [...new Set(commands)];
|
|
741
|
+
}
|
|
742
|
+
function currentIssuedCommand(context, scope, command, excludeId) {
|
|
681
743
|
const rows = context.db.all("SELECT * FROM lifecycle_invocations WHERE scope_json = ? AND fingerprint = ? AND status = 'issued'", [scope, scopedCommandFingerprint(context, scope, command)])
|
|
682
|
-
.filter(row => row.deadline === null || row.deadline > Date.now());
|
|
744
|
+
.filter(row => row.id !== excludeId && (row.deadline === null || row.deadline > Date.now()));
|
|
683
745
|
if (rows.length > 1)
|
|
684
746
|
throw new AppError("invocation_ambiguous", "Multiple current lifecycle successors match this command", 1);
|
|
685
747
|
return rows[0] ?? null;
|
|
@@ -703,18 +765,23 @@ function invocationRecoverySnapshot(context, scope) {
|
|
|
703
765
|
}
|
|
704
766
|
/** Resolve authority, never reconstruct it from an old prompt or issue on read. */
|
|
705
767
|
export function currentStageFinishCommand(context, stage) {
|
|
768
|
+
return currentStageFinishAuthority(context, stage)?.command ?? null;
|
|
769
|
+
}
|
|
770
|
+
/** Observation of an existing attempt is not permission to dispatch it again. */
|
|
771
|
+
export function currentStageFinishAuthority(context, stage) {
|
|
706
772
|
const scope = context.env.DD_FLOW_INVOCATION_SCOPE;
|
|
707
773
|
if (!scope || !context.db.get("SELECT 1 FROM sqlite_master WHERE name='lifecycle_invocations'"))
|
|
708
774
|
return null;
|
|
709
|
-
const rows = context.db.all("SELECT * FROM lifecycle_invocations WHERE scope_json = ? AND status
|
|
775
|
+
const rows = context.db.all("SELECT * FROM lifecycle_invocations WHERE scope_json = ? AND status IN ('issued','observed','executing')", [scope])
|
|
710
776
|
.filter(row => {
|
|
711
777
|
const parsed = invocation(row.command);
|
|
712
|
-
return parsed.operation === "stage_finish" && commandOption(parsed, "stage") === stage
|
|
713
|
-
&& (row.deadline === null || row.deadline > Date.now());
|
|
778
|
+
return parsed.operation === "stage_finish" && commandOption(parsed, "stage") === stage;
|
|
714
779
|
});
|
|
715
780
|
if (rows.length > 1)
|
|
716
781
|
throw new AppError("invocation_ambiguous", "Stage has multiple current finish authorities", 1, { stage });
|
|
717
|
-
|
|
782
|
+
const row = rows[0];
|
|
783
|
+
return row ? { id: row.id, status: row.status, command: row.status === "issued" && (row.deadline === null || row.deadline > Date.now())
|
|
784
|
+
? publicInvocationCommand(context, row, row.command) : null } : null;
|
|
718
785
|
}
|
|
719
786
|
export function managedInvocationContext(context, input) {
|
|
720
787
|
const env = { ...context.env, DD_FLOW_CURRENT_INVOCATION: undefined, DD_FLOW_INVOCATION_SCOPE: undefined };
|
|
@@ -805,6 +872,7 @@ export function managedLifecycleCommand(context, command, options = {}) {
|
|
|
805
872
|
if (context.env.DD_FLOW_INVOCATION_READONLY === "1")
|
|
806
873
|
throw new AppError("invocation_command_unprepared", "No prepared lifecycle command is available; read-only inspection cannot issue one", 1);
|
|
807
874
|
const issued = issueLifecycleInvocation(context, command, scope);
|
|
875
|
+
bindAssignedStageResume(context, load(context, issued.id));
|
|
808
876
|
return publicInvocationCommand(context, load(context, issued.id), command);
|
|
809
877
|
});
|
|
810
878
|
}
|
|
@@ -841,9 +909,13 @@ function successorLifecycleInvocationCommand(context, prior, checkReceiptId) {
|
|
|
841
909
|
throw new AppError("invocation_command_invalid", "Retained retry option cannot be rewritten unambiguously", 1);
|
|
842
910
|
retained = retained.replace(fragments[0], "");
|
|
843
911
|
}
|
|
844
|
-
command = retained.replace(` --invocation-id ${prior.id}`, ` --retry-check ${shellArgument(checkReceiptId)} --reason ${shellArgument("Recheck after
|
|
912
|
+
command = retained.replace(` --invocation-id ${prior.id}`, ` --retry-check ${shellArgument(checkReceiptId)} --reason ${shellArgument("Recheck after diagnosis and correction of the failed check")}`);
|
|
845
913
|
}
|
|
846
|
-
|
|
914
|
+
const successor = managedLifecycleCommand({ ...context, env: { ...context.env, DD_FLOW_INVOCATION_SCOPE: prior.scope_json, DD_FLOW_CURRENT_INVOCATION: prior.id } }, command);
|
|
915
|
+
const next = stagedContinuations.get(context.db)?.get(prior.id) ?? currentIssuedCommand(context, prior.scope_json, successor, prior.id);
|
|
916
|
+
if (next && context.db.get("SELECT 1 FROM lifecycle_invocations WHERE id=?", [next.id]))
|
|
917
|
+
inheritAssignedPackets(context, prior.id, next.id);
|
|
918
|
+
return successor;
|
|
847
919
|
}
|
|
848
920
|
// Caller holds the write transaction and has checked authority. Issued (or
|
|
849
921
|
// legacy expired) attempts have never admitted an effect; observed/executing
|
|
@@ -862,6 +934,16 @@ const receiptTimeout = (id) => ({ code: "invocation_receipt_timeout", message: "
|
|
|
862
934
|
* A crash must leave either the old attempt executing, or both the settled
|
|
863
935
|
* outcome and its successor; never a usable successor beside an unknown effect. */
|
|
864
936
|
export function settleLifecycleRejection(context, id, error) {
|
|
937
|
+
const accepted = readAcceptedAssignedWork(context, id);
|
|
938
|
+
if (accepted) {
|
|
939
|
+
const row = load(context, id), scope = JSON.parse(row.scope_json);
|
|
940
|
+
settleLifecycleInvocation(context, id, { result: accepted });
|
|
941
|
+
Object.assign(error.details, { effect: "committed", recoverable: false, work_result_accepted: true, reconciliation_required: true });
|
|
942
|
+
const project = requireProjectByRoot(context, scope.projectRoot);
|
|
943
|
+
recordTechnicalNote(context, project.id, scope.runId, { code: "work_post_commit_pending", message: "Work result was accepted; derivative publication or projection needs reconciliation", phase: "publication", generation: scope.generation,
|
|
944
|
+
source_operation_id: id, work_id: String(accepted.work_id), consequence: "dependency_blocked" });
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
865
947
|
storage(context);
|
|
866
948
|
// Only this settlement may publish a successor; inherited help is not authority.
|
|
867
949
|
delete error.details.retry_command;
|
|
@@ -935,24 +1017,33 @@ export function hasLifecyclePacketAssignments(context, originalPrompt) {
|
|
|
935
1017
|
}
|
|
936
1018
|
export function recoverLifecycleCommands(context, originalPrompt, scope) {
|
|
937
1019
|
const digest = crypto.createHash("sha256").update(originalPrompt).digest("hex");
|
|
938
|
-
const
|
|
939
|
-
if (!
|
|
1020
|
+
const packet = context.db.all("SELECT li.* FROM lifecycle_packet_assignments pa JOIN lifecycle_invocations li ON li.id=pa.invocation_id WHERE pa.packet_sha256 = ? ORDER BY pa.rowid", [digest]);
|
|
1021
|
+
if (!packet.length)
|
|
940
1022
|
throw new AppError("invocation_recovery_unbound", "Original packet has no verified lifecycle assignment binding; reconcile it before continuing", 1);
|
|
1023
|
+
const origins = new Map();
|
|
1024
|
+
for (const row of packet) {
|
|
1025
|
+
const priorScope = JSON.parse(row.scope_json);
|
|
1026
|
+
if (priorScope.projectRoot !== scope.projectRoot || priorScope.runId !== scope.runId || priorScope.rootSessionId !== scope.rootSessionId
|
|
1027
|
+
|| priorScope.generation > scope.generation || priorScope.generation === scope.generation && row.scope_json !== JSON.stringify(scope))
|
|
1028
|
+
throw new AppError("invocation_recovery_scope_mismatch", "Retained command does not belong to the recovered root and an earlier generation", 1, { invocation_id: row.id });
|
|
1029
|
+
// Recovery inherits the immutable packet binding. Those new rows are
|
|
1030
|
+
// successors of its original commands, not additional commands to recover.
|
|
1031
|
+
if (priorScope.generation < scope.generation && !origins.has(row.fingerprint))
|
|
1032
|
+
origins.set(row.fingerprint, row);
|
|
1033
|
+
}
|
|
1034
|
+
if (!origins.size)
|
|
1035
|
+
throw new AppError("invocation_recovery_scope_mismatch", "Recovery requires an earlier packet generation", 1);
|
|
941
1036
|
const recovered = [];
|
|
942
|
-
for (const
|
|
943
|
-
|
|
944
|
-
const oldScope = JSON.parse(prior.scope_json);
|
|
945
|
-
if (oldScope.projectRoot !== scope.projectRoot || oldScope.runId !== scope.runId
|
|
946
|
-
|| oldScope.rootSessionId !== scope.rootSessionId || oldScope.generation >= scope.generation) {
|
|
947
|
-
throw new AppError("invocation_recovery_scope_mismatch", "Retained command does not belong to the recovered root and an earlier generation", 1, { invocation_id: id });
|
|
948
|
-
}
|
|
1037
|
+
for (const origin of origins.values()) {
|
|
1038
|
+
const id = origin.id;
|
|
949
1039
|
// The immutable prompt can predate several recoveries. Follow the newest
|
|
950
1040
|
// retained attempt, including an unknown successor, rather than reviving
|
|
951
1041
|
// an older unexecuted command after the successor already produced effects.
|
|
952
|
-
prior = context.db.get(`SELECT
|
|
953
|
-
|
|
954
|
-
AND json_extract(scope_json, '$.
|
|
955
|
-
|
|
1042
|
+
const prior = context.db.get(`SELECT li.* FROM lifecycle_invocations li JOIN lifecycle_packet_assignments pa ON pa.invocation_id=li.id
|
|
1043
|
+
WHERE pa.packet_sha256=? AND li.fingerprint = ?
|
|
1044
|
+
AND json_extract(li.scope_json, '$.projectRoot') = ? AND json_extract(li.scope_json, '$.runId') IS ?
|
|
1045
|
+
AND json_extract(li.scope_json, '$.rootSessionId') = ? AND (json_extract(li.scope_json, '$.generation') < ? OR li.scope_json = ?)
|
|
1046
|
+
ORDER BY json_extract(li.scope_json, '$.generation') DESC, li.rowid DESC LIMIT 1`, [digest, origin.fingerprint, scope.projectRoot, scope.runId, scope.rootSessionId, scope.generation, JSON.stringify(scope)]);
|
|
956
1047
|
if (prior.status === "executing" || prior.status === "settled") {
|
|
957
1048
|
const outcome = prior.outcome_json ? JSON.parse(prior.outcome_json) : null;
|
|
958
1049
|
recovered.push({ invocation_id: id, status: prior.status, outcome,
|
|
@@ -968,6 +1059,9 @@ export function recoverLifecycleCommands(context, originalPrompt, scope) {
|
|
|
968
1059
|
const env = { ...parsed.env, DD_FLOW_INVOCATION_SCOPE: JSON.stringify(scope) };
|
|
969
1060
|
const command = `${Object.entries(env).map(([key, value]) => `${key}=${quote([value])}`).join(" ")} ${launchExpression(parsed)} ${quote(argv)} ${prior.command.slice(shellSuffixOffset(prior.command))}`.trim();
|
|
970
1061
|
const prepared = managedLifecycleCommand({ ...context, env: { ...context.env, DD_FLOW_INVOCATION_SCOPE: JSON.stringify(scope), DD_FLOW_CURRENT_INVOCATION: undefined } }, command);
|
|
1062
|
+
const successor = currentIssuedCommand(context, JSON.stringify(scope), prepared);
|
|
1063
|
+
if (successor)
|
|
1064
|
+
inheritAssignedPackets(context, prior.id, successor.id);
|
|
971
1065
|
recovered.push({ invocation_id: id, status: "replaced_unexecuted", command: prepared });
|
|
972
1066
|
}
|
|
973
1067
|
return recovered;
|
|
@@ -1029,6 +1123,28 @@ function checkCommand(row, command) {
|
|
|
1029
1123
|
throw new AppError("invocation_command_mismatch", "Lifecycle attempt belongs to another command or project", 1, { invocation_id: row.id });
|
|
1030
1124
|
}
|
|
1031
1125
|
}
|
|
1126
|
+
/** Match a daemon-attested native tool against its exact issued command.
|
|
1127
|
+
* This is only a correlation check: claim/owner/generation are verified again
|
|
1128
|
+
* by the normal invocation path before the command can mutate anything. */
|
|
1129
|
+
export function matchesIssuedNativeCommand(context, id, command) {
|
|
1130
|
+
const row = load(context, id);
|
|
1131
|
+
const analysis = parseLifecycleCommand(command);
|
|
1132
|
+
if (analysis.kind === "none" || analysis.supported === false)
|
|
1133
|
+
return false;
|
|
1134
|
+
try {
|
|
1135
|
+
const scope = JSON.parse(row.scope_json);
|
|
1136
|
+
const parsed = invocation(canonicalManagedCommand(context, analysis.invocation.command, scope));
|
|
1137
|
+
assertSuppliedLifecycleTarget(row, parsed);
|
|
1138
|
+
// Public Work packets deliberately omit the internal UUID. The caller
|
|
1139
|
+
// has already selected the unique issued assignment in its managed scope;
|
|
1140
|
+
// this comparison supplies no authority and must still pass native proof/CAS.
|
|
1141
|
+
const supplied = commandOption(parsed, "invocation-id");
|
|
1142
|
+
return (supplied === id || supplied === undefined && parsed.operation === "work_start") && fingerprint(parsed) === row.fingerprint;
|
|
1143
|
+
}
|
|
1144
|
+
catch {
|
|
1145
|
+
return false;
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1032
1148
|
/** Public commands may omit the coordinator RUN. A supplied target remains
|
|
1033
1149
|
* input to validate, not text to silently overwrite with retained authority. */
|
|
1034
1150
|
function assertSuppliedLifecycleTarget(row, actual) {
|
|
@@ -1480,6 +1596,32 @@ export function observeLifecycleInvocation(context, input) {
|
|
|
1480
1596
|
}
|
|
1481
1597
|
function observeLifecycleInvocationReceipt(context, input) {
|
|
1482
1598
|
storage(context);
|
|
1599
|
+
const delivered = parseLifecycleCommand(input.command);
|
|
1600
|
+
if (delivered.kind === "standalone") {
|
|
1601
|
+
const id = commandOption(delivered.invocation, "invocation-id");
|
|
1602
|
+
const direct = context.db.all("SELECT * FROM lifecycle_invocations WHERE admission_json IS NOT NULL AND status IN ('executing','settled')")
|
|
1603
|
+
.filter(row => {
|
|
1604
|
+
const scope = JSON.parse(row.scope_json);
|
|
1605
|
+
if (id && row.id !== id || scope.daemonId !== input.identity.daemonId || scope.rootSessionId !== input.identity.rootSessionId)
|
|
1606
|
+
return false;
|
|
1607
|
+
// A fresh native call must bind the explicitly committed successor,
|
|
1608
|
+
// not short-circuit to its predecessor's successful repair response.
|
|
1609
|
+
// Exact delivery/explicit-ID replay remains historical read-only.
|
|
1610
|
+
const successor = row.status === "settled" && row.outcome_json ? committedSuccessor(context, row, JSON.parse(row.outcome_json)) : null;
|
|
1611
|
+
if (!id && row.identity_json && successor && ["issued", "observed"].includes(successor.status)
|
|
1612
|
+
&& !sameNativeCall(row.identity_json, JSON.stringify([input.identity.daemonId, input.identity.rootSessionId, input.identity.sessionId, input.identity.parentSessionId, input.identity.toolCallId])))
|
|
1613
|
+
return false;
|
|
1614
|
+
try {
|
|
1615
|
+
return fingerprint(invocation(canonicalManagedCommand(context, delivered.invocation.command, scope))) === row.fingerprint;
|
|
1616
|
+
}
|
|
1617
|
+
catch {
|
|
1618
|
+
return false;
|
|
1619
|
+
}
|
|
1620
|
+
});
|
|
1621
|
+
if (direct.length === 1)
|
|
1622
|
+
return { eventKey: direct[0].event_key, duplicate: true,
|
|
1623
|
+
disposition: direct[0].status === "settled" ? "settled_read" : "in_flight", invocationId: direct[0].id };
|
|
1624
|
+
}
|
|
1483
1625
|
const projectRoot = input.projectId ? context.db.get("SELECT root FROM projects WHERE id = ?", [input.projectId])?.root : undefined;
|
|
1484
1626
|
const provenScope = projectRoot && input.harness ? managedNativeInvocationScope(context, projectRoot, input.identity, input.harness) : null;
|
|
1485
1627
|
const owner = provenScope && input.harness && input.projectId ? managedNativeOwner(context, input.identity, input.harness, input.projectId) : null;
|
|
@@ -1587,6 +1729,10 @@ function observeLifecycleInvocationReceipt(context, input) {
|
|
|
1587
1729
|
// public ID is corrected only by an exact operation/target match inside the
|
|
1588
1730
|
// trusted native root. Never fuzzy-match UUID text or select a global latest.
|
|
1589
1731
|
const initial = supplied ?? (candidates.length === 1 ? candidates[0] : undefined);
|
|
1732
|
+
// A live observation arriving after direct admission is optional correlation,
|
|
1733
|
+
// never a second authority or an executing -> observed transition.
|
|
1734
|
+
if (initial?.admission_json && ["executing", "settled"].includes(initial.status))
|
|
1735
|
+
return { eventKey: initial.event_key, duplicate: true, disposition: initial.status === "settled" ? "settled_read" : "in_flight", invocationId: initial.id };
|
|
1590
1736
|
if (!supplied && candidates.length > 1) {
|
|
1591
1737
|
input.recordReceipt();
|
|
1592
1738
|
throw new AppError("invocation_ambiguous", "More than one issued lifecycle attempt matches this native command", 1, { effect: "no_effect", matches: candidates.map(row => row.id), ...(provenScope ? { lifecycle_assignment: { issuer: "dd-flow", scope: provenScope } } : {}) });
|
|
@@ -1735,7 +1881,8 @@ export function observedLifecycleInvocation(context, command, scopeConstraint, c
|
|
|
1735
1881
|
const retainedId = suppliedId && context.db.get("SELECT id FROM lifecycle_invocations WHERE id = ?", [suppliedId]) ? suppliedId : undefined;
|
|
1736
1882
|
// `issued` is a rendezvous, not authority: the CLI still blocks in
|
|
1737
1883
|
// awaitLifecycleInvocation until the native observer commits the receipt.
|
|
1738
|
-
const
|
|
1884
|
+
const history = context.db.all("SELECT * FROM lifecycle_invocations WHERE status IN ('issued','observed','executing','settled') ORDER BY rowid DESC");
|
|
1885
|
+
const matching = history
|
|
1739
1886
|
.filter(row => {
|
|
1740
1887
|
try {
|
|
1741
1888
|
const explicitId = retainedId;
|
|
@@ -1755,7 +1902,7 @@ export function observedLifecycleInvocation(context, command, scopeConstraint, c
|
|
|
1755
1902
|
}
|
|
1756
1903
|
});
|
|
1757
1904
|
const identity = correlation?.identity;
|
|
1758
|
-
const matches = lifecycleCandidates(matching, identity ? JSON.stringify([identity.daemonId, identity.rootSessionId, identity.sessionId, identity.parentSessionId, identity.toolCallId]) : undefined, true);
|
|
1905
|
+
const matches = lifecycleCandidates(matching, identity ? JSON.stringify([identity.daemonId, identity.rootSessionId, identity.sessionId, identity.parentSessionId, identity.toolCallId]) : undefined, true, history);
|
|
1759
1906
|
if (!matches.length)
|
|
1760
1907
|
return null;
|
|
1761
1908
|
if (matches.length !== 1)
|
|
@@ -1764,8 +1911,9 @@ export function observedLifecycleInvocation(context, command, scopeConstraint, c
|
|
|
1764
1911
|
}
|
|
1765
1912
|
/** A new native call may consume a successor, but never a historical result.
|
|
1766
1913
|
* Exact native delivery replays its immutable assignment. CLI lookup without
|
|
1767
|
-
* native correlation
|
|
1768
|
-
|
|
1914
|
+
* native correlation may use a uniquely bound committed successor, not an
|
|
1915
|
+
* arbitrary newest/active row. Unrelated history remains ambiguous. */
|
|
1916
|
+
function lifecycleCandidates(rows, identity, lookup = false, history = rows) {
|
|
1769
1917
|
if (identity) {
|
|
1770
1918
|
const replay = rows.filter(row => {
|
|
1771
1919
|
if (!row.identity_json)
|
|
@@ -1786,6 +1934,26 @@ function lifecycleCandidates(rows, identity, lookup = false) {
|
|
|
1786
1934
|
return replay;
|
|
1787
1935
|
}
|
|
1788
1936
|
const active = rows.filter(row => row.status !== "settled");
|
|
1937
|
+
if (lookup && !identity && active.length === 1 && (active[0].status === "issued" || active[0].identity_json && active[0].event_key)) {
|
|
1938
|
+
const current = active[0];
|
|
1939
|
+
const leadsToCurrent = (row, seen = new Set()) => {
|
|
1940
|
+
if (row.id === current.id)
|
|
1941
|
+
return true;
|
|
1942
|
+
if (row.status !== "settled" || seen.has(row.id))
|
|
1943
|
+
return false;
|
|
1944
|
+
seen.add(row.id);
|
|
1945
|
+
try {
|
|
1946
|
+
const next = JSON.parse(row.outcome_json ?? "null")?.successor_invocation_id;
|
|
1947
|
+
const successor = history.find(candidate => candidate.id === next && candidate.scope_json === current.scope_json);
|
|
1948
|
+
return Boolean(successor && leadsToCurrent(successor, seen));
|
|
1949
|
+
}
|
|
1950
|
+
catch {
|
|
1951
|
+
return false;
|
|
1952
|
+
}
|
|
1953
|
+
};
|
|
1954
|
+
if (rows.every(row => leadsToCurrent(row)))
|
|
1955
|
+
return active;
|
|
1956
|
+
}
|
|
1789
1957
|
return (!lookup || identity) && active.length ? active : rows;
|
|
1790
1958
|
}
|
|
1791
1959
|
/** Restore private assignment arguments without discarding the provider's
|
|
@@ -2024,13 +2192,50 @@ export async function awaitLifecycleInvocation(context, input) {
|
|
|
2024
2192
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 60000)
|
|
2025
2193
|
throw new AppError("invocation_timeout_invalid", "Receipt timeout must be within 1..60000 ms", 1);
|
|
2026
2194
|
context.db.run("UPDATE lifecycle_invocations SET deadline = COALESCE(deadline, ?), updated_at = ? WHERE id = ? AND status = 'issued'", [Date.now() + timeoutMs, context.now(), input.id]);
|
|
2195
|
+
const initialClock = new ObservationClock({ timeoutMs }), seenProgress = new Set();
|
|
2196
|
+
const receiptClock = new ObservationClock({ timeoutMs, snapshotIsLive: true, saved: { ...initialClock.state(),
|
|
2197
|
+
elapsed_ms: Math.max(0, timeoutMs - Math.max(0, (load(context, input.id).deadline ?? Date.now()) - Date.now())) } });
|
|
2027
2198
|
for (;;) {
|
|
2028
|
-
|
|
2199
|
+
let row = load(context, input.id);
|
|
2200
|
+
if (row.status === "issued" && invocation(row.command).operation === "work_start" && input.reconcileNativeObservation) {
|
|
2201
|
+
const scope = JSON.parse(row.scope_json);
|
|
2202
|
+
input.assertCurrent(scope);
|
|
2203
|
+
await input.reconcileNativeObservation(scope, progress => {
|
|
2204
|
+
input.assertCurrent(scope);
|
|
2205
|
+
if (seenProgress.has(progress.cursor))
|
|
2206
|
+
return;
|
|
2207
|
+
const current = load(context, input.id);
|
|
2208
|
+
if (current.status !== 'issued' || current.scope_json !== row.scope_json || current.deadline === null
|
|
2209
|
+
|| !Number.isFinite(progress.observed_at) || progress.observed_at >= current.deadline || progress.observed_at > Date.now())
|
|
2210
|
+
return;
|
|
2211
|
+
const expired = receiptClock.sample(progress);
|
|
2212
|
+
const state = receiptClock.state();
|
|
2213
|
+
if (expired || state.cursor !== progress.cursor)
|
|
2214
|
+
return;
|
|
2215
|
+
seenProgress.add(progress.cursor);
|
|
2216
|
+
context.db.run("UPDATE lifecycle_invocations SET deadline = ?, updated_at = ? WHERE id = ? AND status = 'issued' AND scope_json = ?", [Date.now() + Math.max(1, timeoutMs - state.elapsed_ms), context.now(), input.id, row.scope_json]);
|
|
2217
|
+
input.progress?.("native_verification_progress");
|
|
2218
|
+
});
|
|
2219
|
+
// A native receipt can arrive while it is being read. Reload it before
|
|
2220
|
+
// deciding whether this exact invocation is eligible for admission.
|
|
2221
|
+
row = load(context, input.id);
|
|
2222
|
+
if (row.status !== "issued")
|
|
2223
|
+
continue;
|
|
2224
|
+
}
|
|
2029
2225
|
if (row.status === "settled")
|
|
2030
2226
|
return { outcome: JSON.parse(row.outcome_json), replay: true };
|
|
2031
2227
|
if (row.status === "executing") {
|
|
2032
2228
|
const scope = JSON.parse(row.scope_json);
|
|
2033
2229
|
const parsed = invocation(row.command);
|
|
2230
|
+
if (row.admission_json && ["work_finish", "work_fail"].includes(parsed.operation)) {
|
|
2231
|
+
const { recoverCommittedAssignedWork } = await import("./work-registry.js");
|
|
2232
|
+
const result = recoverCommittedAssignedWork(context, row.id);
|
|
2233
|
+
if (result) {
|
|
2234
|
+
const outcome = { result };
|
|
2235
|
+
settleLifecycleInvocation(context, row.id, outcome);
|
|
2236
|
+
return { outcome, replay: true };
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2034
2239
|
if (parsed.operation === "stage_resume" && scope.runId) {
|
|
2035
2240
|
input.assertCurrent(scope);
|
|
2036
2241
|
const { recoverCommittedStageResume } = await import("./stage-pause.js");
|
|
@@ -2073,6 +2278,14 @@ export async function awaitLifecycleInvocation(context, input) {
|
|
|
2073
2278
|
throw new AppError("invocation_outcome_pending", "This attempt already started; inspect its retained operation outcome before retrying", 1, { invocation_id: input.id, effect: "unknown" });
|
|
2074
2279
|
}
|
|
2075
2280
|
input.assertCurrent(JSON.parse(row.scope_json));
|
|
2281
|
+
if (["issued", "observed"].includes(row.status) && assignedLifecycleBinding(context, row)) {
|
|
2282
|
+
if (claimAssignedLifecycle(context, row))
|
|
2283
|
+
return { kind: "managed_assignment", replay: false };
|
|
2284
|
+
continue;
|
|
2285
|
+
}
|
|
2286
|
+
if (row.status === "issued" && context.env.DD_FLOW_DAEMON_ID && ["work_finish", "work_fail", "stage_finish", "stage_pause", "stage_resume", "stage_start"].includes(invocation(row.command).operation)) {
|
|
2287
|
+
throw new AppError("invocation_assignment_unproven", "Current managed assignment is unavailable; reconcile this operation without repeating Work", 1, { invocation_id: row.id, effect: "no_effect", recoverable: true, reconciliation_required: true });
|
|
2288
|
+
}
|
|
2076
2289
|
if (row.status === "observed") {
|
|
2077
2290
|
if (row.identity_json) {
|
|
2078
2291
|
// Receipt display text is bounded/redacted and may not be valid JSON.
|
|
@@ -2086,11 +2299,13 @@ export async function awaitLifecycleInvocation(context, input) {
|
|
|
2086
2299
|
}
|
|
2087
2300
|
// Another native observation can replace an unclaimed receipt. Claim
|
|
2088
2301
|
// exactly the identity/route checked above, or reload and validate it.
|
|
2089
|
-
const
|
|
2090
|
-
const
|
|
2302
|
+
const operation = invocation(row.command).operation;
|
|
2303
|
+
const physicalAdmission = operation === "work_start" || operation.startsWith("delivery_");
|
|
2304
|
+
const birth = physicalAdmission ? processStartedAt(process.pid) : null, cwd = physicalAdmission ? process.cwd() : "";
|
|
2091
2305
|
input.assertCurrent(JSON.parse(row.scope_json));
|
|
2092
2306
|
let admittedAt = "";
|
|
2093
2307
|
const claimed = context.db.writeTransaction(() => {
|
|
2308
|
+
assertTerminalSettlementUnreserved(context, row);
|
|
2094
2309
|
// Acquire the writer before taking the anchor; waiting for SQLite or
|
|
2095
2310
|
// physical birth observation must not make an old Hook appear fresh.
|
|
2096
2311
|
admittedAt = context.now();
|
|
@@ -2107,7 +2322,7 @@ export async function awaitLifecycleInvocation(context, input) {
|
|
|
2107
2322
|
owned.delete(owned.keys().next().value);
|
|
2108
2323
|
owned.set(input.id, { eventKey: row.event_key, identity: row.identity_json, scope: row.scope_json, command: row.command, cwd, pid: process.pid, birth, admittedAt });
|
|
2109
2324
|
}
|
|
2110
|
-
return { eventKey: row.event_key, replay: false };
|
|
2325
|
+
return { kind: "native_hook", eventKey: row.event_key, replay: false };
|
|
2111
2326
|
}
|
|
2112
2327
|
continue;
|
|
2113
2328
|
}
|
|
@@ -2139,8 +2354,10 @@ export function settleLifecycleInvocation(context, id, outcome) {
|
|
|
2139
2354
|
}
|
|
2140
2355
|
if (row.status !== "executing")
|
|
2141
2356
|
throw new AppError("invocation_settlement_conflict", "Attempt is no longer executing", 1, { invocation_id: id });
|
|
2142
|
-
if (successor)
|
|
2357
|
+
if (successor) {
|
|
2143
2358
|
insertIssuedInvocation(context, successor);
|
|
2359
|
+
inheritAssignedPackets(context, id, successor.id);
|
|
2360
|
+
}
|
|
2144
2361
|
if (context.db.run("UPDATE lifecycle_invocations SET status = 'settled', outcome_json = ?, updated_at = ? WHERE id = ? AND status = 'executing'", [encoded, context.now(), id]).changes !== 1)
|
|
2145
2362
|
throw new AppError("invocation_settlement_conflict", "Lifecycle outcome changed before commit", 1, { invocation_id: id });
|
|
2146
2363
|
});
|