@sema-agent/core 5.46.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 (78) hide show
  1. package/CHANGELOG.md +93 -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 +231 -4
  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/governance-codes.d.ts +13 -0
  13. package/dist/core/governance-codes.js +33 -0
  14. package/dist/core/mcp.d.ts +6 -1
  15. package/dist/core/mcp.js +34 -7
  16. package/dist/core/memory-engine/delegation-settlement.d.ts +318 -0
  17. package/dist/core/memory-engine/delegation-settlement.js +661 -0
  18. package/dist/core/memory-engine/engine.d.ts +159 -1
  19. package/dist/core/memory-engine/engine.js +699 -15
  20. package/dist/core/memory-engine/file-backend.d.ts +1 -0
  21. package/dist/core/memory-engine/file-backend.js +3 -1
  22. package/dist/core/memory-engine/frontmatter.d.ts +46 -19
  23. package/dist/core/memory-engine/frontmatter.js +91 -77
  24. package/dist/core/memory-engine/index.d.ts +4 -3
  25. package/dist/core/memory-engine/index.js +3 -2
  26. package/dist/core/memory-engine/layout.d.ts +14 -0
  27. package/dist/core/memory-engine/layout.js +2 -2
  28. package/dist/core/memory-engine/memory-backend-contract.js +43 -0
  29. package/dist/core/memory-engine/origin-clearance.d.ts +66 -0
  30. package/dist/core/memory-engine/origin-clearance.js +84 -0
  31. package/dist/core/memory-engine/provenance-wording.d.ts +50 -0
  32. package/dist/core/memory-engine/provenance-wording.js +15 -0
  33. package/dist/core/memory-engine/tools.d.ts +61 -7
  34. package/dist/core/memory-engine/tools.js +34 -9
  35. package/dist/core/memory-engine/types.d.ts +70 -2
  36. package/dist/core/reminder-disclosure.d.ts +90 -0
  37. package/dist/core/reminder-disclosure.js +64 -0
  38. package/dist/core/runner/prepare-acquire-reconcile.d.ts +6 -0
  39. package/dist/core/runner/prepare-acquire-reconcile.js +1 -1
  40. package/dist/core/runner/prepare-hands-readface.d.ts +4 -0
  41. package/dist/core/runner/prepare-hands-readface.js +1 -0
  42. package/dist/core/runner/prepare-memory.js +50 -15
  43. package/dist/core/runner/prepare-task.d.ts +39 -0
  44. package/dist/core/runner/prepare-task.js +128 -40
  45. package/dist/core/runner/runtask.js +3 -1
  46. package/dist/core/session-reconcile.js +3 -2
  47. package/dist/core/session-store.d.ts +59 -1
  48. package/dist/core/session-store.js +82 -14
  49. package/dist/core/session.d.ts +83 -1
  50. package/dist/core/task-registry-agent.d.ts +28 -0
  51. package/dist/core/task-registry-agent.js +63 -2
  52. package/dist/core/task-registry.d.ts +21 -0
  53. package/dist/core/task-registry.js +4 -1
  54. package/dist/core/types.d.ts +98 -3
  55. package/dist/core/types.js +3 -0
  56. package/dist/core/untrusted-text.d.ts +63 -0
  57. package/dist/core/untrusted-text.js +48 -0
  58. package/dist/core/wiring-manifest.d.ts +35 -0
  59. package/dist/core/wiring-manifest.js +21 -1
  60. package/dist/engine/harness/types.d.ts +36 -1
  61. package/dist/index.d.ts +7 -5
  62. package/dist/index.js +6 -4
  63. package/dist/internal/harness-types.d.ts +1 -0
  64. package/dist/stores/file/index.d.ts +19 -3
  65. package/dist/stores/file/index.js +24 -1
  66. package/dist/stores/file/session-store.d.ts +18 -4
  67. package/dist/stores/file/session-store.js +73 -12
  68. package/dist/tools/fs/fs-pdf.d.ts +12 -1
  69. package/dist/tools/fs/fs-pdf.js +17 -3
  70. package/dist/tools/fs/fs-read.d.ts +2 -1
  71. package/dist/tools/fs/fs-read.js +33 -5
  72. package/dist/tools/fs/fs-shared.d.ts +6 -2
  73. package/dist/tools/fs/index.d.ts +7 -0
  74. package/dist/tools/fs/index.js +1 -1
  75. package/dist/tools/task-list.d.ts +5 -1
  76. package/dist/tools/web.js +21 -2
  77. package/package.json +3 -2
  78. package/test/export-surface.snapshot.json +24 -2
@@ -3446,7 +3446,8 @@ export class Runner {
3446
3446
  gates: [...prepared.humanReviewRef.gates],
3447
3447
  };
3448
3448
  }
