@sema-agent/server 7.1.0 → 7.3.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/README.md +3 -1
- package/README.zh-CN.md +1 -1
- package/USAGE.md +6 -1
- package/dist/approval-ask-machine.d.ts +39 -0
- package/dist/approval-ask-machine.js +101 -0
- package/dist/approval-card.d.ts +244 -0
- package/dist/approval-card.js +237 -0
- package/dist/approval-deny-reasons.d.ts +56 -0
- package/dist/approval-deny-reasons.js +54 -0
- package/dist/approval-reconciler.d.ts +167 -0
- package/dist/approval-reconciler.js +307 -0
- package/dist/boot/coordinators.d.ts +1 -0
- package/dist/boot/coordinators.js +36 -3
- package/dist/boot/lexical-path-env.d.ts +14 -0
- package/dist/boot/lexical-path-env.js +116 -0
- package/dist/boot/org-memory.d.ts +8 -5
- package/dist/boot/org-memory.js +23 -12
- package/dist/boot/reapers.d.ts +34 -0
- package/dist/boot/reapers.js +198 -23
- package/dist/boot/resolve-spec.d.ts +16 -1
- package/dist/boot/resolve-spec.js +104 -46
- package/dist/config-center/facade.d.ts +1 -1
- package/dist/config-center/facade.js +1 -1
- package/dist/config-center/http-client.d.ts +19 -0
- package/dist/config-center/http-client.js +81 -32
- package/dist/config-types.d.ts +76 -7
- package/dist/config.d.ts +1 -0
- package/dist/config.js +134 -4
- package/dist/elicitation.d.ts +4 -0
- package/dist/elicitation.js +2 -2
- package/dist/http/routes/capabilities.js +18 -2
- package/dist/http/routes/runs.d.ts +1 -0
- package/dist/http/routes/runs.js +548 -16
- package/dist/http/routes/tasks.js +190 -12
- package/dist/http/server.d.ts +1 -1
- package/dist/http/server.js +124 -8
- package/dist/http/sse-log.d.ts +51 -0
- package/dist/http/sse-log.js +64 -0
- package/dist/http/wire-types.d.ts +20 -5
- package/dist/leader/wire.js +4 -0
- package/dist/main.js +7 -4
- package/dist/org-memory-admission.d.ts +2 -1
- package/dist/org-memory-admission.js +10 -4
- package/dist/parked-decide.js +5 -2
- package/dist/plugins/approval-ask-store-memory.d.ts +38 -0
- package/dist/plugins/approval-ask-store-memory.js +299 -0
- package/dist/plugins/approval-ask-store-sql.d.ts +341 -0
- package/dist/plugins/approval-ask-store-sql.js +705 -0
- package/dist/plugins/background-agent-store-sql.js +20 -1
- package/dist/plugins/checkpoint-store-sql.d.ts +84 -9
- package/dist/plugins/checkpoint-store-sql.js +297 -16
- package/dist/plugins/local-checkpoint-store.d.ts +6 -5
- package/dist/plugins/local-checkpoint-store.js +4 -0
- package/dist/plugins/pg-pool.js +11 -0
- package/dist/plugins/store-backend.d.ts +18 -0
- package/dist/plugins/store-backend.js +10 -0
- package/dist/plugins/tidb-pool.js +27 -4
- package/dist/question.d.ts +18 -3
- package/dist/question.js +20 -6
- package/dist/runs.d.ts +16 -1
- package/dist/runs.js +61 -3
- package/dist/runtime-caps-resolver.d.ts +7 -1
- package/dist/runtime-caps-resolver.js +65 -3
- package/dist/runtime-governance.d.ts +11 -4
- package/dist/runtime-governance.js +16 -5
- package/dist/spec-fields.d.ts +4 -0
- package/dist/spec-fields.js +6 -0
- package/dist/task-settings.d.ts +35 -15
- package/dist/task-settings.js +19 -5
- package/dist/tool-approval.d.ts +296 -3
- package/dist/tool-approval.js +1066 -50
- package/dist/trace/core-keyset-guard.d.ts +2 -2
- package/dist/trace/ledger-sink.js +14 -1
- package/dist/trace/project.d.ts +55 -0
- package/dist/trace/project.js +135 -0
- package/package.json +4 -3
|
@@ -6,10 +6,13 @@ import { defaultSubagentTailBus, projectTailFrame } from "../../fleet/subagent-t
|
|
|
6
6
|
import { emitPendingWorkflowCompletions, taskNotificationInboxEntry, taskNotificationStreamKey, NotifiedKeys } from "../../orchestration/workflow-completion-inbox.js";
|
|
7
7
|
import { createLedgerSink } from "../../trace/ledger-sink.js";
|
|
8
8
|
import { redactSecrets } from "../../trace/redact.js";
|
|
9
|
-
import { contextUsageEventData, toolStartEventData, toolEndEventData, taskProgressEventData, taskNotificationEventData, compactedEventData, diagnosticsEventData, brainStatusEventData, steeringInjectedEventData, compactionOutcomeEventData, workspaceChangedEventData, appendModelUsageDelta, attachModelUsage } from "../../trace/project.js";
|
|
9
|
+
import { contextUsageEventData, toolStartEventData, toolEndEventData, taskProgressEventData, taskNotificationEventData, compactedEventData, diagnosticsEventData, brainStatusEventData, steeringInjectedEventData, compactionOutcomeEventData, workspaceChangedEventData, wiringManifestEventData, humanInputEventData, appendModelUsageDelta, attachModelUsage } from "../../trace/project.js";
|
|
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 { collectSsePreamble } from "../sse-log.js";
|
|
14
|
+
import { buildApprovalPreamble, buildApprovalPreambleSseFrames } from "../../approval-card.js";
|
|
15
|
+
import { createApprovalCardEmitter, resolveApprovalLeg, resolveStreamApprovalGate } from "../../tool-approval.js";
|
|
13
16
|
import { buildActiveRunConflict, toDoneFrameResult } from "../active-run-conflict.js";
|
|
14
17
|
import { headerStr, gatedPrincipal } from "../principal-gate.js";
|
|
15
18
|
/** #135([C95]/[C96]):live 腿 SSE 数据帧的**唯一**出口 —— 载荷带账本可锚 `eventId`(非空 string)的帧
|
|
@@ -21,6 +24,37 @@ function sseData(res, payload) {
|
|
|
21
24
|
const idLine = typeof anchor === "string" && anchor.length > 0 ? `id: ${anchor}\n` : "";
|
|
22
25
|
res.write(`${idLine}data: ${JSON.stringify(payload)}\n\n`);
|
|
23
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* 同步提交腿的**唯一**应答口:`retryAfterSec` 在体里 ⇒ 标准 `retry-after` 头同值在头里。
|
|
29
|
+
*
|
|
30
|
+
* 🔴 为什么必须是同一个口(复审车 C,2026-08-05):`memory.admission_required`(503)与
|
|
31
|
+
* `usage.window_exhausted`(429)是同族「等一会儿再来」,而后者在 `http/server.ts` 的 pre-admission 门里
|
|
32
|
+
* **头体双给**。本腿此前只给体——但**自动重试引擎读的是头**:SDK `Transport.request` 取
|
|
33
|
+
* `parseRetryAfter(res.headers.get("retry-after"))`,退避睡 `retryAfterMs ?? backoff(attempt)`,而 503 在
|
|
34
|
+
* `RETRYABLE_STATUS` 内。头缺席 ⇒ 引擎回落 250ms/500ms 通用退避,默认 `maxRetries: 2` 的三次尝试**全部**
|
|
35
|
+
* 落在 core 的不可用窗内(core 侧 `Math.max(backoffMs, 1000)`),提交方在 1 秒内烧光重试预算然后报错 ——
|
|
36
|
+
* 这个提示存在的唯一目的正好被绕过。头体同源(体有才给、值逐字相同)也让「体说等 11 秒、头说别的」不可能。
|
|
37
|
+
*
|
|
38
|
+
* 放在应答口而不是铸体处:同步腿的 4xx/5xx **不进** idem 缓存(谓词 `status < 400`),但并发同键的调用方会
|
|
39
|
+
* 被 `peek` 喂在飞 promise 的**同一份**结果 —— 那条腿不重跑铸体处的代码,只走这里。
|
|
40
|
+
*/
|
|
41
|
+
function sendSubmitResult(res, resp) {
|
|
42
|
+
const body = resp.body;
|
|
43
|
+
// 两个判定各司其职,都不能省:`Object.hasOwn` 管**语义**——`in` 认原型链,而 `JSON.stringify` 只序列化
|
|
44
|
+
// 自有可枚举键,只用 `in` 就存在「头说等 11 秒、体里根本没这个键」的形(继承来的键足以点亮头);`in` 管
|
|
45
|
+
// **窄化**——本仓 lib 里的 `Object.hasOwn` 没有类型谓词重载,少了它就得写断言(边界禁项)。
|
|
46
|
+
// 🔴 诚实边界:这两个判定合起来**不等于**「JSON 里一定看得见」的通用证明(不可枚举自有键、数组上的具名键
|
|
47
|
+
// 都能同时通过而不被序列化)。本函数的真保证是**受控生产者**级的:本腿流经这里的应答体形状不止一种
|
|
48
|
+
// (200 的 `stripCheckpointToken(result)` 是引擎对象、409 是冲突材料……),而其中**唯一**带
|
|
49
|
+
// `retryAfterSec` 的是下面那个 memory.admission_required 臂里的对象字面量。写死这条而不是宣称一个更强的
|
|
50
|
+
// 不变式 —— 宣称过强正是「过期声明」的铸法。
|
|
51
|
+
if (typeof body === "object" && body !== null && Object.hasOwn(body, "retryAfterSec") && "retryAfterSec" in body) {
|
|
52
|
+
const sec = body.retryAfterSec;
|
|
53
|
+
if (typeof sec === "number" && Number.isFinite(sec))
|
|
54
|
+
res.setHeader("retry-after", String(sec));
|
|
55
|
+
}
|
|
56
|
+
sendJson(res, resp.status, resp.body);
|
|
57
|
+
}
|
|
24
58
|
export async function handleTasks(req, res, url, ctx) {
|
|
25
59
|
const miss = { fell: false };
|
|
26
60
|
await handleTasksBody(req, res, url, ctx, miss);
|
|
@@ -70,7 +104,7 @@ async function handleTasksBody(req, res, url, ctx, miss) {
|
|
|
70
104
|
}
|
|
71
105
|
else {
|
|
72
106
|
const resp = await cached;
|
|
73
|
-
|
|
107
|
+
sendSubmitResult(res, resp); // 头体同源(见 sendSubmitResult 顶注:并发同键的这条腿不重跑铸体处)
|
|
74
108
|
}
|
|
75
109
|
return;
|
|
76
110
|
}
|
|
@@ -165,6 +199,49 @@ async function handleTasksBody(req, res, url, ctx, miss) {
|
|
|
165
199
|
if (!res.writableEnded)
|
|
166
200
|
onDisconnect();
|
|
167
201
|
});
|
|
202
|
+
// #151 车3 §5.1:sync 腿的**开流重放**——`sseHeaders` + meta 帧之后、`streamBody` 之前,把同一
|
|
203
|
+
// (owner, session) 的未决审批卡投成 `approval_request` preamble 帧。
|
|
204
|
+
//
|
|
205
|
+
// 🔴 位置:必须排在**上面那套断连接线之后**(codex 复审 2026-08-06 的 high)。Node 的 `'close'`
|
|
206
|
+
// 只发一次 —— preamble 的 ≤2s 等待窗若插在接线之前,窗内断连的 close 事件在监听器注册前就烧掉了,
|
|
207
|
+
// `closed` 恒 false、`ac` 从不 abort ⇒ 模型腿在一条已断的连接上照常起跑并烧 token,「断连即杀 run」
|
|
208
|
+
// 这条既有契约在窗口内失效。排在接线之后 = 窗内断连照常 abort,腿拿到的是已 abort 的 signal
|
|
209
|
+
// (与「断连发生在起跑之后」同一条既有路径,不新增早退分支)。
|
|
210
|
+
//
|
|
211
|
+
// 🔴 为什么按 **session** 读而不是按 taskId:sync 腿每次开流都是**新 taskId**;它要接的未决卡来自
|
|
212
|
+
// 「同一 (owner, session) 的上一条腿 / 长命 bg 子代」——正是 `boundAsk` broker 的
|
|
213
|
+
// `["session", owner, sessionId]` 键域。按 taskId 读永远读不到它们。
|
|
214
|
+
//
|
|
215
|
+
// `sessionId` 缺席的 adhoc 流**不重放**:那条腿的行上 task_id/session_id 都是客户端永远看不到的
|
|
216
|
+
// 一次性 uuid,没有「重连回同一条腿」的概念(§5.1 表:这不是缺陷,是该腿形本来的语义)。
|
|
217
|
+
//
|
|
218
|
+
// owner = **verified** principal(与下方 `askOwner`、与 respond 的 owner gate 同一姿势)——重放面是
|
|
219
|
+
// 跨腿投递,租户门必须带上,不能让 A 租户的开流看到 B 租户的卡。
|
|
220
|
+
// 有界(2s)+ fail-open:store 抛或挂起都只记一次 warn、零卡继续开流(§5.3)。
|
|
221
|
+
// 🔴 开关谓词与 runs.ts 同源同理由(codex 复审第二轮 high:回滚下表里可能有残留 STREAM_PENDING
|
|
222
|
+
// 行,无谓词会破「默认关 = 逐字零变化」;顺带省掉关闭态每次开流的一次 store 往返)。钉:场景9。
|
|
223
|
+
const replaySessionId = deps.config.streamApproval.enabled ? prepared.spec.sessionId : undefined;
|
|
224
|
+
if (replaySessionId) {
|
|
225
|
+
const replayOwner = gatedPrincipal(req, deps.config) ?? null;
|
|
226
|
+
const preambleFrames = await collectSsePreamble(async (signal) => {
|
|
227
|
+
const store = deps.backend?.approvalAsk();
|
|
228
|
+
if (!store)
|
|
229
|
+
return [];
|
|
230
|
+
const rows = await store.listPendingBySession(replaySessionId, replayOwner, signal);
|
|
231
|
+
const { frames, skipped, dropped } = buildApprovalPreamble(rows, Date.now(), deps.config.streamApproval.replayMax);
|
|
232
|
+
if (skipped > 0)
|
|
233
|
+
deps.logger?.warn?.("approval_preamble_row_skipped", { sessionId: replaySessionId, skipped, face: "tasks.stream" });
|
|
234
|
+
if (dropped > 0)
|
|
235
|
+
deps.logger?.warn?.("approval_preamble_capped", { sessionId: replaySessionId, dropped, replayMax: deps.config.streamApproval.replayMax, face: "tasks.stream" });
|
|
236
|
+
return buildApprovalPreambleSseFrames(frames);
|
|
237
|
+
}, (reason, err) => deps.logger?.warn?.("approval_preamble_failed", { sessionId: replaySessionId, reason, face: "tasks.stream", error: err instanceof Error ? err.message : String(err ?? "") }));
|
|
238
|
+
// 具名事件、**无 `id:` 行**:preamble 不是账本行,不参与 live 腿的 eventId 游标(sseData 的契约)。
|
|
239
|
+
// 等待窗里断连了就别再写(与心跳的 `!res.writableEnded && !res.destroyed` 同一判据)。
|
|
240
|
+
if (!res.writableEnded && !res.destroyed) {
|
|
241
|
+
for (const f of preambleFrames)
|
|
242
|
+
res.write(`event: ${f.event}\ndata: ${JSON.stringify(f.data)}\n\n`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
168
245
|
// Heartbeat so a long no-token gap (e.g. a debate phase) doesn't trip a mesh/ingress idle timeout on
|
|
169
246
|
// the relay (center). SSE comment lines are ignored by EventSource but keep the socket alive.
|
|
170
247
|
const hb = setInterval(() => {
|
|
@@ -673,6 +750,20 @@ async function handleTasksBody(req, res, url, ctx, miss) {
|
|
|
673
750
|
// 与两条 durable 腿同源(它曾是唯一不走 builder 的内容帧=三方普查共同盲区)。
|
|
674
751
|
sseData(res, { type: "compaction_outcome", ...compactionOutcomeEventData(ev) });
|
|
675
752
|
}
|
|
753
|
+
else if (ev.type === "wiring_manifest") {
|
|
754
|
+
// 🔴 core 5.14.0 design/173 新臂 —— **止泄第一刀**。本臂到货当天,它落在下面那条裸
|
|
755
|
+
// catch-all 上,于是 `manifest.governance`(core 盖了 `audience:"operator"` 的治理面在场
|
|
756
|
+
// 段)被原样透传给任意租户订阅者;core 把失败方向写死成「no projection ⇒ do not disclose」,
|
|
757
|
+
// 所以在 operator 投影(#154)落地之前,唯一正确的服务端行为是**整段剥除**。
|
|
758
|
+
// 共享白名单构造器,与两条 durable 腿同源(账本可经 events 重放读走 ⇒ 同一泄露面同一剥法)。
|
|
759
|
+
sseData(res, { type: "wiring_manifest", ...wiringManifestEventData(ev) });
|
|
760
|
+
}
|
|
761
|
+
else if (ev.type === "human_input") {
|
|
762
|
+
// core 5.14.0 design/171 新臂:谁把什么喂进了这条 run(本帧不带正文,只带载体/署名)。
|
|
763
|
+
// 同上——离开裸 catch-all:`issuer`/`actor.*` 是宿主派生的身份串(自由文本形)⇒ 构造器
|
|
764
|
+
// 内过 redactSecrets;`principal` 刻意不投影(帧本就走 owner-scoped 流)。
|
|
765
|
+
sseData(res, { type: "human_input", ...humanInputEventData(ev) });
|
|
766
|
+
}
|
|
676
767
|
else if (ev.type === "text_delta" || ev.type === "turn_end" || ev.type === "message_committed" || ev.type === "context_usage") {
|
|
677
768
|
// 裸 catch-all 白名单化收官(probe-wire-1 表征钉的根治半场):最后四个已知臂离开
|
|
678
769
|
// `JSON.stringify(ev)`。**零字节变化**白名单——已知键(含 E2 四 identity 键:live 现状
|
|
@@ -746,8 +837,13 @@ async function handleTasksBody(req, res, url, ctx, miss) {
|
|
|
746
837
|
const withElicit = deps.elicitation
|
|
747
838
|
? () => deps.elicitation.runWithContext({ taskId: askTaskId, owner: askOwner, emit: emitAsk, abortSignal: ac.signal }, streamBody)
|
|
748
839
|
: streamBody;
|
|
840
|
+
// #152 复审 A1:`deliverable` 是 durable 部署上 park-vs-live 判决的投递面谓词(见 question.ts 该键注)。
|
|
841
|
+
// 本腿的投递面就是这条 SSE 连接,而 ①b detach 车道下客户端断连**不 abort、不结束这条腿**——run 继续在
|
|
842
|
+
// 本 runWithContext 作用域里跑,emitAsk 往已死 socket 写被静默丢弃(question 帧无 ledgerSink 双写)。
|
|
843
|
+
// 谓词让判决面此时如实读作「无活流」⇒ durable park(运维可经 /decide 补答),而不是 allow 后挂满 ttl
|
|
844
|
+
// 以空答喂模型。断连发生在判决之后的窗口仍落 ttl 空答(与 elicit/approval 同栏,各自 TTL 语义)。
|
|
749
845
|
const withQuestion = () => deps.question
|
|
750
|
-
? deps.question.runWithContext({ taskId: askTaskId, owner: askOwner, emit: emitAsk, abortSignal: ac.signal }, withElicit)
|
|
846
|
+
? deps.question.runWithContext({ taskId: askTaskId, owner: askOwner, emit: emitAsk, abortSignal: ac.signal, deliverable: () => !res.writableEnded && !res.destroyed }, withElicit)
|
|
751
847
|
: withElicit();
|
|
752
848
|
// [816]/[820]②: the live tool-approval context — SAME id/owner/emit/signal as question/elicit
|
|
753
849
|
// (nested ALS, each its own store). sessionId keys the "allow all edits this session" state.
|
|
@@ -764,22 +860,104 @@ async function handleTasksBody(req, res, url, ctx, miss) {
|
|
|
764
860
|
throw new Error("live stream ended — approval card undeliverable");
|
|
765
861
|
emitAsk(frame);
|
|
766
862
|
};
|
|
863
|
+
// ── #151 车3 刀 3b:sync 腿的流内审批协议装配 ────────────────────────────────────────────
|
|
864
|
+
// 上场判据走**单一谓词**(与协调器注入 / `/v1/capabilities` / 回决端点 501 同一个符号)。
|
|
865
|
+
// 关着 ⇒ 下面三样(呈卡口 / 腿轴真值 / windowZero 分支)全部不接,本腿字节逐字不变(A-1)。
|
|
866
|
+
const streamApprovalOn = resolveStreamApprovalGate({
|
|
867
|
+
toolApprovalEnabled: Boolean(deps.toolApproval),
|
|
868
|
+
streamApprovalEnabled: deps.config.streamApproval?.enabled === true,
|
|
869
|
+
backend: deps.backend,
|
|
870
|
+
parkFacility: deps.checkpointStore !== undefined,
|
|
871
|
+
}).active;
|
|
872
|
+
const legWalltimeMs = typeof prepared.spec.limits?.maxWalltimeMs === "number" ? prepared.spec.limits.maxWalltimeMs : undefined;
|
|
873
|
+
// §7.3 + §8.3:本腿的「上不上场 / 窗是不是 0 / leg deadline 真值」走**三腿共用**的那一份判据
|
|
874
|
+
// (`resolveApprovalLeg` 顶注记着为什么必须是一个函数:第一版只写在本腿上,bg/resume 两腿因此
|
|
875
|
+
// 在开关关时行为变了、窗=0 也不生效)。取值点 = **本腿开始执行的时刻**(这里就是 `streamBody`
|
|
876
|
+
// 进入前的最后一站),误差方向偏短=安全侧。
|
|
877
|
+
const approvalLeg = resolveApprovalLeg({
|
|
878
|
+
streamApprovalOn,
|
|
879
|
+
windowMs: deps.config.streamApproval?.windowMs ?? 0,
|
|
880
|
+
windowMarginMs: deps.config.streamAskWindowMarginMs,
|
|
881
|
+
...(legWalltimeMs !== undefined ? { legWalltimeMs } : {}),
|
|
882
|
+
nowMonotonicMs: performance.now(),
|
|
883
|
+
});
|
|
884
|
+
const legDeadlineMonotonic = approvalLeg.legDeadlineMonotonic;
|
|
885
|
+
// 🔴 ①`forceDurableGate` principal 这一源**本装配点读不到**(`runtimeCapsResolver` 是 core 在
|
|
886
|
+
// prepare 内部按 principal 异步解析的 deps 级 seam)。按 §14 §12-5 亲裁:不可读则该源不做 ——
|
|
887
|
+
// core 自己兜得住(`prepare-task.js:3330` 的 `runtimeCaps?.forceDurableGate !== true` 让
|
|
888
|
+
// suspendAsk 不退位 ⇒ 结构性 park)。代价只是这类 principal 可能先收到一张必被 park 的卡,
|
|
889
|
+
// 已写进 wire 契约文档。
|
|
890
|
+
const windowZero = approvalLeg.windowZero;
|
|
891
|
+
// §4.3(a)+(c):呈卡帧的投递口 —— live SSE **与** detach 车道的 durable 账本**双写**。
|
|
892
|
+
// 🔴 codex round2 R2-1 修:两个 sink 的成败判定收进**唯一属主** `createApprovalCardEmitter`
|
|
893
|
+
// (顶注写着为什么「两路都没接下就必须抛」——静默成功会压住 park 路由,把一只没人能回答的 ask
|
|
894
|
+
// 挂到窗到期)。本处只负责把两个 sink 交出去;`writeLive` 在流已结束时**缺席**(不是 no-op),
|
|
895
|
+
// 于是「socket 已死 ∧ 账本写失败」如实成为一次投递失败。
|
|
896
|
+
const emitCard = createApprovalCardEmitter({
|
|
897
|
+
...(ledgerSink
|
|
898
|
+
? {
|
|
899
|
+
appendDurable: async (frame) => {
|
|
900
|
+
const { type, ...rest } = frame;
|
|
901
|
+
await ledgerSink.append(type, rest);
|
|
902
|
+
},
|
|
903
|
+
}
|
|
904
|
+
: {}),
|
|
905
|
+
writeLive: (frame) => {
|
|
906
|
+
if (res.writableEnded || res.destroyed)
|
|
907
|
+
throw new Error("live stream ended");
|
|
908
|
+
emitAsk(frame);
|
|
909
|
+
},
|
|
910
|
+
});
|
|
911
|
+
// 车6 撤卡帧:**live only**(不进 durable tail —— 收敛器/reaper 那类调用点没有 seq 可分配,
|
|
912
|
+
// 硬塞进账本会铸一条序号伪造的行;丢帧的结构补偿恒是重连 preamble 的全量对账基准)。
|
|
913
|
+
const emitRevokeLive = (frame) => {
|
|
914
|
+
if (!res.writableEnded && !res.destroyed)
|
|
915
|
+
emitAsk(frame);
|
|
916
|
+
};
|
|
767
917
|
// [1535] server 半场([1539] 定谳断点①的修):同一 ctx 双装——ALS 包裹(宿主自身 ask 的
|
|
768
918
|
// 既有路)+ **spec.onAsk = boundAsk(ctx)**(core 1.294 继承链 parentConstraints 冻结
|
|
769
919
|
// `spec.onAsk ?? deps.onAsk` 逐 ancestor 传给每个委派子代:bg 子代 lane 无 ALS ctx 时不再
|
|
770
920
|
// 「unavailable/零帧 fail-closed deny」,权限卡带 sourceTaskId 浮到宿主本流)。suspendAsk
|
|
771
921
|
// 退位条件的「onAsk 在场」真值不变(deps.onAsk 恒 wire),durable 部署行为零漂移。
|
|
772
|
-
|
|
773
|
-
|
|
922
|
+
// #151 车3 刀 3b:`windowZero` ⇒ **不包 ALS**(ALS 腿一并让位,`withApproval` 退化成
|
|
923
|
+
// `withQuestion`),且下面的 `spec.onAsk` 换成 immediate-unavailable 闭包 —— 两件必须同时做,
|
|
924
|
+
// 只做一件的话另一条路仍会呈卡(ALS 腿是 `deps.onAsk` 的到达路)。
|
|
925
|
+
const approvalCtx = deps.toolApproval && !windowZero
|
|
926
|
+
? {
|
|
927
|
+
taskId: askTaskId,
|
|
928
|
+
owner: askOwner,
|
|
929
|
+
emit: emitApproval,
|
|
930
|
+
abortSignal: ac.signal,
|
|
931
|
+
...(prepared.spec.sessionId ? { sessionId: prepared.spec.sessionId } : {}),
|
|
932
|
+
// 协议上场才接新协议族的两个口与两条腿轴;关着时逐字保持既有四键形。
|
|
933
|
+
...(streamApprovalOn
|
|
934
|
+
? {
|
|
935
|
+
emitCard,
|
|
936
|
+
emitRevoke: emitRevokeLive,
|
|
937
|
+
// sync 腿恒是**首腿**:它不经 checkpoint token 复活(那条是 resume 腿的形),
|
|
938
|
+
// 空串是首腿的**真值**而不是「未知」(见 ToolApprovalRunContext.legKey 顶注)。
|
|
939
|
+
legKey: "",
|
|
940
|
+
...(legDeadlineMonotonic !== undefined ? { legDeadlineMonotonic } : {}),
|
|
941
|
+
}
|
|
942
|
+
: {}),
|
|
943
|
+
}
|
|
774
944
|
: undefined;
|
|
775
|
-
if (
|
|
945
|
+
if (deps.toolApproval) {
|
|
776
946
|
// [1546] HIGH-1:闭包只携身份——emit 经 broker 取「当前」活跃流(宿主重连的新 SSE 在
|
|
777
947
|
// runWithContext 注册时覆盖指针),internalsSnapshot 冻结的闭包不再携死 response/死 signal。
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
948
|
+
// #151 车3 刀 3b(§8.3 ⑦):`windowZero` ⇒ 换成**立即** `"unavailable"` 的闭包。为什么不是
|
|
949
|
+
// 「不装 onAsk」:`deps.onAsk` 恒 wire(boot/runner-deps.ts)且 core 取 `spec.onAsk ?? deps.onAsk`,
|
|
950
|
+
// per-task 的「缺席」结构上不可达;而 core 的 `approverUnavailable === true` 分支
|
|
951
|
+
// (prepare-task.js:3328-3335)**直接绕过** onAsk 在场判定去铸 checkpoint —— 与「onAsk 缺席」
|
|
952
|
+
// 在 park 结局上逐字等价,且可 per-task 表达。
|
|
953
|
+
prepared.spec.onAsk = windowZero
|
|
954
|
+
? () => Promise.resolve("unavailable")
|
|
955
|
+
: deps.toolApproval.boundAsk({
|
|
956
|
+
owner: askOwner,
|
|
957
|
+
taskId: askTaskId,
|
|
958
|
+
...(prepared.spec.sessionId ? { sessionId: prepared.spec.sessionId } : {}),
|
|
959
|
+
...(streamApprovalOn ? { legKey: "", ...(legDeadlineMonotonic !== undefined ? { legDeadlineMonotonic } : {}) } : {}),
|
|
960
|
+
});
|
|
783
961
|
}
|
|
784
962
|
const withApproval = () => approvalCtx && deps.toolApproval ? deps.toolApproval.runWithContext(approvalCtx, withQuestion) : withQuestion();
|
|
785
963
|
// SendUserFile:sync 腿 file_link 走 live out-of-band 帧面(emitAsk)。detach 车道([1840]§五
|
|
@@ -1116,7 +1294,7 @@ async function handleTasksBody(req, res, url, ctx, miss) {
|
|
|
1116
1294
|
return { status: 200, body: stripCheckpointToken(result) };
|
|
1117
1295
|
};
|
|
1118
1296
|
const resp = await idemCache.run(idemKey, runSyncSubmitLeg, (r) => r.status < 400); // cache only success-range outcomes (a completed/failed BILLED result, replay to avoid double-bill); never a session-active 409 or a resume-at 4xx (billed nothing — a retry must re-check)
|
|
1119
|
-
|
|
1297
|
+
sendSubmitResult(res, resp); // 体里有 retryAfterSec ⇒ 标准 retry-after 头同值(见 sendSubmitResult 顶注)
|
|
1120
1298
|
}
|
|
1121
1299
|
return;
|
|
1122
1300
|
}
|
package/dist/http/server.d.ts
CHANGED
|
@@ -16,7 +16,7 @@ import type { FleetLeaseManager } from "../fleet-lease.js";
|
|
|
16
16
|
import { type FleetEventBus } from "../fleet/fleet-bus.js";
|
|
17
17
|
import type { ElicitationCoordinator } from "../elicitation.js";
|
|
18
18
|
import type { QuestionCoordinator } from "../question.js";
|
|
19
|
-
import type
|
|
19
|
+
import { type ToolApprovalCoordinator } from "../tool-approval.js";
|
|
20
20
|
import type { PlanCacheProbe } from "../plan-cache-probe.js";
|
|
21
21
|
import type { Logger } from "../observability/logger.js";
|
|
22
22
|
import type { Metrics } from "../observability/metrics.js";
|
package/dist/http/server.js
CHANGED
|
@@ -18,9 +18,10 @@ import {} from "../orchestration/workflow-agent-steer.js";
|
|
|
18
18
|
import { emitPendingWorkflowCompletions, taskNotificationInboxEntry, taskNotificationStreamKey, NotifiedKeys } from "../orchestration/workflow-completion-inbox.js";
|
|
19
19
|
import { fleetRunPublisher, fleetRunLabels, fleetRunResiduals, isFleetAgentTerminalNotification } from "../fleet/fleet-bus.js";
|
|
20
20
|
import { defaultSubagentTailBus, projectTailFrame } from "../fleet/subagent-tail-bus.js";
|
|
21
|
+
import { createApprovalCardEmitter, resolveApprovalLeg, resolveStreamApprovalGate } from "../tool-approval.js";
|
|
21
22
|
import {} from "../observability/rate-limit.js";
|
|
22
23
|
import { withPrincipal } from "../observability/principal-context.js";
|
|
23
|
-
import { turnEndEventData, contextUsageEventData, toolStartEventData, toolEndEventData, taskProgressEventData, taskNotificationEventData, compactedEventData, diagnosticsEventData, brainStatusEventData, steeringInjectedEventData, compactionOutcomeEventData, workspaceChangedEventData, appendModelUsageDelta, appendPromptManifest, attachModelUsage } from "../trace/project.js";
|
|
24
|
+
import { turnEndEventData, contextUsageEventData, toolStartEventData, toolEndEventData, taskProgressEventData, taskNotificationEventData, compactedEventData, diagnosticsEventData, brainStatusEventData, steeringInjectedEventData, compactionOutcomeEventData, workspaceChangedEventData, wiringManifestEventData, humanInputEventData, appendModelUsageDelta, appendPromptManifest, attachModelUsage } from "../trace/project.js";
|
|
24
25
|
import { redactSecrets } from "../trace/redact.js";
|
|
25
26
|
import { IdempotencyCache, scopedIdempotencyKey } from "./idempotency.js";
|
|
26
27
|
export { scopedIdempotencyKey };
|
|
@@ -270,7 +271,10 @@ export function createHttpServer(rawDeps) {
|
|
|
270
271
|
return await cs.setPendingSteer(token, scope, { text, trusted: false });
|
|
271
272
|
}
|
|
272
273
|
catch {
|
|
273
|
-
|
|
274
|
+
// park 面任何失败=诚实 false(调用方按 dropped 记账),绝不 throw 回 hook 收尾路径。
|
|
275
|
+
// core 5.14.0(#147)新增的 `steering.queue_full` 也落这里:队列满 ⇒ 这条 hook 输出确实没投出去,
|
|
276
|
+
// `false` 就是它的真实结局。**不给它 wire 码**——本通道是进程内 hook 收尾,没有 HTTP 应答面。
|
|
277
|
+
return false;
|
|
274
278
|
}
|
|
275
279
|
};
|
|
276
280
|
}
|
|
@@ -733,6 +737,13 @@ export function createHttpServer(rawDeps) {
|
|
|
733
737
|
return null;
|
|
734
738
|
}
|
|
735
739
|
const perms = st.permissions;
|
|
740
|
+
// F1(#157 复审车B 留裁,裁修 2026-08-05):出现但形错(数组/标量)⇒ 400,与 settings 自身门(上方
|
|
741
|
+
// :1097)同姿势——此前数组形被本门与 #157-③ 枚举门的 `!Array.isArray` 双双跳过、parse 层再静默
|
|
742
|
+
// drop = 保护型配置(deny 规则)端到端无效且无声。null 保持「容器缺席」不咬(既有钉)。
|
|
743
|
+
if (perms !== undefined && perms !== null && (typeof perms !== "object" || Array.isArray(perms))) {
|
|
744
|
+
sendError(res, 400, "request.field_invalid", "settings.permissions must be an object");
|
|
745
|
+
return null;
|
|
746
|
+
}
|
|
736
747
|
if (perms !== null && typeof perms === "object" && !Array.isArray(perms)) {
|
|
737
748
|
for (const k of ["allow", "deny", "ask"]) {
|
|
738
749
|
const arr = perms[k];
|
|
@@ -1065,6 +1076,44 @@ export function createHttpServer(rawDeps) {
|
|
|
1065
1076
|
return null;
|
|
1066
1077
|
}
|
|
1067
1078
|
}
|
|
1079
|
+
// [2856]② toolMaterializeStrategy:枚举 fail-loud(邻居 promptProfile 同款,同一条理由——静默折缺省
|
|
1080
|
+
// 会让调用方以为切了策略却没切,而这个键的整个存在理由就是 #181 那条「缺省下无界循环」)。
|
|
1081
|
+
{
|
|
1082
|
+
const v = body.toolMaterializeStrategy;
|
|
1083
|
+
if (v !== undefined && v !== "static" && v !== "swap") {
|
|
1084
|
+
sendError(res, 400, "request.field_invalid", 'toolMaterializeStrategy must be "static" or "swap"');
|
|
1085
|
+
return null;
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
// #157-② permissionMode:枚举 fail-loud(邻居 promptProfile 同款)。此前开放 string + coercePermissionMode
|
|
1089
|
+
// 未知词静默折 "default"(最爱问档)——方向对(收紧)但静默:拼错(大小写 "Plan")的调用方以为进了 plan
|
|
1090
|
+
// 模式,实拿恒询问,silent-drop 病灶同族([2759] cli settings.json 同源病)。RESUME 重放持久化 body 不过
|
|
1091
|
+
// 此门(coercePermissionMode 宽容层护存量,双层与 [854]/outputStyle 同姿势);bundle 的
|
|
1092
|
+
// settings.permissions.defaultMode drop 臂本批不动(候 [2759] 跨仓对表统一裁,task-settings.ts:216 注)。
|
|
1093
|
+
{
|
|
1094
|
+
const v = body.permissionMode;
|
|
1095
|
+
if (v !== undefined && v !== "default" && v !== "acceptEdits" && v !== "plan" && v !== "bypassPermissions" && v !== "auto") {
|
|
1096
|
+
sendError(res, 400, "request.field_invalid", 'permissionMode must be one of "default" | "acceptEdits" | "plan" | "bypassPermissions" | "auto"');
|
|
1097
|
+
return null;
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
// #157-③([2766] cli 对表裁决):bundle 的 settings.permissions.defaultMode 与顶层同形 400 —— 经壳的
|
|
1101
|
+
// 流量上 wire 前已过闭集校验(壳侧对人手编辑面走「降级+响亮」b979f74),server 收到未知词只可能来自
|
|
1102
|
+
// 旧版壳或第三方直连=程序化入口,严格拒合理。parseTaskSettings 的 drop 臂保留护 RESUME 存量
|
|
1103
|
+
// (task-settings.ts:216 双层,同 permissionMode/outputStyle 姿势)。
|
|
1104
|
+
{
|
|
1105
|
+
const s = body.settings;
|
|
1106
|
+
if (s !== null && typeof s === "object" && !Array.isArray(s)) {
|
|
1107
|
+
const p = s.permissions;
|
|
1108
|
+
if (p !== null && typeof p === "object" && !Array.isArray(p)) {
|
|
1109
|
+
const m = p.defaultMode;
|
|
1110
|
+
if (m !== undefined && m !== "default" && m !== "acceptEdits" && m !== "plan" && m !== "bypassPermissions" && m !== "auto") {
|
|
1111
|
+
sendError(res, 400, "request.field_invalid", 'settings.permissions.defaultMode must be one of "default" | "acceptEdits" | "plan" | "bypassPermissions" | "auto"');
|
|
1112
|
+
return null;
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1068
1117
|
// [865]② fresh-submit 未知模型 400 fail-loud(clay 生产实锤:未知 body.model 静默落 default = 用户不知情
|
|
1069
1118
|
// 换模型)。🔴 门目录必须与 resolveSpec 同源 = expandTiers 增广视图(codex H1:裸 config.models 会把合法
|
|
1070
1119
|
// 档位词 "pro"/CC alias "sonnet" 当未知 400 误杀——SDK 契约明确允许它们;增广后 matchCatalogModel 对
|
|
@@ -1396,10 +1445,16 @@ export function createHttpServer(rawDeps) {
|
|
|
1396
1445
|
}
|
|
1397
1446
|
// Durable-ask request/state coherence (TC-5.4), all pre-CAS so a mismatched request never consumes the
|
|
1398
1447
|
// checkpoint:
|
|
1399
|
-
// - approving a pending AskUserQuestion REQUIRES body.answer. Without it the
|
|
1400
|
-
//
|
|
1401
|
-
//
|
|
1402
|
-
//
|
|
1448
|
+
// - approving a pending AskUserQuestion REQUIRES body.answer. Without it the redeemed call executes against
|
|
1449
|
+
// whatever question face the rebuilt spec has, and the operator's question is unrecoverably LOST (checkpoint
|
|
1450
|
+
// consumed, no answer ever delivered). Fail the request instead; the operator retries with the answer.
|
|
1451
|
+
// 🔴 #152: this check is now the ONLY one on this route. core has its own pre-CAS refusal for
|
|
1452
|
+
// "approve a content-ask with no answer" (runtask.js: `taskConfig.onQuestion ?? deps.onQuestion` is
|
|
1453
|
+
// `undefined` or QUESTION_AWAITS_RESUME), but on an ASK_QUESTION_ENABLED deployment the spec no longer
|
|
1454
|
+
// stamps the sentinel and `deps.onQuestion` is the coordinator, so core's condition is never met and it
|
|
1455
|
+
// does NOT refuse. Without the guard below the resume would execute the question on a leg with no live
|
|
1456
|
+
// context, take the coordinator's empty "no human" default, and consume the checkpoint. Do not delete it
|
|
1457
|
+
// on the belief that core still catches this (parked twin: src/parked-decide.ts).
|
|
1403
1458
|
// - an answer is only meaningful on an approve of a question gate; on a deny or a non-question gate it
|
|
1404
1459
|
// signals the operator is deciding a different pending action than the one parked — reject, don't guess.
|
|
1405
1460
|
if (decision === "approve" && pendingTool === "AskUserQuestion" && !answer) {
|
|
@@ -1461,6 +1516,32 @@ export function createHttpServer(rawDeps) {
|
|
|
1461
1516
|
*/
|
|
1462
1517
|
async function driveResumeIntoRunLog(args) {
|
|
1463
1518
|
const { token, sessionId, principal, fleetScope, taskConfig, resumeObjective, outcome, verifyRounds } = args;
|
|
1519
|
+
// ── #151 车3 刀 3b:resume 腿的 `legKey` 真值(设计稿 §3.2′)──────────────────────────────────
|
|
1520
|
+
// 一次 park→resume 的 checkpoint token **就是**这条腿的天然身份:同一 token 重投 = 同一条腿(幂等,
|
|
1521
|
+
// askId 不变);新 park ⇒ 新 token ⇒ 新腿 ⇒ 新 askId ⇒ **重新征询**(§3.3「resume 只重放未完成兄弟」)。
|
|
1522
|
+
// 🔴 存**摘要**不存原文:token 是能力凭据(本仓有 `stripCheckpointToken` 专门把它从可重放账本/结果里
|
|
1523
|
+
// 剥掉),而 `askId` 是 wire 可见值 —— 摘要单向,原始 token 不进任何行、不进任何帧。
|
|
1524
|
+
// 入口算一次(这里就是唯一持有 `args.token` 的地方),下面随 ctx 走。
|
|
1525
|
+
const legKey = createHash("sha256").update(String(token)).digest("hex");
|
|
1526
|
+
// §7.3:本 leg 的 walltime deadline —— 取值点 = 本腿开始执行之前(`resumeStream` 调用在下方)。
|
|
1527
|
+
// 与 sync/bg 两腿同一条判据:只在协议上场时供值,误差方向偏短(安全侧),理由见 routes/tasks.ts 同处注。
|
|
1528
|
+
const streamApprovalOn = resolveStreamApprovalGate({
|
|
1529
|
+
toolApprovalEnabled: Boolean(deps.toolApproval),
|
|
1530
|
+
streamApprovalEnabled: deps.config.streamApproval?.enabled === true,
|
|
1531
|
+
backend: deps.backend,
|
|
1532
|
+
parkFacility: deps.checkpointStore !== undefined,
|
|
1533
|
+
}).active;
|
|
1534
|
+
// 🔴 codex 交叉复审 F4(2026-08-06 真 finding)的修:本腿此前**无条件**包 approval ALS(只把新协议的
|
|
1535
|
+
// 口与轴挂在 `streamApprovalOn` 后面)⇒ ①协议关时本腿凭空多了一条活卡投递路(此前恒 park,A-1 破)、
|
|
1536
|
+
// ②窗=0 在本腿上不生效。裁定走**三腿共用**的 `resolveApprovalLeg`,一份判据三处消费。
|
|
1537
|
+
const approvalLeg = resolveApprovalLeg({
|
|
1538
|
+
streamApprovalOn,
|
|
1539
|
+
windowMs: deps.config.streamApproval?.windowMs ?? 0,
|
|
1540
|
+
windowMarginMs: deps.config.streamAskWindowMarginMs,
|
|
1541
|
+
...(typeof taskConfig.limits?.maxWalltimeMs === "number" ? { legWalltimeMs: taskConfig.limits.maxWalltimeMs } : {}),
|
|
1542
|
+
nowMonotonicMs: performance.now(),
|
|
1543
|
+
});
|
|
1544
|
+
const legDeadlineMonotonic = approvalLeg.legDeadlineMonotonic;
|
|
1464
1545
|
// 🔴 复审 C2:lease admission for EVERY resume family (/decide, /answer, /plan_review, preempt-resume)
|
|
1465
1546
|
// funnels through here — keyed on `principal` = the CHECKPOINT OWNER, the SAME identity the resumed model spend
|
|
1466
1547
|
// is billed to via `withPrincipal(principal)` below. The route guards deliberately do NOT lease-gate (they'd
|
|
@@ -1864,6 +1945,14 @@ export function createHttpServer(rawDeps) {
|
|
|
1864
1945
|
await flush();
|
|
1865
1946
|
await append("workspace_changed", workspaceChangedEventData(ev));
|
|
1866
1947
|
break; // [1559]二: cwd move (bash cd/EnterWorktree) — shared whitelist builder, §E1 redact — resume twin of the sync + ledger-sink legs
|
|
1948
|
+
case "wiring_manifest":
|
|
1949
|
+
await flush();
|
|
1950
|
+
await append("wiring_manifest", wiringManifestEventData(ev));
|
|
1951
|
+
break; // 🔴 core 5.14.0 design/173:resume 腿自证清单(每条腿发自己那份)。**同一个**构造器剥 `governance` 段(audience:"operator",no projection ⇒ do not disclose)——三腿同源,分头挑键即下一次泄露
|
|
1952
|
+
case "human_input":
|
|
1953
|
+
await flush();
|
|
1954
|
+
await append("human_input", humanInputEventData(ev));
|
|
1955
|
+
break; // core 5.14.0 design/171:人类输入生命周期账本(parked-steer 续跑帧正是在这条腿上出现);身份串脱敏在共享构造器里
|
|
1867
1956
|
case "message_committed":
|
|
1868
1957
|
await anchor.onMessageCommitted(ev.role, ev.entryId);
|
|
1869
1958
|
break; // R8: USER-message rewind anchor (CC "rewind to the prompt")
|
|
@@ -1946,9 +2035,33 @@ export function createHttpServer(rawDeps) {
|
|
|
1946
2035
|
}
|
|
1947
2036
|
return safe;
|
|
1948
2037
|
};
|
|
2038
|
+
// #151 车3 刀 3b(design/172 §4.3(b)):resume 腿的**审批 ALS** —— 此前这条腿只有 elicitation ctx,
|
|
2039
|
+
// 审批 ask 恒 `"unavailable"` ⇒ 恒 park,一张卡都不产。投递面 = 本腿的 durable events tail
|
|
2040
|
+
// (与 bg 腿同形:`append(type, rest)`,`emitCard` 同口;撤卡帧 live-only 故不接)。
|
|
2041
|
+
// `legKey` = 上面算好的 token 摘要 —— 这是 resume 腿与 sync/bg 首腿的唯一区别,也正是「新 park ⇒
|
|
2042
|
+
// 新腿 ⇒ 重新征询」这条安全语义的承载轴。
|
|
2043
|
+
const approvalEmit = (frame) => {
|
|
2044
|
+
const { type, ...rest } = frame;
|
|
2045
|
+
return append(type, rest);
|
|
2046
|
+
};
|
|
2047
|
+
// 包 ALS ⟺ 协议在本腿上场且窗不为 0(F4 修;`windowZero` 在没有 per-task `spec.onAsk` 装配点的
|
|
2048
|
+
// 腿上,「不包 ALS」就是 immediate-unavailable 闭包的等价形,见 resolveApprovalLeg 顶注)。
|
|
2049
|
+
const withApproval = () => deps.toolApproval && taskId && approvalLeg.active && !approvalLeg.windowZero
|
|
2050
|
+
? deps.toolApproval.runWithContext({
|
|
2051
|
+
taskId,
|
|
2052
|
+
owner: principal ?? null,
|
|
2053
|
+
emit: approvalEmit,
|
|
2054
|
+
abortSignal: cancelCtrl.signal,
|
|
2055
|
+
sessionId,
|
|
2056
|
+
// 同 bg 腿:唯一 sink 是本腿的 durable tail,append 失败如实算未送达(R2-1)。
|
|
2057
|
+
emitCard: createApprovalCardEmitter({ appendDurable: (f) => approvalEmit(f) }),
|
|
2058
|
+
legKey,
|
|
2059
|
+
...(legDeadlineMonotonic !== undefined ? { legDeadlineMonotonic } : {}),
|
|
2060
|
+
}, driveResume)
|
|
2061
|
+
: driveResume();
|
|
1949
2062
|
return await (deps.elicitation && taskId
|
|
1950
|
-
? deps.elicitation.runWithContext({ taskId, owner: principal ?? null, emit: (frame) => { const { type, ...rest } = frame; return append(type, rest); }, abortSignal: cancelCtrl.signal },
|
|
1951
|
-
:
|
|
2063
|
+
? deps.elicitation.runWithContext({ taskId, owner: principal ?? null, emit: (frame) => { const { type, ...rest } = frame; return append(type, rest); }, abortSignal: cancelCtrl.signal }, withApproval)
|
|
2064
|
+
: withApproval());
|
|
1952
2065
|
});
|
|
1953
2066
|
}
|
|
1954
2067
|
catch (e) {
|
|
@@ -2618,6 +2731,9 @@ const ROUTE_LABEL_PATTERNS = [
|
|
|
2618
2731
|
[/^\/v1\/workflows\/[^/]+\/agents\/[^/]+\/steer$/, "/v1/workflows/:id/agents/:aid/steer"],
|
|
2619
2732
|
[/^\/v1\/workflows\/[^/]+\/(stream|journal)$/, "/v1/workflows/:id/$sub"],
|
|
2620
2733
|
[/^\/v1\/workflows\/[^/]+$/, "/v1/workflows/:id"],
|
|
2734
|
+
// #151 车4:durable 回决口。必须排在 `/v1/tasks/:id/$sub` **之前**?—— 不必(那条只认三个字面动词),
|
|
2735
|
+
// 但仍按「具体先于泛化」的表序纪律放在它前面,免得日后 $sub 放宽成 `[^/]+` 时静默吞掉本条。
|
|
2736
|
+
[/^\/v1\/tasks\/[^/]+\/asks\/[^/]+\/decision$/, "/v1/tasks/:id/asks/:askId/decision"],
|
|
2621
2737
|
[/^\/v1\/tasks\/[^/]+\/(turns|stream|artifacts)$/, "/v1/tasks/:id/$sub"],
|
|
2622
2738
|
[/^\/v1\/leader\/[^/]+$/, "/v1/leader/:id"],
|
|
2623
2739
|
[/^\/v1\/attachments\/[^/]+$/, "/v1/attachments/:id"],
|
package/dist/http/sse-log.d.ts
CHANGED
|
@@ -30,7 +30,58 @@ export interface SseLogProvider<E extends {
|
|
|
30
30
|
event: string;
|
|
31
31
|
data: unknown;
|
|
32
32
|
};
|
|
33
|
+
/**
|
|
34
|
+
* #151 车3 §5:**开流 preamble** —— 在 416 判定与 `sseHeaders` **之后**、日志 tail **之前**写的
|
|
35
|
+
* 一批**非账本**帧(今天唯一的用户 = 审批未决卡的全量对账基准)。
|
|
36
|
+
*
|
|
37
|
+
* 🔴 为什么必须是这里的内部 hook,而不是调用方在 `streamSseLog` **之前**自己写:本 helper 的判序是
|
|
38
|
+
* ①读 `last-event-id`/`?from=` → ②保留期比对、超界直接 `sendError(416)` → ③`sseHeaders` → ④tail。
|
|
39
|
+
* 在 ① 之前写任何字节 = 提前提交 200 头 ⇒ ② 的 416 分支**结构上不可能再触发**(或触发
|
|
40
|
+
* headers-already-sent),而调用方自己先调 `sseHeaders` 又会被本 helper 重复写头。
|
|
41
|
+
*
|
|
42
|
+
* 返回帧**不带 `id:`** —— 它不是账本行,不参与 `Last-Event-ID` 游标(壳的规则:preamble 给的是
|
|
43
|
+
* 权威全量卡集,账本重放里的同 id 帧只用于时间线渲染)。
|
|
44
|
+
*
|
|
45
|
+
* 缺席 = 零影响(其余 provider 不实现)。失败/挂起的处置见 {@link collectSsePreamble}。
|
|
46
|
+
*
|
|
47
|
+
* `signal`(#151 车3 刀 3b,§14.2 遗留记账):有界窗到点时**被 abort** —— 实现方应把它传进自己的
|
|
48
|
+
* store 读口,让那次读当场以 abort 拒绝,而不是留一个「结果已被丢弃、调用链却还在飞」的悬挂读。
|
|
49
|
+
* 可达到的语义与残留边界见 `ApprovalAskStore.listPendingBySession` 的 `signal` 注(驱动无取消 seam)。
|
|
50
|
+
*/
|
|
51
|
+
preamble?(signal: AbortSignal): Promise<Array<{
|
|
52
|
+
event: string;
|
|
53
|
+
data: unknown;
|
|
54
|
+
}>>;
|
|
55
|
+
/**
|
|
56
|
+
* `preamble` 被有界超时掐断("timeout")或抛出("error")时的**观测**钩(fail-open,已经决定不挡
|
|
57
|
+
* 开流了,这里只让域模块用自己的 logger 记一条 warn —— 本叶子模块没有、也不该有 logger)。
|
|
58
|
+
*/
|
|
59
|
+
onPreambleFailure?(reason: "timeout" | "error", err?: unknown): void;
|
|
33
60
|
}
|
|
61
|
+
/** #151 车3 §5.3:preamble 的**有界**等待上限。挂起的 store 不许拖住开流——开流是壳的主路径,
|
|
62
|
+
* 而 preamble 只是一个锦上添花的对账基准。超时即按「无卡集基准」继续(与今天等同)。 */
|
|
63
|
+
export declare const SSE_PREAMBLE_TIMEOUT_MS = 2000;
|
|
64
|
+
/**
|
|
65
|
+
* 跑一次 preamble,**有界 + fail-open**(#151 车3 §5.3)。
|
|
66
|
+
*
|
|
67
|
+
* 🔴 只 catch throw 是不够的:store **挂起**(不抛)会把整条开流一起拖住。所以这里是 `Promise.race`
|
|
68
|
+
* 而不是 try/catch —— 两种失败形(抛 / 不返回)各自有一条出路,都落到「零帧 + 一次 warn + 继续开流」。
|
|
69
|
+
*
|
|
70
|
+
* 🔴 #151 车3 刀 3b(§14.2 遗留记账兑现):超时**同时 abort** 一个交给 `preamble` 的 `AbortSignal`。
|
|
71
|
+
* 此前只是「丢结果」,原 promise 仍在飞 —— DB 挂死时的重连风暴会一轮轮往连接池里堆等待者。现在读口
|
|
72
|
+
* 拿到 abort 会当场拒绝,调用链不再累积;库那侧的那条查询仍会跑完(驱动无取消 seam,残留边界见
|
|
73
|
+
* `ApprovalAskStore.listPendingBySession` 的注)。丢结果这条语义不变:preamble 只读,丢弃零副作用。
|
|
74
|
+
*
|
|
75
|
+
* 导出给 `routes/tasks.ts` 的 sync 腿直接用 —— 那条腿不走 `streamSseLog`(它不是账本 tail),但
|
|
76
|
+
* 「有界 + fail-open」这条判据必须是**同一份实现**,不能各腿各写一遍。
|
|
77
|
+
*/
|
|
78
|
+
export declare function collectSsePreamble(preamble: (signal: AbortSignal) => Promise<Array<{
|
|
79
|
+
event: string;
|
|
80
|
+
data: unknown;
|
|
81
|
+
}>>, onFailure?: (reason: "timeout" | "error", err?: unknown) => void, timeoutMs?: number): Promise<Array<{
|
|
82
|
+
event: string;
|
|
83
|
+
data: unknown;
|
|
84
|
+
}>>;
|
|
34
85
|
/** The shared resumable SSE pump (P2.8) — provider supplies the log-specific differences; the liveness/
|
|
35
86
|
* heartbeat/416/terminal-re-fetch/15-min-cap logic is identical for task_run + image_bake. */
|
|
36
87
|
export declare function streamSseLog<E extends {
|
package/dist/http/sse-log.js
CHANGED
|
@@ -1,4 +1,51 @@
|
|
|
1
1
|
import { sendJson, sendError, sseHeaders } from "./send.js";
|
|
2
|
+
/** #151 车3 §5.3:preamble 的**有界**等待上限。挂起的 store 不许拖住开流——开流是壳的主路径,
|
|
3
|
+
* 而 preamble 只是一个锦上添花的对账基准。超时即按「无卡集基准」继续(与今天等同)。 */
|
|
4
|
+
export const SSE_PREAMBLE_TIMEOUT_MS = 2_000;
|
|
5
|
+
/**
|
|
6
|
+
* 跑一次 preamble,**有界 + fail-open**(#151 车3 §5.3)。
|
|
7
|
+
*
|
|
8
|
+
* 🔴 只 catch throw 是不够的:store **挂起**(不抛)会把整条开流一起拖住。所以这里是 `Promise.race`
|
|
9
|
+
* 而不是 try/catch —— 两种失败形(抛 / 不返回)各自有一条出路,都落到「零帧 + 一次 warn + 继续开流」。
|
|
10
|
+
*
|
|
11
|
+
* 🔴 #151 车3 刀 3b(§14.2 遗留记账兑现):超时**同时 abort** 一个交给 `preamble` 的 `AbortSignal`。
|
|
12
|
+
* 此前只是「丢结果」,原 promise 仍在飞 —— DB 挂死时的重连风暴会一轮轮往连接池里堆等待者。现在读口
|
|
13
|
+
* 拿到 abort 会当场拒绝,调用链不再累积;库那侧的那条查询仍会跑完(驱动无取消 seam,残留边界见
|
|
14
|
+
* `ApprovalAskStore.listPendingBySession` 的注)。丢结果这条语义不变:preamble 只读,丢弃零副作用。
|
|
15
|
+
*
|
|
16
|
+
* 导出给 `routes/tasks.ts` 的 sync 腿直接用 —— 那条腿不走 `streamSseLog`(它不是账本 tail),但
|
|
17
|
+
* 「有界 + fail-open」这条判据必须是**同一份实现**,不能各腿各写一遍。
|
|
18
|
+
*/
|
|
19
|
+
export async function collectSsePreamble(preamble, onFailure, timeoutMs = SSE_PREAMBLE_TIMEOUT_MS) {
|
|
20
|
+
const timedOut = Symbol("preamble-timeout");
|
|
21
|
+
let timer;
|
|
22
|
+
const ac = new AbortController();
|
|
23
|
+
try {
|
|
24
|
+
const raced = await Promise.race([
|
|
25
|
+
preamble(ac.signal),
|
|
26
|
+
new Promise((resolve) => {
|
|
27
|
+
timer = setTimeout(() => {
|
|
28
|
+
ac.abort(); // 先掐读,再回空集——两件事的顺序无关紧要,但都要发生
|
|
29
|
+
resolve(timedOut);
|
|
30
|
+
}, timeoutMs);
|
|
31
|
+
timer.unref?.(); // 别让这只表把进程钉住(测试进程里尤其明显)
|
|
32
|
+
}),
|
|
33
|
+
]);
|
|
34
|
+
if (raced === timedOut) {
|
|
35
|
+
onFailure?.("timeout");
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
return raced;
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
onFailure?.("error", err);
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
if (timer)
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
2
49
|
/** The shared resumable SSE pump (P2.8) — provider supplies the log-specific differences; the liveness/
|
|
3
50
|
* heartbeat/416/terminal-re-fetch/15-min-cap logic is identical for task_run + image_bake. */
|
|
4
51
|
export async function streamSseLog(req, res, provider, id, staleMs) {
|
|
@@ -14,10 +61,27 @@ export async function streamSseLog(req, res, provider, id, staleMs) {
|
|
|
14
61
|
}
|
|
15
62
|
}
|
|
16
63
|
sseHeaders(res);
|
|
64
|
+
// 🔴 断连接线必须**先于** preamble 的等待窗接上(codex 复审 2026-08-06 的 medium):Node 的 `'close'`
|
|
65
|
+
// 只发一次,在等待窗里断连的客户端其 close 事件会在监听器注册之前烧掉 ⇒ `closed` 恒 false ⇒ 泵照常
|
|
66
|
+
// 进轮询循环,对着已死的 res 反复 getEvents/statusOf 直到终态/stale/15 分钟帽。本行原本紧跟
|
|
67
|
+
// sseHeaders,是 preamble 插进来才多出这个窗 —— 所以搬接线、不搬 preamble。
|
|
17
68
|
let closed = false;
|
|
18
69
|
req.on("close", () => {
|
|
19
70
|
closed = true;
|
|
20
71
|
});
|
|
72
|
+
// #151 车3 §5.1:preamble 排在 416 判定与 sseHeaders **之后**、日志 tail 之前。有界 + fail-open,
|
|
73
|
+
// 见 collectSsePreamble 顶注;`id:` 行故意不写(非账本行,不推进 Last-Event-ID 游标)。
|
|
74
|
+
const preamble = provider.preamble?.bind(provider); // bind:接口按方法签名声明,实现方允许用 `this`
|
|
75
|
+
if (preamble) {
|
|
76
|
+
const frames = await collectSsePreamble(preamble, (reason, err) => provider.onPreambleFailure?.(reason, err));
|
|
77
|
+
// 等待窗里断连了就别再写(`closed` 由上面的 close 监听器置;`writableEnded`/`destroyed` 是
|
|
78
|
+
// 第二道 —— codex 复审第二轮 medium 指出 res 侧的 close 面本 helper 没听,那半条见终报的存疑单,
|
|
79
|
+
// 这里先把「往已死的 res 写」这半堵掉,判据与 routes/tasks.ts 心跳处逐字同源)。
|
|
80
|
+
if (!closed && !res.writableEnded && !res.destroyed) {
|
|
81
|
+
for (const f of frames)
|
|
82
|
+
res.write(`event: ${f.event}\ndata: ${JSON.stringify(f.data)}\n\n`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
21
85
|
const start = Date.now();
|
|
22
86
|
const MAX_MS = 15 * 60 * 1000;
|
|
23
87
|
let lastBeat = Date.now();
|