@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.
- package/dist/fleet/fleet-bus.d.ts +2 -0
- package/dist/fleet/fleet-bus.js +34 -7
- package/dist/http/active-run-conflict.d.ts +68 -0
- package/dist/http/active-run-conflict.js +89 -0
- package/dist/http/routes/runs.js +3 -1
- package/dist/http/routes/tasks.js +5 -3
- package/dist/plugins/file-run-store.js +16 -3
- package/dist/plugins/memory-run-store.js +14 -3
- package/dist/plugins/run-store-sql.js +6 -0
- package/package.json +2 -2
|
@@ -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;
|
package/dist/fleet/fleet-bus.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
package/dist/http/routes/runs.js
CHANGED
|
@@ -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
|
-
|
|
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 = {
|
|
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
|
-
|
|
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: {
|
|
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.
|
|
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.
|
|
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",
|