@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.
Files changed (35) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/build-info.json +8 -8
  3. package/dist/cli/error-output.js +11 -1
  4. package/dist/cli/input-preparation.js +32 -4
  5. package/dist/cli/run-cli.js +24 -7
  6. package/dist/harness-runtime/lib/daemon-operations.mjs +46 -11
  7. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +19 -6
  8. package/dist/harness-runtime/lib/dd-agy.mjs +19 -0
  9. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +1 -1
  10. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +1 -1
  11. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +2 -2
  12. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +1 -1
  13. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +1 -1
  14. package/dist/harness-runtime/lib/delegation-instructions.d.mts +1 -0
  15. package/dist/harness-runtime/lib/delegation-instructions.mjs +3 -0
  16. package/dist/schemas/code-work-batch.schema.json +1 -1
  17. package/dist/schemas/vnext-protocol-plan.schema.json +1 -1
  18. package/dist/services/codex-capacity.js +367 -0
  19. package/dist/services/continuation-outcome.js +11 -6
  20. package/dist/services/controller-child-repair.js +46 -24
  21. package/dist/services/controller-fanout.js +62 -21
  22. package/dist/services/controller-result-authority.js +13 -8
  23. package/dist/services/controller-work-failure.js +198 -0
  24. package/dist/services/lifecycle-invocations.js +280 -96
  25. package/dist/services/managed-daemon-binding.js +40 -0
  26. package/dist/services/run-control.js +18 -36
  27. package/dist/services/run-controller-state.js +8 -0
  28. package/dist/services/run-controller.js +136 -27
  29. package/dist/services/vnext-code.js +3 -1
  30. package/dist/services/vnext-plan-review.js +2 -2
  31. package/dist/services/vnext-plan.js +3 -3
  32. package/dist/services/work-registry.js +59 -12
  33. package/dist/services/work-retry-context.js +55 -0
  34. package/dist/verification-components.json +3 -0
  35. 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" && !isRegisteredRepair(context, outcome.error?.details?.continuation, { projectId: project.id, runId: scope.runId }))
386
- throw new AppError(outcome.error?.code ?? "lifecycle_outcome_unknown", outcome.error?.message || "Mandatory lifecycle failed (historical receipt has no message)", 1, { ...outcome.error?.details, lifecycle_outcome: outcome });
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" && !committedSuccessor(context, row, outcome) && !isCorrectableLifecycleRejection(error.code, error.details) && !isRegisteredRepair(context, error.details?.continuation, { projectId: project.id, runId: scope.runId }))
400
- throw new AppError(error.code, typeof error.message === "string" ? error.message : "Mandatory lifecycle failed", 1, { ...(error.details ?? {}), invocation_id: row.id });
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') = ? LIMIT 1`, [anchor.project_id, anchor.hook_event_id, anchor.run_id, anchor.generation, anchor.operation, correlationKey,
611
- anchor.harness, anchor.session_id, anchor.stage ?? null, anchor.attempt ?? null, signature]);
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.repeated_without_progress = true;
623
- record.disposition = "fatal";
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 supplied = input.id ? context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [input.id]) : undefined;
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
- return { eventKey: updated.changes === 1 ? eventKey : row.event_key, duplicate: updated.changes !== 1, disposition: updated.changes === 1 ? "admitted" : "in_flight", ...resolved };
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 suppliedId = commandOption(analysis.invocation, "invocation-id");
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 history = context.db.all("SELECT * FROM lifecycle_invocations WHERE status IN ('issued','observed','executing','settled') ORDER BY rowid DESC");
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
- const identity = correlation?.identity;
1905
- const matches = lifecycleCandidates(matching, identity ? JSON.stringify([identity.daemonId, identity.rootSessionId, identity.sessionId, identity.parentSessionId, identity.toolCallId]) : undefined, true, history);
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, history = rows) {
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
- if (lookup && !identity && active.length === 1 && (active[0].status === "issued" || active[0].identity_json && active[0].event_key)) {
1938
- const current = active[0];
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 true;
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 next = JSON.parse(row.outcome_json ?? "null")?.successor_invocation_id;
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 active;
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
- const command = `${prefix ? `${prefix} ` : ""}${executable} ${quote(argv)}${suffix ? ` ${suffix}` : ""}`;
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
- if (context.db.run("UPDATE lifecycle_invocations SET command = ?, updated_at = ? WHERE id = ? AND status = 'executing'", [command, context.now(), id]).changes !== 1) {
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
- const row = load(context, id);
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
- return JSON.parse(prior.outcome_json ?? "null")?.error?.details?.rejection_signature === signature;
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
- context.db.run("UPDATE lifecycle_invocations SET status = 'settled', outcome_json = ?, updated_at = ? WHERE id = ?", [JSON.stringify({ error: { ...errorRecord(error), details } }), context.now(), row.id]);
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 key = JSON.stringify([outcome.operation ?? "", outcome.session_id ?? null, outcome.correlation_key ?? outcome.fingerprint ?? hook.id]);
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 continuation = outcome.error.details?.continuation;
2090
- if (continuation && typeof continuation === "object"
2091
- && continuation.run_id === input.runId
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 && (!input.projectRoot || scope.projectRoot === input.projectRoot);
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 fingerprints = [...new Set(scoped.map(row => row.fingerprint))];
2109
- for (const fingerprint of fingerprints) {
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 continuation = outcome.error.details?.continuation;
2146
- if (continuation && typeof continuation === "object"
2147
- && continuation.run_id === input.runId
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
  }