@deksden-com/dd-flow-cli 0.9.0-beta.104 → 0.9.0-beta.106

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.
Files changed (69) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/build-info.json +3 -3
  3. package/dist/cli/run-cli.js +23 -8
  4. package/dist/harness-runtime/bin/dd-agy.mjs +1 -1
  5. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +2 -2
  6. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +4 -3
  7. package/dist/harness-runtime/lib/delegation-instructions.d.mts +3 -0
  8. package/dist/harness-runtime/lib/delegation-instructions.mjs +10 -1
  9. package/dist/protocol/local-files.js +18 -9
  10. package/dist/runtime/context.js +2 -2
  11. package/dist/services/cleanup.js +81 -32
  12. package/dist/services/code-checks.js +18 -19
  13. package/dist/services/continuation-outcome.js +1 -1
  14. package/dist/services/controller-fanout.js +12 -4
  15. package/dist/services/eval-snapshots.js +69 -41
  16. package/dist/services/execution-policy.js +12 -1
  17. package/dist/services/external-work-launch.js +2 -1
  18. package/dist/services/hooks.js +153 -43
  19. package/dist/services/lanes.js +23 -18
  20. package/dist/services/lifecycle-command.js +11 -2
  21. package/dist/services/lifecycle-invocations.js +291 -54
  22. package/dist/services/managed-processes.js +35 -23
  23. package/dist/services/merge-queue.js +71 -67
  24. package/dist/services/merge-server.js +5 -12
  25. package/dist/services/migrations.js +28 -12
  26. package/dist/services/plan-runtime.js +8 -4
  27. package/dist/services/projects.js +3 -11
  28. package/dist/services/protocols.js +98 -68
  29. package/dist/services/recovery-snapshot-database.js +43 -8
  30. package/dist/services/run-control-worker.js +15 -32
  31. package/dist/services/run-control.js +3 -17
  32. package/dist/services/run-controller-capture.js +58 -27
  33. package/dist/services/run-controller-process.js +21 -30
  34. package/dist/services/run-controller-recovery.js +36 -37
  35. package/dist/services/run-controller.js +83 -88
  36. package/dist/services/run-fork.js +10 -6
  37. package/dist/services/run-projection.js +22 -64
  38. package/dist/services/run-recovery-runtime.js +25 -14
  39. package/dist/services/run-recovery.js +56 -54
  40. package/dist/services/runs.js +218 -160
  41. package/dist/services/runtime-budget.js +19 -45
  42. package/dist/services/runtime-scope-capture.js +8 -6
  43. package/dist/services/runtime-scope-control.js +49 -39
  44. package/dist/services/runtime-scope-resume.js +134 -122
  45. package/dist/services/runtime-scope-worker.js +48 -49
  46. package/dist/services/scope-writer-cleanup.js +26 -0
  47. package/dist/services/stage-context.js +6 -1
  48. package/dist/services/stage-lifecycle.js +1 -1
  49. package/dist/services/stage-pause.js +13 -29
  50. package/dist/services/stage-report-renderer.js +116 -6
  51. package/dist/services/usage.js +2 -9
  52. package/dist/services/vnext-code-review.js +10 -7
  53. package/dist/services/vnext-code.js +9 -17
  54. package/dist/services/vnext-merge.js +64 -21
  55. package/dist/services/vnext-plan-review.js +43 -30
  56. package/dist/services/vnext-plan.js +28 -24
  57. package/dist/services/vnext-protocolize.js +183 -20
  58. package/dist/services/vnext-specify.js +27 -19
  59. package/dist/services/work-registry.js +229 -150
  60. package/dist/storage/database.js +286 -56
  61. package/dist/storage/foreign-key-transaction.js +39 -0
  62. package/dist/storage/immutable-input.js +45 -0
  63. package/dist/storage/import-guard.js +102 -0
  64. package/dist/storage/native-cleanup.js +52 -0
  65. package/dist/storage/projection-lock.js +73 -0
  66. package/dist/storage/writer-contract.js +3 -3
  67. package/dist/storage/writer-migration.js +24 -41
  68. package/package.json +1 -1
  69. package/tools/repair-paused-run-status.mjs +48 -23
@@ -6,7 +6,7 @@ import { setTimeout as delay } from "node:timers/promises";
6
6
  import { AppError, errorRecord } from "../shared/errors.js";
7
7
  import { shortEntityReference } from "../shared/entity-references.js";
8
8
  import { resolveWorkReference } from "../storage/work-references.js";
9
- import { commandOption, continuationCommand, lifecycleCommandFromPayload, parseLifecycleCommand, shellSuffixOffset } from "./lifecycle-command.js";
9
+ import { commandOption, commandPosition, continuationCommand, lifecycleCommandFromPayload, parseLifecycleCommand, shellSuffixOffset } from "./lifecycle-command.js";
10
10
  import { requireProjectByRoot } from "./projects.js";
11
11
  import { assertRunMutationAllowed, assertStageSettlementAllowed, assertWorkSettlementAllowed, recoveryGuard } from "./run-recovery.js";
12
12
  import { readNativeDaemonState, requireManagedDaemonBinding } from "./managed-daemon-binding.js";
@@ -20,6 +20,7 @@ import { isRegisteredRepair } from "./repair-intents.js";
20
20
  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
+ import { runtimeCommand } from "./runtime-command.js";
23
24
  /** Auxiliary commands have no invocation capability, but may not escape their
24
25
  * native daemon's project/RUN. Context aliases never grant mutation authority. */
25
26
  export function assertAuxiliaryCommandScope(context, command, projectRoot) {
@@ -98,11 +99,15 @@ function invocation(command) {
98
99
  }
99
100
  return parsed.invocation;
100
101
  }
102
+ function launchExpression(parsed) {
103
+ const executable = parsed.executable === "$DD_FLOW_BIN" ? '"$DD_FLOW_BIN"' : quote([parsed.executable]);
104
+ return parsed.launcher ? `${quote([parsed.launcher])} ${executable}` : executable;
105
+ }
101
106
  // Input data may be supplied later (stdin/file contents); authority is bound to
