@sema-agent/server 7.2.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 +2 -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/reapers.d.ts +34 -0
- package/dist/boot/reapers.js +198 -23
- package/dist/boot/resolve-spec.js +82 -30
- package/dist/config-types.d.ts +61 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.js +130 -2
- package/dist/elicitation.d.ts +4 -0
- package/dist/elicitation.js +2 -2
- package/dist/http/routes/capabilities.js +14 -0
- package/dist/http/routes/runs.d.ts +1 -0
- package/dist/http/routes/runs.js +548 -16
- package/dist/http/routes/tasks.js +151 -9
- package/dist/http/server.d.ts +1 -1
- package/dist/http/server.js +114 -4
- 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/main.js +1 -1
- 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 +3 -0
- package/dist/question.js +2 -2
- 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/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)的帧
|
|
@@ -196,6 +199,49 @@ async function handleTasksBody(req, res, url, ctx, miss) {
|
|
|
196
199
|
if (!res.writableEnded)
|
|
197
200
|
onDisconnect();
|
|
198
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
|
+
}
|
|
199
245
|
// Heartbeat so a long no-token gap (e.g. a debate phase) doesn't trip a mesh/ingress idle timeout on
|
|
200
246
|
// the relay (center). SSE comment lines are ignored by EventSource but keep the socket alive.
|
|
201
247
|
const hb = setInterval(() => {
|
|
@@ -704,6 +750,20 @@ async function handleTasksBody(req, res, url, ctx, miss) {
|
|
|
704
750
|
// 与两条 durable 腿同源(它曾是唯一不走 builder 的内容帧=三方普查共同盲区)。
|
|
705
751
|
sseData(res, { type: "compaction_outcome", ...compactionOutcomeEventData(ev) });
|
|
706
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
|
+
}
|
|
707
767
|
else if (ev.type === "text_delta" || ev.type === "turn_end" || ev.type === "message_committed" || ev.type === "context_usage") {
|
|
708
768
|
// 裸 catch-all 白名单化收官(probe-wire-1 表征钉的根治半场):最后四个已知臂离开
|
|
709
769
|
// `JSON.stringify(ev)`。**零字节变化**白名单——已知键(含 E2 四 identity 键:live 现状
|
|
@@ -800,22 +860,104 @@ async function handleTasksBody(req, res, url, ctx, miss) {
|
|
|
800
860
|
throw new Error("live stream ended — approval card undeliverable");
|
|
801
861
|
emitAsk(frame);
|
|
802
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
|
+
};
|
|
803
917
|
// [1535] server 半场([1539] 定谳断点①的修):同一 ctx 双装——ALS 包裹(宿主自身 ask 的
|
|
804
918
|
// 既有路)+ **spec.onAsk = boundAsk(ctx)**(core 1.294 继承链 parentConstraints 冻结
|
|
805
919
|
// `spec.onAsk ?? deps.onAsk` 逐 ancestor 传给每个委派子代:bg 子代 lane 无 ALS ctx 时不再
|
|
806
920
|
// 「unavailable/零帧 fail-closed deny」,权限卡带 sourceTaskId 浮到宿主本流)。suspendAsk
|
|
807
921
|
// 退位条件的「onAsk 在场」真值不变(deps.onAsk 恒 wire),durable 部署行为零漂移。
|
|
808
|
-
|
|
809
|
-
|
|
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
|
+
}
|
|
810
944
|
: undefined;
|
|
811
|
-
if (
|
|
945
|
+
if (deps.toolApproval) {
|
|
812
946
|
// [1546] HIGH-1:闭包只携身份——emit 经 broker 取「当前」活跃流(宿主重连的新 SSE 在
|
|
813
947
|
// runWithContext 注册时覆盖指针),internalsSnapshot 冻结的闭包不再携死 response/死 signal。
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
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
|
+
});
|
|
819
961
|
}
|
|
820
962
|
const withApproval = () => approvalCtx && deps.toolApproval ? deps.toolApproval.runWithContext(approvalCtx, withQuestion) : withQuestion();
|
|
821
963
|
// SendUserFile:sync 腿 file_link 走 live out-of-band 帧面(emitAsk)。detach 车道([1840]§五
|
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 对
|
|
@@ -1467,6 +1516,32 @@ export function createHttpServer(rawDeps) {
|
|
|
1467
1516
|
*/
|
|
1468
1517
|
async function driveResumeIntoRunLog(args) {
|
|
1469
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;
|
|
1470
1545
|
// 🔴 复审 C2:lease admission for EVERY resume family (/decide, /answer, /plan_review, preempt-resume)
|
|
1471
1546
|
// funnels through here — keyed on `principal` = the CHECKPOINT OWNER, the SAME identity the resumed model spend
|
|
1472
1547
|
// is billed to via `withPrincipal(principal)` below. The route guards deliberately do NOT lease-gate (they'd
|
|
@@ -1870,6 +1945,14 @@ export function createHttpServer(rawDeps) {
|
|
|
1870
1945
|
await flush();
|
|
1871
1946
|
await append("workspace_changed", workspaceChangedEventData(ev));
|
|
1872
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 续跑帧正是在这条腿上出现);身份串脱敏在共享构造器里
|
|
1873
1956
|
case "message_committed":
|
|
1874
1957
|
await anchor.onMessageCommitted(ev.role, ev.entryId);
|
|
1875
1958
|
break; // R8: USER-message rewind anchor (CC "rewind to the prompt")
|
|
@@ -1952,9 +2035,33 @@ export function createHttpServer(rawDeps) {
|
|
|
1952
2035
|
}
|
|
1953
2036
|
return safe;
|
|
1954
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();
|
|
1955
2062
|
return await (deps.elicitation && taskId
|
|
1956
|
-
? deps.elicitation.runWithContext({ taskId, owner: principal ?? null, emit: (frame) => { const { type, ...rest } = frame; return append(type, rest); }, abortSignal: cancelCtrl.signal },
|
|
1957
|
-
:
|
|
2063
|
+
? deps.elicitation.runWithContext({ taskId, owner: principal ?? null, emit: (frame) => { const { type, ...rest } = frame; return append(type, rest); }, abortSignal: cancelCtrl.signal }, withApproval)
|
|
2064
|
+
: withApproval());
|
|
1958
2065
|
});
|
|
1959
2066
|
}
|
|
1960
2067
|
catch (e) {
|
|
@@ -2624,6 +2731,9 @@ const ROUTE_LABEL_PATTERNS = [
|
|
|
2624
2731
|
[/^\/v1\/workflows\/[^/]+\/agents\/[^/]+\/steer$/, "/v1/workflows/:id/agents/:aid/steer"],
|
|
2625
2732
|
[/^\/v1\/workflows\/[^/]+\/(stream|journal)$/, "/v1/workflows/:id/$sub"],
|
|
2626
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"],
|
|
2627
2737
|
[/^\/v1\/tasks\/[^/]+\/(turns|stream|artifacts)$/, "/v1/tasks/:id/$sub"],
|
|
2628
2738
|
[/^\/v1\/leader\/[^/]+$/, "/v1/leader/:id"],
|
|
2629
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();
|
|
@@ -230,12 +230,27 @@ export interface TaskRequestBody {
|
|
|
230
230
|
/** [1144]/[1146] (core 1.328 R2): 提示词双形轴——simple(CC 212 短形,引擎缺省)| classic(长形,可按
|
|
231
231
|
* 任务/模型试分)。纯呈现轴无租户门;枚举 fail-loud at submit;缺省不挂键。 */
|
|
232
232
|
promptProfile?: "simple" | "classic";
|
|
233
|
+
/** [2856]②(core 5.14.0 座已在,`TaskSpec.toolMaterializeStrategy`):deferred 工具在**激活之后**怎么给
|
|
234
|
+
* 模型供 schema。`static` = 占位符形不变(激活只改可调用性,广告的 schema 仍是空对象);`swap` = 激活
|
|
235
|
+
* 后下一轮把占位符换成真 schema。
|
|
236
|
+
* 为什么要上 wire:cli 的 #181 取证表明,忠实跟随广告 schema 解码的 provider(openai-completions 车道)
|
|
237
|
+
* 在 `static` 下会**无界循环** —— 每轮发 `{}` → invalidArgumentsRejection → 下一轮广告仍空,纠错回路
|
|
238
|
+
* 结构上无效。core 的缺省是 `spec.toolMaterializeStrategy ?? env ?? "static"`,进程级 env 说不了
|
|
239
|
+
* 「每个模型」的话(模型目录车道 per-model api 可混),所以必须有一个**按任务**的位子。
|
|
240
|
+
* 缺省不挂键(交给 core 的 env/缺省链);未知词 400 fail-loud(与 promptProfile/permissionMode 同姿势 ——
|
|
241
|
+
* 静默折缺省会让调用方以为切了策略却没切)。 */
|
|
242
|
+
toolMaterializeStrategy?: "static" | "swap";
|
|
233
243
|
/** R4 (CC parity): the LIGHT top-level per-turn permission-mode intent (CC `permissionMode`). The shell sends
|
|
234
|
-
* the RAW mode (axis-agnostic, no client-side interpretation); the SERVICE interprets it tighten-only
|
|
235
|
-
*
|
|
236
|
-
* `
|
|
237
|
-
*
|
|
238
|
-
|
|
244
|
+
* the RAW mode (axis-agnostic, no client-side interpretation); the SERVICE interprets it tighten-only vs the
|
|
245
|
+
* deployment baseline. Post-[816] all FIVE modes are honored as gate-SHAPE choices (`plan` ⇒ read-only hands +
|
|
246
|
+
* `present_plan`; `acceptEdits`/`bypassPermissions`/`auto` select how much the mode-derived fs-write ask gate
|
|
247
|
+
* asks — they can never subtract from deployment/operator policy, tightenTaskSpec deny-wins; the old "LOOSEN →
|
|
248
|
+
* coerced to default" note was pre-[816] doc-rot). Folds onto the SAME tighten-only governance as
|
|
249
|
+
* `settings.permissions.defaultMode` (the heavier bundle), so it's a lighter alias, not a second path.
|
|
250
|
+
* #157-②: CLOSED enum on the wire — an unknown word 400s at submit (`request.field_invalid`, sibling of
|
|
251
|
+
* promptProfile) instead of silently coercing to `default`; resume replay of a stored body stays lenient
|
|
252
|
+
* (coercePermissionMode). */
|
|
253
|
+
permissionMode?: "default" | "acceptEdits" | "plan" | "bypassPermissions" | "auto";
|
|
239
254
|
/** MF-30 memory PAUSE (shell-host contract, option B per-request — clay 2026-06-27): `false` makes THIS run
|
|
240
255
|
* read-only over long-term memory (`TaskSpec.memory.writeScope:null` — the design/138 memory ENGINE materializes/
|
|
241
256
|
* reads but its harvest commits nothing). Absent/`true` ⇒ normal read+write. The shell's `/memory` pause carries
|
package/dist/main.js
CHANGED
|
@@ -658,7 +658,7 @@ async function main() {
|
|
|
658
658
|
config, logger, metrics, localRoot, backend, subRunner, runStore, checkpointStore,
|
|
659
659
|
rateLimiter, costQuota, toolResultStore, fileSnapshotStore, taskAttachmentStore, imageBakes, worktreeReap,
|
|
660
660
|
workflowNotifyGate, workflowJournalStore, sqlWorkflowRunStore, workflowNotifyJournal, rosterStore,
|
|
661
|
-
backgroundAgentStore, mailboxStore,
|
|
661
|
+
backgroundAgentStore, mailboxStore, toolApproval,
|
|
662
662
|
getRunDenySweep: () => runDenySweep,
|
|
663
663
|
});
|
|
664
664
|
// Optional OTLP/HTTP metrics export (1.37). Periodically pushes the registry to an OTel collector;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `InMemoryApprovalAskStore` —— #151 车1,`ApprovalAskStore` 的语义真源(twin 对拆基准)+ LOCAL
|
|
3
|
+
* 车道占位(File 形不在本车,见设计定稿 §7)。单进程内两个 `Map`,方法体全同步(不含 `await` 中断点)——
|
|
4
|
+
* Node 单线程下这就是天然原子:`Promise.all` 发起的并发调用不会在方法体内部交错执行,与
|
|
5
|
+
* `approval-ask-store-sql.ts` 的 CAS UPDATE 语义逐字段一致(SQL twin 对拆套件的基准就是这份实现)。
|
|
6
|
+
*
|
|
7
|
+
* 行为口径与 `SqlApprovalAskStore` 逐条对齐:
|
|
8
|
+
* - ensureAsk 幂等 upsert(ask_id 已在 ⇒ 返回既有行,不改写;batch 行若不存在则以 OPEN 补齐)。
|
|
9
|
+
* - transitionAsk 先过 machine 层 `canAskTransition`(非法转移 throw,不返回 false)。
|
|
10
|
+
* - decideAsk/expireAsk/bindBatch/abortBatch 的双/三步判定顺序与 SQL twin 的事务步骤一一对应
|
|
11
|
+
* (哪一步先判、判什么、失败时是否触碰另一半状态——照抄,不是「反正内存里原子就随便」)。
|
|
12
|
+
* - resolveProvisional 故意不经 canAskTransition(同 SQL twin 头注:这是版本化补偿的例外通道)。
|
|
13
|
+
*/
|
|
14
|
+
import { type AskState, type BatchState } from "../approval-ask-machine.js";
|
|
15
|
+
import type { AskDecision, AskRow, AskTransitionPatch, ApprovalAskStore, BatchRow, BindGateInput, BindResult, DecideAskInput, DecideResult, ExpireResult, NewAskRow } from "./approval-ask-store-sql.js";
|
|
16
|
+
export declare class InMemoryApprovalAskStore implements ApprovalAskStore {
|
|
17
|
+
private readonly asks;
|
|
18
|
+
private readonly batches;
|
|
19
|
+
ensureAsk(row: NewAskRow): Promise<AskRow>;
|
|
20
|
+
transitionAsk(askId: string, from: AskState, to: AskState, patch: AskTransitionPatch): Promise<boolean>;
|
|
21
|
+
decideAsk(askId: string, batchId: string, decision: DecideAskInput): Promise<DecideResult>;
|
|
22
|
+
expireAsk(askId: string, batchId: string): Promise<ExpireResult>;
|
|
23
|
+
bindBatch(batchId: string, askId: string, gate: BindGateInput): Promise<BindResult>;
|
|
24
|
+
deferReconcile(askId: string, expectedState: AskState, expectedVersion: number, nowMs: number): Promise<boolean>;
|
|
25
|
+
abortBatch(batchId: string): Promise<string[]>;
|
|
26
|
+
listByState(state: AskState, limit: number): Promise<AskRow[]>;
|
|
27
|
+
/** `signal` 的 twin 语义(车3 刀 3b):同步实现里没有「在飞」这回事,所以能做也只需做**入口复核** ——
|
|
28
|
+
* 已 abort 就一条都不读、如实抛,与 SQL twin 的第①条语义逐字一致(第②条在这里天然平凡成立)。 */
|
|
29
|
+
listPendingByTask(taskId: string, signal?: AbortSignal): Promise<AskRow[]>;
|
|
30
|
+
listPendingBySession(sessionId: string, owner: string | null, signal?: AbortSignal): Promise<AskRow[]>;
|
|
31
|
+
getAsk(askId: string): Promise<AskRow | null>;
|
|
32
|
+
getByIdempotencyKey(taskId: string, key: string): Promise<AskRow | null>;
|
|
33
|
+
resolveProvisional(askId: string, from: AskState, to: AskState, patch: AskTransitionPatch): Promise<boolean>;
|
|
34
|
+
deleteByTask(taskId: string): Promise<void>;
|
|
35
|
+
getBatch(batchId: string): Promise<BatchRow | null>;
|
|
36
|
+
}
|
|
37
|
+
export type { AskDecision, AskState, BatchState };
|
|
38
|
+
//# sourceMappingURL=approval-ask-store-memory.d.ts.map
|