@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.
- package/CHANGELOG.md +23 -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 +26 -8
- 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 +3 -3
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +1 -1
- package/dist/harness-runtime/lib/delegation-instructions.d.mts +2 -1
- package/dist/harness-runtime/lib/delegation-instructions.mjs +8 -2
- 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 +117 -0
- package/dist/services/controller-fanout.js +33 -20
- package/dist/services/controller-result-authority.js +13 -8
- package/dist/services/controller-work-failure.js +198 -0
- package/dist/services/lifecycle-invocations.js +117 -25
- package/dist/services/managed-daemon-binding.js +40 -0
- package/dist/services/retained-native-hook.js +34 -5
- package/dist/services/run-control.js +18 -36
- package/dist/services/run-controller-process.js +19 -6
- package/dist/services/run-controller.js +154 -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 +62 -15
- package/dist/services/work-retry-context.js +55 -0
- package/dist/verification-components.json +3 -0
- 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.
|
|
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,
|
|
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') = ?
|
|
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") {
|
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
1909
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|