@deksden-com/dd-flow-cli 0.9.0-beta.156 → 0.9.0-beta.158

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 (33) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/build-info.json +8 -8
  3. package/dist/cli/error-output.js +11 -1
  4. package/dist/cli/input-preparation.js +32 -4
  5. package/dist/cli/run-cli.js +24 -7
  6. package/dist/harness-runtime/lib/daemon-operations.mjs +46 -11
  7. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +19 -6
  8. package/dist/harness-runtime/lib/dd-agy.mjs +19 -0
  9. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +1 -1
  10. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +1 -1
  11. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +2 -2
  12. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +1 -1
  13. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +1 -1
  14. package/dist/harness-runtime/lib/delegation-instructions.d.mts +1 -0
  15. package/dist/harness-runtime/lib/delegation-instructions.mjs +3 -0
  16. package/dist/schemas/code-work-batch.schema.json +1 -1
  17. package/dist/schemas/vnext-protocol-plan.schema.json +1 -1
  18. package/dist/services/codex-capacity.js +367 -0
  19. package/dist/services/controller-child-repair.js +46 -24
  20. package/dist/services/controller-fanout.js +23 -15
  21. package/dist/services/controller-result-authority.js +13 -8
  22. package/dist/services/controller-work-failure.js +198 -0
  23. package/dist/services/lifecycle-invocations.js +81 -16
  24. package/dist/services/managed-daemon-binding.js +40 -0
  25. package/dist/services/run-control.js +18 -36
  26. package/dist/services/run-controller.js +107 -22
  27. package/dist/services/vnext-code.js +3 -1
  28. package/dist/services/vnext-plan-review.js +2 -2
  29. package/dist/services/vnext-plan.js +3 -3
  30. package/dist/services/work-registry.js +59 -12
  31. package/dist/services/work-retry-context.js +55 -0
  32. package/dist/verification-components.json +3 -0
  33. package/package.json +1 -1