102
107
  // the exact operation and literal argv. Existing lifecycle validation owns data.
103
108
  function fingerprint(parsed) {
104
109
  const projected = publicInvocationArgs(parsed);
105
- const publicParsed = invocation(`${quote([parsed.executable])} ${quoteModelFacingArgs(projected)}`);
110
+ const publicParsed = invocation(`${launchExpression(parsed)} ${quoteModelFacingArgs(projected)}`);
106
111
  const options = [...publicParsed.args.options].filter(([key]) => !["invocation-id", "hook-event-id", "json", "progress-jsonl"].includes(key))
107
112
  .map(([key, values]) => [key, key === "reason" ? ["<semantic-reason>"] : values.map(value => normalizedInvocationValue(key, value))])
108
113
  .sort(([a], [b]) => a.localeCompare(b));
@@ -194,7 +199,7 @@ function withoutInvocationId(command, aliases) {
194
199
  argv[index] = normalizedInvocationValue("positional", argv[index]);
195
200
  }
196
201
  const env = Object.fromEntries(Object.entries(parsed.env).filter(([key]) => !["DD_FLOW_INVOCATION_SCOPE", "DD_FLOW_HOME", "DD_FLOW_BIN"].includes(key)));
197
- const executable = parsed.env.DD_FLOW_BIN || parsed.executable === "$DD_FLOW_BIN" ? '"$DD_FLOW_BIN"' : quote([parsed.executable]);
202
+ const executable = launchExpression(parsed);
198
203
  const prefix = Object.entries(env).map(([key, value]) => `${key}=${quote([value])}`).join(" ");
199
204
  const suffix = command.slice(shellSuffixOffset(command)).trimStart();
200
205
  return `${prefix ? `${prefix} ` : ""}${executable}${argv.length ? ` ${quoteModelFacingArgs(argv)}` : ""}${suffix ? ` ${suffix}` : ""}`.trim();
@@ -205,8 +210,35 @@ function publicInvocationCommand(context, row, command) {
205
210
  const rendered = renderInvocationCommand(row, command);
206
211
  const scope = JSON.parse(row.scope_json);
207
212
  const run = scope.runId ? context.db.get("SELECT workspace_root, run_root FROM runs WHERE id = ?", [scope.runId]) : undefined;
213
+ context.captureLifecycleAssignment?.(row.id);
208
214
  return withoutInvocationId(rendered, { project: scope.projectRoot, ...(run?.workspace_root ? { workspace: run.workspace_root } : {}), ...(run?.run_root ? { run: run.run_root } : {}) });
209
215
  }
216
+ /** Bind issued commands to the exact bytes sent to the model, not to an
217
+ * internal marker which is intentionally absent from that prompt. */
218
+ export function bindLifecyclePacketAssignments(context, prompt, ids) {
219
+ const unique = [...new Set(ids)];
220
+ if (!unique.length)
221
+ return;
222
+ const digest = crypto.createHash("sha256").update(prompt).digest("hex");
223
+ context.db.writeTransaction(() => {
224
+ for (const id of unique) {
225
+ const row = load(context, id);
226
+ if (row.status !== "issued")
227
+ throw new AppError("invocation_packet_conflict", "Packet contains a lifecycle assignment already in flight", 1, { invocation_id: id });
228
+ context.db.run("INSERT OR IGNORE INTO lifecycle_packet_assignments (packet_sha256, invocation_id) VALUES (?, ?)", [digest, id]);
229
+ }
230
+ });
231
+ }
232
+ /** Stage renderers issue their commands before the final packet is assembled.
233
+ * Bind only issued commands visibly present in that exact published packet. */
234
+ export function bindLifecyclePublishedPacket(context, prompt) {
235
+ const scope = context.env.DD_FLOW_INVOCATION_SCOPE;
236
+ if (!scope)
237
+ return;
238
+ const issued = context.db.all("SELECT * FROM lifecycle_invocations WHERE scope_json = ? AND status = 'issued' ORDER BY rowid", [scope]);
239
+ const ids = issued.filter(row => prompt.includes(publicInvocationCommand(context, row, retainedCommandWithoutInvocationId(row)))).map(row => row.id);
240
+ bindLifecyclePacketAssignments(context, prompt, ids);
241
+ }
210
242
  function retainedCommandWithoutInvocationId(row) {
211
243
  const marker = ` --invocation-id ${row.id}`;
212
244
  const at = row.command.indexOf(marker);
@@ -248,7 +280,7 @@ function canonicalManagedCommand(context, command, scope) {
248
280
  const argv = expandLifecycleInvocationArgs(context, parsed.argv, scope, parsed.operation);
249
281
  const env = { ...parsed.env, DD_FLOW_INVOCATION_SCOPE: JSON.stringify(scope) };
250
282
  const prefix = Object.entries(env).map(([key, value]) => `${key}=${quote([value])}`).join(" ");
251
- const executable = parsed.executable === "$DD_FLOW_BIN" ? '"$DD_FLOW_BIN"' : quote([parsed.executable]);
283
+ const executable = launchExpression(parsed);
252
284
  const suffix = command.slice(shellSuffixOffset(command)).trimStart();
253
285
  return `${prefix} ${executable}${argv.length ? ` ${quote(argv)}` : ""}${suffix ? ` ${suffix}` : ""}`.trim();
254
286
  }
@@ -391,9 +423,29 @@ export function observeLifecycleCommand(context, command, nativeEvent) {
391
423
  return;
392
424
  const error = outcome.error ? errorRecord(outcome.error) : undefined;
393
425
  const correctable = error && isCorrectableLifecycleRejection(error.code, error.details);
394
- const record = { ...anchor, ...(error ? { error } : outcome), effect: error ? error.details.effect ?? (correctable ? "no_effect" : "unknown") : "committed", disposition: error ? auxiliary ? "pending_continuation" : correctable ? "correctable" : "fatal" : "completed" };
426
+ const correlationKey = parsed.kind === "standalone" ? fingerprint(parsed.invocation)
427
+ : JSON.stringify([auxiliary.operation, auxiliary.operation === "stage_block" ? auxiliary.args.positional[2] ?? null : auxiliary.args.options.get("run")?.[0] ?? null, auxiliary.args.options.get("stage")?.[0] ?? null]);
428
+ const record = { ...anchor, correlation_key: correlationKey, ...(parsed.kind === "standalone" ? { fingerprint: correlationKey } : {}), ...(error ? { error } : outcome), effect: error ? error.details.effect ?? (correctable ? "no_effect" : "unknown") : "committed", disposition: error ? auxiliary ? "pending_continuation" : correctable ? "correctable" : "fatal" : "completed" };
395
429
  try {
396
430
  context.db.writeTransaction(() => {
431
+ if (error && parsed.kind === "standalone" && !auxiliary && !invocationId && !context.env.DD_FLOW_INVOCATION_SCOPE
432
+ && error.details.effect === "no_effect" && isRetryableLifecycleRejection(error.code, error.details)
433
+ && !isRegisteredRepair(context, error.details.continuation, { projectId: anchor.project_id, runId: anchor.run_id })) {
434
+ const signature = preparationRejectionSignature(new AppError(error.code, error.message, 1, error.details));
435
+ error.details.hook_retry_signature = signature;
436
+ const repeated = context.db.get("SELECT 1 FROM hook_events WHERE project_id = ? AND id <> ? AND json_extract(outcome_json, '$.run_id') = ? AND json_extract(outcome_json, '$.generation') = ? AND json_extract(outcome_json, '$.operation') = ? AND json_extract(outcome_json, '$.error.details.hook_retry_signature') = ? LIMIT 1", [anchor.project_id, anchor.hook_event_id, anchor.run_id, anchor.generation, anchor.operation, signature]);
437
+ if (!repeated) {
438
+ const argv = parsed.invocation.argv.filter((value, index, all) => value !== "--hook-event-id" && all[index - 1] !== "--hook-event-id" && value !== "--invocation-id" && all[index - 1] !== "--invocation-id");
439
+ error.details.retry_command = runtimeCommand(context, argv);
440
+ error.details.retry_instruction = "Correct the evidenced input, then run retry_command once as a standalone command in this Session. The rejected attempt had no lifecycle effect.";
441
+ error.details.recoverable = true;
442
+ }
443
+ else {
444
+ error.details.recoverable = false;
445
+ error.details.repeated_without_progress = true;
446
+ record.disposition = "fatal";
447
+ }
448
+ }
397
449
  const retained = context.db.get("SELECT outcome_json FROM hook_events WHERE id = ?", [anchor.hook_event_id]);
398
450
  const previous = retained?.outcome_json ? JSON.parse(retained.outcome_json) : null;
399
451
  if (previous?.storage_retry === 1)
@@ -457,7 +509,7 @@ export function lifecycleRetryCommands(context, stage) {
457
509
  }
458
510
  export function managedInvocationContext(context, input) {
459
511
  const env = { ...context.env, DD_FLOW_CURRENT_INVOCATION: undefined, DD_FLOW_INVOCATION_SCOPE: undefined };
460
- if (input.harness !== "zcode-acp" && input.harness !== "codex-desktop")
512
+ if (!["zcode-acp", "codex-desktop", "grok-acp", "antigravity-cli"].includes(input.harness))
461
513
  return { ...context, env };
462
514
  const project = requireProjectByRoot(context, input.projectRoot);
463
515
  const { state, process: daemon } = requireManagedDaemonBinding(context, { stateDir: input.stateDir, projectId: project.id, runId: input.runId });
@@ -467,7 +519,7 @@ export function managedInvocationContext(context, input) {
467
519
  // Codex owns a single root Session per controller and persists that root as
468
520
  // a string. Child agent_id values arrive later through the native hook and
469
521
  // are checked against this root scope; they are not controller Sessions.
470
- const rootSessionId = input.harness === "codex-desktop"
522
+ const rootSessionId = input.harness !== "zcode-acp"
471
523
  ? (typeof session === "string" ? session : session?.root_provider_session_id ?? session?.provider_session_id ?? null)
472
524
  : (typeof session === "string" ? null : session?.root_provider_session_id ?? null);
473
525
  if (!state.daemon_id || !rootSessionId)
@@ -569,8 +621,7 @@ const receiptTimeout = (id) => ({ code: "invocation_receipt_timeout", message: "
569
621
  export function settleLifecycleRejection(context, id, error) {
570
622
  storage(context);
571
623
  const details = { ...error.details };
572
- context.db.beginWriteTransaction();
573
- try {
624
+ context.db.writeTransaction(() => {
574
625
  const prior = load(context, id);
575
626
  if (prior.status !== "executing")
576
627
  throw new AppError("invocation_settlement_conflict", "Attempt is no longer executing", 1, { invocation_id: id });
@@ -593,18 +644,20 @@ export function settleLifecycleRejection(context, id, error) {
593
644
  details.recovery_instruction = "Do not retry this command in the current Session. End the coordinator Turn; the controller will launch the already-registered Work in a fresh child Session.";
594
645
  }
595
646
  settleLifecycleInvocation(context, id, { error: { ...error, details } });
596
- context.db.exec("COMMIT");
597
- Object.assign(error.details, details);
598
- }
599
- catch (error) {
600
- context.db.exec("ROLLBACK");
601
- throw error;
602
- }
647
+ });
648
+ Object.assign(error.details, details);
603
649
  }
604
650
  /** Recovery preserves the original packet as evidence. Only its retained,
605
651
  * never-executed commands receive new authority; unknown effects never replay. */
652
+ export function hasLifecyclePacketAssignments(context, originalPrompt) {
653
+ const digest = crypto.createHash("sha256").update(originalPrompt).digest("hex");
654
+ return Boolean(context.db.get("SELECT 1 FROM lifecycle_packet_assignments WHERE packet_sha256 = ? LIMIT 1", [digest]));
655
+ }
606
656
  export function recoverLifecycleCommands(context, originalPrompt, scope) {
607
- const ids = [...new Set([...originalPrompt.matchAll(/--invocation-id ([0-9a-f-]{36})\b/g)].map(match => match[1]))];
657
+ const digest = crypto.createHash("sha256").update(originalPrompt).digest("hex");
658
+ const ids = context.db.all("SELECT invocation_id FROM lifecycle_packet_assignments WHERE packet_sha256 = ? ORDER BY rowid", [digest]).map(row => row.invocation_id);
659
+ if (!ids.length)
660
+ throw new AppError("invocation_recovery_unbound", "Original packet has no verified lifecycle assignment binding; reconcile it before continuing", 1);
608
661
  const recovered = [];
609
662
  for (const id of ids) {
610
663
  let prior = load(context, id);
@@ -633,7 +686,7 @@ export function recoverLifecycleCommands(context, originalPrompt, scope) {
633
686
  throw new AppError("invocation_command_invalid", "Retained command lost its exact invocation marker", 1);
634
687
  argv.splice(at, 2);
635
688
  const env = { ...parsed.env, DD_FLOW_INVOCATION_SCOPE: JSON.stringify(scope) };
636
- const command = `${Object.entries(env).map(([key, value]) => `${key}=${quote([value])}`).join(" ")} ${quote([parsed.executable, ...argv])} ${prior.command.slice(shellSuffixOffset(prior.command))}`.trim();
689
+ const command = `${Object.entries(env).map(([key, value]) => `${key}=${quote([value])}`).join(" ")} ${launchExpression(parsed)} ${quote(argv)} ${prior.command.slice(shellSuffixOffset(prior.command))}`.trim();
637
690
  const prepared = managedLifecycleCommand({ ...context, env: { ...context.env, DD_FLOW_INVOCATION_SCOPE: JSON.stringify(scope), DD_FLOW_CURRENT_INVOCATION: undefined } }, command);
638
691
  recovered.push({ invocation_id: id, status: "replaced_unexecuted", command: prepared });
639
692
  }
@@ -677,8 +730,10 @@ export function assertLifecycleInvocationCurrent(context, scope, command) {
677
730
  if (guard?.status === "draining" && guard.generation === scope.generation + 1) {
678
731
  if (workId && ["work_finish", "work_fail"].includes(parsed.operation))
679
732
  return assertWorkSettlementAllowed(context, project.id, scope.runId, workId);
680
- if (parsed.operation === "stage_finish")
681
- return assertStageSettlementAllowed(context, project.id, scope.runId, commandOption(parsed, "stage") ?? "");
733
+ if (parsed.operation === "stage_finish") {
734
+ assertStageSettlementAllowed(context, project.id, scope.runId, commandOption(parsed, "stage") ?? "");
735
+ return;
736
+ }
682
737
  }
683
738
  if ((guard?.generation ?? 0) !== scope.generation)
684
739
  throw new AppError("invocation_generation_stale", "Lifecycle attempt belongs to another recovery generation", 1);
@@ -753,11 +808,112 @@ function checkNativeIdentity(scope, identity, id) {
753
808
  throw new AppError("invocation_identity_mismatch", "Native identity does not belong to the issued managed scope", 1, { invocation_id: id });
754
809
  }
755
810
  }
811
+ function managedNativeOwner(context, identity, harness, projectId) {
812
+ const owners = [];
813
+ const hasTable = (name) => Boolean(context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?", [name]));
814
+ if (hasTable("run_controllers")) {
815
+ 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]);
816
+ for (const controller of controllers) {
817
+ if (!controller.state_json.includes(identity.rootSessionId))
818
+ continue;
819
+ const state = JSON.parse(controller.state_json);
820
+ for (const [index, session] of (state.sessions ?? []).entries()) {
821
+ if (session.id !== identity.rootSessionId)
822
+ continue;
823
+ owners.push({ runId: controller.run_id, rootSessionId: session.id,
824
+ stateDir: session.state_dir, harness: runtimeHarness(session.profile), generation: controller.generation,
825
+ active: ["running", "paused", "waiting_for_user", "blocked"].includes(controller.run_status)
826
+ && !["completed", "cancelled", "superseded"].includes(controller.status)
827
+ && state.current_session === index && session.stopped !== true });
828
+ }
829
+ }
830
+ }
831
+ if (hasTable("work_launches")) {
832
+ 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 = ? AND l.provider_session_id = ?", [projectId, identity.rootSessionId]);
833
+ for (const launch of launches) {
834
+ // Cooperative pause/drain may retain an admitted Work settlement while
835
+ // the RUN is paused; only terminal RUN states retire its native root.
836
+ const active = ["running", "paused", "waiting_for_user", "blocked"].includes(launch.run_status)
837
+ && ["launching", "start_requested"].includes(launch.status);
838
+ if (!active)
839
+ throw new AppError("invocation_scope_unproven", "External Work native root is no longer active", 1, { effect: "no_effect" });
840
+ const profile = JSON.parse(launch.profile_json);
841
+ const owner = profile.runtime_adapter?.owner;
842
+ if (!owner || owner.owner_id !== `run:${launch.run_id}:work:${launch.work_id}` || owner.project_id !== projectId
843
+ || owner.run_id !== launch.run_id || owner.work_id !== launch.work_id || owner.operation_id !== launch.operation_id || owner.state_dir !== launch.state_dir) {
844
+ throw new AppError("invocation_scope_unproven", "External Work has no matching persisted runtime owner", 1, { effect: "no_effect" });
845
+ }
846
+ owners.push({ runId: launch.run_id, rootSessionId: launch.provider_session_id, stateDir: launch.state_dir,
847
+ harness: runtimeHarness(profile.worker), ownerId: owner.owner_id, active });
848
+ }
849
+ }
850
+ if (hasTable("merge_requests")) {
851
+ 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 CASE WHEN json_valid(m.adapter_receipt_json) THEN json_extract(m.adapter_receipt_json, '$.session_id') = ? ELSE 0 END", [projectId, identity.rootSessionId]);
852
+ for (const request of requests) {
853
+ const active = ["running", "paused", "waiting_for_user", "blocked"].includes(request.run_status)
854
+ && ["dispatching", "active", "waiting_user", "action_required", "recovery_required"].includes(request.status);
855
+ if (!active)
856
+ throw new AppError("invocation_scope_unproven", "Server MERGE native root is no longer active", 1, { effect: "no_effect" });
857
+ const receipt = JSON.parse(request.adapter_receipt_json);
858
+ if (receipt.controller_id) {
859
+ const controller = context.db.get("SELECT generation, status, state_json FROM run_controllers WHERE controller_id = ? AND project_id = ? AND run_id = ?", [receipt.controller_id, projectId, request.run_id]);
860
+ const state = controller && JSON.parse(controller.state_json);
861
+ const session = state?.current_session === undefined || state.current_session === null ? undefined : state.sessions?.[state.current_session];
862
+ if (!request.dispatch_lease_token || request.dispatch_owner !== receipt.controller_id
863
+ || !controller || ["completed", "cancelled", "superseded"].includes(controller.status)
864
+ || state?.merge_claim?.request_id !== request.merge_request_id || state.merge_claim.token !== request.dispatch_lease_token
865
+ || state.merge_claim.work_id !== request.executor_work_id || !session || session.stopped === true
866
+ || session.id !== receipt.session_id || session.state_dir !== receipt.state_dir || !receipt.profile
867
+ || JSON.stringify(session.profile) !== JSON.stringify(receipt.profile)) {
868
+ throw new AppError("invocation_scope_unproven", "Controller MERGE root does not match its dispatch lease and retained Session", 1, { effect: "no_effect" });
869
+ }
870
+ // The controller Session above is already an owner candidate; its
871
+ // daemon binding and generation are checked once in the common loop.
872
+ continue;
873
+ }
874
+ const owner = receipt.owner;
875
+ if (!receipt.session_id || !receipt.state_dir || !receipt.profile || !owner
876
+ || owner.owner_id !== `run:${request.run_id}:merge:${request.merge_request_id}` || owner.project_id !== projectId
877
+ || owner.run_id !== request.run_id || owner.work_id !== request.executor_work_id || owner.state_dir !== receipt.state_dir
878
+ || owner.operation_id !== `${request.merge_request_id}:${request.dispatch_lease_token}`) {
879
+ throw new AppError("invocation_scope_unproven", "Server MERGE has no matching persisted runtime owner", 1, { effect: "no_effect" });
880
+ }
881
+ owners.push({ runId: request.run_id, rootSessionId: receipt.session_id, stateDir: receipt.state_dir,
882
+ harness: runtimeHarness(receipt.profile), ownerId: owner.owner_id, active });
883
+ }
884
+ }
885
+ for (const owner of owners) {
886
+ if (!owner.active)
887
+ throw new AppError("invocation_scope_unproven", "Managed native root is no longer active", 1, { effect: "no_effect" });
888
+ if (owner.harness !== harness)
889
+ throw new AppError("invocation_scope_unproven", "Native root belongs to another managed harness", 1, { effect: "no_effect" });
890
+ const binding = requireManagedDaemonBinding(context, { stateDir: owner.stateDir, projectId, runId: owner.runId, ...(owner.ownerId ? { ownerId: owner.ownerId } : {}) });
891
+ if (binding.process.kind !== (harness === "grok-acp" ? "grok-daemon" : "agy-daemon")) {
892
+ throw new AppError("invocation_scope_unproven", "Native daemon kind differs from its persisted harness", 1, { effect: "no_effect" });
893
+ }
894
+ if (binding.state.daemon_id !== identity.daemonId)
895
+ throw new AppError("invocation_scope_unproven", "Native daemon differs from its persisted launch", 1, { effect: "no_effect" });
896
+ const root = binding.state.sessions?.some(native => typeof native === "string" ? native === identity.rootSessionId
897
+ : native.provider_session_id === identity.rootSessionId && (native.root_provider_session_id ?? identity.rootSessionId) === identity.rootSessionId);
898
+ const generation = JSON.parse(binding.process.metadata_json).run_generation;
899
+ if (!root || !Number.isSafeInteger(generation) || generation < 0 || owner.generation !== undefined && generation !== owner.generation) {
900
+ throw new AppError("invocation_scope_unproven", "Managed native root or RUN generation is not persisted", 1, { effect: "no_effect" });
901
+ }
902
+ return { projectId, runId: owner.runId, generation };
903
+ }
904
+ return null;
905
+ }
906
+ /** recordReceipt is a synchronous SQL-only hook ingress callback. Native
907
+ * adapters must prepare any filesystem or process evidence before calling. */
756
908
  export function observeLifecycleInvocation(context, input) {
757
909
  storage(context);
758
910
  const supplied = input.id ? context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [input.id]) : undefined;
759
911
  const analysis = parseLifecycleCommand(input.command);
760
912
  if (analysis.kind !== "standalone" || analysis.invocation.wrapped) {
913
+ if (analysis.kind !== "none" && ["grok-acp", "antigravity-cli"].includes(input.harness ?? "") && input.projectId
914
+ && managedNativeOwner(context, input.identity, input.harness, input.projectId)) {
915
+ throw new AppError("invocation_command_invalid", "Managed native lifecycle call must be one direct standalone command", 1, { phase: "prepare", effect: "no_effect", recoverable: false });
916
+ }
761
917
  const eventKey = input.recordReceipt();
762
918
  if (analysis.kind !== "none") {
763
919
  const retained = context.db.get("SELECT outcome_json FROM hook_events WHERE event_key = ?", [eventKey]);
@@ -797,6 +953,16 @@ export function observeLifecycleInvocation(context, input) {
797
953
  // public ID is corrected only by an exact operation/target match inside the
798
954
  // trusted native root. Never fuzzy-match UUID text or select a global latest.
799
955
  const initial = supplied ?? (candidates.length === 1 ? candidates[0] : undefined);
956
+ const owner = ["grok-acp", "antigravity-cli"].includes(input.harness ?? "") && input.projectId
957
+ ? managedNativeOwner(context, input.identity, input.harness, input.projectId) : null;
958
+ if (owner) {
959
+ if (!initial)
960
+ throw new AppError("invocation_assignment_missing", "Managed native lifecycle call has no issued assignment", 1, { effect: "no_effect", recoverable: false });
961
+ const scope = JSON.parse(initial.scope_json);
962
+ if (requireProjectByRoot(context, scope.projectRoot).id !== owner.projectId || scope.runId !== owner.runId || scope.generation !== owner.generation) {
963
+ throw new AppError("invocation_scope_mismatch", "Native daemon owner does not match issued RUN authority", 1, { effect: "no_effect", recoverable: false });
964
+ }
965
+ }
800
966
  if (!initial) {
801
967
  const eventKey = input.recordReceipt();
802
968
  if (candidates.length > 1)
@@ -814,21 +980,16 @@ export function observeLifecycleInvocation(context, input) {
814
980
  const identity = input.identity;
815
981
  checkNativeIdentity(scope, identity, initial.id);
816
982
  const identityJson = JSON.stringify([identity.daemonId, identity.rootSessionId, identity.sessionId, identity.parentSessionId, identity.toolCallId]);
817
- context.db.beginWriteTransaction();
818
- try {
983
+ return context.db.writeTransaction(() => {
819
984
  const row = context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [initial.id]);
820
- if (row.status === "settled") {
821
- context.db.exec("COMMIT");
985
+ if (row.status === "settled")
822
986
  return { eventKey: row.event_key, duplicate: true, ...resolved };
823
- }
824
987
  // Observation records native facts only. CLI owns deadline, argument and
825
988
  // RUN-state validation, including decisions about retrying an invocation.
826
989
  if (row.status === "expired" || (row.deadline !== null && row.deadline <= Date.now() && !row.event_key)) {
827
- context.db.exec("COMMIT");
828
990
  return { eventKey: row.event_key, duplicate: true, ...resolved };
829
991
  }
830
992
  if (row.identity_json === identityJson) {
831
- context.db.exec("COMMIT");
832
993
  return { eventKey: row.event_key, duplicate: true, ...resolved };
833
994
  }
834
995
  const eventKey = input.recordReceipt({ projectRoot: scope.projectRoot, runId: scope.runId, command: initial.command });
@@ -846,15 +1007,10 @@ export function observeLifecycleInvocation(context, input) {
846
1007
  // is safe. Keep the newest receipt until the CLI claims the attempt. The
847
1008
  // observed -> executing compare-and-swap remains the exactly-once boundary.
848
1009
  const updated = context.db.run("UPDATE lifecycle_invocations SET identity_json = ?, event_key = ?, status = 'observed', updated_at = ? WHERE id = ? AND status IN ('issued', 'observed')", [identityJson, eventKey, context.now(), initial.id]);
849
- context.db.exec("COMMIT");
850
1010
  // Once execution has been claimed, later PreToolUse observations are
851
1011
  // retained as hook evidence but cannot replace the executor's receipt.
852
1012
  return { eventKey: updated.changes === 1 ? eventKey : row.event_key, duplicate: updated.changes !== 1, ...resolved };
853
- }
854
- catch (error) {
855
- context.db.exec("ROLLBACK");
856
- throw error;
857
- }
1013
+ });
858
1014
  }
859
1015
  /** Correlate exact native argv for diagnostics, independently of assignment
860
1016
  * matching. A hook-added event key must name the same persisted native call. */
@@ -894,15 +1050,17 @@ export function observedLifecycleNativeEvent(context, command, daemonId) {
894
1050
  return matches.length === 1 ? { project_id: matches[0].project_id, id: matches[0].id } : null;
895
1051
  }
896
1052
  /** Keep shell diagnostics distinct from genuinely absent native delivery. */
897
- export function observedLifecycleDiagnostic(context, command, daemonId) {
1053
+ export function observedLifecycleDiagnostic(context, command, daemonId, invocationId) {
898
1054
  const analysis = parseLifecycleCommand(command);
899
1055
  if (analysis.kind !== "standalone")
900
1056
  return null;
901
1057
  const commandFingerprint = fingerprint(analysis.invocation);
902
- const rows = context.db.all("SELECT event_key, outcome_json FROM hook_events WHERE daemon_id = ? AND status = 'observed' AND outcome_json IS NOT NULL ORDER BY id DESC LIMIT 20", [daemonId]).flatMap(row => {
1058
+ const issuedAt = invocationId ? context.db.get("SELECT created_at FROM lifecycle_invocations WHERE id = ?", [invocationId])?.created_at : undefined;
1059
+ const rows = context.db.all(`SELECT id, event_key, outcome_json FROM hook_events WHERE daemon_id = ? AND status = 'observed' AND outcome_json IS NOT NULL
1060
+ ${issuedAt ? "AND created_at >= ?" : ""} ORDER BY id DESC LIMIT 20`, issuedAt ? [daemonId, issuedAt] : [daemonId]).flatMap(row => {
903
1061
  try {
904
1062
  const outcome = JSON.parse(row.outcome_json);
905
- return outcome.lifecycle_observation?.command_fingerprint === commandFingerprint ? [{ ...outcome.lifecycle_observation, event_key: row.event_key }] : [];
1063
+ return outcome.lifecycle_observation?.command_fingerprint === commandFingerprint ? [{ ...outcome.lifecycle_observation, event_key: row.event_key, hook_event_id: row.id }] : [];
906
1064
  }
907
1065
  catch {
908
1066
  return [];
@@ -944,23 +1102,58 @@ export function observedLifecycleInvocation(context, command, daemonId) {
944
1102
  throw new AppError("invocation_ambiguous", "More than one observed lifecycle attempt matches this command", 1, { effect: "no_effect", matches: matches.map(row => row.id) });
945
1103
  return { id: matches[0].id, scope: JSON.parse(matches[0].scope_json) };
946
1104
  }
947
- /** Replace presentation argv with the exact retained machine assignment.
948
- * Public semantic arguments are already covered by the projected fingerprint;
949
- * any explicitly repeated private value must agree instead of being ignored. */
1105
+ /** Restore private assignment arguments without discarding the provider's
1106
+ * verified public input. The fingerprint deliberately projects semantic
1107
+ * values such as a Work failure reason and a Work result-file path. */
950
1108
  export function resolveObservedLifecycleArgs(context, id, actualArgs) {
951
1109
  const row = context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [id]);
952
1110
  if (!row)
953
1111
  throw new AppError("invocation_unknown", "Lifecycle invocation is not registered", 1, { invocation_id: id });
954
- const retained = invocation(row.command), actual = invocation(`${quote([retained.executable])} ${quoteModelFacingArgs(actualArgs)}`);
1112
+ const retained = invocation(row.command), actual = invocation(`${launchExpression(retained)} ${quoteModelFacingArgs(actualArgs)}`);
955
1113
  if (fingerprint(actual) !== row.fingerprint)
956
1114
  throw new AppError("invocation_command_mismatch", "Lifecycle command does not match its issued assignment", 2, { phase: "prepare", effect: "no_effect", recoverable: true });
957
1115
  for (const key of runtimeOwnedOptions[retained.operation] ?? []) {
958
1116
  const supplied = commandOption(actual, key), expected = commandOption(retained, key);
959
- if (supplied !== undefined && normalizedInvocationValue(key, supplied) !== normalizedInvocationValue(key, expected ?? "")) {
1117
+ const exactInput = ["result-file", "decision-file", "verification-file"].includes(key);
1118
+ const actualValue = exactInput && supplied !== undefined
1119
+ ? commandOption(invocation(`${launchExpression(retained)} ${quote(expandLifecycleInvocationArgs(context, actual.argv, JSON.parse(row.scope_json), retained.operation))}`), key)
1120
+ : supplied;
1121
+ if (supplied !== undefined && (exactInput ? path.resolve(actualValue ?? "") !== path.resolve(expected ?? "")
1122
+ : normalizedInvocationValue(key, supplied) !== normalizedInvocationValue(key, expected ?? ""))) {
960
1123
  throw new AppError("invocation_argument_mismatch", `--${key} is owned by the managed assignment and does not match it`, 2, { phase: "prepare", effect: "no_effect", recoverable: true, parameter: key });
961
1124
  }
962
1125
  }
963
- return [...retained.argv];
1126
+ const argv = [...retained.argv];
1127
+ const publicKey = retained.operation === "work_fail" ? "reason" : retained.operation === "work_finish" ? "result-file" : null;
1128
+ if (publicKey && commandOption(actual, publicKey) !== undefined) {
1129
+ const index = argv.indexOf(`--${publicKey}`);
1130
+ if (index >= 0 && index + 1 < argv.length)
1131
+ argv[index + 1] = commandOption(actual, publicKey);
1132
+ else {
1133
+ const inline = argv.findIndex(value => value.startsWith(`--${publicKey}=`));
1134
+ if (inline >= 0)
1135
+ argv[inline] = `--${publicKey}=${commandOption(actual, publicKey)}`;
1136
+ }
1137
+ }
1138
+ return argv;
1139
+ }
1140
+ /** Once native admission is committed, retain the actual public semantics so
1141
+ * an authorized successor cannot silently revert to the issued placeholder. */
1142
+ export function retainObservedLifecycleSemantics(context, id, argv) {
1143
+ const row = context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [id]);
1144
+ if (!row || row.status !== "executing")
1145
+ throw new AppError("invocation_settlement_conflict", "Lifecycle attempt is not executing", 1, { invocation_id: id });
1146
+ const parsed = invocation(row.command);
1147
+ if (!["work_fail", "work_finish"].includes(parsed.operation))
1148
+ return;
1149
+ const prefix = Object.entries(parsed.env).map(([key, value]) => `${key}=${quote([value])}`).join(" ");
1150
+ const executable = launchExpression(parsed);
1151
+ const suffix = row.command.slice(shellSuffixOffset(row.command)).trimStart();
1152
+ const command = `${prefix ? `${prefix} ` : ""}${executable} ${quote(argv)}${suffix ? ` ${suffix}` : ""}`;
1153
+ checkCommand(row, command);
1154
+ if (context.db.run("UPDATE lifecycle_invocations SET command = ?, updated_at = ? WHERE id = ? AND status = 'executing'", [command, context.now(), id]).changes !== 1) {
1155
+ throw new AppError("invocation_settlement_conflict", "Lifecycle attempt changed before its public input was retained", 1, { invocation_id: id });
1156
+ }
964
1157
  }
965
1158
  export function settleLifecyclePreparationRejection(context, id, error) {
966
1159
  return context.db.writeTransaction(() => {
@@ -994,7 +1187,7 @@ export function settleLifecyclePreparationRejection(context, id, error) {
994
1187
  });
995
1188
  }
996
1189
  function preparationRejectionSignature(error) {
997
- const omitted = new Set(["retry_command", "retry_instruction", "invocation_id", "hook_event_id", "rejection_signature"]);
1190
+ const omitted = new Set(["retry_command", "retry_instruction", "invocation_id", "hook_event_id", "rejection_signature", "hook_retry_signature"]);
998
1191
  const normalize = (value) => {
999
1192
  if (Array.isArray(value))
1000
1193
  return value.map(normalize);
@@ -1005,11 +1198,45 @@ function preparationRejectionSignature(error) {
1005
1198
  return crypto.createHash("sha256").update(JSON.stringify([error.code, normalize(error.details)])).digest("hex");
1006
1199
  }
1007
1200
  export function currentLifecycleFailure(context, input) {
1201
+ if (input.turn !== undefined) {
1202
+ const hooks = context.db.all(`SELECT id, outcome_json FROM hook_events
1203
+ WHERE json_extract(outcome_json, '$.run_id') = ? AND json_extract(outcome_json, '$.generation') = ?
1204
+ AND json_extract(outcome_json, '$.stage') = ? AND json_extract(outcome_json, '$.controller_turn') = ?
1205
+ ${input.projectId ? "AND project_id = ?" : ""}
1206
+ ORDER BY id DESC`, [input.runId, input.generation, input.stage, input.turn, ...(input.projectId ? [input.projectId] : [])]);
1207
+ const completed = new Set();
1208
+ const activeAttempt = input.projectId ? stageAttempt(context, input.projectId, input.runId, input.stage) : null;
1209
+ for (const hook of hooks) {
1210
+ let outcome;
1211
+ try {
1212
+ outcome = JSON.parse(hook.outcome_json);
1213
+ }
1214
+ catch {
1215
+ continue;
1216
+ }
1217
+ if (activeAttempt && outcome.attempt && outcome.attempt !== activeAttempt)
1218
+ continue;
1219
+ const key = JSON.stringify([outcome.operation ?? "", outcome.session_id ?? null, outcome.correlation_key ?? outcome.fingerprint ?? hook.id]);
1220
+ if (outcome.disposition === "completed") {
1221
+ completed.add(key);
1222
+ continue;
1223
+ }
1224
+ if (completed.has(key) || !outcome.error?.code)
1225
+ continue;
1226
+ const continuation = outcome.error.details?.continuation;
1227
+ if (continuation && typeof continuation === "object"
1228
+ && continuation.run_id === input.runId
1229
+ && continuation.stage === input.stage
1230
+ && isRegisteredRepair(context, continuation, input.projectId ? { projectId: input.projectId, runId: input.runId } : undefined))
1231
+ continue;
1232
+ return { code: outcome.error.code, message: outcome.error.message ?? "Lifecycle command failed", details: outcome.error.details ?? {}, hook_event_id: hook.id };
1233
+ }
1234
+ }
1008
1235
  const attempts = context.db.all("SELECT * FROM lifecycle_invocations ORDER BY updated_at DESC, rowid DESC");
1009
1236
  const scoped = attempts.filter(row => {
1010
1237
  try {
1011
1238
  const scope = JSON.parse(row.scope_json);
1012
- return scope.runId === input.runId && scope.generation === input.generation;
1239
+ return scope.runId === input.runId && scope.generation === input.generation && (!input.projectRoot || scope.projectRoot === input.projectRoot);
1013
1240
  }
1014
1241
  catch {
1015
1242
  return false;
@@ -1018,7 +1245,9 @@ export function currentLifecycleFailure(context, input) {
1018
1245
  const fingerprints = [...new Set(scoped.map(row => row.fingerprint))];
1019
1246
  for (const fingerprint of fingerprints) {
1020
1247
  const rows = scoped.filter(row => row.fingerprint === fingerprint);
1021
- if (rows.some(row => ["issued", "observed", "executing"].includes(row.status)))
1248
+ // An issued successor has not performed the correction. Once the Turn
1249
+ // ended it cannot erase the predecessor's actual rejection.
1250
+ if (rows.some(row => ["observed", "executing"].includes(row.status)))
1022
1251
  continue;
1023
1252
  const row = rows.find(candidate => candidate.status === "settled");
1024
1253
  if (!row)
@@ -1039,9 +1268,23 @@ export function currentLifecycleFailure(context, input) {
1039
1268
  catch {
1040
1269
  continue;
1041
1270
  }
1042
- const stage = parsed.operation.startsWith("merge_") ? "merge" : commandOption(parsed, "stage");
1271
+ let stage = parsed.operation.startsWith("merge_") ? "merge" : commandOption(parsed, "stage");
1272
+ if (!stage && parsed.operation.startsWith("work_") && input.runId) {
1273
+ try {
1274
+ const work = resolveWorkReference(context, commandPosition(parsed, 0) ?? "", { runId: input.runId });
1275
+ const membership = context.db.get("SELECT stage FROM stage_work_memberships WHERE run_id = ? AND work_id = ? ORDER BY rowid DESC LIMIT 1", [input.runId, work.work_id]);
1276
+ stage = membership?.stage;
1277
+ }
1278
+ catch { /* A stale or unbound Work is not evidence for this Stage. */ }
1279
+ }
1043
1280
  if (stage !== input.stage)
1044
1281
  continue;
1282
+ const continuation = outcome.error.details?.continuation;
1283
+ if (continuation && typeof continuation === "object"
1284
+ && continuation.run_id === input.runId
1285
+ && continuation.stage === input.stage
1286
+ && isRegisteredRepair(context, continuation, input.projectId ? { projectId: input.projectId, runId: input.runId } : undefined))
1287
+ continue;
1045
1288
  return { code: outcome.error.code, message: outcome.error.message, details: outcome.error.details ?? {}, invocation_id: row.id };
1046
1289
  }
1047
1290
  return null;
@@ -1097,19 +1340,13 @@ export async function awaitLifecycleInvocation(context, input) {
1097
1340
  continue;
1098
1341
  }
1099
1342
  if (row.status === "expired" || (row.deadline !== null && row.deadline <= Date.now())) {
1100
- context.db.beginWriteTransaction();
1101
- try {
1343
+ context.db.writeTransaction(() => {
1102
1344
  const current = load(context, input.id);
1103
1345
  if (["issued", "expired"].includes(current.status)) {
1104
1346
  input.assertCurrent(JSON.parse(current.scope_json));
1105
1347
  settleUnadmittedInvocation(context, current, receiptTimeout(input.id));
1106
1348
  }
1107
- context.db.exec("COMMIT");
1108
- }
1109
- catch (error) {
1110
- context.db.exec("ROLLBACK");
1111
- throw error;
1112
- }
1349
+ });
1113
1350
  continue;
1114
1351
  }
1115
1352
  await delay(Math.min(25, Math.max(1, (row.deadline ?? Date.now() + 25) - Date.now())));