@sema-agent/core 5.47.0 → 5.48.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 (52) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/dist/agents/agent-transcript-tool.d.ts +4 -0
  3. package/dist/agents/agent-transcript-tool.js +10 -3
  4. package/dist/agents/send-message-tool.d.ts +43 -1
  5. package/dist/agents/send-message-tool.js +50 -11
  6. package/dist/agents/subagent.d.ts +18 -0
  7. package/dist/agents/subagent.js +102 -2
  8. package/dist/config/defaults.d.ts +20 -0
  9. package/dist/config/defaults.js +5 -0
  10. package/dist/core/background-agent-store.d.ts +1 -0
  11. package/dist/core/background-agent-store.js +13 -0
  12. package/dist/core/mcp.d.ts +6 -1
  13. package/dist/core/mcp.js +34 -7
  14. package/dist/core/reminder-disclosure.d.ts +90 -0
  15. package/dist/core/reminder-disclosure.js +64 -0
  16. package/dist/core/runner/prepare-acquire-reconcile.d.ts +6 -0
  17. package/dist/core/runner/prepare-acquire-reconcile.js +1 -1
  18. package/dist/core/runner/prepare-hands-readface.d.ts +4 -0
  19. package/dist/core/runner/prepare-hands-readface.js +1 -0
  20. package/dist/core/runner/prepare-task.d.ts +15 -0
  21. package/dist/core/runner/prepare-task.js +48 -30
  22. package/dist/core/runner/runtask.js +3 -1
  23. package/dist/core/session-store.d.ts +59 -1
  24. package/dist/core/session-store.js +82 -14
  25. package/dist/core/session.d.ts +83 -1
  26. package/dist/core/task-registry-agent.d.ts +28 -0
  27. package/dist/core/task-registry-agent.js +63 -2
  28. package/dist/core/task-registry.d.ts +21 -0
  29. package/dist/core/task-registry.js +4 -1
  30. package/dist/core/types.d.ts +60 -0
  31. package/dist/core/untrusted-text.d.ts +63 -0
  32. package/dist/core/untrusted-text.js +48 -0
  33. package/dist/core/wiring-manifest.d.ts +35 -0
  34. package/dist/core/wiring-manifest.js +21 -1
  35. package/dist/engine/harness/types.d.ts +36 -1
  36. package/dist/index.d.ts +5 -4
  37. package/dist/index.js +4 -3
  38. package/dist/internal/harness-types.d.ts +1 -0
  39. package/dist/stores/file/index.d.ts +19 -3
  40. package/dist/stores/file/index.js +24 -1
  41. package/dist/stores/file/session-store.d.ts +18 -4
  42. package/dist/stores/file/session-store.js +73 -12
  43. package/dist/tools/fs/fs-pdf.d.ts +12 -1
  44. package/dist/tools/fs/fs-pdf.js +17 -3
  45. package/dist/tools/fs/fs-read.d.ts +2 -1
  46. package/dist/tools/fs/fs-read.js +33 -5
  47. package/dist/tools/fs/fs-shared.d.ts +6 -2
  48. package/dist/tools/fs/index.d.ts +7 -0
  49. package/dist/tools/fs/index.js +1 -1
  50. package/dist/tools/web.js +21 -2
  51. package/package.json +3 -2
  52. package/test/export-surface.snapshot.json +15 -1
@@ -1,4 +1,4 @@
1
- import { InMemorySessionRepo, uuidv7, } from "../internal/harness.js";
1
+ import { InMemorySessionRepo, SessionError, uuidv7, } from "../internal/harness.js";
2
2
  import { isSessionConflict } from "./session.js";
3
3
  const DAY_MS = 24 * 60 * 60 * 1000;
4
4
  import { SESSION_DEFAULT_TTL_DAYS } from "../config/defaults.js";