@@ -0,0 +1,198 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { AppError } from "../shared/errors.js";
4
+ import { publishImmutableInput } from "../storage/immutable-input.js";
5
+ import { prepareAgentOutputFile } from "../storage/agent-output.js";
6
+ import { parseLifecycleCommand } from "./lifecycle-command.js";
7
+ import { stageAttempt, stageCycle, stageWorkIds } from "./stage-work-graph.js";
8
+ import { assertRunMutationAllowed } from "./run-recovery.js";
9
+ import { appendEvent, digest } from "./run-controller-state.js";
10
+ import { retryWork } from "./work-registry.js";
11
+ import { blockStageForRuntime } from "./stage-blocker.js";
12
+ import { pauseStageForUser } from "./stage-pause.js";
13
+ /** SQL settlement provenance, never the worker's prose, distinguishes a semantic refusal. */
14
+ export function explicitWorkFailure(context, projectId, runId, workId) {
15
+ const work = context.db.get("SELECT w.status,w.result,w.parent_work_id,w.payload_json,r.run_root FROM works w JOIN runs r ON r.project_id=w.project_id AND r.id=w.run_id WHERE w.project_id=? AND w.run_id=? AND w.work_id=?", [projectId, runId, workId]);
16
+ if (!work || work.status !== "failed" || !work.parent_work_id || work.result === null || !work.run_root)
17
+ return null;
18
+ const payload = JSON.parse(work.payload_json ?? "null");
19
+ if (typeof payload?.kind === "string" && /^(memory|interactive)/.test(payload.kind))
20
+ return null;
21
+ if (!context.db.get("SELECT 1 FROM sqlite_schema WHERE name='audit_events'") || !context.db.get("SELECT 1 FROM sqlite_schema WHERE name='lifecycle_invocations'"))
22
+ return null;
23
+ const resultHash = digestBytes(work.result);
24
+ const receipts = context.db.all(`SELECT a.payload_json,l.command,l.status AS invocation_status FROM audit_events a JOIN lifecycle_invocations l ON l.id=json_extract(a.payload_json,'$.invocation_id')
25
+ WHERE a.project_id=? AND a.event_type='lifecycle.work_settlement' AND json_extract(a.payload_json,'$.run_id')=? AND json_extract(a.payload_json,'$.work_id')=? ORDER BY a.rowid DESC`, [projectId, runId, workId]);
26
+ for (const row of receipts) {
27
+ const receipt = JSON.parse(row.payload_json), parsed = parseLifecycleCommand(row.command);
28
+ if (row.invocation_status !== "settled" || parsed.kind !== "standalone" || parsed.invocation.operation !== "work_fail" || receipt.result_sha256 !== resultHash || receipt.response?.status !== "failed")
29
+ continue;
30
+ if (!context.db.get("SELECT 1 FROM work_sessions WHERE id=? AND work_id=? AND status='failed'", [receipt.work_session_id, workId]))
31
+ continue;
32
+ return { work_id: workId, work_session_id: receipt.work_session_id, parent_work_id: work.parent_work_id, result_sha256: resultHash, primary_reason: work.result, result_ref: path.join(work.run_root, "works", workId, "result.json") };
33
+ }
34
+ return null;
35
+ }
36
+ import { createHash } from "node:crypto";
37
+ const digestBytes = (text) => createHash("sha256").update(text).digest("hex");
38
+ function events(context, row, type) {
39
+ return context.db.all(`SELECT e.data_json FROM run_controller_events e JOIN run_controllers c ON c.controller_id=e.controller_id WHERE c.project_id=? AND c.run_id=? AND e.event_type=? ORDER BY e.rowid`, [row.project_id, row.run_id, type]).map(e => JSON.parse(e.data_json));
40
+ }
41
+ export function prepareWorkFailureResolution(context, row, stage, failure) {
42
+ if (!stageWorkIds(context, row.project_id, row.run_id, stage).has(failure.work_id))
43
+ throw new AppError("work_failure_scope_changed", "Failed Work no longer belongs to this Stage", 1);
44
+ const current = explicitWorkFailure(context, row.project_id, row.run_id, failure.work_id);
45
+ if (!current || current.work_session_id !== failure.work_session_id || current.result_sha256 !== failure.result_sha256)
46
+ throw new AppError("work_failure_scope_changed", "Failed Work receipt changed before resolution", 1);
47
+ const attempt = stageAttempt(context, row.project_id, row.run_id, stage), cycle = stageCycle(context, row.project_id, row.run_id, stage);
48
+ const episode = digest([row.project_id, row.run_id, stage, attempt, cycle, failure.work_id, failure.work_session_id, failure.result_sha256]);
49
+ const run = context.db.get("SELECT run_root FROM runs WHERE project_id=? AND id=?", [row.project_id, row.run_id]);
50
+ const round = events(context, row, "work_failure_action_applied").filter(e => e.episode_id === episode).length;
51
+ const root = path.join(run.run_root, "work-failure-resolutions", episode, `round-${round}`);
52
+ const resolution = { ...failure, episode_id: episode, stage, attempt, cycle, decision_file: path.join(root, "decision-input.json"), accepted_file: path.join(root, "decision.json"), diagnostic_file: path.join(root, "failure.json"),
53
+ retry_used: events(context, row, "work_failure_action_reserved").some(e => e.action === "retry" && e.work_id === failure.work_id) };
54
+ fs.mkdirSync(root, { recursive: true });
55
+ publishImmutableInput(resolution.diagnostic_file, Buffer.from(JSON.stringify({ schema_id: "work-failure-context@1", ...failure, episode_id: episode, stage, attempt, cycle, packet: path.join(run.run_root, "works", failure.work_id, "starts") }, null, 2)), "work_failure_context_changed");
56
+ if (!fs.existsSync(resolution.decision_file)) {
57
+ prepareAgentOutputFile(resolution.decision_file);
58
+ fs.writeFileSync(resolution.decision_file, "", { flag: "wx", mode: 0o600 });
59
+ }
60
+ return resolution;
61
+ }
62
+ const text = (value) => typeof value === "string" && value.trim().length > 0;
63
+ export function readFailureDecision(resolution) {
64
+ const file = fs.existsSync(resolution.accepted_file) ? resolution.accepted_file : resolution.decision_file;
65
+ const stat = fs.lstatSync(file);
66
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024 * 1024)
67
+ throw new AppError("work_failure_decision_invalid", "Expected a regular decision JSON file", 2);
68
+ let value;
69
+ try {
70
+ value = JSON.parse(fs.readFileSync(file, "utf8"));
71
+ }
72
+ catch {
73
+ throw new AppError("work_failure_decision_invalid", "Write one complete decision JSON object", 2);
74
+ }
75
+ const keys = ["schema_id", "episode_id", "action", "reason", "evidence_refs", "correction", "blocker", "question"];
76
+ if (!value || Array.isArray(value) || typeof value !== "object" || Object.keys(value).some(k => !keys.includes(k)) || value.schema_id !== "work-failure-decision@1" || value.episode_id !== resolution.episode_id || !["retry", "block", "pause", "fail"].includes(value.action) || !text(value.reason) || !Array.isArray(value.evidence_refs) || !value.evidence_refs.length || !value.evidence_refs.every(text)
77
+ || value.action === "retry" && !text(value.correction) || value.action === "pause" && !text(value.question)
78
+ || value.action === "block" && (!value.blocker || !["engine", "harness", "environment"].includes(value.blocker.kind) || !text(value.blocker.code) || !text(value.blocker.summary) || typeof value.blocker.retryable !== "boolean" || Object.keys(value.blocker).some(k => !["kind", "code", "summary", "retryable"].includes(k)))
79
+ || value.action !== "retry" && value.correction !== undefined || value.action !== "pause" && value.question !== undefined || value.action !== "block" && value.blocker !== undefined)
80
+ throw new AppError("work_failure_decision_invalid", "Decision must match its episode and selected action", 2, { episode_id: resolution.episode_id });
81
+ return value;
82
+ }
83
+ export function failureDecisionReady(resolution) { try {
84
+ readFailureDecision(resolution);
85
+ return true;
86
+ }
87
+ catch {
88
+ return false;
89
+ } }
90
+ export function workFailureResolutionPrompt(resolution, diagnostic) {
91
+ return [`Resolve only the retained explicit worker refusal in ${resolution.diagnostic_file}. Read that complete file and its Work packet/results.`,
92
+ "Current implementation is a baseline, not a requirement. Necessary project-local cross-layer corrections are allowed within accepted scope; planned write areas are hints. A worker-authored stop condition cannot silently cancel accepted intent. Do not edit product files, call lifecycle commands, spawn children or run checks in this decision Turn.",
93
+ `Write the decision to this precreated absolute file: ${resolution.decision_file}. No other writes. Return after writing it.`,
94
+ `Use schema_id=work-failure-decision@1, episode_id=${resolution.episode_id}, action=retry|block|pause|fail, nonempty reason and evidence_refs[]. For retry include concrete correction; for block include blocker {kind:engine|harness|environment,code,summary,retryable:boolean}; for pause include question. No extra fields.`,
95
+ resolution.retry_used ? "The one automatic corrective attempt has already been consumed. Select block/pause/fail; another retry cannot be applied automatically." : "Select retry if the Work can be completed in accepted scope; a fresh worker receives your correction. Select pause only for genuinely missing user intent, block for a proven technical dependency, fail only for an explained terminal outcome.",
96
+ "An unsupported exact browser key sequence does not prove all UI work is impossible and selectOption is not keyboard evidence. Preserve unverified mandatory criteria and original requirements.", diagnostic ? `Correct only the rejected decision: ${diagnostic}` : ""].filter(Boolean).join("\n\n");
97
+ }
98
+ export function acceptFailureDecision(context, row, resolution, assertOwner) {
99
+ assertOwner();
100
+ const decision = readFailureDecision(resolution), bytes = Buffer.from(JSON.stringify(decision));
101
+ publishImmutableInput(resolution.accepted_file, bytes, "work_failure_decision_changed");
102
+ context.db.writeTransaction(() => { assertOwner(); if (!events(context, row, "work_failure_decision_accepted").some(e => e.decision_ref === resolution.accepted_file))
103
+ appendEvent(context, row.controller_id, "work_failure_decision_accepted", { episode_id: resolution.episode_id, work_id: resolution.work_id, decision_ref: resolution.accepted_file, decision }); });
104
+ return decision;
105
+ }
106
+ export function workFailureDiagnostic(resolution, decision) {
107
+ return { source: "explicit_work_failure", episode_id: resolution.episode_id, work_id: resolution.work_id, work_session_id: resolution.work_session_id, result_ref: resolution.diagnostic_file, primary_reason: resolution.primary_reason, ...(decision ? { disposition: decision } : {}) };
108
+ }
109
+ /** Repair file-before-event and mutation-before-ack crash windows before HITL delivery. */
110
+ export async function reconcilePendingWorkFailure(context, row, state, continuationKind, projectRoot, assertOwner, writerSettled) {
111
+ const resolution = state.work_failure;
112
+ if (!resolution || resolution.deferred || !fs.existsSync(resolution.accepted_file) || continuationKind === "terminal")
113
+ return "none";
114
+ const session = state.current_session === null ? undefined : state.sessions[state.current_session];
115
+ if (!session)
116
+ throw new AppError("work_failure_scope_changed", "Failure resolution lost its coordinator", 1);
117
+ if (!await writerSettled(session))
118
+ return "waiting";
119
+ const decision = acceptFailureDecision(context, row, resolution, assertOwner);
120
+ applyWorkFailureDecision(context, row, resolution, decision, projectRoot, assertOwner);
121
+ delete state.work_failure;
122
+ state.last_continuation = null;
123
+ return "applied";
124
+ }
125
+ /** Reserve first; a committed retry is recovered through its start authority, never replayed blindly. */
126
+ export function applyWorkFailureDecision(context, row, resolution, decision, projectRoot, assertOwner) {
127
+ assertOwner();
128
+ assertRunMutationAllowed(context, row.project_id, row.run_id, row.generation);
129
+ const index = JSON.parse(context.db.get("SELECT index_json FROM runs WHERE project_id=? AND id=?", [row.project_id, row.run_id])?.index_json ?? "{}");
130
+ if (resolution.stage !== index.current_stage
131
+ || resolution.attempt !== stageAttempt(context, row.project_id, row.run_id, resolution.stage) || resolution.cycle !== stageCycle(context, row.project_id, row.run_id, resolution.stage)
132
+ || !stageWorkIds(context, row.project_id, row.run_id, resolution.stage).has(resolution.work_id))
133
+ throw new AppError("work_failure_scope_changed", "Failure resolution belongs to a stale Stage attempt", 1);
134
+ const applied = events(context, row, "work_failure_action_applied").find(e => e.decision_ref === resolution.accepted_file);
135
+ if (applied)
136
+ return applied;
137
+ const reserved = events(context, row, "work_failure_action_reserved").find(e => e.decision_ref === resolution.accepted_file);
138
+ const current = context.db.get("SELECT status,current_start_invocation_id FROM works WHERE project_id=? AND run_id=? AND work_id=?", [row.project_id, row.run_id, resolution.work_id]);
139
+ if (!current)
140
+ throw new AppError("work_failure_scope_changed", "Work disappeared before resolution", 1);
141
+ let action = decision.action;
142
+ const used = events(context, row, "work_failure_action_reserved").some(e => e.action === "retry" && e.work_id === resolution.work_id && e.decision_ref !== resolution.accepted_file);
143
+ const retainedDecision = events(context, row, "work_failure_decision_accepted").find(e => e.decision_ref === resolution.accepted_file);
144
+ if (!retainedDecision || JSON.stringify(retainedDecision.decision) !== JSON.stringify(decision))
145
+ throw new AppError("work_failure_scope_changed", "Failure decision was not accepted or changed after acceptance", 1);
146
+ const exhausted = action === "retry" && used;
147
+ if (exhausted)
148
+ action = "block";
149
+ if (!reserved) {
150
+ if (current.status !== "failed" || explicitWorkFailure(context, row.project_id, row.run_id, resolution.work_id)?.work_session_id !== resolution.work_session_id)
151
+ throw new AppError("work_failure_scope_changed", "Failure changed before action reservation", 1);
152
+ context.db.writeTransaction(() => { assertOwner(); appendEvent(context, row.controller_id, "work_failure_action_reserved", { episode_id: resolution.episode_id, work_id: resolution.work_id, work_session_id: resolution.work_session_id, action, decision_ref: resolution.accepted_file }); });
153
+ }
154
+ else if (reserved.action !== action)
155
+ throw new AppError("work_failure_scope_changed", "Reserved failure action differs", 1);
156
+ let receipt;
157
+ if (action === "retry") {
158
+ receipt = retryWork(context, resolution.work_id, decision.reason, { episode_id: resolution.episode_id, decision_ref: resolution.accepted_file, correction: decision.correction, failed_result_sha256: resolution.result_sha256 });
159
+ }
160
+ else if (action === "fail")
161
+ throw new AppError("work_failure_unresolved", decision.reason, 1, workFailureDiagnostic(resolution, decision));
162
+ else {
163
+ const coordinator = context.db.get("SELECT status FROM works WHERE project_id=? AND run_id=? AND work_id=?", [row.project_id, row.run_id, resolution.parent_work_id]);
164
+ const blocker = exhausted ? { kind: "engine", code: "work_resolution_exhausted", summary: JSON.stringify(workFailureDiagnostic(resolution, decision)), retryable: false } : decision.blocker;
165
+ const boundBlocker = blocker ? { ...blocker, summary: `${blocker.summary}\n[work-failure:${resolution.episode_id}:${digest(resolution.accepted_file)}]` } : undefined;
166
+ if (coordinator?.status === "paused" && reserved) {
167
+ const stage = index.stage_runs?.find((s) => s.stage === resolution.stage);
168
+ const matches = action === "pause" ? stage?.pause?.work_id === resolution.parent_work_id && typeof stage.pause.question_path === "string" && fs.readFileSync(stage.pause.question_path, "utf8") === decision.question
169
+ : stage?.blocker?.work_id === resolution.parent_work_id && stage.blocker.code === boundBlocker?.code && stage.blocker.kind === boundBlocker?.kind && stage.blocker.summary === boundBlocker?.summary;
170
+ if (!matches)
171
+ throw new AppError("work_failure_scope_changed", "Coordinator pause belongs to another failure action", 1);
172
+ receipt = { ok: true, reconciled: true, outcome: action === "pause" ? "paused" : "blocked", run_id: row.run_id, stage: resolution.stage, work_id: resolution.parent_work_id, ...(boundBlocker ? { blocker: boundBlocker } : {}) };
173
+ }
174
+ else {
175
+ if (coordinator?.status !== "running")
176
+ throw new AppError("work_failure_scope_changed", "Failure resolution needs the running coordinator Work", 1);
177
+ const scope = { projectRoot, runId: row.run_id, stage: resolution.stage, workId: resolution.parent_work_id };
178
+ receipt = action === "pause" ? pauseStageForUser(context, { ...scope, question: decision.question }) : blockStageForRuntime(context, { ...scope, ...boundBlocker });
179
+ }
180
+ }
181
+ const outcome = { episode_id: resolution.episode_id, decision_ref: resolution.accepted_file, stage: resolution.stage, attempt: resolution.attempt, cycle: resolution.cycle, work_id: resolution.work_id, work_session_id: resolution.work_session_id, action, receipt };
182
+ context.db.writeTransaction(() => { assertOwner(); appendEvent(context, row.controller_id, "work_failure_action_applied", outcome); });
183
+ return outcome;
184
+ }
185
+ /** Match only this current bound block; an old unrelated pause must not be hidden. */
186
+ export function workFailureBlockActive(context, row, index) {
187
+ const stage = index.current_stage;
188
+ const blocker = index.stage_runs?.find(s => s.stage === stage)?.blocker;
189
+ if (!stage || !blocker)
190
+ return false;
191
+ return events(context, row, "work_failure_action_applied").some(e => {
192
+ const receipt = e.receipt;
193
+ if (!receipt)
194
+ return false;
195
+ return e.action === "block" && e.stage === stage && e.attempt === stageAttempt(context, row.project_id, row.run_id, stage) && e.cycle === stageCycle(context, row.project_id, row.run_id, stage)
196
+ && receipt?.work_id === blocker.work_id && receipt.blocker?.summary === blocker.summary && receipt.blocker?.code === blocker.code && receipt.blocker?.kind === blocker.kind;
197
+ });
198
+ }
@@ -600,6 +600,9 @@ export function observeLifecycleCommand(context, command, nativeEvent) {
600
600
  }
