@sema-agent/core 5.21.1 → 5.23.0

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 (96) hide show
  1. package/CHANGELOG.md +103 -0
  2. package/dist/agents/send-message-tool.js +6 -3
  3. package/dist/agents/subagent.d.ts +6 -0
  4. package/dist/agents/subagent.js +48 -6
  5. package/dist/brain/errors.d.ts +20 -0
  6. package/dist/brain/errors.js +40 -0
  7. package/dist/brain/retry.d.ts +16 -2
  8. package/dist/brain/retry.js +3 -2
  9. package/dist/brain/status-sink.d.ts +9 -2
  10. package/dist/brain/stream-engine.d.ts +22 -0
  11. package/dist/brain/stream-engine.js +41 -10
  12. package/dist/core/ask-class.d.ts +48 -0
  13. package/dist/core/ask-class.js +33 -0
  14. package/dist/core/checkpoint-store.d.ts +103 -10
  15. package/dist/core/checkpoint-store.js +3 -1
  16. package/dist/core/governance-codes.d.ts +38 -0
  17. package/dist/core/governance-codes.js +14 -0
  18. package/dist/core/hooks.d.ts +101 -1
  19. package/dist/core/hooks.js +116 -14
  20. package/dist/core/locked-config.d.ts +7 -1
  21. package/dist/core/locked-config.js +2 -1
  22. package/dist/core/memory-engine/delegation-provenance.d.ts +62 -0
  23. package/dist/core/memory-engine/delegation-provenance.js +26 -0
  24. package/dist/core/memory-engine/engine.d.ts +94 -1
  25. package/dist/core/memory-engine/engine.js +329 -12
  26. package/dist/core/memory-engine/header-hints.d.ts +30 -0
  27. package/dist/core/memory-engine/header-hints.js +41 -0
  28. package/dist/core/memory-engine/index.d.ts +3 -2
  29. package/dist/core/memory-engine/index.js +3 -2
  30. package/dist/core/memory-engine/layout.d.ts +232 -0
  31. package/dist/core/memory-engine/layout.js +469 -1
  32. package/dist/core/memory-engine/tools.d.ts +30 -0
  33. package/dist/core/memory-engine/tools.js +108 -17
  34. package/dist/core/permission-rule-consent.d.ts +25 -9
  35. package/dist/core/permission-rule-consent.js +93 -21
  36. package/dist/core/permission-rule-model.d.ts +9 -1
  37. package/dist/core/permission-rule-model.js +2 -2
  38. package/dist/core/permission-rule-org.d.ts +195 -0
  39. package/dist/core/permission-rule-org.js +234 -0
  40. package/dist/core/permission-rule-store.d.ts +249 -6
  41. package/dist/core/permission-rule-store.js +314 -3
  42. package/dist/core/permission-rule-sync.d.ts +139 -0
  43. package/dist/core/permission-rule-sync.js +343 -0
  44. package/dist/core/runner/prepare-memory.js +35 -8
  45. package/dist/core/runner/prepare-task.d.ts +64 -3
  46. package/dist/core/runner/prepare-task.js +357 -32
  47. package/dist/core/runner/runtask.js +166 -6
  48. package/dist/core/shared-memory/contract.js +19 -4
  49. package/dist/core/shared-memory/normalize.d.ts +3 -1
  50. package/dist/core/shared-memory/tools.js +73 -17
  51. package/dist/core/shared-memory/types.d.ts +27 -1
  52. package/dist/core/store-contracts/permission-rule-sync-contract.d.ts +33 -0
  53. package/dist/core/store-contracts/permission-rule-sync-contract.js +186 -0
  54. package/dist/core/task-notification.d.ts +5 -2
  55. package/dist/core/task-registry-agent.d.ts +1 -1
  56. package/dist/core/task-registry-agent.js +6 -2
  57. package/dist/core/task-registry-shared.d.ts +9 -2
  58. package/dist/core/task-registry.d.ts +9 -3
  59. package/dist/core/task-registry.js +2 -0
  60. package/dist/core/tool-policy.d.ts +155 -4
  61. package/dist/core/tool-policy.js +148 -10
  62. package/dist/core/tool-result-store.d.ts +9 -1
  63. package/dist/core/tool-result-store.js +2 -1
  64. package/dist/core/trace.d.ts +79 -1
  65. package/dist/core/types.d.ts +94 -3
  66. package/dist/core/wiring-manifest.d.ts +16 -1
  67. package/dist/core/wiring-manifest.js +7 -1
  68. package/dist/index.d.ts +15 -8
  69. package/dist/index.js +12 -5
  70. package/dist/orchestration/goal.d.ts +10 -0
  71. package/dist/orchestration/goal.js +6 -5
  72. package/dist/stores/file/adoption/adopt.d.ts +146 -0
  73. package/dist/stores/file/adoption/adopt.js +616 -0
  74. package/dist/stores/file/adoption/marker.d.ts +194 -0
  75. package/dist/stores/file/adoption/marker.js +198 -0
  76. package/dist/stores/file/background-agent-store.js +2 -0
  77. package/dist/stores/file/checkpoint-store.d.ts +4 -0
  78. package/dist/stores/file/checkpoint-store.js +3 -0
  79. package/dist/stores/file/file-snapshot-store.js +2 -0
  80. package/dist/stores/file/index.d.ts +2 -0
  81. package/dist/stores/file/index.js +4 -0
  82. package/dist/stores/file/mailbox-store.js +2 -0
  83. package/dist/stores/file/memory-store.js +2 -0
  84. package/dist/stores/file/permission-rule-adopt.d.ts +62 -0
  85. package/dist/stores/file/permission-rule-adopt.js +95 -0
  86. package/dist/stores/file/permission-rule-store.d.ts +80 -2
  87. package/dist/stores/file/permission-rule-store.js +189 -46
  88. package/dist/stores/file/session-policy-store.js +2 -0
  89. package/dist/stores/file/session-store.js +2 -0
  90. package/dist/stores/file/task-list-store.js +2 -0
  91. package/dist/stores/file/tool-result-store.js +2 -0
  92. package/dist/stores/file/usage-window-store.js +2 -0
  93. package/dist/stores/file/workflow-journal-store.js +2 -0
  94. package/dist/stores/file/workflow-run-store.js +2 -0
  95. package/dist/tools/fs/bash-readonly-classifier.js +59 -10
  96. package/package.json +3 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,108 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.23.0 — 2026-08-10
