@deksden-com/dd-flow-cli 0.9.0-beta.155 → 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 (35) hide show
  1. package/CHANGELOG.md +23 -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 +26 -8
  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 +3 -3
  13. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +1 -1
  14. package/dist/harness-runtime/lib/delegation-instructions.d.mts +2 -1
  15. package/dist/harness-runtime/lib/delegation-instructions.mjs +8 -2
  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 +117 -0
  20. package/dist/services/controller-fanout.js +33 -20
  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 +117 -25
  24. package/dist/services/managed-daemon-binding.js +40 -0
  25. package/dist/services/retained-native-hook.js +34 -5
  26. package/dist/services/run-control.js +18 -36
  27. package/dist/services/run-controller-process.js +19 -6
  28. package/dist/services/run-controller.js +154 -22
  29. package/dist/services/vnext-code.js +3 -1
  30. package/dist/services/vnext-plan-review.js +2 -2
  31. package/dist/services/vnext-plan.js +3 -3
  32. package/dist/services/work-registry.js +62 -15
  33. package/dist/services/work-retry-context.js +55 -0
  34. package/dist/verification-components.json +3 -0
  35. package/package.json +1 -1
@@ -5,6 +5,7 @@ import { isObservationLoss } from "../harness-runtime/lib/operation-errors.mjs";
5
5
  import { terminalProviderRecovery } from "../harness-runtime/lib/provider-overload-policy.mjs";
6
6
  import { getVnextFanoutStatus } from "./vnext-fanout.js";
7
7
  import { normalizeNativeChildren, nativeTreeReadiness } from "../harness-runtime/lib/native-children.mjs";
8
+ import { failureDecisionReady } from "./controller-work-failure.js";
8
9
  /** Disappearance from a truncated child list is not retirement evidence. */