601
601
  if (error && parsed.kind === "standalone" && hookOnlyCorrection && error.details.recoverable !== false) {
602
602
  const signature = preparationRejectionSignature(new AppError(error.code, error.message, 1, error.details));
603
+ const inputKey = preparationInputKey(parsed.invocation, error.details);
604
+ if (inputKey)
605
+ error.details.input_key = inputKey;
603
606
  error.details.hook_retry_signature = signature;
604
607
  const repeated = context.db.get(`SELECT 1 FROM hook_events WHERE project_id = ? AND id <> ?
605
608
  AND json_extract(outcome_json, '$.run_id') = ? AND json_extract(outcome_json, '$.generation') = ?
@@ -607,8 +610,9 @@ export function observeLifecycleCommand(context, command, nativeEvent) {
607
610
  AND json_extract(outcome_json, '$.harness') = ? AND json_extract(outcome_json, '$.session_id') = ?
608
611
  AND json_type(outcome_json, '$.stage') IS NOT NULL AND json_type(outcome_json, '$.attempt') IS NOT NULL
609
612
  AND json_extract(outcome_json, '$.stage') IS ? AND json_extract(outcome_json, '$.attempt') IS ?
610
- 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, correlationKey,
611
- anchor.harness, anchor.session_id, anchor.stage ?? null, anchor.attempt ?? null, signature]);
613
+ AND json_extract(outcome_json, '$.error.details.hook_retry_signature') = ?
614
+ AND (? IS NULL OR json_extract(outcome_json, '$.error.details.input_key') = ?) LIMIT 1`, [anchor.project_id, anchor.hook_event_id, anchor.run_id, anchor.generation, anchor.operation, correlationKey,
615
+ anchor.harness, anchor.session_id, anchor.stage ?? null, anchor.attempt ?? null, signature, inputKey ?? null, inputKey ?? null]);
612
616
  if (!repeated) {
613
617
  const argv = parsed.invocation.argv.filter((value, index, all) => !/^--(?:hook-event-id|invocation-id)(?:=|$)/.test(value)
614
618
  && !/^--(?:hook-event-id|invocation-id)$/.test(all[index - 1] ?? ""));
@@ -619,8 +623,15 @@ export function observeLifecycleCommand(context, command, nativeEvent) {
619
623
  }
620
624
  else {
621
625
  error.details.recoverable = false;
622
- error.details.repeated_without_progress = true;
623
- record.disposition = "fatal";
626
+ delete error.details.retry_command;
627
+ delete error.details.retry_instruction;
628
+ if (inputKey)
629
+ error.details.repeated_without_progress = true;
630
+ else
631
+ error.details.input_binding_unavailable = true;
632
+ // An unread file is not evidence of unchanged contents. Withhold
633
+ // automatic correction locally; do not turn that ignorance into fatal.
634
+ record.disposition = inputKey ? "fatal" : "pending_continuation";
624
635
  }
625
636
  }
626
637
  if (error && !auxiliary && record.disposition === "fatal") {
@@ -1988,6 +1999,8 @@ export function resolveObservedLifecycleArgs(context, id, actualArgs) {
1988
1999
  const inline = argv.findIndex(value => value.startsWith(`--${publicKey}=`));
1989
2000
  if (inline >= 0)
1990
2001
  argv[inline] = `--${publicKey}=${commandOption(actual, publicKey)}`;
2002
+ else
2003
+ argv.push(`--${publicKey}`, commandOption(actual, publicKey));
1991
2004
  }
1992
2005
  }
1993
2006
  return argv;
@@ -1998,24 +2011,47 @@ export function retainObservedLifecycleSemantics(context, id, argv) {
1998
2011
  const row = context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [id]);
1999
2012
  if (!row || row.status !== "executing")
2000
2013
  throw new AppError("invocation_settlement_conflict", "Lifecycle attempt is not executing", 1, { invocation_id: id });
2014
+ const command = commandWithObservedWorkSemantics(row, argv);
2015
+ if (command === row.command)
2016
+ return;
2017
+ if (context.db.run("UPDATE lifecycle_invocations SET command = ?, updated_at = ? WHERE id = ? AND status = 'executing'", [command, context.now(), id]).changes !== 1) {
2018
+ throw new AppError("invocation_settlement_conflict", "Lifecycle attempt changed before its public input was retained", 1, { invocation_id: id });
2019
+ }
2020
+ }
2021
+ function commandWithObservedWorkSemantics(row, argv) {
2001
2022
  const parsed = invocation(row.command);
2002
2023
  if (!["work_fail", "work_finish"].includes(parsed.operation))
2003
- return;
2024
+ return row.command;
2004
2025
  const prefix = Object.entries(parsed.env).map(([key, value]) => `${key}=${quote([value])}`).join(" ");
2005
2026
  const executable = launchExpression(parsed);
2006
2027
  const suffix = row.command.slice(shellSuffixOffset(row.command)).trimStart();
2007
- const command = `${prefix ? `${prefix} ` : ""}${executable} ${quote(argv)}${suffix ? ` ${suffix}` : ""}`;
2028
+ // CLI dispatch removes presentation options. Update only the public semantic
2029
+ // value, retaining the issued transport, response destination and shell suffix.
2030
+ const actual = invocation(`${executable} ${quote(argv)}`);
2031
+ const semanticKey = parsed.operation === "work_fail" ? "reason" : "result-file";
2032
+ const retainedArgs = [...parsed.argv];
2033
+ const value = commandOption(actual, semanticKey);
2034
+ if (value !== undefined) {
2035
+ const index = retainedArgs.findIndex(arg => arg === `--${semanticKey}` || arg.startsWith(`--${semanticKey}=`));
2036
+ if (index >= 0) {
2037
+ if (retainedArgs[index] === `--${semanticKey}`)
2038
+ retainedArgs[index + 1] = value;
2039
+ else
2040
+ retainedArgs[index] = `--${semanticKey}=${value}`;
2041
+ }
2042
+ else
2043
+ retainedArgs.push(`--${semanticKey}`, value);
2044
+ }
2045
+ const command = `${prefix ? `${prefix} ` : ""}${executable} ${quote(retainedArgs)}${suffix ? ` ${suffix}` : ""}`;
2008
2046
  checkCommand(row, command);
2009
- if (context.db.run("UPDATE lifecycle_invocations SET command = ?, updated_at = ? WHERE id = ? AND status = 'executing'", [command, context.now(), id]).changes !== 1) {
2010
- throw new AppError("invocation_settlement_conflict", "Lifecycle attempt changed before its public input was retained", 1, { invocation_id: id });
2011
- }
2047
+ return command;
2012
2048
  }
