@sema-agent/server 7.5.0 → 7.7.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/LICENSE +1 -1
- package/README.md +18 -3
- package/README.zh-CN.md +14 -3
- package/USAGE.md +100 -2
- package/dist/approval-reconciler.d.ts +1 -1
- package/dist/approval-reconciler.js +6 -5
- package/dist/boot/budget-tracing.js +1 -1
- package/dist/boot/config-center.js +17 -3
- package/dist/boot/execution-env.js +1 -1
- package/dist/boot/parked-revive-gate.d.ts +78 -0
- package/dist/boot/parked-revive-gate.js +114 -0
- package/dist/boot/resolve-spec.d.ts +8 -20
- package/dist/boot/resolve-spec.js +40 -125
- package/dist/budget.d.ts +1 -1
- package/dist/budget.js +1 -1
- package/dist/capabilities/hands-lane.d.ts +91 -0
- package/dist/capabilities/hands-lane.js +104 -0
- package/dist/capabilities/repo-tools.d.ts +1 -1
- package/dist/capabilities/repo-tools.js +8 -2
- package/dist/capabilities/scenarios.d.ts +49 -2
- package/dist/capabilities/scenarios.js +84 -16
- package/dist/config-provider.d.ts +1 -0
- package/dist/config-provider.js +23 -3
- package/dist/config-types.d.ts +5 -3
- package/dist/config.js +5 -4
- package/dist/deployment-governance.d.ts +168 -0
- package/dist/deployment-governance.js +206 -0
- package/dist/fleet/fleet-bus.d.ts +11 -1
- package/dist/fleet/fleet-bus.js +43 -0
- package/dist/fleet/fleet-terminal-window.d.ts +98 -0
- package/dist/fleet/fleet-terminal-window.js +319 -0
- package/dist/hooks/hook-runner.js +11 -0
- package/dist/http/route-ctx.d.ts +7 -1
- package/dist/http/routes/approvals-assistant.js +6 -7
- package/dist/http/routes/fleet.js +164 -14
- package/dist/http/routes/runs.js +7 -2
- package/dist/http/routes/tasks.js +5 -4
- package/dist/http/routes/trace-usage.js +3 -4
- package/dist/http/send.d.ts +23 -0
- package/dist/http/send.js +23 -0
- package/dist/http/server.d.ts +10 -0
- package/dist/http/server.js +28 -12
- package/dist/http/sse-log.js +3 -4
- package/dist/leader/diffout.d.ts +10 -0
- package/dist/leader/diffout.js +14 -2
- package/dist/leader/diffup.js +3 -2
- package/dist/leader/planner.js +7 -0
- package/dist/main.js +79 -32
- package/dist/observability/fail-open.d.ts +4 -0
- package/dist/observability/fail-open.js +4 -0
- package/dist/observability/tool-trace.d.ts +13 -0
- package/dist/observability/tool-trace.js +14 -0
- package/dist/orchestration/workflow-notify-journal.d.ts +1 -1
- package/dist/orchestration/workflow-notify-journal.js +14 -34
- package/dist/plugins/approval-ask-store-sql.d.ts +33 -0
- package/dist/plugins/approval-ask-store-sql.js +66 -33
- package/dist/plugins/background-agent-store-sql.js +16 -16
- package/dist/plugins/breaker-state-sql.js +2 -2
- package/dist/plugins/checkpoint-store-sql.d.ts +5 -2
- package/dist/plugins/checkpoint-store-sql.js +5 -2
- package/dist/plugins/image-bake-store-sql.d.ts +1 -1
- package/dist/plugins/image-bake-store-sql.js +27 -27
- package/dist/plugins/image-index-sql.js +15 -15
- package/dist/plugins/mailbox-store-sql.js +3 -3
- package/dist/plugins/memory-engine-pg.js +9 -9
- package/dist/plugins/memory-engine-tidb.js +7 -7
- package/dist/plugins/memory-sync-store-pg.js +13 -13
- package/dist/plugins/memory-sync-store-tidb.js +5 -5
- package/dist/plugins/outcome-ledger-sql.js +7 -7
- package/dist/plugins/pg-cost-quota.js +3 -3
- package/dist/plugins/pg-pool.js +84 -75
- package/dist/plugins/pg-rate-limiter.js +3 -3
- package/dist/plugins/pg-session-storage.d.ts +1 -1
- package/dist/plugins/pg-session-storage.js +12 -13
- package/dist/plugins/remote-env-host.js +3 -1
- package/dist/plugins/remote-env-local-docker.js +6 -3
- package/dist/plugins/remote-env-ssh.d.ts +13 -1
- package/dist/plugins/roster-store-sql.js +8 -8
- package/dist/plugins/store-contracts.d.ts +19 -0
- package/dist/plugins/store-contracts.js +42 -0
- package/dist/plugins/task-attachment-store.js +5 -5
- package/dist/plugins/task-list-store-sql.js +1 -1
- package/dist/plugins/tidb-cost-quota.js +1 -1
- package/dist/plugins/tidb-pool.js +83 -60
- package/dist/plugins/tidb-rate-limiter.js +1 -1
- package/dist/plugins/tidb-session-store.js +2 -5
- package/dist/plugins/tool-result-store-sql.js +2 -2
- package/dist/plugins/usage-window-store-sql.js +13 -13
- package/dist/plugins/write-behind-counter.d.ts +10 -2
- package/dist/plugins/write-behind-counter.js +13 -3
- package/dist/resource-suspend.d.ts +3 -1
- package/dist/resource-suspend.js +3 -1
- package/dist/run-local.d.ts +73 -1
- package/dist/run-local.js +160 -9
- package/dist/runs.d.ts +3 -1
- package/dist/runs.js +3 -1
- package/dist/security.d.ts +12 -0
- package/dist/security.js +12 -0
- package/dist/session-sync-kernel.d.ts +13 -0
- package/dist/session-sync-kernel.js +13 -0
- package/dist/trace/core-keyset-guard.d.ts +1 -1
- package/dist/trace/project.d.ts +10 -1
- package/dist/trace/project.js +31 -0
- package/package.json +3 -3
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { FLEET_SNAPSHOT_TERMINAL_MAX_ROWS, isTerminalWorkflowRowStatus, recentTerminalWorkflowRows } from "../../fleet/fleet-terminal-window.js";
|
|
1
2
|
import { taskNotificationFoldKey } from "../../orchestration/workflow-completion-inbox.js";
|
|
2
3
|
import { sendJson, sendError, sseHeaders } from "../send.js";
|
|
3
4
|
import { gatedPrincipal } from "../principal-gate.js";
|
|
@@ -44,7 +45,16 @@ async function handleFleetBody(req, res, url, ctx, miss) {
|
|
|
44
45
|
// (a non-shell observer following one session). Default (absent) keeps the frame-即-投递 semantics —
|
|
45
46
|
// today's only session-bound consumer IS the model-injecting shell.
|
|
46
47
|
const observeOnly = fleetParams.get("observe") === "1";
|
|
47
|
-
|
|
48
|
+
// #189:终态行窗的 durable 读交给 streamFleet 在**订阅注册之后**做(见那里的缓冲段)——放在这里
|
|
49
|
+
// (开流之前)会让"帧已扇出而本连接尚未订阅"的窗口凭空多出一个读的时长,codex R1-H1 红先复现过。
|
|
50
|
+
// [3156] 轮2 真红修:终态窗的查询键必须与可见性判据**同源**——fleetWide 连接(callerScope=null,
|
|
51
|
+
// visW 放行全 scope)此前窗却按 caller principal("default")查,而行的 scope 是提交方自报
|
|
52
|
+
// principal 的透传值(cli 壳本地单用户模式自发 "anon:shell-live";引擎不发明该字面量,[3225]
|
|
53
|
+
// 后续侦察定谳)⇒ durable pull 恒 miss。fleetWide ⇒ null(窗走进程内 seed 缓存,全 scope)。
|
|
54
|
+
// 读侧 `?? "default"` 三处与 core 写侧铸链同源(core prepare-task `spec.principal ?? "default"`,
|
|
55
|
+
// 其 CHANGELOG 成文「Single-tenant spelling is an explicit scope:"default"」)——在 core 撤其
|
|
56
|
+
// 写侧铸点前,server 单方面撤读侧会把无主部署的 durable pull 面弄瞎,故保持同源不动。
|
|
57
|
+
await streamFleet(req, res, deps.fleetBus, fleetWide ? null : (principal ?? "default"), callerSession || null, observeOnly ? undefined : deps.workflowCompletionInbox, { store: deps.workflowRunStore, scope: fleetWide ? null : (principal ?? "default"), pullScope: principal ?? "default", ready: deps.fleetRecoveryDone });
|
|
48
58
|
return;
|
|
49
59
|
}
|
|
50
60
|
miss.fell = true;
|
|
@@ -54,7 +64,7 @@ async function handleFleetBody(req, res, url, ctx, miss) {
|
|
|
54
64
|
* else owner-scoped (only rows whose `scope` matches). The tenant-internal `scope` field is STRIPPED from every
|
|
55
65
|
* emitted row (it's a gate input, never rendered). A removal is forwarded ONLY for a row this connection actually
|
|
56
66
|
* saw (no id-existence oracle for an unseen tenant's run). Heartbeat + dual-close cleanup unsubscribe the bus. */
|
|
57
|
-
function streamFleet(req, res, bus, callerScope, callerSession = null, completionInbox) {
|
|
67
|
+
function streamFleet(req, res, bus, callerScope, callerSession = null, completionInbox, terminal) {
|
|
58
68
|
return new Promise((resolve) => {
|
|
59
69
|
sseHeaders(res);
|
|
60
70
|
// [1508] 1.247 `bgNotifyFailClosed` — the GENERATION marker cli asked for ([1500]📌/[1502] F2): declares
|
|
@@ -81,19 +91,68 @@ function streamFleet(req, res, bus, callerScope, callerSession = null, completio
|
|
|
81
91
|
// subscriber gets a content-scrubbed projection (ids/status/progress/timing kept); a fleet-wide observer
|
|
82
92
|
// (trace/dashboard, callerSession === null) keeps the full row. Rows tagged with the caller's own session
|
|
83
93
|
// pass sessionOk and are never scrubbed (r.sessionId present ⇒ it equals callerSession here).
|
|
94
|
+
// codex R6:FleetTaskRow 逐键处置表——`satisfies Record<keyof FleetTaskRow,…>` 使**任何**行新键先在
|
|
95
|
+
// 编译期红(TS2741),强迫对跨会话可见性表态;"keep" 键必须同步进下方白名单构造,配套钉在
|
|
96
|
+
// test/fleet-bg-child.test.ts(R6 格:kept 键集 ⊆ 表内 keep∪rewrite 集)。
|
|
97
|
+
const SCOPED_TASK_KEY_DISPOSITION = {
|
|
98
|
+
id: "keep", parentId: "keep", parentToolCallId: "keep", workflowRunId: "keep", status: "keep",
|
|
99
|
+
startedAt: "keep", elapsedMs: "keep", tokens: "keep", queuedCount: "keep",
|
|
100
|
+
awaitingPlanApproval: "keep", toolUses: "keep", usage: "keep", resumable: "keep", sourceLane: "keep",
|
|
101
|
+
name: "rewrite", scope: "strip", sessionId: "strip",
|
|
102
|
+
description: "scrub", agentType: "scrub", agentName: "scrub", currentAction: "scrub",
|
|
103
|
+
currentTool: "scrub", transcriptId: "scrub", stoppedBy: "scrub", editedFiles: "scrub",
|
|
104
|
+
};
|
|
105
|
+
void SCOPED_TASK_KEY_DISPOSITION;
|
|
84
106
|
const stripT = (r) => {
|
|
85
107
|
const { scope: _s, sessionId: _sid, ...rest } = r;
|
|
86
108
|
if (callerSession !== null && r.sessionId === undefined) {
|
|
87
|
-
|
|
88
|
-
|
|
109
|
+
// [3136]②→codex R6(确认轮):黑名单形被两次击穿(currentTool、editedFiles)——同一信任边界的键
|
|
110
|
+
// 靠逐个想起来列,漏一个就是跨会话内容泄漏。改**白名单构造**:kept 对象逐键显式拼,未列键默认
|
|
111
|
+
// 不过;新键先过上方处置表的编译期门。
|
|
112
|
+
return {
|
|
113
|
+
id: rest.id,
|
|
114
|
+
status: rest.status,
|
|
115
|
+
name: "(unattributed)",
|
|
116
|
+
...(rest.parentId !== undefined ? { parentId: rest.parentId } : {}),
|
|
117
|
+
...(rest.parentToolCallId !== undefined ? { parentToolCallId: rest.parentToolCallId } : {}),
|
|
118
|
+
...(rest.workflowRunId !== undefined ? { workflowRunId: rest.workflowRunId } : {}),
|
|
119
|
+
...(rest.startedAt !== undefined ? { startedAt: rest.startedAt } : {}),
|
|
120
|
+
...(rest.elapsedMs !== undefined ? { elapsedMs: rest.elapsedMs } : {}),
|
|
121
|
+
...(rest.tokens !== undefined ? { tokens: rest.tokens } : {}),
|
|
122
|
+
...(rest.queuedCount !== undefined ? { queuedCount: rest.queuedCount } : {}),
|
|
123
|
+
...(rest.awaitingPlanApproval !== undefined ? { awaitingPlanApproval: rest.awaitingPlanApproval } : {}),
|
|
124
|
+
...(rest.toolUses !== undefined ? { toolUses: rest.toolUses } : {}),
|
|
125
|
+
...(rest.usage !== undefined ? { usage: rest.usage } : {}),
|
|
126
|
+
...(rest.resumable !== undefined ? { resumable: rest.resumable } : {}),
|
|
127
|
+
...(rest.sourceLane !== undefined ? { sourceLane: rest.sourceLane } : {}),
|
|
128
|
+
};
|
|
89
129
|
}
|
|
90
130
|
return rest;
|
|
91
131
|
};
|
|
132
|
+
// A-002.5(#194 病族 F2):workflow 行与 task 行同一条降权跨会话边界——stripT 的黑名单三次被
|
|
133
|
+
// 击穿后改了白名单,stripW 当时漏改(同函数体孪生)。处置表同姿势:行加新键先编译红强迫表态。
|
|
134
|
+
const SCOPED_WORKFLOW_KEY_DISPOSITION = {
|
|
135
|
+
id: "keep", status: "keep", doneCount: "keep", totalCount: "keep", failedCount: "keep",
|
|
136
|
+
startedCount: "keep", elapsedMs: "keep", tokens: "keep",
|
|
137
|
+
name: "rewrite", scope: "strip", sessionId: "strip",
|
|
138
|
+
description: "scrub",
|
|
139
|
+
};
|
|
140
|
+
void SCOPED_WORKFLOW_KEY_DISPOSITION;
|
|
92
141
|
const stripW = (r) => {
|
|
93
142
|
const { scope: _s, sessionId: _sid, ...rest } = r;
|
|
94
143
|
if (callerSession !== null && r.sessionId === undefined) {
|
|
95
|
-
|
|
96
|
-
return {
|
|
144
|
+
// 白名单构造:kept 逐键显式拼,未列键默认不过(与 stripT 同形;"keep" 键必须同步进处置表)。
|
|
145
|
+
return {
|
|
146
|
+
id: rest.id,
|
|
147
|
+
status: rest.status,
|
|
148
|
+
name: "(unattributed)",
|
|
149
|
+
...(rest.doneCount !== undefined ? { doneCount: rest.doneCount } : {}),
|
|
150
|
+
...(rest.totalCount !== undefined ? { totalCount: rest.totalCount } : {}),
|
|
151
|
+
...(rest.failedCount !== undefined ? { failedCount: rest.failedCount } : {}),
|
|
152
|
+
...(rest.startedCount !== undefined ? { startedCount: rest.startedCount } : {}),
|
|
153
|
+
...(rest.elapsedMs !== undefined ? { elapsedMs: rest.elapsedMs } : {}),
|
|
154
|
+
...(rest.tokens !== undefined ? { tokens: rest.tokens } : {}),
|
|
155
|
+
};
|
|
97
156
|
}
|
|
98
157
|
return rest;
|
|
99
158
|
};
|
|
@@ -102,14 +161,18 @@ function streamFleet(req, res, bus, callerScope, callerSession = null, completio
|
|
|
102
161
|
// 单点注入:调用方已带 type 的帧(hook_notice/bg_notification)保持原值。
|
|
103
162
|
const send = (event, data) => { if (!res.writableEnded)
|
|
104
163
|
res.write(`event: ${event}\ndata: ${JSON.stringify({ type: event, ...data })}\n\n`); };
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
164
|
+
/** #189:快照里那些**按窗保留**的 workflow 行 id(来自 durable 终态窗,或握手期间才翻终态的行)
|
|
165
|
+
* —— 见 workflow / workflow_remove 两臂。 */
|
|
166
|
+
const retainedTerminalWf = new Set();
|
|
167
|
+
/** 现在是否正在重放握手期缓冲(只在这段里,终态行帧才升级为"保留行")。 */
|
|
168
|
+
let replayingBuffer = false;
|
|
169
|
+
/** [3193] codex R4:首见即终态的历史行保留**预算**(每连接)。retainedTerminalWf 本身不积累
|
|
170
|
+
* (每个 id 的撤行帧即刻消费),真正会无界的是「被豁免撤行、留在面板与 seenWf 里的历史行数」。
|
|
171
|
+
* 预算=终态窗容量同阶(×2);耗尽后退回旧「终帧→撤行」契约(行瞬态可见)——有界降级,不吞帧。
|
|
172
|
+
* 快照径的保留行不占预算(已由窗的行数+窗龄双界)。 */
|
|
173
|
+
let retainedHistoryBudget = FLEET_SNAPSHOT_TERMINAL_MAX_ROWS * 2;
|
|
174
|
+
/** 一条增量帧的处理(**先注册订阅、后组装快照**,故与快照解耦成具名函数——见下方缓冲段)。 */
|
|
175
|
+
const handleFrame = (frame) => {
|
|
113
176
|
switch (frame.type) {
|
|
114
177
|
case "task":
|
|
115
178
|
if (visT(frame.row)) {
|
|
@@ -119,6 +182,19 @@ function streamFleet(req, res, bus, callerScope, callerSession = null, completio
|
|
|
119
182
|
break;
|
|
120
183
|
case "workflow":
|
|
121
184
|
if (visW(frame.row)) {
|
|
185
|
+
// #189 / codex R4-M3 → [3193] ①a 泛化:**本连接首见即终态**的行(重放期或活径皆然)与终态窗
|
|
186
|
+
// 里的行是同一件事——它对这条连接从来不是"活跃集成员",是历史;它的撤行帧描述的活跃集离场
|
|
187
|
+
// 对本连接无意义,照发只会让"看不看得见刚结束的 workflow"取决于 recover 落在握手窗哪一侧
|
|
188
|
+
// (cli [3193] 插桩实证:落在重放窗内=保留可见,落在重放窗后=终帧+撤行背靠背、行瞬现即消)。
|
|
189
|
+
// ⇒ 判据=「重放期 ∪ 首见即终态」并集(#189 原判据的严格超集):重放期臂保住"快照见过
|
|
190
|
+
// running、缓冲里才翻终态"的行(R4-M3);首见臂保住活径上 recover 晚到的行(①a)。见过
|
|
191
|
+
// running 的行在活径仍按既有契约走(终帧→撤行),在线消费端语义不变。豁免仍一次性,集合有界如旧。
|
|
192
|
+
// [3193] codex R5:已在保留集里的 id(快照径收的)不重复扣预算——否则握手重放把同一批行
|
|
193
|
+
// 按"快照+重放"记两次账,40 预算被快照行白耗光,其后真正的首见历史行立刻失去豁免。
|
|
194
|
+
if ((replayingBuffer || !seenWf.has(frame.row.id)) && isTerminalWorkflowRowStatus(frame.row.status) && !retainedTerminalWf.has(frame.row.id) && retainedHistoryBudget > 0) {
|
|
195
|
+
retainedTerminalWf.add(frame.row.id);
|
|
196
|
+
retainedHistoryBudget--;
|
|
197
|
+
}
|
|
122
198
|
seenWf.add(frame.row.id);
|
|
123
199
|
send("workflow", { row: stripW(frame.row), ts: frame.ts });
|
|
124
200
|
}
|
|
@@ -128,6 +204,17 @@ function streamFleet(req, res, bus, callerScope, callerSession = null, completio
|
|
|
128
204
|
send("task_remove", { id: frame.id, ts: frame.ts });
|
|
129
205
|
break;
|
|
130
206
|
case "workflow_remove":
|
|
207
|
+
// 🔴 #189 / codex R2-M1:按窗**刻意保留**的历史行不受本连接的撤行帧影响。行在 durable 读
|
|
208
|
+
// 期间翻终态时,活写路径的「终帧 + 撤行」会落进缓冲,重放到这里就会把快照刚保住的那一行
|
|
209
|
+
// 又撤掉 —— 于是「看不看得见刚结束的 workflow」重新取决于连接时机(晚 1ms 连上的人看得见,
|
|
210
|
+
// 撞上这一拍的人看不见),正是本 issue 要消灭的竞态。撤行帧描述的是**活跃集**离场,而这些
|
|
211
|
+
// 行进快照时就已经是终态历史,不在活跃集语义里。id 不复用(runId 每次新铸,终态不回滚),
|
|
212
|
+
// 故这层豁免既有界又不会误伤后来的真活行。
|
|
213
|
+
// 豁免**一次性**:配对的那一帧撤行到达即消费(codex R5-M3)。否则这个 id 被永久噤声——
|
|
214
|
+
// 同 id 若之后又出现活行(帧乱序/未来的行复用),它的撤行帧也会被吞,面板留一条撤不掉的死行;
|
|
215
|
+
// 顺带,每条长连接的保留集不再随握手期完成数无界增长。
|
|
216
|
+
if (retainedTerminalWf.delete(frame.id))
|
|
217
|
+
break;
|
|
131
218
|
if (seenWf.delete(frame.id))
|
|
132
219
|
send("workflow_remove", { id: frame.id, ts: frame.ts });
|
|
133
220
|
break;
|
|
@@ -217,6 +304,21 @@ function streamFleet(req, res, bus, callerScope, callerSession = null, completio
|
|
|
217
304
|
default:
|
|
218
305
|
break; // snapshot frames are connect-time only, never fanned out
|
|
219
306
|
}
|
|
307
|
+
};
|
|
308
|
+
// 🔴 #189 / codex R1-H1:订阅**先于**快照注册,快照组装期间到达的帧先进缓冲、发完快照再按序重放。
|
|
309
|
+
// 快照第一次有了一段 await(durable 终态窗读),而 fleet bus 只扇给**当前**订阅者、不重放 —— 先读后
|
|
310
|
+
// 订阅就等于开了一个"这段时间的帧对本连接不存在"的窗口(bg_notification 是模型注入面,丢一帧=这次
|
|
311
|
+
// 完成回执要等到下次开流才由 durable inbox 补;task_remove/hook_notice 同理)。
|
|
312
|
+
// 顺序安全:`bus.snapshot()` 取在**读之后**,缓冲里的行帧描述的都是快照那一刻或更早的状态,重放
|
|
313
|
+
// 只会把同一状态再叠一次(upsert 幂等);撤行帧仍按 seen 集判(没见过的行不发撤除)。
|
|
314
|
+
// 缓冲无显式上限:窗口被读预算硬界在 2s,且缓冲存的是**帧对象引用**(不复制行);设一个会丢帧的
|
|
315
|
+
// 上限反而正好丢掉这段代码要保住的东西。
|
|
316
|
+
let buffered = [];
|
|
317
|
+
const unsubscribe = bus.subscribe((frame) => {
|
|
318
|
+
if (buffered !== null)
|
|
319
|
+
buffered.push(frame);
|
|
320
|
+
else
|
|
321
|
+
handleFrame(frame);
|
|
220
322
|
});
|
|
221
323
|
// Real frame (not an SSE comment) — a per-frame-parsing BFF drops comments (zero-frame window downstream)
|
|
222
324
|
const hb = setInterval(() => { if (!res.writableEnded)
|
|
@@ -234,8 +336,56 @@ function streamFleet(req, res, bus, callerScope, callerSession = null, completio
|
|
|
234
336
|
res.end();
|
|
235
337
|
resolve();
|
|
236
338
|
};
|
|
339
|
+
// 关闭钩子在 await **之前**挂上:快照还在组装时对端就断了,也照常收摊(不然那段时间的断连没人处理)。
|
|
237
340
|
req.on("close", shutdown);
|
|
238
341
|
res.on("close", shutdown);
|
|
342
|
+
void (async () => {
|
|
343
|
+
// [3193] 病理1:boot recover(判死前世 running 行)与快照组装赛跑——SIGKILL 后壳重连常抢在
|
|
344
|
+
// recover 之前,此刻 seed/durable 两径皆空,快照永远缺那一行。屏障=recovery 完成 promise
|
|
345
|
+
// (main.ts 铸,memory 后端/无 gate 部署=已 resolve),**有界**等待(帧订阅已挂上,等待期帧全
|
|
346
|
+
// 进缓冲不丢):超时=recover 真挂了,按旧行为出快照(可用性路径,行还有 first-sight-terminal
|
|
347
|
+
// 豁免与下次重连兜底),不无限拖住连接。
|
|
348
|
+
if (terminal?.ready !== undefined) {
|
|
349
|
+
await Promise.race([terminal.ready, new Promise((r) => { const t = setTimeout(r, 3000); if (typeof t.unref === "function")
|
|
350
|
+
t.unref(); })]);
|
|
351
|
+
if (done || res.writableEnded)
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
// #189:durable 近期终态行窗(有界 + 永不抛;详见 fleet-terminal-window.ts)。
|
|
355
|
+
const terminalWindow = terminal === undefined ? [] : await recentTerminalWorkflowRows(terminal.store, terminal.scope, terminal.pullScope);
|
|
356
|
+
if (done || res.writableEnded)
|
|
357
|
+
return; // 组装期间断连:shutdown 已 unsubscribe,不再发任何东西
|
|
358
|
+
// connect-time snapshot (filtered + scope-stripped) — the visible rows become this connection's "seen" set.
|
|
359
|
+
const snap = bus.snapshot();
|
|
360
|
+
const tasks = snap.tasks.filter(visT);
|
|
361
|
+
const live = snap.workflows.filter(visW);
|
|
362
|
+
// #189:活行(bus 的活跃集)+ durable 终态窗。
|
|
363
|
+
// · 一行一 id:同 id 两侧都有(行刚翻转的那一拍)时 **活行优先** —— 活行是唯一写者写的当前态,
|
|
364
|
+
// 终态窗是读时快照,后者覆盖前者会让面板倒退。
|
|
365
|
+
// · 并入行同样过 `visW`(scope + `?session=`),与活行**同一套判据**:终态行泄跨租户是事故。
|
|
366
|
+
// · bus 的 Map **不被触碰** —— 单写者不变量原样:活跃集里永远没有终态行,增量帧语义逐字不变。
|
|
367
|
+
const liveIds = new Set(live.map((r) => r.id));
|
|
368
|
+
const retained = terminalWindow.filter((r) => !liveIds.has(r.id) && visW(r));
|
|
369
|
+
retained.forEach((r) => retainedTerminalWf.add(r.id));
|
|
370
|
+
const workflows = [...live, ...retained];
|
|
371
|
+
tasks.forEach((r) => seenTasks.add(r.id));
|
|
372
|
+
workflows.forEach((r) => seenWf.add(r.id));
|
|
373
|
+
send("snapshot", { tasks: tasks.map(stripT), workflows: workflows.map(stripW), ts: snap.ts });
|
|
374
|
+
const pending = buffered ?? [];
|
|
375
|
+
buffered = null; // 自此直投(先置空再重放:重放期间新到的帧走直投,不会插到旧帧前面)
|
|
376
|
+
replayingBuffer = true;
|
|
377
|
+
try {
|
|
378
|
+
for (const frame of pending)
|
|
379
|
+
handleFrame(frame);
|
|
380
|
+
}
|
|
381
|
+
finally {
|
|
382
|
+
replayingBuffer = false; // 重放一结束立刻恢复常态语义(哪怕上面抛了)
|
|
383
|
+
}
|
|
384
|
+
})().catch(() => shutdown());
|
|
385
|
+
// ⚠️ 上面这个 catch 不是"静默降级":快照组装本身抛错(写向已销毁的 socket 那一族)时**没有**可以继续
|
|
386
|
+
// 的语义——这条连接已经没救了,干净收摊即正解。它真正防的是把原先同步执行器里的抛错(会直接 reject
|
|
387
|
+
// 出去)变成一条 **unhandled rejection**(Node 默认带走整个进程)。订阅回调自身的抛错另有隔离层
|
|
388
|
+
// (fleet bus 的 subscribe,记 fail-open),不经这里。
|
|
239
389
|
});
|
|
240
390
|
}
|
|
241
391
|
//# sourceMappingURL=fleet.js.map
|
package/dist/http/routes/runs.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { uuidv7, mintCheckpointToken, CheckpointError, validatePendingSteer } from "@sema-agent/core";
|
|
3
|
+
import { isTerminalRunStatus } from "../../plugins/store-contracts.js";
|
|
3
4
|
import { redactSecrets } from "../../trace/redact.js";
|
|
4
5
|
import { HttpError, verifiedPrincipal, isUuidV7, encodeCheckpointScope } from "../../security.js";
|
|
5
6
|
import { runInBackground } from "../../runs.js";
|
|
@@ -325,7 +326,8 @@ async function handleRunsBody(req, res, url, ctx, miss) {
|
|
|
325
326
|
rootTaskId: taskId,
|
|
326
327
|
...fleetRunLabels(prepared.spec.objective), // BC-1: name = short objective preview (description = live-activity, set by the publisher onEvent tool_start)
|
|
327
328
|
});
|
|
328
|
-
void runInBackground(
|
|
329
|
+
void runInBackground(prepared.runner, // #196:场景 hands 判别位选出的 Runner(hands=none ⇒ 无手孪生)
|
|
330
|
+
{ ...prepared.spec, sessionId }, runStore, taskId, deps.metrics, prepared.auth?.principal, prepared.verify, prepared.cascade ? cascadeConfig(deps.config.cascadeLadder, prepared.spec.limits?.maxCostUsd) : undefined, deps.instrumentDegenerate, deps.planCacheProbe, deps.config.traceThinking, inflightRuns, preemptableRuns, steerableRuns, deps.modelUsage, deps.elicitation, // E23: per-run elicitation context (onElicit routes inbound MCP elicitations to this run's stream)
|
|
329
331
|
gatedPrincipal(req, deps.config) ?? null, // E23: the VERIFIED principal that may answer (same source the respond gate uses — never the spoofable header)
|
|
330
332
|
captureTurnAnchor, // E18: per-turn (message eventId → leaf entryId) anchor capture
|
|
331
333
|
fleetPub, // MF-Fleet: the run-scoped fleet-row publisher (onStart/onEvent/onTerminal across all legs)
|
|
@@ -575,7 +577,10 @@ async function handleRunsBody(req, res, url, ctx, miss) {
|
|
|
575
577
|
// 并发腿可能先终态化(如 approval.expired),我方写打空却仍答 "cancelled" 就是响应与账本不一致。
|
|
576
578
|
// 写后复读,照实际行应答(锁一样释放了;谁先写赢谁的 errorCode)。
|
|
577
579
|
const finalRow = await rs.getRun(taskId).catch(() => undefined);
|
|
578
|
-
|
|
580
|
+
// 终局判据取自词表属主 `store-contracts.ts` 的穷举 `isTerminalRunStatus`(原地手抄的三连 `===`
|
|
581
|
+
// 对 core **增**一个终局态是静默的:新态会被判成「还没终局」⇒ 这里改答 "cancelled",与账本不一致,
|
|
582
|
+
// 正是本段注释要防的那件事)。
|
|
583
|
+
if (finalRow && finalRow.errorCode !== "cancelled" && isTerminalRunStatus(finalRow.status)) {
|
|
579
584
|
sendJson(res, 202, { taskId, status: finalRow.status, errorCode: finalRow.errorCode ?? null, note: `session unlocked; the run was terminalized concurrently (${finalRow.errorCode ?? finalRow.status}) before this cancel's write — reporting the actual ledger state` });
|
|
580
585
|
return;
|
|
581
586
|
}
|
|
@@ -601,7 +601,7 @@ async function handleTasksBody(req, res, url, ctx, miss) {
|
|
|
601
601
|
// (mid-flight ctrl+b) and, as a natural consequence, steer/compact — now reach a mid-turn
|
|
602
602
|
// INTERACTIVE run too (previously only the bg + resume legs registered). Deregistered in the
|
|
603
603
|
// outer finally (with the fleet settle), so an early throw can't leak a stale handle.
|
|
604
|
-
const liveStream =
|
|
604
|
+
const liveStream = prepared.runner.runTaskStream({ ...prepared.spec, signal: ac.signal }, undefined, fwdInternals); // #196:场景 hands 判别位选出的 Runner
|
|
605
605
|
liveStreamRef = liveStream;
|
|
606
606
|
if (durableTaskId)
|
|
607
607
|
steerableRuns.set(durableTaskId, liveStream);
|
|
@@ -1283,9 +1283,10 @@ async function handleTasksBody(req, res, url, ctx, miss) {
|
|
|
1283
1283
|
// ── B3 ── 原文此处是一处三层三元,把三条模型驱动腿挤在五行里。三腿具名化后判别顺序
|
|
1284
1284
|
// (verify → cascade → plain)、短路语义、传参**逐字不变**;`runWithVerification` 仍是整对象
|
|
1285
1285
|
// 直传(快审 F1:cost 顶不在重建中丢失),`runCascade` 仍从操作员梯子 + 本任务预算现算配置。
|
|
1286
|
-
|
|
1287
|
-
const
|
|
1288
|
-
const
|
|
1286
|
+
// #196:三腿一律跑 `prepared.runner`(场景 hands 判别位选出的那只),不再直接摸 deps.runner。
|
|
1287
|
+
const verifyLeg = (v) => runWithVerification(prepared.runner, specWithSignal, v);
|
|
1288
|
+
const cascadeLeg = () => runCascade(prepared.runner, specWithSignal, cascadeConfig(deps.config.cascadeLadder, prepared.spec.limits?.maxCostUsd));
|
|
1289
|
+
const plainLeg = () => prepared.runner.runTask(specWithSignal);
|
|
1289
1290
|
result = await withPrincipal(principal, () => prepared.verify ? verifyLeg(prepared.verify) : prepared.cascade ? cascadeLeg() : plainLeg());
|
|
1290
1291
|
}
|
|
1291
1292
|
finally {
|
|
@@ -2,7 +2,7 @@ import { projectEvents, taskSummary, mapTraceEvent } from "../../trace/project.j
|
|
|
2
2
|
import { projectArtifacts } from "../../trace/artifacts.js";
|
|
3
3
|
import { usageSummary, usageSeries, usageBreakdown } from "../../usage-analytics.js";
|
|
4
4
|
import { sleep } from "../sse-log.js";
|
|
5
|
-
import { sendJson, sendError, sseHeaders } from "../send.js";
|
|
5
|
+
import { sendJson, sendError, sseHeaders, SSE_MAX_STREAM_MS, SSE_HEARTBEAT_IDLE_MS } from "../send.js";
|
|
6
6
|
import { gatedPrincipal } from "../principal-gate.js";
|
|
7
7
|
const TRACE_RE = /^\/v1\/tasks(?:\/([^/]+)(\/turns|\/stream|\/artifacts))?$/;
|
|
8
8
|
export async function handleTraceUsage(req, res, url, ctx) {
|
|
@@ -248,7 +248,6 @@ async function streamTaskTrace(req, res, runStore, taskId, staleMs) {
|
|
|
248
248
|
closed = true;
|
|
249
249
|
});
|
|
250
250
|
const start = Date.now();
|
|
251
|
-
const MAX_MS = 15 * 60 * 1000;
|
|
252
251
|
let lastBeat = Date.now();
|
|
253
252
|
const write = (events) => {
|
|
254
253
|
for (const ev of events) {
|
|
@@ -272,14 +271,14 @@ async function streamTaskTrace(req, res, runStore, taskId, staleMs) {
|
|
|
272
271
|
res.write(`event: error\ndata: ${JSON.stringify({ type: "error", errorCode: "WORKER_DOWN", message: "run stalled (instance lost?)" })}\n\n`);
|
|
273
272
|
break;
|
|
274
273
|
}
|
|
275
|
-
if (Date.now() - start >
|
|
274
|
+
if (Date.now() - start > SSE_MAX_STREAM_MS) {
|
|
276
275
|
// BL-16: the stream hit its 15-min cap but the RUN is still going — emit a terminal event so the client
|
|
277
276
|
// can tell "reconnect to continue" from "task completed" (a silent close looked like completion).
|
|
278
277
|
res.write(`event: error\ndata: ${JSON.stringify({ type: "error", errorCode: "STREAM_MAX_DURATION", message: "trace stream reached its 15-minute cap — reconnect with Last-Event-ID to continue (the run is still active)" })}\n\n`);
|
|
279
278
|
break;
|
|
280
279
|
}
|
|
281
280
|
if (events.length === 0) {
|
|
282
|
-
if (Date.now() - lastBeat >
|
|
281
|
+
if (Date.now() - lastBeat > SSE_HEARTBEAT_IDLE_MS) {
|
|
283
282
|
res.write(`event: heartbeat\ndata: {}\n\n`);
|
|
284
283
|
lastBeat = Date.now();
|
|
285
284
|
}
|
package/dist/http/send.d.ts
CHANGED
|
@@ -9,6 +9,29 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import type { ServerResponse } from "node:http";
|
|
11
11
|
export declare function sseHeaders(res: ServerResponse, extra?: Record<string, string>): void;
|
|
12
|
+
/**
|
|
13
|
+
* 一条 SSE 连接的墙钟上限。到点发一帧 `error`/`STREAM_MAX_DURATION`(**不是**静默关闭 —— 客户端分不出
|
|
14
|
+
* 「到帽了请重连」和「跑完了」)然后收流。
|
|
15
|
+
*
|
|
16
|
+
* 🔴 为什么共享(2026-08-08,#176 二轮扫描 finding):这个数与下面的心跳间隔此前在**三处**各手抄一份
|
|
17
|
+
* —— `http/sse-log.ts`(task_run + image_bake 共用的那台泵)、`http/routes/trace-usage.ts`、
|
|
18
|
+
* `http/routes/approvals-assistant.ts`(它的顶注还自称「parity with streamTaskTrace」)。三处各自有测试
|
|
19
|
+
* 钉着**自己那一份**,没有任何跨副本的一致性钉 ⇒ 单边调帽后「parity」只剩注释里那句话。
|
|
20
|
+
*
|
|
21
|
+
* ⚠️ 改这个数请连**文案**一起改:三处的 `STREAM_MAX_DURATION` 消息里把「15-minute」写死在散文里
|
|
22
|
+
* (文案面另有 freeze 门,故此处不做插值——散文与常量的同步靠这行告诫,不靠机器)。
|
|
23
|
+
*/
|
|
24
|
+
export declare const SSE_MAX_STREAM_MS: number;
|
|
25
|
+
/**
|
|
26
|
+
* 长时间无事件时的心跳间隔(防中继的 idle timeout 掐流)。与 {@link SSE_MAX_STREAM_MS} 同批收口。
|
|
27
|
+
*
|
|
28
|
+
* ⚠️ 收口的只有**间隔**,**不含帧形**:`sse-log` / `trace-usage` 发 `data: {}`,`approvals-assistant`
|
|
29
|
+
* 发 `data: {"type":"heartbeat"}`。这是真分歧不是笔误 —— trace-usage 自己的 meta 帧还引着 [2373]C-12
|
|
30
|
+
* 「每个 data payload 都带 type」的约定,而它的心跳帧没有 type(probe-sse-1 的注释把这条记成「按
|
|
31
|
+
* data.type 分派的客户端会拿到 undefined」)。统一帧形是**行为面**改动(要么给两处加 type=多发一个键,
|
|
32
|
+
* 要么给 approvals 减 type=消费端破坏),属候裁,不在本批的零行为码范围内。
|
|
33
|
+
*/
|
|
34
|
+
export declare const SSE_HEARTBEAT_IDLE_MS = 15000;
|
|
12
35
|
export declare function sendJson(res: ServerResponse, status: number, body: unknown): void;
|
|
13
36
|
/** A6(design/158 lens1 §C3/lens3 §C3a): canonical error-response sender for sites that already carry a
|
|
14
37
|
* machine `errorCode` — `errorCode` is a required positional arg (not optional-bag) so a call site
|
package/dist/http/send.js
CHANGED
|
@@ -6,6 +6,29 @@ export function sseHeaders(res, extra) {
|
|
|
6
6
|
...extra,
|
|
7
7
|
});
|
|
8
8
|
}
|
|
9
|
+
/**
|
|
10
|
+
* 一条 SSE 连接的墙钟上限。到点发一帧 `error`/`STREAM_MAX_DURATION`(**不是**静默关闭 —— 客户端分不出
|
|
11
|
+
* 「到帽了请重连」和「跑完了」)然后收流。
|
|
12
|
+
*
|
|
13
|
+
* 🔴 为什么共享(2026-08-08,#176 二轮扫描 finding):这个数与下面的心跳间隔此前在**三处**各手抄一份
|
|
14
|
+
* —— `http/sse-log.ts`(task_run + image_bake 共用的那台泵)、`http/routes/trace-usage.ts`、
|
|
15
|
+
* `http/routes/approvals-assistant.ts`(它的顶注还自称「parity with streamTaskTrace」)。三处各自有测试
|
|
16
|
+
* 钉着**自己那一份**,没有任何跨副本的一致性钉 ⇒ 单边调帽后「parity」只剩注释里那句话。
|
|
17
|
+
*
|
|
18
|
+
* ⚠️ 改这个数请连**文案**一起改:三处的 `STREAM_MAX_DURATION` 消息里把「15-minute」写死在散文里
|
|
19
|
+
* (文案面另有 freeze 门,故此处不做插值——散文与常量的同步靠这行告诫,不靠机器)。
|
|
20
|
+
*/
|
|
21
|
+
export const SSE_MAX_STREAM_MS = 15 * 60 * 1000;
|
|
22
|
+
/**
|
|
23
|
+
* 长时间无事件时的心跳间隔(防中继的 idle timeout 掐流)。与 {@link SSE_MAX_STREAM_MS} 同批收口。
|
|
24
|
+
*
|
|
25
|
+
* ⚠️ 收口的只有**间隔**,**不含帧形**:`sse-log` / `trace-usage` 发 `data: {}`,`approvals-assistant`
|
|
26
|
+
* 发 `data: {"type":"heartbeat"}`。这是真分歧不是笔误 —— trace-usage 自己的 meta 帧还引着 [2373]C-12
|
|
27
|
+
* 「每个 data payload 都带 type」的约定,而它的心跳帧没有 type(probe-sse-1 的注释把这条记成「按
|
|
28
|
+
* data.type 分派的客户端会拿到 undefined」)。统一帧形是**行为面**改动(要么给两处加 type=多发一个键,
|
|
29
|
+
* 要么给 approvals 减 type=消费端破坏),属候裁,不在本批的零行为码范围内。
|
|
30
|
+
*/
|
|
31
|
+
export const SSE_HEARTBEAT_IDLE_MS = 15_000;
|
|
9
32
|
export function sendJson(res, status, body) {
|
|
10
33
|
res.writeHead(status, { "content-type": "application/json" });
|
|
11
34
|
res.end(JSON.stringify(body));
|
package/dist/http/server.d.ts
CHANGED
|
@@ -77,6 +77,12 @@ export interface ServiceCoreDeps {
|
|
|
77
77
|
resolveSpec: (body: TaskRequestBody, req: IncomingMessage | undefined, auth?: RequestAuth, opts?: {
|
|
78
78
|
leg?: "fresh" | "resume";
|
|
79
79
|
}) => TaskSpec | Promise<TaskSpec>;
|
|
80
|
+
/**
|
|
81
|
+
* #196 场景 hands lane 选路:按 `resolveSpec` 刚产出的 TaskSpec 取本任务的执行 Runner。
|
|
82
|
+
* 装配点(main.ts)把「场景判别位 → Runner 对」的映射整只闭包传进来 —— 本层不解释场景,只取用。
|
|
83
|
+
* 缺席 ⇒ 恒 {@link ServiceDeps.runner}(单 Runner 部署形 = #196 之前的行为,逐字不变;测试桩即此形)。
|
|
84
|
+
*/
|
|
85
|
+
runnerFor?: (spec: TaskSpec) => Runner;
|
|
80
86
|
}
|
|
81
87
|
/** **durable 持久面**:每个键 present ⇔ 对应路由/能力位在场,absent ⇒ 501(诚实缺席),
|
|
82
88
|
* 绝不静默降级。装配点 = main.ts 的 store backend 开箱段。 */
|
|
@@ -187,6 +193,10 @@ export interface ServiceCoordinatorDeps {
|
|
|
187
193
|
* (the live multi-row task/workflow fleet view: status/elapsed/tokens/doneCount transitions pushed over time).
|
|
188
194
|
* Present when wired in main.ts; the run + workflow lifecycle publish their per-row deltas to it. */
|
|
189
195
|
fleetBus?: FleetEventBus;
|
|
196
|
+
/** [3193] 病理1:boot recover(判死前世 running 行并回灌 fleet)完成屏障——`/v1/fleet/stream` 快照组装
|
|
197
|
+
* 对它做**有界**等待(3s cap,routes/fleet.ts),SIGKILL 后壳重连不再与 recover 赛跑。缺席或已
|
|
198
|
+
* resolve=立即组装(memory 后端、无 notify gate 的部署);main.ts 铸(recover 收官即落,失败也落)。 */
|
|
199
|
+
fleetRecoveryDone?: Promise<void>;
|
|
190
200
|
/** [1196] session-watch registry(SSE 订阅共享探针面)。present ⇔ sessionStorage 有 getLeafId/ownerOf
|
|
191
201
|
* (main.ts 建);absent ⇒ /v1/sessions/:id/events 501(capabilities.sessionEvents 同谓词,永不漂)。 */
|
|
192
202
|
sessionWatch?: import("../session-watch.js").SessionWatchRegistry;
|
package/dist/http/server.js
CHANGED
|
@@ -5,7 +5,7 @@ import { uuidv7, isThinkingLevel, expandTiers, resumeWithVerification, Checkpoin
|
|
|
5
5
|
import { decideParkedAgent, findParkedAgentForCheckpoint } from "../parked-decide.js";
|
|
6
6
|
import { matchCatalogModel } from "../model-select.js";
|
|
7
7
|
import {} from "../config-center/facade.js";
|
|
8
|
-
import { HttpError, principalFrom, verifiedPrincipal, setSsoPrincipal, ssoVerifiedPrincipal, setSsoScope, isUuidV7, isDestructiveSessionWrite, decodeCheckpointScope, CHECKPOINT_PUBLIC_SCOPE } from "../security.js";
|
|
8
|
+
import { HttpError, principalFrom, verifiedPrincipal, setSsoPrincipal, ssoVerifiedPrincipal, setSsoScope, isUuidV7, isDestructiveSessionWrite, decodeCheckpointScope, CHECKPOINT_PUBLIC_SCOPE, PRINCIPAL_TOKEN_HEADER, APPROVAL_MAC_HEADER, APPROVAL_MAC_KID_HEADER } from "../security.js";
|
|
9
9
|
import { verifyDirectDoorProof } from "../principal-jwt.js";
|
|
10
10
|
import {} from "../memory-sync.js";
|
|
11
11
|
import { MAX_SETTINGS_OUTPUT_STYLE_CHARS, MAX_SETTINGS_PERMISSION_RULES, MAX_SETTINGS_ENV_VARS, MAX_SETTINGS_ENV_KEY_CHARS, MAX_SETTINGS_ENV_VALUE_CHARS } from "../task-settings.js";
|
|
@@ -615,6 +615,11 @@ export function createHttpServer(rawDeps) {
|
|
|
615
615
|
return;
|
|
616
616
|
sendError(res, 404, "not_found.route", "not found");
|
|
617
617
|
}
|
|
618
|
+
/** #196:本任务跑哪只 Runner —— 由装配点按场景 hands 判别位裁定(main.ts 的 `runnerFor` 闭包)。
|
|
619
|
+
* seam 缺席 = 单 Runner 部署形(#196 之前的行为逐字不变),不是降级臂。 */
|
|
620
|
+
function runnerFor(spec) {
|
|
621
|
+
return deps.runnerFor?.(spec) ?? deps.runner;
|
|
622
|
+
}
|
|
618
623
|
/** Read + validate the body, authorize, and build the spec. Returns null if it already responded. */
|
|
619
624
|
async function prepareSpec(req, res) {
|
|
620
625
|
const body = (await readJson(req));
|
|
@@ -1205,7 +1210,10 @@ export function createHttpServer(rawDeps) {
|
|
|
1205
1210
|
}
|
|
1206
1211
|
try {
|
|
1207
1212
|
const auth = deps.authorize ? await deps.authorize({ req, body }) : undefined;
|
|
1208
|
-
|
|
1213
|
+
const spec = await deps.resolveSpec(body, req, auth, { leg: "fresh" });
|
|
1214
|
+
// #196:执行 Runner 与 spec 同一次解析产出 —— 两个消费域(tasks 同步/流式、runs 异步)拿的都是这一只,
|
|
1215
|
+
// 不各自再问一遍(再问 = 两处判别,正是漂移的成因)。
|
|
1216
|
+
return { spec, runner: runnerFor(spec), auth, verify, cascade, jobId: body.jobId, body };
|
|
1209
1217
|
}
|
|
1210
1218
|
catch (err) {
|
|
1211
1219
|
if (err instanceof HttpError) {
|
|
@@ -1413,9 +1421,12 @@ export function createHttpServer(rawDeps) {
|
|
|
1413
1421
|
// Typed pre-check: the pending tool must still exist in the rebuilt config; a scenario
|
|
1414
1422
|
// redeploy that removed it means the approval can no longer be applied — fail clearly, never a 500/silent.
|
|
1415
1423
|
// The "hand" tools are NOT in spec.tools — core mounts them at the runner from the executionEnvFactory
|
|
1416
|
-
// (
|
|
1417
|
-
// has remote exec configured the rebuilt task WILL expose them, so union them in; else
|
|
1418
|
-
// (the common code-agent case) would always 422 on resume.
|
|
1424
|
+
// (`HAND_TOOL_NAMES` above derives from core's root-exported `HAND_TOOL_EFFECTS`; nothing is mirrored here).
|
|
1425
|
+
// When this deployment has remote exec configured the rebuilt task WILL expose them, so union them in; else
|
|
1426
|
+
// gating a hand tool (the common code-agent case) would always 422 on resume.
|
|
1427
|
+
// #196 旁注:本 union 只看部署有没有 remoteExec,不看本任务的 hands lane —— 对 hands=none 的任务这是
|
|
1428
|
+
// **过近似**(那些工具根本没 mount,也就不可能有它们的 park 行),只影响 422 的 UX 宽严,不放大任何权限;
|
|
1429
|
+
// band 的**完整**名单(含 Monitor/EnterWorktree/… 这些不在 effects 表里的)在 capabilities/hands-lane.ts。
|
|
1419
1430
|
// [2380] RB-476(5.0.0):Q6 折叠预检退役——满足性匹配按 RAW 名。pre-floor 旧名 checkpoint
|
|
1420
1431
|
// (`bash`/`run_workflow` 等)在此 422 诚实拒,与 core 自己的 resume.tool_unavailable 同方向
|
|
1421
1432
|
// ([2364] 令:耐久面响亮牺牲 + P-7 重开,不静默兜)。现役名恒自映射。
|
|
@@ -1510,7 +1521,7 @@ export function createHttpServer(rawDeps) {
|
|
|
1510
1521
|
// heartbeat, the resumeStream / resumeWithVerification loop, CheckpointError classification, re-suspend vs
|
|
1511
1522
|
// terminal drive. This HITL /decide path supplies a `policy_ask` outcome; the scheduler preempt path supplies
|
|
1512
1523
|
// a `resource_limit`/continue one — sharing this prevents the two resume entries from drifting.
|
|
1513
|
-
return driveResumeIntoRunLog({ token, sessionId: cp.sessionId, principal: auth.principal, fleetScope: resumeFleetScope(req, auth), taskConfig, resumeObjective, outcome, verifyRounds, ...(onResumeCommitted ? { onResumeCommitted } : {}) });
|
|
1524
|
+
return driveResumeIntoRunLog({ runner: runnerFor(spec), token, sessionId: cp.sessionId, principal: auth.principal, fleetScope: resumeFleetScope(req, auth), taskConfig, resumeObjective, outcome, verifyRounds, ...(onResumeCommitted ? { onResumeCommitted } : {}) });
|
|
1514
1525
|
}
|
|
1515
1526
|
/**
|
|
1516
1527
|
* The resume-drive machinery SHARED by `resumeCheckpoint` (operator /decide, `policy_ask`) and `resumePreempted`
|
|
@@ -1524,7 +1535,8 @@ export function createHttpServer(rawDeps) {
|
|
|
1524
1535
|
* (setSuspended, keep the lock) vs a terminal (setTerminal), and returns the HTTP {status, body}.
|
|
1525
1536
|
*/
|
|
1526
1537
|
async function driveResumeIntoRunLog(args) {
|
|
1527
|
-
|
|
1538
|
+
// #196:`runner` = 本次续跑的执行 Runner(场景 hands 判别位裁定,与首跑同源;见 DriveResumeArgs 注)。
|
|
1539
|
+
const { runner: legRunner, token, sessionId, principal, fleetScope, taskConfig, resumeObjective, outcome, verifyRounds } = args;
|
|
1528
1540
|
// ── #151 车3 刀 3b:resume 腿的 `legKey` 真值(设计稿 §3.2′)──────────────────────────────────
|
|
1529
1541
|
// 一次 park→resume 的 checkpoint token **就是**这条腿的天然身份:同一 token 重投 = 同一条腿(幂等,
|
|
1530
1542
|
// askId 不变);新 park ⇒ 新 token ⇒ 新腿 ⇒ 新 askId ⇒ **重新征询**(§3.3「resume 只重放未完成兄弟」)。
|
|
@@ -1732,7 +1744,7 @@ export function createHttpServer(rawDeps) {
|
|
|
1732
1744
|
if (verifyRounds !== undefined) {
|
|
1733
1745
|
// resumeWithVerification runs the SAME pre-CAS guard + atomic CAS as resume() internally, so a lost
|
|
1734
1746
|
// CAS rejects with CheckpointError before any work — handled by the catch below, same as the stream path.
|
|
1735
|
-
const vr = await resumeWithVerification(
|
|
1747
|
+
const vr = await resumeWithVerification(legRunner, token, outcome, { ...taskConfig, signal: cancelCtrl.signal, preemptSignal: preemptCtrl.signal }, resumeObjective, verifyRounds); // 快审 F1:整对象直传
|
|
1736
1748
|
let safe = stripCheckpointToken(vr);
|
|
1737
1749
|
// 🔴 A re-suspend on a LATER gate is NOT terminal: core maps it to status:"failed" +
|
|
1738
1750
|
// verification.unverifiedReason:"suspended" with the checkpoint token still live. Treat it exactly like
|
|
@@ -1828,7 +1840,7 @@ export function createHttpServer(rawDeps) {
|
|
|
1828
1840
|
// (`++seq` is a sync increment — sink-vs-loop appends get unique seqs); only when a durable log exists.
|
|
1829
1841
|
// false once this leg settles — notifications after that take the durable-inbox path.
|
|
1830
1842
|
resumeLegLive = true;
|
|
1831
|
-
const stream = await
|
|
1843
|
+
const stream = await legRunner.resumeStream(token, outcome, { ...taskConfig, signal: cancelCtrl.signal, preemptSignal: preemptCtrl.signal }, {
|
|
1832
1844
|
onForwardEvent: (e) => {
|
|
1833
1845
|
fleetPub?.onForwardEvent(e);
|
|
1834
1846
|
// S2 live tail(复审 #1:forward sink 有三条腿——resume 腿上 spawn 的 bg 子代同样带
|
|
@@ -2256,7 +2268,7 @@ export function createHttpServer(rawDeps) {
|
|
|
2256
2268
|
// A resource-suspended task that was submitted with verify:true is re-gated on completion (parity with the
|
|
2257
2269
|
// HITL path — the flag rides the persisted ctx.body).
|
|
2258
2270
|
const verifyRounds = verifyRoundsFromBody(ctx.body);
|
|
2259
|
-
return driveResumeIntoRunLog({ token, sessionId: cp.sessionId, principal: auth.principal, fleetScope: resumeFleetScope(req, auth), taskConfig, resumeObjective, outcome: { gate: "resource_limit", decision: "continue" }, verifyRounds });
|
|
2271
|
+
return driveResumeIntoRunLog({ runner: runnerFor(spec), token, sessionId: cp.sessionId, principal: auth.principal, fleetScope: resumeFleetScope(req, auth), taskConfig, resumeObjective, outcome: { gate: "resource_limit", decision: "continue" }, verifyRounds });
|
|
2260
2272
|
}
|
|
2261
2273
|
/** design/144 §3:wake 一个 task_done 纯 park——非门决策,解除 park 消费消息续跑。owner 门:非 operator
|
|
2262
2274
|
* 只能 wake 自己 scope 的 park(404 无 oracle);core 端 gate_pending/nothing_to_deliver 双拒兜底。 */
|
|
@@ -2314,6 +2326,7 @@ export function createHttpServer(rawDeps) {
|
|
|
2314
2326
|
const spec = await deps.resolveSpec({ ...ctx.body, resumeAt: undefined }, req, auth);
|
|
2315
2327
|
const { objective: resumeObjective, sessionId: _sessionId, ...taskConfig } = spec;
|
|
2316
2328
|
return driveResumeIntoRunLog({
|
|
2329
|
+
runner: runnerFor(spec),
|
|
2317
2330
|
token,
|
|
2318
2331
|
sessionId: cp.sessionId,
|
|
2319
2332
|
principal: auth.principal,
|
|
@@ -2379,7 +2392,7 @@ export function createHttpServer(rawDeps) {
|
|
|
2379
2392
|
...(editedPlan !== undefined ? { editedPlan } : {}),
|
|
2380
2393
|
...(reason ? { reason } : {}),
|
|
2381
2394
|
};
|
|
2382
|
-
return driveResumeIntoRunLog({ token, sessionId: cp.sessionId, principal: auth.principal, fleetScope: resumeFleetScope(req, auth), taskConfig, resumeObjective, outcome, verifyRounds });
|
|
2395
|
+
return driveResumeIntoRunLog({ runner: runnerFor(spec), token, sessionId: cp.sessionId, principal: auth.principal, fleetScope: resumeFleetScope(req, auth), taskConfig, resumeObjective, outcome, verifyRounds });
|
|
2383
2396
|
}
|
|
2384
2397
|
function recordTaskResult(result) {
|
|
2385
2398
|
deps.metrics?.inc("tasks_total", { status: result.status });
|
|
@@ -2813,7 +2826,10 @@ function cors(res, req, origins, principalHeader) {
|
|
|
2813
2826
|
// 后五项都是**真被 handler 读**的自定义头:`idempotency-key`(tasks/runs/images)、
|
|
2814
2827
|
// `x-detach-on-disconnect`(tasks 断连不中止)、direct-door 决策证明三件套(approvals /decide 的
|
|
2815
2828
|
// crypto 绑定腿 —— 缺了它,direct-door worker 上的 web 审批面在浏览器里根本用不了)。
|
|
2816
|
-
res.setHeader("access-control-allow-headers",
|
|
2829
|
+
res.setHeader("access-control-allow-headers",
|
|
2830
|
+
// direct-door 三件套用 security.ts 的具名导出插值(与同句的 `${principalHeader}` 同姿势)——手抄的
|
|
2831
|
+
// 头名漏改在这一行是**静默**的:预检挡下 ⇒ 请求到不了 handler,任何一处都不会报错。
|
|
2832
|
+
`content-type, authorization, last-event-id, ${principalHeader}, idempotency-key, x-detach-on-disconnect, ${PRINCIPAL_TOKEN_HEADER}, ${APPROVAL_MAC_HEADER}, ${APPROVAL_MAC_KID_HEADER}, if-none-match`);
|
|
2817
2833
|
// 🔴 expose-headers 此前**整个缺席** ⇒ fetch 类客户端读不到 `X-Task-Id`(routes/tasks.ts 经 sseHeaders
|
|
2818
2834
|
// 下发的 durable rewind handle)。tasks.ts 的 G15 meta 帧注释把这归因为「EventSource 与部分代理读不到
|
|
2819
2835
|
// 响应头」—— 对 EventSource 成立,但对 fetch 客户端真因是这里缺 expose,meta 帧只是绕过。
|
package/dist/http/sse-log.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { sendJson, sendError, sseHeaders } from "./send.js";
|
|
1
|
+
import { sendJson, sendError, sseHeaders, SSE_MAX_STREAM_MS, SSE_HEARTBEAT_IDLE_MS } from "./send.js";
|
|
2
2
|
/** #151 车3 §5.3:preamble 的**有界**等待上限。挂起的 store 不许拖住开流——开流是壳的主路径,
|
|
3
3
|
* 而 preamble 只是一个锦上添花的对账基准。超时即按「无卡集基准」继续(与今天等同)。 */
|
|
4
4
|
export const SSE_PREAMBLE_TIMEOUT_MS = 2_000;
|
|
@@ -83,7 +83,6 @@ export async function streamSseLog(req, res, provider, id, staleMs) {
|
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
85
|
const start = Date.now();
|
|
86
|
-
const MAX_MS = 15 * 60 * 1000;
|
|
87
86
|
let lastBeat = Date.now();
|
|
88
87
|
const write = (events) => {
|
|
89
88
|
for (const ev of events) {
|
|
@@ -116,7 +115,7 @@ export async function streamSseLog(req, res, provider, id, staleMs) {
|
|
|
116
115
|
res.write(`event: ${sf.event}\ndata: ${JSON.stringify(sf.data)}\n\n`);
|
|
117
116
|
break;
|
|
118
117
|
}
|
|
119
|
-
if (Date.now() - start >
|
|
118
|
+
if (Date.now() - start > SSE_MAX_STREAM_MS) {
|
|
120
119
|
// BL-16: cap reached but the run continues — emit a terminal reconnect signal (not a silent close, which
|
|
121
120
|
// a client couldn't distinguish from a completed run). Distinct from the `failed`/`done` run events.
|
|
122
121
|
res.write(`event: error\ndata: ${JSON.stringify({ type: "error", errorCode: "STREAM_MAX_DURATION", message: "stream reached its 15-minute cap — reconnect with Last-Event-ID to continue (the run is still active)" })}\n\n`);
|
|
@@ -124,7 +123,7 @@ export async function streamSseLog(req, res, provider, id, staleMs) {
|
|
|
124
123
|
}
|
|
125
124
|
if (events.length === 0) {
|
|
126
125
|
// Heartbeat during a long idle tail (e.g. a debate phase) so the relay's idle timeout doesn't fire.
|
|
127
|
-
if (Date.now() - lastBeat >
|
|
126
|
+
if (Date.now() - lastBeat > SSE_HEARTBEAT_IDLE_MS) {
|
|
128
127
|
res.write(`event: heartbeat\ndata: {}\n\n`);
|
|
129
128
|
lastBeat = Date.now();
|
|
130
129
|
}
|
package/dist/leader/diffout.d.ts
CHANGED
|
@@ -38,6 +38,16 @@ export type PullDiffResult = {
|
|
|
38
38
|
truncated?: boolean;
|
|
39
39
|
exitCode?: number;
|
|
40
40
|
};
|
|
41
|
+
/**
|
|
42
|
+
* 一次 diff 的字节帽(pull 腿与 push/上行腿**共用同一个数**)。
|
|
43
|
+
*
|
|
44
|
+
* 🔴 exported 是判据的一部分(2026-08-08,#176 二轮扫描 finding):`diffup.ts` 原先另抄了一份同值常量,
|
|
45
|
+
* 而它**本就已经** `import { safeRev } from "./diffout.js"` —— 依赖边在,共享零成本。两份手抄的后果不是
|
|
46
|
+
* 「多写一行」,是**单边改值后上行/pull 两腿对同一个 diff 的过/拒判定分家**:同一份补丁在一条腿上过、
|
|
47
|
+
* 另一条腿上被截断,而两处都「按自己的常量」正确工作,没有任何一处会报错。
|
|
48
|
+
* (同族先例:`checkpoint-store-sql.ts` 的 `export const TERMINAL_GRACE_MS`。)
|
|
49
|
+
*/
|
|
50
|
+
export declare const DEFAULT_MAX_DIFF_BYTES: number;
|
|
41
51
|
export declare function safeRev(rev: string): boolean;
|
|
42
52
|
/**
|
|
43
53
|
* Pull `<baseSha>..<ref>` from a worker container as a `git format-patch` series. Never throws — failures
|