4
+
5
+ No BREAKING changes.
6
+
7
+ ### Added
8
+
9
+ - **design/183 adoption protocol, core file half.** Root adoption marker + six-phase
10
+ `adoptLocalDataRoot` state machine + `readAdoptionStatus` standing introspection + config account
11
+ (witness/ack) + quarantine listing; 23 new exports. The I6 boot gate is a new refusal surface: every
12
+ file-store constructor under a root whose `adoption.json` marker is IN FLIGHT throws
13
+ `AdoptionError("adoption_in_flight")` (corrupt marker: `adoption_marker_corrupt`, fail-closed).
14
+ Roots without a marker are untouched. The terminal report carries `notMigratedByDesign` — the
15
+ machine-readable closed set of assets ruled to stay local, sharded per migration form.
16
+ - **design/182 org layer wired into the decision chain** (org-governed deployments only): org deny
17
+ blocks on both the main gate and the durable-resume leg; an unavailable org snapshot past
18
+ `ORG_ADJUDICATION_TIMEOUT_MS` (15s, mandatory at both call sites) downgrades every terminal allow
19
+ to a human ask, fail-closed. Closed-set additions consumers should re-pin: `DecisionReason`
20
+ +`org_rule`/`org_unavailable`; `PermissionDeniedSource` +`org`; `TraceEvent`
21
+ +`rule_sync_resurrected`/`rule_sync_dropped`/`org_snapshot_unavailable`.
22
+ - `AskOutcome` terminal arms accept an optional `settledBy` (host self-report; `allow` × `"timeout"`
23
+ is loudly refused); `GoalResult.budgetCause` names which ceiling ended a budget run;
24
+ `NON_GOVERNANCE_MEMORY_CODES` +3 members (`memory.challenge_ledger_oversize`,
25
+ `memory.control_plane_rebuilt`, `memory.control_plane_not_corrupt`);
26
+ `MemoryEngine.rebuildCorruptControlPlaneLedger` (host-only, refuses a healthy ledger, quarantines
27
+ corrupt bytes) and `CHALLENGE_LEDGER_MAX_EVENTS` disclosure threshold.
28
+
29
+ ### Fixed / narrowed
30
+
31
+ - **#109**: separated option values (`head -c 5`, `cut -f 1`, `grep -A 3 pat`, …) are no longer
32
+ counted as file operands by the read-only shell classifier — fileless forms of these commands now
33
+ ask instead of auto-passing.
34
+ - **#110**: a checkpoint `put` that errors but whose row is durably present (read-back claims it)
35
+ suspends instead of running on; `put`+`get` both failing aborts the run fail-closed.
36
+ - **#114**: `settledBy:"timeout"` documented promise narrowed to the two core-managed wait windows.
37
+ - **#115**: the offload truncation notice states where the remainder IS instead of implying any
38
+ caller can fetch it.
39
+ - Consent-record ids and the in-memory CRDT actor default now take real entropy (`randomBytes`);
40
+ format is opaque, do not parse.
41
+
42
+ ### Tooling
43
+
44
+ - New `gate:error-surface` (four invariants over model-visible error minting: no `.stack`,
45
+ >400-char templates ledgered, uncapped-arm ratchet, verbatim-guidance dedup) wired into CI.
46
+ - The environment-knob surface (25 names) is under an exhaustive AST-driven liveness registry;
47
+ `docs/CONFIG-CATALOG-GUIDE.md` gained the environment-knobs chapter (two comment-only pseudo-knobs
48
+ called out: `MAX_TOOL_CONCURRENCY`, `APPROVAL_TTL_MS` — setting them has no effect).
49
+
50
+ ## 5.22.0 — 2026-08-09
51
+
52
+ ### BREAKING
53
+
54
+ - **Checkpoint version 6 → 7.** A checkpoint row that carries the new inheritance-chain constraint
55
+ snapshot or delegation-provenance state is stamped v7; a pre-5.22 worker refuses it up front
56
+ (`checkpoint.unsupported_version`) instead of resuming with the ancestors' gate silently dropped.
57
+ Rows without the new faces keep their old stamp and resume everywhere.
58
+ - **Durable re-supply contract: content digest replaces the count check.** Resuming a suspended task
59
+ under inherited constraints now verifies a `cpv1:` sha256 digest of the persisted constraint
60
+ projection (topology + decision-chain metadata: autoMode armed, durable/content mandates) — a
61
+ same-length substitute chain, or one that dropped the ancestor's classifier, is refused
62
+ (`resume.parent_constraint_mismatch`). Old checkpoints without a digest fall back to the count check.
63
+ - **Approver-edited args re-adjudicate BEFORE the resolve CAS, and a refusal keeps the park pending.**
64
+ Previously the edit recheck ran after the checkpoint was consumed; a refusal now surfaces as
65
+ `resume.constraint_rejected` / `resume.constraint_unprojectable` with the checkpoint still
66
+ `pending` and re-decidable (new reopen reason `constraint_rejected`). Deployments that pinned the
67
+ old "resume completes, denial injected" shape must re-pin.
68
+ - **`basePolicyForResumeEdit` absent no longer skips the edit recheck.** The knob becomes an
69
+ override; absence falls back to the deployment's locked preflight policy, so an approver edit is
70
+ never executed with zero re-adjudication. Configured deployments are unchanged.
71
+
72
+ ### Added
73
+
74
+ - **Inheritance-chain decision-chain integrity (F-012).** Ancestor deny verdicts freeze into the
75
+ chain as a canonical projection; the ancestor's auto-mode classifier now travels with the chain
76
+ (both wrapper arms and the approved-edit recheck) — a delegated child no longer executes a call the
77
+ ancestor's own gate would have classified into a block. The budget ledger is documented as
78
+ task-scoped (it does not follow the chain), and the edit-recheck path now sees a real budget
79
+ snapshot instead of an absent one.
80
+ - **Sandbox admission (opt-in, narrow form).** Under an execution environment that declares
81
+ `capabilities.isolation`, surviving engine-minted local asks on engine hand tools admit
82
+ automatically with a durable `permission.sandbox_admitted` disclosure (`decisionReason:"sandbox"`)
83
+ instead of parking. Caller-registered in-process tools never auto-admit — the isolation capability
84
+ only vouches for what runs inside that environment. Enabling the mode requires durable parking and
85
+ a checkpoint store that declares re-decision capability; otherwise preparation refuses loudly.
86
+ - **Deterministic connect refusals take a short retry lane (#107).** ECONNREFUSED/ENOTFOUND-class
87
+ failures retry 2× with a 1s backoff cap instead of consuming the full transient budget
88
+ (measured 186s → 1.8s); `BrainStatus` and `brain.retry` telemetry carry a closed `errClass` and
89
+ the in-force lane budget. Consumers pinning the `reconnecting` detail text or reading
90
+ `maxRetries` as the engine-wide budget are affected.
91
+ - **Delegation attestation persists across processes (design/180 R-4 half).** The three-value
92
+ delegation-provenance aggregate rides the durable checkpoint; a resumed leg missing the state
93
+ reads as `unknown` (static floor), never as `clean`.
94
+ - **`buildToolResultRef` exported from the package root** — a host wiring an HTTP tool-result read
95
+ face binds an incoming ref to its owning task by prefix recomputation.
96
+ - **Content mandate split from the approval mandate (#94).** A delegated child's question routes on
97
+ the QUESTION seat, not the approver seat. Behavior widening, called out explicitly: under
98
+ `durableApproval` with a live `onQuestion`, an absent or string approver seat used to leave child
99
+ questions undeliverable — they now reach the live question face. The permission leg is unchanged.
100
+ - **Background-agent revival honesty (#84).** `resumable` answers the real revival ladder;
101
+ the revive lane distinguishes a transient `recycling` window (`resume.row_recycling`, retry) from
102
+ `row_gone` (terminal), checks access before disclosing row lifecycle, and
103
+ `TaskRegistry.reviveBackgroundAgent`'s refusal union gains the `recycling` member — exhaustive
104
+ consumers add one arm.
105
+
3
106
  ## 5.21.1 — 2026-08-09
4
107
 
5
108
  - **Fix: every `hands: none` deployment failed at the door on 5.21.0** (P0). `HAND_TOOL_EFFECTS`
@@ -709,9 +709,12 @@ export function createSendMessageTool(opts) {
709
709
  : code === "steering.still_running"
710
710
  ?
711
711
  `${who} (or a prior follow-up to it) is still running — wait for its completion notification. ${DEDUP_RETRY_NOTE}`
712
- : code === "resume.row_gone"
713
- ? `${who}'s registry row no longer exists (terminal GC) — relaunch a new agent instead.`
714
- : `${e instanceof Error ? e.message : String(e)} ${DEDUP_RETRY_NOTE}`;
712
+ : code === "resume.row_recycling"
713
+ ?
714
+ `${who}'s registry row is being adjudicated right now (a revival claim or a reap sweep holds it) — send again in a moment. ${DEDUP_RETRY_NOTE}`
715
+ : code === "resume.row_gone"
716
+ ? `${who}'s registry row no longer exists (terminal GC) — relaunch a new agent instead.`
717
+ : `${e instanceof Error ? e.message : String(e)} ${DEDUP_RETRY_NOTE}`;
715
718
  return { content: `Message not sent: ${text}`, details: { error: code ?? "resume_failed", to }, isError: true };
716
719
  }
717
720
  });
@@ -293,6 +293,12 @@ export declare function completedAgentCard(child: {
293
293
  * flagged (or no review was armed), so a consumer must not read presence as a verdict source for
294
294
  * anything else. */
295
295
  handbackWarning?: string;
296
+ /** design/180 half A — the child's delegation runtime-provenance attestation, reduced from its
297
+ * recorder aggregate at delivery. Present only when the parent chain armed the recorder; the
298
+ * parent-side judgment (the content-origin wrap) reads it off this card — `clean` retires the
299
+ * static face verdict for this call, `external` marks with the observed-event reason, `unknown`
300
+ * (or absence) keeps the static floor. Deliberate whitelist admission (see the boundary note). */
301
+ attestation?: import("../core/memory-engine/delegation-provenance.js").DelegationAttestation;
296
302
  }): Record<string, unknown>;
297
303
  /**
298
304
  * ruled 2026-08-04 — the DELEGATION-REPORT vocabulary for a child that durably paused at an approval
@@ -1,6 +1,8 @@
1
1
  import { Type } from "typebox";
2
2
  import { isAbsolute } from "node:path";
3
3
  import { withDelegationProvenance } from "../core/tool-policy.js";
4
+ import { isHighSurrogate, isLowSurrogate } from "../core/surrogate-safe-slice.js";
5
+ import { newDelegationProvenanceAggregate, reduceDelegationAttestation } from "../core/memory-engine/delegation-provenance.js";
4
6
  import { resolveModel, resolveModelDisplayLabel } from "../core/roles.js";
5
7
  import { OUTPUT_TOOL_NAME, REPORT_BLOCKED_TOOL_NAME } from "../core/runner/synthetic-tools.js";
6
8
  import { TOOL_SEARCH_NAME } from "../core/runner/tool-disclosure.js";
@@ -64,8 +66,8 @@ function defuseGapMarkers(slice) {
64
66
  return slice.replace(/\[…/g, `${REVIEW_MARKER_SCAR}…`);
65
67
  }
66
68
  function surrogateSafeSlice(text, from, width, anchorEnd = false) {
67
- const isLow = (i) => text.charCodeAt(i) >= 0xdc00 && text.charCodeAt(i) <= 0xdfff;
68
- const isHigh = (i) => text.charCodeAt(i) >= 0xd800 && text.charCodeAt(i) <= 0xdbff;
69
+ const isLow = (i) => isLowSurrogate(text.charCodeAt(i));
70
+ const isHigh = (i) => isHighSurrogate(text.charCodeAt(i));
69
71
  let start = from;
70
72
  if (start > 0 && start < text.length && isLow(start) && isHigh(start - 1))
71
73
  start--;
@@ -311,6 +313,13 @@ const ccElapsedTag = (ms) => {
311
313
  return sec >= 60 ? ` \u00b7 ${Math.floor(sec / 60)}m${sec % 60}s` : ` \u00b7 ${sec}s`;
312
314
  };
313
315
  const ccCompletionText = (desc, settled, rawStatus, elapsedMs) => `Agent "${desc}" ${settled === "killed" ? "stopped" : settled === "completed" ? "finished" : rawStatus}${ccElapsedTag(elapsedMs)}`;
316
+ function resumabilityClaim(f) {
317
+ if (f.status === "killed" && f.stoppedBy === "user")
318
+ return false;
319
+ if (f.liveRetainPresent)
320
+ return f.liveRetainHasBudget;
321
+ return f.durableRow && f.named;
322
+ }
314
323
  const BG_AGENT_COLLATERAL_REAP_REASON = "its parent run ended";
315
324
  const RESERVED_AGENT_NAMES = new Set([OUTPUT_TOOL_NAME, TOOL_SEARCH_NAME, OFFLOAD_TOOL_NAME, REPORT_BLOCKED_TOOL_NAME, DEFAULT_SUBAGENT_TOOL_NAME]);
316
325
  export const REPORT_FIELD_MAX = 300;
@@ -341,6 +350,7 @@ export function classifySubagentError(child) {
341
350
  export function completedAgentCard(child, extras) {
342
351
  return {
343
352
  type: "agent",
353
+ ...(extras.attestation !== undefined ? { attestation: extras.attestation } : {}),
344
354
  ...(extras.subagentType !== undefined ? { subagent_type: extras.subagentType } : {}),
345
355
  status: child.status,
346
356
  taskId: child.taskId,
@@ -522,7 +532,9 @@ export function createSubagentResume(deps) {
522
532
  throw configError(revived.reason === "still_running"
523
533
  ?
524
534
  "resume unavailable: the agent's registry row is not available for a resume right now (it is running, or another revival claimed it) — wait for its completion notification and send again."
525
- : "resume unavailable: the agent's registry row no longer exists (terminal GC) — relaunch a new agent instead.", revived.reason === "still_running" ? "steering.still_running" : "resume.row_gone");
535
+ : revived.reason === "recycling"
536
+ ? "resume unavailable: the agent's registry row is being adjudicated right now (a revival claim or a reap sweep holds it) — this clears on its own; send again in a moment."
537
+ : "resume unavailable: the agent's registry row no longer exists (terminal GC) — relaunch a new agent instead.", revived.reason === "still_running" ? "steering.still_running" : revived.reason === "recycling" ? "resume.row_recycling" : "resume.row_gone");
526
538
  }
527
539
  reviveCycle = revived.cycle;
528
540
  if (deps.bgSink !== undefined) {
@@ -682,6 +694,16 @@ export function createSubagentResume(deps) {
682
694
  const reviveTerminalSummary = failReasonRevive !== undefined
683
695
  ? `Agent "${reviveName}" (resumed) failed: ${failReasonRevive}${ccElapsedTag(Date.now() - reviveStartedAt)}`.slice(0, 300) + errorKindClause(errClassRevive)
684
696
  : `Agent "${reviveName}" (resumed) ${status === "killed" ? "stopped" : child.status === "completed" ? "finished" : String(child.status)}${ccElapsedTag(Date.now() - reviveStartedAt)}`;
697
+ const reviveDurableProbe = deps.registry !== undefined && deps.taskId !== undefined ? deps.registry.durableAgentRowProbe(deps.taskId) : undefined;
698
+ const reviveDurableRow = reviveDurableProbe !== undefined ? await reviveDurableProbe().catch(() => false) : false;
699
+ const resumableRevive = resumabilityClaim({
700
+ status,
701
+ ...(stoppedByRevive !== undefined ? { stoppedBy: stoppedByRevive } : {}),
702
+ liveRetainPresent: ledger.get(deps.parentToolCallId) === entry,
703
+ liveRetainHasBudget: entry.resumeCount < SUBAGENT_RESUME_CAP,
704
+ durableRow: reviveDurableRow,
705
+ named: deps.rowName !== undefined,
706
+ });
685
707
  const ownsTerminalFacesRevive = deps.registry !== undefined && deps.taskId !== undefined ? deps.registry.claimAgentTerminalNotify(deps.taskId) : true;
686
708
  if (ownsTerminalFacesRevive)
687
709
  reviveEmit?.({
@@ -696,7 +718,7 @@ export function createSubagentResume(deps) {
696
718
  ...(stoppedByRevive !== undefined ? { stoppedBy: stoppedByRevive } : {}),
697
719
  summary: reviveTerminalSummary,
698
720
  ...resumeResidual(),
699
- resumable: status !== "killed",
721
+ resumable: resumableRevive,
700
722
  usage: { tokens: child.stats.tokens, turns: child.stats.turns, ...delegatedCostField(child.stats) },
701
723
  ...(completionIdRevive !== undefined ? { completionId: completionIdRevive } : {}),
702
724
  });
@@ -714,7 +736,7 @@ export function createSubagentResume(deps) {
714
736
  ...(resumeHandbackResult ? { result: notifyResultField(resumeHandbackResult) } : {}),
715
737
  ...(status === "killed" && child.result ? { partial: true } : {}),
716
738
  ...resumeResidual(),
717
- resumable: status !== "killed",
739
+ resumable: resumableRevive,
718
740
  usage: { tokens: child.stats.tokens, turns: child.stats.turns, ...delegatedCostField(child.stats) },
719
741
  ...(completionIdRevive !== undefined ? { completionId: completionIdRevive } : {}),
720
742
  };
@@ -1589,8 +1611,12 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1589
1611
  return { ...(recentSteps ? { recentSteps } : {}), ...(editedFiles ? { editedFiles } : {}) };
1590
1612
  };
1591
1613
  const treeScope = reviveClaim?.row.scope ?? ctx.principal ?? opts.background?.scope;
1614
+ const provenanceRequest = ctx.delegationProvenanceForChildren?.();
1615
+ const childProvenanceRef = provenanceRequest !== undefined ? { current: newDelegationProvenanceAggregate() } : undefined;
1616
+ const childAttestation = (status) => childProvenanceRef !== undefined ? reduceDelegationAttestation(childProvenanceRef.current, { completed: status === "completed" }) : undefined;
1592
1617
  const childInternals = {
1593
1618
  ...(inheritedManifestScope ? { inheritedManifestScope } : {}),
1619
+ ...(childProvenanceRef !== undefined && provenanceRequest !== undefined ? { delegationProvenance: { ref: childProvenanceRef, contentSafety: provenanceRequest } } : {}),
1594
1620
  ...(treeScope !== undefined ? { registryScope: treeScope } : {}),
1595
1621
  ...(ctx.inheritedGateForChildren ? { inheritedGate: ctx.inheritedGateForChildren() } : {}),
1596
1622
  ownOrgAdmissionRef: { current: reviveClaim !== undefined ? readDurableOrgAdmission(reviveClaim.row) : undefined },
@@ -2274,6 +2300,10 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2274
2300
  details: completedAgentCard(forkChild, {
2275
2301
  subagentType: FORK_SUBAGENT_TYPE,
2276
2302
  error: forkErr,
2303
+ ...(() => {
2304
+ const a = childAttestation(forkChild.status);
2305
+ return a !== undefined ? { attestation: a } : {};
2306
+ })(),
2277
2307
  ...(forkHandbackWarning !== undefined ? { handbackWarning: forkHandbackWarning } : {}),
2278
2308
  ...(worktreeKeptPath !== undefined ? { worktreePath: worktreeKeptPath } : {}),
2279
2309
  ...(() => {
@@ -2795,7 +2825,15 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2795
2825
  const completionIdBg = bg.registry.getCompletionId(taskId);
2796
2826
  const observerNote = observerNoteFor(await startBoundedObserverDrain());
2797
2827
  const residual = residualFields();
2798
- const resumableBg = bgRetain !== undefined && settled !== "killed";
2828
+ const bgDurableRow = await bgRowConfirmed();
2829
+ const resumableBg = resumabilityClaim({
2830
+ status: settled,
2831
+ ...(stoppedBy !== undefined ? { stoppedBy } : {}),
2832
+ liveRetainPresent: settled !== "killed" && bgRetain !== undefined && bgRetainLedger?.get(ctx.toolCallId) === bgRetain,
2833
+ liveRetainHasBudget: bgRetain !== undefined && bgRetain.resumeCount < SUBAGENT_RESUME_CAP,
2834
+ durableRow: bgDurableRow,
2835
+ named: agentName !== undefined,
2836
+ });
2799
2837
  const failReasonBg = settled === "failed" ? unparkedPauseReason ?? child.errorMessage : undefined;
2800
2838
  const bgTerminalSummary = failReasonBg !== undefined
2801
2839
  ? `Agent "${shortDesc}" failed: ${failReasonBg}${ccElapsedTag(Date.now() - bgStartedAt)}${observerNote}`.slice(0, 300) + errorKindClause(errClassBg)
@@ -3106,6 +3144,10 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
3106
3144
  details: completedAgentCard(child, {
3107
3145
  ...(def !== undefined ? { subagentType: def.name } : {}),
3108
3146
  error: errClass,
3147
+ ...(() => {
3148
+ const a = childAttestation(child.status);
3149
+ return a !== undefined ? { attestation: a } : {};
3150
+ })(),
3109
3151
  ...(handbackWarning !== undefined ? { handbackWarning } : {}),
3110
3152
  ...(modelFallback !== undefined ? { modelFallback } : {}),
3111
3153
  ...(worktreeKeptPath !== undefined ? { worktreePath: worktreeKeptPath } : {}),
@@ -45,6 +45,26 @@ export declare function extractErrorCode(errorMessage: string | undefined): Brai
45
45
  * human text. Pairs with {@link extractErrorCode} so a caller can lift the code and clean the message
46
46
  * without re-hardcoding the code list (the Runner does both when assembling `TaskResult`). */
47
47
  export declare function stripErrorCodePrefix(errorMessage: string): string;
48
+ /**
49
+ * Whether a transport-level connect failure is DETERMINISTIC (see {@link DETERMINISTIC_CONNECT_CODES})
50
+ * or gives no such verdict. Classification is by `code` along the error graph — never by message text,
51
+ * which providers and runtimes reword freely.
52
+ *
53
+ * The graph, not just the chain: undici nests the actionable error under `cause`, and a dual-stack
54
+ * connect nests one `AggregateError` whose `errors[]` hold the per-family results. A code outside the
55
+ * set ANYWHERE in the graph returns `"indeterminate"`, and every AGGREGATE LEG must reach a
56
+ * deterministic verdict of its own — so a hostname whose IPv4 leg is refused while its IPv6 leg timed
57
+ * out, or whose second leg says nothing this classifier can read, keeps the full ladder. Position
58
+ * decides how silence is read: a WRAPPER with no code (the `TypeError("fetch failed")` shell, an
59
+ * `AbortSignal.timeout` DOMException whose `code` is a number) is structural and defers to what it
60
+ * wraps, while a LEG with nothing readable is an outcome nobody accounted for and blocks the verdict.
61
+ *
62
+ * Fail-safe by construction — every uncertain outcome (empty aggregate, an unreadable leg, a graph
63
+ * larger than the node budget, no deterministic code found at all) answers `"indeterminate"`, i.e. the
64
+ * pre-existing behavior.
65
+ */
66
+ export type ConnectFailureClass = "deterministic" | "indeterminate";
67
+ export declare function classifyConnectFailure(e: unknown): ConnectFailureClass;
48
68
  /** RB-386③ ([2090]) — render a transport-level error WITHOUT folding away its cause chain. undici's
49
69
  * `fetch` rejects with `TypeError("fetch failed", { cause })` where the CAUSE carries the actually
50
70
  * actionable fact (`connect ECONNREFUSED …` / `getaddrinfo ENOTFOUND …` / `SocketError: other side
@@ -61,6 +61,46 @@ function hasStaleConnectionCode(e) {
61
61
  }
62
62
  return false;
63
63
  }
64
+ const DETERMINISTIC_CONNECT_CODES = new Set(["ECONNREFUSED", "ENOTFOUND"]);
65
+ const CONNECT_GRAPH_NODE_BUDGET = 16;
66
+ function walkConnectFailure(node, budget) {
67
+ if (budget.left <= 0)
68
+ return "indeterminate";
69
+ budget.left--;
70
+ if (node === undefined || node === null)
71
+ return "silent";
72
+ let verdict = "silent";
73
+ const code = node.code;
74
+ if (typeof code === "string") {
75
+ if (!DETERMINISTIC_CONNECT_CODES.has(code))
76
+ return "indeterminate";
77
+ verdict = "deterministic";
78
+ }
79
+ if (!(node instanceof Error))
80
+ return verdict;
81
+ const members = node.errors;
82
+ if (Array.isArray(members)) {
83
+ if (members.length === 0)
84
+ return "indeterminate";
85
+ for (const member of members) {
86
+ if (walkConnectFailure(member, budget) !== "deterministic")
87
+ return "indeterminate";
88
+ }
89
+ verdict = "deterministic";
90
+ }
91
+ const cause = node.cause;
92
+ if (cause !== undefined && cause !== null) {
93
+ const below = walkConnectFailure(cause, budget);
94
+ if (below === "indeterminate")
95
+ return "indeterminate";
96
+ if (below === "deterministic")
97
+ verdict = "deterministic";
98
+ }
99
+ return verdict;
100
+ }
101
+ export function classifyConnectFailure(e) {
102
+ return walkConnectFailure(e, { left: CONNECT_GRAPH_NODE_BUDGET }) === "deterministic" ? "deterministic" : "indeterminate";
103
+ }
64
104
  export function describeNetworkError(e) {
65
105
  const top = e instanceof Error ? e.message : String(e);
66
106
  const parts = [];
@@ -46,9 +46,23 @@ export interface ProviderWaitHint {
46
46
  * ask as if nothing had been truncated at all, even though the pair's honored wait is identical either
47
47
  * way. */
48
48
  export declare function providerWaitHint(res: Response | undefined): ProviderWaitHint | undefined;
49
+ /**
50
+ * Ceiling on the exponential term for the SHORT lane that serves deterministic connect failures
51
+ * (`ECONNREFUSED` / a name with no address — see `errors.ts` `classifyConnectFailure`).
52
+ *
53
+ * That lane exists because waiting does not change the answer: the only thing that can is the peer
54
+ * itself coming up. So its ladder is sized to cover a service that is already mid-restart, not to
55
+ * out-wait an overloaded provider — which is what the 32s ceiling above is for. The two are separate
56
+ * constants rather than one parameter because they answer different questions, and collapsing them
57
+ * would make either lane's number look arbitrary.
58
+ */
59
+ export declare const FAST_MAX_BACKOFF_MS = 1000;
49
60
  /**
50
61
  * Backoff (ms) for retry `attempt` (0-based) of a transient failure: an increasing capped exponential
51
62
  * plus ≤25% additive jitter, floored by the response's provider wait hints (see the module note).
52
- * `rand` is injectable for deterministic tests.
63
+ * `rand` is injectable for deterministic tests. `maxBackoffMs` overrides the exponential ceiling for a
64
+ * caller running a deliberately shorter lane; it bounds only the COMPUTED term — a provider wait hint
65
+ * still floors the result, because a lane choosing to be short is not a licence to ignore a server that
66
+ * said when to come back (and such a lane's failures carry no response to read a hint off anyway).
53
67
  */
54
- export declare function retryBackoffMs(baseDelayMs: number, attempt: number, res?: Response, rand?: () => number): number;
68
+ export declare function retryBackoffMs(baseDelayMs: number, attempt: number, res?: Response, rand?: () => number, maxBackoffMs?: number): number;
@@ -48,8 +48,9 @@ export function providerWaitHint(res) {
48
48
  function providerWaitHintMs(res) {
49
49
  return providerWaitHint(res)?.ms;
50
50
  }
51
- export function retryBackoffMs(baseDelayMs, attempt, res, rand = Math.random) {
52
- const exp = Math.min(MAX_BACKOFF_MS, baseDelayMs * 2 ** attempt);
51
+ export const FAST_MAX_BACKOFF_MS = 1_000;
52
+ export function retryBackoffMs(baseDelayMs, attempt, res, rand = Math.random, maxBackoffMs = MAX_BACKOFF_MS) {
53
+ const exp = Math.min(maxBackoffMs, baseDelayMs * 2 ** attempt);
53
54
  const computed = Math.round(exp + rand() * JITTER_FRACTION * exp);
54
55
  const hint = providerWaitHintMs(res);
55
56
  return hint === undefined ? computed : Math.max(hint, computed);
@@ -1,4 +1,4 @@
1
- import type { BrainStatus } from "../core/types.js";
1
+ import type { BrainRetryErrClass, BrainStatus } from "../core/types.js";
2
2
  /** Run `fn` with a per-task brain-status sink in scope. ALS propagates it through the async brain calls
3
3
  * inside `fn` (the harness's prompt → brain.stream → connect/retry loop), so {@link emitBrainStatus}
4
4
  * reaches THIS task's sink and nothing else. */
@@ -34,10 +34,17 @@ export type BrainTelemetry = {
34
34
  key: string;
35
35
  phase: "open" | "half-open" | "closed";
36
36
  failures: number;
37
- } | {
37
+ }
38
+ /** A retried provider call. `errClass` is the same closed bucket the user-facing status frame carries
39
+ * (so an operator reading traces and a user reading a progress line agree on why); `nextDelayMs` is
40
+ * the backoff actually about to be slept — 0 for the immediate re-sends that take no backoff. Both
41
+ * optional so a caller that has no verdict to report is not forced to invent one. */
42
+ | {
38
43
  kind: "retry";
39
44
  attempt: number;
40
45
  phase: "connect" | "midstream";
46
+ errClass?: BrainRetryErrClass;
47
+ nextDelayMs?: number;
41
48
  } | {
42
49
  kind: "vision_placeholder";
43
50
  count: number;
@@ -25,6 +25,21 @@ export interface StreamEngineConfig extends BrainTimeoutConfig {
25
25
  maxRetries?: number;
26
26
  /** First-retry backoff ms; each further attempt doubles it (capped, +≤25% jitter). Default 500. */
27
27
  retryDelayMs?: number;
28
+ /**
29
+ * Retries allowed for a DETERMINISTIC connect failure — one whose error codes say the target itself
30
+ * is not there (`ECONNREFUSED`, or a name with no address; see `errors.ts` `classifyConnectFailure`).
31
+ * Absent ⇒ {@link DEFAULT_DETERMINISTIC_CONNECT_RETRIES}.
32
+ *
33
+ * These retries exist for one case only: a peer that is mid-restart and about to accept. Waiting
34
+ * longer cannot change any other outcome — re-sending at an address where nothing listens gets the
35
+ * same refusal in milliseconds — so spending the full transient budget on it buys nothing and costs
36
+ * an unattended caller minutes of silence.
37
+ *
38
+ * The value is capped by {@link maxRetries} (this lane can only ever be the SHORTER of the two).
39
+ * Setting it AT or ABOVE `maxRetries` turns the lane off entirely: budget and backoff both revert to
40
+ * the shared transient ladder, which is the opt-out for a deployment that wants the old behavior.
41
+ */
42
+ deterministicConnectRetries?: number;
28
43
  }
29
44
  /**
30
45
  * Resolve the transient-failure retry budget — explicit config > `SEMA_MAX_RETRIES` env > default 10
@@ -32,6 +47,13 @@ export interface StreamEngineConfig extends BrainTimeoutConfig {
32
47
  * value is ignored rather than silently meaning "zero retries"; an oversized one is clamped.
33
48
  */
34
49
  export declare function resolveMaxRetries(configured: number | undefined): number;
50
+ /**
51
+ * Resolve the deterministic-connect retry budget. Same posture as {@link resolveMaxRetries}: a chosen
52
+ * NUMBER is honored, a non-finite or negative one falls back to the default rather than silently
53
+ * meaning something (a NaN here would compare false against every budget gate and disable the lane by
54
+ * accident, which is the sentinel-inversion shape CLS-A-8 pinned for the shared budget).
55
+ */
56
+ export declare function resolveDeterministicConnectRetries(configured: number | undefined): number;
35
57
  /**
36
58
  * Is `rebuilt` still THE SAME REQUEST as `original`, differing only in the output cap and the values
37
59
  * that follow from it (ruled 2026-08-05)?
@@ -1,7 +1,7 @@
1
1
  import { createAssistantMessageEventStream, } from "../internal/llm.js";
2
2
  import { FLOOR_OUTPUT_TOKENS, parseContextOverflow, planOutputCapAdjustment } from "./context-overflow.js";
3
- import { BrainError, classifyHttp, describeNetworkError } from "./errors.js";
4
- import { providerWaitHint, retryBackoffMs } from "./retry.js";
3
+ import { BrainError, classifyConnectFailure, classifyHttp, describeNetworkError } from "./errors.js";
4
+ import { FAST_MAX_BACKOFF_MS, providerWaitHint, retryBackoffMs } from "./retry.js";
5
5
  import { emitBrainStatus, emitBrainTelemetry } from "./status-sink.js";
6
6
  import { createConnectController, resolveStallTimeoutMs } from "./timeout.js";
7
7
  const DEFAULT_MAX_RETRIES = 10;
@@ -18,6 +18,12 @@ export function resolveMaxRetries(configured) {
18
18
  }
19
19
  return DEFAULT_MAX_RETRIES;
20
20
  }
21
+ const DEFAULT_DETERMINISTIC_CONNECT_RETRIES = 2;
22
+ export function resolveDeterministicConnectRetries(configured) {
23
+ if (configured !== undefined && Number.isFinite(configured) && configured >= 0)
24
+ return Math.floor(configured);
25
+ return DEFAULT_DETERMINISTIC_CONNECT_RETRIES;
26
+ }
21
27
  const SHOULD_RETRY_HEADER = "x-should-retry";
22
28
  function sameHeaders(a, b) {
23
29
  const aKeys = Object.keys(a);
@@ -272,6 +278,8 @@ export function runStreamingBrain(args) {
272
278
  let maxOutputTokensOverride;
273
279
  const maxRetries = resolveMaxRetries(config.maxRetries);
274
280
  const baseDelay = config.retryDelayMs ?? 500;
281
+ const deterministicConnectBudget = Math.min(resolveDeterministicConnectRetries(config.deterministicConnectRetries), maxRetries);
282
+ const deterministicLaneActive = deterministicConnectBudget < maxRetries;
275
283
  const firstTokenTimeoutMs = resolveStallTimeoutMs(config.firstTokenTimeoutMs ?? stallTimeouts?.firstTokenMs, "firstTokenTimeoutMs");
276
284
  const idleTimeoutMs = resolveStallTimeoutMs(config.idleTimeoutMs ?? stallTimeouts?.idleMs, "idleTimeoutMs");
277
285
  const connectTimeoutMs = resolveStallTimeoutMs(config.connectTimeoutMs ?? stallTimeouts?.connectMs, "connectTimeoutMs");
@@ -342,13 +350,14 @@ export function runStreamingBrain(args) {
342
350
  maxOutputTokensOverride = plan.maxOutputTokens;
343
351
  req = rebuilt;
344
352
  req.onCommitted?.();
345
- emitBrainTelemetry({ kind: "retry", attempt: attempt + 1, phase: "connect" });
353
+ emitBrainTelemetry({ kind: "retry", attempt: attempt + 1, phase: "connect", errClass: "output_cap", nextDelayMs: 0 });
346
354
  announcedRetry = true;
347
355
  emitBrainStatus({
348
356
  phase: "retrying",
349
357
  detail: `output limit lowered from ${previousCap} to ${plan.maxOutputTokens} tokens, retrying`,
350
358
  attempt: attempt + 1,
351
359
  maxRetries,
360
+ errClass: "output_cap",
352
361
  });
353
362
  continue;
354
363
  }
@@ -370,14 +379,33 @@ export function runStreamingBrain(args) {
370
379
  const retryableByStatus = netErr !== undefined ||
371
380
  (r !== undefined && (r.status >= 500 || r.status === 429 || r.status === 408 || r.status === 409));
372
381
  const retryable = shouldRetryHeaderVerdict(r) ?? retryableByStatus;
373
- if (retryable && attempt < maxRetries) {
374
- const delayMs = retryBackoffMs(baseDelay, attempt, r);
382
+ const deterministicConnect = netErr !== undefined && classifyConnectFailure(netErr) === "deterministic";
383
+ const fastLane = deterministicConnect && deterministicLaneActive;
384
+ const laneMaxRetries = fastLane ? deterministicConnectBudget : maxRetries;
385
+ const errClass = netErr !== undefined
386
+ ? deterministicConnect
387
+ ? "connect_refused"
388
+ : "transport"
389
+ : r?.status === 429
390
+ ? "rate_limit"
391
+ : r !== undefined && (r.status >= 500 || r.status === 408 || r.status === 409)
392
+ ? "server"
393
+ : "http";
394
+ if (retryable && attempt < laneMaxRetries) {
395
+ const delayMs = fastLane
396
+ ? retryBackoffMs(baseDelay, attempt, r, Math.random, FAST_MAX_BACKOFF_MS)
397
+ : retryBackoffMs(baseDelay, attempt, r);
375
398
  const waitHint = providerWaitHint(r);
376
399
  const truncationNote = waitHint?.rawMs !== undefined
377
400
  ? ` (the requested wait was longer than the cap; honoring ${Math.ceil(waitHint.ms / 1000)}s instead of ${Math.ceil(waitHint.rawMs / 1000)}s)`
378
401
  : "";
379
402
  const statusPhase = r?.status === 429 ? "rate_limited" : netErr !== undefined ? "reconnecting" : "retrying";
380
- emitBrainTelemetry({ kind: "retry", attempt: attempt + 1, phase: "connect" });
403
+ const reconnectDetail = errClass !== "connect_refused"
404
+ ? "connection lost, reconnecting"
405
+ : fastLane
406
+ ? "no endpoint answering, retrying briefly"
407
+ : "no endpoint answering, retrying";
408
+ emitBrainTelemetry({ kind: "retry", attempt: attempt + 1, phase: "connect", errClass, nextDelayMs: delayMs });
381
409
  announcedRetry = true;
382
410
  discardResponseBody(r);
383
411
  cc.abort();
@@ -387,12 +415,13 @@ export function runStreamingBrain(args) {
387
415
  detail: statusPhase === "rate_limited"
388
416
  ? `rate limited, backing off${truncationNote}`
389
417
  : statusPhase === "reconnecting"
390
- ? "connection lost, reconnecting"
418
+ ? reconnectDetail
391
419
  : `transient error, retrying${truncationNote}`,
392
420
  retryInSec: Math.ceil(remainingMs / 1000),
393
421
  retryInMs: remainingMs,
394
422
  attempt: attempt + 1,
395
- maxRetries,
423
+ maxRetries: laneMaxRetries,
424
+ errClass,
396
425
  }));
397
426
  continue;
398
427
  }
@@ -561,7 +590,7 @@ export function runStreamingBrain(args) {
561
590
  thinkingRetries++;
562
591
  parser.sealForRetry();
563
592
  const delayMs = 100 * thinkingRetries;
564
- emitBrainTelemetry({ kind: "retry", attempt: thinkingRetries, phase: "midstream" });
593
+ emitBrainTelemetry({ kind: "retry", attempt: thinkingRetries, phase: "midstream", errClass: "transport", nextDelayMs: delayMs });
565
594
  announcedRetry = true;
566
595
  await sleepAnnouncingRetry(delayMs, signal, (remainingMs) => ({
567
596
  phase: "reconnecting",
@@ -570,6 +599,7 @@ export function runStreamingBrain(args) {
570
599
  retryInMs: remainingMs,
571
600
  attempt: thinkingRetries,
572
601
  maxRetries: THINKING_RETRY_BUDGET,
602
+ errClass: "transport",
573
603
  }));
574
604
  continue;
575
605
  }
@@ -577,7 +607,7 @@ export function runStreamingBrain(args) {
577
607
  throw failure.err;
578
608
  const delayMs = retryBackoffMs(baseDelay, attempt);
579
609
  attempt++;
580
- emitBrainTelemetry({ kind: "retry", attempt, phase: "midstream" });
610
+ emitBrainTelemetry({ kind: "retry", attempt, phase: "midstream", errClass: "transport", nextDelayMs: delayMs });
581
611
  announcedRetry = true;
582
612
  await sleepAnnouncingRetry(delayMs, signal, (remainingMs) => ({
583
613
  phase: "reconnecting",
@@ -586,6 +616,7 @@ export function runStreamingBrain(args) {
586
616
  retryInMs: remainingMs,
587
617
  attempt,
588
618
  maxRetries,
619
+ errClass: "transport",
589
620
  }));
590
621
  }
591
622
  }