@@ -6,10 +6,14 @@ export { SESSION_DEFAULT_TTL_DAYS };
6
6
  export class TtlSessionStore {
7
7
  retention = "none";
8
8
  durability;
9
+ placements;
10
+ listPlaced;
9
11
  repo;
10
12
  entries = new Map();
11
13
  owners = new Map();
12
14
  pending = new Map();
15
+ pendingPlacedDeletes = new Set();
16
+ deletingPlaced = new Map();
13
17
  pinned = new Set();
14
18
  defaultTtlMs;
15
19
  evictPolicy;
@@ -21,6 +25,12 @@ export class TtlSessionStore {
21
25
  this.durability = durability;
22
26
  this.defaultTtlMs = (opts.defaultTtlDays ?? SESSION_DEFAULT_TTL_DAYS) * DAY_MS;
23
27
  this.evictPolicy = opts.evict ?? (opts.repo ? "forget" : "delete");
28
+ if (opts.placements !== undefined)
29
+ this.placements = opts.placements;
30
+ const repoListPlaced = this.repo.listPlaced;
31
+ if (typeof repoListPlaced === "function") {
32
+ this.listPlaced = (kind, o) => repoListPlaced.call(this.repo, kind, o);
33
+ }
24
34
  if (opts.sweepIntervalMs !== undefined && opts.sweepIntervalMs > 0) {
25
35
  this.sweepTimer = setInterval(() => this.sweep(), opts.sweepIntervalMs);
26
36
  this.sweepTimer.unref?.();
@@ -29,8 +39,12 @@ export class TtlSessionStore {
29
39
  async acquire(sessionId, opts) {
30
40
  this.sweep();
31
41
  if (sessionId) {
42
+ const inflightPlacedDelete = this.deletingPlaced.get(sessionId);
43
+ if (inflightPlacedDelete !== undefined)
44
+ await inflightPlacedDelete;
32
45
  const existing = this.entries.get(sessionId);
33
46
  if (existing) {
47
+ this.assertPlacementAdmission(sessionId, existing.placement, opts);
34
48
  existing.lastActiveAt = Date.now();
35
49
  return { session: existing.session, sessionId };
36
50
  }
@@ -39,26 +53,46 @@ export class TtlSessionStore {
39
53
  }
40
54
  const inflight = this.pending.get(sessionId);
41
55
  if (inflight) {
42
- return { session: await inflight, sessionId };
56
+ const session = await inflight;
57
+ this.assertPlacementAdmission(sessionId, this.entries.get(sessionId)?.placement, opts);
58
+ return { session, sessionId };
43
59
  }
44
- const p = this.openOrCreate(sessionId, false);
60
+ const p = this.openOrCreate(sessionId, false, opts?.placement);
45
61
  this.pending.set(sessionId, p);
46
62
  try {
47
- return { session: await p, sessionId };
63
+ const session = await p;
64
+ this.assertPlacementAdmission(sessionId, this.entries.get(sessionId)?.placement, opts);
65
+ return { session, sessionId };
48
66
  }
49
67
  finally {
50
68
  this.pending.delete(sessionId);
51
69
  }
52
70
  }
53
- const session = await this.createAndStore(undefined);
71
+ const session = await this.createAndStore(undefined, opts?.placement);
54
72
  const meta = await session.getMetadata();
55
73
  return { session, sessionId: meta.id };
56
74
  }
57
- async openOrCreate(id, requireExisting = false) {
75
+ assertPlacementAdmission(sessionId, placement, opts) {
76
+ if (placement !== undefined && opts?.requireExisting !== true && opts?.placement === undefined) {
77
+ throw new SessionError("placement_refused", `session ${sessionId} belongs to the ${placement.kind} placement partition — a claim-form acquire cannot attach to it (read it with requireExisting, or address the agent through its a* handle)`);
78
+ }
79
+ if (opts?.placement !== undefined && opts.requireExisting !== true && this.placements?.subagent !== undefined) {
80
+ const want = opts.placement;
81
+ if (placement === undefined) {
82
+ throw new SessionError("placement_refused", `session ${sessionId} exists OUTSIDE the ${want.kind} placement partition it was declared into — refusing to run a subagent transcript on it (a concurrent claim-form create won this id, or the repo dropped the tuple)`);
83
+ }
84
+ if (placement.kind !== want.kind || placement.scope !== want.scope || placement.handle !== want.handle) {
85
+ throw new SessionError("placement_refused", `session ${sessionId}'s persisted placement does not match the declared one (persisted ${placement.kind}/${placement.scope ?? "-"}/${placement.handle ?? "-"} vs declared ${want.kind}/${want.scope ?? "-"}/${want.handle ?? "-"}) — refusing a cross-tuple attach`);
86
+ }
87
+ }
88
+ }
89
+ async openOrCreate(id, requireExisting = false, placement) {
58
90
  try {
59
91
  const session = await this.repo.open({ id, createdAt: "" });
60
92
  const meta = await session.getMetadata();
61
- this.entries.set(id, { session, lastActiveAt: Date.now(), createdAt: meta.createdAt, ...(meta.forkedFrom !== undefined ? { forkedFrom: meta.forkedFrom } : {}) });
93
+ if (meta.placement === undefined)
94
+ this.pendingPlacedDeletes.delete(id);
95
+ this.entries.set(id, { session, lastActiveAt: Date.now(), createdAt: meta.createdAt, ...(meta.forkedFrom !== undefined ? { forkedFrom: meta.forkedFrom } : {}), ...(meta.placement !== undefined ? { placement: meta.placement } : {}) });
62
96
  return session;
63
97
  }
64
98
  catch (err) {
@@ -68,13 +102,15 @@ export class TtlSessionStore {
68
102
  if (requireExisting && isNotFound(err)) {
69
103
  throw err;
70
104
  }
71
- return this.createAndStore(id);
105
+ return this.createAndStore(id, placement);
72
106
  }
73
107
  }
74
- async createAndStore(id) {
75
- const session = await this.repo.create(id ? { id } : {});
108
+ async createAndStore(id, placement) {
109
+ const session = await this.repo.create({ ...(id !== undefined ? { id } : {}), ...(placement !== undefined ? { placement } : {}) });
76
110
  const meta = await session.getMetadata();
77
- this.entries.set(meta.id, { session, lastActiveAt: Date.now(), createdAt: meta.createdAt, ...(meta.forkedFrom !== undefined ? { forkedFrom: meta.forkedFrom } : {}) });
111
+ if (meta.placement === undefined)
112
+ this.pendingPlacedDeletes.delete(meta.id);
113
+ this.entries.set(meta.id, { session, lastActiveAt: Date.now(), createdAt: meta.createdAt, ...(meta.forkedFrom !== undefined ? { forkedFrom: meta.forkedFrom } : {}), ...(meta.placement !== undefined ? { placement: meta.placement } : {}) });
78
114
  return session;
79
115
  }
80
116
  touch(sessionId) {
@@ -92,6 +128,7 @@ export class TtlSessionStore {
92
128
  }
93
129
  async list() {
94
130
  return [...this.entries.entries()]
131
+ .filter(([, e]) => e.placement === undefined)
95
132
  .map(([sessionId, e]) => ({
96
133
  sessionId,
97
134
  lastActiveAt: e.lastActiveAt,
@@ -137,12 +174,43 @@ export class TtlSessionStore {
137
174
  return;
138
175
  }
139
176
  const e = this.entries.get(sessionId);
140
- if (!e) {
177
+ if (e !== undefined && e.placement === undefined)
178
+ this.pendingPlacedDeletes.delete(sessionId);
179
+ let placed = e !== undefined ? e.placement !== undefined : this.pendingPlacedDeletes.has(sessionId);
180
+ if (!placed && e === undefined && this.placements !== undefined) {
181
+ const probe = this.repo.placementOf;
182
+ if (typeof probe === "function") {
183
+ try {
184
+ placed = (await probe.call(this.repo, sessionId)) !== undefined;
185
+ }
186
+ catch {
187
+ placed = false;
188
+ }
189
+ }
190
+ }
191
+ if (!e && !placed) {
192
+ return;
193
+ }
194
+ if (placed) {
195
+ this.pendingPlacedDeletes.add(sessionId);
196
+ this.entries.delete(sessionId);
197
+ let settleGate;
198
+ this.deletingPlaced.set(sessionId, new Promise((r) => (settleGate = r)));
199
+ try {
200
+ const meta = e !== undefined ? await e.session.getMetadata() : { id: sessionId, createdAt: "" };
201
+ await this.repo.delete(meta);
202
+ }
203
+ finally {
204
+ this.deletingPlaced.delete(sessionId);
205
+ settleGate();
206
+ }
207
+ this.pendingPlacedDeletes.delete(sessionId);
208
+ this.owners.delete(sessionId);
141
209
  return;
142
210
  }
143
211
  this.entries.delete(sessionId);
144
212
  if (this.evictPolicy === "delete") {
145
- await this.repo.delete(await e.session.getMetadata());
213
+ await this.repo.delete(e !== undefined ? await e.session.getMetadata() : { id: sessionId, createdAt: "" });
146
214
  this.owners.delete(sessionId);
147
215
  }
148
216
  }
@@ -174,7 +242,7 @@ export class TtlSessionStore {
174
242
  }
175
243
  if (now - e.lastActiveAt > this.defaultTtlMs) {
176
244
  this.entries.delete(id);
177
- if (this.evictPolicy === "delete") {
245
+ if (this.evictPolicy === "delete" && e.placement === undefined) {
178
246
  void this.repo.delete({ id, createdAt: "" }).then(() => {
179
247
  this.owners.delete(id);
180
248
  }, () => { });
@@ -7,8 +7,43 @@ export { SessionError } from "../internal/harness.js";
7
7
  export type { SessionWriteOptions } from "../internal/harness.js";
8
8
  import type { Session } from "../internal/harness.js";
9
9
  import type { SessionTreeEntry } from "../internal/harness.js";
10
+ export type { SessionPlacementRecord } from "../internal/harness.js";
10
11
  /** True when `err` is a session optimistic-lock conflict (a concurrent writer won the branch leaf). */
11
12
  export declare function isSessionConflict(err: unknown): boolean;
13
+ /**
14
+ * Subagent transcript persistence — the ACQUIRE-form placement declaration a trusted spawner passes
15
+ * when it creates a subagent's transcript session: the session is created INTO the store's subagent
16
+ * partition instead of the host lane. The minter reports the fact; the store never guesses residency
17
+ * from call shape. `placedAt` is stamped by the store at creation (see
18
+ * {@link SessionPlacementRecord}). Reaches stores ONLY through the engine's trusted internals chain
19
+ * (never a public TaskSpec key — a public key would let any caller push arbitrary sessions into the
20
+ * partition and poison store-side retention/enumeration).
21
+ */
22
+ export interface SessionPlacement {
23
+ kind: "subagent";
24
+ /** Tenant key, same source as the durable agent row's `scope`. */
25
+ scope?: string;
26
+ /** The spawning host session (retrieval/partition metadata; authorization stays on the row predicate). */
27
+ parentSessionId?: string;
28
+ /** The delegation tree's root host session (fixed point). */
29
+ rootSessionId?: string;
30
+ /** The durable agent row key (`a…`) — the store-side join key for orphan GC. */
31
+ handle?: string;
32
+ }
33
+ /**
34
+ * One row of {@link SessionStore.listPlaced}. The row-store join key is the FULL `(scope, handle)`
35
+ * tuple — a listing that cannot supply both returns the discriminated `tupleIncomplete` variant
36
+ * instead (consumers fail closed on it: never age-reaped, reported honestly).
37
+ */
38
+ export type PlacedSessionRow = {
39
+ sessionId: string;
40
+ placedAt: number;
41
+ } & ({
42
+ scope: string;
43
+ handle: string;
44
+ } | {
45
+ tupleIncomplete: true;
46
+ });
12
47
  /** A session handed back by a {@link SessionStore}: the harness `Session` plus its resolved id. */
13
48
  export interface AcquiredSession {
14
49
  session: Session;
@@ -66,13 +101,47 @@ export interface SessionStore {
66
101
  * fact.
67
102
  */
68
103
  readonly durability?: import("./checkpoint-store.js").StoreDurability;
104
+ /**
105
+ * Subagent transcript persistence — the store's PER-PLACEMENT durability declaration: "this store
106
+ * understands and honors placement semantics (partitioning + the obligations below), and sessions
107
+ * placed into the `subagent` partition have THIS durability". Declaration, never duck-typing (the
108
+ * wiring manifest relays it verbatim). The store-level {@link durability} continues to describe
109
+ * only UN-placed sessions — a routing/composite store whose transient host lane and durable
110
+ * subagent partition differ can therefore be honest on both axes. ABSENT = the store does not
111
+ * understand placement: an `acquire` carrying one is legally IGNORED (the session is then an
112
+ * ordinary one, and the deployment's delegation-durability tier reads as rows-only).
113
+ *
114
+ * Declaring this commits the store to the placement obligations:
115
+ * 1. a placed session enjoys the declared durability (`acquire(id, {requireExisting:true})`
116
+ * reaches it after a restart when `"durable"`);
117
+ * 2. placed sessions do NOT appear on host enumeration faces (`list()` and every deployment
118
+ * listing path — restart-stable);
119
+ * 3. acquire semantics for sessions WITHOUT a placement are byte-unchanged;
120
+ * 4. `release(placedId)` is a REAL deletion (durable history included) — the joint reap's
121
+ * "row and session both gone" depends on it;
122
+ * 5. placement is FIRST-WRITE IMMUTABLE — a re-open (`requireExisting`, with or without a
123
+ * placement argument) returns the same session and never rewrites the tuple;
124
+ * 6. a placed id cannot be CLAIMED: a later claim-form acquire (no `requireExisting`, no
125
+ * creation placement) of an existing placed id refuses loudly
126
+ * (`SessionError("placement_refused")`) instead of attaching.
127
+ */
128
+ readonly placements?: {
129
+ subagent?: {
130
+ durability: "durable" | "process-local";
131
+ };
132
+ };
69
133
  /** Get an existing session by id, or create one (optionally with a caller-supplied id). design/114 Phase3:
70
134
  * `opts.requireExisting` ⇒ a store MUST fail loud (throw a `not_found` {@link SessionError}) on a genuinely
71
135
  * missing id rather than create-on-miss — so a reuse-style warm-resume of a gone session errors instead of
72
136
  * silently starting a fresh empty run. A store that cannot honor it MUST still not silently create (either
73
- * implement the check or reject the option). */
137
+ * implement the check or reject the option).
138
+ * `opts.placement` (subagent transcript persistence): CREATE the session into the named placement
139
+ * partition (see {@link SessionPlacement} — trusted-internals channel only). Ignored by stores
140
+ * that do not declare {@link placements}; on a store that does, the creating call is exempt from
141
+ * obligation 6 above and idempotent re-acquire by the same trusted chain stays legal. */
74
142
  acquire(sessionId?: string, opts?: {
75
143
  requireExisting?: boolean;
144
+ placement?: SessionPlacement;
76
145
  }): Promise<AcquiredSession>;
77
146
  /** Mark a session recently active (resets idle TTL where applicable). May be async for durable stores. */
78
147
  touch(sessionId: string): void | Promise<void>;
@@ -115,6 +184,19 @@ export interface SessionStore {
115
184
  * that cannot enumerate omits this (the shell then has no session-list affordance — honest degrade).
116
185
  */
117
186
  list?(): Promise<SessionStoreSummary[]>;
187
+ /**
188
+ * Subagent transcript persistence — enumerate the PLACED partition (the input to the joint reap's
189
+ * partition leg: sessions whose owning agent row is gone are the orphan shape it collects).
190
+ * **Optional**: absent = the partition-GC leg is simply unavailable on this store (documented,
191
+ * fail-closed — placed sessions then age out only through the row-joined reap path). `olderThanMs`
192
+ * filters by session AGE (last activity where knowable, else `placedAt`); `scope` narrows to one
193
+ * tenant. Rows return the FULL `(scope, handle)` join tuple or the honest `tupleIncomplete`
194
+ * variant (see {@link PlacedSessionRow}) — consumers must skip incomplete tuples, never guess.
195
+ */
196
+ listPlaced?(kind: "subagent", opts?: {
197
+ olderThanMs?: number;
198
+ scope?: string;
199
+ }): Promise<PlacedSessionRow[]>;
118
200
  /**
119
201
  * design/110 — FORK a session: copy `sourceId`'s durable history (root→leaf) into a NEW session and return its
120
202
  * id (or `null` if the source doesn't exist). The forked session is independent (writes to it don't touch the
@@ -1,6 +1,17 @@
1
1
  import { type BackgroundAgentRecord, type BackgroundAgentStore } from "./background-agent-store.js";
2
2
  import { type StopSource, type TaskAccess, type TaskRetrievalStatus, type UnifiedTaskOutput, type UnifiedTaskResult, type BackgroundAgentTaskHandle, type DurableAgentCore, type ParkedClaimTicket, type RegisterBackgroundAgentInput } from "./task-registry-shared.js";
3
3
  import { type ToolResultStore } from "./tool-result-store.js";
4
+ /**
5
+ * Subagent transcript persistence (delegation entry caps) — the ACTIVE handles of one delegation
6
+ * tree in THIS process's registry: `background_agent` handles whose status is running/pending
7
+ * (parked does not burn a concurrency slot — a suspension is not concurrency; terminal handles are
8
+ * not active) under the `(scope, rootSessionId)` key (full depth: a grandchild's row carries the
9
+ * tree's fixed-point root, so the whole tree shares one pool; a depth-1 row whose rootSessionId
10
+ * predates the floor falls back to its parentSessionId — same fallback the access predicate's root
11
+ * arm rides). SYNCHRONOUS by contract: the caps enforcement runs check→register in one synchronous
12
+ * segment, which is what makes the reservation race-free in-process.
13
+ */
14
+ export declare function activeDelegationHandlesLane(core: DurableAgentCore, scope: string, rootSessionId: string): string[];
4
15
  export declare function ensureDurableHeartbeatLane(core: DurableAgentCore): void;
5
16
  /** design/151 S1a — enqueue one durable-row write (see {@link DurableAgentLane} for the lane
6
17
  * contract). `patch` is captured at CALL time (the settle-site values), applied in chain order.
@@ -66,9 +77,25 @@ export declare function endDurableClaimLane(core: DurableAgentCore, id: string):
66
77
  * transcript lives on unanchored — the exact strand this orchestration exists to prevent. */
67
78
  export declare function reapDurableAgentsLane(core: DurableAgentCore, scope: string, deps: {
68
79
  store: BackgroundAgentStore;
80
+ /** `listPlaced` (subagent transcript persistence, optional): arms the PARTITION leg below —
81
+ * placed transcript sessions whose owning row is GONE (row never landed / deleted-but-release-
82
+ * failed) are the orphan shape only a session-side enumeration can find. Absent = that leg is
83
+ * unavailable (documented; the row-joined path still runs). */
69
84
  sessions?: {
70
85
  unpin?(sessionId: string): unknown;
71
86
  release(sessionId: string): Promise<void> | void;
87
+ listPlaced?(kind: "subagent", opts?: {
88
+ olderThanMs?: number;
89
+ scope?: string;
90
+ }): Promise<Array<{
91
+ sessionId: string;
92
+ placedAt: number;
93
+ } & ({
94
+ scope: string;
95
+ handle: string;
96
+ } | {
97
+ tupleIncomplete: true;
98
+ })>>;
72
99
  };
73
100
  /** design/151 §7.7 (F-13) — the row's mailbox dies with the row: a WINNING delete also drops
74
101
  * the (scope, handle) mailbox (advisory — a mailbox fault never blocks the reap; an orphaned
@@ -86,6 +113,7 @@ export declare function reapDurableAgentsLane(core: DurableAgentCore, scope: str
86
113
  rowsReaped: number;
87
114
  sessionsReleased: number;
88
115
  skippedNoSessions: number;
116
+ orphanPlacedReleased: number;
89
117
  }>;
90
118
  /** codex 终审 C-4 half — after a probe-false RELEASE the row must stop claiming a transcript:
91
119
  * the heartbeat's F-1 arm keeps re-driving a flush-failed lane, so a later successful flush
@@ -7,6 +7,23 @@ import { delimitUntrusted } from "./untrusted-text.js";
7
7
  import { boundedRedactedSummary } from "./untrusted-egress.js";
8
8
  import { mintCompletionId, commitCompletionIdIfEmpty, clipTaskOutput, assertOwnership, sleepPollStep, alreadyTerminalStopNote, canAccess, normalizeAgentName, closestName, DURABLE_AGENT_HEARTBEAT_MS, DURABLE_AGENT_HANDLE_RE, BG_AGENT_REAP_STOP_ERROR, } from "./task-registry-shared.js";
9
9
  import { buildToolResultRef, OFFLOAD_TOOL_NAME, toolResultProvenanceOf } from "./tool-result-store.js";
10
+ export function activeDelegationHandlesLane(core, scope, rootSessionId) {
11
+ const out = [];
12
+ for (const h of core.handles.values()) {
13
+ if (h.type !== "background_agent")
14
+ continue;
15
+ if (h.status !== "running" && h.status !== "pending")
16
+ continue;
17
+ if (h.scope !== scope)
18
+ continue;
19
+ const bh = h;
20
+ const root = bh.rootSessionId ?? bh.parentSessionId ?? (bh.sessionScoped ? bh.owner : undefined);
21
+ if (root !== rootSessionId)
22
+ continue;
23
+ out.push(h.id);
24
+ }
25
+ return out;
26
+ }
10
27
  export function ensureDurableHeartbeatLane(core) {
11
28
  if (core.durableHeartbeatTimer !== undefined)
12
29
  return;
@@ -170,7 +187,7 @@ export async function reapDurableAgentsLane(core, scope, deps, policy) {
170
187
  await deps.store.reap(scope, now, { staleRunningMaxAgeMs: policy.staleRunningMaxAgeMs });
171
188
  }
172
189
  if (policy.maxAgeMs === undefined && policy.keep === undefined)
173
- return { rowsReaped: 0, sessionsReleased: 0, skippedNoSessions: 0 };
190
+ return { rowsReaped: 0, sessionsReleased: 0, skippedNoSessions: 0, orphanPlacedReleased: 0 };
174
191
  const terminal = (await deps.store.listByScope(scope)).filter((r) => r.status !== "running" && r.status !== "parked");
175
192
  terminal.sort((a, b) => b.spawnedAt - a.spawnedAt);
176
193
  const doomed = new Map();
@@ -239,7 +256,51 @@ export async function reapDurableAgentsLane(core, scope, deps, policy) {
239
256
  core.reapedHandles.delete(r.handle);
240
257
  }
241
258
  }
242
- return { rowsReaped, sessionsReleased, skippedNoSessions };
259
+ let orphanPlacedReleased = 0;
260
+ if (policy.maxAgeMs !== undefined && deps.sessions !== undefined && typeof deps.sessions.listPlaced === "function") {
261
+ let placed = [];
262
+ try {
263
+ placed = await deps.sessions.listPlaced("subagent", { olderThanMs: policy.maxAgeMs, scope });
264
+ }
265
+ catch {
266
+ placed = [];
267
+ }
268
+ for (const p of placed) {
269
+ if ("tupleIncomplete" in p)
270
+ continue;
271
+ if (p.scope !== scope)
272
+ continue;
273
+ if (core.claimingHandles.has(p.handle) || core.reapingHandles.has(p.handle))
274
+ continue;
275
+ let row;
276
+ try {
277
+ row = await deps.store.get(p.handle, scope);
278
+ }
279
+ catch {
280
+ continue;
281
+ }
282
+ if (row !== null)
283
+ continue;
284
+ const inProc = core.handles.get(p.handle);
285
+ if (inProc !== undefined && (inProc.status === "running" || inProc.status === "pending" || inProc.status === "parked"))
286
+ continue;
287
+ try {
288
+ await deps.sessions.unpin?.(p.sessionId);
289
+ await deps.sessions.release(p.sessionId);
290
+ orphanPlacedReleased++;
291
+ }
292
+ catch {
293
+ }
294
+ if (deps.mailbox !== undefined) {
295
+ try {
296
+ await deps.mailbox.drop(scope, p.handle);
297
+ }
298
+ catch {
299
+ }
300
+ }
301
+ }
302
+ }
303
+ return { rowsReaped, sessionsReleased, skippedNoSessions, orphanPlacedReleased };
243
304
  }
244
305
  export function releaseDurableTranscriptAnchorLane(core, id) {
245
306
  const handle = core.handles.get(id);
@@ -166,9 +166,24 @@ export declare class TaskRegistry {
166
166
  endDurableClaim(id: string): void;
167
167
  reapDurableAgents(scope: string, deps: {
168
168
  store: BackgroundAgentStore;
169
+ /** `listPlaced` (subagent transcript persistence, optional) arms the partition-orphan leg —
170
+ * see {@link reapDurableAgentsLane}. Pass the deployment's SessionStore directly (the
171
+ * TtlSessionStore over a placement-capable repo exposes it). */
169
172
  sessions?: {
170
173
  unpin?(sessionId: string): unknown;
171
174
  release(sessionId: string): Promise<void> | void;
175
+ listPlaced?(kind: "subagent", opts?: {
176
+ olderThanMs?: number;
177
+ scope?: string;
178
+ }): Promise<Array<{
179
+ sessionId: string;
180
+ placedAt: number;
181
+ } & ({
182
+ scope: string;
183
+ handle: string;
184
+ } | {
185
+ tupleIncomplete: true;
186
+ })>>;
172
187
  };
173
188
  /** design/151 §7.7 (F-13) — the row's mailbox dies with the row: a WINNING delete also drops
174
189
  * the (scope, handle) mailbox (advisory — a mailbox fault never blocks the reap; an orphaned
@@ -186,6 +201,7 @@ export declare class TaskRegistry {
186
201
  rowsReaped: number;
187
202
  sessionsReleased: number;
188
203
  skippedNoSessions: number;
204
+ orphanPlacedReleased: number;
189
205
  }>;
190
206
  releaseDurableTranscriptAnchor(id: string): void;
191
207
  bindBackgroundAgentSession(id: string, sessionId: string): void;
@@ -270,6 +286,11 @@ export declare class TaskRegistry {
270
286
  /** #258 — the row's current stop-cycle counter, read by the spawn lanes right after registering to
271
287
  * thread into the child's `RunInternals.cycleSeq`; see {@link backgroundAgentCycleSeqLane}. */
272
288
  backgroundAgentCycleSeq(id: string): number | undefined;
289
+ /** Subagent transcript persistence (delegation entry caps) — one delegation tree's ACTIVE
290
+ * (running/pending) a* handles in THIS process, keyed `(scope, rootSessionId)`; SYNCHRONOUS by
291
+ * contract (the caps check and the registration form one atomic segment). See
292
+ * {@link activeDelegationHandlesLane}. */
293
+ activeDelegationHandles(scope: string, rootSessionId: string): string[];
273
294
  /** ASYNC (revive arbitration) — the durable half is a guarded ownership claim on the row (awaited before the
274
295
  * in-memory flip), so this leg and a cross-process claim arbitrate in one domain instead of both
275
296
  * believing they own the cycle. See {@link reviveBackgroundAgentLane}. */
@@ -10,7 +10,7 @@ import { registerWorkflowLane, pollWorkflowLane, stopWorkflowLane } from "./task
10
10
  import { mintCompletionId, canAccessWorkflowRun, formatWorkflowRun, clipTaskOutput, assertOwnership, sleepPollStep, statusFromBackground, rollSpoolText, accountDroppedBytes, renderSpoolBody, spoolDropNote, droppedGapNote, alreadyTerminalStopNote, terminalTaskSummary, TASK_OUTPUT_MAX_CHARS, MONITOR_BATCH_WINDOW_MS, MONITOR_DEFAULT_TIMEOUT_MS, MONITOR_MAX_TIMEOUT_MS, MONITOR_MAX_BATCHES_PER_MINUTE, canAccess, DURABLE_AGENT_HANDLE_RE, } from "./task-registry-shared.js";
11
11
  export { normalizeAgentName, DURABLE_AGENT_HEARTBEAT_MS, DURABLE_AGENT_HANDLE_RE, BG_AGENT_REAP_STOP_ERROR } from "./task-registry-shared.js";
12
12
  import { TASK_OUTPUT_TOOL_NAME, TASK_STOP_TOOL_NAME, TASK_OUTPUT_CONTRACT, TASK_STOP_CONTRACT, TASK_OUTPUT_MISSING_ID_MESSAGE, TASK_STOP_MISSING_ID_MESSAGE, TASK_STOP_PARAMS, resolveTaskIdArg, REGISTRY_TASK_TOOL_CAPS, composeTaskOutputDescription, composeTaskOutputParams, composeTaskStopDescription, } from "./task-tool-shape.js";
13
- import { durableAgentArmedLane, durableAgentRowProbeLane, beginDurableClaimLane, endDurableClaimLane, reapDurableAgentsLane, noteBackgroundAgentActivityLane, reapStaleSessionBackgroundAgentsLane, releaseDurableTranscriptAnchorLane, bindBackgroundAgentSessionLane, registerBackgroundAgentLane, recordBackgroundAgentOrgAdmissionLane, parkBackgroundAgentLane, reconcileParkedAgentsLane, claimParkedAgentLane, rollbackParkedClaimLane, consumeParkedFlipLane, finalizeParkedResumeLane, settleBackgroundAgentLane, abortBackgroundAgentsForOwnerLane, serveDurableAgentRowLane, resolveBackgroundAgentByNameLane, backgroundAgentCycleSeqLane, markRetainedContinuationLane, reviveBackgroundAgentLane, settleRevivedAgentLane, unmarkRetainedContinuationLane, attachAgentNotifyLane, deliverToRunningAgentLane, runningBackgroundAgentLabelsLane, runningAgentFooterLane, notFoundRunningAgentsTail, pollBackgroundAgentLane, stopBackgroundAgentLane, } from "./task-registry-agent.js";
13
+ import { durableAgentArmedLane, durableAgentRowProbeLane, beginDurableClaimLane, endDurableClaimLane, reapDurableAgentsLane, noteBackgroundAgentActivityLane, reapStaleSessionBackgroundAgentsLane, releaseDurableTranscriptAnchorLane, bindBackgroundAgentSessionLane, registerBackgroundAgentLane, recordBackgroundAgentOrgAdmissionLane, parkBackgroundAgentLane, reconcileParkedAgentsLane, claimParkedAgentLane, rollbackParkedClaimLane, consumeParkedFlipLane, finalizeParkedResumeLane, settleBackgroundAgentLane, abortBackgroundAgentsForOwnerLane, serveDurableAgentRowLane, resolveBackgroundAgentByNameLane, backgroundAgentCycleSeqLane, activeDelegationHandlesLane, markRetainedContinuationLane, reviveBackgroundAgentLane, settleRevivedAgentLane, unmarkRetainedContinuationLane, attachAgentNotifyLane, deliverToRunningAgentLane, runningBackgroundAgentLabelsLane, runningAgentFooterLane, notFoundRunningAgentsTail, pollBackgroundAgentLane, stopBackgroundAgentLane, } from "./task-registry-agent.js";
14
14
  export { canAccessWorkflowRun, clipTaskOutput, MONITOR_BATCH_WINDOW_MS, MONITOR_DEFAULT_TIMEOUT_MS, MONITOR_MAX_TIMEOUT_MS, MONITOR_MAX_BATCHES_PER_MINUTE };
15
15
  const BLOCK_DEFAULT_TIMEOUT_MS = 30_000;
16
16
  const BLOCK_MAX_TIMEOUT_MS = 600_000;
@@ -194,6 +194,9 @@ export class TaskRegistry {
194
194
  backgroundAgentCycleSeq(id) {
195
195
  return backgroundAgentCycleSeqLane(this.core, id);
196
196
  }
197
+ activeDelegationHandles(scope, rootSessionId) {
198
+ return activeDelegationHandlesLane(this.core, scope, rootSessionId);
199
+ }
197
200
  reviveBackgroundAgent(id, access, abort) {
198
201
  return reviveBackgroundAgentLane(this.core, id, access, abort);
199
202
  }
@@ -643,6 +643,27 @@ export interface ToolExecuteContext {
643
643
  * Undefined when the tool runs outside a Runner task.
644
644
  */
645
645
  reminderMark?: string;
646
+ /**
647
+ * design/319 (B ticket) — the RUNNING task's reminder-disclosure trigger counters
648
+ * ({@link import("./reminder-disclosure.js").ReminderDisclosureCounts}), Runner-filled on the same
649
+ * trusted seat as {@link reminderMark}. Caller-mounted verbatim outlets that run the
650
+ * detect-and-disclose pipeline (the web tools' exact-mark defuse arm) bump their `<outlet>.<form>`
651
+ * keys here; the Runner folds the non-zero result into
652
+ * `TaskResult.stats.mechanisms.reminderDisclosures`. Mutable by design (a counter seat), but only
653
+ * ever additive — never a decision input. Undefined outside a Runner task.
654
+ */
655
+ reminderDisclosureCounts?: import("./reminder-disclosure.js").ReminderDisclosureCounts;
656
+ /**
657
+ * Subagent transcript persistence — the RESOLVED delegation entry caps for this run
658
+ * ({@link RunnerDeps.delegationEntryCaps} after prepare's loud validation; both members always
659
+ * present). Runner-filled trusted seat, never a model argument — the Agent tool's background lane
660
+ * reads it at its registration point. Undefined outside a Runner task (the lane then applies the
661
+ * exported defaults itself, so a directly-driven tool is bounded too).
662
+ */
663
+ delegationEntryCaps?: {
664
+ maxConcurrent: number;
665
+ maxCumulativePerSession: number;
666
+ };
646
667
  /**
647
668
  * Report usage spent in a nested run this tool spawned (e.g. a sub-agent). The Runner accumulates
648
669
  * it into the parent task's `TaskResult.stats.nested`, so delegated cost — the multi-agent "~15×"
@@ -3295,6 +3316,15 @@ export interface TaskResult {
3295
3316
  reps: number;
3296
3317
  segment: string;
3297
3318
  }>;
3319
+ /** design/319 (B ticket, G9② observation seat) — reminder-disclosure trigger counts for the
3320
+ * leg, keyed `<outlet>.<form>`: outlets `read` / `notebook` / `pdf` / `mcp` / `webFetch` /
3321
+ * `webSearch`; forms `bare` (bare-form trailer appended), `marked` (marked-form trailer —
3322
+ * never throttled), `bare_throttled` (a bare trailer suppressed by the 60s per-key window),
3323
+ * `defused` (an MCP/web segment's exact-mark bytes were rewritten — the lane's one sanctioned
3324
+ * byte change, always paired with a `marked` disclosure). Present only when ≥1 key is
3325
+ * non-zero. This is the D-4/D-6 re-ruling data (defuse/trailer widening to Read/Bash/Grep):
3326
+ * a reading, never a gate. */
3327
+ reminderDisclosures?: Record<string, number>;
3298
3328
  };
3299
3329
  /**
3300
3330
  * design/91 — **human-review burden** (design/89 §2.4 C2 axis). The wall-clock time a task spent waiting
@@ -4800,6 +4830,14 @@ export interface EngineNotice {
4800
4830
  * mark would have carried, neutralized/length-bounded (tool and agent-type names are
4801
4831
  * host/model-controlled inputs).
4802
4832
  *
4833
+ * - `"delegation.transcript_integrity"` (subagent transcript persistence) — a durable agent row
4834
+ * with a BOUND transcript sessionId met a session store that attests `not_found` for it: the
4835
+ * deployment's declared transcript durability is being contradicted by reality. Announced at
4836
+ * most once per (handle, process) from the continuation read faces (SendMessage preflight /
4837
+ * AgentTranscript's durable leg); the per-call honest refusals are unchanged, and the declared
4838
+ * tier is NOT auto-downgraded (declaration-制 — observation reports, it never re-adjudicates);
4839
+ * `detail: { handle }`.
4840
+ *
4803
4841
  * Deliberately NOT a notice family: brain retry/reconnect liveness (a rate limit, a 5xx, a
4804
4842
  * transient network failure being retried). Those are per-attempt liveness frames with their own
4805
4843
  * frequency semantics and ride the wire `status` channel ({@link BrainStatus}), whose sink the
@@ -5305,6 +5343,28 @@ export interface RunnerDeps {
5305
5343
  * Same single-instance pairing discipline as `backgroundAgentStore` (RB-37): a deployment that
5306
5344
  * composes its own tools must thread the SAME instance everywhere. */
5307
5345
  mailboxStore?: import("./mailbox-store.js").MailboxStore;
5346
+ /**
5347
+ * Subagent transcript persistence — the delegation ENTRY caps (CC parity values: 20 concurrent /
5348
+ * 200 cumulative per session tree; defaults exported as `DELEGATION_MAX_CONCURRENT_DEFAULT` /
5349
+ * `DELEGATION_MAX_PER_SESSION_DEFAULT`). Key = `(scope, rootSessionId)`, full depth (grandchildren
5350
+ * share the tree's pool). `maxConcurrent` bounds running/pending a* handles in this process's
5351
+ * registry (parked does not burn a slot — a suspension is not concurrency; a revival claim counts
5352
+ * like a spawn); `maxCumulativePerSession` bounds the RETAINED-WINDOW cumulative count (registry-
5353
+ * retained + store-retained rows — a reaped row returns its quota; deliberately NOT CC's lifetime-
5354
+ * monotonic session counter, which would require a persistent counting surface this economic bound
5355
+ * does not justify — registered divergence). Refusals are coded (`delegation.concurrency_cap` /
5356
+ * `delegation.session_cap`) with the current value and this knob's name in the text.
5357
+ *
5358
+ * BAD VALUES REFUSE LOUDLY at prepare (`config.delegation_entry_caps`, the #123 posture): a
5359
+ * non-positive/non-integer/NaN member, or a resolved pair where `maxConcurrent` exceeds
5360
+ * `maxCumulativePerSession` (you cannot run more at once than you may ever create) — never a
5361
+ * silent fold to the defaults. Per-replica bound (multi-replica deployments are each honestly
5362
+ * bounded; row-level CAS owns correctness, this cap owns economics).
5363
+ */
5364
+ delegationEntryCaps?: {
5365
+ maxConcurrent?: number;
5366
+ maxCumulativePerSession?: number;
5367
+ };
5308
5368
  /** design/176 — deployment tuning for the always-on peer-message admission gate (SendMessage entry
5309
5369
  * judgment: rate/dedup/hop-chain/queue bounds). Per-field range-validated against the upstream
5310
5370
  * table with out-of-range values falling back to that field's default; there is NO off switch —