@sema-agent/server 6.4.0 → 6.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/boot/resolve-spec.js +13 -2
- package/dist/http/routes/session-sync.js +5 -2
- package/dist/http/routes/tasks.js +34 -25
- package/dist/http/server.js +18 -0
- package/dist/leader/wire.d.ts +5 -1
- package/dist/observability/metrics.d.ts +6 -0
- package/dist/observability/metrics.js +29 -13
- package/dist/plugins/local-session-store.js +10 -1
- package/dist/plugins/pg-session-storage.js +15 -0
- package/dist/plugins/store-backend.js +4 -2
- package/dist/plugins/tidb-session-store.js +19 -0
- package/dist/session-sync-content.d.ts +29 -0
- package/dist/session-sync-content.js +42 -0
- package/dist/session-sync.d.ts +7 -0
- package/dist/session-sync.js +7 -0
- package/dist/tool-approval.js +10 -9
- package/package.json +3 -3
|
@@ -280,6 +280,15 @@ export function createResolveSpec(ctx) {
|
|
|
280
280
|
if (rawAddDirs !== undefined && !cwdHonored(config)) {
|
|
281
281
|
logger.warn("task_additional_directories_ignored", { honored: false, sessionId: auth?.sessionId ?? null });
|
|
282
282
|
}
|
|
283
|
+
// core 5.11.0([2625] Added①,[2597] P0 案三决③A):`additionalReadDirectories` —— 只读围栏拓宽。
|
|
284
|
+
// 与 additionalDirectories 同门(host 单用户车道)、同形校验(parseAdditionalDirectories:绝对路径
|
|
285
|
+
// 数组 fail-loud)、同「忽略必留声」纪律;差异只在语义:只进 READ 面(classify 读边界 auto-allow +
|
|
286
|
+
// read_file/grep 等 containment),写面照旧拒——读授权永不静默变写授权(core 侧保证,我们只透传)。
|
|
287
|
+
const rawAddReadDirs = body.additionalReadDirectories;
|
|
288
|
+
const additionalReadDirectories = cwdHonored(config) ? parseAdditionalDirectories(rawAddReadDirs) : undefined;
|
|
289
|
+
if (rawAddReadDirs !== undefined && !cwdHonored(config)) {
|
|
290
|
+
logger.warn("task_additional_read_directories_ignored", { honored: false, sessionId: auth?.sessionId ?? null });
|
|
291
|
+
}
|
|
283
292
|
// body.model (explicit /model picker or run --model) WINS over settings.model (the config default); both gate
|
|
284
293
|
// against the catalog allow-list via resolveTaskModel (a settings.model not in the catalog is ignored, never injected).
|
|
285
294
|
// 🔴 TOB 档位联跑曾 FAIL(坐标级):the gate must see the TIER-EXPANDED catalog — expandTiers
|
|
@@ -320,7 +329,7 @@ export function createResolveSpec(ctx) {
|
|
|
320
329
|
metrics.inc("images_omitted_total", { model: picked.model });
|
|
321
330
|
logger.warn("images_omitted_no_vision_model", { model: picked.model, count: body.images.length });
|
|
322
331
|
}
|
|
323
|
-
return { taskHooks, additionalDirectories, wireCatalog, picked };
|
|
332
|
+
return { taskHooks, additionalDirectories, additionalReadDirectories, wireCatalog, picked };
|
|
324
333
|
};
|
|
325
334
|
/** 阶段③(anchors):吃 请求体 + auth,吐 两个历史锚解析后的 entryId(resumeAt / rewindFilesTo —— eventId→entryId
|
|
326
335
|
* 过 anchor store,未知锚在此 4xx 而不是交给 core)与 142-S4 项目登记簿种子 s4DefaultScopes。 */
|
|
@@ -374,7 +383,7 @@ export function createResolveSpec(ctx) {
|
|
|
374
383
|
/** 阶段④(spec 字面量):吃 请求体 + auth + `opts.leg` + 前三阶段的全部产出,吐**尚未过治理层**的 TaskSpec
|
|
375
384
|
* 字面量 —— 部署方拥有的 body→TaskSpec 映射本体(compactionModel 的 fresh 腿 400 也在这一段)。 */
|
|
376
385
|
const assembleSpecLiteral = (body, auth, opts, parts) => {
|
|
377
|
-
const { scenarioName, cap, centerDecls, acceptedAppend, parsedSettings, attachmentNotice, taskHooks, additionalDirectories, wireCatalog, picked, resumeAtEntryId, rewindFilesToEntryId, s4DefaultScopes, } = parts;
|
|
386
|
+
const { scenarioName, cap, centerDecls, acceptedAppend, parsedSettings, attachmentNotice, taskHooks, additionalDirectories, additionalReadDirectories, wireCatalog, picked, resumeAtEntryId, rewindFilesToEntryId, s4DefaultScopes, } = parts;
|
|
378
387
|
const spec = {
|
|
379
388
|
// D-1:附件告知随 objective 进 durable 流(只有名字/mime/尺寸——**内容永不进流**,这正是
|
|
380
389
|
// 「拼进 objective 是伪方案」的账要划清的线;文件名已消毒为安全字符集,无注入面)。
|
|
@@ -464,6 +473,8 @@ export function createResolveSpec(ctx) {
|
|
|
464
473
|
// design/119 (CC --add-dir): extra host dirs the FILE tools may access (core canonicalizes each into the
|
|
465
474
|
// containment allowlist + lists them in the `# Environment` block). Single-user host lane only (gated above).
|
|
466
475
|
additionalDirectories,
|
|
476
|
+
// core 5.11.0:只读围栏(见上方 parse 段注)。缺席=字节等价([2625] 自查③)。
|
|
477
|
+
additionalReadDirectories,
|
|
467
478
|
// EnterPlanMode (core 1.167): MODEL-DRIVEN plan — `enablePlanMode:true` alone (writable
|
|
468
479
|
// start, NO handsReadOnly) auto-mounts `enter_plan_mode`/`present_plan` so the model can self-enter read-only
|
|
469
480
|
// plan AT ANY TIME it judges a task needs planning. 🆕 DEFAULT ON (clay: resident; PLAN_MODE_ENABLED=false opts
|
|
@@ -404,8 +404,11 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
|
|
|
404
404
|
// **不是内容寻址**,所以「同 id 异载荷」结构上完全可能。此时 entries 被**整段跳过**,客户端拿到 200,
|
|
405
405
|
// 目的端却仍是它自己那份内容(探针用真 HTTP 复现过:本地改过内容再 push,云端保持原样、200)。
|
|
406
406
|
// 现在把判据写进响应(`basis`)并显式声明**未验证载荷**,这样客户端不会把它读成"内容已同步"。
|
|
407
|
-
//
|
|
408
|
-
//
|
|
407
|
+
// ✅ 状态更新(2026-08-05,[2557] 2-i 核账):上面说的「真修」——Phase A 拿内容摘要——**已经落了**
|
|
408
|
+
// (`logDigest` 就在本函数上方,三态钉=fanout-characterization §H2/F3);commit 半场的载荷判据也齐
|
|
409
|
+
// (identical=照常改写、fast_forward 分叉=409,见 session-sync-content.ts)。仍到不了这里的只剩
|
|
410
|
+
// **不送 digest 的老客户端**(按设计永久保留的老路,现状钉=G1)——对它们 `basis:"entry-ids"` 的
|
|
411
|
+
// 未验声明就是全部真相。
|
|
409
412
|
sendJson(res, 200, {
|
|
410
413
|
relation: "identical",
|
|
411
414
|
// 判据如实:调用方送了可比摘要且相等 ⇒ 内容**真的**验过;否则仍只是 id 集合相等。
|
|
@@ -6,12 +6,21 @@ 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 {
|
|
9
|
+
import { contextUsageEventData, toolStartEventData, toolEndEventData, taskProgressEventData, taskNotificationEventData, compactedEventData, diagnosticsEventData, brainStatusEventData, steeringInjectedEventData, compactionOutcomeEventData, workspaceChangedEventData, 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
13
|
import { buildActiveRunConflict, toDoneFrameResult } from "../active-run-conflict.js";
|
|
14
14
|
import { headerStr, gatedPrincipal } from "../principal-gate.js";
|
|
15
|
+
/** #135([C95]/[C96]):live 腿 SSE 数据帧的**唯一**出口 —— 载荷带账本可锚 `eventId`(非空 string)的帧
|
|
16
|
+
* 前缀 `id: <eventId>` 行(SSE 游标=账本 eventId,壳/SDK 拿它锚进账本或断线换道 events tail 续读);
|
|
17
|
+
* 无 eventId 的帧**不发行**,EventSource 沿用上一游标 —— 绝不合成占位 id(那会把不可锚的位置伪装成可锚)。
|
|
18
|
+
* durable 腿的 `id:` 行在 sse-log.ts(同一契约的另半);行为钉=test/live-sse-cursor.test.ts(规则式全流判据)。 */
|
|
19
|
+
function sseData(res, payload) {
|
|
20
|
+
const anchor = payload.eventId;
|
|
21
|
+
const idLine = typeof anchor === "string" && anchor.length > 0 ? `id: ${anchor}\n` : "";
|
|
22
|
+
res.write(`${idLine}data: ${JSON.stringify(payload)}\n\n`);
|
|
23
|
+
}
|
|
15
24
|
export async function handleTasks(req, res, url, ctx) {
|
|
16
25
|
const miss = { fell: false };
|
|
17
26
|
await handleTasksBody(req, res, url, ctx, miss);
|
|
@@ -52,7 +61,7 @@ async function handleTasksBody(req, res, url, ctx, miss) {
|
|
|
52
61
|
try {
|
|
53
62
|
const resp = await cached;
|
|
54
63
|
if (!res.writableEnded)
|
|
55
|
-
res
|
|
64
|
+
sseData(res, { type: "done", result: resp.body, replay: true });
|
|
56
65
|
}
|
|
57
66
|
finally {
|
|
58
67
|
clearInterval(hb);
|
|
@@ -226,7 +235,7 @@ async function handleTasksBody(req, res, url, ctx, miss) {
|
|
|
226
235
|
// Headers are already SSE — encode the rejection as the stream's terminal event; the 409 in
|
|
227
236
|
// the returned resp is for a deduplicated concurrent caller (and is never idem-cached).
|
|
228
237
|
// done 帧 additive 携带同一份材料 —— 形状铸在 toDoneFrameResult(有名字有类型),不在这行内联挑键。
|
|
229
|
-
res
|
|
238
|
+
sseData(res, { type: "done", result: toDoneFrameResult(conflict) });
|
|
230
239
|
return { status: 409, body: conflict };
|
|
231
240
|
}
|
|
232
241
|
durableTaskId = tid;
|
|
@@ -399,7 +408,7 @@ async function handleTasksBody(req, res, url, ctx, miss) {
|
|
|
399
408
|
return;
|
|
400
409
|
const t = e.type;
|
|
401
410
|
if (t === "task_progress") {
|
|
402
|
-
res
|
|
411
|
+
sseData(res, { type: "task_progress", ...taskProgressEventData(e) });
|
|
403
412
|
}
|
|
404
413
|
else if (t === "tool_start") {
|
|
405
414
|
// C1 (core 1.219, spec.forwardSubagentEvents): a delegated child's live CONTENT events now
|
|
@@ -407,22 +416,22 @@ async function handleTasksBody(req, res, url, ctx, miss) {
|
|
|
407
416
|
// delegation card). Echo through the SAME shared whitelist+redact builders as the top
|
|
408
417
|
// stream (§E1 — forwarded events are UNTRUSTED RAW; redaction is ours at the wire).
|
|
409
418
|
const tsv = e;
|
|
410
|
-
res
|
|
419
|
+
sseData(res, { type: "tool_start", ...toolStartEventData(tsv) });
|
|
411
420
|
}
|
|
412
421
|
else if (t === "tool_end") {
|
|
413
422
|
const te = e;
|
|
414
|
-
res
|
|
423
|
+
sseData(res, { type: "tool_end", ...toolEndEventData(te) }); // label 现由 builder 自带,外拼冗余已清
|
|
415
424
|
}
|
|
416
425
|
else if (t === "text_delta") {
|
|
417
426
|
// Child text is plain model output (same trust class as the top stream's text_delta, which
|
|
418
427
|
// rides verbatim); parentToolCallId attributes it. Whitelist the three fields explicitly.
|
|
419
428
|
const td = e;
|
|
420
|
-
res
|
|
429
|
+
sseData(res, { type: "text_delta", delta: td.delta, ...(td.eventId ? { eventId: td.eventId } : {}), ...(td.parentToolCallId ? { parentToolCallId: td.parentToolCallId } : {}) });
|
|
421
430
|
}
|
|
422
431
|
else if (t === "reasoning_delta") {
|
|
423
432
|
// §E1 parity with the top stream's live CoT: redactSecrets before echo.
|
|
424
433
|
const rd = e;
|
|
425
|
-
res
|
|
434
|
+
sseData(res, { type: "reasoning_delta", delta: redactSecrets(rd.delta), ...(rd.eventId ? { eventId: rd.eventId } : {}), ...(rd.parentToolCallId ? { parentToolCallId: rd.parentToolCallId } : {}) });
|
|
426
435
|
}
|
|
427
436
|
},
|
|
428
437
|
// background-completion observer — flip the child's fleet
|
|
@@ -472,7 +481,7 @@ async function handleTasksBody(req, res, url, ctx, miss) {
|
|
|
472
481
|
const key = taskNotificationStreamKey(n);
|
|
473
482
|
if (syncNotifiedKeys.get(key) === undefined) {
|
|
474
483
|
syncNotifiedKeys.set(key, Promise.resolve(true));
|
|
475
|
-
res
|
|
484
|
+
sseData(res, { type: "task_notification", ...taskNotificationEventData({ notification: n }) });
|
|
476
485
|
}
|
|
477
486
|
}
|
|
478
487
|
},
|
|
@@ -552,7 +561,7 @@ async function handleTasksBody(req, res, url, ctx, miss) {
|
|
|
552
561
|
await ledgerSink.onDone(finalResult);
|
|
553
562
|
ledgerTerminal = true;
|
|
554
563
|
}
|
|
555
|
-
res
|
|
564
|
+
sseData(res, { ...ev, result: finalResult });
|
|
556
565
|
continue;
|
|
557
566
|
}
|
|
558
567
|
// [900]① detach 车道逐事件落账(await 保序 —— 与 bg 腿同代价姿势;message_committed/未知型
|
|
@@ -581,18 +590,18 @@ async function handleTasksBody(req, res, url, ctx, miss) {
|
|
|
581
590
|
// 可能回显 provider 的 rate-limit/retry 报文)此前落裸 catch-all 未脱敏,与 durable 两腿
|
|
582
591
|
// (ledger-sink.onEvent + resume switch)共用的 brainStatusEventData 脱敏不一致——同类
|
|
583
592
|
// 「留在 catch-all」教训(1.72 task_progress),现挪显式分支,三腿同门同形。
|
|
584
|
-
res
|
|
593
|
+
sseData(res, { type: "status", ...brainStatusEventData(ev) });
|
|
585
594
|
}
|
|
586
595
|
else if (ev.type === "tool_end") {
|
|
587
596
|
const te = ev;
|
|
588
|
-
res
|
|
597
|
+
sseData(res, { type: "tool_end", ...toolEndEventData(te) }); // label 现由 builder 自带,外拼冗余已清
|
|
589
598
|
}
|
|
590
599
|
else if (ev.type === "tool_start") {
|
|
591
600
|
// 🔒 E1 parity (TOC adversarial review): redact tool_start.args on the LIVE wire too — the model's
|
|
592
601
|
// tool-call inputs can echo a secret it read (e.g. a value from a redacted tool_end fed into the
|
|
593
602
|
// next call). Whitelist for the same reason as tool_end (shared durable builder = one shape).
|
|
594
603
|
const tsv = ev;
|
|
595
|
-
res
|
|
604
|
+
sseData(res, { type: "tool_start", ...toolStartEventData(tsv) });
|
|
596
605
|
}
|
|
597
606
|
else if (ev.type === "task_notification") {
|
|
598
607
|
// core 1.202 (design/115 P2): a background task (workflow) finished while THIS harness was live —
|
|
@@ -614,7 +623,7 @@ async function handleTasksBody(req, res, url, ctx, miss) {
|
|
|
614
623
|
if (tnvPrior === undefined || !(await tnvPrior)) {
|
|
615
624
|
if (tnvKey !== undefined)
|
|
616
625
|
syncNotifiedKeys.set(tnvKey, Promise.resolve(true)); // stream delivered this key
|
|
617
|
-
res
|
|
626
|
+
sseData(res, { type: "task_notification", ...taskNotificationEventData(ev) });
|
|
618
627
|
}
|
|
619
628
|
}
|
|
620
629
|
else if (ev.type === "reasoning_delta" && typeof ev.delta === "string") {
|
|
@@ -626,41 +635,41 @@ async function handleTasksBody(req, res, url, ctx, miss) {
|
|
|
626
635
|
// re-proven here: a spread forwards an unknown/future field RAW; this arm was the one
|
|
627
636
|
// sibling branch that still spread — the FORWARDED twin below [t === "reasoning_delta"]
|
|
628
637
|
// already used the narrow form, this now matches it byte-for-byte).
|
|
629
|
-
res
|
|
638
|
+
sseData(res, { type: "reasoning_delta", delta: redactSecrets(ev.delta), ...(ev.eventId ? { eventId: ev.eventId } : {}), ...(ev.parentToolCallId ? { parentToolCallId: ev.parentToolCallId } : {}) });
|
|
630
639
|
}
|
|
631
640
|
else if (ev.type === "task_progress") {
|
|
632
641
|
// 🔒 E1 live-wire parity: explicit whitelist via the SHARED builder (UNTRUSTED
|
|
633
642
|
// `name` redacted; 1.78: E2 identity rides — the shell binds concurrent subagent ticks by
|
|
634
643
|
// eventId/parentToolCallId) — parity with the durable runs.ts + resume legs.
|
|
635
|
-
res
|
|
644
|
+
sseData(res, { type: "task_progress", ...taskProgressEventData(ev) });
|
|
636
645
|
}
|
|
637
646
|
else if (ev.type === "compacted") {
|
|
638
647
|
// core 1.219 (attachWorkingFiles default ON): `compacted` now carries `attachedFiles` — route it
|
|
639
648
|
// through the SHARED whitelist builder (paths redactSecrets'd) instead of the raw catch-all, so
|
|
640
649
|
// the live wire matches the durable legs' shape exactly (one builder, no drift).
|
|
641
|
-
res
|
|
650
|
+
sseData(res, { type: "compacted", ...compactedEventData(ev) });
|
|
642
651
|
}
|
|
643
652
|
else if (ev.type === "diagnostics") {
|
|
644
653
|
// core 1.220 (design/121): the shell's render source for CC's diagnostics row.
|
|
645
654
|
// Shared whitelist builder (§E1: language-server output UNTRUSTED RAW → redactSecrets).
|
|
646
|
-
res
|
|
655
|
+
sseData(res, { type: "diagnostics", ...diagnosticsEventData(ev) });
|
|
647
656
|
}
|
|
648
657
|
else if (ev.type === "steering_injected") {
|
|
649
658
|
// [1550]/[1552]: core's mid-run system-reminder injection — shared whitelist builder
|
|
650
659
|
// (§E1: preview is UNTRUSTED free text → redactSecrets). Previously fell through to the
|
|
651
660
|
// raw catch-all below (unredacted `{...ev}`) — moved to an explicit builder for the same
|
|
652
661
|
// reason every other content type left the catch-all (the 1.72 task_progress lesson).
|
|
653
|
-
res
|
|
662
|
+
sseData(res, { type: "steering_injected", ...steeringInjectedEventData(ev) });
|
|
654
663
|
}
|
|
655
664
|
else if (ev.type === "workspace_changed") {
|
|
656
665
|
// [1559]二 core 裁定:bash cd/EnterWorktree 移动任务逻辑 cwd 后一帧,给 shell UI 的路径
|
|
657
666
|
// 提示行。cwd 与已展示的工具 args 同信任级 → 共享白名单构造器(redactSecrets + bound)。
|
|
658
|
-
res
|
|
667
|
+
sseData(res, { type: "workspace_changed", ...workspaceChangedEventData(ev) });
|
|
659
668
|
}
|
|
660
669
|
else if (ev.type === "compaction_outcome") {
|
|
661
670
|
// core 2.5.0([2105]②)压缩非 compacted 结局帧。[2373]C-2:内联挑键收进共享 builder,
|
|
662
671
|
// 与两条 durable 腿同源(它曾是唯一不走 builder 的内容帧=三方普查共同盲区)。
|
|
663
|
-
res
|
|
672
|
+
sseData(res, { type: "compaction_outcome", ...compactionOutcomeEventData(ev) });
|
|
664
673
|
}
|
|
665
674
|
else if (ev.type === "text_delta" || ev.type === "turn_end" || ev.type === "message_committed" || ev.type === "context_usage") {
|
|
666
675
|
// 裸 catch-all 白名单化收官(probe-wire-1 表征钉的根治半场):最后四个已知臂离开
|
|
@@ -684,10 +693,10 @@ async function handleTasksBody(req, res, url, ctx, miss) {
|
|
|
684
693
|
: ev.type === "message_committed"
|
|
685
694
|
? { type: "message_committed", entryId: e.entryId, role: e.role, ...(e.toolCallId !== undefined ? { toolCallId: e.toolCallId } : {}), ...ident }
|
|
686
695
|
: { type: "context_usage", ...contextUsageEventData(ev), ...ident };
|
|
687
|
-
res
|
|
696
|
+
sseData(res, arm);
|
|
688
697
|
}
|
|
689
698
|
else {
|
|
690
|
-
res
|
|
699
|
+
sseData(res, ev);
|
|
691
700
|
}
|
|
692
701
|
}
|
|
693
702
|
});
|
|
@@ -728,7 +737,7 @@ async function handleTasksBody(req, res, url, ctx, miss) {
|
|
|
728
737
|
const f = frame;
|
|
729
738
|
if (f.type === "task_notification" && f.task_id)
|
|
730
739
|
syncNotifiedKeys.set(taskNotificationStreamKey({ task_type: f.task_type, task_id: f.task_id, status: String(f.status), seq: f.seq }), Promise.resolve(true));
|
|
731
|
-
res
|
|
740
|
+
sseData(res, frame);
|
|
732
741
|
},
|
|
733
742
|
// emit-target diagnostics — this drain rides the caller's OWN live SSE response.
|
|
734
743
|
{ route: "sync-stream-open", connection: "live-sse", log: (m, x) => deps.logger?.info?.(m, x) });
|
|
@@ -950,7 +959,7 @@ async function handleTasksBody(req, res, url, ctx, miss) {
|
|
|
950
959
|
}
|
|
951
960
|
// A concurrent caller the cache deduplicated (ranLive=false) received no live events — hand it the terminal.
|
|
952
961
|
if (!ranLive && resp && !res.writableEnded) {
|
|
953
|
-
res
|
|
962
|
+
sseData(res, { type: "done", result: resp.body, replay: true });
|
|
954
963
|
}
|
|
955
964
|
res.end();
|
|
956
965
|
}
|
package/dist/http/server.js
CHANGED
|
@@ -691,6 +691,24 @@ export function createHttpServer(rawDeps) {
|
|
|
691
691
|
return null;
|
|
692
692
|
}
|
|
693
693
|
}
|
|
694
|
+
// core 5.11.0:additionalReadDirectories(只读围栏)—— 与 additionalDirectories 同形 fail-loud
|
|
695
|
+
// (绝对路径数组、无 `..`、同条数上限);语义差异(只进 READ 面)在 core,形校验在此对齐。
|
|
696
|
+
const addReadDirsRaw = body.additionalReadDirectories;
|
|
697
|
+
if (addReadDirsRaw !== undefined) {
|
|
698
|
+
if (!Array.isArray(addReadDirsRaw)) {
|
|
699
|
+
sendError(res, 400, "request.field_invalid", "additionalReadDirectories must be an array of absolute host paths");
|
|
700
|
+
return null;
|
|
701
|
+
}
|
|
702
|
+
if (addReadDirsRaw.length > MAX_ADDITIONAL_DIRS) {
|
|
703
|
+
sendError(res, 400, "request.field_invalid", `additionalReadDirectories must have at most ${MAX_ADDITIONAL_DIRS} entries`);
|
|
704
|
+
return null;
|
|
705
|
+
}
|
|
706
|
+
const badRead = addReadDirsRaw.find((d) => !isValidCwd(d));
|
|
707
|
+
if (badRead !== undefined) {
|
|
708
|
+
sendError(res, 400, "request.field_invalid", "each additionalReadDirectories entry must be a non-empty absolute host path (no '..' segments)");
|
|
709
|
+
return null;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
694
712
|
// body.settings (client SemaSettings stamp) — fail-loud shape/size caps on submit, matching the sibling
|
|
695
713
|
// fields' posture (parseTaskSettings is also defensive on every path incl. resume; this 400s a bad submit early
|
|
696
714
|
// rather than silently coercing — adversarial review). The semantic projection (tighten-only) happens in resolveSpec.
|
package/dist/leader/wire.d.ts
CHANGED
|
@@ -131,7 +131,11 @@ export declare function leaderResourceConfig(env?: NodeJS.ProcessEnv): {
|
|
|
131
131
|
/** design/74 Slice 6: opt-in (LEADER_RESOURCE_SUSPEND=true) resource-slice suspend for workers. When set, a
|
|
132
132
|
* worker SUSPENDS at the per-slice window (`sliceMaxCostUsd`) instead of failing at the budget, and the
|
|
133
133
|
* leader auto-resumes (resumeWithVerification) until the TOTAL (`totalBudgetUsd` = workerBudgetUsd) is spent
|
|
134
|
-
* — a budget overrun becomes a resumable pause, not a worker failure.
|
|
134
|
+
* — a budget overrun becomes a resumable pause, not a worker failure. ⚠️ 语义更新(core 5.11.0 B3,
|
|
135
|
+
* 2026-08-06):`maxSuspends` 现在只数「连续无进展」park(批准+执行后链基归零),不再是总 park 数上限
|
|
136
|
+
* ——下面按预算切片总数放大的 sizing 公式因此从「必要」变「保守冗余」(合法的资源续跑链永不触 cap,
|
|
137
|
+
* cap 只剩防空转环职责);公式保留是无害的(上限只会更松不误杀),别再按「总数预算」理解它。
|
|
138
|
+
* 原注:`maxSuspends` is sized to the slice
|
|
135
139
|
* count (+margin) so the suspend-loop cap doesn't fire before the budget is legitimately spent. */
|
|
136
140
|
resourceSuspend?: {
|
|
137
141
|
totalBudgetUsd: number;
|
|
@@ -18,6 +18,12 @@ export declare class Metrics {
|
|
|
18
18
|
setGauge(name: string, value: number, labels?: Labels): void;
|
|
19
19
|
addGauge(name: string, delta: number, labels?: Labels): void;
|
|
20
20
|
observe(name: string, value: number, labels?: Labels): void;
|
|
21
|
+
/** [2557] 2-j:dynamic gauge 求值抛错不再折成 0 —— 0 是该 gauge 的合法读数,折 0 让故障态与真实 0
|
|
22
|
+
* 在 wire 上逐字节不可区分(与上方 inc 静默 no-op 同族:观测面的 fail-silent 正是观测要消灭的东西)。
|
|
23
|
+
* 披露方向:错帧**不发样本行**(缺席=读数未知,面板显示 no data 而非假 0)+ 计
|
|
24
|
+
* `metric_gauge_eval_errors_total{name}`(响亮、可告警)。每次求值(每次刮取/snapshot)错一次计一次;
|
|
25
|
+
* 仍然绝不 propagate(/metrics 不因单个读数器坏而 500)。 */
|
|
26
|
+
private evalDynamic;
|
|
21
27
|
/** Prometheus text exposition (content-type text/plain; version=0.0.4). */
|
|
22
28
|
render(): string;
|
|
23
29
|
/** Structured snapshot of the whole registry — the OTLP exporter maps this to OTel data points
|
|
@@ -87,8 +87,30 @@ export class Metrics {
|
|
|
87
87
|
s.count += 1;
|
|
88
88
|
h.series.set(k, s);
|
|
89
89
|
}
|
|
90
|
+
/** [2557] 2-j:dynamic gauge 求值抛错不再折成 0 —— 0 是该 gauge 的合法读数,折 0 让故障态与真实 0
|
|
91
|
+
* 在 wire 上逐字节不可区分(与上方 inc 静默 no-op 同族:观测面的 fail-silent 正是观测要消灭的东西)。
|
|
92
|
+
* 披露方向:错帧**不发样本行**(缺席=读数未知,面板显示 no data 而非假 0)+ 计
|
|
93
|
+
* `metric_gauge_eval_errors_total{name}`(响亮、可告警)。每次求值(每次刮取/snapshot)错一次计一次;
|
|
94
|
+
* 仍然绝不 propagate(/metrics 不因单个读数器坏而 500)。 */
|
|
95
|
+
evalDynamic(name, fn) {
|
|
96
|
+
try {
|
|
97
|
+
return fn();
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
this.inc("metric_gauge_eval_errors_total", { name });
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
90
104
|
/** Prometheus text exposition (content-type text/plain; version=0.0.4). */
|
|
91
105
|
render() {
|
|
106
|
+
// dynamic 先求值(2-j):错帧的 inc 要落进**本次**刮取的 counters 段,不能等下一次。
|
|
107
|
+
const dyn = [];
|
|
108
|
+
for (const [name, d] of this.dynamic) {
|
|
109
|
+
const v = this.evalDynamic(name, d.fn);
|
|
110
|
+
dyn.push(`# HELP ${name} ${d.help}`, `# TYPE ${name} gauge`);
|
|
111
|
+
if (v !== undefined)
|
|
112
|
+
dyn.push(`${name} ${v}`);
|
|
113
|
+
}
|
|
92
114
|
const out = [];
|
|
93
115
|
for (const [name, c] of this.counters) {
|
|
94
116
|
out.push(`# HELP ${name} ${c.help}`, `# TYPE ${name} counter`);
|
|
@@ -100,9 +122,7 @@ export class Metrics {
|
|
|
100
122
|
for (const s of g.series.values())
|
|
101
123
|
out.push(`${name}${renderLabels(s.labels)} ${s.value}`);
|
|
102
124
|
}
|
|
103
|
-
|
|
104
|
-
out.push(`# HELP ${name} ${d.help}`, `# TYPE ${name} gauge`, `${name} ${safe(d.fn)}`);
|
|
105
|
-
}
|
|
125
|
+
out.push(...dyn);
|
|
106
126
|
for (const [name, h] of this.histos) {
|
|
107
127
|
out.push(`# HELP ${name} ${h.help}`, `# TYPE ${name} histogram`);
|
|
108
128
|
for (const s of h.series.values()) {
|
|
@@ -119,6 +139,8 @@ export class Metrics {
|
|
|
119
139
|
/** Structured snapshot of the whole registry — the OTLP exporter maps this to OTel data points
|
|
120
140
|
* (counters→Sum monotonic, gauges/dynamic→Gauge, histograms→Histogram). Read-only copy. */
|
|
121
141
|
snapshot() {
|
|
142
|
+
// dynamic 先求值(2-j,同 render):错帧的 inc 要进本次快照的 counters。
|
|
143
|
+
const dynVals = [...this.dynamic].map(([name, d]) => ({ name, help: d.help, v: this.evalDynamic(name, d.fn) }));
|
|
122
144
|
const counters = [...this.counters].map(([name, c]) => ({
|
|
123
145
|
name,
|
|
124
146
|
help: c.help,
|
|
@@ -129,8 +151,9 @@ export class Metrics {
|
|
|
129
151
|
help: g.help,
|
|
130
152
|
series: [...g.series.values()].map((s) => ({ labels: s.labels, value: s.value })),
|
|
131
153
|
}));
|
|
132
|
-
|
|
133
|
-
|
|
154
|
+
// 错帧 series 空:名字/HELP 仍披露(指标存在),但绝不铸 {value:0} 假数据点。
|
|
155
|
+
for (const { name, help, v } of dynVals)
|
|
156
|
+
gauges.push({ name, help, series: v === undefined ? [] : [{ labels: {}, value: v }] });
|
|
134
157
|
const histograms = [...this.histos].map(([name, h]) => ({
|
|
135
158
|
name,
|
|
136
159
|
help: h.help,
|
|
@@ -192,17 +215,10 @@ export class Metrics {
|
|
|
192
215
|
};
|
|
193
216
|
}
|
|
194
217
|
}
|
|
195
|
-
function safe(fn) {
|
|
196
|
-
try {
|
|
197
|
-
return fn();
|
|
198
|
-
}
|
|
199
|
-
catch {
|
|
200
|
-
return 0;
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
218
|
/** Register the service's standard metric set. */
|
|
204
219
|
export function createMetrics() {
|
|
205
220
|
const m = new Metrics();
|
|
221
|
+
m.counter("metric_gauge_eval_errors_total", "Dynamic gauge eval errors by gauge name (an erroring gauge emits NO sample that scrape — absence means unknown, not 0)");
|
|
206
222
|
m.counter("http_requests_total", "HTTP requests by route/method/status");
|
|
207
223
|
m.histogram("http_request_duration_seconds", "HTTP request duration");
|
|
208
224
|
m.counter("rate_limited_total", "Requests rejected by the rate limiter");
|
|
@@ -30,7 +30,7 @@ import { join, resolve } from "node:path";
|
|
|
30
30
|
// 版本掩盖」这个缺陷机制仍值得后人读;宣称"待办"的部分已按实况改写(注释宣称≠代码实做是本仓
|
|
31
31
|
// 已立案的病族,过期待办比没有更有害)。
|
|
32
32
|
import { SessionError, validateEntriesForImport } from "@sema-agent/core";
|
|
33
|
-
import { identicalIdsAlsoIdenticalContent } from "../session-sync-content.js";
|
|
33
|
+
import { contentForkRelation, fastForwardSharedContentDiverged, identicalIdsAlsoIdenticalContent } from "../session-sync-content.js";
|
|
34
34
|
import { classifySyncRelationshipByIds, SyncConflictError, stagingIdFor, } from "../session-sync-kernel.js";
|
|
35
35
|
const ownerEq = (a, b) => (a ?? null) === (b ?? null);
|
|
36
36
|
const isNotFound = (e) => e instanceof SessionError && e.code === "not_found";
|
|
@@ -692,6 +692,15 @@ class LocalStagingHandle {
|
|
|
692
692
|
if ((rel.relation === "fork" || rel.relation === "stale") && opts?.resolution !== "overwrite-dst") {
|
|
693
693
|
throw new SyncConflictError(this.realSessionId, rel);
|
|
694
694
|
}
|
|
695
|
+
// 🔴 [2557] A5/F2(2026-08-05):fast_forward 的**载荷**盲区 —— dst ⊆ src 按 id,但共享 id 的
|
|
696
|
+
// 内容可能已分叉(目的端纠正过某条、源端拿旧版+追加)。整段换装会静默销毁目的端的纠正,
|
|
697
|
+
// §7 在载荷层同样要求 409(除非显式 overwrite-dst)。判据与铸形见 session-sync-content.ts。
|
|
698
|
+
if (rel.relation === "fast_forward" && opts?.resolution !== "overwrite-dst" && dstEntries !== null && dstEntries.length > 0) {
|
|
699
|
+
const dstSet = new Set(dstEntries.map((e) => e.id));
|
|
700
|
+
const diverged = fastForwardSharedContentDiverged(this.staged.filter((e) => dstSet.has(e.id)), dstEntries);
|
|
701
|
+
if (diverged)
|
|
702
|
+
throw new SyncConflictError(this.realSessionId, contentForkRelation(diverged, rel.newEntryIds));
|
|
703
|
+
}
|
|
695
704
|
// 🔴 2026-07-26:`identical` 不再等于"内容相同"。id 是 uuidv7、不是内容寻址 ⇒ 同 id 异载荷完全可能,
|
|
696
705
|
// 而 commit 这里**手上同时有两份日志**,所以内容比较不需要调用方配合(见 `session-sync-content.ts` 顶注:
|
|
697
706
|
// 我在黑板 [1701] 说过"server 单方面修不了",那句只对 Phase A 成立,对这一半是错的)。
|
|
@@ -23,6 +23,7 @@ import { BaseSessionStorage, StoredSession, SessionError, uuidv7, validateEntrie
|
|
|
23
23
|
import { escapeLike } from "./sql-escape.js";
|
|
24
24
|
import { pgSafeJsonStringify } from "./pg-safe-json.js";
|
|
25
25
|
import { toIso } from "./sql-row-helpers.js";
|
|
26
|
+
import { contentForkRelation, fastForwardSharedContentDiverged } from "../session-sync-content.js";
|
|
26
27
|
import { classifySyncRelationshipByIds, SyncConflictError, stagingIdFor, STAGING_ID_MARKER, } from "../session-sync-kernel.js";
|
|
27
28
|
/** Staging-row reaper grace window (twin of the TiDB store's STAGING_GC_GRACE_MS) — see that store for the rationale. */
|
|
28
29
|
const STAGING_GC_GRACE_MS = 3_600_000;
|
|
@@ -609,6 +610,20 @@ class PgStagingHandle {
|
|
|
609
610
|
await conn.query("ROLLBACK");
|
|
610
611
|
throw new SyncConflictError(this.realSessionId, rel);
|
|
611
612
|
}
|
|
613
|
+
// 🔴 [2557] A5/F2(2026-08-05):fast_forward 的**载荷**判据(tidb twin 同注)——共享段分叉 ⇒ 409,
|
|
614
|
+
// 除非显式 overwrite-dst;载荷只在这条臂上读,行已被 FOR UPDATE 锁住,同 txn 重读是一致读。
|
|
615
|
+
if (rel.relation === "fast_forward" && opts?.resolution !== "overwrite-dst" && dstIds !== null && dstIds.length > 0) {
|
|
616
|
+
const dstFull = await conn.query("SELECT payload FROM session_event WHERE session_id = $1 ORDER BY seq ASC", [this.realSessionId]);
|
|
617
|
+
const stagedFull = await conn.query("SELECT entry_id, payload FROM session_event WHERE session_id = $1 ORDER BY seq ASC", [this.stagingId]);
|
|
618
|
+
const dstSet = new Set(dstIds);
|
|
619
|
+
const dstEntries = dstFull.rows.map((r) => parseEntry(r.payload));
|
|
620
|
+
const stagedShared = stagedFull.rows.filter((r) => dstSet.has(String(r.entry_id))).map((r) => parseEntry(r.payload));
|
|
621
|
+
const diverged = fastForwardSharedContentDiverged(stagedShared, dstEntries);
|
|
622
|
+
if (diverged) {
|
|
623
|
+
await conn.query("ROLLBACK");
|
|
624
|
+
throw new SyncConflictError(this.realSessionId, contentForkRelation(diverged, rel.newEntryIds));
|
|
625
|
+
}
|
|
626
|
+
}
|
|
612
627
|
await conn.query("DELETE FROM session_event WHERE session_id = $1", [this.realSessionId]);
|
|
613
628
|
await conn.query("UPDATE session_event SET session_id = $1 WHERE session_id = $2", [this.realSessionId, this.stagingId]);
|
|
614
629
|
const lastRes = await conn.query("SELECT payload FROM session_event WHERE session_id = $1 ORDER BY seq DESC LIMIT 1", [this.realSessionId]);
|
|
@@ -158,7 +158,9 @@ class PgBackend {
|
|
|
158
158
|
* The session store stays the §0.5 owner-aware {@link LocalSessionStore} wrapper, but over a fresh `FileSessionRepo`
|
|
159
159
|
* (NOT `fileBackend.sessionStore`, which is core's bare `TtlSessionStore` lacking the §0.5 owner/list/fork/delete
|
|
160
160
|
* ops); policy/snapshot delegate to the fileBackend's own file stores. The fileBackend takes a coarse boot
|
|
161
|
-
*
|
|
161
|
+
* single-instance lock on `root/LOCK` (an `O_EXCL`+`linkSync` PIDFILE, not flock — [2557] F4; same-host only:
|
|
162
|
+
* liveness = `process.kill(pid,0)`, so a foreign PID namespace or NFS defeats it, see DEPLOY-PREREQS) —
|
|
163
|
+
* `close()` MUST `dispose()` it so a graceful restart can
|
|
162
164
|
* re-acquire (a stale lock from a dead PID is pruned by core, but graceful restart relies on the release).
|
|
163
165
|
*
|
|
164
166
|
* P0.5 variant-1 (clay 2026-06-26): `run()`/`resumeAnchor()` are now FILE-backed too (FileRunStore /
|
|
@@ -184,7 +186,7 @@ class LocalBackend {
|
|
|
184
186
|
/** design/73 §1 — owner-only JSONL outcome-fact sink (stateless append; no fd held). */
|
|
185
187
|
outcomeSinkInst;
|
|
186
188
|
constructor(root, config) {
|
|
187
|
-
// The fileBackend ctor mkdirs `root` + takes the boot
|
|
189
|
+
// The fileBackend ctor mkdirs `root` + takes the boot pidfile lock — a same-host SECOND instance FAILS FAST.
|
|
188
190
|
// Re-throw with a clear operator message (the raw core error is "another instance owns this data dir").
|
|
189
191
|
try {
|
|
190
192
|
this.fileBackend = new FileStorageBackend({ root, ...(config.rewindSnapshotMaxMb !== undefined ? { snapshotBounds: { maxBytes: Math.round(config.rewindSnapshotMaxMb * 1024 * 1024) } } : {}) }); // REWIND_SNAPSHOT_MAX_MB
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { StoredSession, SessionError, uuidv7, validateEntriesForImport, leafIdAfterEntry } from "@sema-agent/core";
|
|
15
15
|
import { TiDBSessionStorage } from "./tidb-session-storage.js";
|
|
16
16
|
import { escapeLike } from "./sql-escape.js";
|
|
17
|
+
import { contentForkRelation, fastForwardSharedContentDiverged } from "../session-sync-content.js";
|
|
17
18
|
import { classifySyncRelationshipByIds, SyncConflictError, stagingIdFor, STAGING_ID_MARKER, } from "../session-sync-kernel.js";
|
|
18
19
|
/** Staging-row reaper grace window — a staging session's rows are GC'able only if it has NO session_meta (never
|
|
19
20
|
* committed) AND its OLDEST row is older than this (an in-flight stream keeps appending fresh rows → safe). Mirrors
|
|
@@ -573,6 +574,24 @@ class TiDBStagingHandle {
|
|
|
573
574
|
await conn.rollback();
|
|
574
575
|
throw new SyncConflictError(this.realSessionId, rel);
|
|
575
576
|
}
|
|
577
|
+
// (1b) 🔴 [2557] A5/F2(2026-08-05):fast_forward 的**载荷**判据 —— dst ⊆ src 按 id,但共享 id 的
|
|
578
|
+
// 内容可能已分叉(目的端纠正过某条、源端拿旧版+追加);整段换装会静默销毁目的端版本,§7 在载荷层
|
|
579
|
+
// 同样要求 409(除非显式 overwrite-dst)。共享段载荷只在这条臂上读(commit 本就是 O(total) 行操作
|
|
580
|
+
// ——DELETE dst + rename staged——所以这次成比例的载荷读不改变量级);行已被上面的 FOR UPDATE 锁住,
|
|
581
|
+
// 同 txn 重读载荷是一致读。判据与 fork 铸形见 session-sync-content.ts。
|
|
582
|
+
if (rel.relation === "fast_forward" && opts?.resolution !== "overwrite-dst" && dstIds !== null && dstIds.length > 0) {
|
|
583
|
+
const [dstFull] = await conn.query("SELECT payload FROM session_event WHERE session_id = ? ORDER BY seq ASC", [this.realSessionId]);
|
|
584
|
+
const [stagedFull] = await conn.query("SELECT entry_id, payload FROM session_event WHERE session_id = ? ORDER BY seq ASC", [this.stagingId]);
|
|
585
|
+
const parseEntry = (p) => (typeof p === "string" ? JSON.parse(p) : p);
|
|
586
|
+
const dstSet = new Set(dstIds);
|
|
587
|
+
const dstEntries = dstFull.map((r) => parseEntry(r.payload));
|
|
588
|
+
const stagedShared = stagedFull.filter((r) => dstSet.has(String(r.entry_id))).map((r) => parseEntry(r.payload));
|
|
589
|
+
const diverged = fastForwardSharedContentDiverged(stagedShared, dstEntries);
|
|
590
|
+
if (diverged) {
|
|
591
|
+
await conn.rollback();
|
|
592
|
+
throw new SyncConflictError(this.realSessionId, contentForkRelation(diverged, rel.newEntryIds));
|
|
593
|
+
}
|
|
594
|
+
}
|
|
576
595
|
// (2) purge the dst's existing log (the replaceEntries purge; idempotent — fresh deletes 0).
|
|
577
596
|
await conn.query("DELETE FROM session_event WHERE session_id = ?", [this.realSessionId]);
|
|
578
597
|
// (3) RENAME the staged rows onto the real session id (row-key UPDATE — metadata only, NO payload re-copy). seq is
|
|
@@ -46,4 +46,33 @@ import { type SessionTreeEntry } from "@sema-agent/core";
|
|
|
46
46
|
* 不符只意味着"多干一趟活",而不是"这次同步非法"。
|
|
47
47
|
*/
|
|
48
48
|
export declare function identicalIdsAlsoIdenticalContent(staged: readonly SessionTreeEntry[], dst: readonly SessionTreeEntry[] | null): boolean;
|
|
49
|
+
/**
|
|
50
|
+
* `fast_forward` 的**内容判据**([2557] 审计 A5/F2,2-i)——id 分类器的另一格盲区:
|
|
51
|
+
* `dst ⊆ src` 只说明 **id 集合**是子集,共享 id 的**载荷**可能已分叉(目的端把某条 entry
|
|
52
|
+
* 补写/纠正过、id 不变,源端拿着旧版本 + 新追加)。此时按「clean append」整段换装,
|
|
53
|
+
* 目的端那几条纠正会被**静默销毁** —— §7「会丢目的端历史必须 409(除非显式 overwrite-dst)」
|
|
54
|
+
* 在载荷层同样成立。与 `identical` 格的口径**方向不同**是刻意的:identical 格 core 已定
|
|
55
|
+
* 「不符=多干一趟活、照常改写」(那里 src 按 id 完整覆盖 dst,push 语义=以源为准);本格
|
|
56
|
+
* dst 的共享版本与 src 的追加互为对方没有的东西,是真分叉,归 409 那一族。
|
|
57
|
+
*
|
|
58
|
+
* 入参:`stagedShared` = staged 里 id ∈ dst 集合的那部分(**staged 顺序**);`dst` = 目的端全量
|
|
59
|
+
* (**dst 顺序**)。两侧都保留各自顺序 —— 摘要顺序显著,共享段的**乱序**同样是分叉。
|
|
60
|
+
*
|
|
61
|
+
* 返回 `null` = 无分叉(安全 append);返回非空数组 = 分叉的 entry id(dst 顺序):
|
|
62
|
+
* 逐条单 entry 摘要不等的 id;若逐条全等而整段摘要不等(纯乱序),列出位置错开的 id。
|
|
63
|
+
*/
|
|
64
|
+
export declare function fastForwardSharedContentDiverged(stagedShared: readonly SessionTreeEntry[], dst: readonly SessionTreeEntry[]): string[] | null;
|
|
65
|
+
/**
|
|
66
|
+
* 把 fast_forward 的内容级分叉铸成 §7 冲突载荷(SyncConflictError.relation,wire 上是 409 的
|
|
67
|
+
* `{relation}` 详情)。用现有 `fork` 成员而不是扩词表:内容层它**就是** fork(双方各握着对方
|
|
68
|
+
* 没有的东西),且 SDK/客户端的关系词表是闭集,扩成员=BREAKING。字段口径(内容级,与 id 级
|
|
69
|
+
* 注释的措辞差异在此登记):`srcExclusive` = 源端要追加的新 id;`dstExclusive` = 目的端将被
|
|
70
|
+
* 销毁的共享版本的 id;`commonAncestor` = null(未逐条定位分叉点 —— 定位成本与收益不配)。
|
|
71
|
+
*/
|
|
72
|
+
export declare function contentForkRelation(divergedIds: readonly string[], newEntryIds: readonly string[]): {
|
|
73
|
+
relation: "fork";
|
|
74
|
+
commonAncestor: null;
|
|
75
|
+
srcExclusive: string[];
|
|
76
|
+
dstExclusive: string[];
|
|
77
|
+
};
|
|
49
78
|
//# sourceMappingURL=session-sync-content.d.ts.map
|
|
@@ -50,4 +50,46 @@ export function identicalIdsAlsoIdenticalContent(staged, dst) {
|
|
|
50
50
|
return false; // 目的端没有日志 ⇒ 谈不上"内容相同",按需改写
|
|
51
51
|
return sessionLogDigest(staged) === sessionLogDigest(dst);
|
|
52
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* `fast_forward` 的**内容判据**([2557] 审计 A5/F2,2-i)——id 分类器的另一格盲区:
|
|
55
|
+
* `dst ⊆ src` 只说明 **id 集合**是子集,共享 id 的**载荷**可能已分叉(目的端把某条 entry
|
|
56
|
+
* 补写/纠正过、id 不变,源端拿着旧版本 + 新追加)。此时按「clean append」整段换装,
|
|
57
|
+
* 目的端那几条纠正会被**静默销毁** —— §7「会丢目的端历史必须 409(除非显式 overwrite-dst)」
|
|
58
|
+
* 在载荷层同样成立。与 `identical` 格的口径**方向不同**是刻意的:identical 格 core 已定
|
|
59
|
+
* 「不符=多干一趟活、照常改写」(那里 src 按 id 完整覆盖 dst,push 语义=以源为准);本格
|
|
60
|
+
* dst 的共享版本与 src 的追加互为对方没有的东西,是真分叉,归 409 那一族。
|
|
61
|
+
*
|
|
62
|
+
* 入参:`stagedShared` = staged 里 id ∈ dst 集合的那部分(**staged 顺序**);`dst` = 目的端全量
|
|
63
|
+
* (**dst 顺序**)。两侧都保留各自顺序 —— 摘要顺序显著,共享段的**乱序**同样是分叉。
|
|
64
|
+
*
|
|
65
|
+
* 返回 `null` = 无分叉(安全 append);返回非空数组 = 分叉的 entry id(dst 顺序):
|
|
66
|
+
* 逐条单 entry 摘要不等的 id;若逐条全等而整段摘要不等(纯乱序),列出位置错开的 id。
|
|
67
|
+
*/
|
|
68
|
+
export function fastForwardSharedContentDiverged(stagedShared, dst) {
|
|
69
|
+
if (sessionLogDigest([...stagedShared]) === sessionLogDigest([...dst]))
|
|
70
|
+
return null;
|
|
71
|
+
const byId = new Map(stagedShared.map((e) => [e.id, e]));
|
|
72
|
+
const diverged = [];
|
|
73
|
+
for (const d of dst) {
|
|
74
|
+
const s = byId.get(d.id);
|
|
75
|
+
if (!s || sessionLogDigest([s]) !== sessionLogDigest([d]))
|
|
76
|
+
diverged.push(d.id);
|
|
77
|
+
}
|
|
78
|
+
if (diverged.length === 0) {
|
|
79
|
+
for (let i = 0; i < dst.length; i++)
|
|
80
|
+
if (stagedShared[i]?.id !== dst[i].id)
|
|
81
|
+
diverged.push(dst[i].id);
|
|
82
|
+
}
|
|
83
|
+
return diverged;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* 把 fast_forward 的内容级分叉铸成 §7 冲突载荷(SyncConflictError.relation,wire 上是 409 的
|
|
87
|
+
* `{relation}` 详情)。用现有 `fork` 成员而不是扩词表:内容层它**就是** fork(双方各握着对方
|
|
88
|
+
* 没有的东西),且 SDK/客户端的关系词表是闭集,扩成员=BREAKING。字段口径(内容级,与 id 级
|
|
89
|
+
* 注释的措辞差异在此登记):`srcExclusive` = 源端要追加的新 id;`dstExclusive` = 目的端将被
|
|
90
|
+
* 销毁的共享版本的 id;`commonAncestor` = null(未逐条定位分叉点 —— 定位成本与收益不配)。
|
|
91
|
+
*/
|
|
92
|
+
export function contentForkRelation(divergedIds, newEntryIds) {
|
|
93
|
+
return { relation: "fork", commonAncestor: null, srcExclusive: [...newEntryIds], dstExclusive: [...divergedIds] };
|
|
94
|
+
}
|
|
53
95
|
//# sourceMappingURL=session-sync-content.js.map
|
package/dist/session-sync.d.ts
CHANGED
|
@@ -118,6 +118,13 @@ export interface SessionManifest {
|
|
|
118
118
|
* 覆盖与一笔**仍然有效**的欠账记账,那正是本仓 `vitest.config.ts` 里写死的判据「覆盖丢失伪装成绿,
|
|
119
119
|
* 比假红更坏」。⇒ **删除的前置条件**是先把 B4/G1 重锚到活的流式路径上;做完那件,这两个函数连同它们的
|
|
120
120
|
* 测试可一并摘除。判定与机器门见 `test/session-sync-routes.test.ts` 的「死码判定」describe。
|
|
121
|
+
*
|
|
122
|
+
* ✅ **前置条件已达成**(2026-08-05,[2557] 审计 2-i):B4 已重锚活路由(fanout-characterization §H ×3 +
|
|
123
|
+
* sync-commit-write-parity 三后端判据,commit 半场的 fast_forward 载荷缺口**已修**)并销账;G1 改性质为
|
|
124
|
+
* 「老客户端路现状钉」(wire 级、不依赖本模块)。⇒ 本模块 + 依赖它的测试段现在**可以整体摘除**——
|
|
125
|
+
* 剩余依赖=fanout-characterization §A/§B/§F 与 crash-recovery 全文件(§8 扇出切点矩阵的死码腿;活路由的
|
|
126
|
+
* 崩溃/重试语义已由 E 系列 + staging reaper 钉覆盖,摘除前需逐段核对该等价性)。单独排一班机械车做,
|
|
127
|
+
* 别混进行为修的车。
|
|
121
128
|
*/
|
|
122
129
|
export declare function exportSession(sessionId: string, srcBackend: StoreBackend): Promise<{
|
|
123
130
|
bundle: SessionBundle;
|
package/dist/session-sync.js
CHANGED
|
@@ -52,6 +52,13 @@ function ownerAware(backend) {
|
|
|
52
52
|
* 覆盖与一笔**仍然有效**的欠账记账,那正是本仓 `vitest.config.ts` 里写死的判据「覆盖丢失伪装成绿,
|
|
53
53
|
* 比假红更坏」。⇒ **删除的前置条件**是先把 B4/G1 重锚到活的流式路径上;做完那件,这两个函数连同它们的
|
|
54
54
|
* 测试可一并摘除。判定与机器门见 `test/session-sync-routes.test.ts` 的「死码判定」describe。
|
|
55
|
+
*
|
|
56
|
+
* ✅ **前置条件已达成**(2026-08-05,[2557] 审计 2-i):B4 已重锚活路由(fanout-characterization §H ×3 +
|
|
57
|
+
* sync-commit-write-parity 三后端判据,commit 半场的 fast_forward 载荷缺口**已修**)并销账;G1 改性质为
|
|
58
|
+
* 「老客户端路现状钉」(wire 级、不依赖本模块)。⇒ 本模块 + 依赖它的测试段现在**可以整体摘除**——
|
|
59
|
+
* 剩余依赖=fanout-characterization §A/§B/§F 与 crash-recovery 全文件(§8 扇出切点矩阵的死码腿;活路由的
|
|
60
|
+
* 崩溃/重试语义已由 E 系列 + staging reaper 钉覆盖,摘除前需逐段核对该等价性)。单独排一班机械车做,
|
|
61
|
+
* 别混进行为修的车。
|
|
55
62
|
*/
|
|
56
63
|
export async function exportSession(sessionId, srcBackend) {
|
|
57
64
|
const session = ownerAware(srcBackend);
|
package/dist/tool-approval.js
CHANGED
|
@@ -382,18 +382,19 @@ export class ToolApprovalCoordinator {
|
|
|
382
382
|
// [1559]三:emit 完成 vs TTL/abort/respond 三选一落定——显式 race,不让挂死的 emit 独占执行权。
|
|
383
383
|
await Promise.race([emitAllP, allowedP]);
|
|
384
384
|
const settled = await allowedP;
|
|
385
|
-
// Completion breadcrumb (card dismiss) — fire-and-forget to every target that actually got the OPEN frame
|
|
386
|
-
//
|
|
387
|
-
//
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
//
|
|
392
|
-
//
|
|
385
|
+
// Completion breadcrumb (card dismiss) — fire-and-forget to every target that actually got the OPEN frame,
|
|
386
|
+
// so a slow/hung emit never delays returning to core AND every connection that saw the card learns it
|
|
387
|
+
// resolved elsewhere([1559]四「其余连接收 resolved/dismissed 通知」的直接落点——单连接场景退化为
|
|
388
|
+
// 原来的一次 emit)。**per-ctx 链在该 ctx 自己的 openP 之后且看它的布尔结果**([2557] 批3 F2 D1 修,
|
|
389
|
+
// 2026-08-06):emitOne 失败是 resolve(false) 不是 reject,旧形 `.then(() => emit(complete))` 对
|
|
390
|
+
// resolve 值全放行 ⇒ open 没送达的连接(瞬断型 emit 目标)也收 `tool_approval_complete` = 孤儿
|
|
391
|
+
// close,注释与代码曾不符(红先=wire-pairing-approval D1,修后翻正)。`delivered` 门后:
|
|
392
|
+
// `.then((delivered) => delivered ? c.emit(...) : undefined)` —— complete emit 若同步 throw,
|
|
393
|
+
// `.then` 回调内抛出转成 rejection 由尾部 `.catch` 吸收(链形不变)。
|
|
393
394
|
for (const c of aliveCtxs) {
|
|
394
395
|
void openByCtx
|
|
395
396
|
.get(c)
|
|
396
|
-
.then(() => c.emit({ type: "tool_approval_complete", approvalId: id, outcome: lastOutcome }))
|
|
397
|
+
.then((delivered) => (delivered ? c.emit({ type: "tool_approval_complete", approvalId: id, outcome: lastOutcome }) : undefined))
|
|
397
398
|
.catch(() => undefined);
|
|
398
399
|
}
|
|
399
400
|
// emit 全灭 = 卡从未送达任何人(≠人拒绝/TTL 走人)——同属「无活人可达」类 ⇒ "unavailable" 交 durable
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sema-agent/server",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.6.0",
|
|
4
4
|
"description": "Sema Server — the server/API implementation layer for Sema, wiring core, registry, model providers, and cloud agent execution. Built on @sema-agent/core.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "BUSL-1.1",
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"build:binary:run-local:darwin-arm64": "bun build --compile --target=bun-darwin-arm64 src/run-local.ts --outfile dist/run-local-darwin-arm64"
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@sema-agent/core": "^5.
|
|
57
|
+
"@sema-agent/core": "^5.11.0",
|
|
58
58
|
"@sema-agent/registry-core": "^0.14.0",
|
|
59
59
|
"e2b": "^2.28.0",
|
|
60
60
|
"libsodium-wrappers": "^0.8.4",
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
"sharp": "^0.35.3"
|
|
69
69
|
},
|
|
70
70
|
"devDependencies": {
|
|
71
|
-
"@sema-agent/sdk": "^6.
|
|
71
|
+
"@sema-agent/sdk": "^6.4.0",
|
|
72
72
|
"@types/libsodium-wrappers": "^0.7.14",
|
|
73
73
|
"@types/node": "22.10.2",
|
|
74
74
|
"@types/pg": "^8.20.0",
|