@sema-agent/server 3.20.0 → 3.21.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.
@@ -291,6 +291,8 @@ export declare class FleetEventBus {
291
291
  releaseBackgroundChildLane(scope: string | undefined, childUuid: string): void;
292
292
  /** run-leg 车道:该子代是否已有 BCE 真行(有 ⇒ 让位,不铸复合 id 行)。 */
293
293
  backgroundChildLaneRow(scope: string | undefined, childUuid: string): string | undefined;
294
+ /** [2268] 让位迁移料的读面:迁移「只补缺席位」需要看得见存活行现有什么(BCE 是权威车道,不覆盖)。 */
295
+ taskRow(id: string): Readonly<FleetTaskRow> | undefined;
294
296
  /** Upsert a workflow row (MERGE by `id`) + fan out. */
295
297
  publishWorkflow(delta: Partial<FleetWorkflowRow> & {
296
298
  id: string;
@@ -86,6 +86,10 @@ export class FleetEventBus {
86
86
  backgroundChildLaneRow(scope, childUuid) {
87
87
  return this.bceClaims.get(FleetEventBus.bceKey(scope, childUuid));
88
88
  }
89
+ /** [2268] 让位迁移料的读面:迁移「只补缺席位」需要看得见存活行现有什么(BCE 是权威车道,不覆盖)。 */
90
+ taskRow(id) {
91
+ return this.tasks.get(id);
92
+ }
89
93
  // └────────────────────────────────────────────────────────────────────────────────────────────────┘
90
94
  /** Upsert a workflow row (MERGE by `id`) + fan out. */
91
95
  publishWorkflow(delta) {
@@ -172,20 +176,43 @@ export function fleetRunPublisher(bus, run) {
172
176
  * registry ⇒ 永远没有 BCE 真行,让位就等于让这些 agent 从面板消失。那一形保留复合行,并在帧上带
173
177
  * `sourceLane:"run-leg"` 明示这是过渡形——消费端不必再自己发明集合级去重判据。
174
178
  */
175
- const yieldToBackgroundChildLane = (childTaskId, cid) => {
176
- if (bus.backgroundChildLaneRow(run.scope, childTaskId) === undefined)
179
+ const yieldToBackgroundChildLane = (childTaskId, cid, tickName) => {
180
+ const bceRowId = bus.backgroundChildLaneRow(run.scope, childTaskId);
181
+ if (bceRowId === undefined)
177
182
  return false;
178
183
  if (children.has(cid)) {
179
184
  // 我们早于 claim 铸过一行 —— 清场(BCE 侧的后缀扫是同一意图的反应式兜底,两者幂等叠加无害)
180
185
  bus.removeTask(cid);
181
186
  children.delete(cid);
182
- childStartedAt.delete(cid);
187
+ // ⚠️ childStartedAt 故意不删 —— 它是下面 elapsed 供给的计时基准([2268])
183
188
  for (const [k, v] of childByToolCall)
184
189
  if (v === cid)
185
190
  childByToolCall.delete(k);
186
191
  }
192
+ // 🔴 [2268] 迁移料([2269] 认领「迁移」臂):此前让位=删「有名的 run-leg 行」留「无名的 BCE 行」
193
+ // ((unnamed)/0s/详情空白三症状同源)。tick 手上的显示料补给存活行,**只补缺席位** —— BCE 是权威
194
+ // 车道,它已有的 name/真 elapsed 一律不碰。elapsed 供给是持续的(本闸每拍都过),不是一次性冻结;
195
+ // elapsedDonor 记住「这行的 elapsed 是我们供的」,避免把 BCE 自己的真 elapsed 误当缺席重置。
196
+ // transcriptId 不在 run-leg tick 手上,迁不了(诚实范围;那半归 BCE 行自身后续 tick)。
197
+ const row = bus.taskRow(bceRowId);
198
+ if (row) {
199
+ const donorName = row.name === undefined && tickName !== undefined ? redactSecrets(tickName) : undefined;
200
+ if (!childStartedAt.has(cid))
201
+ childStartedAt.set(cid, Date.now());
202
+ if (elapsedDonor.has(cid) || row.elapsedMs === undefined || row.elapsedMs === 0)
203
+ elapsedDonor.add(cid);
204
+ const elapsed = elapsedDonor.has(cid) ? Date.now() - childStartedAt.get(cid) : undefined;
205
+ if (donorName !== undefined || elapsed !== undefined) {
206
+ bus.publishTask({
207
+ id: bceRowId,
208
+ ...(donorName !== undefined ? { name: donorName, agentType: donorName } : {}),
209
+ ...(elapsed !== undefined ? { elapsedMs: elapsed } : {}),
210
+ });
211
+ }
212
+ }
187
213
  return true;
188
214
  };
215
+ const elapsedDonor = new Set(); // [2268] 我们在为哪些 cid 供 elapsed(见上)
189
216
  const sumTokens = (usage) => {
190
217
  const u = usage;
191
218
  if (!u)
@@ -219,8 +246,8 @@ export function fleetRunPublisher(bus, run) {
219
246
  else if (ev.type === "task_progress" && ev.taskId) {
220
247
  // core 1.147 subagent usage tick (per-turn, cumulative) → a CHILD fleet row nested under the run.
221
248
  const cid = childId(ev.taskId);
222
- if (yieldToBackgroundChildLane(ev.taskId, cid))
223
- return; // [2070]① BCE 真行在场 ⇒ 让位不铸
249
+ if (yieldToBackgroundChildLane(ev.taskId, cid, ev.name))
250
+ return; // [2070]① BCE 真行在场 ⇒ 让位不铸([2268] 随迁显示料)
224
251
  children.add(cid);
225
252
  // BC-2 (core 1.151): use the subagent's sanitized display `name` (taskName/agent-type) for the child row.
226
253
  // [2070]③:name 缺席时**不铸**(此前回落 taskId=子代 uuid,人眼乱码)——行 IDENTITY 一直是 `cid`
@@ -273,8 +300,8 @@ export function fleetRunPublisher(bus, run) {
273
300
  if (ev.type !== "task_progress" || !ev.taskId)
274
301
  return;
275
302
  const cid = childId(ev.taskId);
276
- if (yieldToBackgroundChildLane(ev.taskId, cid))
277
- return; // [2070]① BCE 真行在场 ⇒ 让位不铸
303
+ if (yieldToBackgroundChildLane(ev.taskId, cid, ev.name))
304
+ return; // [2070]① BCE 真行在场 ⇒ 让位不铸([2268] 随迁显示料)
278
305
  children.add(cid);
279
306
  const parentId = ev.parentTaskId && ev.parentTaskId !== run.rootTaskId ? childId(ev.parentTaskId) : run.runId;
280
307
  // [WF2-A] redact the forwarded subagent name for parity with the top-level run name (see onEvent above).
@@ -0,0 +1,68 @@
1
+ /**
2
+ * [2252]/[2255]①/[2257] 追加件:409 `conflict.session_active_run` 的**真出路材料**装配(单源)。
3
+ *
4
+ * 事故形:park(suspended/needs_review)占着 session claim,客户端每条新提交吃 409,而旧文案只教
5
+ * cancel(毁灭当前 run)。对 parked 会话,「继续」的唯一合法动作是去**对的** resume 入口决议——但四条
6
+ * resume 入口各认各的 gate.kind,客户端不知道等的是哪种门,只能试错。本模块把答案放进 409 响应体:
7
+ * activeTaskStatus(区分「插话/取消」与「决议/取消」两族出路)
8
+ * pendingGate { kind, decidePath }(parked 且真有 pending checkpoint 时)
9
+ *
10
+ * 🔴 token 永不上 wire(approvals-assistant 纪律:resume 寻址=sessionId,checkpoint token 是秘密能力)。
11
+ * 🔴 失败方向:材料是 best-effort 增强——store 面任何失败都不得挡 409 本体,退化为旧形状,绝不 throw。
12
+ * 🔴 gateKind→入口映射是**这张表在仓里的唯一函数形**;它的知识此前散在 server.ts 三处 guard 文案里
13
+ * (`gate_not_tool_approval`/`gate_not_resumable`/`wake.gate_pending` 的指路句)。三处文案与本表若漂移,
14
+ * test/session-active-conflict-materials.test.ts 的分门用例会红。认不出的 kind ⇒ null(诚实缺席,不铸假门)。
15
+ */
16
+ /** 与 runs.ts/tasks.ts 三个 409 位共享的旧文案(byte-frozen:api-error-text-freeze 门认这句)。 */
17
+ export declare const ACTIVE_RUN_CONFLICT_BASE_TEXT = "session already has an active run \u2014 POST /v1/runs/{activeTaskId}/cancel stops it (same-instance interactive runs abort immediately)";
18
+ export interface ActiveRunConflictBody {
19
+ error: string;
20
+ errorCode: "conflict.session_active_run";
21
+ activeTaskId: string | null;
22
+ activeTaskStatus?: string;
23
+ pendingGate?: {
24
+ kind: string;
25
+ decidePath: string;
26
+ };
27
+ }
28
+ /** gate.kind → 它的那一个 resume 入口(sessionId/taskId 寻址,无秘密)。 */
29
+ export declare function resumeEntryForGate(kind: string, ids: {
30
+ sessionId: string;
31
+ taskId: string;
32
+ }): string | null;
33
+ /** token 泛型:真身是 branded CheckpointToken(秘密能力,只在本函数内部流转,绝不进响应体)。 */
34
+ /**
35
+ * SSE 车道的 done 帧 result(拒绝形)。与 409 body **同一铸体处** —— 此前 tasks.ts 在 res.write 里
36
+ * 手搓内联对象挑键,形状没有名字、没有类型、只活在那一行字符串里(conditional-spread 死键的老坑形)。
37
+ * 这里铸,SDK 的 done 帧 result 类型与本形同车对齐(sdk-api-guarantee-duty),live-contract 套件对已发包真跑。
38
+ */
39
+ export interface ActiveRunConflictDoneResult {
40
+ status: "failed";
41
+ errorMessage: string;
42
+ activeTaskId: string | null;
43
+ activeTaskStatus?: string;
44
+ pendingGate?: {
45
+ kind: string;
46
+ decidePath: string;
47
+ };
48
+ }
49
+ export declare function toDoneFrameResult(body: ActiveRunConflictBody): ActiveRunConflictDoneResult;
50
+ interface ConflictProbeDeps<TToken> {
51
+ runStore?: {
52
+ getRun?: (id: string) => Promise<{
53
+ status?: string;
54
+ } | null | undefined>;
55
+ } | undefined;
56
+ checkpointStore?: {
57
+ peekPendingScope?: (sessionId: string) => Promise<string | null | undefined>;
58
+ findPendingTokenBySession?: (sessionId: string, scope?: string) => Promise<TToken | null | undefined>;
59
+ get?: (token: TToken) => Promise<{
60
+ gate?: {
61
+ kind?: string;
62
+ };
63
+ } | null | undefined>;
64
+ } | undefined;
65
+ }
66
+ export declare function buildActiveRunConflict<TToken>(deps: ConflictProbeDeps<TToken>, sessionId: string, activeTaskId: string | null | undefined): Promise<ActiveRunConflictBody>;
67
+ export {};
68
+ //# sourceMappingURL=active-run-conflict.d.ts.map
@@ -0,0 +1,89 @@
1
+ /**
2
+ * [2252]/[2255]①/[2257] 追加件:409 `conflict.session_active_run` 的**真出路材料**装配(单源)。
3
+ *
4
+ * 事故形:park(suspended/needs_review)占着 session claim,客户端每条新提交吃 409,而旧文案只教
5
+ * cancel(毁灭当前 run)。对 parked 会话,「继续」的唯一合法动作是去**对的** resume 入口决议——但四条
6
+ * resume 入口各认各的 gate.kind,客户端不知道等的是哪种门,只能试错。本模块把答案放进 409 响应体:
7
+ * activeTaskStatus(区分「插话/取消」与「决议/取消」两族出路)
8
+ * pendingGate { kind, decidePath }(parked 且真有 pending checkpoint 时)
9
+ *
10
+ * 🔴 token 永不上 wire(approvals-assistant 纪律:resume 寻址=sessionId,checkpoint token 是秘密能力)。
11
+ * 🔴 失败方向:材料是 best-effort 增强——store 面任何失败都不得挡 409 本体,退化为旧形状,绝不 throw。
12
+ * 🔴 gateKind→入口映射是**这张表在仓里的唯一函数形**;它的知识此前散在 server.ts 三处 guard 文案里
13
+ * (`gate_not_tool_approval`/`gate_not_resumable`/`wake.gate_pending` 的指路句)。三处文案与本表若漂移,
14
+ * test/session-active-conflict-materials.test.ts 的分门用例会红。认不出的 kind ⇒ null(诚实缺席,不铸假门)。
15
+ */
16
+ /** 与 runs.ts/tasks.ts 三个 409 位共享的旧文案(byte-frozen:api-error-text-freeze 门认这句)。 */
17
+ export const ACTIVE_RUN_CONFLICT_BASE_TEXT = "session already has an active run — POST /v1/runs/{activeTaskId}/cancel stops it (same-instance interactive runs abort immediately)";
18
+ /** gate.kind → 它的那一个 resume 入口(sessionId/taskId 寻址,无秘密)。 */
19
+ export function resumeEntryForGate(kind, ids) {
20
+ switch (kind) {
21
+ case "tool_approval":
22
+ case "human":
23
+ case "irreversible_ask":
24
+ case "policy_ask":
25
+ return `/v1/approvals/${ids.sessionId}/decide`;
26
+ case "plan_review":
27
+ return `/v1/assistant/tasks/${ids.taskId}/plan_review`;
28
+ case "resource_limit":
29
+ return `/v1/assistant/tasks/${ids.taskId}/resume`;
30
+ case "task_done":
31
+ return `/v1/sessions/${ids.sessionId}/wake`;
32
+ default:
33
+ return null; // 未知门型:宁缺毋假 —— 客户端仍有 activeTaskStatus + cancel 这条保底真路
34
+ }
35
+ }
36
+ export function toDoneFrameResult(body) {
37
+ return {
38
+ status: "failed",
39
+ errorMessage: body.error,
40
+ activeTaskId: body.activeTaskId,
41
+ ...(body.activeTaskStatus !== undefined ? { activeTaskStatus: body.activeTaskStatus } : {}),
42
+ ...(body.pendingGate !== undefined ? { pendingGate: body.pendingGate } : {}),
43
+ };
44
+ }
45
+ export async function buildActiveRunConflict(deps, sessionId, activeTaskId) {
46
+ const base = {
47
+ error: ACTIVE_RUN_CONFLICT_BASE_TEXT,
48
+ errorCode: "conflict.session_active_run",
49
+ activeTaskId: activeTaskId ?? null,
50
+ };
51
+ if (!activeTaskId)
52
+ return base;
53
+ try {
54
+ const row = await deps.runStore?.getRun?.(activeTaskId);
55
+ const status = row?.status;
56
+ if (status !== "running" && status !== "suspended" && status !== "needs_review")
57
+ return base;
58
+ if (status === "running") {
59
+ return {
60
+ ...base,
61
+ error: "session already has an active run — POST /v1/runs/{activeTaskId}/steer injects a message into the running turn (queued, applied at the next turn boundary), or POST /v1/runs/{activeTaskId}/cancel stops it (same-instance interactive runs abort immediately)",
62
+ activeTaskStatus: status,
63
+ };
64
+ }
65
+ // parked:找 pending checkpoint 的门型,指到它的那一个 resume 入口
66
+ const cs = deps.checkpointStore;
67
+ let pendingGate;
68
+ if (cs?.findPendingTokenBySession) {
69
+ const scope = (await cs.peekPendingScope?.(sessionId)) ?? undefined;
70
+ const token = scope === null ? null : await cs.findPendingTokenBySession(sessionId, scope);
71
+ const kind = token ? (await cs.get?.(token))?.gate?.kind : undefined;
72
+ const decidePath = kind ? resumeEntryForGate(kind, { sessionId, taskId: activeTaskId }) : null;
73
+ if (kind && decidePath)
74
+ pendingGate = { kind, decidePath };
75
+ }
76
+ return {
77
+ ...base,
78
+ error: pendingGate
79
+ ? `session already has an active run — it is parked waiting for a decision (${status}); resolve it via POST ${pendingGate.decidePath}, or POST /v1/runs/{activeTaskId}/cancel abandons it`
80
+ : `session already has an active run — it is parked (${status}); POST /v1/runs/{activeTaskId}/cancel abandons it (its pending decision could not be located on this instance)`,
81
+ activeTaskStatus: status,
82
+ ...(pendingGate ? { pendingGate } : {}),
83
+ };
84
+ }
85
+ catch {
86
+ return base; // 材料装配的任何失败都退化为旧形状 —— 增强绝不成为新故障点
87
+ }
88
+ }
89
+ //# sourceMappingURL=active-run-conflict.js.map
@@ -10,6 +10,7 @@ import { scopedIdempotencyKey } from "../idempotency.js";
10
10
  import { streamSseLog } from "../sse-log.js";
11
11
  import { normalizeRunEventType } from "../../trace/project.js";
12
12
  import { sendJson, sendError, httpErrorCode, sseHeaders } from "../send.js";
13
+ import { buildActiveRunConflict } from "../active-run-conflict.js";
13
14
  import { headerStr, gatedPrincipal, explicitOperatorOk } from "../principal-gate.js";
14
15
  // server.ts 侧的 routeLabel / isBillableSubmitPath 仍要用下面这些正则,故本模块导出(方向恒为 server.ts → routes/*)。
15
16
  export const RUN_ID_RE = /^\/v1\/runs\/([^/]+)(\/events)?$/;
@@ -161,7 +162,8 @@ async function handleRunsBody(req, res, url, ctx, miss) {
161
162
  if (clientTaskId && created.activeTaskId === clientTaskId) {
162
163
  return { status: 202, body: { taskId: clientTaskId, sessionId, status: "running" } };
163
164
  }
164
- return { status: 409, body: { error: "session already has an active run — POST /v1/runs/{activeTaskId}/cancel stops it (same-instance interactive runs abort immediately)", errorCode: "conflict.session_active_run", activeTaskId: created.activeTaskId } };
165
+ // [2255]① 真出路材料:activeTaskStatus + parked pendingGate{kind,decidePath}(best-effort,失败退旧形)
166
+ return { status: 409, body: await buildActiveRunConflict({ runStore, checkpointStore: deps.checkpointStore }, sessionId, created.activeTaskId) };
165
167
  }
166
168
  // Durable F4: persist the resume rebuild inputs (sessionId-keyed) so an operator can resume from any
167
169
  // replica even after this worker is gone — core's checkpoint blob can't carry service scenario context.
@@ -10,6 +10,7 @@ import { turnEndEventData, contextUsageEventData, toolStartEventData, toolEndEve
10
10
  import { cascadeConfig, runMeta } from "../run-meta.js";
11
11
  import { scopedIdempotencyKey } from "../idempotency.js";
12
12
  import { sendJson, sendError, sseHeaders } from "../send.js";
13
+ import { buildActiveRunConflict, toDoneFrameResult } from "../active-run-conflict.js";
13
14
  import { headerStr, gatedPrincipal } from "../principal-gate.js";
14
15
  export async function handleTasks(req, res, url, ctx) {
15
16
  const miss = { fell: false };
@@ -216,10 +217,11 @@ async function handleTasksBody(req, res, url, ctx, miss) {
216
217
  if (created.ok)
217
218
  deps.sessionTitler?.maybeTitle(prepared.spec.sessionId, prepared.spec.objective, typeof prepared.spec.model === "string" ? prepared.spec.model : prepared.spec.model?.id); // fire-and-forget; in-titler dedupe;model 跟 turn([1992]②)
218
219
  if (!created.ok) {
219
- const conflict = { error: "session already has an active run — POST /v1/runs/{activeTaskId}/cancel stops it (same-instance interactive runs abort immediately)", errorCode: "conflict.session_active_run", activeTaskId: created.activeTaskId };
220
+ const conflict = await buildActiveRunConflict({ runStore: deps.runStore, checkpointStore: deps.checkpointStore }, prepared.spec.sessionId, created.activeTaskId); // [2255]① 真出路材料
220
221
  // Headers are already SSE — encode the rejection as the stream's terminal event; the 409 in
221
222
  // the returned resp is for a deduplicated concurrent caller (and is never idem-cached).
222
- res.write(`data: ${JSON.stringify({ type: "done", result: { status: "failed", errorMessage: conflict.error, activeTaskId: conflict.activeTaskId } })}\n\n`);
223
+ // done additive 携带同一份材料 —— 形状铸在 toDoneFrameResult(有名字有类型),不在这行内联挑键。
224
+ res.write(`data: ${JSON.stringify({ type: "done", result: toDoneFrameResult(conflict) })}\n\n`);
223
225
  return { status: 409, body: conflict };
224
226
  }
225
227
  durableTaskId = tid;
@@ -971,7 +973,7 @@ async function handleTasksBody(req, res, url, ctx, miss) {
971
973
  const tid = uuidv7();
972
974
  const created = await deps.runStore.createRun(tid, prepared.spec.sessionId, principal ?? null, deps.instanceId ?? "default", runMeta(prepared, source));
973
975
  if (!created.ok)
974
- return { status: 409, body: { error: "session already has an active run — POST /v1/runs/{activeTaskId}/cancel stops it (same-instance interactive runs abort immediately)", errorCode: "conflict.session_active_run", activeTaskId: created.activeTaskId } };
976
+ return { status: 409, body: await buildActiveRunConflict({ runStore: deps.runStore, checkpointStore: deps.checkpointStore }, prepared.spec.sessionId, created.activeTaskId) }; // [2255]① 真出路材料
975
977
  deps.sessionTitler?.maybeTitle(prepared.spec.sessionId, prepared.spec.objective, typeof prepared.spec.model === "string" ? prepared.spec.model : prepared.spec.model?.id); // fire-and-forget;model 跟 turn([1992]②)
976
978
  durableTaskId = tid;
977
979
  }
@@ -293,8 +293,17 @@ export class FileRunStore {
293
293
  * [1.207 codex M1] 负向形漏 blocked/timeout);claim 释放保持无条件(幂等)。 */
294
294
  async setTerminal(taskId, status, result, error) {
295
295
  const r = this.runs.get(taskId);
296
- if (!r)
296
+ if (!r) {
297
+ // [2255]③ claim 孤儿修:行不在(跨进程窗 —— claim 落盘、行未落/已失;R9 只兜同进程写失败)时,
298
+ // 终局动词仍必须能放锁:cancel 是文案承诺的用户自救路,它对孤儿失效=会话钉死到 reaper/boot。
299
+ // 反查 active 索引找持有该 taskId 的 session(O(claims),claims 数=活跃会话数,小)。
300
+ for (const [sessionId, tid] of this.active)
301
+ if (tid === taskId) {
302
+ this.releaseClaim(sessionId);
303
+ break;
304
+ }
297
305
  return;
306
+ }
298
307
  if (r.status === "running" || r.status === "suspended" || r.status === "needs_review") {
299
308
  r.status = status;
300
309
  r.result = result;
@@ -535,8 +544,10 @@ export class FileRunStore {
535
544
  */
536
545
  async reapSuspended(olderThanMs) {
537
546
  const probe = this.checkpointProbe;
538
- if (!probe)
547
+ if (!probe) {
548
+ this.sweepClaims();
539
549
  return 0;
550
+ } // [2255]③:时间腿无 probe=诚实 NO-OP,但 claim 不变式维护不跟 probe 走
540
551
  const cutoff = Date.now() - olderThanMs;
541
552
  let reaped = 0;
542
553
  for (const r of this.runs.values()) {
@@ -566,8 +577,10 @@ export class FileRunStore {
566
577
  */
567
578
  async failSuspendedWithExpiredCheckpoint() {
568
579
  const probe = this.checkpointProbe;
569
- if (!probe)
580
+ if (!probe) {
581
+ this.sweepClaims();
570
582
  return 0;
583
+ } // [2255]③ 同上
571
584
  let reaped = 0;
572
585
  for (const r of this.runs.values()) {
573
586
  if (r.status !== "suspended" && r.status !== "needs_review")
@@ -127,8 +127,15 @@ export class MemoryRunStore {
127
127
  * [1.207 codex M1] 负向形漏 blocked/timeout);claim 释放保持无条件(幂等)。 */
128
128
  async setTerminal(taskId, status, result, error) {
129
129
  const r = this.runs.get(taskId);
130
- if (!r)
130
+ if (!r) {
131
+ // [2255]③ file 孪生同判据(parity oracle):行缺席时终局动词仍放锁(反查 active)。
132
+ for (const [sessionId, tid] of this.active)
133
+ if (tid === taskId) {
134
+ this.active.delete(sessionId);
135
+ break;
136
+ }
131
137
  return;
138
+ }
132
139
  if (r.status === "running" || r.status === "suspended" || r.status === "needs_review") {
133
140
  r.status = status;
134
141
  r.result = result;
@@ -328,8 +335,10 @@ export class MemoryRunStore {
328
335
  */
329
336
  async reapSuspended(olderThanMs) {
330
337
  const probe = this.checkpointProbe;
331
- if (!probe)
338
+ if (!probe) {
339
+ this.releaseTerminalClaims();
332
340
  return 0;
341
+ } // [2255]③ claim 不变式维护不跟 probe 走
333
342
  const cutoff = Date.now() - olderThanMs;
334
343
  let reaped = 0;
335
344
  for (const r of this.runs.values()) {
@@ -358,8 +367,10 @@ export class MemoryRunStore {
358
367
  */
359
368
  async failSuspendedWithExpiredCheckpoint() {
360
369
  const probe = this.checkpointProbe;
361
- if (!probe)
370
+ if (!probe) {
371
+ this.releaseTerminalClaims();
362
372
  return 0;
373
+ } // [2255]③ claim 不变式维护不跟 probe 走
363
374
  let reaped = 0;
364
375
  for (const r of this.runs.values()) {
365
376
  if (r.status !== "suspended" && r.status !== "needs_review")
@@ -520,6 +520,12 @@ export class SqlRunStore {
520
520
  // claim — the session stays locked until an operator resumes it or reapSuspended expires it. (Was
521
521
  // `<> 'running'`, which would have prematurely unlocked a parked suspended session.)
522
522
  await conn.query(this.q("DELETE ta FROM task_active ta JOIN task_run tr ON ta.task_id = tr.task_id WHERE tr.status NOT IN ('running','suspended','needs_review')", "DELETE FROM task_active ta USING task_run tr WHERE ta.task_id = tr.task_id AND tr.status NOT IN ('running','suspended','needs_review')"));
523
+ // [2255]③ 孤儿 claim 清扫(纵深防御):claim 无对应 task_run 行时,三处 INNER JOIN 释放腿永远碰不到
524
+ // 它。正常路径不产孤儿(createRun 单事务、setTerminal 无条件 DELETE、deleteBySession 同事务对称)——
525
+ // 这形只来自带外(手工 SQL/半截迁移),但一旦出现该 session 只剩 cancel 一条自救路。挂在 reapStale
526
+ // (每 tick 无条件跑)一处即可。同文双方言(关联 NOT EXISTS 指向**另一张**表,两引擎都合法);
527
+ // 无竞态:createRun 的 claim+row 同事务提交,已提交的 claim 必有行。
528
+ await conn.query(this.q("DELETE FROM task_active WHERE NOT EXISTS (SELECT 1 FROM task_run tr WHERE tr.task_id = task_active.task_id)", "DELETE FROM task_active WHERE NOT EXISTS (SELECT 1 FROM task_run tr WHERE tr.task_id = task_active.task_id)"));
523
529
  });
524
530
  return reaped;
525
531
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "3.20.0",
3
+ "version": "3.21.0",
4
4
  "description": "Sema Server — the server/API implementation layer for Sema, wiring core, registry, model providers, and cloud agent execution. Built on @sema-agent/core.",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",
@@ -68,7 +68,7 @@
68
68
  "sharp": "^0.35.3"
69
69
  },
70
70
  "devDependencies": {
71
- "@sema-agent/sdk": "^2.2.0",
71
+ "@sema-agent/sdk": "^2.3.0",
72
72
  "@types/libsodium-wrappers": "^0.7.14",
73
73
  "@types/node": "22.10.2",
74
74
  "@types/pg": "^8.20.0",