@sema-agent/server 7.126.0 → 7.127.1

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.
Files changed (79) hide show
  1. package/MIGRATION.md +38 -0
  2. package/USAGE.md +86 -8
  3. package/deploy/sema-up/chart/templates/networkpolicy-sandbox.yaml +15 -5
  4. package/deploy/sema-up/chart/templates/sandbox-rbac.yaml +28 -1
  5. package/deploy/sema-up/chart/templates/server-v4pro.yaml +1 -0
  6. package/deploy/sema-up/chart/templates/server.yaml +1 -0
  7. package/deploy/sema-up/chart/values.yaml +16 -6
  8. package/deploy/sema-up/kube-up.sh +10 -4
  9. package/deploy/sema-up/lib/node-addrs.sh +42 -0
  10. package/deploy/sema-up/stack/sema-stack-cluster.yaml +20 -1
  11. package/dist/audit.js +1 -0
  12. package/dist/boot/execution-env.js +17 -4
  13. package/dist/boot/leader.d.ts +2 -1
  14. package/dist/boot/memory-consolidation.d.ts +37 -3
  15. package/dist/boot/memory-consolidation.js +18 -2
  16. package/dist/boot/runner-deps.d.ts +2 -1
  17. package/dist/boot/session-faces.d.ts +2 -1
  18. package/dist/boot/stage-06-runners.js +5 -2
  19. package/dist/config-catalog.js +3 -1
  20. package/dist/config-types.d.ts +5 -1
  21. package/dist/config.js +2 -0
  22. package/dist/execution-lane-caps.d.ts +12 -0
  23. package/dist/execution-lane-caps.js +16 -2
  24. package/dist/fleet/subagent-tail-bus.js +15 -5
  25. package/dist/http/admission.js +5 -1
  26. package/dist/http/durable-run-leg.js +1 -3
  27. package/dist/http/resume-legs.js +15 -6
  28. package/dist/http/route-ctx.d.ts +7 -1
  29. package/dist/http/routes/runs.js +23 -1
  30. package/dist/http/routes/tasks.js +17 -8
  31. package/dist/http/routes/workflows.js +8 -0
  32. package/dist/http/server.js +1 -3
  33. package/dist/index.d.ts +1 -0
  34. package/dist/index.js +1 -0
  35. package/dist/lsp/manager.js +1 -1
  36. package/dist/observability/fail-open.d.ts +16 -0
  37. package/dist/observability/fail-open.js +16 -0
  38. package/dist/observability/metrics.js +1 -1
  39. package/dist/observability/run-terminal.js +3 -0
  40. package/dist/plugins/caching-session-store.d.ts +39 -22
  41. package/dist/plugins/caching-session-store.js +16 -9
  42. package/dist/plugins/host-platform.d.ts +18 -3
  43. package/dist/plugins/host-platform.js +23 -10
  44. package/dist/plugins/k8s-exec-protocol.js +3 -0
  45. package/dist/plugins/one-time-migrations.d.ts +28 -4
  46. package/dist/plugins/one-time-migrations.js +22 -4
  47. package/dist/plugins/remote-env-host.d.ts +23 -6
  48. package/dist/plugins/remote-env-host.js +21 -12
  49. package/dist/plugins/remote-env-k8s.d.ts +40 -5
  50. package/dist/plugins/remote-env-k8s.js +119 -25
  51. package/dist/plugins/session-store.d.ts +4 -3
  52. package/dist/plugins/session-store.js +1 -1
  53. package/dist/plugins/store-backend.d.ts +12 -5
  54. package/dist/plugins/store-backend.js +12 -3
  55. package/dist/plugins/store-contracts.d.ts +3 -0
  56. package/dist/plugins/store-open-wait.js +3 -0
  57. package/dist/request-key-closure.d.ts +20 -7
  58. package/dist/request-key-closure.js +22 -6
  59. package/dist/run-acceptance.d.ts +51 -0
  60. package/dist/run-acceptance.js +27 -0
  61. package/dist/runs.d.ts +7 -11
  62. package/dist/runs.js +5 -16
  63. package/dist/sandbox-egress-policy.d.ts +61 -0
  64. package/dist/sandbox-egress-policy.js +235 -0
  65. package/dist/session-error-wire.d.ts +2 -2
  66. package/dist/session-titler.d.ts +5 -3
  67. package/dist/trace/core-keyset-guard.d.ts +3 -2
  68. package/dist/trace/ledger-events.d.ts +9 -3
  69. package/dist/trace/ledger-sink.d.ts +7 -0
  70. package/dist/trace/ledger-sink.js +14 -4
  71. package/dist/trace/project.d.ts +49 -8
  72. package/dist/trace/project.js +39 -7
  73. package/dist/trace/self-minted-wire.d.ts +19 -29
  74. package/dist/trace/self-minted-wire.js +2 -9
  75. package/dist/trace/tool-output-media.d.ts +26 -0
  76. package/dist/trace/tool-output-media.js +57 -0
  77. package/dist/trace/wire-projection-faces.d.ts +7 -1
  78. package/dist/trace/wire-projection-faces.js +2 -1
  79. package/package.json +3 -3
@@ -1,3 +1,4 @@
1
+ import { liveToolEndForBacklog } from "../../trace/tool-output-media.js";
1
2
  import { redactHead, redactLedgerEventForRead } from "../../observability/run-terminal.js";
2
3
  import { z } from "zod";
3
4
  import { uuidv7, mintCheckpointToken, CheckpointError, policyCannotAnswer, validatePendingSteer, TASK_OUTPUT_REPLAY_KINDS, isSystemInjectionPriority } from "@sema-agent/core";
@@ -33,6 +34,7 @@ import { readResumeCtx } from "../resume-ctx-read.js";
33
34
  import { storedBodyReplayAnswer, storedBodyReplayLogFields } from "../checkpoint-ctx.js";
34
35
  import { serviceVersion } from "../../service-version.js";
35
36
  import { storedBodyReplayRefusal } from "../stored-body-replay.js";
37
+ import { steerBodyKeyIssue } from "../../request-key-closure.js";
36
38
  const STEER_PRIORITY_WORDS = INJECTION_TIERS.map((tier) => `'${tier}'`);
37
39
  const STEER_PRIORITY_UNION = STEER_PRIORITY_WORDS.join(" | ");
38
40
  const STEER_PRIORITY_CHOICES = STEER_PRIORITY_WORDS.length <= 2 ? STEER_PRIORITY_WORDS.join(" or ") : `${STEER_PRIORITY_WORDS.slice(0, -1).join(", ")}, or ${STEER_PRIORITY_WORDS[STEER_PRIORITY_WORDS.length - 1]}`;
@@ -413,6 +415,13 @@ export async function handleRuns(req, res, match, ctx) {
413
415
  sendError(res, 400, "request.body_shape", `body must be { text: string (non-empty), mode?: 'all' | 'one-at-a-time', priority?: ${STEER_PRIORITY_UNION} }`);
414
416
  return;
415
417
  }
418
+ {
419
+ const keyIssue = steerBodyKeyIssue(body, "run");
420
+ if (keyIssue) {
421
+ sendError(res, 400, "request.body_shape", keyIssue.message, { unknownKeys: keyIssue.unknownKeys, unsupportedKeys: keyIssue.unsupportedKeys });
422
+ return;
423
+ }
424
+ }
416
425
  if (body.mode !== undefined && body.mode !== "all" && body.mode !== "one-at-a-time") {
417
426
  sendError(res, 400, "request.field_invalid", "mode must be 'all' or 'one-at-a-time' when present");
418
427
  return;
@@ -737,6 +746,11 @@ export async function handleRuns(req, res, match, ctx) {
737
746
  return;
738
747
  }
739
748
  text = interruptBody.text;
749
+ const keyIssue = steerBodyKeyIssue(interruptBody, "interrupt");
750
+ if (keyIssue) {
751
+ sendError(res, 400, "request.body_shape", keyIssue.message, { unknownKeys: keyIssue.unknownKeys, unsupportedKeys: keyIssue.unsupportedKeys });
752
+ return;
753
+ }
740
754
  }