9
10
  export function acceptedChildrenRetired(expected, receipt, sessionId, operationId) {
10
11
  const children = normalizeNativeChildren(receipt, sessionId);
@@ -37,16 +38,18 @@ export function acceptedTechnicalWorkControl(context, row, controlId) {
37
38
  const guard = context.db.get("SELECT recovery_id,generation,status,capture_path FROM run_recovery_guards WHERE project_id=? AND run_id=? ORDER BY generation DESC LIMIT 1", [row.project_id, row.run_id]);
38
39
  if (!guard || guard.recovery_id !== control.recovery_id || guard.status !== "sealed" || !guard.capture_path || guard.generation <= row.generation)
39
40
  return false;
40
- const events = context.db.all("SELECT data_json FROM run_controller_events WHERE controller_id=? AND event_type='technical_dependency_paused' AND json_extract(data_json,'$.control_id')=?", [row.controller_id, controlId]);
41
- if (events.length !== 1)
42
- return false;
43
- const event = JSON.parse(events[0].data_json);
44
- if (event.schema_id !== "dd-flow/controller-dependency@1" || event.request_id !== control.request_id || event.generation !== row.generation || typeof event.source_operation_id !== "string")
45
- return false;
41
+ const events = context.db.all("SELECT data_json FROM run_controller_events WHERE controller_id=? AND event_type='technical_dependency_paused' AND json_extract(data_json,'$.control_id')=?", [row.controller_id, controlId]).map(item => JSON.parse(item.data_json));
46
42
  const state = JSON.parse(row.state_json), session = state.current_session === null ? null : state.sessions?.[state.current_session];
47
- if (!session?.id || !session.owner?.owner_id || event.state_dir !== session.state_dir)
43
+ if (!session?.id || !session.owner?.owner_id || !events.length || events.some(event => event.schema_id !== "dd-flow/controller-dependency@1"
44
+ || event.request_id !== control.request_id || event.generation !== row.generation || event.state_dir !== session.state_dir
45
+ || event.source_operation_id !== undefined && (typeof event.source_operation_id !== "string" || !event.source_operation_id)))
46
+ return false;
47
+ // Concurrent observers may add unbound notes to the same immutable control.
48
+ // They do not revoke one exact accepted source; conflicting sources still do.
49
+ const sources = [...new Set(events.flatMap(event => event.source_operation_id ? [event.source_operation_id] : []))];
50
+ if (sources.length !== 1)
48
51
  return false;
49
- const op = context.db.get("SELECT generation,status,input_json FROM run_controller_operations WHERE controller_id=? AND operation_id=?", [row.controller_id, event.source_operation_id]);
52
+ const op = context.db.get("SELECT generation,status,input_json FROM run_controller_operations WHERE controller_id=? AND operation_id=?", [row.controller_id, sources[0]]);
50
53
  if (!op || op.generation !== row.generation || !["outcome_unknown", "completed"].includes(op.status))
51
54
  return false;
52
55
  const input = JSON.parse(op.input_json), expected = input.obligation;
@@ -84,6 +87,8 @@ export function controllerObligationAccepted(context, row, expected) {
84
87
  const stage = [...stages].reverse().find(item => item.stage === expected.stage && (item.attempt ?? "try-001") === expected.attempt);
85
88
  if (!stage || stageCycle(context, row.project_id, row.run_id, expected.stage) !== expected.cycle)
86
89
  return false;
90
+ if (expected.action === "failure_resolution")
91
+ return Boolean(expected.failure_resolution && failureDecisionReady(expected.failure_resolution));
87
92
  if (["done", "skipped"].includes(stage.status))
88
93
  return true;
89
94
  if (expected.action === "resume")
@@ -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
+ }
@@ -108,7 +108,7 @@ export function isRetryableLifecycleRejection(code, details = {}) {
108
108
  return details.recoverable !== false;
109
109
  if (["work_checks_failed", "code_gate_failed", "code_review_gate_failed", "merge_gate_failed"].includes(code))
110
110
  return Array.isArray(details.failures) && details.failures.length > 0 && details.failures.every(receipt => checkDiagnosisDisposition(receipt) === "diagnosis_required");
111
- return details.recoverable === true || ["review_repair_incomplete", "plan_review_incomplete", "semantic_result_invalid",
111
+ return details.recoverable === true || ["work_contract_incomplete", "review_repair_incomplete", "plan_review_incomplete", "semantic_result_invalid",
112
112
  "review_evidence_invalid", "evidence_obligation_unknown", "invalid_evidence_ref",
113
113
  "evidence_ref_missing", "evidence_line_out_of_range", "document_update_missing",
114
114
  "document_update_not_materialized", "review_repair_no_change"].includes(code)
@@ -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") {
@@ -765,18 +776,23 @@ function invocationRecoverySnapshot(context, scope) {
765
776
  }
766
777
  /** Resolve authority, never reconstruct it from an old prompt or issue on read. */
767
778
  export function currentStageFinishCommand(context, stage) {
779
+ return currentStageFinishAuthority(context, stage)?.command ?? null;
780
+ }
781
+ /** Observation of an existing attempt is not permission to dispatch it again. */
782
+ export function currentStageFinishAuthority(context, stage) {
768
783
  const scope = context.env.DD_FLOW_INVOCATION_SCOPE;
769
784
  if (!scope || !context.db.get("SELECT 1 FROM sqlite_master WHERE name='lifecycle_invocations'"))
770
785
  return null;
771
- const rows = context.db.all("SELECT * FROM lifecycle_invocations WHERE scope_json = ? AND status = 'issued'", [scope])
786
+ const rows = context.db.all("SELECT * FROM lifecycle_invocations WHERE scope_json = ? AND status IN ('issued','observed','executing')", [scope])
772
787
  .filter(row => {
773
788
  const parsed = invocation(row.command);
774
- return parsed.operation === "stage_finish" && commandOption(parsed, "stage") === stage
775
- && (row.deadline === null || row.deadline > Date.now());
789
+ return parsed.operation === "stage_finish" && commandOption(parsed, "stage") === stage;
776
790
  });
777
791
  if (rows.length > 1)
778
792
  throw new AppError("invocation_ambiguous", "Stage has multiple current finish authorities", 1, { stage });
779
- return rows[0] ? publicInvocationCommand(context, rows[0], rows[0].command) : null;
793
+ const row = rows[0];
794
+ return row ? { id: row.id, status: row.status, command: row.status === "issued" && (row.deadline === null || row.deadline > Date.now())
795
+ ? publicInvocationCommand(context, row, row.command) : null } : null;
780
796
  }
781
797
  export function managedInvocationContext(context, input) {
782
798
  const env = { ...context.env, DD_FLOW_CURRENT_INVOCATION: undefined, DD_FLOW_INVOCATION_SCOPE: undefined };
@@ -1876,7 +1892,8 @@ export function observedLifecycleInvocation(context, command, scopeConstraint, c
1876
1892
  const retainedId = suppliedId && context.db.get("SELECT id FROM lifecycle_invocations WHERE id = ?", [suppliedId]) ? suppliedId : undefined;
1877
1893
  // `issued` is a rendezvous, not authority: the CLI still blocks in
1878
1894
  // awaitLifecycleInvocation until the native observer commits the receipt.
1879
- const matching = context.db.all("SELECT * FROM lifecycle_invocations WHERE status IN ('issued','observed','executing','settled') ORDER BY rowid DESC")
1895
+ const history = context.db.all("SELECT * FROM lifecycle_invocations WHERE status IN ('issued','observed','executing','settled') ORDER BY rowid DESC");
1896
+ const matching = history
1880
1897
  .filter(row => {
1881
1898
  try {
1882
1899
  const explicitId = retainedId;
@@ -1896,7 +1913,7 @@ export function observedLifecycleInvocation(context, command, scopeConstraint, c
1896
1913
  }
1897
1914
  });
1898
1915
  const identity = correlation?.identity;
1899
- const matches = lifecycleCandidates(matching, identity ? JSON.stringify([identity.daemonId, identity.rootSessionId, identity.sessionId, identity.parentSessionId, identity.toolCallId]) : undefined, true);
1916
+ const matches = lifecycleCandidates(matching, identity ? JSON.stringify([identity.daemonId, identity.rootSessionId, identity.sessionId, identity.parentSessionId, identity.toolCallId]) : undefined, true, history);
1900
1917
  if (!matches.length)
1901
1918
  return null;
1902
1919
  if (matches.length !== 1)
@@ -1905,8 +1922,9 @@ export function observedLifecycleInvocation(context, command, scopeConstraint, c
1905
1922
  }
1906
1923
  /** A new native call may consume a successor, but never a historical result.
1907
1924
  * Exact native delivery replays its immutable assignment. CLI lookup without
1908
- * native correlation remains ambiguous when history and a successor coexist. */
1909
- function lifecycleCandidates(rows, identity, lookup = false) {
1925
+ * native correlation may use a uniquely bound committed successor, not an
1926
+ * arbitrary newest/active row. Unrelated history remains ambiguous. */
1927
+ function lifecycleCandidates(rows, identity, lookup = false, history = rows) {
1910
1928
  if (identity) {
1911
1929
  const replay = rows.filter(row => {
1912
1930
  if (!row.identity_json)
@@ -1927,6 +1945,26 @@ function lifecycleCandidates(rows, identity, lookup = false) {
1927
1945
  return replay;
1928
1946
  }
1929
1947
  const active = rows.filter(row => row.status !== "settled");
1948
+ if (lookup && !identity && active.length === 1 && (active[0].status === "issued" || active[0].identity_json && active[0].event_key)) {
1949
+ const current = active[0];
1950
+ const leadsToCurrent = (row, seen = new Set()) => {
1951
+ if (row.id === current.id)
1952
+ return true;
1953
+ if (row.status !== "settled" || seen.has(row.id))
1954
+ return false;
1955
+ seen.add(row.id);
1956
+ try {
1957
+ const next = JSON.parse(row.outcome_json ?? "null")?.successor_invocation_id;
1958
+ const successor = history.find(candidate => candidate.id === next && candidate.scope_json === current.scope_json);
1959
+ return Boolean(successor && leadsToCurrent(successor, seen));
1960
+ }
1961
+ catch {
1962
+ return false;
1963
+ }
1964
+ };
1965
+ if (rows.every(row => leadsToCurrent(row)))
1966
+ return active;
1967
+ }
1930
1968
  return (!lookup || identity) && active.length ? active : rows;
1931
1969
  }
1932
1970
  /** Restore private assignment arguments without discarding the provider's
@@ -1961,6 +1999,8 @@ export function resolveObservedLifecycleArgs(context, id, actualArgs) {
1961
1999
  const inline = argv.findIndex(value => value.startsWith(`--${publicKey}=`));
1962
2000
  if (inline >= 0)
1963
2001
  argv[inline] = `--${publicKey}=${commandOption(actual, publicKey)}`;
2002
+ else
2003
+ argv.push(`--${publicKey}`, commandOption(actual, publicKey));
1964
2004
  }
1965
2005
  }
1966
2006
  return argv;
@@ -1971,24 +2011,47 @@ export function retainObservedLifecycleSemantics(context, id, argv) {
1971
2011
  const row = context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [id]);
1972
2012
  if (!row || row.status !== "executing")
1973
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) {
1974
2022
  const parsed = invocation(row.command);
1975
2023
  if (!["work_fail", "work_finish"].includes(parsed.operation))
1976
- return;
2024
+ return row.command;
1977
2025
  const prefix = Object.entries(parsed.env).map(([key, value]) => `${key}=${quote([value])}`).join(" ");
1978
2026
  const executable = launchExpression(parsed);
1979
2027
  const suffix = row.command.slice(shellSuffixOffset(row.command)).trimStart();
1980
- 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}` : ""}`;
1981
2046
  checkCommand(row, command);
1982
- if (context.db.run("UPDATE lifecycle_invocations SET command = ?, updated_at = ? WHERE id = ? AND status = 'executing'", [command, context.now(), id]).changes !== 1) {
1983
- throw new AppError("invocation_settlement_conflict", "Lifecycle attempt changed before its public input was retained", 1, { invocation_id: id });
1984
- }
2047
+ return command;
1985
2048
  }
1986
- export function settleLifecyclePreparationRejection(context, id, error) {
2049
+ export function settleLifecyclePreparationRejection(context, id, error, preparedCommand) {
1987
2050
  delete error.details.retry_command;
1988
2051
  delete error.details.retry_instruction;
1989
2052
  try {
1990
2053
  return context.db.writeTransaction(() => {
1991
- const row = load(context, id);
2054
+ let row = load(context, id);
1992
2055
  if (row.status === "settled") {
1993
2056
  const details = row.outcome_json ? JSON.parse(row.outcome_json)?.error?.details : undefined;
1994
2057
  return details?.effect === "no_effect" && details.retry_command ? details : { effect: "unknown", recoverable: false };
@@ -1998,22 +2061,41 @@ export function settleLifecyclePreparationRejection(context, id, error) {
1998
2061
  const scope = JSON.parse(row.scope_json);
1999
2062
  if (scope.runId)
2000
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
+ }
2001
2080
  const signature = preparationRejectionSignature(error);
2081
+ const inputKey = preparationInputKey(parsed, error.details);
2002
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])
2003
2083
  .some(prior => { try {
2004
- 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);
2005
2086
  }
2006
2087
  catch {
2007
2088
  return false;
2008
2089
  } });
2009
2090
  const retryable = !repeated && isRetryableLifecycleRejection(error.code, error.details);
2010
- 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 } : {}),
2011
2092
  ...(repeated ? { repeated_without_progress: true } : {}),
2012
2093
  ...(retryable ? {
2013
2094
  retry_command: successorLifecycleInvocationCommand(context, row),
2014
2095
  retry_instruction: "Correct the reported input, then execute retry_command verbatim in this same Session. The rejected call changed no lifecycle state."
2015
2096
  } : {}) };
2016
- 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]);
2017
2099
  return details;
2018
2100
  });
2019
2101
  }
@@ -2023,7 +2105,7 @@ export function settleLifecyclePreparationRejection(context, id, error) {
2023
2105
  }
2024
2106
  }
2025
2107
  function preparationRejectionSignature(error) {
2026
- 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"]);
2027
2109
  const normalize = (value) => {
2028
2110
  if (Array.isArray(value))
2029
2111
  return value.map(normalize);
@@ -2033,6 +2115,16 @@ function preparationRejectionSignature(error) {
2033
2115
  };
2034
2116
  return crypto.createHash("sha256").update(JSON.stringify([error.code, normalize(error.details)])).digest("hex");
2035
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
+ }
2036
2128
  export function currentLifecycleFailure(context, input) {
2037
2129
  if (input.turn !== undefined) {
2038
2130
  const hooks = context.db.all(`SELECT id, outcome_json FROM hook_events
@@ -2157,7 +2249,7 @@ export async function awaitLifecycleInvocation(context, input) {
2157
2249
  ? error.details.recoverable !== false
2158
2250
  : isRetryableLifecycleRejection(error.code, details);
2159
2251
  Object.assign(error.details, { phase: "prepare", effect: "no_effect", recoverable });
2160
- Object.assign(error.details, settleLifecyclePreparationRejection(context, input.id, error));
2252
+ Object.assign(error.details, settleLifecyclePreparationRejection(context, input.id, error, input.command));
2161
2253
  throw error;
2162
2254
  }
2163
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
  }