@deksden-com/dd-flow-cli 0.9.0-beta.156 → 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 +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 +24 -7
- package/dist/harness-runtime/lib/daemon-operations.mjs +46 -11
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +19 -6
- package/dist/harness-runtime/lib/dd-agy.mjs +19 -0
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +1 -1
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +1 -1
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +2 -2
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +1 -1
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +1 -1
- package/dist/harness-runtime/lib/delegation-instructions.d.mts +1 -0
- package/dist/harness-runtime/lib/delegation-instructions.mjs +3 -0
- package/dist/schemas/code-work-batch.schema.json +1 -1
- package/dist/schemas/vnext-protocol-plan.schema.json +1 -1
- package/dist/services/codex-capacity.js +367 -0
- package/dist/services/continuation-outcome.js +11 -6
- package/dist/services/controller-child-repair.js +46 -24
- package/dist/services/controller-fanout.js +62 -21
- package/dist/services/controller-result-authority.js +13 -8
- package/dist/services/controller-work-failure.js +198 -0
- package/dist/services/lifecycle-invocations.js +280 -96
- package/dist/services/managed-daemon-binding.js +40 -0
- package/dist/services/run-control.js +18 -36
- package/dist/services/run-controller-state.js +8 -0
- package/dist/services/run-controller.js +136 -27
- package/dist/services/vnext-code.js +3 -1
- package/dist/services/vnext-plan-review.js +2 -2
- package/dist/services/vnext-plan.js +3 -3
- package/dist/services/work-registry.js +59 -12
- package/dist/services/work-retry-context.js +55 -0
- package/dist/verification-components.json +3 -0
- package/package.json +1 -1
|
@@ -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
|
|
@@ -600,6 +605,9 @@ export function observeLifecycleCommand(context, command, nativeEvent) {
|
|
|
600
605
|
}
|
|
601
606
|
if (error && parsed.kind === "standalone" && hookOnlyCorrection && error.details.recoverable !== false) {
|
|
602
607
|
const signature = preparationRejectionSignature(new AppError(error.code, error.message, 1, error.details));
|
|
608
|
+
const inputKey = preparationInputKey(parsed.invocation, error.details);
|
|
609
|
+
if (inputKey)
|
|
610
|
+
error.details.input_key = inputKey;
|
|
603
611
|
error.details.hook_retry_signature = signature;
|
|
604
612
|
const repeated = context.db.get(`SELECT 1 FROM hook_events WHERE project_id = ? AND id <> ?
|
|
605
613
|
AND json_extract(outcome_json, '$.run_id') = ? AND json_extract(outcome_json, '$.generation') = ?
|
|
@@ -607,8 +615,9 @@ export function observeLifecycleCommand(context, command, nativeEvent) {
|
|
|
607
615
|
AND json_extract(outcome_json, '$.harness') = ? AND json_extract(outcome_json, '$.session_id') = ?
|
|
608
616
|
AND json_type(outcome_json, '$.stage') IS NOT NULL AND json_type(outcome_json, '$.attempt') IS NOT NULL
|
|
609
617
|
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
|
-
|
|
618
|
+
AND json_extract(outcome_json, '$.error.details.hook_retry_signature') = ?
|
|
619
|
+
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,
|
|
620
|
+
anchor.harness, anchor.session_id, anchor.stage ?? null, anchor.attempt ?? null, signature, inputKey ?? null, inputKey ?? null]);
|
|
612
621
|
if (!repeated) {
|
|
613
622
|
const argv = parsed.invocation.argv.filter((value, index, all) => !/^--(?:hook-event-id|invocation-id)(?:=|$)/.test(value)
|
|
614
623
|
&& !/^--(?:hook-event-id|invocation-id)$/.test(all[index - 1] ?? ""));
|
|
@@ -619,8 +628,15 @@ export function observeLifecycleCommand(context, command, nativeEvent) {
|
|
|
619
628
|
}
|
|
620
629
|
else {
|
|
621
630
|
error.details.recoverable = false;
|
|
622
|
-
error.details.
|
|
623
|
-
|
|
631
|
+
delete error.details.retry_command;
|
|
632
|
+
delete error.details.retry_instruction;
|
|
633
|
+
if (inputKey)
|
|
634
|
+
error.details.repeated_without_progress = true;
|
|
635
|
+
else
|
|
636
|
+
error.details.input_binding_unavailable = true;
|
|
637
|
+
// An unread file is not evidence of unchanged contents. Withhold
|
|
638
|
+
// automatic correction locally; do not turn that ignorance into fatal.
|
|
639
|
+
record.disposition = inputKey ? "fatal" : "pending_continuation";
|
|
624
640
|
}
|
|
625
641
|
}
|
|
626
642
|
if (error && !auxiliary && record.disposition === "fatal") {
|
|
@@ -749,7 +765,7 @@ function currentIssuedCommand(context, scope, command, excludeId) {
|
|
|
749
765
|
function scopedCommandFingerprint(context, scope, command) {
|
|
750
766
|
return fingerprint(invocation(canonicalManagedCommand(context, command, JSON.parse(scope))));
|
|
751
767
|
}
|
|
752
|
-
function committedSuccessor(context, prior, outcome) {
|
|
768
|
+
export function committedSuccessor(context, prior, outcome) {
|
|
753
769
|
if (typeof outcome?.successor_invocation_id !== "string" || outcome.successor_invocation_id === prior.id)
|
|
754
770
|
return null;
|
|
755
771
|
const command = outcome.error?.details?.retry_command ?? outcome.result?.next?.finish_command;
|
|
@@ -1597,33 +1613,35 @@ export function observeLifecycleInvocation(context, input) {
|
|
|
1597
1613
|
function observeLifecycleInvocationReceipt(context, input) {
|
|
1598
1614
|
storage(context);
|
|
1599
1615
|
const delivered = parseLifecycleCommand(input.command);
|
|
1600
|
-
if (delivered.kind === "standalone") {
|
|
1601
|
-
const id = commandOption(delivered.invocation, "invocation-id");
|
|
1602
|
-
const direct = context.db.all("SELECT * FROM lifecycle_invocations WHERE admission_json IS NOT NULL AND status IN ('executing','settled')")
|
|
1603
|
-
.filter(row => {
|
|
1604
|
-
const scope = JSON.parse(row.scope_json);
|
|
1605
|
-
if (id && row.id !== id || scope.daemonId !== input.identity.daemonId || scope.rootSessionId !== input.identity.rootSessionId)
|
|
1606
|
-
return false;
|
|
1607
|
-
// A fresh native call must bind the explicitly committed successor,
|
|
1608
|
-
// not short-circuit to its predecessor's successful repair response.
|
|
1609
|
-
// Exact delivery/explicit-ID replay remains historical read-only.
|
|
1610
|
-
const successor = row.status === "settled" && row.outcome_json ? committedSuccessor(context, row, JSON.parse(row.outcome_json)) : null;
|
|
1611
|
-
if (!id && row.identity_json && successor && ["issued", "observed"].includes(successor.status)
|
|
1612
|
-
&& !sameNativeCall(row.identity_json, JSON.stringify([input.identity.daemonId, input.identity.rootSessionId, input.identity.sessionId, input.identity.parentSessionId, input.identity.toolCallId])))
|
|
1613
|
-
return false;
|
|
1614
|
-
try {
|
|
1615
|
-
return fingerprint(invocation(canonicalManagedCommand(context, delivered.invocation.command, scope))) === row.fingerprint;
|
|
1616
|
-
}
|
|
1617
|
-
catch {
|
|
1618
|
-
return false;
|
|
1619
|
-
}
|
|
1620
|
-
});
|
|
1621
|
-
if (direct.length === 1)
|
|
1622
|
-
return { eventKey: direct[0].event_key, duplicate: true,
|
|
1623
|
-
disposition: direct[0].status === "settled" ? "settled_read" : "in_flight", invocationId: direct[0].id };
|
|
1624
|
-
}
|
|
1625
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 };
|
|
1626
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
|
+
}
|
|
1627
1645
|
const owner = provenScope && input.harness && input.projectId ? managedNativeOwner(context, input.identity, input.harness, input.projectId) : null;
|
|
1628
1646
|
const ownerSnapshot = () => {
|
|
1629
1647
|
if (!provenScope?.runId || !input.projectId)
|
|
@@ -1645,7 +1663,8 @@ function observeLifecycleInvocationReceipt(context, input) {
|
|
|
1645
1663
|
return JSON.stringify(rows);
|
|
1646
1664
|
};
|
|
1647
1665
|
const retainedOwner = ownerSnapshot();
|
|
1648
|
-
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;
|
|
1649
1668
|
const analysis = parseLifecycleCommand(input.command);
|
|
1650
1669
|
if (analysis.kind === "none" || analysis.supported === false) {
|
|
1651
1670
|
let issuedBarrier;
|
|
@@ -1724,11 +1743,11 @@ function observeLifecycleInvocationReceipt(context, input) {
|
|
|
1724
1743
|
return false;
|
|
1725
1744
|
return true;
|
|
1726
1745
|
});
|
|
1727
|
-
const candidates = lifecycleCandidates(matching, deliveredIdentity);
|
|
1746
|
+
const candidates = lifecycleCandidates(context, matching, deliveredIdentity);
|
|
1728
1747
|
// A known explicit ID remains a compatibility path. A missing or mistyped
|
|
1729
1748
|
// public ID is corrected only by an exact operation/target match inside the
|
|
1730
1749
|
// trusted native root. Never fuzzy-match UUID text or select a global latest.
|
|
1731
|
-
const initial = supplied ?? (candidates.length === 1 ? candidates[0] : undefined);
|
|
1750
|
+
const initial = supplied ?? selectedRow ?? (candidates.length === 1 ? candidates[0] : undefined);
|
|
1732
1751
|
// A live observation arriving after direct admission is optional correlation,
|
|
1733
1752
|
// never a second authority or an executing -> observed transition.
|
|
1734
1753
|
if (initial?.admission_json && ["executing", "settled"].includes(initial.status))
|
|
@@ -1771,6 +1790,9 @@ function observeLifecycleInvocationReceipt(context, input) {
|
|
|
1771
1790
|
const row = context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [initial.id]);
|
|
1772
1791
|
if (retainedOwner !== ownerSnapshot())
|
|
1773
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 });
|
|
1774
1796
|
if (row.scope_json !== initial.scope_json)
|
|
1775
1797
|
throw new AppError("invocation_scope_mismatch", "Lifecycle scope changed before native receipt", 1, { effect: "no_effect" });
|
|
1776
1798
|
if (row.status === "settled")
|
|
@@ -1800,10 +1822,11 @@ function observeLifecycleInvocationReceipt(context, input) {
|
|
|
1800
1822
|
// its workdir is invalid), after which repeating the same issued command
|
|
1801
1823
|
// is safe. Keep the newest receipt until the CLI claims the attempt. The
|
|
1802
1824
|
// observed -> executing compare-and-swap remains the exactly-once boundary.
|
|
1803
|
-
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]);
|
|
1804
1826
|
// Once execution has been claimed, later PreToolUse observations are
|
|
1805
1827
|
// retained as hook evidence but cannot replace the executor's receipt.
|
|
1806
|
-
|
|
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 };
|
|
1807
1830
|
});
|
|
1808
1831
|
}
|
|
1809
1832
|
/** Correlate exact native argv for diagnostics, independently of assignment
|
|
@@ -1877,19 +1900,38 @@ export function observedLifecycleInvocation(context, command, scopeConstraint, c
|
|
|
1877
1900
|
if (analysis.kind === "none" || analysis.supported === false)
|
|
1878
1901
|
return null;
|
|
1879
1902
|
const leafCommand = analysis.invocation.command;
|
|
1880
|
-
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;
|
|
1881
1907
|
const retainedId = suppliedId && context.db.get("SELECT id FROM lifecycle_invocations WHERE id = ?", [suppliedId]) ? suppliedId : undefined;
|
|
1882
1908
|
// `issued` is a rendezvous, not authority: the CLI still blocks in
|
|
1883
1909
|
// awaitLifecycleInvocation until the native observer commits the receipt.
|
|
1884
|
-
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;
|
|
1885
1928
|
const matching = history
|
|
1886
1929
|
.filter(row => {
|
|
1887
1930
|
try {
|
|
1888
|
-
const explicitId = retainedId;
|
|
1889
|
-
const eventKey = commandOption(analysis.invocation, "hook-event-id") ?? correlation?.eventKey;
|
|
1931
|
+
const explicitId = retainedId ?? nativeBindingId;
|
|
1890
1932
|
if (explicitId && row.id !== explicitId || eventKey && row.event_key !== eventKey)
|
|
1891
1933
|
return false;
|
|
1892
|
-
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)
|
|
1893
1935
|
return false;
|
|
1894
1936
|
const scope = JSON.parse(row.scope_json);
|
|
1895
1937
|
const constraint = typeof scopeConstraint === "string" ? { daemonId: scopeConstraint } : scopeConstraint;
|
|
@@ -1901,8 +1943,9 @@ export function observedLifecycleInvocation(context, command, scopeConstraint, c
|
|
|
1901
1943
|
return false;
|
|
1902
1944
|
}
|
|
1903
1945
|
});
|
|
1904
|
-
|
|
1905
|
-
|
|
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);
|
|
1906
1949
|
if (!matches.length)
|
|
1907
1950
|
return null;
|
|
1908
1951
|
if (matches.length !== 1)
|
|
@@ -1913,7 +1956,7 @@ export function observedLifecycleInvocation(context, command, scopeConstraint, c
|
|
|
1913
1956
|
* Exact native delivery replays its immutable assignment. CLI lookup without
|
|
1914
1957
|
* native correlation may use a uniquely bound committed successor, not an
|
|
1915
1958
|
* arbitrary newest/active row. Unrelated history remains ambiguous. */
|
|
1916
|
-
function lifecycleCandidates(rows, identity, lookup = false
|
|
1959
|
+
function lifecycleCandidates(context, rows, identity, lookup = false) {
|
|
1917
1960
|
if (identity) {
|
|
1918
1961
|
const replay = rows.filter(row => {
|
|
1919
1962
|
if (!row.identity_json)
|
|
@@ -1923,8 +1966,8 @@ function lifecycleCandidates(rows, identity, lookup = false, history = rows) {
|
|
|
1923
1966
|
if (!lookup)
|
|
1924
1967
|
return false;
|
|
1925
1968
|
try {
|
|
1926
|
-
const retained = JSON.parse(row.identity_json);
|
|
1927
|
-
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;
|
|
1928
1971
|
}
|
|
1929
1972
|
catch {
|
|
1930
1973
|
return false;
|
|
@@ -1934,17 +1977,20 @@ function lifecycleCandidates(rows, identity, lookup = false, history = rows) {
|
|
|
1934
1977
|
return replay;
|
|
1935
1978
|
}
|
|
1936
1979
|
const active = rows.filter(row => row.status !== "settled");
|
|
1937
|
-
|
|
1938
|
-
|
|
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)) {
|
|
1939
1986
|
const leadsToCurrent = (row, seen = new Set()) => {
|
|
1940
1987
|
if (row.id === current.id)
|
|
1941
|
-
return
|
|
1988
|
+
return row.status !== "issued" || row.deadline === null || row.deadline > Date.now();
|
|
1942
1989
|
if (row.status !== "settled" || seen.has(row.id))
|
|
1943
1990
|
return false;
|
|
1944
1991
|
seen.add(row.id);
|
|
1945
1992
|
try {
|
|
1946
|
-
const
|
|
1947
|
-
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"));
|
|
1948
1994
|
return Boolean(successor && leadsToCurrent(successor, seen));
|
|
1949
1995
|
}
|
|
1950
1996
|
catch {
|
|
@@ -1952,7 +1998,11 @@ function lifecycleCandidates(rows, identity, lookup = false, history = rows) {
|
|
|
1952
1998
|
}
|
|
1953
1999
|
};
|
|
1954
2000
|
if (rows.every(row => leadsToCurrent(row)))
|
|
1955
|
-
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;
|
|
1956
2006
|
}
|
|
1957
2007
|
return (!lookup || identity) && active.length ? active : rows;
|
|
1958
2008
|
}
|
|
@@ -1988,6 +2038,8 @@ export function resolveObservedLifecycleArgs(context, id, actualArgs) {
|
|
|
1988
2038
|
const inline = argv.findIndex(value => value.startsWith(`--${publicKey}=`));
|
|
1989
2039
|
if (inline >= 0)
|
|
1990
2040
|
argv[inline] = `--${publicKey}=${commandOption(actual, publicKey)}`;
|
|
2041
|
+
else
|
|
2042
|
+
argv.push(`--${publicKey}`, commandOption(actual, publicKey));
|
|
1991
2043
|
}
|
|
1992
2044
|
}
|
|
1993
2045
|
return argv;
|
|
@@ -1998,24 +2050,47 @@ export function retainObservedLifecycleSemantics(context, id, argv) {
|
|
|
1998
2050
|
const row = context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [id]);
|
|
1999
2051
|
if (!row || row.status !== "executing")
|
|
2000
2052
|
throw new AppError("invocation_settlement_conflict", "Lifecycle attempt is not executing", 1, { invocation_id: id });
|
|
2053
|
+
const command = commandWithObservedWorkSemantics(row, argv);
|
|
2054
|
+
if (command === row.command)
|
|
2055
|
+
return;
|
|
2056
|
+
if (context.db.run("UPDATE lifecycle_invocations SET command = ?, updated_at = ? WHERE id = ? AND status = 'executing'", [command, context.now(), id]).changes !== 1) {
|
|
2057
|
+
throw new AppError("invocation_settlement_conflict", "Lifecycle attempt changed before its public input was retained", 1, { invocation_id: id });
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
function commandWithObservedWorkSemantics(row, argv) {
|
|
2001
2061
|
const parsed = invocation(row.command);
|
|
2002
2062
|
if (!["work_fail", "work_finish"].includes(parsed.operation))
|
|
2003
|
-
return;
|
|
2063
|
+
return row.command;
|
|
2004
2064
|
const prefix = Object.entries(parsed.env).map(([key, value]) => `${key}=${quote([value])}`).join(" ");
|
|
2005
2065
|
const executable = launchExpression(parsed);
|
|
2006
2066
|
const suffix = row.command.slice(shellSuffixOffset(row.command)).trimStart();
|
|
2007
|
-
|
|
2067
|
+
// CLI dispatch removes presentation options. Update only the public semantic
|
|
2068
|
+
// value, retaining the issued transport, response destination and shell suffix.
|
|
2069
|
+
const actual = invocation(`${executable} ${quote(argv)}`);
|
|
2070
|
+
const semanticKey = parsed.operation === "work_fail" ? "reason" : "result-file";
|
|
2071
|
+
const retainedArgs = [...parsed.argv];
|
|
2072
|
+
const value = commandOption(actual, semanticKey);
|
|
2073
|
+
if (value !== undefined) {
|
|
2074
|
+
const index = retainedArgs.findIndex(arg => arg === `--${semanticKey}` || arg.startsWith(`--${semanticKey}=`));
|
|
2075
|
+
if (index >= 0) {
|
|
2076
|
+
if (retainedArgs[index] === `--${semanticKey}`)
|
|
2077
|
+
retainedArgs[index + 1] = value;
|
|
2078
|
+
else
|
|
2079
|
+
retainedArgs[index] = `--${semanticKey}=${value}`;
|
|
2080
|
+
}
|
|
2081
|
+
else
|
|
2082
|
+
retainedArgs.push(`--${semanticKey}`, value);
|
|
2083
|
+
}
|
|
2084
|
+
const command = `${prefix ? `${prefix} ` : ""}${executable} ${quote(retainedArgs)}${suffix ? ` ${suffix}` : ""}`;
|
|
2008
2085
|
checkCommand(row, command);
|
|
2009
|
-
|
|
2010
|
-
throw new AppError("invocation_settlement_conflict", "Lifecycle attempt changed before its public input was retained", 1, { invocation_id: id });
|
|
2011
|
-
}
|
|
2086
|
+
return command;
|
|
2012
2087
|
}
|
|
2013
|
-
export function settleLifecyclePreparationRejection(context, id, error) {
|
|
2088
|
+
export function settleLifecyclePreparationRejection(context, id, error, preparedCommand) {
|
|
2014
2089
|
delete error.details.retry_command;
|
|
2015
2090
|
delete error.details.retry_instruction;
|
|
2016
2091
|
try {
|
|
2017
2092
|
return context.db.writeTransaction(() => {
|
|
2018
|
-
|
|
2093
|
+
let row = load(context, id);
|
|
2019
2094
|
if (row.status === "settled") {
|
|
2020
2095
|
const details = row.outcome_json ? JSON.parse(row.outcome_json)?.error?.details : undefined;
|
|
2021
2096
|
return details?.effect === "no_effect" && details.retry_command ? details : { effect: "unknown", recoverable: false };
|
|
@@ -2025,22 +2100,41 @@ export function settleLifecyclePreparationRejection(context, id, error) {
|
|
|
2025
2100
|
const scope = JSON.parse(row.scope_json);
|
|
2026
2101
|
if (scope.runId)
|
|
2027
2102
|
assertLifecycleInvocationCurrent(context, scope, row.command);
|
|
2103
|
+
if (preparedCommand) {
|
|
2104
|
+
// Only a command already validated before preparation may retain public
|
|
2105
|
+
// Work inputs. This records a no-effect refusal, not admission to execute.
|
|
2106
|
+
checkCommand(row, preparedCommand);
|
|
2107
|
+
const command = commandWithObservedWorkSemantics(row, invocation(preparedCommand).argv);
|
|
2108
|
+
if (command !== row.command)
|
|
2109
|
+
context.db.run("UPDATE lifecycle_invocations SET command = ?, updated_at = ? WHERE id = ?", [command, context.now(), row.id]);
|
|
2110
|
+
row = { ...row, command };
|
|
2111
|
+
}
|
|
2112
|
+
const parsed = invocation(row.command);
|
|
2113
|
+
if (parsed.operation === "work_finish" && !error.details.work_id) {
|
|
2114
|
+
try {
|
|
2115
|
+
error.details.work_id = resolveWorkReference(context, commandPosition(parsed, 0) ?? "", { ...(scope.runId ? { runId: scope.runId } : {}) }).work_id;
|
|
2116
|
+
}
|
|
2117
|
+
catch { /* An unresolved target remains its original local preparation error. */ }
|
|
2118
|
+
}
|
|
2028
2119
|
const signature = preparationRejectionSignature(error);
|
|
2120
|
+
const inputKey = preparationInputKey(parsed, error.details);
|
|
2029
2121
|
const repeated = context.db.all("SELECT outcome_json FROM lifecycle_invocations WHERE scope_json = ? AND fingerprint = ? AND id <> ? AND status = 'settled'", [row.scope_json, row.fingerprint, row.id])
|
|
2030
2122
|
.some(prior => { try {
|
|
2031
|
-
|
|
2123
|
+
const details = JSON.parse(prior.outcome_json ?? "null")?.error?.details;
|
|
2124
|
+
return details?.rejection_signature === signature && (!inputKey || details.input_key === inputKey);
|
|
2032
2125
|
}
|
|
2033
2126
|
catch {
|
|
2034
2127
|
return false;
|
|
2035
2128
|
} });
|
|
2036
2129
|
const retryable = !repeated && isRetryableLifecycleRejection(error.code, error.details);
|
|
2037
|
-
const details = { ...error.details, effect: "no_effect", recoverable: retryable, rejection_signature: signature,
|
|
2130
|
+
const details = { ...error.details, effect: "no_effect", recoverable: retryable, rejection_signature: signature, ...(inputKey ? { input_key: inputKey } : {}),
|
|
2038
2131
|
...(repeated ? { repeated_without_progress: true } : {}),
|
|
2039
2132
|
...(retryable ? {
|
|
2040
2133
|
retry_command: successorLifecycleInvocationCommand(context, row),
|
|
2041
2134
|
retry_instruction: "Correct the reported input, then execute retry_command verbatim in this same Session. The rejected call changed no lifecycle state."
|
|
2042
2135
|
} : {}) };
|
|
2043
|
-
|
|
2136
|
+
const successor = typeof details.retry_command === "string" ? currentIssuedCommand(context, row.scope_json, details.retry_command, row.id) : null;
|
|
2137
|
+
context.db.run("UPDATE lifecycle_invocations SET status = 'settled', outcome_json = ?, updated_at = ? WHERE id = ?", [JSON.stringify({ error: { ...errorRecord(error), details }, ...(successor ? { successor_invocation_id: successor.id } : {}) }), context.now(), row.id]);
|
|
2044
2138
|
return details;
|
|
2045
2139
|
});
|
|
2046
2140
|
}
|
|
@@ -2050,7 +2144,7 @@ export function settleLifecyclePreparationRejection(context, id, error) {
|
|
|
2050
2144
|
}
|
|
2051
2145
|
}
|
|
2052
2146
|
function preparationRejectionSignature(error) {
|
|
2053
|
-
const omitted = new Set(["retry_command", "retry_instruction", "invocation_id", "hook_event_id", "rejection_signature", "hook_retry_signature", "event_key", "tool_call_id", "controller_turn", "timestamp", "created_at", "updated_at", "repeated_without_progress", "recoverable"]);
|
|
2147
|
+
const omitted = new Set(["retry_command", "retry_instruction", "invocation_id", "hook_event_id", "rejection_signature", "hook_retry_signature", "event_key", "tool_call_id", "controller_turn", "timestamp", "created_at", "updated_at", "repeated_without_progress", "recoverable", "input_key", "input_sha256", "consumed_input_sha256"]);
|
|
2054
2148
|
const normalize = (value) => {
|
|
2055
2149
|
if (Array.isArray(value))
|
|
2056
2150
|
return value.map(normalize);
|
|
@@ -2060,9 +2154,111 @@ function preparationRejectionSignature(error) {
|
|
|
2060
2154
|
};
|
|
2061
2155
|
return crypto.createHash("sha256").update(JSON.stringify([error.code, normalize(error.details)])).digest("hex");
|
|
2062
2156
|
}
|
|
2157
|
+
function preparationInputKey(parsed, details) {
|
|
2158
|
+
const publicParsed = invocation(`${launchExpression(parsed)} ${quoteModelFacingArgs(publicInvocationArgs(parsed))}`);
|
|
2159
|
+
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));
|
|
2160
|
+
const digest = details.consumed_input_sha256 ?? details.input_sha256;
|
|
2161
|
+
const consumed = typeof digest === "string" && /^[a-f0-9]{64}$/.test(digest) ? digest : undefined;
|
|
2162
|
+
// No file read in this layer: an unread body cannot prove unchanged input.
|
|
2163
|
+
if (!consumed && options.some(([key]) => /(?:-file|-stdin)$/.test(key) || key === "data"))
|
|
2164
|
+
return undefined;
|
|
2165
|
+
return crypto.createHash("sha256").update(JSON.stringify([parsed.operation, publicParsed.args.positional, options, consumed ?? null])).digest("hex");
|
|
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
|
+
}
|
|
2063
2259
|
export function currentLifecycleFailure(context, input) {
|
|
2064
2260
|
if (input.turn !== undefined) {
|
|
2065
|
-
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
|
|
2066
2262
|
WHERE json_extract(outcome_json, '$.run_id') = ? AND json_extract(outcome_json, '$.generation') = ?
|
|
2067
2263
|
AND json_extract(outcome_json, '$.stage') = ? AND json_extract(outcome_json, '$.controller_turn') = ?
|
|
2068
2264
|
${input.projectId ? "AND project_id = ?" : ""}
|
|
@@ -2079,41 +2275,33 @@ export function currentLifecycleFailure(context, input) {
|
|
|
2079
2275
|
}
|
|
2080
2276
|
if (activeAttempt && outcome.attempt && outcome.attempt !== activeAttempt)
|
|
2081
2277
|
continue;
|
|
2082
|
-
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]);
|
|
2083
2281
|
if (outcome.disposition === "completed") {
|
|
2084
2282
|
completed.add(key);
|
|
2085
2283
|
continue;
|
|
2086
2284
|
}
|
|
2087
2285
|
if (completed.has(key) || !outcome.error?.code)
|
|
2088
2286
|
continue;
|
|
2089
|
-
const
|
|
2090
|
-
if (
|
|
2091
|
-
|
|
2092
|
-
&& continuation.stage === input.stage
|
|
2093
|
-
&& isRegisteredRepair(context, continuation, input.projectId ? { projectId: input.projectId, runId: input.runId } : undefined))
|
|
2094
|
-
continue;
|
|
2095
|
-
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 };
|
|
2096
2290
|
}
|
|
2097
2291
|
}
|
|
2098
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);
|
|
2099
2294
|
const scoped = attempts.filter(row => {
|
|
2100
2295
|
try {
|
|
2101
2296
|
const scope = JSON.parse(row.scope_json);
|
|
2102
|
-
return scope.runId === input.runId && scope.generation === input.generation && (!
|
|
2297
|
+
return scope.runId === input.runId && scope.generation === input.generation && (!projectRoot || scope.projectRoot === projectRoot);
|
|
2103
2298
|
}
|
|
2104
2299
|
catch {
|
|
2105
2300
|
return false;
|
|
2106
2301
|
}
|
|
2107
2302
|
});
|
|
2108
|
-
const
|
|
2109
|
-
|
|
2110
|
-
const rows = scoped.filter(row => row.fingerprint === fingerprint);
|
|
2111
|
-
// An issued successor has not performed the correction. Once the Turn
|
|
2112
|
-
// ended it cannot erase the predecessor's actual rejection.
|
|
2113
|
-
if (rows.some(row => ["observed", "executing"].includes(row.status)))
|
|
2114
|
-
continue;
|
|
2115
|
-
const row = rows.find(candidate => candidate.status === "settled");
|
|
2116
|
-
if (!row)
|
|
2303
|
+
for (const row of scoped) {
|
|
2304
|
+
if (row.status !== "settled")
|
|
2117
2305
|
continue;
|
|
2118
2306
|
let outcome;
|
|
2119
2307
|
try {
|
|
@@ -2142,13 +2330,9 @@ export function currentLifecycleFailure(context, input) {
|
|
|
2142
2330
|
}
|
|
2143
2331
|
if (stage !== input.stage)
|
|
2144
2332
|
continue;
|
|
2145
|
-
const
|
|
2146
|
-
if (
|
|
2147
|
-
|
|
2148
|
-
&& continuation.stage === input.stage
|
|
2149
|
-
&& isRegisteredRepair(context, continuation, input.projectId ? { projectId: input.projectId, runId: input.runId } : undefined))
|
|
2150
|
-
continue;
|
|
2151
|
-
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 };
|
|
2152
2336
|
}
|
|
2153
2337
|
return null;
|
|
2154
2338
|
}
|
|
@@ -2184,7 +2368,7 @@ export async function awaitLifecycleInvocation(context, input) {
|
|
|
2184
2368
|
? error.details.recoverable !== false
|
|
2185
2369
|
: isRetryableLifecycleRejection(error.code, details);
|
|
2186
2370
|
Object.assign(error.details, { phase: "prepare", effect: "no_effect", recoverable });
|
|
2187
|
-
Object.assign(error.details, settleLifecyclePreparationRejection(context, input.id, error));
|
|
2371
|
+
Object.assign(error.details, settleLifecyclePreparationRejection(context, input.id, error, input.command));
|
|
2188
2372
|
throw error;
|
|
2189
2373
|
}
|
|
2190
2374
|
}
|