@deksden-com/dd-flow-cli 0.9.0-beta.55 → 0.9.0-beta.62

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 CHANGED
@@ -1,5 +1,48 @@
1
1
  # @deksden-com/dd-flow-cli
2
2
 
3
+ ## 0.9.0-beta.62
4
+
5
+ ### Patch Changes
6
+
7
+ - Verify a controlled native Session with a fresh inspection after cancellation before treating its tree as settled.
8
+
9
+ ## 0.9.0-beta.61
10
+
11
+ ### Patch Changes
12
+
13
+ - Keep the scope reconciliation contract test type-safe.
14
+
15
+ ## 0.9.0-beta.60
16
+
17
+ ### Patch Changes
18
+
19
+ - Document and verify explicit reconciliation of a drained runtime scope without automatic observer respawn.
20
+
21
+ ## 0.9.0-beta.59
22
+
23
+ ### Patch Changes
24
+
25
+ - Keep bundled ZCode close-contract fixtures aligned with retained native topology checks.
26
+
27
+ ## 0.9.0-beta.58
28
+
29
+ ### Patch Changes
30
+
31
+ - Keep local task artifacts outside the release lint scope.
32
+
33
+ ## 0.9.0-beta.57
34
+
35
+ ### Patch Changes
36
+
37
+ - Bound recovery observation, expose safe external reconciliation, and require
38
+ complete non-resident ZCode session-tree evidence before a stopped run settles.
39
+
40
+ ## 0.9.0-beta.56
41
+
42
+ ### Patch Changes
43
+
44
+ - Keep RUN database and runtime status aligned with pause, stop, controller failures and trusted recovery. Preserve real Stage pauses and terminal results, reject late revival under a recovery fence, roll back status changes after persistence failures, and provide an explicit backed-up legacy status repair tool.
45
+
3
46
  ## 0.9.0-beta.55
4
47
 
5
48
  ### Patch Changes
