@deksden-com/dd-flow-cli 0.9.0-beta.123 → 0.9.0-beta.127
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 +26 -0
- package/README.md +33 -1
- package/dist/build-info.json +7 -7
- package/dist/cli/argument-values.js +38 -0
- package/dist/cli/command-inputs.js +4 -3
- package/dist/cli/help.js +1 -0
- package/dist/cli/hook-ingress.js +1 -1
- package/dist/cli/run-cli.js +126 -193
- package/dist/cli/runtime-maintenance-diagnostics.js +46 -0
- package/dist/cli/runtime-maintenance-ingress.js +51 -0
- package/dist/cli/runtime-process.js +128 -0
- package/dist/cli.js +8 -5
- package/dist/domain/stage-catalog.js +1 -1
- package/dist/harness-runtime/bin/dd-agy.mjs +3 -2
- package/dist/harness-runtime/bin/dd-codex.mjs +3 -2
- package/dist/harness-runtime/bin/dd-droid.mjs +3 -2
- package/dist/harness-runtime/bin/dd-grok.mjs +16 -6
- package/dist/harness-runtime/bin/dd-opencode.mjs +3 -2
- package/dist/harness-runtime/bin/dd-zcode.mjs +20 -16
- package/dist/harness-runtime/lib/adapter-timeouts.d.mts +5 -0
- package/dist/harness-runtime/lib/adapter-timeouts.mjs +34 -0
- package/dist/harness-runtime/lib/codex-capacity-policy.d.mts +10 -0
- package/dist/harness-runtime/lib/codex-capacity-policy.mjs +89 -0
- package/dist/harness-runtime/lib/daemon-observation.d.mts +2 -0
- package/dist/harness-runtime/lib/daemon-observation.mjs +163 -0
- package/dist/harness-runtime/lib/daemon-operations.mjs +159 -15
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +215 -86
- package/dist/harness-runtime/lib/dd-agy.mjs +11 -13
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +47 -23
- package/dist/harness-runtime/lib/dd-codex.mjs +257 -99
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +32 -30
- package/dist/harness-runtime/lib/dd-droid.mjs +117 -31
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +96 -38
- package/dist/harness-runtime/lib/dd-grok.mjs +15 -13
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +54 -20
- package/dist/harness-runtime/lib/dd-opencode.mjs +44 -14
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +81 -76
- package/dist/harness-runtime/lib/dd-zcode.mjs +204 -116
- package/dist/harness-runtime/lib/driver-recovery.mjs +12 -2
- package/dist/harness-runtime/lib/lease-renewal.d.mts +8 -0
- package/dist/harness-runtime/lib/lease-renewal.mjs +23 -0
- package/dist/harness-runtime/lib/lifecycle-command.d.mts +3 -3
- package/dist/harness-runtime/lib/lifecycle-command.mjs +74 -20
- package/dist/harness-runtime/lib/managed-daemon.d.mts +11 -0
- package/dist/harness-runtime/lib/managed-daemon.mjs +321 -65
- package/dist/harness-runtime/lib/native-children.d.mts +13 -0
- package/dist/harness-runtime/lib/native-children.mjs +78 -0
- package/dist/harness-runtime/lib/native-hook-command.d.mts +4 -0
- package/dist/harness-runtime/lib/native-hook-command.mjs +115 -11
- package/dist/harness-runtime/lib/native-streams.mjs +45 -0
- package/dist/harness-runtime/lib/observation-clock.d.mts +18 -0
- package/dist/harness-runtime/lib/observation-clock.mjs +73 -17
- package/dist/harness-runtime/lib/operation-errors.d.mts +14 -0
- package/dist/harness-runtime/lib/operation-errors.mjs +105 -8
- package/dist/harness-runtime/lib/operation-progress.d.mts +7 -0
- package/dist/harness-runtime/lib/operation-progress.mjs +112 -0
- package/dist/harness-runtime/lib/process-json.mjs +136 -38
- package/dist/harness-runtime/lib/tool-observations.d.mts +1 -0
- package/dist/harness-runtime/lib/tool-observations.mjs +5 -2
- package/dist/harness-runtime/lib/zcode-usage.mjs +3 -2
- package/dist/runtime/context.js +8 -4
- package/dist/schemas/flow-run.schema.json +1 -0
- package/dist/schemas/plan-review-decision.schema.json +3 -3
- package/dist/schemas/run-control-receipt.schema.json +19 -0
- package/dist/services/code-checks.js +93 -27
- package/dist/services/codex-capacity.js +251 -74
- package/dist/services/codex-hook-delivery.js +2 -2
- package/dist/services/compatibility-preflight.js +4 -1
- package/dist/services/controller-fanout.js +9 -75
- package/dist/services/engines.js +70 -20
- package/dist/services/eval-snapshots.js +179 -40
- package/dist/services/external-work-launch.js +21 -9
- package/dist/services/harness-adapter.js +195 -25
- package/dist/services/hooks.js +65 -21
- package/dist/services/lifecycle-invocations.js +404 -32
- package/dist/services/managed-lease-monitor.js +128 -0
- package/dist/services/managed-processes.js +117 -53
- package/dist/services/merge-server.js +66 -23
- package/dist/services/recovery-observation-budget.js +67 -7
- package/dist/services/recovery-snapshot-database.js +7 -4
- package/dist/services/review-deferral.js +23 -0
- package/dist/services/run-control-receipt.js +1 -1
- package/dist/services/run-control-worker.js +177 -118
- package/dist/services/run-control.js +99 -8
- package/dist/services/run-controller-adapter.js +177 -51
- package/dist/services/run-controller-capture.js +14 -2
- package/dist/services/run-controller-process.js +11 -4
- package/dist/services/run-controller-recovery.js +17 -0
- package/dist/services/run-controller-state.js +68 -7
- package/dist/services/run-controller.js +207 -107
- package/dist/services/run-fork.js +8 -3
- package/dist/services/run-recovery-guard.js +75 -0
- package/dist/services/run-recovery-runtime.js +21 -30
- package/dist/services/run-recovery.js +17 -13
- package/dist/services/runs.js +40 -7
- package/dist/services/runtime-budget.js +32 -15
- package/dist/services/runtime-scope-control.js +8 -0
- package/dist/services/runtime-scope-resume.js +30 -8
- package/dist/services/runtime-scope-worker.js +118 -73
- package/dist/services/runtime-service.js +6 -7
- package/dist/services/scope-writer-cleanup.js +3 -2
- package/dist/services/stage-context.js +135 -0
- package/dist/services/stage-lifecycle.js +3 -15
- package/dist/services/stage-pause.js +3 -2
- package/dist/services/usage.js +14 -3
- package/dist/services/verification-matrix.js +30 -1
- package/dist/services/vnext-code-review.js +26 -13
- package/dist/services/vnext-code.js +10 -8
- package/dist/services/vnext-contracts.js +10 -1
- package/dist/services/vnext-execution-profile.js +75 -2
- package/dist/services/vnext-fanout.js +5 -0
- package/dist/services/vnext-merge.js +30 -11
- package/dist/services/vnext-plan-review.js +102 -35
- package/dist/services/vnext-plan.js +9 -7
- package/dist/services/vnext-protocolize.js +15 -9
- package/dist/services/vnext-specify.js +6 -6
- package/dist/services/work-registry.js +43 -6
- package/dist/services/workspace-bootstrap.js +14 -3
- package/dist/shared/errors.js +11 -6
- package/dist/storage/agent-output.js +26 -0
- package/dist/storage/database.js +80 -21
- package/package.json +7 -4
|
@@ -8,9 +8,9 @@ import { shortEntityReference } from "../shared/entity-references.js";
|
|
|
8
8
|
import { resolveWorkReference } from "../storage/work-references.js";
|
|
9
9
|
import { commandOption, commandPosition, continuationCommand, lifecycleCommandFromPayload, parseLifecycleCommand, shellSuffixOffset } from "./lifecycle-command.js";
|
|
10
10
|
import { requireProjectByRoot } from "./projects.js";
|
|
11
|
-
import { assertRunMutationAllowed, assertStageSettlementAllowed, assertWorkSettlementAllowed, recoveryGuard } from "./run-recovery.js";
|
|
11
|
+
import { assertRunMutationAllowed, assertStageSettlementAllowed, assertWorkSettlementAllowed, recoveryGuard, recoveryPacketReceipt } from "./run-recovery.js";
|
|
12
12
|
import { readNativeDaemonState, requireManagedDaemonBinding } from "./managed-daemon-binding.js";
|
|
13
|
-
import { managedProcessStatus } from "./managed-processes.js";
|
|
13
|
+
import { managedProcessStatus, processIsAlive } from "./managed-processes.js";
|
|
14
14
|
import { appendEvent } from "./run-controller-state.js";
|
|
15
15
|
import { recoverCommittedWorkStart } from "./work-registry.js";
|
|
16
16
|
import { observeCommandHook } from "./hooks.js";
|
|
@@ -205,6 +205,7 @@ function withoutInvocationId(command, aliases) {
|
|
|
205
205
|
return `${prefix ? `${prefix} ` : ""}${executable}${argv.length ? ` ${quoteModelFacingArgs(argv)}` : ""}${suffix ? ` ${suffix}` : ""}`.trim();
|
|
206
206
|
}
|
|
207
207
|
function publicInvocationCommand(context, row, command) {
|
|
208
|
+
assertPrivateStageStartContract(row, command);
|
|
208
209
|
if (!commandOption(invocation(command), "project-root"))
|
|
209
210
|
command = retainedCommandWithoutInvocationId(row);
|
|
210
211
|
const rendered = renderInvocationCommand(row, command);
|
|
@@ -213,6 +214,24 @@ function publicInvocationCommand(context, row, command) {
|
|
|
213
214
|
context.captureLifecycleAssignment?.(row.id);
|
|
214
215
|
return withoutInvocationId(rendered, { project: scope.projectRoot, ...(run?.workspace_root ? { workspace: run.workspace_root } : {}), ...(run?.run_root ? { run: run.run_root } : {}) });
|
|
215
216
|
}
|
|
217
|
+
/** Public fingerprints recognize a command; they do not replace its private
|
|
218
|
+
* controller contract when an issuer renders the same public operation. */
|
|
219
|
+
function assertPrivateStageStartContract(row, command) {
|
|
220
|
+
const actual = invocation(command), retained = invocation(row.command);
|
|
221
|
+
if (actual.operation !== "stage_start")
|
|
222
|
+
return;
|
|
223
|
+
const lifecycleAssignment = { issuer: "dd-flow", phase: "issuance", scope: JSON.parse(row.scope_json) };
|
|
224
|
+
if (commandPosition(actual, 0) !== commandPosition(retained, 0))
|
|
225
|
+
throw new AppError("invocation_argument_mismatch", "Retained Stage entry has a different RUN target", 1, { invocation_id: row.id, phase: "prepare", effect: "no_effect", recoverable: false, lifecycle_assignment: lifecycleAssignment });
|
|
226
|
+
for (const key of ["context-file", "context-sha256", "response-file", "require-session-binding"]) {
|
|
227
|
+
const supplied = commandOption(actual, key), expected = commandOption(retained, key);
|
|
228
|
+
const normalized = (value) => ["context-file", "response-file"].includes(key) && path.isAbsolute(value) ? path.normalize(value) : normalizedInvocationValue(key, value);
|
|
229
|
+
if ((supplied === undefined) !== (expected === undefined)
|
|
230
|
+
|| supplied !== undefined && normalized(supplied) !== normalized(expected)) {
|
|
231
|
+
throw new AppError("invocation_argument_mismatch", `Retained Stage entry has a different --${key} contract`, 1, { invocation_id: row.id, parameter: key, phase: "prepare", effect: "no_effect", recoverable: false, lifecycle_assignment: lifecycleAssignment });
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
216
235
|
/** Bind issued commands to the exact bytes sent to the model, not to an
|
|
217
236
|
* internal marker which is intentionally absent from that prompt. */
|
|
218
237
|
export function bindLifecyclePacketAssignments(context, prompt, ids) {
|
|
@@ -479,6 +498,14 @@ export function observeLifecycleCommand(context, command, nativeEvent) {
|
|
|
479
498
|
}
|
|
480
499
|
throw persistence;
|
|
481
500
|
}
|
|
501
|
+
// Serializers intentionally clone JSON-safe diagnostics. These response
|
|
502
|
+
// fields are the deliberate API overlay on the original rejection, only
|
|
503
|
+
// after its exact durable hook outcome has committed.
|
|
504
|
+
if (outcome.error && error)
|
|
505
|
+
for (const field of ["hook_retry_signature", "retry_command", "retry_instruction", "recoverable", "repeated_without_progress"]) {
|
|
506
|
+
if (Object.prototype.hasOwnProperty.call(error.details, field))
|
|
507
|
+
outcome.error.details[field] = error.details[field];
|
|
508
|
+
}
|
|
482
509
|
};
|
|
483
510
|
}
|
|
484
511
|
function lifecycleDiagnostics(context, projectId, runId, generation) {
|
|
@@ -509,7 +536,7 @@ export function lifecycleRetryCommands(context, stage) {
|
|
|
509
536
|
}
|
|
510
537
|
export function managedInvocationContext(context, input) {
|
|
511
538
|
const env = { ...context.env, DD_FLOW_CURRENT_INVOCATION: undefined, DD_FLOW_INVOCATION_SCOPE: undefined };
|
|
512
|
-
if (!["zcode-acp", "codex-desktop", "grok-acp", "antigravity-cli"].includes(input.harness))
|
|
539
|
+
if (!["zcode-acp", "codex-desktop", "grok-acp", "antigravity-cli", "droid-cli", "opencode-server"].includes(input.harness))
|
|
513
540
|
return { ...context, env };
|
|
514
541
|
const project = requireProjectByRoot(context, input.projectRoot);
|
|
515
542
|
const { state, process: daemon } = requireManagedDaemonBinding(context, { stateDir: input.stateDir, projectId: project.id, runId: input.runId });
|
|
@@ -547,6 +574,7 @@ export function managedLifecycleCommand(context, command, options = {}) {
|
|
|
547
574
|
if (retained.scope_json !== JSON.stringify(scope)) {
|
|
548
575
|
throw new AppError("invocation_scope_mismatch", "Lifecycle attempt belongs to another managed scope", 1, { invocation_id: suppliedId });
|
|
549
576
|
}
|
|
577
|
+
assertPrivateStageStartContract(retained, command);
|
|
550
578
|
checkCommand(retained, command);
|
|
551
579
|
assertRenderableInvocation(retained);
|
|
552
580
|
return publicInvocationCommand(context, retained, command);
|
|
@@ -742,12 +770,24 @@ export function assertLifecycleInvocationCurrent(context, scope, command) {
|
|
|
742
770
|
}
|
|
743
771
|
function checkCommand(row, command) {
|
|
744
772
|
const parsed = invocation(command);
|
|
773
|
+
assertSuppliedLifecycleTarget(row, parsed);
|
|
745
774
|
const scope = JSON.parse(row.scope_json);
|
|
746
775
|
if (commandOption(parsed, "invocation-id") !== row.id || fingerprint(parsed) !== row.fingerprint
|
|
747
776
|
|| path.resolve(commandOption(parsed, "project-root") ?? "") !== scope.projectRoot) {
|
|
748
777
|
throw new AppError("invocation_command_mismatch", "Lifecycle attempt belongs to another command or project", 1, { invocation_id: row.id });
|
|
749
778
|
}
|
|
750
779
|
}
|
|
780
|
+
/** Public commands may omit the coordinator RUN. A supplied target remains
|
|
781
|
+
* input to validate, not text to silently overwrite with retained authority. */
|
|
782
|
+
function assertSuppliedLifecycleTarget(row, actual) {
|
|
783
|
+
if (!["stage_start", "stage_finish", "recovery_accept"].includes(actual.operation) || actual.args.options.has("bootstrap"))
|
|
784
|
+
return;
|
|
785
|
+
const supplied = commandPosition(actual, 0), expected = commandPosition(invocation(row.command), 0);
|
|
786
|
+
if (supplied === undefined)
|
|
787
|
+
return;
|
|
788
|
+
if (!expected || ![expected, shortEntityReference(expected)].includes(supplied))
|
|
789
|
+
throw new AppError("invocation_argument_mismatch", "Supplied RUN target differs from its retained lifecycle assignment", 2, { invocation_id: row.id, parameter: "run", phase: "prepare", effect: "no_effect", recoverable: true });
|
|
790
|
+
}
|
|
751
791
|
/** Explicit issuance; callers retain this command. Read-only show/status must
|
|
752
792
|
* return that retained value, not issue a replacement on every observation. */
|
|
753
793
|
export function prepareLifecycleIssuance(command, value) {
|
|
@@ -770,7 +810,7 @@ export function issueLifecycleInvocation(context, command, scope) {
|
|
|
770
810
|
const id = crypto.randomUUID();
|
|
771
811
|
const prepared = commandWithInvocationId(command, id);
|
|
772
812
|
// Validate the actual inserted argv, including quoted heredoc boundaries.
|
|
773
|
-
const row = { id, scope_json: JSON.stringify(scope), fingerprint: fingerprint(parsed) };
|
|
813
|
+
const row = { id, command: prepared, scope_json: JSON.stringify(scope), fingerprint: fingerprint(parsed) };
|
|
774
814
|
checkCommand(row, prepared);
|
|
775
815
|
context.db.run("INSERT INTO lifecycle_invocations (id, scope_json, command, fingerprint, status, created_at, updated_at) VALUES (?, ?, ?, ?, 'issued', ?, ?)", [id, row.scope_json, prepared, row.fingerprint, context.now(), context.now()]);
|
|
776
816
|
return { id, command: prepared };
|
|
@@ -810,19 +850,32 @@ function checkNativeIdentity(scope, identity, id) {
|
|
|
810
850
|
}
|
|
811
851
|
}
|
|
812
852
|
function managedNativeOwner(context, identity, harness, projectId) {
|
|
853
|
+
const nativeRoot = (stateDir, sessionId) => {
|
|
854
|
+
try {
|
|
855
|
+
const state = readNativeDaemonState(stateDir);
|
|
856
|
+
if (state.daemon_id !== identity.daemonId)
|
|
857
|
+
return null;
|
|
858
|
+
const native = state.sessions?.find(native => typeof native === "string" ? native === sessionId : native.adapter_session_id === sessionId || native.provider_session_id === sessionId);
|
|
859
|
+
return typeof native === "string" ? native : native?.root_provider_session_id ?? native?.provider_session_id ?? null;
|
|
860
|
+
}
|
|
861
|
+
catch {
|
|
862
|
+
return null;
|
|
863
|
+
}
|
|
864
|
+
};
|
|
813
865
|
const owners = [];
|
|
814
866
|
const hasTable = (name) => Boolean(context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?", [name]));
|
|
815
867
|
if (hasTable("run_controllers")) {
|
|
816
|
-
const controllers = context.db.all("SELECT c.run_id, r.status AS run_status, c.status, c.generation, c.state_json FROM run_controllers c JOIN runs r ON r.project_id = c.project_id AND r.id = c.run_id WHERE c.project_id = ?", [projectId]);
|
|
868
|
+
const controllers = context.db.all("SELECT c.controller_id, c.run_id, r.status AS run_status, c.status, c.generation, c.state_json FROM run_controllers c JOIN runs r ON r.project_id = c.project_id AND r.id = c.run_id WHERE c.project_id = ?", [projectId]);
|
|
817
869
|
for (const controller of controllers) {
|
|
818
|
-
if (!controller.state_json.includes(identity.rootSessionId))
|
|
819
|
-
continue;
|
|
820
870
|
const state = JSON.parse(controller.state_json);
|
|
821
871
|
for (const [index, session] of (state.sessions ?? []).entries()) {
|
|
822
|
-
if (session.
|
|
872
|
+
if (typeof session.state_dir !== "string")
|
|
873
|
+
continue;
|
|
874
|
+
const root = nativeRoot(session.state_dir, session.id);
|
|
875
|
+
if (root !== identity.rootSessionId)
|
|
823
876
|
continue;
|
|
824
877
|
owners.push({ runId: controller.run_id, rootSessionId: session.id,
|
|
825
|
-
stateDir: session.state_dir, harness: runtimeHarness(session.profile), generation: controller.generation,
|
|
878
|
+
stateDir: session.state_dir, harness: runtimeHarness(session.profile), generation: controller.generation, ownerId: controller.controller_id,
|
|
826
879
|
active: ["running", "paused", "waiting_for_user", "blocked"].includes(controller.run_status)
|
|
827
880
|
&& !["completed", "cancelled", "superseded"].includes(controller.status)
|
|
828
881
|
&& state.current_session === index && session.stopped !== true });
|
|
@@ -830,8 +883,10 @@ function managedNativeOwner(context, identity, harness, projectId) {
|
|
|
830
883
|
}
|
|
831
884
|
}
|
|
832
885
|
if (hasTable("work_launches")) {
|
|
833
|
-
const launches = context.db.all("SELECT w.run_id, r.status AS run_status, l.work_id, l.operation_id, l.status, l.state_dir, l.provider_session_id, l.profile_json FROM work_launches l JOIN works w ON w.work_id = l.work_id JOIN runs r ON r.project_id = w.project_id AND r.id = w.run_id WHERE w.project_id = ?
|
|
886
|
+
const launches = context.db.all("SELECT w.run_id, r.status AS run_status, l.work_id, l.operation_id, l.status, l.state_dir, l.provider_session_id, l.profile_json FROM work_launches l JOIN works w ON w.work_id = l.work_id JOIN runs r ON r.project_id = w.project_id AND r.id = w.run_id WHERE w.project_id = ?", [projectId]);
|
|
834
887
|
for (const launch of launches) {
|
|
888
|
+
if (nativeRoot(launch.state_dir, launch.provider_session_id) !== identity.rootSessionId)
|
|
889
|
+
continue;
|
|
835
890
|
// Cooperative pause/drain may retain an admitted Work settlement while
|
|
836
891
|
// the RUN is paused; only terminal RUN states retire its native root.
|
|
837
892
|
const active = ["running", "paused", "waiting_for_user", "blocked"].includes(launch.run_status)
|
|
@@ -849,8 +904,11 @@ function managedNativeOwner(context, identity, harness, projectId) {
|
|
|
849
904
|
}
|
|
850
905
|
}
|
|
851
906
|
if (hasTable("merge_requests")) {
|
|
852
|
-
const requests = context.db.all("SELECT m.merge_request_id, m.run_id, r.status AS run_status, m.status, m.executor_work_id, m.dispatch_owner, m.dispatch_lease_token, m.adapter_receipt_json FROM merge_requests m JOIN runs r ON r.project_id = m.project_id AND r.id = m.run_id WHERE m.project_id = ? AND m.execution_route = 'server' AND
|
|
907
|
+
const requests = context.db.all("SELECT m.merge_request_id, m.run_id, r.status AS run_status, m.status, m.executor_work_id, m.dispatch_owner, m.dispatch_lease_token, m.adapter_receipt_json FROM merge_requests m JOIN runs r ON r.project_id = m.project_id AND r.id = m.run_id WHERE m.project_id = ? AND m.execution_route = 'server' AND json_valid(m.adapter_receipt_json)", [projectId]);
|
|
853
908
|
for (const request of requests) {
|
|
909
|
+
const retained = JSON.parse(request.adapter_receipt_json);
|
|
910
|
+
if (!retained.session_id || !retained.state_dir || nativeRoot(retained.state_dir, retained.session_id) !== identity.rootSessionId)
|
|
911
|
+
continue;
|
|
854
912
|
const active = ["running", "paused", "waiting_for_user", "blocked"].includes(request.run_status)
|
|
855
913
|
&& ["dispatching", "active", "waiting_user", "action_required", "recovery_required"].includes(request.status);
|
|
856
914
|
if (!active)
|
|
@@ -883,13 +941,16 @@ function managedNativeOwner(context, identity, harness, projectId) {
|
|
|
883
941
|
harness: runtimeHarness(receipt.profile), ownerId: owner.owner_id, active });
|
|
884
942
|
}
|
|
885
943
|
}
|
|
944
|
+
if (owners.length > 1)
|
|
945
|
+
throw new AppError("invocation_scope_unproven", "Native root has more than one persisted owner", 1, { effect: "no_effect" });
|
|
886
946
|
for (const owner of owners) {
|
|
887
947
|
if (!owner.active)
|
|
888
948
|
throw new AppError("invocation_scope_unproven", "Managed native root is no longer active", 1, { effect: "no_effect" });
|
|
889
949
|
if (owner.harness !== harness)
|
|
890
950
|
throw new AppError("invocation_scope_unproven", "Native root belongs to another managed harness", 1, { effect: "no_effect" });
|
|
891
951
|
const binding = requireManagedDaemonBinding(context, { stateDir: owner.stateDir, projectId, runId: owner.runId, ...(owner.ownerId ? { ownerId: owner.ownerId } : {}) });
|
|
892
|
-
|
|
952
|
+
const kind = { "grok-acp": "grok-daemon", "antigravity-cli": "agy-daemon", "zcode-acp": "zcode-daemon", "codex-desktop": "codex-daemon", "droid-cli": "droid-daemon", "opencode-server": "opencode-daemon" }[harness];
|
|
953
|
+
if (binding.process.kind !== kind) {
|
|
893
954
|
throw new AppError("invocation_scope_unproven", "Native daemon kind differs from its persisted harness", 1, { effect: "no_effect" });
|
|
894
955
|
}
|
|
895
956
|
if (binding.state.daemon_id !== identity.daemonId)
|
|
@@ -900,17 +961,266 @@ function managedNativeOwner(context, identity, harness, projectId) {
|
|
|
900
961
|
if (!root || !Number.isSafeInteger(generation) || generation < 0 || owner.generation !== undefined && generation !== owner.generation) {
|
|
901
962
|
throw new AppError("invocation_scope_unproven", "Managed native root or RUN generation is not persisted", 1, { effect: "no_effect" });
|
|
902
963
|
}
|
|
903
|
-
return { projectId, runId: owner.runId, generation };
|
|
964
|
+
return { projectId, runId: owner.runId, generation, ...(owner.ownerId ? { ownerId: owner.ownerId } : {}) };
|
|
904
965
|
}
|
|
905
966
|
return null;
|
|
906
967
|
}
|
|
968
|
+
/** Manual recovery retains native receipt authority, not controller issuance.
|
|
969
|
+
* Recognize only the existing pinned recovery dispatch and its live root. */
|
|
970
|
+
export function manualRecoveryInvocationScope(context, projectRoot, identity, harness) {
|
|
971
|
+
if (identity.sessionId !== identity.rootSessionId || identity.parentSessionId !== null
|
|
972
|
+
|| !context.db.get("SELECT 1 FROM sqlite_master WHERE name = 'run_recovery_bindings'"))
|
|
973
|
+
return null;
|
|
974
|
+
const project = context.db.get("SELECT id,root FROM projects WHERE root = ?", [canonicalPath(projectRoot)]);
|
|
975
|
+
if (!project)
|
|
976
|
+
return null;
|
|
977
|
+
let resolved = null;
|
|
978
|
+
for (const record of managedProcessStatus(context)) {
|
|
979
|
+
if (record.project_id !== project.id || !record.run_id || !record.kind.endsWith("-daemon"))
|
|
980
|
+
continue;
|
|
981
|
+
const metadata = JSON.parse(record.metadata_json);
|
|
982
|
+
if (typeof metadata.state_dir !== "string")
|
|
983
|
+
continue;
|
|
984
|
+
let state;
|
|
985
|
+
try {
|
|
986
|
+
state = readNativeDaemonState(metadata.state_dir);
|
|
987
|
+
}
|
|
988
|
+
catch {
|
|
989
|
+
continue;
|
|
990
|
+
}
|
|
991
|
+
if (state.daemon_id !== identity.daemonId)
|
|
992
|
+
continue;
|
|
993
|
+
const guard = recoveryGuard(context, project.id, record.run_id);
|
|
994
|
+
if (!guard || !["resuming", "recovered"].includes(guard.status) || metadata.run_generation !== guard.generation)
|
|
995
|
+
return null;
|
|
996
|
+
const run = context.db.get("SELECT status FROM runs WHERE project_id = ? AND id = ?", [project.id, record.run_id]);
|
|
997
|
+
if (!run || !["running", "paused", "waiting_for_user", "blocked"].includes(run.status))
|
|
998
|
+
return null;
|
|
999
|
+
if (context.db.get("SELECT 1 FROM sqlite_master WHERE name = 'run_controllers'")
|
|
1000
|
+
&& context.db.get("SELECT 1 FROM run_controllers WHERE project_id = ? AND run_id = ? AND generation = ? AND status NOT IN ('completed','cancelled','superseded')", [project.id, record.run_id, guard.generation]))
|
|
1001
|
+
return null;
|
|
1002
|
+
const binding = context.db.get("SELECT * FROM run_recovery_bindings WHERE recovery_id = ?", [guard.recovery_id]);
|
|
1003
|
+
if (!binding || binding.daemon_id !== identity.daemonId || binding.provider_session_id !== identity.rootSessionId || binding.harness !== harness
|
|
1004
|
+
|| binding.status !== (guard.status === "resuming" ? "prepared" : "accepted") || !binding.runtime_dispatch_json)
|
|
1005
|
+
return null;
|
|
1006
|
+
const dispatch = JSON.parse(binding.runtime_dispatch_json);
|
|
1007
|
+
if (dispatch.process_id !== record.id || dispatch.owner_id !== record.owner_id || dispatch.state_dir !== metadata.state_dir
|
|
1008
|
+
|| dispatch.daemon_id !== identity.daemonId || record.state !== "running" || record.lease_expires_at <= context.now() || !processIsAlive(record))
|
|
1009
|
+
return null;
|
|
1010
|
+
const physical = requireManagedDaemonBinding(context, { stateDir: metadata.state_dir, projectId: project.id, runId: record.run_id, ownerId: dispatch.owner_id });
|
|
1011
|
+
const kind = { "codex-desktop": "codex-daemon", "zcode-acp": "zcode-daemon", "grok-acp": "grok-daemon", "antigravity-cli": "agy-daemon", "droid-cli": "droid-daemon", "opencode-server": "opencode-daemon" }[harness];
|
|
1012
|
+
if (physical.process.id !== dispatch.process_id || physical.process.kind !== kind || !physical.state.sessions?.some(native => typeof native === "string" ? native === identity.rootSessionId
|
|
1013
|
+
: native.provider_session_id === identity.rootSessionId && (native.root_provider_session_id ?? native.provider_session_id) === identity.rootSessionId))
|
|
1014
|
+
return null;
|
|
1015
|
+
const packet = recoveryPacketReceipt(binding).packet;
|
|
1016
|
+
if (packet.run_id !== record.run_id || packet.generation !== guard.generation || packet.recovery_id !== guard.recovery_id)
|
|
1017
|
+
return null;
|
|
1018
|
+
if (resolved)
|
|
1019
|
+
throw new AppError("invocation_scope_unproven", "Manual recovery native root has more than one physical owner", 1, { effect: "no_effect" });
|
|
1020
|
+
resolved = { projectRoot: project.root, runId: record.run_id, generation: guard.generation, daemonId: identity.daemonId, rootSessionId: identity.rootSessionId };
|
|
1021
|
+
}
|
|
1022
|
+
return resolved;
|
|
1023
|
+
}
|
|
1024
|
+
/** Hook-only recovery commands must carry their own target, not acquire one
|
|
1025
|
+
* through assignment expansion. Existing recovery/Work guards own mutation. */
|
|
1026
|
+
export function assertManualRecoveryInvocationTarget(context, scope, command) {
|
|
1027
|
+
const parsed = invocation(command);
|
|
1028
|
+
const projectRoot = commandOption(parsed, "project-root");
|
|
1029
|
+
if (!["recovery_accept", "stage_start", "stage_finish", "stage_pause", "stage_resume", "work_start", "work_finish", "work_fail", "merge_apply", "merge_repair"].includes(parsed.operation)
|
|
1030
|
+
|| parsed.args.options.has("bootstrap") || !commandPosition(parsed, 0) || !projectRoot
|
|
1031
|
+
|| projectRoot !== "@project" && (!path.isAbsolute(projectRoot) || canonicalPath(projectRoot) !== scope.projectRoot)) {
|
|
1032
|
+
throw new AppError("invocation_scope_mismatch", "Manual recovery requires an explicit owned target and project", 1, { effect: "no_effect" });
|
|
1033
|
+
}
|
|
1034
|
+
if (parsed.operation === "recovery_accept" && commandOption(parsed, "recovery-id") !== recoveryGuard(context, requireProjectByRoot(context, scope.projectRoot).id, scope.runId)?.recovery_id)
|
|
1035
|
+
throw new AppError("invocation_scope_mismatch", "Recovery acknowledgement targets another recovery generation", 1, { effect: "no_effect" });
|
|
1036
|
+
assertLifecycleInvocationCurrent(context, scope, command);
|
|
1037
|
+
}
|
|
1038
|
+
/** Resolve scope from the physical runtime owner, never from a command match. */
|
|
1039
|
+
export function managedNativeInvocationScope(context, projectRoot, identity, harness) {
|
|
1040
|
+
const project = context.db.get("SELECT id,root FROM projects WHERE root = ?", [canonicalPath(projectRoot)]);
|
|
1041
|
+
// Bootstrap native hooks precede project registration. They may retain
|
|
1042
|
+
// evidence, but have no managed RUN authority to resolve yet.
|
|
1043
|
+
if (!project)
|
|
1044
|
+
return null;
|
|
1045
|
+
const owner = managedNativeOwner(context, identity, harness, project.id);
|
|
1046
|
+
if (owner)
|
|
1047
|
+
return { projectRoot: project.root, daemonId: identity.daemonId, rootSessionId: identity.rootSessionId, runId: owner.runId, generation: owner.generation };
|
|
1048
|
+
const manualRecovery = manualRecoveryInvocationScope(context, project.root, identity, harness);
|
|
1049
|
+
if (manualRecovery)
|
|
1050
|
+
return manualRecovery;
|
|
1051
|
+
for (const record of managedProcessStatus(context)) {
|
|
1052
|
+
const metadata = JSON.parse(record.metadata_json);
|
|
1053
|
+
if (!record.kind.endsWith("-daemon") || typeof metadata.state_dir !== "string")
|
|
1054
|
+
continue;
|
|
1055
|
+
let state;
|
|
1056
|
+
try {
|
|
1057
|
+
state = readNativeDaemonState(metadata.state_dir);
|
|
1058
|
+
}
|
|
1059
|
+
catch {
|
|
1060
|
+
continue;
|
|
1061
|
+
}
|
|
1062
|
+
if (state.daemon_id === identity.daemonId && state.config?.runtime_owner)
|
|
1063
|
+
throw new AppError("invocation_scope_unproven", "Managed daemon has no retained native owner", 1, { effect: "no_effect" });
|
|
1064
|
+
}
|
|
1065
|
+
return null;
|
|
1066
|
+
}
|
|
1067
|
+
export function cliManagedInvocationScope(context) {
|
|
1068
|
+
const daemonId = context.env.DD_FLOW_DAEMON_ID;
|
|
1069
|
+
if (!daemonId)
|
|
1070
|
+
return null;
|
|
1071
|
+
const scopes = [];
|
|
1072
|
+
for (const record of managedProcessStatus(context)) {
|
|
1073
|
+
const metadata = JSON.parse(record.metadata_json);
|
|
1074
|
+
if (!record.kind.endsWith("-daemon") || typeof metadata.state_dir !== "string")
|
|
1075
|
+
continue;
|
|
1076
|
+
let state;
|
|
1077
|
+
try {
|
|
1078
|
+
state = readNativeDaemonState(metadata.state_dir);
|
|
1079
|
+
}
|
|
1080
|
+
catch {
|
|
1081
|
+
continue;
|
|
1082
|
+
}
|
|
1083
|
+
if (state.daemon_id !== daemonId || !record.project_id || !record.run_id)
|
|
1084
|
+
continue;
|
|
1085
|
+
const harness = { "codex-daemon": "codex-desktop", "zcode-daemon": "zcode-acp", "grok-daemon": "grok-acp", "agy-daemon": "antigravity-cli", "droid-daemon": "droid-cli", "opencode-daemon": "opencode-server" }[record.kind];
|
|
1086
|
+
const project = context.db.get("SELECT root FROM projects WHERE id = ?", [record.project_id]);
|
|
1087
|
+
if (!harness || !project)
|
|
1088
|
+
continue;
|
|
1089
|
+
requireManagedDaemonBinding(context, { stateDir: metadata.state_dir, projectId: record.project_id, runId: record.run_id });
|
|
1090
|
+
const roots = new Set((state.sessions ?? []).flatMap(native => typeof native === "string" ? [native] : native.root_provider_session_id ? [native.root_provider_session_id] : native.provider_session_id ? [native.provider_session_id] : []));
|
|
1091
|
+
for (const rootSessionId of roots) {
|
|
1092
|
+
let scope;
|
|
1093
|
+
try {
|
|
1094
|
+
scope = managedNativeInvocationScope(context, project.root, { daemonId, rootSessionId, sessionId: rootSessionId, parentSessionId: null, toolCallId: "cli-scope-inspection" }, harness);
|
|
1095
|
+
}
|
|
1096
|
+
catch (error) {
|
|
1097
|
+
if (error instanceof AppError && error.code === "invocation_scope_unproven")
|
|
1098
|
+
continue;
|
|
1099
|
+
throw error;
|
|
1100
|
+
}
|
|
1101
|
+
if (scope && !scopes.some(prior => JSON.stringify(prior) === JSON.stringify(scope)))
|
|
1102
|
+
scopes.push(scope);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
if (scopes.length !== 1)
|
|
1106
|
+
throw new AppError("invocation_scope_unproven", "CLI has no unique persisted current managed native scope", 1, { effect: "no_effect" });
|
|
1107
|
+
return scopes[0];
|
|
1108
|
+
}
|
|
1109
|
+
export function isControllerManagedInvocation(context, projectRoot, runId) {
|
|
1110
|
+
const configured = context.env.DD_FLOW_INVOCATION_SCOPE;
|
|
1111
|
+
if (!configured)
|
|
1112
|
+
return false;
|
|
1113
|
+
const scope = JSON.parse(configured);
|
|
1114
|
+
if (context.env.DD_FLOW_CURRENT_INVOCATION) {
|
|
1115
|
+
const current = load(context, context.env.DD_FLOW_CURRENT_INVOCATION);
|
|
1116
|
+
const identity = current.identity_json && JSON.parse(current.identity_json);
|
|
1117
|
+
if (identity && identity[2] !== scope.rootSessionId)
|
|
1118
|
+
throw new AppError("invocation_scope_unproven", "A native child cannot issue coordinator Stage successors", 1);
|
|
1119
|
+
}
|
|
1120
|
+
if (scope.projectRoot !== canonicalPath(projectRoot) || scope.runId !== runId)
|
|
1121
|
+
throw new AppError("invocation_scope_mismatch", "Successor differs from retained lifecycle scope", 1);
|
|
1122
|
+
const projectId = requireProjectByRoot(context, projectRoot).id;
|
|
1123
|
+
const rows = context.db.get("SELECT 1 FROM sqlite_master WHERE name = 'run_controllers'")
|
|
1124
|
+
? context.db.all("SELECT controller_id,generation,status,state_json FROM run_controllers WHERE project_id = ? AND run_id = ?", [projectId, runId]) : [];
|
|
1125
|
+
let matches = 0;
|
|
1126
|
+
for (const row of rows) {
|
|
1127
|
+
const state = JSON.parse(row.state_json);
|
|
1128
|
+
const session = state.sessions?.[state.current_session];
|
|
1129
|
+
if (!session)
|
|
1130
|
+
continue;
|
|
1131
|
+
let nativeState;
|
|
1132
|
+
try {
|
|
1133
|
+
nativeState = readNativeDaemonState(session.state_dir);
|
|
1134
|
+
}
|
|
1135
|
+
catch {
|
|
1136
|
+
continue;
|
|
1137
|
+
}
|
|
1138
|
+
if (nativeState.daemon_id !== scope.daemonId)
|
|
1139
|
+
continue;
|
|
1140
|
+
const binding = requireManagedDaemonBinding(context, { stateDir: session.state_dir, projectId, runId, ownerId: row.controller_id });
|
|
1141
|
+
if (binding.state.daemon_id !== scope.daemonId)
|
|
1142
|
+
continue;
|
|
1143
|
+
const native = binding.state.sessions?.find(native => typeof native === "string" ? native === session.id && native === scope.rootSessionId
|
|
1144
|
+
: (native.adapter_session_id === session.id || native.provider_session_id === session.id) && (native.root_provider_session_id ?? native.provider_session_id) === scope.rootSessionId);
|
|
1145
|
+
const generation = JSON.parse(binding.process.metadata_json).run_generation;
|
|
1146
|
+
if (!native || session.stopped || ["completed", "cancelled", "superseded"].includes(row.status) || row.generation !== scope.generation || generation !== scope.generation)
|
|
1147
|
+
throw new AppError("invocation_scope_unproven", "Controller successor owner is stale or unproven", 1);
|
|
1148
|
+
matches += 1;
|
|
1149
|
+
}
|
|
1150
|
+
if (matches > 1)
|
|
1151
|
+
throw new AppError("invocation_scope_unproven", "Stage successor has multiple coordinator owners", 1);
|
|
1152
|
+
if (matches === 1)
|
|
1153
|
+
return true;
|
|
1154
|
+
for (const record of managedProcessStatus(context)) {
|
|
1155
|
+
const metadata = JSON.parse(record.metadata_json);
|
|
1156
|
+
if (!record.kind.endsWith("-daemon") || typeof metadata.state_dir !== "string")
|
|
1157
|
+
continue;
|
|
1158
|
+
let state;
|
|
1159
|
+
try {
|
|
1160
|
+
state = readNativeDaemonState(metadata.state_dir);
|
|
1161
|
+
}
|
|
1162
|
+
catch {
|
|
1163
|
+
continue;
|
|
1164
|
+
}
|
|
1165
|
+
if (state.daemon_id !== scope.daemonId)
|
|
1166
|
+
continue;
|
|
1167
|
+
if (/^run:.+:(?:work|merge):/.test(record.owner_id))
|
|
1168
|
+
return false;
|
|
1169
|
+
throw new AppError("invocation_scope_unproven", "Managed coordinator has no retained current controller Session", 1);
|
|
1170
|
+
}
|
|
1171
|
+
throw new AppError("invocation_scope_unproven", "Managed successor has no proven physical owner", 1);
|
|
1172
|
+
}
|
|
907
1173
|
/** recordReceipt is a synchronous SQL-only hook ingress callback. Native
|
|
908
1174
|
* adapters must prepare any filesystem or process evidence before calling. */
|
|
909
1175
|
export function observeLifecycleInvocation(context, input) {
|
|
910
1176
|
storage(context);
|
|
1177
|
+
const projectRoot = input.projectId ? context.db.get("SELECT root FROM projects WHERE id = ?", [input.projectId])?.root : undefined;
|
|
1178
|
+
const provenScope = projectRoot && input.harness ? managedNativeInvocationScope(context, projectRoot, input.identity, input.harness) : null;
|
|
1179
|
+
const owner = provenScope && input.harness && input.projectId ? managedNativeOwner(context, input.identity, input.harness, input.projectId) : null;
|
|
1180
|
+
const ownerSnapshot = () => {
|
|
1181
|
+
if (!provenScope?.runId || !input.projectId)
|
|
1182
|
+
return null;
|
|
1183
|
+
const rows = [context.db.get("SELECT status FROM runs WHERE project_id = ? AND id = ?", [input.projectId, provenScope.runId])];
|
|
1184
|
+
if (context.db.get("SELECT 1 FROM sqlite_master WHERE name = 'run_controllers'"))
|
|
1185
|
+
rows.push(context.db.all("SELECT controller_id,generation,status,owner_token,state_json FROM run_controllers WHERE project_id = ? AND run_id = ? AND controller_id = ? ORDER BY controller_id", [input.projectId, provenScope.runId, owner?.ownerId ?? ""]).map(row => {
|
|
1186
|
+
const state = JSON.parse(row.state_json), session = state.sessions?.[state.current_session];
|
|
1187
|
+
return [row.controller_id, row.generation, row.status, row.owner_token, state.current_session, session && [session.id, session.state_dir, session.stopped, session.owner]];
|
|
1188
|
+
}));
|
|
1189
|
+
for (const [table, query] of [
|
|
1190
|
+
["work_launches", "SELECT l.status,l.provider_session_id,l.profile_json FROM work_launches l JOIN works w ON w.work_id = l.work_id WHERE w.project_id = ? AND w.run_id = ? AND CASE WHEN json_valid(l.profile_json) THEN json_extract(l.profile_json, '$.runtime_adapter.owner.owner_id') END = ? ORDER BY l.operation_id"],
|
|
1191
|
+
["merge_requests", "SELECT status,dispatch_owner,dispatch_lease_token,adapter_receipt_json FROM merge_requests WHERE project_id = ? AND run_id = ? AND (dispatch_owner = ? OR CASE WHEN json_valid(adapter_receipt_json) THEN json_extract(adapter_receipt_json, '$.owner.owner_id') END = ?) ORDER BY merge_request_id"]
|
|
1192
|
+
])
|
|
1193
|
+
if (context.db.get("SELECT 1 FROM sqlite_master WHERE name = ?", [table]))
|
|
1194
|
+
rows.push(context.db.all(query, table === "merge_requests" ? [input.projectId, provenScope.runId, owner?.ownerId ?? "", owner?.ownerId ?? ""] : [input.projectId, provenScope.runId, owner?.ownerId ?? ""]));
|
|
1195
|
+
if (context.db.get("SELECT 1 FROM sqlite_master WHERE name = 'run_recovery_bindings'"))
|
|
1196
|
+
rows.push(context.db.all("SELECT g.recovery_id,g.generation,g.status,b.* FROM run_recovery_guards g JOIN run_recovery_bindings b ON b.recovery_id = g.recovery_id WHERE g.project_id = ? AND g.run_id = ? ORDER BY g.generation", [input.projectId, provenScope.runId]));
|
|
1197
|
+
return JSON.stringify(rows);
|
|
1198
|
+
};
|
|
1199
|
+
const retainedOwner = ownerSnapshot();
|
|
911
1200
|
const supplied = input.id ? context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [input.id]) : undefined;
|
|
912
1201
|
const analysis = parseLifecycleCommand(input.command);
|
|
913
1202
|
if (analysis.kind === "none" || analysis.supported === false) {
|
|
1203
|
+
let issuedBarrier;
|
|
1204
|
+
const singleLeaf = analysis.kind !== "none" && (analysis.invocations?.length ?? 1) === 1 && !(analysis.kind === "compound" && analysis.reason === "multiple_lifecycle_invocations");
|
|
1205
|
+
const refused = singleLeaf ? context.db.all("SELECT * FROM lifecycle_invocations WHERE status = 'issued'").filter(row => {
|
|
1206
|
+
const scope = JSON.parse(row.scope_json);
|
|
1207
|
+
if (scope.daemonId !== input.identity.daemonId || scope.rootSessionId !== input.identity.rootSessionId || provenScope && row.scope_json !== JSON.stringify(provenScope))
|
|
1208
|
+
return false;
|
|
1209
|
+
try {
|
|
1210
|
+
return fingerprint(invocation(canonicalManagedCommand(context, analysis.invocation.command, scope))) === row.fingerprint;
|
|
1211
|
+
}
|
|
1212
|
+
catch {
|
|
1213
|
+
return false;
|
|
1214
|
+
}
|
|
1215
|
+
}) : [];
|
|
1216
|
+
const correction = supplied ?? (refused.length === 1 ? refused[0] : undefined);
|
|
1217
|
+
if (singleLeaf && correction && correction.status === "issued" && (correction.deadline === null || correction.deadline > Date.now())) {
|
|
1218
|
+
const scope = JSON.parse(correction.scope_json);
|
|
1219
|
+
checkNativeIdentity(scope, input.identity, correction.id);
|
|
1220
|
+
assertSuppliedLifecycleTarget(correction, invocation(canonicalManagedCommand(context, analysis.invocation.command, scope)));
|
|
1221
|
+
if ((!provenScope || correction.scope_json === JSON.stringify(provenScope)) && fingerprint(invocation(canonicalManagedCommand(context, analysis.invocation.command, scope))) === correction.fingerprint)
|
|
1222
|
+
issuedBarrier = { issued_invocation_id: correction.id, cli_admission_barrier: true };
|
|
1223
|
+
}
|
|
914
1224
|
const eventKey = input.recordReceipt();
|
|
915
1225
|
if (analysis.kind !== "none") {
|
|
916
1226
|
const retained = context.db.get("SELECT outcome_json FROM hook_events WHERE event_key = ?", [eventKey]);
|
|
@@ -933,7 +1243,7 @@ export function observeLifecycleInvocation(context, input) {
|
|
|
933
1243
|
}), eventKey]);
|
|
934
1244
|
}
|
|
935
1245
|
if (analysis.kind !== "none")
|
|
936
|
-
throw new AppError("lifecycle_shell_syntax_invalid", "This shell form cannot bind exactly one lifecycle execution; use the exact issued lifecycle command without the unsupported shell context", 1, { phase: "prepare", effect: "no_effect", recoverable: true, disposition: "pre_cli_shell_refusal", reason: analysis.kind === "compound" ? analysis.reason : "unsupported_lifecycle_command", event_key: eventKey });
|
|
1246
|
+
throw new AppError("lifecycle_shell_syntax_invalid", "This shell form cannot bind exactly one lifecycle execution; use the exact issued lifecycle command without the unsupported shell context", 1, { phase: "prepare", effect: "no_effect", recoverable: true, disposition: "pre_cli_shell_refusal", reason: analysis.kind === "compound" ? analysis.reason : "unsupported_lifecycle_command", event_key: eventKey, ...issuedBarrier });
|
|
937
1247
|
return { eventKey, duplicate: false, invocationId: null };
|
|
938
1248
|
}
|
|
939
1249
|
// Admit the one parsed leaf, never execute or fingerprint the prefix/suffix.
|
|
@@ -942,26 +1252,39 @@ export function observeLifecycleInvocation(context, input) {
|
|
|
942
1252
|
throw new AppError("invocation_directory_mismatch", "Bound lifecycle execution cwd must be absolute", 1, { phase: "prepare", effect: "no_effect", recoverable: false });
|
|
943
1253
|
const deliveredIdentity = JSON.stringify([input.identity.daemonId, input.identity.rootSessionId, input.identity.sessionId, input.identity.parentSessionId, input.identity.toolCallId,
|
|
944
1254
|
...(input.executionCwd === undefined ? [] : [{ executionCwd: canonicalPath(input.executionCwd) }])]);
|
|
945
|
-
const
|
|
1255
|
+
const matching = context.db.all("SELECT * FROM lifecycle_invocations WHERE status IN ('issued','observed','executing','settled') ORDER BY rowid DESC")
|
|
946
1256
|
.filter(row => {
|
|
947
|
-
if (["executing", "settled"].includes(row.status) && row.identity_json !== deliveredIdentity)
|
|
948
|
-
return false;
|
|
949
1257
|
const scope = JSON.parse(row.scope_json);
|
|
950
1258
|
if (scope.daemonId !== input.identity.daemonId || scope.rootSessionId !== input.identity.rootSessionId)
|
|
951
1259
|
return false;
|
|
1260
|
+
if (provenScope && row.scope_json !== JSON.stringify(provenScope))
|
|
1261
|
+
return false;
|
|
952
1262
|
try {
|
|
953
|
-
|
|
1263
|
+
if (fingerprint(invocation(canonicalManagedCommand(context, leafCommand, scope))) !== row.fingerprint)
|
|
1264
|
+
return false;
|
|
954
1265
|
}
|
|
955
1266
|
catch {
|
|
956
1267
|
return false;
|
|
957
1268
|
}
|
|
1269
|
+
if (row.identity_json && row.identity_json !== deliveredIdentity
|
|
1270
|
+
&& JSON.stringify(JSON.parse(row.identity_json).slice(0, 5)) === JSON.stringify(JSON.parse(deliveredIdentity).slice(0, 5))) {
|
|
1271
|
+
throw new AppError("invocation_identity_mismatch", "The same native tool call changed its bound execution route", 1, { phase: "prepare", effect: "no_effect", recoverable: false });
|
|
1272
|
+
}
|
|
1273
|
+
if (row.status === "executing" && row.identity_json !== deliveredIdentity)
|
|
1274
|
+
return false;
|
|
1275
|
+
if (row.status === "settled" && row.outcome_json && JSON.parse(row.outcome_json).error && row.identity_json !== deliveredIdentity)
|
|
1276
|
+
return false;
|
|
1277
|
+
return true;
|
|
958
1278
|
});
|
|
1279
|
+
const candidates = lifecycleCandidates(matching, deliveredIdentity);
|
|
959
1280
|
// A known explicit ID remains a compatibility path. A missing or mistyped
|
|
960
1281
|
// public ID is corrected only by an exact operation/target match inside the
|
|
961
1282
|
// trusted native root. Never fuzzy-match UUID text or select a global latest.
|
|
962
1283
|
const initial = supplied ?? (candidates.length === 1 ? candidates[0] : undefined);
|
|
963
|
-
|
|
964
|
-
|
|
1284
|
+
if (!supplied && candidates.length > 1) {
|
|
1285
|
+
input.recordReceipt();
|
|
1286
|
+
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 } } : {}) });
|
|
1287
|
+
}
|
|
965
1288
|
if (owner) {
|
|
966
1289
|
if (!initial)
|
|
967
1290
|
throw new AppError("invocation_assignment_missing", "Managed native lifecycle call has no issued assignment", 1, { effect: "no_effect", recoverable: false });
|
|
@@ -971,24 +1294,33 @@ export function observeLifecycleInvocation(context, input) {
|
|
|
971
1294
|
}
|
|
972
1295
|
}
|
|
973
1296
|
if (!initial) {
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
1297
|
+
if (provenScope)
|
|
1298
|
+
assertManualRecoveryInvocationTarget(context, provenScope, leafCommand);
|
|
1299
|
+
return context.db.writeTransaction(() => {
|
|
1300
|
+
if (retainedOwner !== ownerSnapshot())
|
|
1301
|
+
throw new AppError("invocation_scope_unproven", "Native owner changed before hook-only recovery receipt", 1, { effect: "no_effect" });
|
|
1302
|
+
const eventKey = input.recordReceipt();
|
|
1303
|
+
return { eventKey, duplicate: false, invocationId: null };
|
|
1304
|
+
});
|
|
978
1305
|
}
|
|
979
1306
|
const resolved = input.id === initial.id ? {} : { invocationId: initial.id };
|
|
1307
|
+
assertSuppliedLifecycleTarget(initial, invocation(canonicalManagedCommand(context, leafCommand, JSON.parse(initial.scope_json))));
|
|
1308
|
+
const scope = JSON.parse(initial.scope_json);
|
|
1309
|
+
checkNativeIdentity(scope, input.identity, initial.id);
|
|
980
1310
|
// A completed invocation is a read, even from a new native tool call after
|
|
981
1311
|
// reattach. Do not manufacture a new receipt or reject that read as a second
|
|
982
1312
|
// executor; the CLI returns the immutable stored outcome without dispatch.
|
|
983
1313
|
if (initial.status === "settled")
|
|
984
1314
|
return { eventKey: initial.event_key, duplicate: true, ...resolved };
|
|
985
|
-
const scope = JSON.parse(initial.scope_json);
|
|
986
1315
|
const project = requireProjectByRoot(context, scope.projectRoot);
|
|
987
1316
|
const identity = input.identity;
|
|
988
|
-
checkNativeIdentity(scope, identity, initial.id);
|
|
989
1317
|
const identityJson = deliveredIdentity;
|
|
990
1318
|
return context.db.writeTransaction(() => {
|
|
991
1319
|
const row = context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [initial.id]);
|
|
1320
|
+
if (retainedOwner !== ownerSnapshot())
|
|
1321
|
+
throw new AppError("invocation_scope_unproven", "Native owner changed before admission receipt", 1, { effect: "no_effect" });
|
|
1322
|
+
if (row.scope_json !== initial.scope_json)
|
|
1323
|
+
throw new AppError("invocation_scope_mismatch", "Lifecycle scope changed before native receipt", 1, { effect: "no_effect" });
|
|
992
1324
|
if (row.status === "settled")
|
|
993
1325
|
return { eventKey: row.event_key, duplicate: true, ...resolved };
|
|
994
1326
|
// Observation records native facts only. CLI owns deadline, argument and
|
|
@@ -1025,8 +1357,6 @@ export function observeLifecycleInvocation(context, input) {
|
|
|
1025
1357
|
/** Correlate exact native argv for diagnostics, independently of assignment
|
|
1026
1358
|
* matching. A hook-added event key must name the same persisted native call. */
|
|
1027
1359
|
export function observedLifecycleNativeEvent(context, command, daemonId) {
|
|
1028
|
-
if (!context.db.get("SELECT 1 FROM sqlite_master WHERE name = 'hook_events'"))
|
|
1029
|
-
return null;
|
|
1030
1360
|
const signature = (value) => {
|
|
1031
1361
|
const parsed = parseLifecycleCommand(value);
|
|
1032
1362
|
const candidate = parsed.kind !== "none" && parsed.supported ? parsed.invocation : continuationCommand(value);
|
|
@@ -1038,6 +1368,8 @@ export function observedLifecycleNativeEvent(context, command, daemonId) {
|
|
|
1038
1368
|
const expected = signature(command);
|
|
1039
1369
|
if (!expected)
|
|
1040
1370
|
return null;
|
|
1371
|
+
if (!context.db.get("SELECT 1 FROM sqlite_master WHERE name = 'hook_events'"))
|
|
1372
|
+
return null;
|
|
1041
1373
|
const parsed = parseLifecycleCommand(command);
|
|
1042
1374
|
const eventKey = parsed.kind === "standalone" ? commandOption(parsed.invocation, "hook-event-id") : undefined;
|
|
1043
1375
|
const matches = context.db.all(daemonId
|
|
@@ -1086,20 +1418,29 @@ export function observedLifecycleDiagnostic(context, command, daemonId, invocati
|
|
|
1086
1418
|
/** Resolve only a command that a native hook has already bound to an issued
|
|
1087
1419
|
* attempt. This is the CLI-side half of admission for model-facing commands
|
|
1088
1420
|
* that intentionally omit the internal UUID. */
|
|
1089
|
-
export function observedLifecycleInvocation(context, command,
|
|
1421
|
+
export function observedLifecycleInvocation(context, command, scopeConstraint, correlation) {
|
|
1090
1422
|
if (!context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'lifecycle_invocations'"))
|
|
1091
1423
|
return null;
|
|
1092
1424
|
const analysis = parseLifecycleCommand(command);
|
|
1093
1425
|
if (analysis.kind === "none" || analysis.supported === false)
|
|
1094
1426
|
return null;
|
|
1095
1427
|
const leafCommand = analysis.invocation.command;
|
|
1428
|
+
const suppliedId = commandOption(analysis.invocation, "invocation-id");
|
|
1429
|
+
const retainedId = suppliedId && context.db.get("SELECT id FROM lifecycle_invocations WHERE id = ?", [suppliedId]) ? suppliedId : undefined;
|
|
1096
1430
|
// `issued` is a rendezvous, not authority: the CLI still blocks in
|
|
1097
1431
|
// awaitLifecycleInvocation until the native observer commits the receipt.
|
|
1098
|
-
const
|
|
1432
|
+
const matching = context.db.all("SELECT * FROM lifecycle_invocations WHERE status IN ('issued','observed','executing','settled') ORDER BY rowid DESC")
|
|
1099
1433
|
.filter(row => {
|
|
1100
1434
|
try {
|
|
1435
|
+
const explicitId = retainedId;
|
|
1436
|
+
const eventKey = commandOption(analysis.invocation, "hook-event-id") ?? correlation?.eventKey;
|
|
1437
|
+
if (explicitId && row.id !== explicitId || eventKey && row.event_key !== eventKey)
|
|
1438
|
+
return false;
|
|
1439
|
+
if (row.status === "settled" && (!row.outcome_json || JSON.parse(row.outcome_json).error) && !explicitId && !eventKey && !correlation?.identity)
|
|
1440
|
+
return false;
|
|
1101
1441
|
const scope = JSON.parse(row.scope_json);
|
|
1102
|
-
|
|
1442
|
+
const constraint = typeof scopeConstraint === "string" ? { daemonId: scopeConstraint } : scopeConstraint;
|
|
1443
|
+
if (constraint && Object.entries(constraint).some(([key, value]) => scope[key] !== value))
|
|
1103
1444
|
return false;
|
|
1104
1445
|
return fingerprint(invocation(canonicalManagedCommand(context, leafCommand, scope))) === row.fingerprint;
|
|
1105
1446
|
}
|
|
@@ -1107,12 +1448,40 @@ export function observedLifecycleInvocation(context, command, daemonId) {
|
|
|
1107
1448
|
return false;
|
|
1108
1449
|
}
|
|
1109
1450
|
});
|
|
1451
|
+
const identity = correlation?.identity;
|
|
1452
|
+
const matches = lifecycleCandidates(matching, identity ? JSON.stringify([identity.daemonId, identity.rootSessionId, identity.sessionId, identity.parentSessionId, identity.toolCallId]) : undefined, true);
|
|
1110
1453
|
if (!matches.length)
|
|
1111
1454
|
return null;
|
|
1112
1455
|
if (matches.length !== 1)
|
|
1113
1456
|
throw new AppError("invocation_ambiguous", "More than one observed lifecycle attempt matches this command", 1, { effect: "no_effect", matches: matches.map(row => row.id) });
|
|
1114
1457
|
return { id: matches[0].id, scope: JSON.parse(matches[0].scope_json) };
|
|
1115
1458
|
}
|
|
1459
|
+
/** A new native call may consume a successor, but never a historical result.
|
|
1460
|
+
* Exact native delivery replays its immutable assignment. CLI lookup without
|
|
1461
|
+
* native correlation remains ambiguous when history and a successor coexist. */
|
|
1462
|
+
function lifecycleCandidates(rows, identity, lookup = false) {
|
|
1463
|
+
if (identity) {
|
|
1464
|
+
const replay = rows.filter(row => {
|
|
1465
|
+
if (!row.identity_json)
|
|
1466
|
+
return false;
|
|
1467
|
+
if (row.identity_json === identity)
|
|
1468
|
+
return true;
|
|
1469
|
+
if (!lookup)
|
|
1470
|
+
return false;
|
|
1471
|
+
try {
|
|
1472
|
+
const retained = JSON.parse(row.identity_json);
|
|
1473
|
+
return Array.isArray(retained) && JSON.stringify(retained.slice(0, 5)) === identity;
|
|
1474
|
+
}
|
|
1475
|
+
catch {
|
|
1476
|
+
return false;
|
|
1477
|
+
}
|
|
1478
|
+
});
|
|
1479
|
+
if (replay.length)
|
|
1480
|
+
return replay;
|
|
1481
|
+
}
|
|
1482
|
+
const active = rows.filter(row => row.status !== "settled");
|
|
1483
|
+
return (!lookup || identity) && active.length ? active : rows;
|
|
1484
|
+
}
|
|
1116
1485
|
/** Restore private assignment arguments without discarding the provider's
|
|
1117
1486
|
* verified public input. The fingerprint deliberately projects semantic
|
|
1118
1487
|
* values such as a Work failure reason and a Work result-file path. */
|
|
@@ -1121,6 +1490,7 @@ export function resolveObservedLifecycleArgs(context, id, actualArgs) {
|
|
|
1121
1490
|
if (!row)
|
|
1122
1491
|
throw new AppError("invocation_unknown", "Lifecycle invocation is not registered", 1, { invocation_id: id });
|
|
1123
1492
|
const retained = invocation(row.command), actual = invocation(`${launchExpression(retained)} ${quoteModelFacingArgs(actualArgs)}`);
|
|
1493
|
+
assertSuppliedLifecycleTarget(row, actual);
|
|
1124
1494
|
if (fingerprint(actual) !== row.fingerprint)
|
|
1125
1495
|
throw new AppError("invocation_command_mismatch", "Lifecycle command does not match its issued assignment", 2, { phase: "prepare", effect: "no_effect", recoverable: true });
|
|
1126
1496
|
for (const key of runtimeOwnedOptions[retained.operation] ?? []) {
|
|
@@ -1369,7 +1739,9 @@ export async function awaitLifecycleInvocation(context, input) {
|
|
|
1369
1739
|
if (typeof route === "string" && canonicalPath(process.cwd()) !== canonicalPath(route))
|
|
1370
1740
|
throw new AppError("invocation_directory_mismatch", "Reached CLI cwd differs from the bound shell execution route", 1, { phase: "prepare", effect: "no_effect", recoverable: false, expected: route, actual: process.cwd(), actual_provenance: "cli_process_cwd" });
|
|
1371
1741
|
}
|
|
1372
|
-
|
|
1742
|
+
// Another native observation can replace an unclaimed receipt. Claim
|
|
1743
|
+
// exactly the identity/route checked above, or reload and validate it.
|
|
1744
|
+
const claimed = context.db.run("UPDATE lifecycle_invocations SET status = 'executing', updated_at = ? WHERE id = ? AND status = 'observed' AND event_key IS ? AND identity_json IS ?", [context.now(), input.id, row.event_key, row.identity_json]);
|
|
1373
1745
|
if (claimed.changes === 1)
|
|
1374
1746
|
return { eventKey: row.event_key, replay: false };
|
|
1375
1747
|
continue;
|