741
755
  const interruptIdemRaw = headerStr(req.headers["idempotency-key"]);
742
756
  const interruptInputId = interruptIdemRaw ? `idem-${idemKeyOf(interruptIdemRaw, idemCallerOf(req, ctx), "run").slice(0, 32)}` : uuidv7();
@@ -1040,7 +1054,8 @@ export async function handleRunVerbs(req, res, match, ctx) {
1040
1054
  const n = await it.next();
1041
1055
  if (n.done || closed || res.writableEnded)
1042
1056
  break;
1043
- res.write(`event: forward\ndata: ${JSON.stringify(n.value)}\n\n`);
1057
+ const frame = n.value.type === "tool_end" ? liveToolEndForBacklog(n.value, res.writableLength) : n.value;
1058
+ res.write(`event: forward\ndata: ${JSON.stringify(frame)}\n\n`);
1044
1059
  if (n.value.type === "task_settled")
1045
1060
  break;
1046
1061
  }
@@ -1161,6 +1176,13 @@ export async function handleRunVerbs(req, res, match, ctx) {
1161
1176
  sendError(res, 400, "request.body_shape", "body must be { content: string (non-empty) }");
1162
1177
  return;
1163
1178
  }
1179
+ {
1180
+ const keyIssue = steerBodyKeyIssue(body, "content");
1181
+ if (keyIssue) {
1182
+ sendError(res, 400, "request.body_shape", keyIssue.message, { unknownKeys: keyIssue.unknownKeys, unsupportedKeys: keyIssue.unsupportedKeys });
1183
+ return;
1184
+ }
1185
+ }
1164
1186
  if (body.content.length > STEER_IN_MAX_REQUEST_CHARS) {
1165
1187
  sendError(res, 413, "steer.content_too_large", `content exceeds ${STEER_IN_MAX_REQUEST_CHARS} characters (got ${body.content.length}); note only the first ${STEER_IN_MAX_CHARS} are delivered anyway`);
1166
1188
  return;
@@ -13,7 +13,8 @@ import { clearTurnActivity, readTurnActivityMs, recordTurnActivity } from "../..
13
13
  import { buildCancelledLedgerRow, createRunAbort, mintCancelledResult } from "../../run-cancel-context.js";
14
14
  import { recordResourceWindow } from "../../resource-window.js";
15
15
  import { redactSecrets } from "../../trace/redact.js";
16
- import { contextUsageEventData, turnEndEventData, toolStartEventData, toolEndEventData, taskProgressEventData, taskNotificationEventData, compactedEventData, diagnosticsEventData, brainStatusEventData, steeringInjectedEventData, compactionOutcomeEventData, workspaceChangedEventData, wiringManifestFaceEventData, toolRosterDeltaEventData, toolProgressEventData, toolDisclosureEventData, humanInputEventData, appendModelUsageDelta, attachModelUsage, textEndEventData, liveIdentityFields } from "../../trace/project.js";
16
+ import { liveToolEndForBacklog } from "../../trace/tool-output-media.js";
17
+ import { contextUsageEventData, turnEndEventData, toolStartEventData, toolEndEventData, taskProgressEventData, taskStartedEventData, taskNotificationEventData, compactedEventData, diagnosticsEventData, brainStatusEventData, steeringInjectedEventData, compactionOutcomeEventData, workspaceChangedEventData, wiringManifestFaceEventData, toolRosterDeltaEventData, toolProgressEventData, toolDisclosureEventData, humanInputEventData, appendModelUsageDelta, attachModelUsage, textEndEventData, liveIdentityFields } from "../../trace/project.js";
17
18
  import { cascadeConfig, runMeta } from "../run-meta.js";
18
19
  import { idemCallerOf, inflightKeyOf, submissionIdemOf } from "../idempotency.js";
19
20
  import { replayOfExistingRun } from "../submit-replay.js";
@@ -37,7 +38,8 @@ const DISCONNECT_PARK_GRACE_MS = 10_000;
37
38
  function sseData(res, payload) {
38
39
  const anchor = payload.eventId;
39
40
  const idLine = typeof anchor === "string" && anchor.length > 0 ? `id: ${anchor}\n` : "";
40
- res.write(`${idLine}data: ${JSON.stringify(payload)}\n\n`);
41
+ const frame = payload.type === "tool_end" ? liveToolEndForBacklog(payload, res.writableLength) : payload;
42
+ res.write(`${idLine}data: ${JSON.stringify(frame)}\n\n`);
41
43
  }
42
44
  async function mintSubmitExitResult(result, checkpoints) {
43
45
  return redactTerminalResult(sealResultForExit(await graftParkToolCallId(result, checkpoints)));
@@ -263,8 +265,6 @@ export async function handleTasks(req, res, match, ctx) {
263
265
  clearTurnActivity(tid);
264
266
  if (created.ok)
265
267
  recordResourceWindow(tid, prepared.spec.resourceSuspend);
266
- if (created.ok)
267
- deps.sessionTitler?.maybeTitle(prepared.spec.sessionId, prepared.spec.objective, typeof prepared.spec.model === "string" ? prepared.spec.model : prepared.spec.model?.id);
268
268
  if (created.existing) {
269
269
  const replay = await replayOfExistingRun(created.existing, { principal: submitter, idem }, "sync", deps, deps.runStore);
270
270
  sseData(res, { type: "done", result: doneResultOfOutcome(replay), replay: true });
@@ -390,6 +390,9 @@ export async function handleTasks(req, res, match, ctx) {
390
390
  if (t === "task_progress") {
391
391
  sseData(res, { type: "task_progress", ...taskProgressEventData(e) });
392
392
  }
393
+ else if (t === "task_started") {
394
+ sseData(res, { type: "task_started", ...taskStartedEventData(e) });
395
+ }
393
396
  else if (t === "tool_start") {
394
397
  const tsv = e;
395
398
  sseData(res, { type: "tool_start", ...toolStartEventData(tsv) });
@@ -458,6 +461,7 @@ export async function handleTasks(req, res, match, ctx) {
458
461
  userMsgEntryId = ev.entryId;
459
462
  const causeAtDone = ev.type === "done" ? ac.cause() : undefined;
460
463
  await deps.userInputLedger?.observeLegEvent(liveStream, ev);
464
+ prepared.acceptance.observe(ev);
461
465
  if (ev.type === "done") {
462
466
  finalResult = await mintSubmitExitResult(ev.result, deps.checkpointStore);
463
467
  if (durableTaskId && finalResult)
@@ -465,7 +469,7 @@ export async function handleTasks(req, res, match, ctx) {
465
469
  if (causeAtDone !== undefined && finalResult !== undefined) {
466
470
  finalResult = mintCancelledResult({ taskId: durableTaskId ?? "", sessionId: prepared.spec.sessionId ?? "", startedAtMs: streamLegStartedAt, message: "cancelled by user", cause: causeAtDone, engineResult: finalResult });
467
471
  }
468
- finalizeTaskResult(ev.result, principal, prepared.spec.objective, prepared.spec.sessionId);
472
+ finalizeTaskResult(ev.result, principal, prepared.spec.objective);
469
473
  noteRunOutcome(finalResult);
470
474
  putRewindAnchor();
471
475
  settleFleet(outwardPlane(finalResult.terminal).status, fleetRunResiduals(finalResult));
@@ -517,6 +521,9 @@ export async function handleTasks(req, res, match, ctx) {
517
521
  else if (ev.type === "task_progress") {
518
522
  sseData(res, { type: "task_progress", ...taskProgressEventData(ev) });
519
523
  }
524
+ else if (ev.type === "task_started") {
525
+ sseData(res, { type: "task_started", ...taskStartedEventData(ev) });
526
+ }
520
527
  else if (ev.type === "compacted") {
521
528
  sseData(res, { type: "compacted", ...compactedEventData(ev) });
522
529
  }
@@ -843,7 +850,6 @@ export async function handleTasks(req, res, match, ctx) {
843
850
  const placementReject = await commitPlacement(deps, deps.runStore, tid, prepared.bindingIntent);
844
851
  if (placementReject)
845
852
  return { status: placementReject.status, body: devicePlacementErrorBody(placementReject) };
846
- deps.sessionTitler?.maybeTitle(prepared.spec.sessionId, prepared.spec.objective, typeof prepared.spec.model === "string" ? prepared.spec.model : prepared.spec.model?.id);
847
853
  durableTaskId = tid;
848
854
  }
849
855
  commitSessionInheritEnv(deps, prepared.spec.sessionId, prepared.body);
@@ -886,8 +892,10 @@ export async function handleTasks(req, res, match, ctx) {
886
892
  const cascadeLeg = () => runCascade(prepared.runner, specWithSignal, cascadeConfig(deps.config.cascadeLadder, prepared.spec.limits?.maxCostUsd));
887
893
  const plainLeg = async () => {
888
894
  const stream = prepared.runner.runTaskStream(specWithSignal);
889
- for await (const ev of stream)
895
+ for await (const ev of stream) {
890
896
  observeSyncLegEvent(ev);
897
+ prepared.acceptance.observe(ev);
898
+ }
891
899
  return stream.result();
892
900
  };
893
901
  result = await instructionLeg.run(prepared.spec.sessionId, () => withPrincipal(principal, () => prepared.verify ? verifyLeg(prepared.verify) : prepared.cascade ? cascadeLeg() : plainLeg()));
@@ -934,7 +942,8 @@ export async function handleTasks(req, res, match, ctx) {
934
942
  }
935
943
  if (durableTaskId && deps.runStore)
936
944
  await deps.runStore.setTerminal(durableTaskId, syncPlane.status, exitResult, syncPlane.errorMessage ?? null);
937
- finalizeTaskResult(result, principal, prepared.spec.objective, prepared.spec.sessionId);
945
+ prepared.acceptance.settle(result);
946
+ finalizeTaskResult(result, principal, prepared.spec.objective);
938
947
  noteRunOutcome(result);
939
948
  return { status: 200, body: withSyncSubmitKeys(buildSyncSubmitBody(exitResult)) };
940
949
  };
@@ -13,6 +13,7 @@ import { sendJson, sendError, sseHeaders } from "../send.js";
13
13
  import { gatedPrincipal, explicitOperatorOk, sendPrincipalRefusal } from "../principal-gate.js";
14
14
  import { assertNever } from "../route-ctx.js";
15
15
  import * as facts from "../../capability-facts.js";
16
+ import { steerBodyKeyIssue } from "../../request-key-closure.js";
16
17
  export async function handleWorkflows(req, res, match, ctx) {
17
18
  const { deps } = ctx;
18
19
  const { runSessionAcceptOk } = ctx.helpers;
@@ -188,6 +189,13 @@ export async function handleWorkflowAgentSteer(req, res, match, ctx) {
188
189
  sendError(res, 400, "request.body_shape", "body must be { content: string (non-empty) }");
189
190
  return;
190
191
  }
192
+ {
193
+ const keyIssue = steerBodyKeyIssue(body, "content");
194
+ if (keyIssue) {
195
+ sendError(res, 400, "request.body_shape", keyIssue.message, { unknownKeys: keyIssue.unknownKeys, unsupportedKeys: keyIssue.unsupportedKeys });
196
+ return;
197
+ }
198
+ }
191
199
  if (body.content.length > STEER_IN_MAX_REQUEST_CHARS) {
192
200
  sendError(res, 413, "steer.content_too_large", `content exceeds ${STEER_IN_MAX_REQUEST_CHARS} characters (got ${body.content.length}); note only the first ${STEER_IN_MAX_CHARS} are delivered anyway`);
193
201
  return;
@@ -5,7 +5,6 @@ import { validatePendingSteer } from "@sema-agent/core";
5
5
  import {} from "../web-admin-client/facade.js";
6
6
  import { HttpError } from "../security.js";
7
7
  import {} from "../memory-sync.js";
8
- import { evictIfConflict } from "../runs.js";
9
8
  import {} from "../orchestration/workflow-agent-steer.js";
10
9
  import {} from "../orchestration/workflow-completion-inbox.js";
11
10
  import {} from "../fleet/fleet-bus.js";
@@ -162,10 +161,9 @@ export function createHttpServer(rawDeps) {
162
161
  deps.metrics?.inc("cascade_total", { outcome: cascadeOutcome });
163
162
  deps.instrumentDegenerate?.(result);
164
163
  }
165
- function finalizeTaskResult(result, principal, objective, sessionId) {
164
+ function finalizeTaskResult(result, principal, objective) {
166
165
  recordTaskResult(result);
167
166
  deps.planCacheProbe?.record(principal, objective, result.terminal.kind === "completed");
168
- evictIfConflict(deps.runner, sessionId, result);
169
167
  }
170
168
  const isRunLiveOnThisReplica = (taskId) => inflightRuns.has(taskId) || steerableRuns.has(taskId);
171
169
  return Object.assign(server, { denyExpiredApprovals, isRunLiveOnThisReplica, redeemParkedAsk });
package/dist/index.d.ts CHANGED
@@ -3,6 +3,7 @@ export { composeExecutionLane, assertExecutionLaneInvariants, type ExecutionLane
3
3
  export { createBrain } from "./brain.js";
4
4
  export { createSessionStore } from "./plugins/session-store.js";
5
5
  export { createStoreBackend, type StoreBackend, type SessionCaptureRecordSource } from "./plugins/store-backend.js";
6
+ export { RetiredSchemaPresentError } from "./plugins/one-time-migrations.js";
6
7
  export { CachingSessionStore, type CachingOptions } from "./plugins/caching-session-store.js";
7
8
  export type { RunRecord, RunEvent } from "./plugins/store-contracts.js";
8
9
  export { runInBackground } from "./runs.js";
package/dist/index.js CHANGED
@@ -3,6 +3,7 @@ export { composeExecutionLane, assertExecutionLaneInvariants } from "./boot/exec
3
3
  export { createBrain } from "./brain.js";
4
4
  export { createSessionStore } from "./plugins/session-store.js";
5
5
  export { createStoreBackend } from "./plugins/store-backend.js";
6
+ export { RetiredSchemaPresentError } from "./plugins/one-time-migrations.js";
6
7
  export { CachingSessionStore } from "./plugins/caching-session-store.js";
7
8
  export { runInBackground } from "./runs.js";
8
9
  export { createHardenedVmRunner, runInHardenedVm } from "./orchestration/hardened-vm-runner.js";
@@ -79,7 +79,7 @@ export class E2bLspManager {
79
79
  return undefined;
80
80
  }
81
81
  if (heal)
82
- await session.warmOpen(heal.carry).catch(() => ({ opened: 0, failed: heal.carry.length }));
82
+ await session.warmOpen(heal.carry).catch(() => ({ opened: 0, failed: heal.carry.length, deferred: 0 }));
83
83
  if (sessions.get(language) !== job) {
84
84
  await session.close().catch(() => undefined);
85
85
  return undefined;
@@ -35,6 +35,18 @@ export declare const FAIL_OPEN_TAGS: {
35
35
  readonly cls: "F";
36
36
  readonly note: "S-297(`http/routes/sessions.ts lastLegMcpFor`,`/mcp` 面板的可选键 `lastLegMcp`):按会话取最近 run 的索引读 / 那条 run 的账本读**抛错或超期** ⇒ 该键缺席,面板本体照常 200。放行的最坏后果=这一屏少一格「上一次真挂上了什么」(壳退回翻账本),不是门、不是权限、不是凭证 —— 而反方向(让附加键的读失败打掉整张面板)会给一口此前零 MCP 配置下根本不碰账本的**既有**读面新加一条挂掉的理由。缺席本就是本键 wire 上的一等状态(消费端必须会读),所以这条兜底不产生任何新的消费端形态;计数让「某台部署的账本一直读不出来」显形。";
37
37
  };
38
+ readonly "server.ledger.forward-append-failed": {
39
+ readonly cls: "F";
40
+ readonly note: "durable 腿 forward 道的写失败(`trace/ledger-sink.ts` 的 `createForwardAppendFailedNote`:账本汇 `onForwardEvent` —— bg 腿、sync detach 车道、续跑腿 verify 臂 —— 与续跑腿自己的 forward 回调同一只出口)。forward 道是同步回调里的 fire-and-forget 写(序号同步分配,结构上不能 await),写失败 ⇒ 这一帧(子代的 `task_started` / `task_progress` / 工具卡)永远不在 `GET /v1/runs/:id/events` 的回放面,run 照跑。放行的最坏后果 = 回放面少一帧子代事实(起跑帧每周期一只,丢了就补不回 `agentType` / `rootSessionId` / `startedAt`),不是门、不是凭据、不改任何裁决;不重试 —— 晚到的重试行会落在读方游标之后(单写串行链存在的理由)。计数非零 = 这台部署的账本写口在出错(主流的 awaited 写多半同时在报错),去看库。detail = `type=<帧型> err=<原句,经记录器脱敏>`。";
41
+ };
42
+ readonly "server.tool-end-media.live-backlog-degraded": {
43
+ readonly cls: "F";
44
+ readonly note: "core 7.50.0 媒体块提货(`trace/tool-output-media.ts` 的 `liveToolEndForBacklog`,三处活面写口共用的那一只判据 —— 同步流出口 `sseData`、子代 tail 路由写环、tail 总线每订阅者队列):本帧媒体字节 + 这条连接 / 这个订阅者还没送出去的字节 > 上界(64 MiB)⇒ 本帧媒体块降成持久面那只描述符(`{ type, mimeType?, bytes, truncated: true }`,帧级 `truncated` + `totalChars`)。每降一帧记一次,只在这只函数里记(三处写口不各记)。放行的最坏后果 = 一个读得慢的观众在 SSE 上看不到这张图,只见「这里有一张多大的什么图、没送达」(账本面本就恒是描述符),不是门、不是凭据、不改任何裁决;反方向(不降)让一条停读的连接随帧数线性压住几百 MiB。帧上它与引擎自己的媒体预算降级同形不可分,所以计数是运维唯一能看出「多少观众丢了图」的位;计数持续非零 = 有消费端读流太慢(或网络瓶颈),去看那几条连接。detail = `backlog=<待发字节> media=<本帧媒体字节>`。";
45
+ };
46
+ readonly "server.memory-consolidation.operator-valve-unjudged": {
47
+ readonly cls: "P-DEBT";
48
+ readonly note: "core 7.50.0 #1653 提货(`boot/memory-consolidation.ts` `createOperatorValveWriteJudge`):运维手动整理阀门这条腿的写判官座答 `no_objection` —— 这条腿没有腿(无名册写脸、无引擎盖的豁免根、无人的持久规则车道),引擎包不导出它那只合成写判官的构造器,本仓不手搭第二只。每被问一次(早问库目录一次 + 每个产物一次)记一次。放行的最坏后果 = 运维手动整理时,一条点名记忆库路径的部署写策略或人的持久规则拦不住这一轮折叠(与引擎 7.49.x 逐字同:那时驱动根本不问);自动整理腿走引擎腿自己的判官,不在本 tag 射程。收口 = 引擎导出宿主可用的写判官构造器,换上即删本 tag。";
49
+ };
38
50
  readonly "server.workflow-park-index.sync-failed": {
39
51
  readonly cls: "P-DEBT";
40
52
  readonly note: "S-185 车CM(codex r1-F2 [high],验真后登记):workflow 出身 park 的 join 索引(`workflow_agent_session` / local 账本)在**一次成功的 run 写之后**同步失败 ⇒ 该次同步被吞,重试要等这条 run 的**下一次**写。而 park 恰好常常是这条 run 的**最后**一次写(park = run 终局 `failed`)⇒ 那条 park 可能永久 join 不到。**多数情形**是诚实 miss:这张卡没有别的指针 ⇒ `/decide` 结构性退回本批之前的行为(409 `conflict.resume_context_unavailable`),不误配、不伪造、不放行,丢的是「人能不能把这条批准送出去」这个能力。**7.118.0 起不止于此**:路由把指针候选当持有者的全集读,并有分叉门(两条同代次持有者 ⇒ 409 `decide.workflow_park_forked`);同一张卡上一枚**竞争**指针没写上(本臂的写失败,或写记录与写指针之间进程崩溃 —— 后者不经本臂、不计数)⇒ 分叉被判成唯一持有者 ⇒ 决定投给幸存的那一支(它确实持有这张卡,但另一支同样站在上面,引擎把这种形交给消费方拒)。归 P 类的理由:缺指针决定**车道选择**(缺行 ⇒ 选中 legacy 腿),且能让分叉门失效;归 DEBT 的理由是真正的解要一条**按子代 checkpoint 键的耐久决议回执 + 重启期回填**(与 codex r1-F3 同一件欠账,另车),分叉门失效那一形另有设计票。计数非零 = 有 park 可能决不掉、或这张卡的分叉门可能失效,运维要按 detail 里的 runId 去查那条 run 的续跑链。";
@@ -431,6 +443,10 @@ export declare const FAIL_OPEN_TAGS: {
431
443
  readonly cls: "F";
432
444
  readonly note: "k8s 车道工作区空间结束时对**快照对象**的回收(`plugins/remote-env-k8s.ts` `createSpaceReclaimer` 的对象部分;与上一条同一只回收座、两条腿:会话删除协调器与托管留存 sweep):按空间的 `<前缀>/<化身>/space/` 分区(快照 + `latest`)和该会话停驻车道里名字带被结束住客归属记号的 `<前缀>/<会话 id>/*.tar.gz` 分别 LIST、逐个预签 DELETE;LIST 失败、DELETE 失败(HTTP 非 2xx / 非 404、网络)LIST 超过 1000 个对象被截断,或回收座一次调用的绝对截止(= 快照时限;卷回收也在内)到点〔切断在途请求、停掉余下的;这次调用里还没轮到的空间零请求、各记一次;没删的归生命周期规则〕,各记一次(detail `lane=space|park stage=list|delete|budget`;会话删除口把回收座脱离应答路径,座若抛了——契约禁止——记 `stage=seat_threw`)⇒ 这些对象留在桶里,会话照删(不挡删除)。放行的最坏后果 = 一份已无人能读的工作区快照留在桶里(会话已删,经 API 不可达;内容是用户工作区,所以不是「只占盘」—— 留到桶的生命周期规则把它过期掉为止);不碰任何别的空间、不是门不是凭据;反方向(清不掉对象就拒删会话)会让一个对象存储抖动挡住用户的删除权。故 F 类,但必须留痕:静默吞掉之后「这个会话删了、它的工作区内容还在不在桶里」在遥测里没有任何痕迹。同拍一行 `workspace_space_reclaim_failed`(`space` 前缀 / `sessionId` / `lane` / `stage` / `error`)。计数非零 = 有对象没回收掉,运维按行里的空间前缀 / 会话 id 去桶里手清,并核对象存储的可达性与 DELETE 权限。回收与同一空间的尚在途的 run 末提交不是互斥的:会话删除时没有活 run(409 挡),但上一条 run 的提交若恰在删除之后才落对象,这批对象不在本次回收射程内(同样归桶的生命周期规则)。停驻分区按会话 id 分区,所以只删名字带被结束住客归属记号的对象(不读任何钟)—— 会话 id 删后被别人重新登记,新住客停驻出的快照带它自己的记号、不碰;不带记号的旧形与 `none` 留给桶的生命周期规则。";
433
445
  };
446
+ readonly "server.sandbox-egress.policy-gap": {
447
+ readonly cls: "P-DEBT";
448
+ readonly note: "k8s 车道沙箱出网的起服自检(`plugins/remote-env-k8s.ts` `checkSandboxEgressPolicy` 读节点表与沙箱 ns 的 NetworkPolicy,判法 `sandbox-egress-policy.ts` `sandboxEgressGaps`;起服一次、不挡起服)读出一个缺口 —— 某节点的 InternalIP / ExternalIP 落在一条放行 ipBlock 内(`kind=node_address_open`)、没有策略以 Egress 选中沙箱 pod(`kind=policy_absent`)、pod 保留 IPv6 而策略层担保不了 v6(`kind=ipv6_enabled`)、节点表或策略表读不到(`kind=unreadable what=nodes|networkpolicies`)⇒ 车道**照常起**,每个缺口一行 `sandbox_egress_policy_gap`(带后果句与去哪改)。归 P 类:出网策略是保护面,缺口意味着沙箱里的代码可能够到节点上的服务;归 DEBT 的理由:修法都在集群配置里(节点地址点名、kubelet 放行 v6 sysctl、CNI 的 v6 执行、RBAC),本服务改不了,而拒起车道会让缺省单节点装法(恒有 `ipv6_enabled`)升级即停摆沙箱执行 —— 车道自己的隔离(VM 边界、零 SA token)仍在。放行的最坏后果 = 读出缺口的那一面照旧可达,直到运维按行里的后果句改配置。计数非零 = 这台部署的沙箱出网面有读得出的缺口。读不出的面(宿主 DNAT 发布端口、「pod 所在节点恒放行」的实现差异、CNI 根本不执行策略)不在本 tag 的射程里。";
449
+ };
434
450
  readonly "server.fleet.announce-reply-unreadable": {
435
451
  readonly cls: "P-DEBT";
436
452
  readonly note: "7.121.0 修复验证轮(`fleet-client.ts` 的公告环,fleet 成员每一拍心跳 2xx 答复;detail = `why=<body|not-an-object|invalid-capabilities> leaseState=<此刻的租约态>`):答复不是能力读数(体读不出 / 不是对象 / `capabilities` 键在场而形坏 —— settings-schema 9.0.0 `readCapabilityReading` 的 `unreadable`,外加本仓的 `body`)⇒ 当作没有这一拍答复,租约的状态与钟原样。**一律**计数(不论租约生效与否):租约还没生效(`leaseState=none`)时这只成员**一直不受围栏**,而修前这一形零计数(中心每拍答 `<html>` ⇒ 成员恒 none、零痕迹);租约已生效时计数说明中心的答复在坏、围栏靠的是上一次读数。归 P 类:围栏轴;归 DEBT:本进程判不出中心为什么答坏体,收口条件 = 中心契约 / 监控(中心侧对心跳答复形的执法)落地。每段连拍另有一行 warn `fleet_announce_reply_unreadable`(带 `why`)。";
@@ -16,6 +16,18 @@ export const FAIL_OPEN_TAGS = {
16
16
  cls: "F",
17
17
  note: "S-297(`http/routes/sessions.ts lastLegMcpFor`,`/mcp` 面板的可选键 `lastLegMcp`):按会话取最近 run 的索引读 / 那条 run 的账本读**抛错或超期** ⇒ 该键缺席,面板本体照常 200。放行的最坏后果=这一屏少一格「上一次真挂上了什么」(壳退回翻账本),不是门、不是权限、不是凭证 —— 而反方向(让附加键的读失败打掉整张面板)会给一口此前零 MCP 配置下根本不碰账本的**既有**读面新加一条挂掉的理由。缺席本就是本键 wire 上的一等状态(消费端必须会读),所以这条兜底不产生任何新的消费端形态;计数让「某台部署的账本一直读不出来」显形。",
18
18
  },
19
+ "server.ledger.forward-append-failed": {
20
+ cls: "F",
21
+ note: "durable 腿 forward 道的写失败(`trace/ledger-sink.ts` 的 `createForwardAppendFailedNote`:账本汇 `onForwardEvent` —— bg 腿、sync detach 车道、续跑腿 verify 臂 —— 与续跑腿自己的 forward 回调同一只出口)。forward 道是同步回调里的 fire-and-forget 写(序号同步分配,结构上不能 await),写失败 ⇒ 这一帧(子代的 `task_started` / `task_progress` / 工具卡)永远不在 `GET /v1/runs/:id/events` 的回放面,run 照跑。放行的最坏后果 = 回放面少一帧子代事实(起跑帧每周期一只,丢了就补不回 `agentType` / `rootSessionId` / `startedAt`),不是门、不是凭据、不改任何裁决;不重试 —— 晚到的重试行会落在读方游标之后(单写串行链存在的理由)。计数非零 = 这台部署的账本写口在出错(主流的 awaited 写多半同时在报错),去看库。detail = `type=<帧型> err=<原句,经记录器脱敏>`。",
22
+ },
23
+ "server.tool-end-media.live-backlog-degraded": {
24
+ cls: "F",
25
+ note: "core 7.50.0 媒体块提货(`trace/tool-output-media.ts` 的 `liveToolEndForBacklog`,三处活面写口共用的那一只判据 —— 同步流出口 `sseData`、子代 tail 路由写环、tail 总线每订阅者队列):本帧媒体字节 + 这条连接 / 这个订阅者还没送出去的字节 > 上界(64 MiB)⇒ 本帧媒体块降成持久面那只描述符(`{ type, mimeType?, bytes, truncated: true }`,帧级 `truncated` + `totalChars`)。每降一帧记一次,只在这只函数里记(三处写口不各记)。放行的最坏后果 = 一个读得慢的观众在 SSE 上看不到这张图,只见「这里有一张多大的什么图、没送达」(账本面本就恒是描述符),不是门、不是凭据、不改任何裁决;反方向(不降)让一条停读的连接随帧数线性压住几百 MiB。帧上它与引擎自己的媒体预算降级同形不可分,所以计数是运维唯一能看出「多少观众丢了图」的位;计数持续非零 = 有消费端读流太慢(或网络瓶颈),去看那几条连接。detail = `backlog=<待发字节> media=<本帧媒体字节>`。",
26
+ },
27
+ "server.memory-consolidation.operator-valve-unjudged": {
28
+ cls: "P-DEBT",
29
+ note: "core 7.50.0 #1653 提货(`boot/memory-consolidation.ts` `createOperatorValveWriteJudge`):运维手动整理阀门这条腿的写判官座答 `no_objection` —— 这条腿没有腿(无名册写脸、无引擎盖的豁免根、无人的持久规则车道),引擎包不导出它那只合成写判官的构造器,本仓不手搭第二只。每被问一次(早问库目录一次 + 每个产物一次)记一次。放行的最坏后果 = 运维手动整理时,一条点名记忆库路径的部署写策略或人的持久规则拦不住这一轮折叠(与引擎 7.49.x 逐字同:那时驱动根本不问);自动整理腿走引擎腿自己的判官,不在本 tag 射程。收口 = 引擎导出宿主可用的写判官构造器,换上即删本 tag。",
30
+ },
19
31
  "server.workflow-park-index.sync-failed": {
20
32
  cls: "P-DEBT",
21
33
  note: "S-185 车CM(codex r1-F2 [high],验真后登记):workflow 出身 park 的 join 索引(`workflow_agent_session` / local 账本)在**一次成功的 run 写之后**同步失败 ⇒ 该次同步被吞,重试要等这条 run 的**下一次**写。而 park 恰好常常是这条 run 的**最后**一次写(park = run 终局 `failed`)⇒ 那条 park 可能永久 join 不到。**多数情形**是诚实 miss:这张卡没有别的指针 ⇒ `/decide` 结构性退回本批之前的行为(409 `conflict.resume_context_unavailable`),不误配、不伪造、不放行,丢的是「人能不能把这条批准送出去」这个能力。**7.118.0 起不止于此**:路由把指针候选当持有者的全集读,并有分叉门(两条同代次持有者 ⇒ 409 `decide.workflow_park_forked`);同一张卡上一枚**竞争**指针没写上(本臂的写失败,或写记录与写指针之间进程崩溃 —— 后者不经本臂、不计数)⇒ 分叉被判成唯一持有者 ⇒ 决定投给幸存的那一支(它确实持有这张卡,但另一支同样站在上面,引擎把这种形交给消费方拒)。归 P 类的理由:缺指针决定**车道选择**(缺行 ⇒ 选中 legacy 腿),且能让分叉门失效;归 DEBT 的理由是真正的解要一条**按子代 checkpoint 键的耐久决议回执 + 重启期回填**(与 codex r1-F3 同一件欠账,另车),分叉门失效那一形另有设计票。计数非零 = 有 park 可能决不掉、或这张卡的分叉门可能失效,运维要按 detail 里的 runId 去查那条 run 的续跑链。",
@@ -391,6 +403,10 @@ export const FAIL_OPEN_TAGS = {
391
403
  cls: "F",
392
404
  note: "k8s 车道工作区空间结束时对**快照对象**的回收(`plugins/remote-env-k8s.ts` `createSpaceReclaimer` 的对象部分;与上一条同一只回收座、两条腿:会话删除协调器与托管留存 sweep):按空间的 `<前缀>/<化身>/space/` 分区(快照 + `latest`)和该会话停驻车道里名字带被结束住客归属记号的 `<前缀>/<会话 id>/*.tar.gz` 分别 LIST、逐个预签 DELETE;LIST 失败、DELETE 失败(HTTP 非 2xx / 非 404、网络)LIST 超过 1000 个对象被截断,或回收座一次调用的绝对截止(= 快照时限;卷回收也在内)到点〔切断在途请求、停掉余下的;这次调用里还没轮到的空间零请求、各记一次;没删的归生命周期规则〕,各记一次(detail `lane=space|park stage=list|delete|budget`;会话删除口把回收座脱离应答路径,座若抛了——契约禁止——记 `stage=seat_threw`)⇒ 这些对象留在桶里,会话照删(不挡删除)。放行的最坏后果 = 一份已无人能读的工作区快照留在桶里(会话已删,经 API 不可达;内容是用户工作区,所以不是「只占盘」—— 留到桶的生命周期规则把它过期掉为止);不碰任何别的空间、不是门不是凭据;反方向(清不掉对象就拒删会话)会让一个对象存储抖动挡住用户的删除权。故 F 类,但必须留痕:静默吞掉之后「这个会话删了、它的工作区内容还在不在桶里」在遥测里没有任何痕迹。同拍一行 `workspace_space_reclaim_failed`(`space` 前缀 / `sessionId` / `lane` / `stage` / `error`)。计数非零 = 有对象没回收掉,运维按行里的空间前缀 / 会话 id 去桶里手清,并核对象存储的可达性与 DELETE 权限。回收与同一空间的尚在途的 run 末提交不是互斥的:会话删除时没有活 run(409 挡),但上一条 run 的提交若恰在删除之后才落对象,这批对象不在本次回收射程内(同样归桶的生命周期规则)。停驻分区按会话 id 分区,所以只删名字带被结束住客归属记号的对象(不读任何钟)—— 会话 id 删后被别人重新登记,新住客停驻出的快照带它自己的记号、不碰;不带记号的旧形与 `none` 留给桶的生命周期规则。",
393
405
  },
406
+ "server.sandbox-egress.policy-gap": {
407
+ cls: "P-DEBT",
408
+ note: "k8s 车道沙箱出网的起服自检(`plugins/remote-env-k8s.ts` `checkSandboxEgressPolicy` 读节点表与沙箱 ns 的 NetworkPolicy,判法 `sandbox-egress-policy.ts` `sandboxEgressGaps`;起服一次、不挡起服)读出一个缺口 —— 某节点的 InternalIP / ExternalIP 落在一条放行 ipBlock 内(`kind=node_address_open`)、没有策略以 Egress 选中沙箱 pod(`kind=policy_absent`)、pod 保留 IPv6 而策略层担保不了 v6(`kind=ipv6_enabled`)、节点表或策略表读不到(`kind=unreadable what=nodes|networkpolicies`)⇒ 车道**照常起**,每个缺口一行 `sandbox_egress_policy_gap`(带后果句与去哪改)。归 P 类:出网策略是保护面,缺口意味着沙箱里的代码可能够到节点上的服务;归 DEBT 的理由:修法都在集群配置里(节点地址点名、kubelet 放行 v6 sysctl、CNI 的 v6 执行、RBAC),本服务改不了,而拒起车道会让缺省单节点装法(恒有 `ipv6_enabled`)升级即停摆沙箱执行 —— 车道自己的隔离(VM 边界、零 SA token)仍在。放行的最坏后果 = 读出缺口的那一面照旧可达,直到运维按行里的后果句改配置。计数非零 = 这台部署的沙箱出网面有读得出的缺口。读不出的面(宿主 DNAT 发布端口、「pod 所在节点恒放行」的实现差异、CNI 根本不执行策略)不在本 tag 的射程里。",
409
+ },
394
410
  "server.fleet.announce-reply-unreadable": {
395
411
  cls: "P-DEBT",
396
412
  note: "7.121.0 修复验证轮(`fleet-client.ts` 的公告环,fleet 成员每一拍心跳 2xx 答复;detail = `why=<body|not-an-object|invalid-capabilities> leaseState=<此刻的租约态>`):答复不是能力读数(体读不出 / 不是对象 / `capabilities` 键在场而形坏 —— settings-schema 9.0.0 `readCapabilityReading` 的 `unreadable`,外加本仓的 `body`)⇒ 当作没有这一拍答复,租约的状态与钟原样。**一律**计数(不论租约生效与否):租约还没生效(`leaseState=none`)时这只成员**一直不受围栏**,而修前这一形零计数(中心每拍答 `<html>` ⇒ 成员恒 none、零痕迹);租约已生效时计数说明中心的答复在坏、围栏靠的是上一次读数。归 P 类:围栏轴;归 DEBT:本进程判不出中心为什么答坏体,收口条件 = 中心契约 / 监控(中心侧对心跳答复形的执法)落地。每段连拍另有一行 warn `fleet_announce_reply_unreadable`(带 `why`)。",
@@ -280,7 +280,7 @@ export function createMetrics() {
280
280
  m.counter("memory_dedup_folded_total", "Memory writes folded into an existing near-duplicate row (LOW)");
281
281
  m.counter("memory_dedup_probe_failed_total", "Memory dedup KNN probes that failed and fell through to INSERT (LOW)");
282
282
  m.counter("redactions_applied_total", "redactSecrets rewrites (S24), by pattern label and `arm` (S-88: full vs prefix-fragment arm — which RULE fired, NOT proof the text was upstream-truncated)");
283
- m.counter("stale_cache_conflict_total", "Warm session-cache entries evicted by a cross-instance write while cached (S25)");
283
+ m.counter("session_cache_stale_hit_total", "Warm session-cache hits dropped because the store had moved past the cached leaf (written by another replica, or gone); the session was re-woken from the store");
284
284
  m.counter("breaker_writethrough_failed_total", "Circuit-breaker cross-replica write-through failures (LOW), by backend");
285
285
  m.counter("web_search_bad_payload_total", "Web-search provider responses whose results field was not an array (LOW), by provider");
286
286
  m.counter("auto_mode_classifier_deny_cause_total", "Auto-mode gate denials where the classifier did NOT rule, by the FORM of the deny (core CLASSIFIER_DENY_CAUSES on GateDisposition.cause: 'unavailable' = the classifier could not run at all (threw/rejected/ran past the cap — retryable), 'parse_error' = it ran but answered outside its verdict contract (blocked for safety); 'other' = this build cannot name the word). 🔴 BREAKING in server 7.72.0: this counter was named auto_mode_classifier_unavailable_total through 7.71.0 and is RENAMED with NO alias (a counter that also counts parse_error is not an 'unavailable' counter); its label vocabulary was 'error'/'timeout' (WHY it was unavailable) — those two collapsed into 'unavailable' (the WHY now rides the deny sentence and the auto_mode.classified trace frame), and 'parse_error' is a bucket the old counter could not see, so by-cause series do NOT splice across the upgrade. Distinct from permission_denied_total{deniedBy=\"classifier\"}, which also counts genuine safety blocks (the classifier's own <block> ruling carries NO cause) — a spike here means the classifier itself is not working (gateway/credential fault, too slow, or answering off-contract), not that the model kept asking for dangerous things");
@@ -6,6 +6,7 @@ import { isUnsuccessfulRunStatus } from "../plugins/store-contracts.js";
6
6
  import { sealResultForExit } from "../terminal.js";
7
7
  import { errorText } from "./err-text.js";
8
8
  import { recordFailOpen } from "./fail-open.js";
9
+ import { toolEndMediaAsDescriptors } from "../trace/tool-output-media.js";
9
10
  const MESSAGE_CAP = 1024;
10
11
  export function runTerminalLogLevel(status, errorCode) {
11
12
  if (!isUnsuccessfulRunStatus(status))
@@ -451,6 +452,8 @@ export function redactLedgerEventData(type, data) {
451
452
  }
452
453
  if (type === "turn_end")
453
454
  return shapeEchoMembers(data, TURN_END_ECHO_TREATMENT, "turn_end");
455
+ if (type === "tool_end")
456
+ return toolEndMediaAsDescriptors(data);
454
457
  if (type === "task_notification") {
455
458
  const af = data.apiFailure;
456
459
  if (af === undefined)
@@ -8,24 +8,36 @@ export interface CachingOptions {
8
8
  /** Idle seconds a woken session is kept warm. 0 disables caching (pure passthrough). */
9
9
  ttlSec: number;
10
10
  sweepIntervalMs?: number;
11
- /** S25 (SILENT-FALLBACK P1): called when a cross-instance write (import/replace) evicts a WARM entry —
12
- * the affinity-contract violation signal that previously had zero fingerprint. */
13
- onConflictEvict?: (sessionId: string) => void;
11
+ /** Called once per warm hit that turned out stale (the store's head no longer names the cached leaf, or the session is
12
+ * gone): the entry was dropped and the session woken from the store again. Not an error — the operator signal for
13
+ * "requests for one session alternate between replicas within the TTL" (the hit rate affinity would buy back). */
14
+ onStaleHit?: (sessionId: string) => void;
14
15
  }
16
+ /** The store's current head for one session — the one read every warm hit is checked against (`undefined` = no such
17
+ * session). The same single-row read `OwnerAwareSessionStore.getHead` names; this cache requires its inner store to have it. */
18
+ export type SessionHeadReader = Required<Pick<OwnerAwareSessionStore, "getHead">>;
15
19
  /**
16
- * Warm/sticky cache in front of a durable {@link SessionStore} (Option A).
20
+ * Warm cache in front of a durable {@link SessionStore}.
17
21
  *
18
- * Keeps a woken session in memory and **reuses the same storage instance across turns**, so a hot
19
- * session is not re-read from TiDB every short turn (treats the cattle like a warm pet while it's
20
- * active). Idle sessions expire after `ttlSec`; `release` evicts immediately.
22
+ * Keeps a woken session in memory and **reuses the same storage instance across turns**, so an active session is not
23
+ * woken from the store (meta row + event log) on every short turn. Idle sessions expire after `ttlSec`; `release` evicts.
21
24
  *
22
- * ⚠️ **Session affinity is REQUIRED** when this cache is enabled (`SESSION_CACHE_TTL_SEC>0`): route a
23
- * given sessionId to the same instance. Under affinity the cached storage is the sole writer, so its
24
- * in-memory leaf is always current and consistent with TiDB (write-through; F2 CAS still applies).
25
- * Without affinity a concurrent write from another instance makes this cache stale; that surfaces as
26
- * a `SessionError("conflict")` on the next append — the async run driver calls `evict()` on conflict
27
- * (defense-in-depth) and the entry also expires after `ttlSec`, but those are backstops, not the
28
- * guarantee. If you cannot guarantee affinity, set `SESSION_CACHE_TTL_SEC=0` to disable the cache.
25
+ * **One rule: a warm entry is served only while the store's head for that session still names the leaf the entry
26
+ * holds.** Every hit reads the head first (`getHead` — one primary-key row read on the SQL twins); head gone or leaf
27
+ * moved ⇒ the entry is dropped and the session is woken from the store again ({@link CachingOptions.onStaleHit}). The
28
+ * leaf is the token because it is exactly what the stores' conditional writes compare (`expectedLeafId`): a handle this
29
+ * cache hands out is never behind the store's leaf, so those writes fail only on a genuinely concurrent write — two runs
30
+ * writing one session at the same time. Any number of replicas may share one store under any routing; session affinity
31
+ * only raises the hit rate.
32
+ *
33
+ * Open item (the same before this rule): a write that rewrites history while leaving the final leaf id where it was —
34
+ * a session-sync replace / `overwrite-dst` staged commit on ANOTHER replica that changes entry payloads under the same
35
+ * ids — moves no leaf, so a warm entry elsewhere keeps its old payloads until it expires or is evicted. Its conditional
36
+ * writes still succeed (the leaf matches); what is stale is the content of the entries under it. Closing it needs a
37
+ * store revision bumped by every write path (append, leaf move, replace, staged commit) and carried by the woken handle.
38
+ *
39
+ * Writes that go around the cached handle through THIS wrapper (import / replace / staged commit / delete) also drop
40
+ * the entry at the write site, so the next acquire wakes from the written state.
29
41
  *
30
42
  * Orthogonal to the F3 branch-floor change: floor bounds the *cold wake* size; this cache removes
31
43
  * *repeated* re-reads of an active session.
@@ -36,6 +48,7 @@ export declare class CachingSessionStore implements SessionStore {
36
48
  private readonly pending;
37
49
  private readonly ttlMs;
38
50
  private readonly timer?;
51
+ private readonly onStaleHit?;
39
52
  /** Delegated tenant-ownership ops, present only if the inner store exposes them (the TiDB store does). */
40
53
  readonly ownerOf?: (sessionId: string) => Promise<string | null | undefined>;
41
54
  /** S-790:属主 + 化身一次读,纯转发(不经暖缓存 —— 化身判的就是「此刻坐在座位上的是谁」,缓存的旧答案正是要消灭的那一形)。
@@ -76,11 +89,8 @@ export declare class CachingSessionStore implements SessionStore {
76
89
  /** E18 — delegate the leaf read straight to inner (a cache-bypassing single SELECT; the resume-at turn-capture
77
90
  * reads the DURABLE leaf, not a possibly-stale cached view — same discipline as the §0.5 repo ops above). */
78
91
  readonly getLeafId?: (sessionId: string) => Promise<string | null>;
79
- /** [ref] 六轮复审:owner+leafId 原子快照透传(cache-bypassing,getLeafId 同姿势)。 */
80
- readonly getHead?: (sessionId: string) => Promise<{
81
- owner: string | null;
82
- leafId: string | null;
83
- } | undefined>;
92
+ /** [ref] 六轮复审:owner+leafId 原子快照透传(cache-bypassing,getLeafId 同姿势)。内层店必有(本类的暖命中按它核新鲜)。 */
93
+ readonly getHead: SessionHeadReader["getHead"];
84
94
  /** 2c session-sync — delegated export/import seam, DURABLE (bypasses the warm cache exactly like
85
95
  * list/fork: an export reads the full durable log, an import writes durable state). */
86
96
  readonly exportEntries?: (sessionId: string) => Promise<SessionTreeEntry[] | null>;
@@ -99,7 +109,7 @@ export declare class CachingSessionStore implements SessionStore {
99
109
  * 转发白名单里 ⇒ 默认部署(SESSION_CACHE_TTL_SEC=300 把真店包进本类)PUSH Phase A 恒 501
100
110
  * `capability.session_store_required`,而 capabilities().sessionSync 谓词三项(backend/exportEntries/
101
111
  * fileHistoryStore)都被转发了 ⇒ 照报 true(能力面谎)。转发形:handle 的 `commit` 是唯一写真 id 的
102
- * 时刻,成功后逐出该 id 的暖 handle + S25 指纹(importEntries/replaceEntries 同纪律);`abort` 只丢
112
+ * 时刻,成功后逐出该 id 的暖 handle(importEntries/replaceEntries 同纪律);`abort` 只丢
103
113
  * staging 行、不碰真 id ⇒ 不逐出。 */
104
114
  readonly beginImportStaging?: (realSessionId: string, token: string) => StagingHandle;
105
115
  /** Staging 清扫面(kernel `ImportStagingStore.sweepStagingSessions`)。当下 reaper 走 `backend.session()`
@@ -136,11 +146,18 @@ export declare class CachingSessionStore implements SessionStore {
136
146
  scope?: string;
137
147
  }) => Promise<PlacedSessionRow[]>;
138
148
  readonly placementOf?: (sessionId: string) => Promise<SessionPlacementRecord | undefined>;
139
- constructor(inner: SessionStore, opts: CachingOptions);
149
+ constructor(inner: SessionStore & SessionHeadReader, opts: CachingOptions);
140
150
  acquire(sessionId?: string, opts?: PlacementAcquireOpts): Promise<AcquiredSession>;
141
151
  touch(sessionId: string): Promise<void>;
142
152
  release(sessionId: string): Promise<void>;
143
- /** Drop a session from the warm cache (e.g. after a cross-instance write conflict). */
153
+ /**
154
+ * The cache's one rule: the warm entry for `sessionId`, if the store's head still names the leaf it holds; otherwise
155
+ * the entry is dropped (counted once via `onStaleHit`) and `undefined` sends the caller to the store. The head is read
156
+ * first, the cached leaf second: a write this replica commits in between can only turn a fresh verdict into a stale
157
+ * one (an extra wake), never the reverse.
158
+ */
159
+ private freshHit;
160
+ /** Drop a session from the warm cache. */
144
161
  evict(sessionId: string): void;
145
162
  get size(): number;
146
163
  dispose(): void;
@@ -5,6 +5,7 @@ export class CachingSessionStore {
5
5
  pending = new Map();
6
6
  ttlMs;
7
7
  timer;
8
+ onStaleHit;
8
9
  ownerOf;
9
10
  sessionFaceOf;
10
11
  incarnationOf;
@@ -35,6 +36,8 @@ export class CachingSessionStore {
35
36
  constructor(inner, opts) {
36
37
  this.inner = inner;
37
38
  this.ttlMs = Math.max(0, opts.ttlSec) * 1000;
39
+ this.onStaleHit = opts.onStaleHit;
40
+ this.getHead = inner.getHead.bind(inner);
38
41
  const declared = inner.retention;
39
42
  if (declared !== undefined)
40
43
  this.retention = declared;
@@ -72,8 +75,6 @@ export class CachingSessionStore {
72
75
  this.fork = ownerAware.fork.bind(inner);
73
76
  if (ownerAware.getLeafId)
74
77
  this.getLeafId = ownerAware.getLeafId.bind(inner);
75
- if (ownerAware.getHead)
76
- this.getHead = ownerAware.getHead.bind(inner);
77
78
  if (ownerAware.exportEntries)
78
79
  this.exportEntries = ownerAware.exportEntries.bind(inner);
79
80
  if (ownerAware.listEntryIds)
@@ -88,8 +89,6 @@ export class CachingSessionStore {
88
89
  const imp = ownerAware.importEntries.bind(inner);
89
90
  this.importEntries = async (sessionId, owner, entries) => {
90
91
  await imp(sessionId, owner, entries);
91
- if (this.cache.has(sessionId))
92
- opts.onConflictEvict?.(sessionId);
93
92
  this.evict(sessionId);
94
93
  };
95
94
  }
@@ -97,8 +96,6 @@ export class CachingSessionStore {
97
96
  const rep = ownerAware.replaceEntries.bind(inner);
98
97
  this.replaceEntries = async (sessionId, owner, entries) => {
99
98
  await rep(sessionId, owner, entries);
100
- if (this.cache.has(sessionId))
101
- opts.onConflictEvict?.(sessionId);
102
99
  this.evict(sessionId);
103
100
  };
104
101
  }
@@ -112,8 +109,6 @@ export class CachingSessionStore {
112
109
  appendBatch: (entries) => h.appendBatch(entries),
113
110
  commit: async (owner, o) => {
114
111
  const r = await h.commit(owner, o);
115
- if (this.cache.has(realSessionId))
116
- opts.onConflictEvict?.(realSessionId);
117
112
  this.evict(realSessionId);
118
113
  return r;
119
114
  },
@@ -144,7 +139,7 @@ export class CachingSessionStore {
144
139
  this.store(fresh);
145
140
  return fresh;
146
141
  }
147
- const hit = this.cache.get(sessionId);
142
+ const hit = await this.freshHit(sessionId);
148
143
  if (hit && opts?.requireExisting !== true && this.placements !== undefined && this.placementOf !== undefined) {
149
144
  assertPlacementAdmission(sessionId, await this.placementOf(sessionId), opts);
150
145
  }
@@ -185,6 +180,18 @@ export class CachingSessionStore {
185
180
  this.cache.delete(sessionId);
186
181
  await this.inner.release(sessionId);
187
182
  }
183
+ async freshHit(sessionId) {
184
+ const hit = this.cache.get(sessionId);
185
+ if (hit === undefined)
186
+ return undefined;
187
+ const head = await this.inner.getHead(sessionId);
188
+ if (head !== undefined && head.leafId === (await hit.acquired.session.getLeafId()))
189
+ return hit;
190
+ if (this.cache.get(sessionId) === hit)
191
+ this.cache.delete(sessionId);
192
+ this.onStaleHit?.(sessionId);
193
+ return undefined;
194
+ }
188
195
  evict(sessionId) {
189
196
  this.cache.delete(sessionId);
190
197
  }
@@ -18,6 +18,9 @@
18
18
  * pair from case-sensitive Object.assign reaches CreateProcess as ONE undefined-which
19
19
  * entry). Canonical casing = the first-seen key (process.env's native casing wins since the
20
20
  * inherit base is spread first).
21
+ * - layering = env COMPOSITION is a pure function of config + process.env and never reads the resolved shell;
22
+ * the shell belongs to the SPAWN layer only ({@link pinHostShell}, applied by the spawn sites after
23
+ * `shellReady()`). A read surface (`cdPath`) therefore never depends on resolution having settled.
21
24
  *
22
25
  * ⚠️ The win32 branches are UNVERIFIED on a real machine until S5 (Windows CI runner) — design D5 discipline:
23
26
  * structural tests only on mac/Linux; behavior-level bite happens on the first Windows-runner green.
@@ -39,10 +42,22 @@ export interface HostShell {
39
42
  * read the cached value synchronously via {@link hostShell}.
40
43
  */
41
44
  export declare function resolveHostShell(): Promise<HostShell>;
42
- /** Synchronous read of the resolved shell — valid only AFTER {@link resolveHostShell} settled (the spawn sites
43
- * are all behind `shellReady()`, which awaits it). POSIX never needs the async step. Throws on a mis-ordered
44
- * call (a programming bug, not an operator condition — fail loud, not /bin/sh-on-windows). */
45
+ /** Synchronous read of the resolved shell — a SPAWN-layer read, valid only AFTER {@link resolveHostShell} settled
46
+ * (the spawn sites are all behind `shellReady()`, which awaits it). It is never an env-composition or read-surface
47
+ * input (see {@link pinHostShell}). POSIX never needs the async step.
48
+ * win32, not settled: a resolve that FAILED throws that failure (the real cause + the way out, memoised above);
49
+ * only a resolve that never ran / is still in flight reaches the ordering sentence — on the spawn paths that arm
50
+ * is unreachable (they await the resolve first), so it stays as the loud guard against a future mis-ordered caller
51
+ * (a programming bug, not an operator condition — fail loud, not /bin/sh-on-windows). */
45
52
  export declare function hostShell(): HostShell;
53
+ /**
54
+ * The SPAWN layer's one env edit: win32 pins `SHELL` to the resolved Git Bash (subshell scripts read `$SHELL`).
55
+ * Takes the {@link HostShell} the caller already read via {@link hostShell} — so a spawn site cannot reach the pin
56
+ * without a resolved shell (the type is the ordering guard), and env COMPOSITION never needs one. Mutates and
57
+ * returns `env` (call it BEFORE {@link collapseWin32EnvKeys} so an inherited case-variant `Shell` folds into one
58
+ * entry). POSIX: identity — `SHELL` is whatever the composed env already carried (byte-identical to pre-Windows).
59
+ */
60
+ export declare function pinHostShell(env: NodeJS.ProcessEnv, sh: HostShell): NodeJS.ProcessEnv;
46
61
  /** test-only: reset the shell caches (each test can re-drive resolution). */
47
62
  export declare function resetHostShellCacheForTest(): void;
48
63
  /** Per-platform spawn options for a group-killable child (design D3).