2013
- export function settleLifecyclePreparationRejection(context, id, error) {
2049
+ export function settleLifecyclePreparationRejection(context, id, error, preparedCommand) {
2014
2050
  delete error.details.retry_command;
2015
2051
  delete error.details.retry_instruction;
2016
2052
  try {
2017
2053
  return context.db.writeTransaction(() => {
2018
- const row = load(context, id);
2054
+ let row = load(context, id);
2019
2055
  if (row.status === "settled") {
2020
2056
  const details = row.outcome_json ? JSON.parse(row.outcome_json)?.error?.details : undefined;
2021
2057
  return details?.effect === "no_effect" && details.retry_command ? details : { effect: "unknown", recoverable: false };
@@ -2025,22 +2061,41 @@ export function settleLifecyclePreparationRejection(context, id, error) {
2025
2061
  const scope = JSON.parse(row.scope_json);
2026
2062
  if (scope.runId)
2027
2063
  assertLifecycleInvocationCurrent(context, scope, row.command);
2064
+ if (preparedCommand) {
2065
+ // Only a command already validated before preparation may retain public
2066
+ // Work inputs. This records a no-effect refusal, not admission to execute.
2067
+ checkCommand(row, preparedCommand);
2068
+ const command = commandWithObservedWorkSemantics(row, invocation(preparedCommand).argv);
2069
+ if (command !== row.command)
2070
+ context.db.run("UPDATE lifecycle_invocations SET command = ?, updated_at = ? WHERE id = ?", [command, context.now(), row.id]);
2071
+ row = { ...row, command };
2072
+ }
2073
+ const parsed = invocation(row.command);
2074
+ if (parsed.operation === "work_finish" && !error.details.work_id) {
2075
+ try {
2076
+ error.details.work_id = resolveWorkReference(context, commandPosition(parsed, 0) ?? "", { ...(scope.runId ? { runId: scope.runId } : {}) }).work_id;
2077
+ }
2078
+ catch { /* An unresolved target remains its original local preparation error. */ }
2079
+ }
2028
2080
  const signature = preparationRejectionSignature(error);
2081
+ const inputKey = preparationInputKey(parsed, error.details);
2029
2082
  const repeated = context.db.all("SELECT outcome_json FROM lifecycle_invocations WHERE scope_json = ? AND fingerprint = ? AND id <> ? AND status = 'settled'", [row.scope_json, row.fingerprint, row.id])
2030
2083
  .some(prior => { try {
2031
- return JSON.parse(prior.outcome_json ?? "null")?.error?.details?.rejection_signature === signature;
2084
+ const details = JSON.parse(prior.outcome_json ?? "null")?.error?.details;
2085
+ return details?.rejection_signature === signature && (!inputKey || details.input_key === inputKey);
2032
2086
  }
2033
2087
  catch {
2034
2088
  return false;
2035
2089
  } });
2036
2090
  const retryable = !repeated && isRetryableLifecycleRejection(error.code, error.details);
2037
- const details = { ...error.details, effect: "no_effect", recoverable: retryable, rejection_signature: signature,
2091
+ const details = { ...error.details, effect: "no_effect", recoverable: retryable, rejection_signature: signature, ...(inputKey ? { input_key: inputKey } : {}),
2038
2092
  ...(repeated ? { repeated_without_progress: true } : {}),
2039
2093
  ...(retryable ? {
2040
2094
  retry_command: successorLifecycleInvocationCommand(context, row),
2041
2095
  retry_instruction: "Correct the reported input, then execute retry_command verbatim in this same Session. The rejected call changed no lifecycle state."
2042
2096
  } : {}) };
2043
- context.db.run("UPDATE lifecycle_invocations SET status = 'settled', outcome_json = ?, updated_at = ? WHERE id = ?", [JSON.stringify({ error: { ...errorRecord(error), details } }), context.now(), row.id]);
2097
+ const successor = typeof details.retry_command === "string" ? currentIssuedCommand(context, row.scope_json, details.retry_command, row.id) : null;
2098
+ context.db.run("UPDATE lifecycle_invocations SET status = 'settled', outcome_json = ?, updated_at = ? WHERE id = ?", [JSON.stringify({ error: { ...errorRecord(error), details }, ...(successor ? { successor_invocation_id: successor.id } : {}) }), context.now(), row.id]);
2044
2099
  return details;
2045
2100
  });
2046
2101
  }
@@ -2050,7 +2105,7 @@ export function settleLifecyclePreparationRejection(context, id, error) {
2050
2105
  }
2051
2106
  }
2052
2107
  function preparationRejectionSignature(error) {
2053
- const omitted = new Set(["retry_command", "retry_instruction", "invocation_id", "hook_event_id", "rejection_signature", "hook_retry_signature", "event_key", "tool_call_id", "controller_turn", "timestamp", "created_at", "updated_at", "repeated_without_progress", "recoverable"]);
2108
+ const omitted = new Set(["retry_command", "retry_instruction", "invocation_id", "hook_event_id", "rejection_signature", "hook_retry_signature", "event_key", "tool_call_id", "controller_turn", "timestamp", "created_at", "updated_at", "repeated_without_progress", "recoverable", "input_key", "input_sha256", "consumed_input_sha256"]);
2054
2109
  const normalize = (value) => {
2055
2110
  if (Array.isArray(value))
2056
2111
  return value.map(normalize);
@@ -2060,6 +2115,16 @@ function preparationRejectionSignature(error) {
2060
2115
  };
2061
2116
  return crypto.createHash("sha256").update(JSON.stringify([error.code, normalize(error.details)])).digest("hex");
2062
2117
  }
2118
+ function preparationInputKey(parsed, details) {
2119
+ const publicParsed = invocation(`${launchExpression(parsed)} ${quoteModelFacingArgs(publicInvocationArgs(parsed))}`);
2120
+ const options = [...publicParsed.args.options].filter(([key]) => !["invocation-id", "hook-event-id", "json", "progress-jsonl", "response-file"].includes(key)).sort(([a], [b]) => a.localeCompare(b));
2121
+ const digest = details.consumed_input_sha256 ?? details.input_sha256;
2122
+ const consumed = typeof digest === "string" && /^[a-f0-9]{64}$/.test(digest) ? digest : undefined;
2123
+ // No file read in this layer: an unread body cannot prove unchanged input.
2124
+ if (!consumed && options.some(([key]) => /(?:-file|-stdin)$/.test(key) || key === "data"))
2125
+ return undefined;
2126
+ return crypto.createHash("sha256").update(JSON.stringify([parsed.operation, publicParsed.args.positional, options, consumed ?? null])).digest("hex");
2127
+ }
2063
2128
  export function currentLifecycleFailure(context, input) {
2064
2129
  if (input.turn !== undefined) {
2065
2130
  const hooks = context.db.all(`SELECT id, outcome_json FROM hook_events
@@ -2184,7 +2249,7 @@ export async function awaitLifecycleInvocation(context, input) {
2184
2249
  ? error.details.recoverable !== false
2185
2250
  : isRetryableLifecycleRejection(error.code, details);
2186
2251
  Object.assign(error.details, { phase: "prepare", effect: "no_effect", recoverable });
2187
- Object.assign(error.details, settleLifecyclePreparationRejection(context, input.id, error));
2252
+ Object.assign(error.details, settleLifecyclePreparationRejection(context, input.id, error, input.command));
2188
2253
  throw error;
2189
2254
  }
2190
2255
  }
@@ -2,6 +2,21 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { AppError } from "../shared/errors.js";
4
4
  import { managedProcessRuntimeHome, managedProcessStatus, processTreeIsAlive } from "./managed-processes.js";
5
+ function retainedResourceStopped(resource, daemonId) {
6
+ if (resource.state !== "stopped" || resource.observation_conflict)
7
+ return false;
8
+ if (resource.native_retirement?.method)
9
+ return true;
10
+ // Completed native tasks retain an ordered terminal envelope, not a later
11
+ // stop-RPC receipt. Requiring that RPC after provider exit loses valid proof.
12
+ const source = resource.native_source, terminal = resource.terminal_source;
13
+ return Boolean(daemonId && resource.owner_daemon_id === daemonId
14
+ && resource.provider_session_id && resource.resource_id?.startsWith(`${resource.provider_session_id}/task-`)
15
+ && source?.locator && source.locator === terminal?.locator
16
+ && /^[a-f0-9]{64}$/.test(source.sha256 ?? "") && /^[a-f0-9]{64}$/.test(terminal?.sha256 ?? "")
17
+ && Number.isSafeInteger(source.step_index) && source.step_index >= 0
18
+ && Number.isSafeInteger(terminal?.step_index) && terminal.step_index > source.step_index);
19
+ }
5
20
  /** Native execution and AGY's potentially scheduled writers are separate facts. */
6
21
  export function managedExecutionReusable(state, harness) {
7
22
  const shutdown = state.shutdown, result = shutdown?.result;
@@ -85,5 +100,30 @@ export function managedDaemonExecutionAbsent(context, input) {
85
100
  if (!provider || !managedProviderBelongsToDaemon(provider, binding.process, binding.state))
86
101
  return false;
87
102
  }
103
+ // The registry leaders are not the entire native inventory: retain detached
104
+ // tools and schedules even after their provider endpoint disappears.
105
+ if (binding.state.native_topology_error || binding.state.native_retirement_notifications?.some(notice => notice.disposition !== "after_owned_native_stop" || notice.owner_daemon_id !== (binding.state.daemon_id ?? binding.state.config?.daemonId)
106
+ || !Number.isSafeInteger(notice.generation) || notice.revision !== binding.state.native_execution_revision))
107
+ return false;
108
+ const nativeResources = [...(binding.state.native_resources_entries ?? []), ...(binding.state.shutdown?.result?.native_resources?.entries ?? [])];
109
+ const retained = [...(binding.state.owned_processes ?? []), ...(binding.state.shutdown?.owned_processes ?? []),
110
+ ...nativeResources.filter(resource => resource.pid !== undefined || !retainedResourceStopped(resource, binding.state.daemon_id ?? binding.state.config?.daemonId))];
111
+ if (nativeResources.some(resource => resource.observation_conflict || resource.owner_daemon_id !== undefined
112
+ && resource.owner_daemon_id !== (binding.state.daemon_id ?? binding.state.config?.daemonId)))
113
+ return false;
114
+ for (const process of retained) {
115
+ if (!Number.isSafeInteger(process.pid) || process.pid <= 0 || typeof process.started !== "string" || !process.started.trim())
116
+ return false;
117
+ if (records.some(record => record.pid === process.pid && record.pid_started_at === process.started))
118
+ continue;
119
+ try {
120
+ globalThis.process.kill(process.pid, 0);
121
+ return false;
122
+ }
123
+ catch (error) {
124
+ if (error.code !== "ESRCH")
125
+ return false;
126
+ }
127
+ }
88
128
  return true;
89
129
  }
@@ -302,16 +302,19 @@ async function reconcileOwnedSessions(context, input, interrupt, boundary) {
302
302
  // third-party session/card never acknowledged completion.
303
303
  const physicalContext = { ...context, env: { ...context.env, DD_FLOW_RESOURCE_HOME: registeredProcesses.find(item => records.includes(item.record))?.home ?? resourceHome(context) } };
304
304
  let executionAbsent = false;
305
- if ((boundary || technicalPause) && !interrupt && !conflicts.has(directory) && !unsafeDaemons.has(canonicalPath(directory, false))) {
305
+ let absenceVersion;
306
+ if ((boundary || technicalPause || control?.action === "stop") && !interrupt && !conflicts.has(directory) && !unsafeDaemons.has(canonicalPath(directory, false))) {
306
307
  try {
307
- executionAbsent = managedDaemonExecutionAbsent(physicalContext, { stateDir: directory, projectId: project.id, runId: input.runId });
308
+ absenceVersion = nativeInventoryVersion(directory);
309
+ executionAbsent = managedDaemonExecutionAbsent(physicalContext, { stateDir: directory, projectId: project.id, runId: input.runId })
310
+ && absenceVersion === nativeInventoryVersion(directory);
308
311
  }
309
312
  catch { /* Unknown physical ownership remains on strict reconciliation. */ }
310
313
  }
311
314
  if (executionAbsent) {
312
315
  const leader = records.find(record => record.kind.endsWith("-daemon") && record.id === state.resource_process?.id && record.pid === state.pid && record.lease_token === state.resource_process.lease_token);
313
316
  if (leader && (state.daemon_id ?? state.config?.daemonId)) {
314
- nativeVersions.set(directory, nativeInventoryVersion(directory));
317
+ nativeVersions.set(directory, absenceVersion);
315
318
  closedDaemons.set(directory, { source: "owned_execution_absent", daemon_id: state.daemon_id ?? state.config?.daemonId,
316
319
  session_ids: state.sessions.map(item => typeof item === "string" ? item : adapterSessionId(item)), execution_settled: true,
317
320
  native_cleanup_confirmed: state.shutdown_state === "clean", processes: records.map(record => managedProcessBinding(record, registeredProcesses.find(item => item.record === record).home)) });
@@ -320,34 +323,6 @@ async function reconcileOwnedSessions(context, input, interrupt, boundary) {
320
323
  continue;
321
324
  }
322
325
  }
323
- // Explicit stop can reconcile a retired owner whose last bookkeeping ACK
324
- // failed. This is physical retirement, not a fabricated clean shutdown.
325
- if (control?.action === "stop" && state.shutdown_state === "cleanup_failed")
326
- try {
327
- const daemonId = state.daemon_id ?? state.config?.daemonId;
328
- const daemon = records.filter(record => record.kind.endsWith("-daemon"));
329
- const provider = records.filter(record => record.kind.endsWith("-provider"));
330
- const operations = retainedDaemonOperations(directory);
331
- const owner = state.config?.runtime_owner;
332
- if (daemonId && !conflicts.has(directory) && !unsafeDaemons.has(canonicalPath(directory, false))
333
- && state.active_tree === false && !state.active_operation && state.shutdown?.daemon_id === daemonId
334
- && state.shutdown.phases?.tree === true && state.shutdown.phases.provider === true
335
- && daemon.length === 1 && provider.length === 1 && records.length === 2
336
- && daemon[0].id === state.resource_process?.id && daemon[0].lease_token === state.resource_process.lease_token && daemon[0].pid === state.pid
337
- && provider[0].id === state.provider_process?.id && provider[0].lease_token === state.provider_process.lease_token && provider[0].pid === state.provider_pid
338
- && JSON.parse(daemon[0].metadata_json).dispatch_barrier === "run-generation@1"
339
- && records.every(record => record.pid && record.pid_started_at && ["stopped", "failed"].includes(record.state) && record.finished_at && !processTreeIsAlive(record))
340
- && operations.length > 0 && operations.every(operation => ["completed", "failed", "interrupted"].includes(String(operation.state))
341
- && (operation.operation === "daemon.start" ? operation.daemon_id === null && operation.launch?.runtime_owner === JSON.stringify(owner)
342
- : operation.daemon_id === daemonId && ["daemon.stop", "daemon.status", "session.create", "session.prompt", "session.start", "session.fork", "session.resume", "session.cancel", "session.inspect"].includes(String(operation.operation))
343
- && (operation.session_id == null || state.sessions.some(item => (typeof item === "string" ? item : adapterSessionId(item)) === operation.session_id))))) {
344
- nativeVersions.set(directory, nativeInventoryVersion(directory, operations));
345
- closedDaemons.set(directory, { source: "observed_owned_retirement", daemon_id: daemonId, stopped: true, settled: true,
346
- session_ids: state.sessions.map(item => typeof item === "string" ? item : adapterSessionId(item)),
347
- processes: records.map(record => ({ process_id: record.id, pid: record.pid, pid_started_at: record.pid_started_at })) });
348
- }
349
- }
350
- catch { /* Invalid or unfinished evidence remains on the existing recovery path. */ }
351
326
  if (!interrupt) {
352
327
  try {
353
328
  const nativeOperations = retainedDaemonOperations(directory);
@@ -477,10 +452,17 @@ async function reconcileOwnedSessions(context, input, interrupt, boundary) {
477
452
  if (!Array.isArray(args) || args.some(value => typeof value !== "string") || !saved.state_dir || !saved.executable || saved.owner !== operation.controller_id || conflicts.has(saved.state_dir) || directoryAdapters.get(saved.state_dir) !== saved.executable)
478
453
  throw new AppError("controller_operation_binding_unknown", "Unfinished controller operation has no matching frozen runtime binding", 1);
479
454
  const method = `${args[0]}.${args[1] === "status" ? "inspect" : args[1]}`;
480
- if ((boundary || technicalPause) && closedDaemons.get(saved.state_dir)?.source === "owned_execution_absent"
481
- && (["daemon.stop", "session.cancel", "session.inspect"].includes(method)
455
+ if (control?.action === "stop" && closedDaemons.get(saved.state_dir)?.source === "owned_execution_absent"
456
+ && (!["session.create", "session.prompt", "session.start", "session.fork", "session.cancel", "session.resume", "session.inspect", "daemon.start", "daemon.stop"].includes(method)
457
+ || !args.includes("--state-dir") || args[args.indexOf("--state-dir") + 1] !== saved.state_dir
458
+ || args.includes("--session-id") && !targets.has(JSON.stringify([saved.state_dir, args[args.indexOf("--session-id") + 1]]))))
459
+ throw new AppError("controller_operation_binding_unknown", "Fenced operation differs from its retained runtime scope", 1);
460
+ if ((boundary || technicalPause || control?.action === "stop") && closedDaemons.get(saved.state_dir)?.source === "owned_execution_absent"
461
+ && (control?.action === "stop" || ["daemon.stop", "session.cancel", "session.inspect"].includes(method)
482
462
  || saved.obligation && controllerObligationAccepted(context, requireController(context, operation.controller_id), saved.obligation))) {
483
- return { operation_id: operation.operation_id, status: "completed", receipt: { source: "accepted_boundary_execution_absent", native_outcome: "unavailable", cleanup_pending: true } };
463
+ // This completes the settlement observation, not the original unknown
464
+ // operation. Its retained row/effect remains unknown and is not replayed.
465
+ return { operation_id: operation.operation_id, status: "completed", receipt: { source: control?.action === "stop" ? "fenced_stop_execution_absent" : "accepted_boundary_execution_absent", native_outcome: "unavailable", original_operation_status: operation.status, semantic_outcome_accepted: false, cleanup_pending: true } };
484
466
  }
485
467
  if (!["session.create", "session.prompt", "session.start", "session.fork", "session.cancel", "session.resume", "session.inspect", "daemon.start", "daemon.stop"].includes(method))
486
468
  throw new AppError("controller_operation_receipt_required", "Unfinished controller operation needs its original receipt", 1);
@@ -733,7 +715,7 @@ async function reconcileOwnedSessions(context, input, interrupt, boundary) {
733
715
  // remaining idempotent resource finish. Unknown outcomes keep their ID.
734
716
  if (sessionsSettled) {
735
717
  const remainingFinish = state.shutdown_state === "cleanup_failed" && state.active_tree === false
736
- && state.shutdown?.daemon_id === daemonId && state.shutdown.phases?.tree === true
718
+ && state.shutdown?.daemon_id === daemonId && state.shutdown.phases?.execution === true
737
719
  && state.shutdown.required_phases?.includes("daemon_resource")
738
720
  && state.shutdown.phases.daemon_resource !== true
739
721
  && state.shutdown.required_phases.every(phase => phase === "daemon_resource" || state.shutdown.phases[phase] === true);
@@ -922,7 +904,7 @@ async function reconcileOwnedSessions(context, input, interrupt, boundary) {
922
904
  }
923
905
  export function nativeInventoryVersion(directory, operations = retainedDaemonOperations(directory)) {
924
906
  const state = JSON.parse(fs.readFileSync(path.join(directory, "daemon.json"), "utf8"));
925
- const keys = ["schema_id", "daemon_id", "config", "pid", "provider_pid", "shutdown_state", "shutdown", "active_operation", "active_tree", "sessions", "descendants", "root_session_id", "resource_process", "provider_process", "owned_processes"];
907
+ const keys = ["schema_id", "daemon_id", "config", "pid", "provider_pid", "shutdown_state", "shutdown", "active_operation", "active_tree", "sessions", "descendants", "root_session_id", "resource_process", "provider_process", "owned_processes", "native_execution_revision", "unclaimed_activity", "native_resources_entries", "native_topology_error", "native_retirement_notifications", "terminal_observation_pending", "tool_observation_gaps"];
926
908
  // Only these native verbs are read-only. Unknown verbs remain guarded.
927
909
  const productive = operations.filter(operation => !isObservationOperation(operation.operation));
928
910
  const knownSchema = ["dd-opencode/daemon-state@2", "dd-codex/daemon-state@1", "dd-grok/daemon-state@1", "dd-zcode/daemon-state@1", "dd-agy/daemon-state@1", "dd-droid/daemon-state@1"].includes(String(state.schema_id));