@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.
- package/CHANGELOG.md +15 -0
- package/dist/build-info.json +8 -8
- package/dist/cli/error-output.js +11 -1
- package/dist/cli/input-preparation.js +32 -4
- package/dist/cli/run-cli.js +24 -7
- package/dist/harness-runtime/lib/daemon-operations.mjs +46 -11
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +19 -6
- package/dist/harness-runtime/lib/dd-agy.mjs +19 -0
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +1 -1
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +1 -1
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +2 -2
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +1 -1
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +1 -1
- package/dist/harness-runtime/lib/delegation-instructions.d.mts +1 -0
- package/dist/harness-runtime/lib/delegation-instructions.mjs +3 -0
- package/dist/schemas/code-work-batch.schema.json +1 -1
- package/dist/schemas/vnext-protocol-plan.schema.json +1 -1
- package/dist/services/codex-capacity.js +367 -0
- package/dist/services/controller-child-repair.js +46 -24
- package/dist/services/controller-fanout.js +23 -15
- package/dist/services/controller-result-authority.js +13 -8
- package/dist/services/controller-work-failure.js +198 -0
- package/dist/services/lifecycle-invocations.js +81 -16
- package/dist/services/managed-daemon-binding.js +40 -0
- package/dist/services/run-control.js +18 -36
- package/dist/services/run-controller.js +107 -22
- package/dist/services/vnext-code.js +3 -1
- package/dist/services/vnext-plan-review.js +2 -2
- package/dist/services/vnext-plan.js +3 -3
- package/dist/services/work-registry.js +59 -12
- package/dist/services/work-retry-context.js +55 -0
- package/dist/verification-components.json +3 -0
- 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') = ?
|
|
611
|
-
|
|
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.
|
|
623
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
305
|
+
let absenceVersion;
|
|
306
|
+
if ((boundary || technicalPause || control?.action === "stop") && !interrupt && !conflicts.has(directory) && !unsafeDaemons.has(canonicalPath(directory, false))) {
|
|
306
307
|
try {
|
|
307
|
-
|
|
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,
|
|
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 (
|
|
481
|
-
&& (["
|
|
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
|
-
|
|
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?.
|
|
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));
|