@deksden-com/dd-flow-cli 0.9.0-beta.158 → 0.9.0-beta.159
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 +8 -0
- package/dist/build-info.json +3 -3
- package/dist/services/continuation-outcome.js +11 -6
- package/dist/services/controller-fanout.js +39 -6
- package/dist/services/lifecycle-invocations.js +199 -80
- package/dist/services/run-controller-state.js +8 -0
- package/dist/services/run-controller.js +33 -9
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# @deksden-com/dd-flow-cli
|
|
2
2
|
|
|
3
|
+
## 0.9.0-beta.159
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Follow committed lifecycle successors consistently in native and CLI admission, including changed-fingerprint repair retries. Keep native-call bindings immutable after expiry and restrict actionable repairs to the current stage attempt/cycle.
|
|
8
|
+
- Recognize semantic completion progress independently of the Work graph; pause genuine nonprogress with current diagnostics instead of replaying historical failures (plan 113).
|
|
9
|
+
- Resolve emitted JavaScript import specifiers to their TypeScript fixture sources when selecting affected verification consumers, without selecting an unrelated full suite.
|
|
10
|
+
|
|
3
11
|
## 0.9.0-beta.158
|
|
4
12
|
|
|
5
13
|
### Patch Changes
|
package/dist/build-info.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"cli_package": "@deksden-com/dd-flow-cli",
|
|
3
|
-
"cli_version": "0.9.0-beta.
|
|
4
|
-
"cli_commit": "
|
|
5
|
-
"built_at": "2026-10-
|
|
3
|
+
"cli_version": "0.9.0-beta.159",
|
|
4
|
+
"cli_commit": "138ff716eb8e3f8e4b5c32a7556284c30e50804b",
|
|
5
|
+
"built_at": "2026-10-29T12:37:35.400Z",
|
|
6
6
|
"built_with_canon": {
|
|
7
7
|
"version": "4.1.4",
|
|
8
8
|
"commit": "7cc415b3ef3ce37c9624c7cee51023e66f67cd57",
|
|
@@ -1,20 +1,25 @@
|
|
|
1
1
|
import { AppError } from "../shared/errors.js";
|
|
2
|
-
import {
|
|
2
|
+
import { applicableLifecycleRejection } from "./lifecycle-invocations.js";
|
|
3
3
|
import { stageAttempt } from "./stage-work-graph.js";
|
|
4
4
|
/** Only the settled coordinator turn can explain a repeated continuation.
|
|
5
5
|
* A stale error from another stage/turn or an error followed by success is not
|
|
6
6
|
* authority. Never inspect the assistant's prose for this decision. */
|
|
7
7
|
export function assertNoCorrelatedRejection(context, scope) {
|
|
8
|
-
const rows = context.db.all(`SELECT outcome_json FROM hook_events
|
|
8
|
+
const rows = context.db.all(`SELECT event_key, outcome_json FROM hook_events
|
|
9
9
|
WHERE project_id = ? AND json_extract(outcome_json, '$.run_id') = ?
|
|
10
10
|
AND json_extract(outcome_json, '$.generation') = ? AND json_extract(outcome_json, '$.stage') = ?
|
|
11
11
|
AND json_extract(outcome_json, '$.controller_turn') = ? AND json_extract(outcome_json, '$.attempt') = ? ORDER BY id`, [scope.projectId, scope.runId, scope.generation, scope.stage, scope.turn, stageAttempt(context, scope.projectId, scope.runId, scope.stage)]);
|
|
12
12
|
const latest = new Map();
|
|
13
13
|
for (const row of rows) {
|
|
14
|
-
const value = JSON.parse(row.outcome_json);
|
|
15
|
-
|
|
14
|
+
const value = { ...JSON.parse(row.outcome_json), event_key: row.event_key };
|
|
15
|
+
const invocationId = context.db.get("SELECT 1 FROM sqlite_master WHERE name='lifecycle_invocations'")
|
|
16
|
+
? context.db.get("SELECT id FROM lifecycle_invocations WHERE event_key=?", [row.event_key])?.id : undefined;
|
|
17
|
+
latest.set(invocationId ?? JSON.stringify([value.operation, value.session_id ?? null, value.correlation_key ?? value.fingerprint ?? value.work_id ?? null, value.attempt ?? null]), value);
|
|
16
18
|
}
|
|
17
19
|
for (const value of latest.values())
|
|
18
|
-
if (value.error
|
|
19
|
-
|
|
20
|
+
if (value.error) {
|
|
21
|
+
const failure = applicableLifecycleRejection(context, value.error, scope, { eventKey: value.event_key });
|
|
22
|
+
if (failure)
|
|
23
|
+
throw new AppError(failure.code, failure.message, 1, { ...failure.details, operation: value.operation, hook_event_id: value.hook_event_id, continuation_scope: scope });
|
|
24
|
+
}
|
|
20
25
|
}
|
|
@@ -12,9 +12,42 @@ import { requireProjectByRoot } from "./projects.js";
|
|
|
12
12
|
import { stageAttempt, stageWorkIds } from "./stage-work-graph.js";
|
|
13
13
|
import { runtimeCommand } from "./runtime-command.js";
|
|
14
14
|
import { promptDeliveryInstruction } from "./prompt-delivery.js";
|
|
15
|
-
import { currentStageFinishAuthority } from "./lifecycle-invocations.js";
|
|
15
|
+
import { committedSuccessor, currentStageFinishAuthority } from "./lifecycle-invocations.js";
|
|
16
16
|
import { acceptedChildWork } from "./controller-result-authority.js";
|
|
17
17
|
import { explicitWorkFailure } from "./controller-work-failure.js";
|
|
18
|
+
import { commandOption, parseLifecycleCommand } from "./lifecycle-command.js";
|
|
19
|
+
/** A new transport ID is not progress. Read only the current assignment's
|
|
20
|
+
* committed transition and its check inputs; never inspect mutable files here. */
|
|
21
|
+
export function controllerSemanticCheckpoint(context, input) {
|
|
22
|
+
const scopeJson = context.env.DD_FLOW_INVOCATION_SCOPE;
|
|
23
|
+
const scope = scopeJson ? JSON.parse(scopeJson) : null;
|
|
24
|
+
const project = scope && context.db.get("SELECT id FROM projects WHERE root=?", [scope.projectRoot]);
|
|
25
|
+
const attempt = project && scope ? stageAttempt(context, project.id, scope.runId, input.stage) : null;
|
|
26
|
+
const receipt = (id) => typeof id === "string" && project && scope
|
|
27
|
+
? context.db.get("SELECT declaration_id,input_hash,status FROM check_receipts WHERE id=? AND project_id=? AND run_id=?", [id, project.id, scope.runId]) ?? null : null;
|
|
28
|
+
const stableSet = (values) => [...new Set(values.map(value => JSON.stringify(value)))].sort().map(value => JSON.parse(value));
|
|
29
|
+
const parsed = input.command ? parseLifecycleCommand(input.command) : null;
|
|
30
|
+
const operation = parsed && parsed.kind !== "none" ? {
|
|
31
|
+
operation: parsed.invocation.operation, positional: parsed.invocation.args.positional,
|
|
32
|
+
options: [...parsed.invocation.args.options].filter(([key]) => !["invocation-id", "hook-event-id", "json", "progress-jsonl", "response-file", "reason"].includes(key))
|
|
33
|
+
.sort(([a], [b]) => a.localeCompare(b)).map(([key, values]) => [key, key === "retry-check" ? values.map(receipt) : values])
|
|
34
|
+
} : null;
|
|
35
|
+
const transitions = scopeJson && input.invocationId ? stableSet(context.db.all("SELECT * FROM lifecycle_invocations WHERE scope_json=? AND status='settled' AND json_extract(outcome_json,'$.successor_invocation_id')=?", [scopeJson, input.invocationId]).flatMap(row => {
|
|
36
|
+
const prior = parseLifecycleCommand(row.command);
|
|
37
|
+
if (prior.kind === "none" || commandOption(prior.invocation, "stage") !== input.stage)
|
|
38
|
+
return [];
|
|
39
|
+
const outcome = JSON.parse(row.outcome_json ?? "null");
|
|
40
|
+
if (committedSuccessor(context, row, outcome)?.id !== input.invocationId)
|
|
41
|
+
return [];
|
|
42
|
+
const result = outcome.result, error = outcome.error;
|
|
43
|
+
const references = result?.receipts ?? error?.details?.failures ?? [];
|
|
44
|
+
return [{ outcome: result?.outcome ?? null, verification: result?.verification?.verdict ?? null,
|
|
45
|
+
error: error ? { code: error.code, rejection_signature: error.details?.rejection_signature ?? null, input_key: error.details?.input_key ?? null } : null,
|
|
46
|
+
receipts: stableSet((Array.isArray(references) ? references : []).map(value => receipt(value?.id))) }];
|
|
47
|
+
})) : [];
|
|
48
|
+
const memoryChecks = project && scope && input.reviewInputSha256 ? stableSet(context.db.all("SELECT payload_json FROM audit_events WHERE project_id=? AND event_type='memory.correction_checks' AND json_extract(payload_json,'$.run_id')=? AND json_extract(payload_json,'$.wave')=? AND json_extract(payload_json,'$.review_input_sha256')=?", [project.id, scope.runId, input.wave ?? 0, input.reviewInputSha256]).map(row => { const value = JSON.parse(row.payload_json); return { corrected_input: value.corrected_input_sha256, completed_correction: value.completed_correction_sha256, receipts: stableSet((value.checks ?? []).map((check) => receipt(check.id))) }; })) : [];
|
|
49
|
+
return { phase: input.phase, ...(input.wave === undefined ? {} : { wave: input.wave }), attempt, generation: scope?.generation ?? null, operation, transitions, ...(input.reviewInputSha256 ? { memory_checks: memoryChecks } : {}) };
|
|
50
|
+
}
|
|
18
51
|
function semanticCompletionPrompt(stage, completed, command) {
|
|
19
52
|
const task = stage === "plan-review"
|
|
20
53
|
? "Classify the reviewer findings, apply every accepted correction in the current PLAN-REVIEW Work, and write the authoritative review decision."
|
|
@@ -94,7 +127,7 @@ export async function nextControllerFanout(context, input) {
|
|
|
94
127
|
if (fanout.state === "dispatch_required" || fanout.state === "capacity_required")
|
|
95
128
|
return { kind: "wait", reason: "qualified_capacity_required" };
|
|
96
129
|
}
|
|
97
|
-
const prompt = (action, text) => ({ kind: "prompt", action, prompt: action === "dispatch_work" ? text : `${text}\n\n${lifecycleCommandCompletionInstruction}`, fingerprint: JSON.stringify({ stage: input.stage, parent: fanout.parent_work_id, state: fanout.state, graph: fanout.graph_revision }) });
|
|
130
|
+
const prompt = (action, text, checkpoint) => ({ kind: "prompt", action, prompt: action === "dispatch_work" ? text : `${text}\n\n${lifecycleCommandCompletionInstruction}`, ...(checkpoint ? { checkpoint } : {}), fingerprint: JSON.stringify({ stage: input.stage, action, parent: fanout.parent_work_id, state: fanout.state, graph: fanout.graph_revision, ...(checkpoint ? { semantic: checkpoint } : {}) }) });
|
|
98
131
|
if (fanout.state === "coordinator_required")
|
|
99
132
|
return prompt("materialize_graph", `Continue the current ${input.stage} Stage using its authoritative packet. Materialize only its required Work graph, declaring real dependencies at registration. If the packet's CLI command registers the graph, use its returned Work graph unchanged: do not add or alter dependencies after registration. Accepted predecessor Stages already fence their completed producers; do not add cross-stage edges merely to restate input provenance. Do not launch children, finish the Stage, or start a successor. Return after registering the graph; the runtime will dispatch its ready Work.`);
|
|
100
133
|
if (fanout.works.failed || fanout.works.cancelled) {
|
|
@@ -198,7 +231,7 @@ export async function nextControllerFanout(context, input) {
|
|
|
198
231
|
const run = context.db.get("SELECT flow_kind,run_root,run_dir FROM runs WHERE project_id=? AND id=?", [projectId, input.runId]);
|
|
199
232
|
if (run?.flow_kind === "mb-init" || run?.flow_kind === "mb-upgrade") {
|
|
200
233
|
const attempt = stageAttempt(context, projectId, input.runId, "review");
|
|
201
|
-
const repair = context.db.get(`SELECT CASE event_type WHEN 'memory.second_repair_registration' THEN 2 ELSE 1 END AS wave
|
|
234
|
+
const repair = context.db.get(`SELECT CASE event_type WHEN 'memory.second_repair_registration' THEN 2 ELSE 1 END AS wave,json_extract(payload_json,'$.scope.input_sha256') AS input_sha256
|
|
202
235
|
FROM audit_events WHERE project_id=? AND event_type IN ('memory.repair_registration','memory.second_repair_registration')
|
|
203
236
|
AND json_extract(payload_json,'$.run_id')=? AND json_extract(payload_json,'$.scope.attempt')=?
|
|
204
237
|
AND json_extract(payload_json,'$.scope.parent_work_id')=? ORDER BY wave DESC LIMIT 1`, [projectId, input.runId, attempt, fanout.parent_work_id]);
|
|
@@ -206,7 +239,7 @@ export async function nextControllerFanout(context, input) {
|
|
|
206
239
|
AND json_extract(payload_json,'$.run_id')=? AND json_extract(payload_json,'$.scope.attempt')=?
|
|
207
240
|
AND json_extract(payload_json,'$.scope.parent_work_id')=? AND json_extract(payload_json,'$.wave')=?`, [projectId, input.runId, attempt, fanout.parent_work_id, repair.wave])) {
|
|
208
241
|
const args = ["--run", input.runId, "--project-root", input.projectRoot, "--wave", String(repair.wave), "--json"];
|
|
209
|
-
return prompt("semantic_completion", `Correction wave ${repair.wave} Work has settled. Before Stage finish, run ${runtimeCommand(context, ["memory", "corrections", "checks", ...args])}, then ${runtimeCommand(context, ["memory", "review", "selective-register", ...args])}. Read both complete responses, then end this Turn so the controller can dispatch the independent selective reviewers. Do not finish the Stage or treat repair Work completion as independent acceptance
|
|
242
|
+
return prompt("semantic_completion", `Correction wave ${repair.wave} Work has settled. Before Stage finish, run ${runtimeCommand(context, ["memory", "corrections", "checks", ...args])}, then ${runtimeCommand(context, ["memory", "review", "selective-register", ...args])}. Read both complete responses, then end this Turn so the controller can dispatch the independent selective reviewers. Do not finish the Stage or treat repair Work completion as independent acceptance.`, controllerSemanticCheckpoint(context, { stage: input.stage, phase: "memory_correction_checks_register", wave: repair.wave, reviewInputSha256: repair.input_sha256 }));
|
|
210
243
|
}
|
|
211
244
|
const wave = repair?.wave ?? 0;
|
|
212
245
|
const runHome = run.run_root ?? run.run_dir;
|
|
@@ -222,7 +255,7 @@ export async function nextControllerFanout(context, input) {
|
|
|
222
255
|
? `If the outcome is needs_repair, use the returned canonical findings and correction_plan_binding to author substantive repair tasks with exact owned write_paths. Register that plan with the packet's issued memory corrections register command for wave ${wave + 1}, then end this Turn so the controller can dispatch it. Do not invoke Stage finish before corrections, checks and independent selective review complete.`
|
|
223
256
|
: "If the outcome is needs_repair or blocked, the two correction waves are exhausted. Use the issued pause command for the concrete unresolved blocker; do not mark it accepted.",
|
|
224
257
|
"For blocked collection, use the issued pause command. Do not calculate finding hashes, merge or drop findings, edit project files in the coordinator, create speculative Work or start a successor Stage."
|
|
225
|
-
].join("\n\n"));
|
|
258
|
+
].join("\n\n"), controllerSemanticCheckpoint(context, { stage: input.stage, phase: "memory_collection", wave, command: collect }));
|
|
226
259
|
}
|
|
227
260
|
}
|
|
228
261
|
const finish = currentStageFinishAuthority(context, input.stage);
|
|
@@ -230,7 +263,7 @@ export async function nextControllerFanout(context, input) {
|
|
|
230
263
|
throw new AppError("controller_finish_authority_missing", "Settled child Work has no current Stage finish authority", 1, { stage: input.stage, reconciliation_required: true });
|
|
231
264
|
if (finish.status !== "issued" || !finish.command)
|
|
232
265
|
throw new AppError("controller_finish_authority_pending", "Observe the original Stage finish before another productive dispatch", 1, { stage: input.stage, invocation_id: finish.id, status: finish.status, reconciliation_required: true });
|
|
233
|
-
return prompt("semantic_completion", semanticCompletionPrompt(input.stage, fanout.works.completed, finish.command));
|
|
266
|
+
return { ...prompt("semantic_completion", semanticCompletionPrompt(input.stage, fanout.works.completed, finish.command), controllerSemanticCheckpoint(context, { stage: input.stage, phase: "stage_finish", command: finish.command, invocationId: finish.id })), authority: { id: finish.id, status: finish.status } };
|
|
234
267
|
}
|
|
235
268
|
/** Shared pinned runtime normalization; preserve structured errors at the TS boundary. */
|
|
236
269
|
export function controllerNativeChildren(receipt, rootSessionId) {
|
|
@@ -17,7 +17,7 @@ import { observeCommandHook } from "./hooks.js";
|
|
|
17
17
|
import { runtimeHarness } from "./execution-policy.js";
|
|
18
18
|
import { normalizedPathAlias, resolveContextualPaths } from "./command-context.js";
|
|
19
19
|
import { isRegisteredRepair } from "./repair-receipt.js";
|
|
20
|
-
import { stageAttempt } from "./stage-work-graph.js";
|
|
20
|
+
import { stageAttempt, stageWorkIds } from "./stage-work-graph.js";
|
|
21
21
|
import { contextualPathOptionsForLifecycle } from "../cli/command-inputs.js";
|
|
22
22
|
import { canonicalPath } from "../storage/paths.js";
|
|
23
23
|
import { runtimeCommand, shellArgument } from "./runtime-command.js";
|
|
@@ -378,12 +378,15 @@ export function assertLifecycleOutcomes(context, scope) {
|
|
|
378
378
|
}
|
|
379
379
|
const columns = context.db.all("PRAGMA table_info(hook_events)");
|
|
380
380
|
if (columns.some(column => column.name === "outcome_json")) {
|
|
381
|
-
for (const row of context.db.all("SELECT outcome_json FROM hook_events WHERE project_id = ? AND json_extract(outcome_json, '$.run_id') = ? AND json_extract(outcome_json, '$.generation') = ? ORDER BY id", [project.id, scope.runId, scope.generation])) {
|
|
381
|
+
for (const row of context.db.all("SELECT event_key, outcome_json FROM hook_events WHERE project_id = ? AND json_extract(outcome_json, '$.run_id') = ? AND json_extract(outcome_json, '$.generation') = ? ORDER BY id", [project.id, scope.runId, scope.generation])) {
|
|
382
382
|
const outcome = JSON.parse(row.outcome_json);
|
|
383
383
|
if (outcome.disposition === "retrying" && outcome.deadline <= Date.now())
|
|
384
384
|
throw new AppError("lifecycle_outcome_unknown", "Storage retry has no terminal receipt; reconcile its committed evidence", 1, { lifecycle_outcome: outcome, effect: "unknown" });
|
|
385
|
-
if (outcome.disposition === "fatal"
|
|
386
|
-
|
|
385
|
+
if (outcome.disposition === "fatal") {
|
|
386
|
+
const failure = applicableLifecycleRejection(context, { code: outcome.error?.code ?? "lifecycle_outcome_unknown", message: outcome.error?.message || "Mandatory lifecycle failed (historical receipt has no message)", details: outcome.error?.details }, { ...scope, projectId: project.id, stage: outcome.stage }, { eventKey: row.event_key });
|
|
387
|
+
if (failure)
|
|
388
|
+
throw new AppError(failure.code, failure.message, 1, { ...failure.details, lifecycle_outcome: outcome });
|
|
389
|
+
}
|
|
387
390
|
}
|
|
388
391
|
}
|
|
389
392
|
if (!context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'lifecycle_invocations'"))
|
|
@@ -391,13 +394,15 @@ export function assertLifecycleOutcomes(context, scope) {
|
|
|
391
394
|
const rows = context.db.all(`SELECT a.* FROM lifecycle_invocations a
|
|
392
395
|
WHERE json_extract(a.scope_json, '$.projectRoot') = ? AND json_extract(a.scope_json, '$.runId') = ?
|
|
393
396
|
AND json_extract(a.scope_json, '$.generation') = ? AND a.status = 'settled'
|
|
394
|
-
AND NOT EXISTS (SELECT 1 FROM lifecycle_invocations newer WHERE newer.scope_json = a.scope_json AND newer.fingerprint = a.fingerprint AND newer.rowid > a.rowid)
|
|
395
397
|
ORDER BY a.rowid`, [scope.projectRoot, scope.runId, scope.generation]);
|
|
396
398
|
for (const row of rows) {
|
|
397
399
|
const outcome = row.outcome_json ? JSON.parse(row.outcome_json) : null;
|
|
398
400
|
const error = outcome?.error;
|
|
399
|
-
if (typeof error?.code === "string" && !
|
|
400
|
-
|
|
401
|
+
if (typeof error?.code === "string" && !isCorrectableLifecycleRejection(error.code, error.details)) {
|
|
402
|
+
const failure = applicableLifecycleRejection(context, { code: error.code, message: typeof error.message === "string" ? error.message : "Mandatory lifecycle failed", details: error.details }, { ...scope, projectId: project.id }, { invocationId: row.id });
|
|
403
|
+
if (failure)
|
|
404
|
+
throw new AppError(failure.code, failure.message, 1, { ...failure.details, invocation_id: failure.details.invocation_id ?? row.id });
|
|
405
|
+
}
|
|
401
406
|
}
|
|
402
407
|
}
|
|
403
408
|
/** Hooks already own native identity. Retain command outcomes there, with an
|
|
@@ -760,7 +765,7 @@ function currentIssuedCommand(context, scope, command, excludeId) {
|
|
|
760
765
|
function scopedCommandFingerprint(context, scope, command) {
|
|
761
766
|
return fingerprint(invocation(canonicalManagedCommand(context, command, JSON.parse(scope))));
|
|
762
767
|
}
|
|
763
|
-
function committedSuccessor(context, prior, outcome) {
|
|
768
|
+
export function committedSuccessor(context, prior, outcome) {
|
|
764
769
|
if (typeof outcome?.successor_invocation_id !== "string" || outcome.successor_invocation_id === prior.id)
|
|
765
770
|
return null;
|
|
766
771
|
const command = outcome.error?.details?.retry_command ?? outcome.result?.next?.finish_command;
|
|
@@ -1608,33 +1613,35 @@ export function observeLifecycleInvocation(context, input) {
|
|
|
1608
1613
|
function observeLifecycleInvocationReceipt(context, input) {
|
|
1609
1614
|
storage(context);
|
|
1610
1615
|
const delivered = parseLifecycleCommand(input.command);
|
|
1611
|
-
if (delivered.kind === "standalone") {
|
|
1612
|
-
const id = commandOption(delivered.invocation, "invocation-id");
|
|
1613
|
-
const direct = context.db.all("SELECT * FROM lifecycle_invocations WHERE admission_json IS NOT NULL AND status IN ('executing','settled')")
|
|
1614
|
-
.filter(row => {
|
|
1615
|
-
const scope = JSON.parse(row.scope_json);
|
|
1616
|
-
if (id && row.id !== id || scope.daemonId !== input.identity.daemonId || scope.rootSessionId !== input.identity.rootSessionId)
|
|
1617
|
-
return false;
|
|
1618
|
-
// A fresh native call must bind the explicitly committed successor,
|
|
1619
|
-
// not short-circuit to its predecessor's successful repair response.
|
|
1620
|
-
// Exact delivery/explicit-ID replay remains historical read-only.
|
|
1621
|
-
const successor = row.status === "settled" && row.outcome_json ? committedSuccessor(context, row, JSON.parse(row.outcome_json)) : null;
|
|
1622
|
-
if (!id && row.identity_json && successor && ["issued", "observed"].includes(successor.status)
|
|
1623
|
-
&& !sameNativeCall(row.identity_json, JSON.stringify([input.identity.daemonId, input.identity.rootSessionId, input.identity.sessionId, input.identity.parentSessionId, input.identity.toolCallId])))
|
|
1624
|
-
return false;
|
|
1625
|
-
try {
|
|
1626
|
-
return fingerprint(invocation(canonicalManagedCommand(context, delivered.invocation.command, scope))) === row.fingerprint;
|
|
1627
|
-
}
|
|
1628
|
-
catch {
|
|
1629
|
-
return false;
|
|
1630
|
-
}
|
|
1631
|
-
});
|
|
1632
|
-
if (direct.length === 1)
|
|
1633
|
-
return { eventKey: direct[0].event_key, duplicate: true,
|
|
1634
|
-
disposition: direct[0].status === "settled" ? "settled_read" : "in_flight", invocationId: direct[0].id };
|
|
1635
|
-
}
|
|
1636
1616
|
const projectRoot = input.projectId ? context.db.get("SELECT root FROM projects WHERE id = ?", [input.projectId])?.root : undefined;
|
|
1617
|
+
// Selection is SQL-only and shared with CLI lookup. A historical result may
|
|
1618
|
+
// return early, but never before considering its committed successor chain.
|
|
1619
|
+
const correlation = { identity: input.identity, ...(input.id ? { id: input.id } : {}), ...(input.executionCwd === undefined ? {} : { executionCwd: input.executionCwd }), native: true };
|
|
1620
|
+
let selected = null;
|
|
1621
|
+
let deferredAmbiguity;
|
|
1622
|
+
if (delivered.kind !== "none" && delivered.supported !== false) {
|
|
1623
|
+
try {
|
|
1624
|
+
selected = observedLifecycleInvocation(context, input.command, { daemonId: input.identity.daemonId, rootSessionId: input.identity.rootSessionId, ...(projectRoot ? { projectRoot } : {}) }, correlation);
|
|
1625
|
+
}
|
|
1626
|
+
catch (error) {
|
|
1627
|
+
if (!(error instanceof AppError) || error.code !== "invocation_ambiguous")
|
|
1628
|
+
throw error;
|
|
1629
|
+
// A native root can retain assignments from different generations.
|
|
1630
|
+
// Only the existing trusted owner discovery may narrow that scope.
|
|
1631
|
+
deferredAmbiguity = error;
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
let selectedRow = selected ? context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [selected.id]) : undefined;
|
|
1635
|
+
if (selectedRow?.admission_json && ["executing", "settled"].includes(selectedRow.status))
|
|
1636
|
+
return { eventKey: selectedRow.event_key, duplicate: true,
|
|
1637
|
+
disposition: selectedRow.status === "settled" ? "settled_read" : "in_flight", invocationId: selectedRow.id };
|
|
1637
1638
|
const provenScope = projectRoot && input.harness ? managedNativeInvocationScope(context, projectRoot, input.identity, input.harness) : null;
|
|
1639
|
+
if (deferredAmbiguity) {
|
|
1640
|
+
if (!provenScope)
|
|
1641
|
+
throw deferredAmbiguity;
|
|
1642
|
+
selected = observedLifecycleInvocation(context, input.command, provenScope, correlation);
|
|
1643
|
+
selectedRow = selected ? context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [selected.id]) : undefined;
|
|
1644
|
+
}
|
|
1638
1645
|
const owner = provenScope && input.harness && input.projectId ? managedNativeOwner(context, input.identity, input.harness, input.projectId) : null;
|
|
1639
1646
|
const ownerSnapshot = () => {
|
|
1640
1647
|
if (!provenScope?.runId || !input.projectId)
|
|
@@ -1656,7 +1663,8 @@ function observeLifecycleInvocationReceipt(context, input) {
|
|
|
1656
1663
|
return JSON.stringify(rows);
|
|
1657
1664
|
};
|
|
1658
1665
|
const retainedOwner = ownerSnapshot();
|
|
1659
|
-
const
|
|
1666
|
+
const suppliedId = input.id ?? (delivered.kind !== "none" ? commandOption(delivered.invocation, "invocation-id") : undefined);
|
|
1667
|
+
const supplied = suppliedId ? context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [suppliedId]) : undefined;
|
|
1660
1668
|
const analysis = parseLifecycleCommand(input.command);
|
|
1661
1669
|
if (analysis.kind === "none" || analysis.supported === false) {
|
|
1662
1670
|
let issuedBarrier;
|
|
@@ -1735,11 +1743,11 @@ function observeLifecycleInvocationReceipt(context, input) {
|
|
|
1735
1743
|
return false;
|
|
1736
1744
|
return true;
|
|
1737
1745
|
});
|
|
1738
|
-
const candidates = lifecycleCandidates(matching, deliveredIdentity);
|
|
1746
|
+
const candidates = lifecycleCandidates(context, matching, deliveredIdentity);
|
|
1739
1747
|
// A known explicit ID remains a compatibility path. A missing or mistyped
|
|
1740
1748
|
// public ID is corrected only by an exact operation/target match inside the
|
|
1741
1749
|
// trusted native root. Never fuzzy-match UUID text or select a global latest.
|
|
1742
|
-
const initial = supplied ?? (candidates.length === 1 ? candidates[0] : undefined);
|
|
1750
|
+
const initial = supplied ?? selectedRow ?? (candidates.length === 1 ? candidates[0] : undefined);
|
|
1743
1751
|
// A live observation arriving after direct admission is optional correlation,
|
|
1744
1752
|
// never a second authority or an executing -> observed transition.
|
|
1745
1753
|
if (initial?.admission_json && ["executing", "settled"].includes(initial.status))
|
|
@@ -1782,6 +1790,9 @@ function observeLifecycleInvocationReceipt(context, input) {
|
|
|
1782
1790
|
const row = context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [initial.id]);
|
|
1783
1791
|
if (retainedOwner !== ownerSnapshot())
|
|
1784
1792
|
throw new AppError("invocation_scope_unproven", "Native owner changed before admission receipt", 1, { effect: "no_effect" });
|
|
1793
|
+
const current = observedLifecycleInvocation(context, input.command, scope, { ...(input.id ? { id: input.id } : {}), identity, ...(input.executionCwd === undefined ? {} : { executionCwd: input.executionCwd }), native: true });
|
|
1794
|
+
if (!current || current.id !== initial.id)
|
|
1795
|
+
throw new AppError("invocation_assignment_unproven", "Lifecycle continuation changed before native receipt; inspect its committed successor", 1, { effect: "no_effect", reconciliation_required: true, invocation_id: initial.id, successor_invocation_id: current?.id });
|
|
1785
1796
|
if (row.scope_json !== initial.scope_json)
|
|
1786
1797
|
throw new AppError("invocation_scope_mismatch", "Lifecycle scope changed before native receipt", 1, { effect: "no_effect" });
|
|
1787
1798
|
if (row.status === "settled")
|
|
@@ -1811,10 +1822,11 @@ function observeLifecycleInvocationReceipt(context, input) {
|
|
|
1811
1822
|
// its workdir is invalid), after which repeating the same issued command
|
|
1812
1823
|
// is safe. Keep the newest receipt until the CLI claims the attempt. The
|
|
1813
1824
|
// observed -> executing compare-and-swap remains the exactly-once boundary.
|
|
1814
|
-
const updated = context.db.run("UPDATE lifecycle_invocations SET identity_json = ?, event_key = ?, status = 'observed', updated_at = ? WHERE id = ? AND status IN ('issued', 'observed')", [identityJson, eventKey, context.now(), initial.id]);
|
|
1825
|
+
const updated = context.db.run("UPDATE lifecycle_invocations SET identity_json = ?, event_key = ?, status = 'observed', updated_at = ? WHERE id = ? AND scope_json = ? AND command = ? AND status IN ('issued', 'observed')", [identityJson, eventKey, context.now(), initial.id, initial.scope_json, initial.command]);
|
|
1815
1826
|
// Once execution has been claimed, later PreToolUse observations are
|
|
1816
1827
|
// retained as hook evidence but cannot replace the executor's receipt.
|
|
1817
|
-
|
|
1828
|
+
const retained = updated.changes === 1 ? row : context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [initial.id]);
|
|
1829
|
+
return { eventKey: updated.changes === 1 ? eventKey : retained.event_key, duplicate: updated.changes !== 1, disposition: updated.changes === 1 ? "admitted" : "in_flight", ...resolved };
|
|
1818
1830
|
});
|
|
1819
1831
|
}
|
|
1820
1832
|
/** Correlate exact native argv for diagnostics, independently of assignment
|
|
@@ -1888,19 +1900,38 @@ export function observedLifecycleInvocation(context, command, scopeConstraint, c
|
|
|
1888
1900
|
if (analysis.kind === "none" || analysis.supported === false)
|
|
1889
1901
|
return null;
|
|
1890
1902
|
const leafCommand = analysis.invocation.command;
|
|
1891
|
-
const
|
|
1903
|
+
const inlineId = commandOption(analysis.invocation, "invocation-id");
|
|
1904
|
+
if (inlineId && correlation?.id && inlineId !== correlation.id)
|
|
1905
|
+
throw new AppError("invocation_identity_mismatch", "Explicit lifecycle identifiers disagree", 1, { effect: "no_effect" });
|
|
1906
|
+
const suppliedId = correlation?.id ?? inlineId;
|
|
1892
1907
|
const retainedId = suppliedId && context.db.get("SELECT id FROM lifecycle_invocations WHERE id = ?", [suppliedId]) ? suppliedId : undefined;
|
|
1893
1908
|
// `issued` is a rendezvous, not authority: the CLI still blocks in
|
|
1894
1909
|
// awaitLifecycleInvocation until the native observer commits the receipt.
|
|
1895
|
-
const
|
|
1910
|
+
const retainedHistory = context.db.all("SELECT * FROM lifecycle_invocations ORDER BY rowid DESC");
|
|
1911
|
+
const history = retainedHistory.filter(row => ["issued", "observed", "executing", "settled"].includes(row.status));
|
|
1912
|
+
const inlineEvent = commandOption(analysis.invocation, "hook-event-id");
|
|
1913
|
+
if (inlineEvent && correlation?.eventKey && inlineEvent !== correlation.eventKey)
|
|
1914
|
+
throw new AppError("invocation_identity_mismatch", "Lifecycle event identifiers disagree", 1, { effect: "no_effect" });
|
|
1915
|
+
const eventKey = inlineEvent ?? correlation?.eventKey;
|
|
1916
|
+
if (correlation?.executionCwd !== undefined && !path.isAbsolute(correlation.executionCwd))
|
|
1917
|
+
throw new AppError("invocation_directory_mismatch", "Bound lifecycle execution cwd must be absolute", 1, { phase: "prepare", effect: "no_effect", recoverable: false });
|
|
1918
|
+
const identity = correlation?.identity;
|
|
1919
|
+
const identityJson = identity ? JSON.stringify([identity.daemonId, identity.rootSessionId, identity.sessionId, identity.parentSessionId, identity.toolCallId,
|
|
1920
|
+
...(correlation.executionCwd === undefined ? [] : [{ executionCwd: canonicalPath(correlation.executionCwd) }])]) : undefined;
|
|
1921
|
+
const nativeRows = identityJson ? retainedHistory.filter(row => row.identity_json && sameNativeCall(row.identity_json, identityJson)) : [];
|
|
1922
|
+
if (correlation?.native && nativeRows.some(row => row.identity_json !== identityJson))
|
|
1923
|
+
throw new AppError("invocation_identity_mismatch", "The same native tool call changed its bound execution route", 1, { phase: "prepare", effect: "no_effect", recoverable: false });
|
|
1924
|
+
const bindings = [retainedId, ...retainedHistory.filter(row => eventKey && row.event_key === eventKey).map(row => row.id), ...nativeRows.map(row => row.id)].filter((id) => Boolean(id));
|
|
1925
|
+
if (new Set(bindings).size > 1)
|
|
1926
|
+
throw new AppError("invocation_identity_mismatch", "Lifecycle identifiers refer to different retained attempts", 1, { phase: "prepare", effect: "no_effect", recoverable: false });
|
|
1927
|
+
const nativeBindingId = nativeRows[0]?.id;
|
|
1896
1928
|
const matching = history
|
|
1897
1929
|
.filter(row => {
|
|
1898
1930
|
try {
|
|
1899
|
-
const explicitId = retainedId;
|
|
1900
|
-
const eventKey = commandOption(analysis.invocation, "hook-event-id") ?? correlation?.eventKey;
|
|
1931
|
+
const explicitId = retainedId ?? nativeBindingId;
|
|
1901
1932
|
if (explicitId && row.id !== explicitId || eventKey && row.event_key !== eventKey)
|
|
1902
1933
|
return false;
|
|
1903
|
-
if (row.status === "settled" && (!row.outcome_json || JSON.parse(row.outcome_json).error) && !explicitId && !eventKey && !correlation?.identity)
|
|
1934
|
+
if (row.status === "settled" && (!row.outcome_json || JSON.parse(row.outcome_json).error && !JSON.parse(row.outcome_json).successor_invocation_id) && !explicitId && !eventKey && !correlation?.identity)
|
|
1904
1935
|
return false;
|
|
1905
1936
|
const scope = JSON.parse(row.scope_json);
|
|
1906
1937
|
const constraint = typeof scopeConstraint === "string" ? { daemonId: scopeConstraint } : scopeConstraint;
|
|
@@ -1912,8 +1943,9 @@ export function observedLifecycleInvocation(context, command, scopeConstraint, c
|
|
|
1912
1943
|
return false;
|
|
1913
1944
|
}
|
|
1914
1945
|
});
|
|
1915
|
-
|
|
1916
|
-
|
|
1946
|
+
if (nativeBindingId && !matching.length)
|
|
1947
|
+
throw new AppError("invocation_identity_mismatch", "The same native tool call changed its bound lifecycle command or scope", 1, { phase: "prepare", effect: "no_effect", recoverable: false, invocation_id: nativeBindingId });
|
|
1948
|
+
const matches = retainedId || eventKey ? matching : lifecycleCandidates(context, matching, identityJson, true);
|
|
1917
1949
|
if (!matches.length)
|
|
1918
1950
|
return null;
|
|
1919
1951
|
if (matches.length !== 1)
|
|
@@ -1924,7 +1956,7 @@ export function observedLifecycleInvocation(context, command, scopeConstraint, c
|
|
|
1924
1956
|
* Exact native delivery replays its immutable assignment. CLI lookup without
|
|
1925
1957
|
* native correlation may use a uniquely bound committed successor, not an
|
|
1926
1958
|
* arbitrary newest/active row. Unrelated history remains ambiguous. */
|
|
1927
|
-
function lifecycleCandidates(rows, identity, lookup = false
|
|
1959
|
+
function lifecycleCandidates(context, rows, identity, lookup = false) {
|
|
1928
1960
|
if (identity) {
|
|
1929
1961
|
const replay = rows.filter(row => {
|
|
1930
1962
|
if (!row.identity_json)
|
|
@@ -1934,8 +1966,8 @@ function lifecycleCandidates(rows, identity, lookup = false, history = rows) {
|
|
|
1934
1966
|
if (!lookup)
|
|
1935
1967
|
return false;
|
|
1936
1968
|
try {
|
|
1937
|
-
const retained = JSON.parse(row.identity_json);
|
|
1938
|
-
return Array.isArray(retained) && JSON.stringify(retained.slice(0, 5)) === identity;
|
|
1969
|
+
const retained = JSON.parse(row.identity_json), delivered = JSON.parse(identity);
|
|
1970
|
+
return Array.isArray(retained) && delivered.length === 5 && JSON.stringify(retained.slice(0, 5)) === identity;
|
|
1939
1971
|
}
|
|
1940
1972
|
catch {
|
|
1941
1973
|
return false;
|
|
@@ -1945,17 +1977,20 @@ function lifecycleCandidates(rows, identity, lookup = false, history = rows) {
|
|
|
1945
1977
|
return replay;
|
|
1946
1978
|
}
|
|
1947
1979
|
const active = rows.filter(row => row.status !== "settled");
|
|
1948
|
-
|
|
1949
|
-
|
|
1980
|
+
const terminal = rows.filter(row => row.status === "settled" && !JSON.parse(row.outcome_json ?? "null")?.successor_invocation_id);
|
|
1981
|
+
const current = active.length === 1 ? active[0] : !active.length && terminal.length === 1 ? terminal[0] : undefined;
|
|
1982
|
+
if (lookup && !current && !active.length && rows.some(row => JSON.parse(row.outcome_json ?? "null")?.successor_invocation_id)) {
|
|
1983
|
+
throw new AppError("invocation_assignment_unproven", "Retained lifecycle chain has no current successor", 1, { effect: "no_effect", reconciliation_required: true });
|
|
1984
|
+
}
|
|
1985
|
+
if (lookup && current && (current.status === "issued" || current.status === "settled" || current.identity_json && current.event_key)) {
|
|
1950
1986
|
const leadsToCurrent = (row, seen = new Set()) => {
|
|
1951
1987
|
if (row.id === current.id)
|
|
1952
|
-
return
|
|
1988
|
+
return row.status !== "issued" || row.deadline === null || row.deadline > Date.now();
|
|
1953
1989
|
if (row.status !== "settled" || seen.has(row.id))
|
|
1954
1990
|
return false;
|
|
1955
1991
|
seen.add(row.id);
|
|
1956
1992
|
try {
|
|
1957
|
-
const
|
|
1958
|
-
const successor = history.find(candidate => candidate.id === next && candidate.scope_json === current.scope_json);
|
|
1993
|
+
const successor = committedSuccessor(context, row, JSON.parse(row.outcome_json ?? "null"));
|
|
1959
1994
|
return Boolean(successor && leadsToCurrent(successor, seen));
|
|
1960
1995
|
}
|
|
1961
1996
|
catch {
|
|
@@ -1963,7 +1998,11 @@ function lifecycleCandidates(rows, identity, lookup = false, history = rows) {
|
|
|
1963
1998
|
}
|
|
1964
1999
|
};
|
|
1965
2000
|
if (rows.every(row => leadsToCurrent(row)))
|
|
1966
|
-
return
|
|
2001
|
+
return [current];
|
|
2002
|
+
// A declared but broken link cannot be repaired by picking an unrelated
|
|
2003
|
+
// active row. Existing unlinked history remains a native rendezvous only.
|
|
2004
|
+
if (rows.some(row => row.status === "settled" && JSON.parse(row.outcome_json ?? "null")?.successor_invocation_id))
|
|
2005
|
+
return rows;
|
|
1967
2006
|
}
|
|
1968
2007
|
return (!lookup || identity) && active.length ? active : rows;
|
|
1969
2008
|
}
|
|
@@ -2125,9 +2164,101 @@ function preparationInputKey(parsed, details) {
|
|
|
2125
2164
|
return undefined;
|
|
2126
2165
|
return crypto.createHash("sha256").update(JSON.stringify([parsed.operation, publicParsed.args.positional, options, consumed ?? null])).digest("hex");
|
|
2127
2166
|
}
|
|
2167
|
+
/** Historical registration is evidence, not an eternally actionable repair.
|
|
2168
|
+
* Follow only committed links; matching command text or an issued UUID alone
|
|
2169
|
+
* cannot retire a rejection. This reader never grants execution authority. */
|
|
2170
|
+
function lifecycleFailureAttemptCurrent(context, row, scope) {
|
|
2171
|
+
if (!scope.projectId)
|
|
2172
|
+
return true;
|
|
2173
|
+
const parsed = invocation(row.command), stage = commandOption(parsed, "stage");
|
|
2174
|
+
if (scope.stage && stage && stage !== scope.stage)
|
|
2175
|
+
return false;
|
|
2176
|
+
const currentStage = scope.stage ?? stage;
|
|
2177
|
+
if (!currentStage)
|
|
2178
|
+
return true;
|
|
2179
|
+
const hook = row.event_key ? context.db.get("SELECT outcome_json FROM hook_events WHERE event_key=? AND project_id=?", [row.event_key, scope.projectId]) : undefined;
|
|
2180
|
+
const outcome = hook?.outcome_json ? JSON.parse(hook.outcome_json) : null;
|
|
2181
|
+
const packet = context.db.get(`SELECT a.payload_json FROM audit_events a JOIN lifecycle_packet_assignments pa
|
|
2182
|
+
ON pa.packet_sha256=json_extract(a.payload_json,'$.packet_sha256') WHERE a.project_id=? AND a.event_type='lifecycle.coordinator_packet'
|
|
2183
|
+
AND pa.invocation_id=? ORDER BY a.id DESC LIMIT 1`, [scope.projectId, row.id]);
|
|
2184
|
+
const retainedAttempt = outcome?.attempt ?? (packet ? JSON.parse(packet.payload_json).stage_scope?.attempt : undefined);
|
|
2185
|
+
return !retainedAttempt || retainedAttempt === stageAttempt(context, scope.projectId, scope.runId, currentStage);
|
|
2186
|
+
}
|
|
2187
|
+
export function applicableLifecycleRejection(context, error, scope, correlation = {}) {
|
|
2188
|
+
let current = { ...error, details: error.details ?? {} };
|
|
2189
|
+
const hasInvocations = context.db.get("SELECT 1 FROM sqlite_master WHERE name='lifecycle_invocations'");
|
|
2190
|
+
const detailsId = typeof current.details.invocation_id === "string" ? current.details.invocation_id : undefined;
|
|
2191
|
+
const explicit = hasInvocations && correlation.invocationId ? context.db.get("SELECT * FROM lifecycle_invocations WHERE id=?", [correlation.invocationId]) : undefined;
|
|
2192
|
+
const event = hasInvocations && correlation.eventKey ? context.db.get("SELECT * FROM lifecycle_invocations WHERE event_key=?", [correlation.eventKey]) : undefined;
|
|
2193
|
+
if (explicit && event && explicit.id !== event.id)
|
|
2194
|
+
return current;
|
|
2195
|
+
let row = explicit ?? event;
|
|
2196
|
+
const detailsRow = hasInvocations && detailsId ? context.db.get("SELECT * FROM lifecycle_invocations WHERE id=?", [detailsId]) : undefined;
|
|
2197
|
+
if (row && detailsRow && row.id !== detailsRow.id)
|
|
2198
|
+
return { ...current, details: { ...current.details, invocation_id: row.id, reported_invocation_id: detailsRow.id } };
|
|
2199
|
+
// Error payloads are retained diagnostics, not authority over a newer hook.
|
|
2200
|
+
if (!correlation.invocationId && !correlation.eventKey)
|
|
2201
|
+
row = detailsRow;
|
|
2202
|
+
const root = scope.projectRoot ?? (scope.projectId ? context.db.get("SELECT root FROM projects WHERE id=?", [scope.projectId])?.root : undefined);
|
|
2203
|
+
if (row) {
|
|
2204
|
+
const retained = JSON.parse(row.scope_json);
|
|
2205
|
+
if (retained.runId !== scope.runId || retained.generation !== scope.generation || root && retained.projectRoot !== root)
|
|
2206
|
+
row = undefined;
|
|
2207
|
+
else if (!lifecycleFailureAttemptCurrent(context, row, scope))
|
|
2208
|
+
return null;
|
|
2209
|
+
}
|
|
2210
|
+
let pending = null;
|
|
2211
|
+
const seen = new Set();
|
|
2212
|
+
while (row && !seen.has(row.id)) {
|
|
2213
|
+
seen.add(row.id);
|
|
2214
|
+
const outcome = row.outcome_json ? JSON.parse(row.outcome_json) : null;
|
|
2215
|
+
const next = committedSuccessor(context, row, outcome);
|
|
2216
|
+
if (!next || seen.has(next.id))
|
|
2217
|
+
break;
|
|
2218
|
+
if (!lifecycleFailureAttemptCurrent(context, next, scope))
|
|
2219
|
+
break;
|
|
2220
|
+
pending = next;
|
|
2221
|
+
if (["observed", "executing"].includes(next.status))
|
|
2222
|
+
return null;
|
|
2223
|
+
if (next.status !== "settled")
|
|
2224
|
+
break;
|
|
2225
|
+
const successorOutcome = next.outcome_json ? JSON.parse(next.outcome_json) : null;
|
|
2226
|
+
if (successorOutcome?.error?.code)
|
|
2227
|
+
current = { code: successorOutcome.error.code, message: successorOutcome.error.message ?? "Lifecycle command failed", details: { ...(successorOutcome.error.details ?? {}), invocation_id: next.id } };
|
|
2228
|
+
else if (successorOutcome?.result) {
|
|
2229
|
+
const result = successorOutcome.result;
|
|
2230
|
+
const operation = invocation(next.command).operation;
|
|
2231
|
+
const accepted = ["accepted", "completed", "done", "resumed"].includes(result.outcome) || ["done", "completed"].includes(result.status) || result.stage_status === "done"
|
|
2232
|
+
|| operation === "work_start" && result.ok === true && typeof result.work_session_id === "string"
|
|
2233
|
+
|| operation === "stage_start" && result.ok === true && typeof result.next?.finish_command === "string"
|
|
2234
|
+
|| operation === "stage_resume" && result.resumed === true;
|
|
2235
|
+
if (accepted && !["blocked", "repair_required"].includes(result.outcome)
|
|
2236
|
+
|| result.outcome === "verification_required" && Array.isArray(result.receipts) && result.receipts.length && result.receipts.every((receipt) => receipt.status === "passed"))
|
|
2237
|
+
return null;
|
|
2238
|
+
}
|
|
2239
|
+
row = next;
|
|
2240
|
+
}
|
|
2241
|
+
const continuation = current.details.continuation;
|
|
2242
|
+
if (!isRegisteredRepair(context, continuation, scope.projectId ? { projectId: scope.projectId, runId: scope.runId } : undefined))
|
|
2243
|
+
return current;
|
|
2244
|
+
const repair = continuation;
|
|
2245
|
+
if (repair.run_id !== scope.runId || scope.stage && repair.stage !== scope.stage)
|
|
2246
|
+
return current;
|
|
2247
|
+
if (!stageWorkIds(context, repair.project_id, repair.run_id, repair.stage).has(repair.work_id))
|
|
2248
|
+
return current;
|
|
2249
|
+
const work = context.db.get("SELECT status FROM works WHERE project_id=? AND run_id=? AND work_id=?", [repair.project_id, repair.run_id, repair.work_id]);
|
|
2250
|
+
if (work && ["created", "ready", "running"].includes(work.status))
|
|
2251
|
+
return null;
|
|
2252
|
+
if (work?.status !== "completed")
|
|
2253
|
+
return current;
|
|
2254
|
+
if (pending?.status === "issued")
|
|
2255
|
+
return null;
|
|
2256
|
+
return { code: "invocation_continuation_unavailable", message: "Accepted repair has no proven current lifecycle continuation", details: { phase: "productive_continuation", effect: "no_effect", reconciliation_required: true,
|
|
2257
|
+
work_id: repair.work_id, stage: repair.stage, attempt: repair.attempt, ...(row ? { refusal_invocation_id: row.id } : {}), primary: { code: current.code, message: current.message } } };
|
|
2258
|
+
}
|
|
2128
2259
|
export function currentLifecycleFailure(context, input) {
|
|
2129
2260
|
if (input.turn !== undefined) {
|
|
2130
|
-
const hooks = context.db.all(`SELECT id, outcome_json FROM hook_events
|
|
2261
|
+
const hooks = context.db.all(`SELECT id, event_key, outcome_json FROM hook_events
|
|
2131
2262
|
WHERE json_extract(outcome_json, '$.run_id') = ? AND json_extract(outcome_json, '$.generation') = ?
|
|
2132
2263
|
AND json_extract(outcome_json, '$.stage') = ? AND json_extract(outcome_json, '$.controller_turn') = ?
|
|
2133
2264
|
${input.projectId ? "AND project_id = ?" : ""}
|
|
@@ -2144,41 +2275,33 @@ export function currentLifecycleFailure(context, input) {
|
|
|
2144
2275
|
}
|
|
2145
2276
|
if (activeAttempt && outcome.attempt && outcome.attempt !== activeAttempt)
|
|
2146
2277
|
continue;
|
|
2147
|
-
const
|
|
2278
|
+
const invocationId = context.db.get("SELECT 1 FROM sqlite_master WHERE name='lifecycle_invocations'")
|
|
2279
|
+
? context.db.get("SELECT id FROM lifecycle_invocations WHERE event_key=?", [hook.event_key])?.id : undefined;
|
|
2280
|
+
const key = invocationId ?? JSON.stringify([outcome.operation ?? "", outcome.session_id ?? null, outcome.correlation_key ?? outcome.fingerprint ?? hook.id]);
|
|
2148
2281
|
if (outcome.disposition === "completed") {
|
|
2149
2282
|
completed.add(key);
|
|
2150
2283
|
continue;
|
|
2151
2284
|
}
|
|
2152
2285
|
if (completed.has(key) || !outcome.error?.code)
|
|
2153
2286
|
continue;
|
|
2154
|
-
const
|
|
2155
|
-
if (
|
|
2156
|
-
|
|
2157
|
-
&& continuation.stage === input.stage
|
|
2158
|
-
&& isRegisteredRepair(context, continuation, input.projectId ? { projectId: input.projectId, runId: input.runId } : undefined))
|
|
2159
|
-
continue;
|
|
2160
|
-
return { code: outcome.error.code, message: outcome.error.message ?? "Lifecycle command failed", details: outcome.error.details ?? {}, hook_event_id: hook.id };
|
|
2287
|
+
const failure = applicableLifecycleRejection(context, { code: outcome.error.code, message: outcome.error.message ?? "Lifecycle command failed", details: outcome.error.details ?? {} }, input, { eventKey: hook.event_key });
|
|
2288
|
+
if (failure)
|
|
2289
|
+
return { ...failure, hook_event_id: hook.id };
|
|
2161
2290
|
}
|
|
2162
2291
|
}
|
|
2163
2292
|
const attempts = context.db.all("SELECT * FROM lifecycle_invocations ORDER BY updated_at DESC, rowid DESC");
|
|
2293
|
+
const projectRoot = input.projectRoot ?? (input.projectId ? context.db.get("SELECT root FROM projects WHERE id=?", [input.projectId])?.root : undefined);
|
|
2164
2294
|
const scoped = attempts.filter(row => {
|
|
2165
2295
|
try {
|
|
2166
2296
|
const scope = JSON.parse(row.scope_json);
|
|
2167
|
-
return scope.runId === input.runId && scope.generation === input.generation && (!
|
|
2297
|
+
return scope.runId === input.runId && scope.generation === input.generation && (!projectRoot || scope.projectRoot === projectRoot);
|
|
2168
2298
|
}
|
|
2169
2299
|
catch {
|
|
2170
2300
|
return false;
|
|
2171
2301
|
}
|
|
2172
2302
|
});
|
|
2173
|
-
const
|
|
2174
|
-
|
|
2175
|
-
const rows = scoped.filter(row => row.fingerprint === fingerprint);
|
|
2176
|
-
// An issued successor has not performed the correction. Once the Turn
|
|
2177
|
-
// ended it cannot erase the predecessor's actual rejection.
|
|
2178
|
-
if (rows.some(row => ["observed", "executing"].includes(row.status)))
|
|
2179
|
-
continue;
|
|
2180
|
-
const row = rows.find(candidate => candidate.status === "settled");
|
|
2181
|
-
if (!row)
|
|
2303
|
+
for (const row of scoped) {
|
|
2304
|
+
if (row.status !== "settled")
|
|
2182
2305
|
continue;
|
|
2183
2306
|
let outcome;
|
|
2184
2307
|
try {
|
|
@@ -2207,13 +2330,9 @@ export function currentLifecycleFailure(context, input) {
|
|
|
2207
2330
|
}
|
|
2208
2331
|
if (stage !== input.stage)
|
|
2209
2332
|
continue;
|
|
2210
|
-
const
|
|
2211
|
-
if (
|
|
2212
|
-
|
|
2213
|
-
&& continuation.stage === input.stage
|
|
2214
|
-
&& isRegisteredRepair(context, continuation, input.projectId ? { projectId: input.projectId, runId: input.runId } : undefined))
|
|
2215
|
-
continue;
|
|
2216
|
-
return { code: outcome.error.code, message: outcome.error.message, details: outcome.error.details ?? {}, invocation_id: row.id };
|
|
2333
|
+
const failure = applicableLifecycleRejection(context, { code: outcome.error.code, message: outcome.error.message, details: outcome.error.details ?? {} }, input, { invocationId: row.id });
|
|
2334
|
+
if (failure)
|
|
2335
|
+
return { ...failure, invocation_id: typeof failure.details.invocation_id === "string" ? failure.details.invocation_id : row.id };
|
|
2217
2336
|
}
|
|
2218
2337
|
return null;
|
|
2219
2338
|
}
|
|
@@ -85,6 +85,14 @@ export function retainControllerContinuation(state, fingerprint, error) {
|
|
|
85
85
|
throw new AppError(error.code, error.message, 1, error.details);
|
|
86
86
|
state.last_continuation = fingerprint;
|
|
87
87
|
}
|
|
88
|
+
/** Keep a bounded diagnostic projection, not the whole failing prompt/receipt. */
|
|
89
|
+
export function controllerDependencyDiagnostic(recorded, protectedAction) {
|
|
90
|
+
const details = recorded.details && typeof recorded.details === "object" ? recorded.details : {};
|
|
91
|
+
return { primary: { code: String(recorded.code), message: String(recorded.message) }, protected_action: protectedAction,
|
|
92
|
+
...Object.fromEntries(["reason", "work_id", "operation_id", "refusal_invocation_id", "successor_invocation_id", "stage", "attempt", "action", "invocation_id"].filter(key => typeof details[key] === "string").map(key => [key, details[key]])),
|
|
93
|
+
...(details.checkpoint && typeof details.checkpoint === "object" ? { checkpoint: details.checkpoint } : {}),
|
|
94
|
+
...(details.authority && typeof details.authority === "object" ? { authority: details.authority } : {}) };
|
|
95
|
+
}
|
|
88
96
|
export function digest(value) { return digestBytes(JSON.stringify(value)); }
|
|
89
97
|
export function digestBytes(value) { return crypto.createHash("sha256").update(value).digest("hex"); }
|
|
90
98
|
export function errorRecord(error) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { assertLifecycleOutcomes, currentLifecycleFailure, managedInvocationContext, managedLifecycleCommand, awaitLifecycleInvocation, assertLifecycleInvocationCurrent, bindControllerStageAssignment } from "./lifecycle-invocations.js";
|
|
1
|
+
import { assertLifecycleOutcomes, currentLifecycleFailure, currentStageFinishAuthority, managedInvocationContext, managedLifecycleCommand, awaitLifecycleInvocation, assertLifecycleInvocationCurrent, bindControllerStageAssignment } from "./lifecycle-invocations.js";
|
|
2
|
+
import { stageAttempt } from "./stage-work-graph.js";
|
|
2
3
|
import { isSourceSnapshotFlow } from "../domain/source-snapshot-flows.js";
|
|
3
4
|
import { isDeliveryFlow } from "../domain/delivery-flows.js";
|
|
4
5
|
export function recoveryPacketMatchesStage(packet, scope) {
|
|
@@ -36,12 +37,12 @@ import { readNativeDaemonState, requireManagedDaemonBinding, managedDaemonExecut
|
|
|
36
37
|
import { initCodexHome } from "./hooks.js";
|
|
37
38
|
import { launchExternalWork } from "./external-work-launch.js";
|
|
38
39
|
import { runControllerMemoryTask } from "./controller-memory-task.js";
|
|
39
|
-
import { controllerNativeChildren, controllerStageEntryPrompt, controllerStageResumePrompt, nextControllerFanout } from "./controller-fanout.js";
|
|
40
|
+
import { controllerNativeChildren, controllerSemanticCheckpoint, controllerStageEntryPrompt, controllerStageResumePrompt, nextControllerFanout } from "./controller-fanout.js";
|
|
40
41
|
import { publishPromptDelivery } from "./prompt-delivery.js";
|
|
41
42
|
import { publishStagePacket } from "./stage-report-renderer.js";
|
|
42
43
|
import { claimQueuedMergeRequest } from "./merge-server.js";
|
|
43
44
|
import { prepareVnextCodeWorkspace } from "./vnext-code.js";
|
|
44
|
-
import { requireRun, requireController, controllerReceipt, appendEvent, saveState, retainControllerContinuation, retainControllerCapacityIntent, digest, digestBytes, errorRecord } from "./run-controller-state.js";
|
|
45
|
+
import { requireRun, requireController, controllerReceipt, controllerDependencyDiagnostic, appendEvent, saveState, retainControllerContinuation, retainControllerCapacityIntent, digest, digestBytes, errorRecord } from "./run-controller-state.js";
|
|
45
46
|
import { assertNoCorrelatedRejection } from "./continuation-outcome.js";
|
|
46
47
|
import { spawnController, recordControllerLauncher, claimControllerProcess, markControllerDispatchReady, assertControllerLeaseAllowed } from "./run-controller-process.js";
|
|
47
48
|
import { controllerAdapter, controllerEnvironment, sessionArguments } from "./run-controller-adapter.js";
|
|
@@ -684,11 +685,9 @@ async function executeController(context, row, manifest, state, assertOwnership,
|
|
|
684
685
|
// require that same failure to disappear before the owner can pause it.
|
|
685
686
|
assertPhysicalOwner();
|
|
686
687
|
const recorded = cause ? errorRecord(cause) : { code: "native_writer_dependency_blocked", message: "Conflicting native execution has not settled" };
|
|
687
|
-
const
|
|
688
|
+
const dependency = controllerDependencyDiagnostic(recorded, protectedAction);
|
|
689
|
+
const primary = dependency.primary;
|
|
688
690
|
const details = cause instanceof AppError ? cause.details : {};
|
|
689
|
-
const dependency = { primary, protected_action: protectedAction,
|
|
690
|
-
...(typeof details.reason === "string" ? { reason: details.reason } : {}),
|
|
691
|
-
...Object.fromEntries(["work_id", "operation_id", "refusal_invocation_id", "successor_invocation_id"].filter(key => typeof details[key] === "string").map(key => [key, details[key]])) };
|
|
692
691
|
recordTechnicalNote(context, row.project_id, row.run_id, { code: primary.code, message: primary.message,
|
|
693
692
|
phase: protectedAction, generation: row.generation, ...(state.stage ? { stage: state.stage } : {}),
|
|
694
693
|
...(typeof details.work_id === "string" ? { work_id: details.work_id } : {}),
|
|
@@ -1641,8 +1640,33 @@ async function executeController(context, row, manifest, state, assertOwnership,
|
|
|
1641
1640
|
saveState(context, row, state);
|
|
1642
1641
|
continue;
|
|
1643
1642
|
}
|
|
1644
|
-
if (state.last_continuation === fanout.fingerprint)
|
|
1645
|
-
|
|
1643
|
+
if (state.last_continuation === fanout.fingerprint) {
|
|
1644
|
+
// Re-read committed evidence before pausing; a settlement can race the
|
|
1645
|
+
// graph observation. No new prompt or invocation is issued to reconcile.
|
|
1646
|
+
const latest = executionContext.db.get("SELECT status,index_json FROM runs WHERE project_id=? AND id=?", [row.project_id, row.run_id]);
|
|
1647
|
+
const latestStage = latest && [...(JSON.parse(latest.index_json).stage_runs ?? [])].reverse().find((stage) => stage.stage === next.stage);
|
|
1648
|
+
if (latest?.status === "done" || latestStage?.status === "done")
|
|
1649
|
+
continue;
|
|
1650
|
+
if (fanout.authority) {
|
|
1651
|
+
const current = currentStageFinishAuthority(executionContext, next.stage);
|
|
1652
|
+
if (current?.status === "issued" && current.command) {
|
|
1653
|
+
const checkpoint = controllerSemanticCheckpoint(executionContext, { stage: next.stage, phase: "stage_finish", command: current.command, invocationId: current.id });
|
|
1654
|
+
if (JSON.stringify(checkpoint) !== JSON.stringify(fanout.checkpoint))
|
|
1655
|
+
continue;
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
let rejected;
|
|
1659
|
+
try {
|
|
1660
|
+
assertNoCorrelatedRejection(context, { projectId: row.project_id, runId: row.run_id, generation: row.generation, stage: next.stage, turn: state.turns });
|
|
1661
|
+
}
|
|
1662
|
+
catch (error) {
|
|
1663
|
+
if (!(error instanceof AppError))
|
|
1664
|
+
throw error;
|
|
1665
|
+
rejected = error;
|
|
1666
|
+
}
|
|
1667
|
+
await pauseDependency(session, undefined, new AppError(rejected?.code ?? "fanout_stage_nonprogressing", rejected?.message ?? "The coordinator repeated an unchanged continuation", 1, { ...rejected?.details, stage: next.stage, attempt: stageAttempt(executionContext, row.project_id, row.run_id, next.stage), action: fanout.action,
|
|
1668
|
+
checkpoint: JSON.parse(fanout.fingerprint), ...(fanout.authority ? { authority: fanout.authority } : {}), reconciliation_required: true }), "productive_continuation");
|
|
1669
|
+
}
|
|
1646
1670
|
retainControllerContinuation(state, fanout.fingerprint, { code: "fanout_stage_nonprogressing", message: "The coordinator repeated an unchanged Work-graph continuation", details: { stage: next.stage, action: fanout.action, before: state.last_continuation, after: fanout.fingerprint, controller_turn: state.turns } });
|
|
1647
1671
|
saveState(context, row, state);
|
|
1648
1672
|
await prompt(session, fanout.prompt, undefined, undefined, fanout.action === "materialize_graph" ? "graph" : fanout.action === "dispatch_work" ? "work" : "finish", fanout.work_ids);
|