package/README.md CHANGED
@@ -452,3 +452,25 @@ pnpm build
452
452
  ```
453
453
 
454
454
  Manual smoke checks should use a temporary `DD_FLOW_HOME` when they do not intentionally inspect the operator's real local state.
455
+
456
+ ### RUN status during recovery
457
+
458
+ A controller failure pauses the logical RUN without rewriting its Stage/Work
459
+ outcomes. Accepted pause/stop or recovery intent immediately persists `paused` in the RUN row,
460
+ `index_json`, and `run.json`. A trusted recovery acknowledgement restores the
461
+ operational status; an existing Stage/Work pause remains paused. Terminal RUNs
462
+ are never reopened. Physical tree settlement remains a separate control receipt.
463
+
464
+ Legacy stores with a fenced recovery may still contain `running`. After building,
465
+ inspect one exact RUN with:
466
+
467
+ ```sh
468
+ node tools/repair-paused-run-status.mjs --home /path/to/dd-flow-home --project PRJ-001-project --run RUN-001-eval-subject
469
+ ```
470
+
471
+ Add `--apply` to repair it. This requires the latest recovery to be draining, sealed or resuming, saves the original row and runtime JSON
472
+ beside `run.json`, and changes no schema, engine binding or recovery capture.
473
+ It does not claim physical settlement or alter its receipts. `--cancel-abandoned`
474
+ explicitly cancels an abandoned fixture only when the project directory is absent
475
+ and it has no Work, Session, controller or recovery history. Historical experiment
476
+ engine snapshots remain pinned; rebuild/release the engine for future executions.
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "cli_package": "@deksden-com/dd-flow-cli",
3
- "cli_version": "0.9.0-beta.55",
4
- "cli_commit": "84e76cfa798732b3121534551515b02f4a238425",
5
- "built_at": "2026-09-13T19:08:39.320Z",
3
+ "cli_version": "0.9.0-beta.62",
4
+ "cli_commit": "a496c8a3b9781cd1808bd4a49fe314f0ef17c52c",
5
+ "built_at": "2026-09-14T08:57:06.311Z",
6
6
  "built_with_canon": {
7
7
  "version": "4.1.1",
8
8
  "commit": "97f811d33c212ae3497020178b1ed825c7c3ebac",
9
9
  "flow_contract": "dd-flow-canonical-2026-08",
10
- "repo_root": "/home/runner/work/dd-flow-cli/dd-flow-cli/dd-memorybank",
11
- "memorybank_root": "/home/runner/work/dd-flow-cli/dd-flow-cli/dd-memorybank/.memory-bank",
12
- "flow_root": "/home/runner/work/dd-flow-cli/dd-flow-cli/dd-memorybank/.memory-bank/dd-flow",
10
+ "repo_root": "/Users/deksden/Documents/_Projects/dd-memorybank",
11
+ "memorybank_root": "/Users/deksden/Documents/_Projects/dd-memorybank/.memory-bank",
12
+ "flow_root": "/Users/deksden/Documents/_Projects/dd-memorybank/.memory-bank/dd-flow",
13
13
  "layout": "dot_memory_bank"
14
14
  }
15
15
  }
package/dist/cli/help.js CHANGED
@@ -5,6 +5,7 @@ const helpPages = new Map([
5
5
  dd-flow runtime scope stop --scope-id EVAL_ID --request-id ID --json
6
6
  dd-flow runtime scope control --scope-id EVAL_ID --request-id ID --mode pause|stop [--manifest-json JSON] --json
7
7
  dd-flow runtime scope resume --scope-id EVAL_ID --request-id ID --generation N --capture-key SHA256 --json
8
+ dd-flow runtime scope reconcile --scope-id EVAL_ID --generation N --request-id ID --json
8
9
 
9
10
  Use the EVAL manifest's retained DD_FLOW_RESOURCE_HOME. Commands select exactly
10
11
  one scope across its registered runtime homes; neighboring scopes are untouched.
@@ -188,6 +189,7 @@ Usage:
188
189
  dd-flow id next --type protocol|run --project-root <root> --slug <slug> --json
189
190
  dd-flow run status <RUN-ID|RUN-short-id> --project-root <root> --json
190
191
  dd-flow run control status --run <RUN-ID> --project-root <root> --json
192
+ dd-flow run control reconcile --run <RUN-ID> --project-root <root> --control-id <ID> --request-id <ID> --json
191
193
  dd-flow run drive launch --run <RUN-ID> --project-root <root> --request-id <id> --context-file <file> --context-sha256 <sha256> [--stop-after <stage>] [--capture-root <directory>] --json
192
194
  dd-flow run drive status --run <RUN-ID> --project-root <root> [--after <sequence>] --json
193
195
  dd-flow run list --project-root <root> --json
@@ -59,11 +59,11 @@ import { loadExternalStageContext } from "../services/stage-context.js";
59
59
  import { stopManagedProcess, confirmManagedProcess, finishManagedProcess, heartbeatManagedProcess, managedProcessRuntimeHome, managedProcessStatus, processIsAlive, reconcileExpiredManagedProcesses, registerManagedProcess } from "../services/managed-processes.js";
60
60
  import { fenceRuntimeBudget, runtimeBudgetStatus, admitRuntimeCheck, parseRuntimeBudget, reserveProviderTurn, releaseProviderTurns } from "../services/runtime-budget.js";
61
61
  import { stopRuntimeScope } from "../services/runtime-scope-stop.js";
62
- import { launchRuntimeScopeControl, launchRuntimeScopeResume, serveRuntimeScopeControl } from "../services/runtime-scope-worker.js";
62
+ import { launchRuntimeScopeControl, launchRuntimeScopeResume, reconcileRuntimeScopeControl, serveRuntimeScopeControl } from "../services/runtime-scope-worker.js";
63
63
  import { launchRunController, resumeRunController, serveRunController, runControllerStatus, answerRunController, supplyRunControllerContext } from "../services/run-controller.js";
64
64
  import { controllerHitlAdmission } from "../services/run-controller-adapter.js";
65
65
  import { controlLifecycleReply, runControlStatus } from "../services/run-control.js";
66
- import { requestRunControl, serveRunControl } from "../services/run-control-worker.js";
66
+ import { reconcileRunControl, requestRunControl, serveRunControl } from "../services/run-control-worker.js";
67
67
  import { startRuntimeService } from "../services/runtime-service.js";
68
68
  const defaultIo = {
69
69
  stdout: process.stdout,
@@ -1142,6 +1142,11 @@ async function dispatch(args, context, io, scopeProjectRoot = null, classificati
1142
1142
  const scope = { projectRoot: requiredOption(parsed, "project-root"), runId: requiredOption(parsed, "run") };
1143
1143
  if (action === "status")
1144
1144
  return runControlStatus(context, scope);
1145
+ if (action === "reconcile") {
1146
+ if ([...parsed.options.keys()].some(key => !["run", "project-root", "control-id", "request-id"].includes(key)) || [...parsed.options.values()].some(values => values.length !== 1))
1147
+ throw new AppError("usage", "Use run control reconcile --run ID --project-root PATH --control-id ID --request-id ID", 2);
1148
+ return await reconcileRunControl(context, { ...scope, controlId: requiredOption(parsed, "control-id"), requestId: requiredOption(parsed, "request-id") });
1149
+ }
1145
1150
  if (action === "resume") {
1146
1151
  if (["force", "grace-ms", "escalate"].some(option => hasOption(parsed, option)))
1147
1152
  throw new AppError("usage", "Resume does not accept stop or pause escalation options", 2);
@@ -1157,7 +1162,7 @@ async function dispatch(args, context, io, scopeProjectRoot = null, classificati
1157
1162
  ...(hasOption(parsed, "escalate") ? { escalate: requiredOption(parsed, "escalate") } : {}),
1158
1163
  ...(hasOption(parsed, "wait-ms") ? { waitMs: Number(requiredOption(parsed, "wait-ms")) } : {})
1159
1164
  });
1160
- throw new AppError("usage", "Use: dd-flow run control pause|stop|resume|status", 2);
1165
+ throw new AppError("usage", "Use: dd-flow run control pause|stop|resume|reconcile|status", 2);
1161
1166
  }
1162
1167
  if (command === "drive") {
1163
1168
  const action = requiredPosition(parsed, 0, "drive action");
@@ -1790,6 +1795,11 @@ function dispatchRun(context, command, parsed) {
1790
1795
  }
1791
1796
  async function dispatchRuntime(context, command, parsed, progress) {
1792
1797
  if (command === "scope") {
1798
+ if (parsed.positional.length === 1 && parsed.positional[0] === "reconcile") {
1799
+ if ([...parsed.options.keys()].some(key => !["scope-id", "request-id", "generation"].includes(key)) || [...parsed.options.values()].some(values => values.length !== 1))
1800
+ throw new AppError("usage", "Use runtime scope reconcile --scope-id ID --generation N --request-id ID", 2);
1801
+ return { ok: true, ...await reconcileRuntimeScopeControl(context, requiredOption(parsed, "scope-id"), Number(requiredOption(parsed, "generation")), requiredOption(parsed, "request-id")) };
1802
+ }
1793
1803
  if (parsed.positional.length === 1 && parsed.positional[0] === "resume") {
1794
1804
  if ([...parsed.options.keys()].some(key => !["scope-id", "request-id", "generation", "capture-key"].includes(key)) || [...parsed.options.values()].some(values => values.length !== 1))
1795
1805
  throw new AppError("usage", "Use runtime scope resume --scope-id ID --request-id ID --generation N --capture-key SHA256", 2);
@@ -16,6 +16,8 @@ import {
16
16
  forkSessionWithBridge,
17
17
  inspectSessionWithBridge,
18
18
  promptSessionWithBridge,
19
+ zcodeChildSessionIds,
20
+ observeClosedZcodeTree,
19
21
  } from "./dd-zcode.mjs";
20
22
 
21
23
  const REQUEST_SCHEMA = "dd-zcode/daemon-request@1";
@@ -228,7 +230,7 @@ export class DaemonRuntime {
228
230
  const known = this.sessions.get(params.sessionId);
229
231
  const adapter = params.adapterSessionId ?? known?.adapter_session_id ?? params.sessionId;
230
232
  const generation = this.activeGeneration ?? this.controlGeneration ?? 0;
231
- return { ...this.state.config, ...params, adapterSessionId: adapter, daemonId: this.state.daemon_id, allowBackground: true, liveSession: this.liveAdapters.has(adapter), assertDispatch: () => { if (generation !== (this.controlGeneration ?? 0)) throw new DaemonError("operation_cancelled", "ZCode prompt was cancelled before native dispatch"); } };
233
+ return { ...this.state.config, ...params, knownChildSessionIds: known?.known_child_session_ids ?? [], adapterSessionId: adapter, daemonId: this.state.daemon_id, allowBackground: true, liveSession: this.liveAdapters.has(adapter), assertDispatch: () => { if (generation !== (this.controlGeneration ?? 0)) throw new DaemonError("operation_cancelled", "ZCode prompt was cancelled before native dispatch"); } };
232
234
  }
233
235
 
234
236
  track(result, topology = result?.evidence?.subagents) {
@@ -244,6 +246,7 @@ export class DaemonRuntime {
244
246
  parent_provider_session_id: parent,
245
247
  root_provider_session_id: existing?.root_provider_session_id ?? (parent ? parentRoot : result.provider_session_id),
246
248
  topology: topology ?? existing?.topology ?? null,
249
+ known_child_session_ids: [...new Set([...(existing?.known_child_session_ids ?? zcodeChildSessionIds(existing?.topology)), ...zcodeChildSessionIds(topology)])],
247
250
  completed_turns: existing?.completed_turns ?? 0,
248
251
  close_receipt: result.close?.closed === true ? result : existing?.close_receipt ?? null,
249
252
  }); }
@@ -321,7 +324,17 @@ export class DaemonRuntime {
321
324
  const closed = this.sessions.get(params.sessionId)?.close_receipt;
322
325
  if (closed) {
323
326
  if (operation === "session.resume") throw new DaemonError("session_closed", "Closed Session requires explicit recovery");
324
- return { ...closed, read: { projection: { status: "stopped" } }, subagents: closed.after, settled: closed.settled === true };
327
+ const topology = closed.topology;
328
+ if (topology?.root_session_id !== params.sessionId || !Array.isArray(topology.child_session_ids) || topology.child_session_ids.some(id => typeof id !== "string" || !id)) return { ...closed, settled: false, observation_error: { code: "topology_incomplete", message: "closed Session has no native topology identity" } };
329
+ const previous = this.state.settlement_observations?.[params.sessionId];
330
+ const known = [...new Set([...topology.child_session_ids, ...(previous?.topology?.child_session_ids ?? [])])];
331
+ const observation = { ...closed, ...await observeClosedZcodeTree(this.bridge, params.sessionId, known), observed_at: new Date().toISOString() };
332
+ // Observations are not ownership inventory: their timestamps must not
333
+ // invalidate a drain fence on every read.
334
+ this.state.settlement_observations ??= {};
335
+ this.state.settlement_observations[params.sessionId] = observation;
336
+ await this.persist();
337
+ return observation;
325
338
  }
326
339
  const adapter = params.adapterSessionId ?? params.sessionId;
327
340
  if (!this.liveAdapters.has(adapter) && this.activeProductive) {
@@ -367,7 +380,8 @@ export class DaemonRuntime {
367
380
  const unsettled = [];
368
381
  for (const session of this.sessions.values()) {
369
382
  if (session.close_receipt) {
370
- if (!session.close_receipt.settled) unsettled.push(session.provider_session_id);
383
+ const observed = await this.dispatch("session.inspect", { sessionId: session.provider_session_id });
384
+ if (observed.settled !== true) unsettled.push(session.provider_session_id);
371
385
  continue;
372
386
  }
373
387
  const options = this.options({ sessionId: session.provider_session_id, adapterSessionId: session.adapter_session_id });
@@ -199,11 +199,14 @@ export class AcpBridge {
199
199
  if (message.method === "session/update" && this.options.onLifecycleNotification) {
200
200
  const observer = this.options.onLifecycleNotification;
201
201
  const pending = Promise.resolve().then(() => observer(message)).catch(async error => {
202
- if (!this.notificationError || (fatalLifecycleError(error) && !fatalLifecycleError(this.notificationError))) this.notificationError = error;
202
+ // A validated no-effect rejection is returned through the invocation
203
+ // ledger. It is evidence, not a poisoned ACP Session.
204
+ const fatal = fatalLifecycleError(error);
205
+ if (!this.notificationError || fatal) this.notificationError = error;
203
206
  // A missing/foreign authority is not a recoverable CLI validation
204
207
  // result. Stop admitting productive requests immediately; the managed
205
208
  // controller still owns physical stop/drain and evidence capture.
206
- if (fatalLifecycleError(error)) this.rejectAll(error);
209
+ if (fatal) this.rejectAll(error);
207
210
  try {
208
211
  await this.journal.write("notification_failure", { code: error.code ?? "invocation_observation_failed", message: error.message,
209
212
  session_id: message.params?.sessionId ?? null, tool_call_id: update?.toolCallId ?? null, event_time: now(), delivery_stage: "lifecycle_receipt", completeness: "incomplete" });
@@ -429,7 +432,50 @@ export function latestAssistantText(read) {
429
432
  return null;
430
433
  }
431
434
 
432
- async function cancelTree(bridge, sessionId, before, providerSessionId) {
435
+ export function zcodeChildSessionIds(snapshot) {
436
+ return [...new Set([
437
+ ...(Array.isArray(snapshot?.childSessionIds) ? snapshot.childSessionIds : []),
438
+ ...(Array.isArray(snapshot?.running) ? snapshot.running : []).map(child => child?.childSessionId),
439
+ ...(Array.isArray(snapshot?.ended?.items) ? snapshot.ended.items : []).map(child => child?.childSessionId),
440
+ ].filter(id => typeof id === "string" && id))];
441
+ }
442
+
443
+ // Read residency BEFORE each durable topology snapshot. An inactive node cannot
444
+ // add descendants; a resident node never proves settlement, even when idle.
445
+ // No resolve/read/subagents extension here: those may implicitly resume.
446
+ export async function observeClosedZcodeTree(bridge, root, retainedChildren = []) {
447
+ const pending = new Set([root, ...retainedChildren]), residents = [], snapshots = [];
448
+ const deadline = performance.now() + 30_000;
449
+ try {
450
+ for (const sessionId of pending) {
451
+ if (pending.size > 1024 || performance.now() >= deadline) throw new Error("Closed tree observation limit exceeded");
452
+ const timeout = () => Math.max(1, Math.min(5000, deadline - performance.now()));
453
+ const resident = await bridge.request("zcode/session/resident", { sessionId }, timeout());
454
+ if (resident?.sessionId !== sessionId || typeof resident.resident !== "boolean") throw new Error("Invalid residency identity or state");
455
+ residents.push(resident);
456
+ const retained = await bridge.request("zcode/session/retainedSubagents", { sessionId }, timeout());
457
+ const tree = retained?.topology;
458
+ if (retained?.sessionId !== sessionId || !Array.isArray(tree?.childSessionIds)
459
+ || tree.childSessionIds.some(id => typeof id !== "string" || !id || id === sessionId)
460
+ || !Array.isArray(tree?.running) || !Array.isArray(tree?.ended?.items)
461
+ || !Number.isSafeInteger(tree.ended.total) || tree.ended.total < 0
462
+ || tree.ended.total > tree.childSessionIds.length
463
+ || [...tree.running, ...tree.ended.items].some(child => !tree.childSessionIds.includes(child?.childSessionId))) throw new Error("Incomplete retained native topology");
464
+ snapshots.push({ sessionId, ...tree });
465
+ for (const child of tree.childSessionIds) pending.add(child);
466
+ }
467
+ return { topology: { root_session_id: root, child_session_ids: [...pending].filter(id => id !== root), complete: true }, residents, snapshots, settled: residents.every(item => item.resident === false) };
468
+ } catch (error) {
469
+ return { topology: { root_session_id: root, child_session_ids: [...pending].filter(id => id !== root), complete: false }, residents, snapshots, settled: false, observation_error: { code: error.code ?? "topology_observation_failed", message: error.message } };
470
+ }
471
+ }
472
+
473
+ async function cancelTree(bridge, sessionId, before, providerSessionId, retainedChildren = []) {
474
+ const knownChildren = new Set(retainedChildren);
475
+ const remember = snapshot => {
476
+ for (const id of zcodeChildSessionIds(snapshot)) knownChildren.add(id);
477
+ };
478
+ remember(before);
433
479
  const cancellations = [];
434
480
  for (const child of before.running ?? []) {
435
481
  const taskId = child.taskId ?? child.agentId;
@@ -452,27 +498,20 @@ async function cancelTree(bridge, sessionId, before, providerSessionId) {
452
498
  bridge.request("zcode/session/read", { sessionId }, timeout).catch(error => ({ observation_error: { code: error.code, message: error.message } })),
453
499
  ]);
454
500
  root_status = read?.projection?.status ?? read?.session?.status ?? null;
501
+ remember(after);
455
502
  if (after.observation_error || read?.observation_error) break;
456
503
  if (Array.isArray(after.running) && !after.running.length && !(read?.projection?.activeToolCalls ?? []).length && ["idle", "completed", "cancelled", "failed", "stopped"].includes(root_status)) {
457
- return { cancellations, before, after, root_status, cancellation_requested: false, settled: true, close_requested: false };
504
+ // Idle is only a turn-level observation. Close the owned resident before
505
+ // proving the transitive tree cannot create another child.
506
+ break;
458
507
  }
459
508
  }
460
509
  let close = null;
461
510
  try { close = await bridge.request("zcode/session/close", { sessionId }); }
462
511
  catch (error) { close = { closed: false, error: { code: error.code ?? "close_failed", message: error.message } }; }
463
- const knownChildren = [...new Set([...(before.running ?? []), ...(after.running ?? [])].map(child => child.childSessionId).filter(Boolean))];
464
- const residents = close?.closed === true ? await Promise.all([providerSessionId, ...knownChildren].map(async id => {
465
- try {
466
- const observed = await bridge.request("zcode/session/resident", { sessionId: id }, 5000);
467
- if (observed.sessionId !== id) throw Object.assign(new Error("Residency evidence belongs to another Session"), { code: "session_identity_mismatch" });
468
- return observed;
469
- }
470
- catch (error) { return { sessionId: id, observation_error: { code: error.code, message: error.message } }; }
471
- })) : [];
472
- const completeTopology = !before.observation_error && Array.isArray(before.running) && !after.observation_error && Array.isArray(after.running)
473
- && [...(before.running ?? []), ...after.running].every(child => typeof child.childSessionId === "string");
474
- return { cancellations, before, after, root_status, cancellation_requested: true, close_requested: true, close, residents,
475
- settled: close?.closed === true && completeTopology && residents.length > 0 && residents.every(item => item.resident === false) };
512
+ const observation = close?.closed === true ? await observeClosedZcodeTree(bridge, providerSessionId, [...knownChildren])
513
+ : { topology: { root_session_id: providerSessionId, child_session_ids: [...knownChildren], complete: false }, settled: false };
514
+ return { cancellations, before, after, root_status, cancellation_requested: true, close_requested: true, close, ...observation };
476
515
  }
477
516
 
478
517
  // Cancel one known child without cancelling its parent turn. The parent keeps
@@ -749,7 +788,7 @@ export async function cancelSessionWithBridge(bridge, options) {
749
788
  requireJournal(options);
750
789
  const identity = await controlledIdentity(bridge, options);
751
790
  const before = await bridge.request("zcode/session/subagents", { sessionId: identity.adapterSessionId }).catch(error => ({ observation_error: { code: error.code, message: error.message } }));
752
- return receipt(options, { harness: "zcode-acp", provider_session_id: identity.providerSessionId, adapter_session_id: identity.adapterSessionId, ...await cancelTree(bridge, identity.adapterSessionId, before, identity.providerSessionId) });
791
+ return receipt(options, { harness: "zcode-acp", provider_session_id: identity.providerSessionId, adapter_session_id: identity.adapterSessionId, ...await cancelTree(bridge, identity.adapterSessionId, before, identity.providerSessionId, options.knownChildSessionIds) });
753
792
  }
754
793
 
755
794
  export async function forkSession(options) {
@@ -3,6 +3,7 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath, pathToFileURL } from "node:url";
5
5
  import { AppError } from "../shared/errors.js";
6
+ import { recoveryRpcTimeout } from "./recovery-observation-budget.js";
6
7
  const maxAdapterOutputBytes = 1_000_000;
7
8
  /** Native hooks need a real executable, not a PATH-dependent shell expression. */
8
9
  export function prepareHarnessFlowExecutable(context, stateDir) {
@@ -37,7 +38,7 @@ export function prepareHarnessFlowExecutable(context, stateDir) {
37
38
  }
38
39
  /** Runs one harness command with the lifecycle guarantees shared by agent services. */
39
40
  export function runHarnessAdapter(input) {
40
- const timeoutMs = input.timeoutMs ?? 120_000;
41
+ const timeoutMs = recoveryRpcTimeout(input.timeoutMs ?? 120_000);
41
42
  if (!input.operationId.trim())
42
43
  throw new AppError("operation_id_required", "Harness calls require a scoped operation id", 2);
43
44
  // The current adapter transport uses this name; its identity is runtime-owned,
@@ -9,7 +9,7 @@ import { ensureDir, resolveProjectRoot } from "../storage/paths.js";
9
9
  import { appendAudit } from "./audit.js";
10
10
  import { nativeSessionIdentity, storageSessionId } from "./session-identity.js";
11
11
  import { assertRecoveryHookCurrent } from "./run-recovery.js";
12
- import { observeLifecycleInvocation } from "./lifecycle-invocations.js";
12
+ import { observeLifecycleInvocation, settleLifecycleNoEffectRejection } from "./lifecycle-invocations.js";
13
13
  import { commandHasOption, commandOption, commandPosition, shellSuffixOffset, parseLifecycleCommand, unwrapShellCommand } from "./lifecycle-command.js";
14
14
  import { registerProject, requireProjectByRoot } from "./projects.js";
15
15
  import { activeFlowSessionsForProject, bindObservedFlowSession, flowSessionPayloadFromRegisterCommand, recordFlowSessionObservation } from "./sessions.js";
@@ -433,9 +433,9 @@ export function handleZcodeEvent(context, input) {
433
433
  }) }, commandOption(parsed.invocation, "invocation-id"));
434
434
  }
435
435
  /**
436
- * Accept the synchronous native ZCode PreToolUse callback. ACP notifications
437
- * are retained as evidence, but cannot be an admission signal: they arrive
438
- * after the tool call and may be serialized behind unrelated work.
436
+ * Accept the synchronous native ZCode PreToolUse callback. Invocation-ID
437
+ * commands instead rendezvous with their ACP event while the CLI awaits
438
+ * admission; the native hook must not create a competing receipt for them.
439
439
  */
440
440
  export function handleZcodeHook(context, input) {
441
441
  return recordZcodeLifecycle(context, input);
@@ -455,9 +455,6 @@ function recordZcodeLifecycle(context, input, invocationId) {
455
455
  return { ok: true, observed: false, reason: "event_not_participating" };
456
456
  if (!invocationId && commandOption(lifecycle.invocation, "invocation-id"))
457
457
  return { ok: true, observed: false, reason: "cli_invocation_owns_admission" };
458
- if (lifecycle.analysis.kind === "compound") {
459
- throw new AppError("compound_lifecycle_command", "dd-flow lifecycle commands must be a standalone ZCode Bash tool call", 1, { standalone_command: lifecycle.invocation.command });
460
- }
461
458
  const expectedRoot = resolveProjectRoot(input.projectRoot);
462
459
  if (lifecycle.commandProjectRoot && resolveProjectRoot(lifecycle.commandProjectRoot) !== expectedRoot) {
463
460
  throw new AppError("project_mismatch", "ZCode lifecycle command project root does not match the controlled workspace", 1, { expected: expectedRoot, actual: lifecycle.commandProjectRoot });
@@ -477,6 +474,17 @@ function recordZcodeLifecycle(context, input, invocationId) {
477
474
  const toolUseId = stringValue(hook.tool_use_id) ?? stringValue(hook.toolUseId) ?? stringValue(hook.event_id) ?? stringValue(hook.eventId);
478
475
  if (!toolUseId)
479
476
  throw new AppError("zcode_hook_event_missing", "ZCode native hook has no tool-use ID", 1);
477
+ if (lifecycle.analysis.kind === "compound") {
478
+ if (lifecycle.analysis.reason !== "shell_composition")
479
+ throw new AppError("compound_lifecycle_command", "Ambiguous shell cannot authorize a lifecycle replacement", 1, { reason: lifecycle.analysis.reason });
480
+ if (invocationId) {
481
+ const error = { code: "compound_lifecycle_command", message: "dd-flow lifecycle commands must be a standalone ZCode Bash tool call", exitCode: 1,
482
+ details: { standalone_command: lifecycle.invocation.command } };
483
+ settleLifecycleNoEffectRejection(context, invocationId, lifecycle.invocation.command, error, { daemonId, rootSessionId: rootProviderSessionId, sessionId: providerSessionId, parentSessionId: parentProviderSessionId ?? null, toolCallId: toolUseId });
484
+ return { ok: true, observed: false, invocation_id: invocationId, no_effect: true, error };
485
+ }
486
+ throw new AppError("compound_lifecycle_command", "dd-flow lifecycle commands must be a standalone ZCode Bash tool call", 1, { standalone_command: lifecycle.invocation.command });
487
+ }
480
488
  const eventKey = crypto.createHash("sha256").update(JSON.stringify(["zcode-acp", daemonId, providerSessionId, toolUseId])).digest("hex");
481
489
  const rewritten = invocationId ? command : commandWithHookEvent(command, eventKey);
482
490
  if (!invocationId && rewritten === command && commandOption(lifecycle.invocation, "hook-event-id") !== eventKey)
@@ -69,7 +69,7 @@ export function lifecycleRetryCommands(context, stage) {
69
69
  return [];
70
70
  const successor = load(context, commandOption(invocation(retry), "invocation-id") ?? "");
71
71
  const latest = context.db.get("SELECT * FROM lifecycle_invocations WHERE scope_json = ? AND fingerprint = ? ORDER BY rowid DESC LIMIT 1", [row.scope_json, row.fingerprint]);
72
- return successor.id === latest?.id && successor.status === "issued" ? [retry] : [];
72
+ return successor.id === latest?.id && successor.status === "issued" && (successor.deadline === null || successor.deadline > Date.now()) ? [retry] : [];
73
73
  }
74
74
  catch {
75
75
  return [];
@@ -109,6 +109,7 @@ export function managedLifecycleCommand(context, command) {
109
109
  throw new AppError("invocation_scope_mismatch", "Lifecycle attempt belongs to another managed scope", 1, { invocation_id: suppliedId });
110
110
  }
111
111
  checkCommand(retained, command);
112
+ assertRenderableInvocation(retained);
112
113
  return command;
113
114
  }
114
115
  if (!context.db.writable || context.env.DD_FLOW_INVOCATION_READONLY === "1") {
@@ -143,21 +144,67 @@ export function retryLifecycleInvocationCommand(context, id) {
143
144
  const prior = load(context, id);
144
145
  if (prior.status !== "executing")
145
146
  throw new AppError("invocation_retry_not_authorized", "Only the executing lifecycle operation can publish its next attempt", 1);
147
+ return successorLifecycleInvocationCommand(context, prior);
148
+ }
149
+ /** A predecessor may publish exactly one successor only while the caller has
150
+ * proved that no lifecycle effect was admitted. This is shared by ordinary
151
+ * command validation and the native pre-execution rejection path. */
152
+ function successorLifecycleInvocationCommand(context, prior) {
146
153
  const parsed = invocation(prior.command);
147
154
  const argv = [...parsed.argv];
148
155
  const at = argv.indexOf("--invocation-id");
149
- if (at < 0 || argv[at + 1] !== id)
156
+ if (at < 0 || argv[at + 1] !== prior.id)
150
157
  throw new AppError("invocation_command_invalid", "Retained issued command lost its invocation marker", 1);
151
158
  // The parser proves the marker belongs to argv. Remove that one rendered
152
159
  // token from the retained text so response-file/stdin/heredoc presentation
153
160
  // remains byte-for-byte stable across a retry.
154
- const marker = ` --invocation-id ${id}`;
161
+ const marker = ` --invocation-id ${prior.id}`;
155
162
  const markerAt = prior.command.indexOf(marker);
156
163
  if (markerAt < 0 || prior.command.indexOf(marker, markerAt + marker.length) >= 0)
157
164
  throw new AppError("invocation_command_invalid", "Retained issued command has an ambiguous invocation marker", 1);
158
165
  const command = `${prior.command.slice(0, markerAt)}${prior.command.slice(markerAt + marker.length)}`;
159
- return managedLifecycleCommand({ ...context, env: { ...context.env, DD_FLOW_INVOCATION_SCOPE: prior.scope_json, DD_FLOW_CURRENT_INVOCATION: id } }, command);
166
+ return managedLifecycleCommand({ ...context, env: { ...context.env, DD_FLOW_INVOCATION_SCOPE: prior.scope_json, DD_FLOW_CURRENT_INVOCATION: prior.id } }, command);
167
+ }
168
+ /** A native observer can reject a malformed tool call before the CLI claims
169
+ * execution. Persist that fact and its sole replacement atomically: waiting
170
+ * callers must never time out merely because the observer rejected syntax. */
171
+ export function settleLifecycleNoEffectRejection(context, id, command, error, identity) {
172
+ storage(context);
173
+ context.db.exec("BEGIN IMMEDIATE");
174
+ try {
175
+ const prior = load(context, id);
176
+ checkCommand(prior, command);
177
+ const scope = JSON.parse(prior.scope_json);
178
+ checkNativeIdentity(scope, identity, id);
179
+ if (prior.status === "settled") {
180
+ context.db.exec("COMMIT");
181
+ return;
182
+ }
183
+ if (scope.runId)
184
+ assertLifecycleInvocationCurrent(context, scope, command);
185
+ if (prior.status !== "issued")
186
+ throw new AppError("invocation_rejection_conflict", "A lifecycle rejection arrived after execution admission", 1, { invocation_id: id });
187
+ settleUnadmittedInvocation(context, prior, error);
188
+ context.db.exec("COMMIT");
189
+ }
190
+ catch (error) {
191
+ context.db.exec("ROLLBACK");
192
+ throw error;
193
+ }
160
194
  }
195
+ // Caller holds the write transaction and has checked authority. Issued (or
196
+ // legacy expired) attempts have never admitted an effect; observed/executing
197
+ // attempts must never receive an automatic replacement.
198
+ function settleUnadmittedInvocation(context, prior, error) {
199
+ if (!["issued", "expired"].includes(prior.status))
200
+ throw new AppError("invocation_rejection_conflict", "Attempt already admitted native execution", 1);
201
+ const details = { ...error.details, retry_command: successorLifecycleInvocationCommand(context, prior),
202
+ retry_instruction: "Use retry_command as one standalone lifecycle command. The rejected command did not change lifecycle state." };
203
+ if (context.db.run("UPDATE lifecycle_invocations SET status = 'settled', outcome_json = ?, updated_at = ? WHERE id = ? AND status = ?", [JSON.stringify({ error: { ...error, details } }), context.now(), prior.id, prior.status]).changes !== 1) {
204
+ throw new AppError("invocation_settlement_conflict", "Lifecycle rejection could not be committed", 1, { invocation_id: prior.id });
205
+ }
206
+ }
207
+ const receiptTimeout = (id) => ({ code: "invocation_receipt_timeout", message: "No native confirmation before the receipt deadline; lifecycle state was not changed", exitCode: 1, details: { invocation_id: id } });
161
208
  /** Settle a conclusive rejection and publish its only legal successor atomically.
162
209
  * A crash must leave either the old attempt executing, or both the settled
163
210
  * outcome and its successor; never a usable successor beside an unknown effect. */
@@ -291,7 +338,13 @@ function commandWithInvocationId(command, id) {
291
338
  /** The attempt owns authority; the caller owns presentation-only shell input.
292
339
  * Preserve current stdin/heredoc/response-file text while validating that it
293
340
  * still names the exact persisted operation and project. */
341
+ function assertRenderableInvocation(row) {
342
+ if (row.status === "expired" || row.status === "issued" && row.deadline !== null && row.deadline <= Date.now()) {
343
+ throw new AppError("invocation_expired", "Retained lifecycle attempt has expired; inspection cannot grant a new attempt", 1, { invocation_id: row.id, status: row.status, outcome: row.outcome_json ? JSON.parse(row.outcome_json) : null });
344
+ }
345
+ }
294
346
  function renderInvocationCommand(row, command) {
347
+ assertRenderableInvocation(row);
295
348
  const parsed = invocation(command);
296
349
  if (commandOption(parsed, "invocation-id"))
297
350
  throw new AppError("invocation_command_invalid", "Rendered lifecycle command already has an invocation id", 1);
@@ -301,6 +354,13 @@ function renderInvocationCommand(row, command) {
301
354
  }
302
355
  /** Native observer and receipt writer commit together. Never await I/O inside
303
356
  * this transaction; the callback must write to this same SQLite connection. */
357
+ function checkNativeIdentity(scope, identity, id) {
358
+ if (identity.daemonId !== scope.daemonId || identity.rootSessionId !== scope.rootSessionId
359
+ || !identity.sessionId || !identity.toolCallId
360
+ || (identity.sessionId === scope.rootSessionId ? identity.parentSessionId !== null : !identity.parentSessionId)) {
361
+ throw new AppError("invocation_identity_mismatch", "Native identity does not belong to the issued managed scope", 1, { invocation_id: id });
362
+ }
363
+ }
304
364
  export function observeLifecycleInvocation(context, input) {
305
365
  const initial = load(context, input.id);
306
366
  checkCommand(initial, input.command);
@@ -311,11 +371,7 @@ export function observeLifecycleInvocation(context, input) {
311
371
  return { eventKey: initial.event_key, duplicate: true };
312
372
  const scope = JSON.parse(initial.scope_json);
313
373
  const identity = input.identity;
314
- if (identity.daemonId !== scope.daemonId || identity.rootSessionId !== scope.rootSessionId
315
- || !identity.sessionId || !identity.toolCallId
316
- || (identity.sessionId === scope.rootSessionId ? identity.parentSessionId !== null : !identity.parentSessionId)) {
317
- throw new AppError("invocation_identity_mismatch", "Native identity does not belong to the issued managed scope", 1, { invocation_id: input.id });
318
- }
374
+ checkNativeIdentity(scope, identity, input.id);
319
375
  const identityJson = JSON.stringify([identity.daemonId, identity.rootSessionId, identity.sessionId, identity.parentSessionId, identity.toolCallId]);
320
376
  context.db.exec("BEGIN IMMEDIATE");
321
377
  try {
@@ -325,7 +381,11 @@ export function observeLifecycleInvocation(context, input) {
325
381
  return { eventKey: row.event_key, duplicate: true };
326
382
  }
327
383
  if (row.status === "expired" || (row.deadline !== null && row.deadline <= Date.now() && !row.event_key)) {
328
- throw new AppError("invocation_expired", "Late observation cannot revive an expired lifecycle attempt", 1, { invocation_id: input.id });
384
+ if (scope.runId)
385
+ assertLifecycleInvocationCurrent(context, scope, input.command);
386
+ settleUnadmittedInvocation(context, row, receiptTimeout(input.id));
387
+ context.db.exec("COMMIT");
388
+ return { eventKey: row.event_key, duplicate: true };
329
389
  }
330
390
  if (row.identity_json) {
331
391
  if (row.identity_json !== identityJson)
@@ -370,10 +430,20 @@ export async function awaitLifecycleInvocation(context, input) {
370
430
  continue;
371
431
  }
372
432
  if (row.status === "expired" || (row.deadline !== null && row.deadline <= Date.now())) {
373
- const expired = context.db.run("UPDATE lifecycle_invocations SET status = 'expired', updated_at = ? WHERE id = ? AND status = 'issued'", [context.now(), input.id]);
374
- if (!expired.changes && row.status !== "expired")
375
- continue;
376
- throw new AppError("invocation_receipt_timeout", "No native confirmation before the receipt deadline; lifecycle state was not changed", 1, { invocation_id: input.id });
433
+ context.db.exec("BEGIN IMMEDIATE");
434
+ try {
435
+ const current = load(context, input.id);
436
+ if (["issued", "expired"].includes(current.status)) {
437
+ input.assertCurrent(JSON.parse(current.scope_json));
438
+ settleUnadmittedInvocation(context, current, receiptTimeout(input.id));
439
+ }
440
+ context.db.exec("COMMIT");
441
+ }
442
+ catch (error) {
443
+ context.db.exec("ROLLBACK");
444
+ throw error;
445
+ }
446
+ continue;
377
447
  }
378
448
  await delay(Math.min(25, Math.max(1, (row.deadline ?? Date.now() + 25) - Date.now())));
379
449
  }
@@ -8,15 +8,17 @@ export async function cancelControlledNativeSession(input) {
8
8
  if (input.observe && (reply.operation_id !== input.operationId || reply.operation !== "session.cancel" || reply.session_id !== input.sessionId))
9
9
  throw new AppError("controller_operation_mismatch", "Cancellation observation belongs to another operation or Session", 1);
10
10
  const result = input.observe ? reply.state === "completed" ? reply.result : null : reply;
11
+ // A completed cancel command is an immutable ACK, not proof that the
12
+ // provider tree has stopped. Callers must use a separate fresh inspection.
11
13
  if (!input.observe || reply.state === "completed")
12
- assertNativeCancellationReceipt(result, input.sessionId, input.requireSettled);
14
+ assertNativeCancellationReceipt(result, input.sessionId);
13
15
  input.assertCurrent();
14
16
  return { status: result ? "completed" : "outcome_unknown", receipt: (result ?? reply) };
15
17
  }
16
- export function assertNativeCancellationReceipt(value, sessionId, requireSettled = false) {
18
+ export function assertNativeCancellationReceipt(value, sessionId) {
17
19
  const identity = adapterSessionId(value);
18
- if (!value || typeof value !== "object" || Array.isArray(value) || identity && identity !== sessionId || (requireSettled && value.settled !== true))
19
- throw new AppError("controller_operation_mismatch", requireSettled ? "Cancellation receipt has no terminal settlement for its target Session" : "Cancellation receipt has no valid result for its target Session", 1);
20
+ if (!value || typeof value !== "object" || Array.isArray(value) || identity && identity !== sessionId)
21
+ throw new AppError("controller_operation_mismatch", "Cancellation receipt has no valid result for its target Session", 1);
20
22
  }
21
23
  /** Native observation is shared by RUN and non-Work EVAL owners. */
22
24
  export async function inspectControlledNativeSession(input) {