3449
- if (rs.counters.finalVerifyInjections > 0 || rs.attach.attachmentsInjected > 0 || rs.counters.repetitionCuts > 0 || rs.counters.repetitionSpared > 0 || rs.counters.approachNoticesSent > 0) {
3449
+ const reminderDisclosuresActive = Object.keys(prepared.reminderDisclosureCounts).length > 0;
3450
+ if (rs.counters.finalVerifyInjections > 0 || rs.attach.attachmentsInjected > 0 || rs.counters.repetitionCuts > 0 || rs.counters.repetitionSpared > 0 || rs.counters.approachNoticesSent > 0 || reminderDisclosuresActive) {
3450
3451
  stats.mechanisms = {
3451
3452
  ...(rs.counters.finalVerifyInjections > 0 ? { finalVerifyInjected: true } : {}),
3452
3453
  ...(rs.counters.finalVerifyInjections > 0 ? { finalVerifyInjections: rs.counters.finalVerifyInjections } : {}),
@@ -3455,6 +3456,7 @@ export class Runner {
3455
3456
  ...(rs.counters.repetitionCuts > 0 ? { repetitionCuts: rs.counters.repetitionCuts } : {}),
3456
3457
  ...(rs.counters.repetitionSpared > 0 ? { repetitionSpared: rs.counters.repetitionSpared } : {}),
3457
3458
  ...(rs.counters.repetitionEvents.length > 0 ? { repetitionEvents: rs.counters.repetitionEvents } : {}),
3459
+ ...(reminderDisclosuresActive ? { reminderDisclosures: { ...prepared.reminderDisclosureCounts } } : {}),
3458
3460
  };
3459
3461
  }
3460
3462
  const result = assembleResult(spec, prepared.sessionId, final, stats, {
@@ -9,7 +9,8 @@ const INTERRUPTED_IDEMPOTENT = "[INTERRUPTED] The previous run ended before this
9
9
  "may not have taken effect. It is safe to REPLAY: re-issuing the same call converges the state to " +
10
10
  "what you intended. If you decide not to re-issue it, verify the current state first.";
11
11
  const INTERRUPTED_NEVER_STARTED = "[INTERRUPTED] The run was aborted before this tool call started. It was never executed and had " +
12
- "no side effects it is safe to re-issue this call if you still need it.";
12
+ "no side effects. If the run was stopped by a pending approval or an unresolved gate, wait for it " +
13
+ "to be resolved before re-issuing; re-issuing is otherwise safe if you still need this call.";
13
14
  const INTERRUPTED_REPEAT_UNKNOWN = "[INTERRUPTED] Same as an earlier interrupted call in this batch: no result was recorded and the outcome is " +
14
15
  "UNKNOWN — verify before relying on it.";
15
16
  const INTERRUPTED_REPEAT_SAFE = "[INTERRUPTED] Same as an earlier interrupted call in this batch: this tool is read-only/idempotent, so just " +
@@ -17,7 +18,7 @@ const INTERRUPTED_REPEAT_SAFE = "[INTERRUPTED] Same as an earlier interrupted ca
17
18
  const INTERRUPTED_REPEAT_IDEMPOTENT = "[INTERRUPTED] Same as an earlier interrupted call in this batch: it is safe to REPLAY — re-issue it, or verify " +
18
19
  "the current state if you decide not to.";
19
20
  const INTERRUPTED_REPEAT_NEVER_STARTED = "[INTERRUPTED] Same as an earlier interrupted call in this batch: it never started and had no side effects — " +
20
- "safe to re-issue.";
21
+ "re-issuing is safe once whatever stopped the run is resolved.";
21
22
  const REPEAT_TEXT = {
22
23
  never_started: INTERRUPTED_REPEAT_NEVER_STARTED,
23
24
  read: INTERRUPTED_REPEAT_SAFE,
@@ -1,5 +1,5 @@
1
1
  import { type SessionRepo } from "../internal/harness.js";
2
- import { type AcquiredSession, type SessionStore, type SessionStoreSummary } from "./session.js";
2
+ import { type AcquiredSession, type PlacedSessionRow, type SessionPlacement, type SessionStore, type SessionStoreSummary } from "./session.js";
3
3
  import type { StoreDurability } from "./checkpoint-store.js";
4
4
  import { SESSION_DEFAULT_TTL_DAYS } from "../config/defaults.js";
5
5
  export { SESSION_DEFAULT_TTL_DAYS };
@@ -36,6 +36,21 @@ export interface TtlSessionStoreOptions {
36
36
  * fail-closed as process-local (under-promise, never over-promise).
37
37
  */
38
38
  durability?: StoreDurability;
39
+ /**
40
+ * Subagent transcript persistence — the store's per-placement durability declaration (see
41
+ * {@link SessionStore.placements}). Same declaration rule as {@link durability}: this cache layer
42
+ * cannot inspect whether the injected repo persists placed sessions, so the DEPLOYMENT that wired
43
+ * the repo states the fact (`FileStorageBackend` passes `{subagent:{durability:"durable"}}`).
44
+ * Declaring it also arms the cache-layer placement obligations (list exclusion, claim refusal,
45
+ * release-is-deletion, sweep-never-deletes) — they key on the repo-persisted tuple, which a repo
46
+ * that ignores `placement` never mints, so declaring over such a repo is the deployment lying to
47
+ * itself (declaration-制, wiring-manifest posture).
48
+ */
49
+ placements?: {
50
+ subagent?: {
51
+ durability: StoreDurability;
52
+ };
53
+ };
39
54
  }
40
55
  /**
41
56
  * TTL-cached {@link SessionStore} over a pluggable {@link SessionRepo}.
@@ -59,6 +74,18 @@ export declare class TtlSessionStore implements SessionStore {
59
74
  readonly retention: "none";
60
75
  /** design/173 §2.3 — see {@link TtlSessionStoreOptions.durability} for the declaration rules. */
61
76
  readonly durability?: StoreDurability;
77
+ /** Subagent transcript persistence — see {@link TtlSessionStoreOptions.placements}. */
78
+ readonly placements?: {
79
+ subagent?: {
80
+ durability: StoreDurability;
81
+ };
82
+ };
83
+ /** Placed-partition enumeration, present only when the backing repo can enumerate placements
84
+ * (see {@link SessionStore.listPlaced} — absence means the partition-GC leg is unavailable). */
85
+ readonly listPlaced?: (kind: "subagent", opts?: {
86
+ olderThanMs?: number;
87
+ scope?: string;
88
+ }) => Promise<PlacedSessionRow[]>;
62
89
  private repo;
63
90
  private entries;
64
91
  /** Session-ownership rows (the multi-tenant admission capability face — see SessionStore.ownerOf).
@@ -68,6 +95,20 @@ export declare class TtlSessionStore implements SessionStore {
68
95
  private owners;
69
96
  /** In-flight acquisitions keyed by id, so concurrent acquire(sameId) share one session. */
70
97
  private pending;
98
+ /** Subagent transcript persistence (codex 323-r2) — placed ids whose durable deletion FAILED: the
99
+ * placed fact for the retry lives HERE, not on a retained cache entry, because the repo may have
100
+ * torn down the shared session authority before its removal failed (the File repo closes the
101
+ * append fd pre-rmSync) — a retained entry would serve a session whose log is closed while the
102
+ * file still exists (append → log_closed, transcript revival broken until restart). The entry is
103
+ * dropped instead, so a re-acquire replays a FRESH authority from disk; success clears the row. */
104
+ private pendingPlacedDeletes;
105
+ /** Subagent transcript persistence (codex 323-r3) — placed deletions IN FLIGHT: acquire awaits the
106
+ * settlement before resolving the id, because the repo's delete tears down the shared session
107
+ * authority mid-flight — an acquire racing the await window could capture (or rebuild and then
108
+ * lose) an authority the delete is about to close, and the caller would hold a session whose
109
+ * first append answers log_closed while the file still exists. The gate is per-id and bounded by
110
+ * the delete's own settlement (both outcomes release it). */
111
+ private deletingPlaced;
71
112
  /** Sessions pinned by a design/45 checkpoint — skipped by idle sweep until unpinned (B6/§5). */
72
113
  private pinned;
73
114
  private defaultTtlMs;
@@ -77,7 +118,24 @@ export declare class TtlSessionStore implements SessionStore {
77
118
  /** Get an existing session by id (resuming from the repo if needed), or create one. */
78
119
  acquire(sessionId?: string, opts?: {
79
120
  requireExisting?: boolean;
121
+ placement?: SessionPlacement;
80
122
  }): Promise<AcquiredSession>;
123
+ /**
124
+ * Placement obligation 6 (claim closure): an EXISTING placed session refuses the claim-form
125
+ * acquire — no `requireExisting` (the read/revive form, obligation 5) and no creation placement
126
+ * (the trusted-internals form; the creating call and its dedup/retry re-acquires stay legal, and
127
+ * placement immutability means the argument can never REWRITE anything — the persisted tuple wins).
128
+ *
129
+ * …and the MIRROR direction (codex 323-r1 F1 — the pre-claim SQUAT race): the child session id is
130
+ * published on the spawn frame BEFORE the child's prepare creates the session, so a concurrent
131
+ * claim-form acquire could win first creation and hand the trusted placement caller an ORDINARY
132
+ * session — host-listable, never really-deleted, the declared full tier falsified in silence. On
133
+ * a store that DECLARES the partition (an ignoring store legally drops placements), an acquire
134
+ * carrying a placement must therefore come back with a PERSISTED tuple whose join identity
135
+ * matches the declaration — a missing or foreign tuple refuses loudly, on every resolution path
136
+ * (cache hit, pending-promise inherit, open, idempotent re-create).
137
+ */
138
+ private assertPlacementAdmission;
81
139
  /** Resume an existing session from the repo, or create it if the backend has no such id (unless
82
140
  * `requireExisting`, in which case a genuinely-missing id fails loud instead — design/114 Phase3). */
83
141
  private openOrCreate;
@@ -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
  }