@deksden-com/dd-flow-cli 0.9.0-beta.87 → 0.9.0-beta.89

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.
@@ -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, parseLifecycleCommand, shellSuffixOffset } from "./lifecycle-command.js";
9
+ import { commandOption, 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";
@@ -15,8 +15,49 @@ import { appendEvent } from "./run-controller-state.js";
15
15
  import { recoverCommittedWorkStart } from "./work-registry.js";
16
16
  import { observeCommandHook } from "./hooks.js";
17
17
  import { runtimeHarness } from "./execution-policy.js";
18
- import { assertPathWithin } from "../storage/paths.js";
18
+ import { normalizedPathAlias, resolveContextualPaths } from "./command-context.js";
19
+ import { isRegisteredRepair } from "./repair-intents.js";
20
+ import { stageAttempt } from "./stage-work-graph.js";
19
21
  import { contextualPathOptionsForLifecycle } from "../cli/command-inputs.js";
22
+ import { canonicalPath } from "../storage/paths.js";
23
+ /** Auxiliary commands have no invocation capability, but may not escape their
24
+ * native daemon's project/RUN. Context aliases never grant mutation authority. */
25
+ export function assertAuxiliaryCommandScope(context, command, projectRoot) {
26
+ const auxiliary = continuationCommand(command);
27
+ const daemonId = context.env.DD_FLOW_DAEMON_ID;
28
+ if (!auxiliary || !daemonId)
29
+ return;
30
+ const event = observedLifecycleNativeEvent(context, command, daemonId);
31
+ if (!event)
32
+ throw new AppError("invocation_receipt_missing", "Auxiliary command has no exact native receipt", 1, { phase: "prepare", effect: "no_effect" });
33
+ const project = context.db.get("SELECT root FROM projects WHERE id = ?", [event.project_id]);
34
+ if (!project || !projectRoot || canonicalPath(projectRoot, false) !== project.root)
35
+ throw new AppError("invocation_scope_mismatch", "Auxiliary command differs from its native project scope", 1, { phase: "prepare", effect: "no_effect" });
36
+ for (const record of managedProcessStatus(context)) {
37
+ if (record.project_id !== event.project_id || !record.run_id || !record.kind.endsWith("-daemon"))
38
+ continue;
39
+ const metadata = JSON.parse(record.metadata_json);
40
+ if (typeof metadata.state_dir !== "string")
41
+ continue;
42
+ let state;
43
+ try {
44
+ state = readNativeDaemonState(metadata.state_dir);
45
+ }
46
+ catch {
47
+ continue;
48
+ }
49
+ if (state.daemon_id !== daemonId)
50
+ continue;
51
+ requireManagedDaemonBinding(context, { stateDir: metadata.state_dir, projectId: event.project_id, runId: record.run_id });
52
+ const reference = auxiliary.args.options.get("run")?.[0] ?? auxiliary.args.positional[2];
53
+ const run = context.db.get("SELECT id FROM runs WHERE project_id = ? AND (id = ? OR short_id = ?)", [event.project_id, reference, reference]);
54
+ if (run?.id !== record.run_id)
55
+ throw new AppError("invocation_scope_mismatch", "Auxiliary command differs from its native RUN scope", 1, { phase: "prepare", effect: "no_effect" });
56
+ assertRunMutationAllowed(context, event.project_id, record.run_id, metadata.run_generation);
57
+ return;
58
+ }
59
+ throw new AppError("invocation_scope_unavailable", "Auxiliary command has no live managed daemon binding", 1, { phase: "prepare", effect: "no_effect" });
60
+ }
20
61
  // Conclusive result/check rejections, never transport, ownership, registry or
21
62
  // unknown-effect failures. These validators reject before accepting the result.
22
63
  export function isRetryableLifecycleRejection(code, details = {}) {
@@ -68,7 +109,7 @@ function fingerprint(parsed) {
68
109
  const runtimeOwnedOptions = {
69
110
  stage_start: new Set(["project-root", "context-file", "context-sha256", "response-file", "require-session-binding"]),
70
111
  stage_finish: new Set(["project-root", "result-file", "decision-file", "verification-file"]),
71
- stage_pause: new Set(["project-root"]), stage_resume: new Set(["project-root"]),
112
+ stage_pause: new Set(["project-root"]), stage_resume: new Set(["project-root", "answer-file"]),
72
113
  work_start: new Set(["project-root", "recovery-id"]),
73
114
  work_finish: new Set(["project-root"]), work_fail: new Set(["project-root"]),
74
115
  merge_apply: new Set(["project-root"]), merge_repair: new Set(["project-root"]),
@@ -122,9 +163,6 @@ function normalizedInvocationValue(key, value) {
122
163
  }
123
164
  /** Shell/JSON builders sometimes preserve the shell escape before an alias.
124
165
  * Accept it only for the small declared alias vocabulary. */
125
- function normalizedPathAlias(value) {
126
- return value.replace(/^\\+(?=@(?:project|workspace|run)(?:\/|$))/, "");
127
- }
128
166
  function quoteModelFacingArgs(argv) {
129
167
  return argv.map(value => /^@(project|workspace|run)(?:\/.*)?$/.test(value)
130
168
  ? `'${value.replaceAll("'", `'\\''`)}'`
@@ -181,23 +219,11 @@ export function expandLifecycleInvocationArgs(context, args, scope, operation) {
181
219
  const pathOptions = contextualPathOptionsForLifecycle(operation);
182
220
  const run = scope.runId ? context.db.get("SELECT id, short_id, workspace_root, run_root FROM runs WHERE id = ?", [scope.runId]) : undefined;
183
221
  const roots = { project: scope.projectRoot, workspace: run?.workspace_root, run: run?.run_root };
184
- return args.map((value, index) => {
222
+ return resolveContextualPaths(args.map((value, index) => {
185
223
  if (run && !args[index - 1]?.startsWith("--") && [run.short_id, normalizedInvocationValue("positional", run.id)].includes(value) && !operation.startsWith("work_"))
186
224
  return run.id;
187
- const option = args[index - 1]?.replace(/^--/, "");
188
- if (!option || !pathOptions.has(option))
189
- return value;
190
- value = normalizedPathAlias(value);
191
- if (!value.startsWith("@"))
192
- return value;
193
- const match = /^@(project|workspace|run)(?:\/(.*))?$/.exec(value);
194
- if (!match)
195
- throw new AppError("usage", `Unknown path alias: ${value}`, 2, { phase: "prepare", effect: "no_effect", recoverable: true, parameter: option });
196
- const root = roots[match[1]];
197
- if (!root)
198
- throw new AppError("usage", `Path alias @${match[1]} is unavailable without a bound RUN`, 2, { phase: "prepare", effect: "no_effect", recoverable: true, parameter: option });
199
- return assertPathWithin(root, path.join(root, match[2] ?? ""), `${match[1]}_alias`);
200
- });
225
+ return value;
226
+ }), pathOptions, roots);
201
227
  }
202
228
  /** Validate alias spelling before native-receipt lookup, so a correctable
203
229
  * command typo is never reported as missing infrastructure evidence. */
@@ -259,7 +285,7 @@ export function assertLifecycleOutcomes(context, scope) {
259
285
  const outcome = JSON.parse(row.outcome_json);
260
286
  if (outcome.disposition === "retrying" && outcome.deadline <= Date.now())
261
287
  throw new AppError("lifecycle_outcome_unknown", "Storage retry has no terminal receipt; reconcile its committed evidence", 1, { lifecycle_outcome: outcome, effect: "unknown" });
262
- if (outcome.disposition === "fatal")
288
+ if (outcome.disposition === "fatal" && !isRegisteredRepair(context, outcome.error?.details?.continuation, { projectId: project.id, runId: scope.runId }))
263
289
  throw new AppError(outcome.error?.code ?? "lifecycle_outcome_unknown", outcome.error?.message || "Mandatory lifecycle failed (historical receipt has no message)", 1, { ...outcome.error?.details, lifecycle_outcome: outcome });
264
290
  }
265
291
  }
@@ -272,15 +298,16 @@ export function assertLifecycleOutcomes(context, scope) {
272
298
  ORDER BY a.rowid`, [scope.projectRoot, scope.runId, scope.generation]);
273
299
  for (const row of rows) {
274
300
  const error = (row.outcome_json ? JSON.parse(row.outcome_json) : null)?.error;
275
- if (typeof error?.code === "string" && !isCorrectableLifecycleRejection(error.code, error.details))
301
+ if (typeof error?.code === "string" && !isCorrectableLifecycleRejection(error.code, error.details) && !isRegisteredRepair(context, error.details?.continuation, { projectId: project.id, runId: scope.runId }))
276
302
  throw new AppError(error.code, typeof error.message === "string" ? error.message : "Mandatory lifecycle failed", 1, { ...(error.details ?? {}), invocation_id: row.id });
277
303
  }
278
304
  }
279
305
  /** Hooks already own native identity. Retain command outcomes there, with an
280
306
  * atomic controller notification; missing notifications reconcile from the row. */
281
- export function observeLifecycleCommand(context, command) {
307
+ export function observeLifecycleCommand(context, command, nativeEvent) {
282
308
  const parsed = parseLifecycleCommand(command);
283
- if (parsed.kind !== "standalone")
309
+ const auxiliary = continuationCommand(command);
310
+ if (parsed.kind !== "standalone" && !auxiliary)
284
311
  return () => { };
285
312
  let anchor;
286
313
  context.observeLifecycleHook = (projectId, eventId) => {
@@ -291,6 +318,7 @@ export function observeLifecycleCommand(context, command) {
291
318
  return;
292
319
  let runId = context.db.get("SELECT run_id FROM sessions WHERE project_id = ? AND harness = ? AND (session_id = ? OR session_id = ? OR provider_session_id = ?) AND run_id IS NOT NULL ORDER BY session_id = ? DESC LIMIT 1", [projectId, event.harness, event.session_id, event.parent_session_id, event.parent_session_id, event.session_id])?.run_id;
293
320
  let generation;
321
+ let correlation = {};
294
322
  // A first Stage command precedes logical Session binding. Its native root
295
323
  // is already owned by the controller's retained create receipt.
296
324
  if (context.db.get("SELECT 1 FROM sqlite_master WHERE name = 'run_controllers'")) {
@@ -305,6 +333,8 @@ export function observeLifecycleCommand(context, command) {
305
333
  return;
306
334
  runId = owners[0].run_id;
307
335
  generation = owners[0].generation;
336
+ const current = JSON.parse(owners[0].state_json);
337
+ correlation = { stage: current.stage, ...(current.stage ? { attempt: stageAttempt(context, projectId, runId, current.stage) } : {}), controller_turn: current.turns };
308
338
  }
309
339
  }
310
340
  if (event.daemon_id) {
@@ -333,11 +363,14 @@ export function observeLifecycleCommand(context, command) {
333
363
  }
334
364
  if (!runId)
335
365
  return;
336
- anchor = { project_id: projectId, run_id: runId, generation: generation ?? recoveryGuard(context, projectId, runId)?.generation ?? 0, hook_event_id: eventId, session_id: event.session_id, harness: event.harness, operation: parsed.invocation.operation };
366
+ anchor = { project_id: projectId, run_id: runId, generation: generation ?? recoveryGuard(context, projectId, runId)?.generation ?? 0, hook_event_id: eventId, session_id: event.session_id, harness: event.harness, operation: parsed.kind === "standalone" ? parsed.invocation.operation : auxiliary.operation, ...correlation, ...(auxiliary ? { auxiliary: true } : {}) };
337
367
  };
338
- observeCommandHook(context, command);
339
- const invocationId = commandOption(parsed.invocation, "invocation-id");
340
- if (!anchor && invocationId) {
368
+ if (nativeEvent)
369
+ context.observeLifecycleHook(nativeEvent.project_id, nativeEvent.id);
370
+ else
371
+ observeCommandHook(context, command);
372
+ const invocationId = parsed.kind === "standalone" ? commandOption(parsed.invocation, "invocation-id") : undefined;
373
+ if (!anchor && invocationId && !nativeEvent) {
341
374
  const attempt = load(context, invocationId);
342
375
  checkCommand(attempt, command);
343
376
  if (attempt.event_key && ["observed", "executing"].includes(attempt.status)) {
@@ -356,7 +389,7 @@ export function observeLifecycleCommand(context, command) {
356
389
  return;
357
390
  const error = outcome.error ? errorRecord(outcome.error) : undefined;
358
391
  const correctable = error && isCorrectableLifecycleRejection(error.code, error.details);
359
- const record = { ...anchor, ...(error ? { error } : outcome), effect: error ? error.details.effect ?? (correctable ? "no_effect" : "unknown") : "committed", disposition: error ? correctable ? "correctable" : "fatal" : "completed" };
392
+ 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" };
360
393
  try {
361
394
  context.db.writeTransaction(() => {
362
395
  const retained = context.db.get("SELECT outcome_json FROM hook_events WHERE id = ?", [anchor.hook_event_id]);
@@ -441,9 +474,10 @@ export function managedInvocationContext(context, input) {
441
474
  env.DD_FLOW_INVOCATION_SCOPE = JSON.stringify(scope);
442
475
  return { ...context, env };
443
476
  }
444
- /** Generated commands retain their attempt across repeated rendering. Only a
445
- * currently executing command can emit its own successor with the same argv. */
446
- export function managedLifecycleCommand(context, command) {
477
+ /** Generated commands retain their attempt across repeated rendering. Execution
478
+ * can issue a retry; accepted HITL intent can replace an unobserved placeholder
479
+ * or issue the next pause's assignment after successful settlement. */
480
+ export function managedLifecycleCommand(context, command, options = {}) {
447
481
  const configured = context.env.DD_FLOW_INVOCATION_SCOPE;
448
482
  if (!configured)
449
483
  return command;
@@ -474,7 +508,18 @@ export function managedLifecycleCommand(context, command) {
474
508
  return context.db.writeTransaction(() => {
475
509
  const prior = context.db.get("SELECT * FROM lifecycle_invocations WHERE scope_json = ? AND fingerprint = ? ORDER BY rowid DESC LIMIT 1", [JSON.stringify(scope), fingerprint(parsed)]);
476
510
  if (prior && prior.id !== context.env.DD_FLOW_CURRENT_INVOCATION) {
477
- return publicInvocationCommand(context, prior, command);
511
+ const replacesAnswer = options.replaceUnobservedAnswer && parsed.operation === "stage_resume"
512
+ && (prior.status === "settled" || commandOption(invocation(prior.command), "answer-file") !== commandOption(parsed, "answer-file"));
513
+ if (!replacesAnswer)
514
+ return publicInvocationCommand(context, prior, command);
515
+ // The pause packet can prepare a placeholder before the controller has
516
+ // accepted an answer. Replace only an unobserved assignment; never change
517
+ // the authority of a native call already in flight.
518
+ const completed = prior.status === "settled" && prior.outcome_json && !JSON.parse(prior.outcome_json).error;
519
+ if (prior.status !== "issued" && !completed)
520
+ throw new AppError("invocation_answer_conflict", "Cannot replace an answer while its lifecycle assignment is in flight or failed", 1, { invocation_id: prior.id });
521
+ if (prior.status === "issued")
522
+ context.db.run("UPDATE lifecycle_invocations SET status = 'expired' WHERE id = ?", [prior.id]);
478
523
  }
479
524
  if (context.env.DD_FLOW_INVOCATION_READONLY === "1")
480
525
  throw new AppError("invocation_command_unprepared", "No prepared lifecycle command is available; read-only inspection cannot issue one", 1);
@@ -535,7 +580,9 @@ export function settleLifecycleRejection(context, id, error) {
535
580
  details.recoverable = true;
536
581
  if (storageRetry)
537
582
  details.storage_retry = 1;
538
- details.retry_instruction = "Correct only the evidenced defect, then use retry_command with the required result input. Reissuing the old command only reads its retained outcome.";
583
+ details.retry_instruction = isRegisteredRepair(context, details.continuation)
584
+ ? "The repair is already registered. End this coordinator turn so the controller can launch its fresh child. Use retry_command only after that repair completes, with fresh semantic verification."
585
+ : "Correct only the evidenced defect, then use retry_command with the required result input. Reissuing the old command only reads its retained outcome.";
539
586
  }
540
587
  else if (isCorrectableLifecycleRejection(error.code, error.details)) {
541
588
  details.effect = "no_effect";
@@ -807,8 +854,40 @@ export function observeLifecycleInvocation(context, input) {
807
854
  throw error;
808
855
  }
809
856
  }
810
- /** Return a precise native-observer rejection for the same argv. This keeps
811
- * shell diagnostics distinct from a genuinely absent hook receipt. */
857
+ /** Correlate exact native argv for diagnostics, independently of assignment
858
+ * matching. A hook-added event key must name the same persisted native call. */
859
+ export function observedLifecycleNativeEvent(context, command, daemonId) {
860
+ if (!context.db.get("SELECT 1 FROM sqlite_master WHERE name = 'hook_events'"))
861
+ return null;
862
+ const signature = (value) => {
863
+ const parsed = parseLifecycleCommand(value);
864
+ const candidate = parsed.kind === "standalone" && !parsed.invocation.wrapped ? parsed.invocation : continuationCommand(value);
865
+ if (!candidate)
866
+ return null;
867
+ return JSON.stringify([candidate.operation, candidate.args.positional,
868
+ [...candidate.args.options].filter(([key]) => !["json", "progress-jsonl", "hook-event-id"].includes(key)).sort(([a], [b]) => a.localeCompare(b))]);
869
+ };
870
+ const expected = signature(command);
871
+ if (!expected)
872
+ return null;
873
+ const parsed = parseLifecycleCommand(command);
874
+ const eventKey = parsed.kind === "standalone" ? commandOption(parsed.invocation, "hook-event-id") : undefined;
875
+ const matches = context.db.all("SELECT project_id, id, event_key, sanitized_summary FROM hook_events WHERE daemon_id = ? AND status = 'observed' AND outcome_json IS NULL", [daemonId]).filter(row => {
876
+ if (eventKey && row.event_key !== eventKey)
877
+ return false;
878
+ try {
879
+ const observed = lifecycleCommandFromPayload(JSON.parse(row.sanitized_summary));
880
+ return typeof observed === "string" && signature(observed) === expected;
881
+ }
882
+ catch {
883
+ return false;
884
+ }
885
+ });
886
+ // Diagnostic correlation only: never use this as execution authority, and
887
+ // never choose the latest of several identical native calls.
888
+ return matches.length === 1 ? { project_id: matches[0].project_id, id: matches[0].id } : null;
889
+ }
890
+ /** Keep shell diagnostics distinct from genuinely absent native delivery. */
812
891
  export function observedLifecycleDiagnostic(context, command, daemonId) {
813
892
  const analysis = parseLifecycleCommand(command);
814
893
  if (analysis.kind !== "standalone")
@@ -833,7 +912,7 @@ export function observedLifecycleDiagnostic(context, command, daemonId) {
833
912
  /** Resolve only a command that a native hook has already bound to an issued
834
913
  * attempt. This is the CLI-side half of admission for model-facing commands
835
914
  * that intentionally omit the internal UUID. */
836
- export function observedLifecycleInvocation(context, command) {
915
+ export function observedLifecycleInvocation(context, command, daemonId) {
837
916
  if (!context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'lifecycle_invocations'"))
838
917
  return null;
839
918
  const analysis = parseLifecycleCommand(command);
@@ -843,6 +922,8 @@ export function observedLifecycleInvocation(context, command) {
843
922
  .filter(row => {
844
923
  try {
845
924
  const scope = JSON.parse(row.scope_json);
925
+ if (daemonId && scope.daemonId !== daemonId)
926
+ return false;
846
927
  return fingerprint(invocation(canonicalManagedCommand(context, command, scope))) === row.fingerprint;
847
928
  }
848
929
  catch {
@@ -49,7 +49,7 @@ export function captureRecoveryDatabase(source, output, projectId, runId, protoc
49
49
  merge_servers: "0", merge_sessions: "0", merge_queue: "0", lanes: "0", lane_locks: "0", lane_waiters: "0",
50
50
  sqlite_sequence: "0"
51
51
  };
52
- for (const table of ["works", "check_receipts", "merge_requests", "flow_run_flag_mutations", "usage", "flow_session_segments", "flow_jobs", "run_recovery_guards", "run_controllers", "run_controls"])
52
+ for (const table of ["works", "stage_work_memberships", "repair_intents", "check_receipts", "merge_requests", "flow_run_flag_mutations", "usage", "flow_session_segments", "flow_jobs", "run_recovery_guards", "run_controllers", "run_controls"])
53
53
  rules[table] = ownRun;
54
54
  for (const table of ["plan_bindings", "plan_progress", "worktree_records", "audit_events"])
55
55
  rules[table] = `${ownProject} AND protocol_id IN (${protocols})`;
@@ -0,0 +1,40 @@
1
+ import crypto from "node:crypto";
2
+ import { stageAttempt, stageCycle, bindStageWork } from "./stage-work-graph.js";
3
+ import { assertRunMutationAllowed } from "./run-recovery.js";
4
+ import { AppError } from "../shared/errors.js";
5
+ /** Checks finish before entering this synchronous registration transaction. */
6
+ export function ensureAggregateRepair(context, input, create) {
7
+ if (!input.failures.length || input.failures.some(receipt => receipt.status !== "failed" && receipt.status !== "aborted"))
8
+ throw new AppError("repair_intent_invalid", "Repair requires settled failed receipts", 1);
9
+ const causes = input.failures.map(receipt => ({ id: receipt.id, epoch: receipt.verification_epoch, fingerprint: receipt.workspace_fingerprint })).sort((a, b) => a.id.localeCompare(b.id));
10
+ return ensureRepairIntent(context, { ...input, causes: { receipts: causes, origins: [...(input.origins ?? [])].sort() } }, create);
11
+ }
12
+ export function ensureRepairIntent(context, input, create) {
13
+ return context.db.writeTransaction(() => {
14
+ assertRunMutationAllowed(context, input.projectId, input.runId, input.generation);
15
+ const attempt = stageAttempt(context, input.projectId, input.runId, input.stage);
16
+ const payload = JSON.stringify({ project_id: input.projectId, run_id: input.runId, stage: input.stage, attempt, cycle: stageCycle(context, input.projectId, input.runId, input.stage), causes: input.causes });
17
+ const identity = crypto.createHash("sha256").update(payload).digest("hex");
18
+ const prior = context.db.get("SELECT work_id, payload_json FROM repair_intents WHERE identity = ?", [identity]);
19
+ if (prior && prior.payload_json !== payload)
20
+ throw new AppError("repair_intent_conflict", "Repair identity has different causes", 1);
21
+ const workId = prior?.work_id ?? create();
22
+ if (!prior) {
23
+ bindStageWork(context, { projectId: input.projectId, runId: input.runId, workId, stage: input.stage, attempt });
24
+ context.db.run("INSERT INTO repair_intents (identity, project_id, run_id, stage, attempt, payload_hash, payload_json, work_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", [identity, input.projectId, input.runId, input.stage, attempt, identity, payload, workId, context.now()]);
25
+ }
26
+ return { schema_id: "dd-flow/continuation@1", kind: "repair_required", project_id: input.projectId, run_id: input.runId, stage: input.stage, attempt, identity, work_id: workId };
27
+ });
28
+ }
29
+ export function isRegisteredRepair(context, value, scope) {
30
+ if (!value || typeof value !== "object")
31
+ return false;
32
+ const receipt = value;
33
+ if (receipt.schema_id !== "dd-flow/continuation@1" || receipt.kind !== "repair_required")
34
+ return false;
35
+ if (scope && (receipt.project_id !== scope.projectId || receipt.run_id !== scope.runId))
36
+ return false;
37
+ if (!context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'repair_intents'"))
38
+ return false;
39
+ return Boolean(context.db.get("SELECT 1 FROM repair_intents i JOIN works w ON w.work_id = i.work_id AND w.project_id = i.project_id AND w.run_id = i.run_id WHERE i.identity = ? AND i.project_id = ? AND i.run_id = ? AND i.stage = ? AND i.attempt = ? AND i.work_id = ?", [receipt.identity, receipt.project_id, receipt.run_id, receipt.stage, receipt.attempt, receipt.work_id]));
40
+ }
@@ -26,6 +26,7 @@ import { controllerNativeChildren, controllerStageEntryPrompt, nextControllerFan
26
26
  import { claimQueuedMergeRequest } from "./merge-server.js";
27
27
  import { prepareVnextCodeWorkspace } from "./vnext-code.js";
28
28
  import { requireRun, requireController, controllerReceipt, appendEvent, saveState, retainControllerContinuation, digest, digestBytes, errorRecord } from "./run-controller-state.js";
29
+ import { assertNoCorrelatedRejection } from "./continuation-outcome.js";
29
30
  import { spawnController, recordControllerLauncher, claimControllerProcess, markControllerDispatchReady } from "./run-controller-process.js";
30
31
  import { controllerAdapter, controllerEnvironment, sessionArguments } from "./run-controller-adapter.js";
31
32
  import { resumeControllerSession, failControllerRecovery } from "./run-controller-recovery.js";
@@ -598,19 +599,25 @@ async function executeController(context, row, manifest, state, assertOwnership,
598
599
  const pause = view.index.stage_runs.find(stage => stage.stage === next.stage)?.pause;
599
600
  if (!pause || state.current_session === null)
600
601
  throw new AppError("controller_hitl_missing", "Paused RUN has no managed Session or HITL receipt", 1);
601
- const answer = context.db.get("SELECT operation_id, input_json FROM run_controller_operations WHERE controller_id = ? AND status = 'accepted' AND json_extract(input_json, '$.action') = 'user_answer' AND json_extract(input_json, '$.pause_id') = ? ORDER BY created_at LIMIT 1", [row.controller_id, pause.id]);
602
+ const answer = context.db.get("SELECT operation_id, input_json, status FROM run_controller_operations WHERE controller_id = ? AND status IN ('accepted','completed') AND json_extract(input_json, '$.action') = 'user_answer' AND json_extract(input_json, '$.pause_id') = ? ORDER BY created_at LIMIT 1", [row.controller_id, pause.id]);
602
603
  if (!answer) {
603
604
  await delay(1000);
604
605
  continue;
605
606
  }
607
+ if (answer.status === "completed")
608
+ throw new AppError("controller_answer_not_applied", "Completed answer operation still has its original HITL pause", 1, { pause_id: pause.id, operation_id: answer.operation_id, stage: next.stage });
606
609
  const input = JSON.parse(answer.input_json);
607
610
  if (digestBytes(fs.readFileSync(input.file)) !== input.sha256)
608
611
  throw new AppError("context_checksum_mismatch", "Accepted HITL answer bytes changed", 1);
609
612
  assertOwner();
610
613
  // The accepted answer is logical intent; only its generation-bound
611
614
  // adapter operation represents a physical dispatch that stop must drain.
612
- const command = managedLifecycleCommand(executionContext, `${flowCommand(executionContext)} stage resume ${run.id} --stage ${next.stage} --work ${input.work_id} --project-root ${quote([run.project_root])} --answer-file ${quote([input.file])} --json`);
615
+ const command = managedLifecycleCommand(executionContext, `${flowCommand(executionContext)} stage resume ${run.id} --stage ${next.stage} --work ${input.work_id} --project-root ${quote([run.project_root])} --answer-file ${quote([input.file])} --json`, { replaceUnobservedAnswer: true });
613
616
  const receipt = await prompt(state.sessions[state.current_session], `The user supplied the raw answer for pause ${pause.id}. Your first tool call must be this exact standalone command:\n\n${command}\n\nDo not alter the answer. Follow the returned packet, then stop at this Stage boundary.`, `${answer.operation_id}:prompt:${row.generation}`);
617
+ const afterAnswer = getFlowRunStatus(executionContext, { projectRoot: run.project_root, runId: run.id });
618
+ if (afterAnswer.index.stage_runs.some(stage => stage.status === "paused" && stage.pause?.id === pause.id)) {
619
+ throw new AppError("controller_answer_not_applied", "The answer Turn completed without resuming the assigned HITL pause", 1, { pause_id: pause.id, operation_id: answer.operation_id, stage: next.stage, effect: "no_progress" });
620
+ }
614
621
  context.db.run("UPDATE run_controller_operations SET status = 'completed', receipt_json = ?, updated_at = ? WHERE operation_id = ?", [JSON.stringify({ delivered: true, session_id: adapterSessionId(receipt) }), context.now(), answer.operation_id]);
615
622
  state.last_continuation = null;
616
623
  saveState(context, row, state);
@@ -744,7 +751,9 @@ async function executeController(context, row, manifest, state, assertOwnership,
744
751
  saveState(context, row, state);
745
752
  continue;
746
753
  }
747
- retainControllerContinuation(state, fanout.fingerprint, { code: "fanout_stage_nonprogressing", message: "The coordinator repeated an unchanged Work-graph continuation", details: { stage: next.stage } });
754
+ if (state.last_continuation === fanout.fingerprint)
755
+ assertNoCorrelatedRejection(context, { projectId: row.project_id, runId: row.run_id, generation: row.generation, stage: next.stage, turn: state.turns });
756
+ retainControllerContinuation(state, fanout.fingerprint, { code: "fanout_stage_nonprogressing", message: "The coordinator repeated an unchanged Work-graph continuation", details: { stage: next.stage, before: state.last_continuation, after: fanout.fingerprint, controller_turn: state.turns } });
748
757
  saveState(context, row, state);
749
758
  await prompt(session, fanout.prompt);
750
759
  }
@@ -0,0 +1,43 @@
1
+ import path from "node:path";
2
+ import crypto from "node:crypto";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import { AppError } from "../shared/errors.js";
6
+ /** Only the trusted launcher is a shell fragment; all caller data is argv. */
7
+ export function runtimeCommand(context, args) {
8
+ return `${flowCommand(context)} ${args.map(shellArgument).join(" ")}`;
9
+ }
10
+ export function shellArgument(value) {
11
+ return /^[A-Za-z0-9_./:@=-]+$/.test(value) ? value : `'${value.replace(/'/g, "'\\''")}'`;
12
+ }
13
+ export function flowCommand(context) {
14
+ const defaultHome = path.resolve(path.join(os.homedir(), ".dd-flow"));
15
+ const quoted = (value) => `"${value.replace(/[\\"$`]/g, character => `\\${character}`)}"`;
16
+ const home = `DD_FLOW_HOME=${quoted(context.ddFlowHome)}`;
17
+ // The runner binds this variable to its isolated published executable.
18
+ if (context.env.DD_FLOW_BIN && path.isAbsolute(context.env.DD_FLOW_BIN))
19
+ return `${home} "$DD_FLOW_BIN"`;
20
+ const isolated = path.join(context.ddFlowHome, "bin", "dd-flow");
21
+ if (context.ddFlowHome !== defaultHome && fs.existsSync(isolated))
22
+ return `${home} ${quoted(isolated)}`;
23
+ return context.ddFlowHome === defaultHome ? "dd-flow" : `${home} dd-flow`;
24
+ }
25
+ export function recoveryEvidenceInput(runRoot, receiptId) {
26
+ const basename = crypto.createHash("sha256").update(receiptId).digest("hex").slice(0, 20);
27
+ return {
28
+ schema_id: "dd-flow/continuation@1",
29
+ kind: "needs_input",
30
+ actor: "coordinator",
31
+ input_file: path.join(runRoot, `check-recovery-${basename}.json`),
32
+ input_contract: { reason: "Nonempty evidence explaining what environment was restored; do not invent recovery." },
33
+ instruction: "Only after restoring the environment, write this JSON file. Then use command_after_input. This is not permission to change product code or bypass the failed check."
34
+ };
35
+ }
36
+ export function checkRetryArgs(context, projectRoot, runId, receiptId) {
37
+ if (!receiptId)
38
+ return [];
39
+ const run = context.db.get("SELECT run_root FROM runs WHERE project_root = ? AND id = ?", [projectRoot, runId]);
40
+ if (!run?.run_root)
41
+ throw new AppError("runtime_missing", "Check recovery requires a bound RUN root", 1);
42
+ return ["--retry-check", receiptId, "--reason-file", recoveryEvidenceInput(run.run_root, receiptId).input_file];
43
+ }
@@ -1,6 +1,5 @@
1
1
  import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
- import os from "node:os";
4
3
  import path from "node:path";
5
4
  import { quote } from "shell-quote";
6
5
  import { lifecycleRetryCommands, managedLifecycleCommand } from "./lifecycle-invocations.js";
@@ -13,6 +12,8 @@ import { requireProjectByRoot } from "./projects.js";
13
12
  import { appendFlowRunTimelineEvent, pauseFlowRunStage, resumeFlowRunStage } from "./runs.js";
14
13
  import { bindSessionForResume, refreshRunWorkProjection } from "./work-registry.js";
15
14
  import { assertRunMutationAllowed } from "./run-recovery.js";
15
+ import { runtimeCommand } from "./runtime-command.js";
16
+ export { flowCommand } from "./runtime-command.js";
16
17
  export function prepareStagePause(context, input) {
17
18
  if (typeof input.question !== "string" || !input.question.trim())
18
19
  throw new AppError("validation", "stage pause requires a non-empty user question", 2);
@@ -159,8 +160,10 @@ export function resumeStageAfterUser(context, input) {
159
160
  return { ok: true, outcome: "resumed", run_id: run.id, work_id: work.work_id, stage: stage.stage, pause_id: pause.id, question_path: pause.question_path, answer_path: answerPath, prompt_path: workSession.prompt_path, worker_prompt_markdown: continuation, next_action: `continue_${stage.stage}` };
160
161
  }
161
162
  export function stagePauseCommand(context, input) {
162
- const questionInputPath = `@run/works/${input.workId}/question-input.md`;
163
- return managedLifecycleCommand(context, `${flowCommand(context)} stage pause ${shortEntityReference(input.runId)} --stage ${input.stage} --work ${shortEntityReference(input.workId)} --question-file '${questionInputPath}' --project-root ${quote([input.projectRoot])} --json`);
163
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
164
+ const run = requireRun(context, project.id, input.runId);
165
+ const questionInputPath = path.join(requireRunHome(run), "works", input.workId, "question-input.md");
166
+ return managedLifecycleCommand(context, runtimeCommand(context, ["stage", "pause", shortEntityReference(input.runId), "--stage", input.stage, "--work", shortEntityReference(input.workId), "--question-file", questionInputPath, "--project-root", input.projectRoot, "--json"]));
164
167
  }
165
168
  /** Render separate file-write and lifecycle-call instructions for HITL. */
166
169
  export function stagePauseCommandTemplate(command) {
@@ -185,24 +188,7 @@ Then run this standalone command:
185
188
  ${command}`;
186
189
  }
187
190
  function stageResumeCommand(context, input) {
188
- return managedLifecycleCommand(context, `${flowCommand(context)} stage resume ${shortEntityReference(input.runId)} --stage ${input.stage} --work ${shortEntityReference(input.workId)} --answer-file ${quote([input.answerInputPath])} --project-root ${quote([input.projectRoot])} --json`);
189
- }
190
- export function flowCommand(context) {
191
- const defaultHome = path.resolve(path.join(os.homedir(), ".dd-flow"));
192
- // Desktop/ACP tool processes are not required to inherit the environment of
193
- // the adapter that created their Session. In an isolated runtime, a bare
194
- // `dd-flow` can therefore select the host-global router despite a correct
195
- // DD_FLOW_HOME. The runner supplies an absolute executable for that case;
196
- // make every generated lifecycle command self-contained.
197
- const executable = context.env.DD_FLOW_BIN;
198
- if (executable && path.isAbsolute(executable)) {
199
- return `DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} "$DD_FLOW_BIN"`;
200
- }
201
- const isolatedExecutable = path.join(context.ddFlowHome, "bin", "dd-flow");
202
- if (context.ddFlowHome !== defaultHome && fs.existsSync(isolatedExecutable)) {
203
- return `DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} ${JSON.stringify(isolatedExecutable)}`;
204
- }
205
- return context.ddFlowHome === defaultHome ? "dd-flow" : `DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} dd-flow`;
191
+ return managedLifecycleCommand(context, runtimeCommand(context, ["stage", "resume", shortEntityReference(input.runId), "--stage", input.stage, "--work", shortEntityReference(input.workId), "--answer-file", input.answerInputPath, "--project-root", input.projectRoot, "--json"]));
206
192
  }
207
193
  function requireRun(context, projectId, id) {
208
194
  const rows = context.db.all("SELECT id, short_id, project_id, project_root, run_root, index_json FROM runs WHERE project_id = ? AND (id = ? OR short_id = ?)", [projectId, id, id]);
@@ -0,0 +1,35 @@
1
+ import { AppError } from "../shared/errors.js";
2
+ export function stageAttempt(context, projectId, runId, stage) {
3
+ const run = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
4
+ if (!run)
5
+ throw new AppError("not_found", "Stage graph RUN is unavailable", 1);
6
+ const stages = JSON.parse(run.index_json).stage_runs ?? [];
7
+ return [...stages].reverse().find(item => item.stage === stage)?.attempt ?? "try-001";
8
+ }
9
+ export function bindStageWork(context, input) {
10
+ context.db.run("INSERT OR IGNORE INTO stage_work_memberships (project_id, run_id, work_id, stage, attempt, cycle) VALUES (?, ?, ?, ?, ?, ?)", [input.projectId, input.runId, input.workId, input.stage, input.attempt ?? stageAttempt(context, input.projectId, input.runId, input.stage), input.cycle ?? stageCycle(context, input.projectId, input.runId, input.stage)]);
11
+ }
12
+ export function stageCycle(context, projectId, runId, stage) {
13
+ if (stage !== "code" && stage !== "code-review")
14
+ return "";
15
+ const row = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
16
+ const cycle = row ? JSON.parse(row.index_json).variables?.["merge.source_repair"]?.cycle : undefined;
17
+ return typeof cycle === "string" ? cycle : stage === "code-review" ? "initial" : "";
18
+ }
19
+ export function stageWorkIds(context, projectId, runId, stage) {
20
+ if (!context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'stage_work_memberships'"))
21
+ throw new AppError("stage_graph_compatibility_required", "Stage Work membership is unavailable; use the RUN's pinned engine", 1);
22
+ return new Set(context.db.all("SELECT work_id FROM stage_work_memberships WHERE project_id = ? AND run_id = ? AND stage = ? AND attempt = ? AND cycle = ?", [projectId, runId, stage, stageAttempt(context, projectId, runId, stage), stageCycle(context, projectId, runId, stage)]).map(row => row.work_id));
23
+ }
24
+ /** Generic Work APIs also serve non-stage runs; null preserves that contract. */
25
+ export function currentStageWorkIds(context, projectId, runId) {
26
+ const row = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
27
+ if (!row)
28
+ return null;
29
+ const index = JSON.parse(row.index_json);
30
+ const stage = [...(index.stage_runs ?? [])].reverse().find(item => item.status === "running")?.stage;
31
+ return stage ? stageWorkIds(context, projectId, runId, stage) : null;
32
+ }
33
+ export function stageGraphScope(context, projectId, runId, stage) {
34
+ return { project_id: projectId, run_id: runId, attempt: stageAttempt(context, projectId, runId, stage), cycle: stageCycle(context, projectId, runId, stage) };
35
+ }