@sema-agent/server 7.48.0 → 7.50.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/USAGE.md +39 -0
- package/dist/approval-card.d.ts +1 -1
- package/dist/auth-bridge.d.ts +2 -2
- package/dist/auth-bridge.js +1 -1
- package/dist/boot/config-center.js +2 -2
- package/dist/boot/engine-lease.d.ts +95 -0
- package/dist/boot/engine-lease.js +56 -0
- package/dist/boot/runner-deps.d.ts +4 -2
- package/dist/boot/shutdown.d.ts +2 -0
- package/dist/boot/shutdown.js +16 -0
- package/dist/config-center/apply-effective.js +1 -1
- package/dist/config-center/http-client.d.ts +1 -1
- package/dist/config-center/http-client.js +1 -1
- package/dist/config-center/restart-signal.js +1 -1
- package/dist/config-center/skills-mcp.js +1 -1
- package/dist/config-provider.d.ts +2 -2
- package/dist/config-provider.js +2 -2
- package/dist/config-types.d.ts +21 -2
- package/dist/config.js +24 -2
- package/dist/fleet/subagent-tail-bus.d.ts +1 -1
- package/dist/fleet/subagent-tail-bus.js +10 -0
- package/dist/fleet-client.d.ts +1 -1
- package/dist/fleet-client.js +1 -1
- package/dist/fleet-lease.d.ts +1 -1
- package/dist/fleet-lease.js +1 -1
- package/dist/hooks/hook-runner.d.ts +1 -1
- package/dist/hooks/hook-runner.js +1 -1
- package/dist/http/routes/approvals-assistant.js +65 -0
- package/dist/http/routes/notify-wake.js +1 -1
- package/dist/http/routes/runs.d.ts +1 -1
- package/dist/http/routes/runs.js +1 -1
- package/dist/http/routes/sessions.js +1 -1
- package/dist/http/routes/tasks.js +15 -9
- package/dist/http/send.d.ts +14 -0
- package/dist/http/send.js +5 -0
- package/dist/http/server.d.ts +5 -0
- package/dist/http/server.js +24 -8
- package/dist/http/wire-types.d.ts +1 -1
- package/dist/orchestration/workflow-completion-inbox.d.ts +12 -1
- package/dist/orchestration/workflow-completion-inbox.js +6 -1
- package/dist/org-memory-admission.js +1 -1
- package/dist/plugins/checkpoint-store-sql.d.ts +108 -1
- package/dist/plugins/checkpoint-store-sql.js +65 -0
- package/dist/plugins/local-checkpoint-store.d.ts +14 -1
- package/dist/plugins/local-checkpoint-store.js +22 -1
- package/dist/plugins/scheduler-support.d.ts +1 -1
- package/dist/plugins/scheduler-support.js +1 -1
- package/dist/plugins/usage-window-store-sql.d.ts +2 -2
- package/dist/plugins/usage-window-store-sql.js +17 -6
- package/dist/run-local.js +1 -1
- package/dist/runs.js +3 -3
- package/dist/runtime-caps-resolver.d.ts +1 -1
- package/dist/runtime-governance.d.ts +1 -1
- package/dist/task-settings.d.ts +1 -1
- package/dist/trace/core-keyset-guard.d.ts +3 -3
- package/dist/trace/engine-notice-wire.d.ts +13 -3
- package/dist/trace/engine-notice-wire.js +4 -0
- package/dist/trace/injection-tier.d.ts +33 -0
- package/dist/trace/injection-tier.js +7 -0
- package/dist/trace/project.d.ts +5 -1
- package/dist/trace/project.js +5 -0
- package/package.json +3 -3
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { DECIDED_REPLAY_SCAN_CAP, approvalPayloadFingerprint } from "../../plugins/checkpoint-store-sql.js";
|
|
1
2
|
import { isParkedRunStatus } from "../../plugins/store-contracts.js";
|
|
2
3
|
import { principalFrom, decodeCheckpointScope, PRINCIPAL_TOKEN_HEADER, APPROVAL_MAC_HEADER, APPROVAL_MAC_KID_HEADER } from "../../security.js";
|
|
3
4
|
import { verifyDirectDoorProof } from "../../principal-jwt.js";
|
|
@@ -30,6 +31,52 @@ export function isQuestionAnswer(v) {
|
|
|
30
31
|
(note === undefined || typeof note === "string"));
|
|
31
32
|
});
|
|
32
33
|
}
|
|
34
|
+
async function decideIdempotentReplay(cs, args) {
|
|
35
|
+
const { sessionId, out, binding, decision, payload, deciderPrincipal, explicitOperator, warn } = args;
|
|
36
|
+
if (out.status !== 404 || out.body.errorCode !== "not_found.approval")
|
|
37
|
+
return undefined;
|
|
38
|
+
const boundCallId = binding.boundCallId;
|
|
39
|
+
if (boundCallId === undefined)
|
|
40
|
+
return undefined;
|
|
41
|
+
if (typeof cs.findDecidedApprovalsForBinding !== "function")
|
|
42
|
+
return undefined;
|
|
43
|
+
const rows = await cs.findDecidedApprovalsForBinding(sessionId, boundCallId);
|
|
44
|
+
if (rows.length === 0)
|
|
45
|
+
return undefined;
|
|
46
|
+
if (rows.length >= DECIDED_REPLAY_SCAN_CAP) {
|
|
47
|
+
warn("decide_replay_scan_cap_hit", { sessionId, rows: rows.length });
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
const requestFp = approvalPayloadFingerprint(payload);
|
|
51
|
+
if (requestFp === null)
|
|
52
|
+
return undefined;
|
|
53
|
+
for (const row of rows) {
|
|
54
|
+
if (row.decision === null)
|
|
55
|
+
return undefined;
|
|
56
|
+
if (!explicitOperator) {
|
|
57
|
+
const owner = decodeCheckpointScope(row.scope);
|
|
58
|
+
if (owner !== undefined && owner !== deciderPrincipal) {
|
|
59
|
+
warn("decide_replay_scope_mismatch", { sessionId });
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (binding.boundInputHash !== undefined && row.boundInputHash !== binding.boundInputHash)
|
|
64
|
+
return undefined;
|
|
65
|
+
if (row.decision !== decision)
|
|
66
|
+
return undefined;
|
|
67
|
+
if (row.payloadFp === null || row.payloadFp !== requestFp)
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
status: 200,
|
|
72
|
+
body: {
|
|
73
|
+
sessionId,
|
|
74
|
+
idempotent: true,
|
|
75
|
+
decision,
|
|
76
|
+
bindingEnforced: true,
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
33
80
|
export async function handleApprovalsAssistant(req, res, url, ctx) {
|
|
34
81
|
const miss = { fell: false };
|
|
35
82
|
await handleApprovalsAssistantBody(req, res, url, ctx, miss);
|
|
@@ -440,6 +487,24 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
|
|
|
440
487
|
}
|
|
441
488
|
: undefined;
|
|
442
489
|
const out = await resumeCheckpoint(sessionId, decision, body.reason ?? undefined, "human", req, answer, binding, grantOnCommit, true);
|
|
490
|
+
const replay = await decideIdempotentReplay(cs, {
|
|
491
|
+
sessionId,
|
|
492
|
+
out,
|
|
493
|
+
binding,
|
|
494
|
+
decision,
|
|
495
|
+
payload: {
|
|
496
|
+
...(decision === "approve" && binding.updatedInput !== undefined ? { updatedInput: binding.updatedInput } : {}),
|
|
497
|
+
...(answer !== undefined ? { answer } : {}),
|
|
498
|
+
...(body.reason ? { reason: body.reason } : {}),
|
|
499
|
+
},
|
|
500
|
+
deciderPrincipal,
|
|
501
|
+
explicitOperator,
|
|
502
|
+
warn: (event, fields) => deps.logger?.warn?.(event, fields),
|
|
503
|
+
});
|
|
504
|
+
if (replay !== undefined) {
|
|
505
|
+
sendResumeOutcome(res, replay, deps.logger);
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
443
508
|
sendResumeOutcome(res, out, deps.logger, remember && deps.approvalExemptionStore ? { rememberApplied } : undefined);
|
|
444
509
|
return;
|
|
445
510
|
}
|
|
@@ -83,7 +83,7 @@ async function handleNotifyWakeBody(req, res, url, ctx, miss) {
|
|
|
83
83
|
const liveStream = liveTaskId !== undefined && liveTaskId !== null ? steerableRuns.get(liveTaskId) : undefined;
|
|
84
84
|
if (liveStream) {
|
|
85
85
|
try {
|
|
86
|
-
await liveStream.notify(payload);
|
|
86
|
+
await liveStream.notify(payload, { priority: "next" });
|
|
87
87
|
sendJson(res, 200, { sessionId: notifySession, delivery: "live" });
|
|
88
88
|
return;
|
|
89
89
|
}
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* `cancelledViaVerb` first-writer-wins 判别、`steerableRuns` 活流句柄、`wakeParkMints` park 铸造闩)。
|
|
14
14
|
*/
|
|
15
15
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
16
|
-
import { ActorAssertionWire } from "@sema-agent/
|
|
16
|
+
import { ActorAssertionWire } from "@sema-agent/settings-schema";
|
|
17
17
|
import type { RouteCtx, PreparedTaskSubmission } from "../route-ctx.js";
|
|
18
18
|
export declare const RUN_ID_RE: RegExp;
|
|
19
19
|
export declare const RUN_CANCEL_RE: RegExp;
|
package/dist/http/routes/runs.js
CHANGED
|
@@ -23,7 +23,7 @@ import { buildActiveRunConflict } from "../active-run-conflict.js";
|
|
|
23
23
|
import { clearTurnActivity, readTurnActivityMs } from "../../turn-activity.js";
|
|
24
24
|
import { buildCrossSliceUsage, readResourceWindow, recordResourceWindow } from "../../resource-window.js";
|
|
25
25
|
import { headerStr, gatedPrincipal, explicitOperatorOk } from "../principal-gate.js";
|
|
26
|
-
import { ActorAssertionWire } from "@sema-agent/
|
|
26
|
+
import { ActorAssertionWire } from "@sema-agent/settings-schema";
|
|
27
27
|
import { RUN_NOT_FOUND_MESSAGE, runNotFoundMessage } from "../route-ctx.js";
|
|
28
28
|
export const RUN_ID_RE = /^\/v1\/runs\/([^/]+)(\/events)?$/;
|
|
29
29
|
export const RUN_CANCEL_RE = /^\/v1\/runs\/([^/]+)\/cancel$/;
|
|
@@ -366,7 +366,7 @@ async function handleSessionsBody(req, res, url, ctx, miss) {
|
|
|
366
366
|
sendError(res, 503, "state.sse_probe_cap", "session-events probe cap reached — fall back to /head polling");
|
|
367
367
|
return;
|
|
368
368
|
}
|
|
369
|
-
res
|
|
369
|
+
sseHeaders(res, { "cache-control": "no-cache, no-transform", "x-accel-buffering": "no" });
|
|
370
370
|
writeRaw(": connected\n\n");
|
|
371
371
|
started = true;
|
|
372
372
|
if (preVal !== undefined)
|
|
@@ -331,12 +331,16 @@ async function handleTasksBody(req, res, url, ctx, miss) {
|
|
|
331
331
|
const td = e;
|
|
332
332
|
sseData(res, { type: "text_delta", delta: td.delta, ...(td.eventId ? { eventId: td.eventId } : {}), ...(td.parentToolCallId ? { parentToolCallId: td.parentToolCallId } : {}) });
|
|
333
333
|
}
|
|
334
|
+
else if (t === "text_end") {
|
|
335
|
+
const tend = e;
|
|
336
|
+
sseData(res, { type: "text_end", content: tend.content, ...(tend.eventId ? { eventId: tend.eventId } : {}), ...(tend.parentToolCallId ? { parentToolCallId: tend.parentToolCallId } : {}) });
|
|
337
|
+
}
|
|
334
338
|
else if (t === "reasoning_delta") {
|
|
335
339
|
const rd = e;
|
|
336
340
|
sseData(res, { type: "reasoning_delta", delta: redactSecrets(rd.delta), ...(rd.eventId ? { eventId: rd.eventId } : {}), ...(rd.parentToolCallId ? { parentToolCallId: rd.parentToolCallId } : {}) });
|
|
337
341
|
}
|
|
338
342
|
},
|
|
339
|
-
onTaskNotification: (n) => {
|
|
343
|
+
onTaskNotification: (n, opts) => {
|
|
340
344
|
if (n.task_type === "workflow")
|
|
341
345
|
return;
|
|
342
346
|
defaultSubagentTailBus.forgetHandleContentMode(n.task_id);
|
|
@@ -348,13 +352,13 @@ async function handleTasksBody(req, res, url, ctx, miss) {
|
|
|
348
352
|
const parked = !deliverable && Boolean(deps.workflowCompletionInbox && prepared.spec.sessionId);
|
|
349
353
|
deps.logger?.info?.("task_notification_observed", { route: "sync-stream", taskId: n.task_id, taskType: n.task_type, status: n.status, hadFleetRow: hadRow, legLive: syncLegLive, parkedDurable: parked });
|
|
350
354
|
if (parked) {
|
|
351
|
-
void deps.workflowCompletionInbox.enqueue(taskNotificationInboxEntry(prepared.spec.sessionId, askOwner, n, Date.now(), durableTaskId)).catch((err) => deps.logger?.warn?.("park_enqueue_failed", { route: "sync-stream", taskId: n.task_id, err: err instanceof Error ? err.message : String(err) }));
|
|
355
|
+
void deps.workflowCompletionInbox.enqueue(taskNotificationInboxEntry(prepared.spec.sessionId, askOwner, n, Date.now(), durableTaskId, opts?.priority)).catch((err) => deps.logger?.warn?.("park_enqueue_failed", { route: "sync-stream", taskId: n.task_id, err: err instanceof Error ? err.message : String(err) }));
|
|
352
356
|
}
|
|
353
357
|
else if (deliverable) {
|
|
354
358
|
const key = taskNotificationStreamKey(n);
|
|
355
359
|
if (syncNotifiedKeys.get(key) === undefined) {
|
|
356
360
|
syncNotifiedKeys.set(key, Promise.resolve(true));
|
|
357
|
-
sseData(res, { type: "task_notification", ...taskNotificationEventData({ notification: n }) });
|
|
361
|
+
sseData(res, { type: "task_notification", ...taskNotificationEventData({ notification: n, ...(opts?.priority !== undefined ? { priority: opts.priority } : {}) }) });
|
|
358
362
|
}
|
|
359
363
|
}
|
|
360
364
|
},
|
|
@@ -459,7 +463,7 @@ async function handleTasksBody(req, res, url, ctx, miss) {
|
|
|
459
463
|
else if (ev.type === "human_input") {
|
|
460
464
|
sseData(res, { type: "human_input", ...humanInputEventData(ev) });
|
|
461
465
|
}
|
|
462
|
-
else if (ev.type === "text_delta" || ev.type === "turn_end" || ev.type === "message_committed" || ev.type === "context_usage") {
|
|
466
|
+
else if (ev.type === "text_delta" || ev.type === "text_end" || ev.type === "turn_end" || ev.type === "message_committed" || ev.type === "context_usage") {
|
|
463
467
|
const e = ev;
|
|
464
468
|
const ident = {
|
|
465
469
|
...(e.eventId !== undefined ? { eventId: e.eventId } : {}),
|
|
@@ -469,11 +473,13 @@ async function handleTasksBody(req, res, url, ctx, miss) {
|
|
|
469
473
|
};
|
|
470
474
|
const arm = ev.type === "text_delta"
|
|
471
475
|
? { type: "text_delta", delta: e.delta, ...ident }
|
|
472
|
-
: ev.type === "
|
|
473
|
-
? { type: "
|
|
474
|
-
: ev.type === "
|
|
475
|
-
? { type: "
|
|
476
|
-
:
|
|
476
|
+
: ev.type === "text_end"
|
|
477
|
+
? { type: "text_end", content: e.content, ...ident }
|
|
478
|
+
: ev.type === "turn_end"
|
|
479
|
+
? { type: "turn_end", ...(e.usage !== undefined ? { usage: e.usage } : {}), ...(e.usageMissing !== undefined ? { usageMissing: e.usageMissing } : {}), ...(e.stopReason !== undefined ? { stopReason: e.stopReason } : {}), ...ident }
|
|
480
|
+
: ev.type === "message_committed"
|
|
481
|
+
? { type: "message_committed", entryId: e.entryId, role: e.role, ...(e.toolCallId !== undefined ? { toolCallId: e.toolCallId } : {}), ...ident }
|
|
482
|
+
: { type: "context_usage", ...contextUsageEventData(ev), ...ident };
|
|
477
483
|
sseData(res, arm);
|
|
478
484
|
}
|
|
479
485
|
else {
|
package/dist/http/send.d.ts
CHANGED
|
@@ -8,6 +8,20 @@
|
|
|
8
8
|
* now imports them from here, so all ~500 existing `sendJson(...)` call sites are unchanged text.
|
|
9
9
|
*/
|
|
10
10
|
import type { ServerResponse } from "node:http";
|
|
11
|
+
/**
|
|
12
|
+
* #118(黑板 [5371]②)—— 一条响应「是 SSE 流」的**在场标记**。
|
|
13
|
+
*
|
|
14
|
+
* 引擎附着租约把**打开着的 SSE 流**当作壳的附着租约来数,于是 `handle()` 需要判「这条响应是不是流」。
|
|
15
|
+
* 🔴 **不能靠读回 content-type**(本机 node v24.2.0 实测):`res.writeHead(status, headersObject)` 直接
|
|
16
|
+
* 把头写进网络缓冲,`res.getHeader("content-type")` / `res.hasHeader(...)` 在那之后**仍然**回
|
|
17
|
+
* `undefined` / `false`。照那条路写会得到一个恒为 0 的附着计数 —— 而恒 0 的方向恰好是「以为没人附着」
|
|
18
|
+
* ⇒ 在一只有人用的引擎底下触发自退。所以判据必须是我们自己在开流那一刻盖的这个章。
|
|
19
|
+
*
|
|
20
|
+
* symbol 键:不上 wire、不进 `JSON.stringify`、不与任何应用属性撞名。
|
|
21
|
+
*/
|
|
22
|
+
export declare const SSE_RESPONSE_MARK: unique symbol;
|
|
23
|
+
/** 这条响应是否已开成 SSE 流(= 走过 {@link sseHeaders})。 */
|
|
24
|
+
export declare function isSseResponse(res: ServerResponse): boolean;
|
|
11
25
|
export declare function sseHeaders(res: ServerResponse, extra?: Record<string, string>): void;
|
|
12
26
|
/**
|
|
13
27
|
* 一条 SSE 连接的墙钟上限。到点发一帧 `error`/`STREAM_MAX_DURATION`(**不是**静默关闭 —— 客户端分不出
|
package/dist/http/send.js
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
+
export const SSE_RESPONSE_MARK = Symbol("sema.http.sse-response");
|
|
2
|
+
export function isSseResponse(res) {
|
|
3
|
+
return res[SSE_RESPONSE_MARK] === true;
|
|
4
|
+
}
|
|
1
5
|
export function sseHeaders(res, extra) {
|
|
6
|
+
res[SSE_RESPONSE_MARK] = true;
|
|
2
7
|
res.writeHead(200, {
|
|
3
8
|
"content-type": "text/event-stream",
|
|
4
9
|
"cache-control": "no-cache",
|
package/dist/http/server.d.ts
CHANGED
|
@@ -513,6 +513,9 @@ export interface ServiceDeploymentDeps {
|
|
|
513
513
|
* • `inflight` is ASSIGNED BY createServer (a live union of this replica's in-flight legs: durable bg/resume
|
|
514
514
|
* `inflightRuns` + live sync/resume streams `steerableRuns`) — main.ts polls it to know when drain is done.
|
|
515
515
|
* • /health carries `draining:true` (k8s readiness 摘流信号) + `version` (build self-description).
|
|
516
|
+
* • #118:`attachedStreams` 同样 ASSIGNED BY createServer —— 本副本当前打开着的 SSE 流条数
|
|
517
|
+
* (= 引擎附着租约集的大小)。消费方是 `boot/engine-lease.ts` 的自退判据;与 `inflight` 同族,
|
|
518
|
+
* 同一个共享盒子,不另开第二条 main↔server 的通道。
|
|
516
519
|
*/
|
|
517
520
|
drainState?: {
|
|
518
521
|
draining: boolean;
|
|
@@ -520,6 +523,8 @@ export interface ServiceDeploymentDeps {
|
|
|
520
523
|
reason?: string;
|
|
521
524
|
inflight?: () => number;
|
|
522
525
|
lastActivityAt?: () => number;
|
|
526
|
+
attachedStreams?: () => number;
|
|
527
|
+
lastAttachEndedAt?: () => number;
|
|
523
528
|
};
|
|
524
529
|
/** boot ready 门(b):false = registry 部署无显式 env 模型且首次 effective pull 尚未落 roster
|
|
525
530
|
* (worker 只有占位模型)。计费提交 503 + /health 加性 `ready:false`。absent = 恒 ready(env 模型在/非
|
package/dist/http/server.js
CHANGED
|
@@ -16,7 +16,7 @@ import { runInBackground, evictIfConflict, stripCheckpointToken, parkToolCallId,
|
|
|
16
16
|
import { readTurnActivityMs, recordTurnActivity } from "../turn-activity.js";
|
|
17
17
|
import { recordResumeResourceWindow } from "../resource-window.js";
|
|
18
18
|
import { publicStoreProbeError } from "../store-live-probe.js";
|
|
19
|
-
import { looksLikeJwt } from "@sema-agent/
|
|
19
|
+
import { looksLikeJwt } from "@sema-agent/settings-schema/api/auth-bridge";
|
|
20
20
|
import {} from "../orchestration/workflow-agent-steer.js";
|
|
21
21
|
import { emitPendingWorkflowCompletions, taskNotificationInboxEntry, taskNotificationStreamKey, NotifiedKeys } from "../orchestration/workflow-completion-inbox.js";
|
|
22
22
|
import { HANDS_BAND_TOOL_NAMES } from "../capabilities/hands-lane.js";
|
|
@@ -70,7 +70,7 @@ import { cascadeConfig } from "./run-meta.js";
|
|
|
70
70
|
export { cascadeConfig };
|
|
71
71
|
import { handleImages, createImagesLocal, coarseStatusForState, errorCodeForExit } from "./routes/images.js";
|
|
72
72
|
export { coarseStatusForState, errorCodeForExit };
|
|
73
|
-
import { sendJson, sendError, httpErrorCode, msg } from "./send.js";
|
|
73
|
+
import { sendJson, sendError, httpErrorCode, msg, isSseResponse } from "./send.js";
|
|
74
74
|
import { authorized, systemFor, gatedPrincipal, explicitOperatorOk, isOperator } from "./principal-gate.js";
|
|
75
75
|
import { buildActiveRunConflict, resumeEntryForGate } from "./active-run-conflict.js";
|
|
76
76
|
export { explicitOperatorOk, isOperator };
|
|
@@ -179,6 +179,18 @@ export function createHttpServer(rawDeps) {
|
|
|
179
179
|
};
|
|
180
180
|
if (deps.drainState)
|
|
181
181
|
deps.drainState.lastActivityAt = () => lastBillableActivityAt;
|
|
182
|
+
const openResponses = new Set();
|
|
183
|
+
let lastAttachEndedAt = 0;
|
|
184
|
+
if (deps.drainState) {
|
|
185
|
+
deps.drainState.attachedStreams = () => {
|
|
186
|
+
let n = 0;
|
|
187
|
+
for (const r of openResponses)
|
|
188
|
+
if (isSseResponse(r))
|
|
189
|
+
n++;
|
|
190
|
+
return n;
|
|
191
|
+
};
|
|
192
|
+
deps.drainState.lastAttachEndedAt = () => lastAttachEndedAt;
|
|
193
|
+
}
|
|
182
194
|
if (deps.hookWakeBus) {
|
|
183
195
|
deps.hookWakeBus.deliver = async (sessionId, text) => {
|
|
184
196
|
try {
|
|
@@ -247,6 +259,8 @@ export function createHttpServer(rawDeps) {
|
|
|
247
259
|
const admitted = method !== "OPTIONS" && url !== "/health" && !url.startsWith("/metrics");
|
|
248
260
|
if (admitted)
|
|
249
261
|
counters.admittedInflight++;
|
|
262
|
+
if (admitted)
|
|
263
|
+
openResponses.add(res);
|
|
250
264
|
let logged = false;
|
|
251
265
|
const reqState = { streamTaskId: undefined, streamDetached: false, source: null };
|
|
252
266
|
const ctx = { deps: routeCtxBase.deps, registry: routeCtxBase.registry, helpers: routeCtxBase.helpers, local: routeCtxBase.local, legs: routeCtxBase.legs, req: reqState };
|
|
@@ -256,6 +270,8 @@ export function createHttpServer(rawDeps) {
|
|
|
256
270
|
logged = true;
|
|
257
271
|
if (admitted)
|
|
258
272
|
counters.admittedInflight--;
|
|
273
|
+
if (openResponses.delete(res) && isSseResponse(res))
|
|
274
|
+
lastAttachEndedAt = performance.now();
|
|
259
275
|
const route = routeLabel(method, url);
|
|
260
276
|
const seconds = (Date.now() - startedAt) / 1000;
|
|
261
277
|
const aborted = viaClose && !res.writableEnded;
|
|
@@ -1249,7 +1265,7 @@ export function createHttpServer(rawDeps) {
|
|
|
1249
1265
|
void verifySink.appendStatus(e).catch((err) => deps.logger?.warn?.("status_event_append_failed", { route: "resume-verify", taskId, err: err instanceof Error ? err.message : String(err) }));
|
|
1250
1266
|
}
|
|
1251
1267
|
},
|
|
1252
|
-
onTaskNotification: (n) => {
|
|
1268
|
+
onTaskNotification: (n, opts) => {
|
|
1253
1269
|
if (n.task_type === "workflow")
|
|
1254
1270
|
return;
|
|
1255
1271
|
defaultSubagentTailBus.forgetHandleContentMode(n.task_id);
|
|
@@ -1260,12 +1276,12 @@ export function createHttpServer(rawDeps) {
|
|
|
1260
1276
|
const parked = !resumeLegLive && Boolean(deps.workflowCompletionInbox && sessionId);
|
|
1261
1277
|
deps.logger?.info?.("task_notification_observed", { route: "resume-verify", taskId: n.task_id, taskType: n.task_type, status: n.status, hadFleetRow: hadRow, legLive: resumeLegLive, parkedDurable: parked });
|
|
1262
1278
|
if (parked) {
|
|
1263
|
-
void deps.workflowCompletionInbox.enqueue(taskNotificationInboxEntry(sessionId, principal ?? null, n, Date.now(), taskId)).catch((err) => deps.logger?.warn?.("park_enqueue_failed", { route: "resume-verify", taskId: n.task_id, err: err instanceof Error ? err.message : String(err) }));
|
|
1279
|
+
void deps.workflowCompletionInbox.enqueue(taskNotificationInboxEntry(sessionId, principal ?? null, n, Date.now(), taskId, opts?.priority)).catch((err) => deps.logger?.warn?.("park_enqueue_failed", { route: "resume-verify", taskId: n.task_id, err: err instanceof Error ? err.message : String(err) }));
|
|
1264
1280
|
}
|
|
1265
1281
|
else if (resumeLegLive) {
|
|
1266
1282
|
const key = taskNotificationStreamKey(n);
|
|
1267
1283
|
if (resumeNotifiedKeys.get(key) === undefined) {
|
|
1268
|
-
resumeNotifiedKeys.set(key, vAppend("task_notification", taskNotificationEventData({ notification: n })).then(() => true, () => false));
|
|
1284
|
+
resumeNotifiedKeys.set(key, vAppend("task_notification", taskNotificationEventData({ notification: n, ...(opts?.priority !== undefined ? { priority: opts.priority } : {}) })).then(() => true, () => false));
|
|
1269
1285
|
}
|
|
1270
1286
|
}
|
|
1271
1287
|
},
|
|
@@ -1376,7 +1392,7 @@ export function createHttpServer(rawDeps) {
|
|
|
1376
1392
|
void append("tool_end", toolEndEventData(e)).catch(warnAppend(t));
|
|
1377
1393
|
}
|
|
1378
1394
|
},
|
|
1379
|
-
onTaskNotification: (n) => {
|
|
1395
|
+
onTaskNotification: (n, opts) => {
|
|
1380
1396
|
if (n.task_type === "workflow")
|
|
1381
1397
|
return;
|
|
1382
1398
|
defaultSubagentTailBus.forgetHandleContentMode(n.task_id);
|
|
@@ -1387,12 +1403,12 @@ export function createHttpServer(rawDeps) {
|
|
|
1387
1403
|
const parked = !resumeLegLive && Boolean(deps.workflowCompletionInbox && sessionId);
|
|
1388
1404
|
deps.logger?.info?.("task_notification_observed", { route: "resume", taskId: n.task_id, taskType: n.task_type, status: n.status, hadFleetRow: hadRow, legLive: resumeLegLive, parkedDurable: parked });
|
|
1389
1405
|
if (parked) {
|
|
1390
|
-
void deps.workflowCompletionInbox.enqueue(taskNotificationInboxEntry(sessionId, principal ?? null, n, Date.now(), taskId)).catch((err) => deps.logger?.warn?.("park_enqueue_failed", { route: "resume", taskId: n.task_id, err: err instanceof Error ? err.message : String(err) }));
|
|
1406
|
+
void deps.workflowCompletionInbox.enqueue(taskNotificationInboxEntry(sessionId, principal ?? null, n, Date.now(), taskId, opts?.priority)).catch((err) => deps.logger?.warn?.("park_enqueue_failed", { route: "resume", taskId: n.task_id, err: err instanceof Error ? err.message : String(err) }));
|
|
1391
1407
|
}
|
|
1392
1408
|
else if (resumeLegLive) {
|
|
1393
1409
|
const key = taskNotificationStreamKey(n);
|
|
1394
1410
|
if (resumeNotifiedKeys.get(key) === undefined) {
|
|
1395
|
-
resumeNotifiedKeys.set(key, append("task_notification", taskNotificationEventData({ notification: n })).then(() => true, () => false));
|
|
1411
|
+
resumeNotifiedKeys.set(key, append("task_notification", taskNotificationEventData({ notification: n, ...(opts?.priority !== undefined ? { priority: opts.priority } : {}) })).then(() => true, () => false));
|
|
1396
1412
|
}
|
|
1397
1413
|
}
|
|
1398
1414
|
},
|
|
@@ -216,7 +216,7 @@ export interface TaskRequestBody {
|
|
|
216
216
|
skillsListing?: boolean;
|
|
217
217
|
};
|
|
218
218
|
/** C1 (core 1.219, subagent viewing pane): opt-in — forward a delegated child's live CONTENT events
|
|
219
|
-
* (text_delta/reasoning_delta/tool_start/tool_end, each stamped `parentToolCallId`) onto this run's stream/durable
|
|
219
|
+
* (text_delta/text_end/reasoning_delta/tool_start/tool_end, each stamped `parentToolCallId`) onto this run's stream/durable
|
|
220
220
|
* log, so a shell can render the child's transcript live ("enter 看详情"). Default OFF = progress-only (prior
|
|
221
221
|
* behavior). Purely a render channel — core never merges the child stream into the parent's model context. */
|
|
222
222
|
forwardSubagentEvents?: boolean;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SystemInjectionPriority } from "@sema-agent/core";
|
|
1
2
|
import type { LedgerEventType } from "../trace/ledger-events.js";
|
|
2
3
|
/** One pending completion, scoped to the session that must be told about it. `summary` is ALREADY
|
|
3
4
|
* redacted + length-bounded by core's notifier seam (see {@link WorkflowCompletionPayload}). */
|
|
@@ -262,7 +263,17 @@ export declare function taskNotificationInboxEntry(sessionId: string, owner: str
|
|
|
262
263
|
/** lead-b (dual-ID audit): the PARENT run's durable taskId (the uuid the shell's footer rows
|
|
263
264
|
* key on) — rides the drained frame so a consumer can bind the a*-keyed child notification to its uuid-keyed
|
|
264
265
|
* parent row without a side lookup. */
|
|
265
|
-
parentTaskId?: string
|
|
266
|
+
parentTaskId?: string,
|
|
267
|
+
/**
|
|
268
|
+
* design/373 §3.6(core 5.61.0,#366 / codex R1[high]):core 在 `onTaskNotification` 的**第二参**上报的
|
|
269
|
+
* 投递档位。park 是**跨 run** 的那条投递路径 —— 不带过去,档位就在最常走的一条路上恒缺席。
|
|
270
|
+
*
|
|
271
|
+
* 🔴 只在 core **真的给了**档位时传:缺席原样缺席(与 live 投影同一条「缺席≠later」纪律)。
|
|
272
|
+
* 闭集之外的值当作缺席(下面的 `INJECTION_TIERS` 守卫)——durable 载荷更不该出现引擎从没发过的词。
|
|
273
|
+
* ⚠️ 本仓自己铸的 park(`POST /v1/sessions/:id/notify` 的 idle 半场)**不传**这一位:那条 park 是
|
|
274
|
+
* 纯展示帧,没有任何引擎侧的档位承诺,填一个词就是凭空铸一条投递声明。
|
|
275
|
+
*/
|
|
276
|
+
priority?: SystemInjectionPriority): WorkflowCompletionInboxEntry;
|
|
266
277
|
/**
|
|
267
278
|
* Resolve WHERE a workflow completion routes (core 1.208): the SESSION comes straight off
|
|
268
279
|
* `originatingSessionId` (the Runner closes over it at RunWorkflow mount — lookup-free). The OWNER comes from,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { mkdirSync, readFileSync, existsSync, openSync, writeSync, fsyncSync, closeSync, renameSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { redactSecrets } from "../trace/redact.js";
|
|
4
|
+
import { injectionTierOrAbsent } from "../trace/injection-tier.js";
|
|
4
5
|
export const MAX_PENDING_PER_SESSION = 100;
|
|
5
6
|
export const COMPACT_EVERY = 512;
|
|
6
7
|
export const PURGE_FENCE_MS = 10 * 60 * 1000;
|
|
@@ -288,10 +289,14 @@ export async function emitPendingWorkflowCompletions(inbox, sessionId, callerPri
|
|
|
288
289
|
}
|
|
289
290
|
}
|
|
290
291
|
}
|
|
291
|
-
export function taskNotificationInboxEntry(sessionId, owner, n, enqueuedAt, parentTaskId) {
|
|
292
|
+
export function taskNotificationInboxEntry(sessionId, owner, n, enqueuedAt, parentTaskId, priority) {
|
|
292
293
|
const cap = (s, max) => (s.length > max ? `${s.slice(0, max)}…[+${s.length - max} chars]` : s);
|
|
293
294
|
const extras = {
|
|
294
295
|
task_type: n.task_type,
|
|
296
|
+
...(() => {
|
|
297
|
+
const tier = injectionTierOrAbsent(priority);
|
|
298
|
+
return tier !== undefined ? { priority: tier } : {};
|
|
299
|
+
})(),
|
|
295
300
|
...(parentTaskId ? { parentTaskId } : {}),
|
|
296
301
|
...(n.sessionId ? { sessionId: n.sessionId } : {}),
|
|
297
302
|
...(n.toolUseId ? { toolUseId: n.toolUseId } : {}),
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { PrincipalOrgMemoryWire } from "@sema-agent/
|
|
1
|
+
import { PrincipalOrgMemoryWire } from "@sema-agent/settings-schema";
|
|
2
2
|
import { assertPrincipalShape } from "./security.js";
|
|
3
3
|
const isPlainObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
4
4
|
const ORG_SCOPE_RE = /^org:\S+$/;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Pool as MySqlPool } from "mysql2/promise";
|
|
2
2
|
import type { Pool as PgPool } from "pg";
|
|
3
|
-
import { type Checkpoint, type CheckpointGate, type CheckpointState, type CheckpointStore, type CheckpointSummary, type CheckpointToken, type PendingSteerInput, type ResumeOutcome, type ResolveExpectation, type ReopenReason, type RiskDescriptor, type StoreDurability, type StoreFidelity } from "@sema-agent/core";
|
|
3
|
+
import { type Checkpoint, type CheckpointGate, type CheckpointState, type CheckpointStore, type CheckpointSummary, type CheckpointToken, type PendingSteerInput, type ResumeOutcome, type ResolvedOutcome, type ResolveExpectation, type ReopenReason, type RiskDescriptor, type StoreDurability, type StoreFidelity } from "@sema-agent/core";
|
|
4
4
|
import { type RuleOfferProjection } from "../approval-card.js";
|
|
5
5
|
import { type SqlDriver } from "./sql-driver.js";
|
|
6
6
|
/**
|
|
@@ -175,6 +175,80 @@ export interface CheckpointAskCandidate {
|
|
|
175
175
|
/** 在场 ⇒ 这行读不出(blob 版本超前 / JSON 坏)。收敛器视同不匹配;**从不是** false,缺席即可读。 */
|
|
176
176
|
unparseable?: true;
|
|
177
177
|
}
|
|
178
|
+
/**
|
|
179
|
+
* #368 件1 —— legacy `/v1/approvals/:sessionId/decide` 腿的**幂等回放**判别行(窄读,非凭据面)。
|
|
180
|
+
*
|
|
181
|
+
* 刻意**不带 token**:回放只需要「这条已决的行归谁、绑的是哪一次调用、判词是什么」三件,而 token 是
|
|
182
|
+
* resume 凭据(§12-C「checkpointToken 从不外发」;`CheckpointAskCandidate` 带它是因为收敛器要拿它去
|
|
183
|
+
* `bindBatch`,本口没有那个用途 ⇒ 不给)。
|
|
184
|
+
*/
|
|
185
|
+
export interface DecidedApprovalRecord {
|
|
186
|
+
/** 行的属主 scope 列(`encodeCheckpointScope` 的产物:principal 或匿名哨兵 `"_"`)。回放属主门的输入。 */
|
|
187
|
+
scope: string;
|
|
188
|
+
/** 行上的 D-1 入参摘要(`bound_input_hash` 列);pre-D-1 行为 null。 */
|
|
189
|
+
boundInputHash: string | null;
|
|
190
|
+
/**
|
|
191
|
+
* 这条已决行的**工具审批判词**,或 `null` = **判别不出**(codex R1-[medium] 四的收敛形):
|
|
192
|
+
* outcome 读不出 / 没有 winner / winner 绑的是**另一个** callId / 判词是 REVIEW 门那三个词。
|
|
193
|
+
*
|
|
194
|
+
* 🔴 `null` **不是**「无所谓」:调用方必须把它当「这条命中的行我读不懂」⇒ **整次回放拒绝**。
|
|
195
|
+
* 一条读不懂的已决行与一条判词相反的已决行在风险上同级 —— 都意味着这只 callId 上的持久事实
|
|
196
|
+
* 不是本次回放能如实复述的。
|
|
197
|
+
*/
|
|
198
|
+
decision: "approve" | "deny" | null;
|
|
199
|
+
/**
|
|
200
|
+
* 判词**之外**的决议载荷指纹(codex R2-[high] 一,验真后补):`updatedInput` / `answer` / `reason`
|
|
201
|
+
* 三件都在 core 的持久 winner 里,而**同判词不等于同决议** —— 「approve + 编辑后的实参 A」与
|
|
202
|
+
* 「approve + 实参 B」是两次不同的放行,只比判词就会把后者谎报成「你的请求已被处理」(而 B 从未执行)。
|
|
203
|
+
* 调用方按**同一份请求重发**的语义要求它与本次请求的同三件逐字相等。
|
|
204
|
+
* `null` = 算不出(载荷序列化不了)⇒ 与 `decision: null` 同判:整次拒绝。
|
|
205
|
+
* 铸法见 {@link approvalPayloadFingerprint}(两条车道共用同一只,免得指纹口径漂)。
|
|
206
|
+
*/
|
|
207
|
+
payloadFp: string | null;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* #368 件1(codex R2-[high] 一)—— 决议载荷的比对指纹:`[updatedInput, answer, reason]` 三件的规范串。
|
|
211
|
+
*
|
|
212
|
+
* 为什么是指纹而不是把三件原样交出来:`updatedInput` 可能携人写的命令原文/密钥形字节,而本读口的
|
|
213
|
+
* 契约是「窄读、非凭据面」—— 交指纹够比对,也不给任何调用点把它误投上 wire 的机会。
|
|
214
|
+
*
|
|
215
|
+
* 🔴 **必须是 canonical(键序无关)**,不是裸 `JSON.stringify`(codex R3-[high] 二,验真后修):行侧那份
|
|
216
|
+
* 载荷是从 `JSON`/`JSONB` 列**回读**的,而 **PG 的 jsonb 不保留输入键序**(它按自己的规则重排)⇒ 客户端
|
|
217
|
+
* 逐字重发原 body 时,请求侧按原键序、行侧按库的键序,裸 stringify 出来的两串不等 ⇒ 一次**合法重试**
|
|
218
|
+
* 被误拒成 404。方向虽保守(不是谎报成功),但那正是本件要修的那个场景,所以指纹按**递归键排序 +
|
|
219
|
+
* 数组保序**铸(见 {@link canonicalFingerprintJson})。
|
|
220
|
+
*
|
|
221
|
+
* 缺席一律折 `null` 哨兵(不是省位):`{updatedInput: undefined}` 与「没有这个键」在本比对里是同一件事。
|
|
222
|
+
* ⚠️ 调用方**必须**按 outcome 铸点的在场规则喂参(`updatedInput` 只在 approve 上、`reason` 走真值判)——
|
|
223
|
+
* 铸点丢掉的字节行上就没有,请求侧多喂一件会把一次合法重试误判成「不同的决议」。
|
|
224
|
+
* 序列化失败(循环引用/BigInt 等)⇒ `null` = 判别不出,调用方据此拒绝回放(fail-closed)。
|
|
225
|
+
*/
|
|
226
|
+
export declare function approvalPayloadFingerprint(v: {
|
|
227
|
+
updatedInput?: unknown;
|
|
228
|
+
answer?: unknown;
|
|
229
|
+
reason?: string;
|
|
230
|
+
}): string | null;
|
|
231
|
+
/**
|
|
232
|
+
* #368 件1 —— {@link SqlCheckpointStore.findDecidedApprovalsForBinding} 的扫描上界。
|
|
233
|
+
*
|
|
234
|
+
* 🔴 它**同时是判据**,不只是一道 LIMIT:调用方按「命中集里每条判词都一致才回放」判,而一次被截断的
|
|
235
|
+
* 集合可能把分歧藏在第 N+1 行 ⇒ 消费点约定「**回满 = 判别不出**」,整次拒绝回放(回落 404)。现网正常形
|
|
236
|
+
* 恒 0 或 1 行,回满只可能是数据异常。
|
|
237
|
+
*/
|
|
238
|
+
export declare const DECIDED_REPLAY_SCAN_CAP = 8;
|
|
239
|
+
/**
|
|
240
|
+
* #368 件1 —— core 的 `ResolvedOutcome` → 本腿(工具审批 decide)的二值词表。**闭集穷举**(#157):
|
|
241
|
+
* core 哪天给 `ResolvedOutcome.decision` 加员,下面的 `never` 当场编译期红,而不是让一个新判词被
|
|
242
|
+
* 静默折成 `deny` 或漏成「没有决议」。
|
|
243
|
+
*
|
|
244
|
+
* 三条判据,缺一即 `null`(= 不回放,调用方逐字回落修前 404 —— **绝不**把 not-found 洗成成功):
|
|
245
|
+
* ① 有 winner —— `resource_limit` / `wake` / `task_done` 的 resolve 不记 winner(core 契约),那些行
|
|
246
|
+
* 不是「一次人给的工具审批」,没有可回放的判词;
|
|
247
|
+
* ② winner 绑的正是这次请求回显的那个 `boundCallId` —— 身份第二锚(列谓词是第一锚),两锚同意才算同一件事;
|
|
248
|
+
* ③ 判词属于**工具审批**那两个词(`allow`/`deny`)—— `approve`/`reject`/`edit` 是 REVIEW 门
|
|
249
|
+
* (plan_review / dry_run_review)的判词,那些门的决议入口不是本腿,回放它们等于跨门作答。
|
|
250
|
+
*/
|
|
251
|
+
export declare function approvalDecisionOfWinner(winner: ResolvedOutcome | undefined, boundCallId: string): "approve" | "deny" | null;
|
|
178
252
|
/** `pending_steer` 列承载的两个 CheckpointState 字段(= core `appendPendingSteer` / `readPendingSteerQueue`
|
|
179
253
|
* 的入参形)。列是**唯一**权威(suspend 时写的 blob 从不带它们)。 */
|
|
180
254
|
type SteerColumnState = Pick<CheckpointState, "pendingSteer" | "pendingSteerQueue">;
|
|
@@ -431,6 +505,39 @@ export declare class SqlCheckpointStore implements CheckpointStore {
|
|
|
431
505
|
* 维,列命中行结构上也必须解 blob 才拿得到它。
|
|
432
506
|
*/
|
|
433
507
|
findCheckpointCandidatesForAsk(scope: string, sessionId: string, toolCallId: string, sinceMs: number): Promise<CheckpointAskCandidate[]>;
|
|
508
|
+
/**
|
|
509
|
+
* #368 件1 —— 「这条会话上的这一次工具调用,**是不是已经被决过了**?」的窄读口(legacy `/decide`
|
|
510
|
+
* 腿的幂等回放判别器;返回形见 {@link DecidedApprovalRecord})。
|
|
511
|
+
*
|
|
512
|
+
* 为什么必须有这一口(而不是复用 `findPendingTokenBySession`):后者硬 `status='pending'`,首决之后
|
|
513
|
+
* 恒 null ⇒ 重试与「压根没有这条审批」在 wire 上同形(404)。而合规客户端的重试体里**没有**
|
|
514
|
+
* checkpointToken(它只回显二元组),所以既有的 `approval_stale` 409 那条判别路对它结构性不可达。
|
|
515
|
+
*
|
|
516
|
+
* 四条口径,逐条都是判据:
|
|
517
|
+
* · **谓词 = (session_id, tool_call_id, status='resolved')**:`tool_call_id` 列是 `put()` 从
|
|
518
|
+
* `pendingAction.toolCallId` 盖下来的权威投影,也正是 listPending 交给客户端回显的 `boundCallId`
|
|
519
|
+
* ⇒ 客户端手里那把与库里这一列同源。`(session_id, status)` 是既有索引 `idx_checkpoint_session_status`
|
|
520
|
+
* 的前缀,不新增索引面。
|
|
521
|
+
* · **只认 `resolved`**:`expired`(窗到期被 reaper 抹掉)**不是**一次决议 —— 把它回放成
|
|
522
|
+
* 「已决」等于把「没人来得及答」谎报成「有人答过」。那一形照旧落 404(诚实缺席)。
|
|
523
|
+
* · **`decided_at_ms IS NOT NULL`**:与 respond 腿 `decidedRowIsAuthoritative` 的合取项③同判据
|
|
524
|
+
* (「这条决议真的被结算腿落过」的印记);顺带消掉 MySQL/PG 对 `ORDER BY … DESC` 里 NULL 排序
|
|
525
|
+
* 方向不同这个方言差(MySQL NULL 最后、PG NULL 最前)—— 谓词先滤掉,排序就不含 NULL。
|
|
526
|
+
* · **不带 scope 谓词、由调用方判属主**:与本类 `findPendingTokenBySession` 同姿势(读口不做租户
|
|
527
|
+
* 判决,判决在调用点)。回放腿据返回的 `scope` 做与 pending 那道**逐字同判**的属主门,非属主
|
|
528
|
+
* 拿到的仍是 404(不给存在性谕示)。
|
|
529
|
+
*
|
|
530
|
+
* 🔴 **返回的是全部命中行,不是「最近那一条」**(codex R1-[medium] 四,验真后改向):本口原先按
|
|
531
|
+
* `decided_at_ms DESC LIMIT 1` 取「最近一次决议」,而 local 孪生手里根本没有这个时间戳(core 的
|
|
532
|
+
* `Checkpoint` 上没有 decidedAt 字段),它只能按 `createdAt` 排 —— 两条车道于是可能对**同一批行**给出
|
|
533
|
+
* **不同**的判词(创建序与决议序相反时)。判据因此改成一条与排序**无关**的:调用方要求**全部命中行
|
|
534
|
+
* 判词一致**才回放,分歧即拒。同毫秒并列、两方言 NULL 排序差、跨车道排序键不同 —— 三个问题一起消失,
|
|
535
|
+
* 而现网正常形(恒 0 或 1 行)一个字节不变。上限 8 行足够:超过它的形已经不是「重试」而是数据异常。
|
|
536
|
+
*
|
|
537
|
+
* 坏 `outcome` cell **不抛**:回放是一条锦上添花的腿,让一条读不出的 JSON 把一次 decide 重试变成 500
|
|
538
|
+
* 比 404 更坏。留痕后交出 `decision: null`(= 判别不出),由调用方按「整次拒绝」处置。
|
|
539
|
+
*/
|
|
540
|
+
findDecidedApprovalsForBinding(sessionId: string, boundCallId: string): Promise<DecidedApprovalRecord[]>;
|
|
434
541
|
/**
|
|
435
542
|
* The owner SCOPE of a session's pending checkpoint (the multi-tenant key === the owner principal in the
|
|
436
543
|
* BFF/non-operator flow, the same key listPending filters by). For the /decide owner-gate: a non-operator
|
|
@@ -4,10 +4,12 @@ import { CheckpointError, MAX_RULE_TEXT_CHARS, validatePendingSteer, appendPendi
|
|
|
4
4
|
import { redactDeep, redactSecrets } from "../trace/redact.js";
|
|
5
5
|
import { MAX_RULE_OFFERS, MAX_RULE_OFFER_BATCH_MEMBERS, RuleOfferRawSchema } from "../approval-card.js";
|
|
6
6
|
import { parseJsonStrict as parseJson } from "./sql-row-helpers.js";
|
|
7
|
+
import { createLogger } from "../observability/logger.js";
|
|
7
8
|
import { recordFailOpen } from "../observability/fail-open.js";
|
|
8
9
|
import { mysqlDriver, pgDriver, dialectProtocolJsonEncoder } from "./sql-driver.js";
|
|
9
10
|
import { isDupKeyError } from "./sql-errors.js";
|
|
10
11
|
import { MANAGED_RETENTION } from "./retention-store-sql.js";
|
|
12
|
+
const fingerprintLogger = createLogger();
|
|
11
13
|
const MAX_TOOL_INPUT_CHARS = 8192;
|
|
12
14
|
export const TERMINAL_BACKSTOP_MS = Math.max(60_000, (Number.isFinite(Number(process.env.APPROVAL_TERMINAL_BACKSTOP_MS)) ? Number(process.env.APPROVAL_TERMINAL_BACKSTOP_MS) : 0) || 30 * 86_400_000);
|
|
13
15
|
export const TERMINAL_GRACE_MS = 3_600_000;
|
|
@@ -79,6 +81,48 @@ export function boundedToolInput(args) {
|
|
|
79
81
|
return { truncated: true, bytes: json.length };
|
|
80
82
|
return redacted;
|
|
81
83
|
}
|
|
84
|
+
function canonicalFingerprintJson(v) {
|
|
85
|
+
if (v === null || typeof v !== "object")
|
|
86
|
+
return JSON.stringify(v) ?? "null";
|
|
87
|
+
if (Array.isArray(v))
|
|
88
|
+
return `[${v.map(canonicalFingerprintJson).join(",")}]`;
|
|
89
|
+
const entries = Object.entries(v)
|
|
90
|
+
.filter(([, val]) => val !== undefined)
|
|
91
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
92
|
+
return `{${entries.map(([k, val]) => `${JSON.stringify(k)}:${canonicalFingerprintJson(val)}`).join(",")}}`;
|
|
93
|
+
}
|
|
94
|
+
export function approvalPayloadFingerprint(v) {
|
|
95
|
+
try {
|
|
96
|
+
const fp = canonicalFingerprintJson([v.updatedInput ?? null, v.answer ?? null, v.reason ?? null]);
|
|
97
|
+
return fp;
|
|
98
|
+
}
|
|
99
|
+
catch (e) {
|
|
100
|
+
fingerprintLogger.warn("approval decision payload is not serializable — idempotent replay refused", { error: e instanceof Error ? e.message : String(e) });
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
export const DECIDED_REPLAY_SCAN_CAP = 8;
|
|
105
|
+
export function approvalDecisionOfWinner(winner, boundCallId) {
|
|
106
|
+
if (winner === undefined)
|
|
107
|
+
return null;
|
|
108
|
+
if (winner.boundCallId !== boundCallId)
|
|
109
|
+
return null;
|
|
110
|
+
switch (winner.decision) {
|
|
111
|
+
case "allow":
|
|
112
|
+
return "approve";
|
|
113
|
+
case "deny":
|
|
114
|
+
return "deny";
|
|
115
|
+
case "approve":
|
|
116
|
+
case "reject":
|
|
117
|
+
case "edit":
|
|
118
|
+
return null;
|
|
119
|
+
default: {
|
|
120
|
+
const unhandled = winner.decision;
|
|
121
|
+
void unhandled;
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
82
126
|
function pendingActionToolCallId(blob) {
|
|
83
127
|
let parsed;
|
|
84
128
|
try {
|
|
@@ -429,6 +473,27 @@ export class SqlCheckpointStore {
|
|
|
429
473
|
}
|
|
430
474
|
return out;
|
|
431
475
|
}
|
|
476
|
+
async findDecidedApprovalsForBinding(sessionId, boundCallId) {
|
|
477
|
+
const { rows } = await this.db.query(this.q("SELECT scope, bound_input_hash, outcome FROM checkpoint " +
|
|
478
|
+
`WHERE session_id=? AND tool_call_id=? AND status='resolved' AND decided_at_ms IS NOT NULL ORDER BY decided_at_ms DESC LIMIT ${DECIDED_REPLAY_SCAN_CAP}`, "SELECT scope, bound_input_hash, outcome FROM checkpoint " +
|
|
479
|
+
`WHERE session_id=$1 AND tool_call_id=$2 AND status='resolved' AND decided_at_ms IS NOT NULL ORDER BY decided_at_ms DESC LIMIT ${DECIDED_REPLAY_SCAN_CAP}`), [sessionId, boundCallId]);
|
|
480
|
+
return rows.map((r) => {
|
|
481
|
+
let winner;
|
|
482
|
+
try {
|
|
483
|
+
const outcome = parseJson(r.outcome);
|
|
484
|
+
winner = outcome ? winnerFromOutcome(outcome) : undefined;
|
|
485
|
+
}
|
|
486
|
+
catch (e) {
|
|
487
|
+
this.logger?.info?.("checkpoint_decided_outcome_unreadable", { sessionId, error: e instanceof Error ? e.message : String(e) });
|
|
488
|
+
}
|
|
489
|
+
return {
|
|
490
|
+
scope: String(r.scope),
|
|
491
|
+
boundInputHash: r.bound_input_hash == null ? null : String(r.bound_input_hash),
|
|
492
|
+
decision: approvalDecisionOfWinner(winner, boundCallId),
|
|
493
|
+
payloadFp: winner === undefined ? null : approvalPayloadFingerprint(winner),
|
|
494
|
+
};
|
|
495
|
+
});
|
|
496
|
+
}
|
|
432
497
|
async peekPendingScope(sessionId) {
|
|
433
498
|
const { rows } = await this.db.query(this.q("SELECT scope FROM checkpoint WHERE session_id=? AND status='pending' LIMIT 1", "SELECT scope FROM checkpoint WHERE session_id=$1 AND status='pending' LIMIT 1"), [sessionId]);
|
|
434
499
|
if (!rows[0])
|