@sema-agent/server 7.51.0 → 7.52.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 (203) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/USAGE.md +103 -24
  4. package/dist/adoption/plan.d.ts +4 -4
  5. package/dist/adoption/plan.js +11 -0
  6. package/dist/approval-card.d.ts +26 -2
  7. package/dist/approval-card.js +9 -0
  8. package/dist/approval-hmac.d.ts +1 -1
  9. package/dist/approval-reconciler.d.ts +2 -2
  10. package/dist/approval-reconciler.js +1 -1
  11. package/dist/bench/l8/artifact.d.ts +1 -1
  12. package/dist/bench/s1/arms.d.ts +1 -1
  13. package/dist/bench/s1/live-deps.d.ts +1 -1
  14. package/dist/bench/s1/oracle.d.ts +1 -1
  15. package/dist/bench/s1/row.d.ts +1 -1
  16. package/dist/boot/budget-tracing.d.ts +1 -1
  17. package/dist/boot/budget-tracing.js +2 -5
  18. package/dist/boot/coordinators.js +3 -1
  19. package/dist/boot/deferred-sandbox-path-env.d.ts +1 -1
  20. package/dist/boot/device-lane.js +2 -0
  21. package/dist/boot/engine-lease.d.ts +1 -1
  22. package/dist/boot/execution-env.d.ts +1 -1
  23. package/dist/boot/governance-seams.d.ts +2 -2
  24. package/dist/boot/leader.d.ts +1 -1
  25. package/dist/boot/memory-boundary.d.ts +2 -2
  26. package/dist/boot/memory-consolidation.d.ts +6 -6
  27. package/dist/boot/org-memory.d.ts +1 -1
  28. package/dist/boot/parked-revive-gate.d.ts +3 -3
  29. package/dist/boot/reapers.d.ts +2 -2
  30. package/dist/boot/reapers.js +17 -4
  31. package/dist/boot/resolve-spec.d.ts +12 -0
  32. package/dist/boot/resolve-spec.js +29 -7
  33. package/dist/boot/retention-lane.d.ts +2 -2
  34. package/dist/boot/runner-deps.d.ts +17 -7
  35. package/dist/boot/runner-deps.js +4 -2
  36. package/dist/boot/runtime-caps.d.ts +23 -0
  37. package/dist/boot/runtime-caps.js +24 -2
  38. package/dist/boot/session-faces.d.ts +1 -1
  39. package/dist/boot/session-faces.js +14 -3
  40. package/dist/boot/stores.js +31 -1
  41. package/dist/boot/task-list-lane.d.ts +2 -2
  42. package/dist/brain.js +51 -31
  43. package/dist/budget.d.ts +2 -2
  44. package/dist/capabilities/center-plugins.d.ts +1 -1
  45. package/dist/capabilities/collab-workflows.d.ts +1 -1
  46. package/dist/capabilities/hands-lane.d.ts +3 -3
  47. package/dist/capabilities/scenarios.d.ts +3 -3
  48. package/dist/capabilities/skills.d.ts +1 -1
  49. package/dist/config-center/hot-keys-registry.js +5 -0
  50. package/dist/config-center/http-client.d.ts +2 -2
  51. package/dist/config-center/read-face.d.ts +1 -1
  52. package/dist/config-center/restart-signal.d.ts +1 -1
  53. package/dist/config-types.d.ts +100 -25
  54. package/dist/config.d.ts +11 -3
  55. package/dist/config.js +91 -15
  56. package/dist/deployment-governance.d.ts +1 -1
  57. package/dist/device-enrollment.d.ts +2 -2
  58. package/dist/device-store.d.ts +3 -3
  59. package/dist/device-ws-hub.d.ts +4 -2
  60. package/dist/device-ws-hub.js +9 -3
  61. package/dist/device-ws-protocol.d.ts +2 -2
  62. package/dist/env-facts.d.ts +1 -1
  63. package/dist/execution-lane-caps.d.ts +1 -1
  64. package/dist/fleet/fleet-bus.d.ts +5 -2
  65. package/dist/fleet/fleet-reconciler.d.ts +1 -1
  66. package/dist/fleet/fleet-terminal-window.d.ts +1 -1
  67. package/dist/hooks/hook-runner.d.ts +8 -1
  68. package/dist/http/active-run-conflict.d.ts +2 -2
  69. package/dist/http/principal-gate.d.ts +1 -1
  70. package/dist/http/route-ctx.d.ts +27 -5
  71. package/dist/http/routes/admin-memory-optout.d.ts +48 -0
  72. package/dist/http/routes/admin-memory-optout.js +75 -0
  73. package/dist/http/routes/agents-roster.d.ts +1 -1
  74. package/dist/http/routes/approvals-assistant.js +21 -21
  75. package/dist/http/routes/capabilities.js +20 -9
  76. package/dist/http/routes/diagnostics.d.ts +1 -1
  77. package/dist/http/routes/leader.d.ts +1 -1
  78. package/dist/http/routes/memory-bundle.d.ts +1 -1
  79. package/dist/http/routes/memory-bundle.js +2 -1
  80. package/dist/http/routes/memory-compliance.d.ts +4 -4
  81. package/dist/http/routes/memory-origin.d.ts +4 -4
  82. package/dist/http/routes/memory-origin.js +3 -1
  83. package/dist/http/routes/memory-policy.js +6 -2
  84. package/dist/http/routes/retention-ops.d.ts +1 -1
  85. package/dist/http/routes/rules.d.ts +5 -2
  86. package/dist/http/routes/rules.js +84 -5
  87. package/dist/http/routes/run-memory-capture.d.ts +34 -0
  88. package/dist/http/routes/run-memory-capture.js +97 -0
  89. package/dist/http/routes/runs.d.ts +1 -0
  90. package/dist/http/routes/runs.js +212 -6
  91. package/dist/http/routes/session-sync.d.ts +4 -6
  92. package/dist/http/routes/session-sync.js +60 -47
  93. package/dist/http/routes/sessions.js +61 -188
  94. package/dist/http/routes/shared-memory.d.ts +1 -1
  95. package/dist/http/routes/tasks.js +1 -1
  96. package/dist/http/routes/workflows.js +19 -0
  97. package/dist/http/server.d.ts +35 -13
  98. package/dist/http/server.js +110 -14
  99. package/dist/http/verify-rounds.d.ts +1 -1
  100. package/dist/http/wire-types.d.ts +41 -8
  101. package/dist/leader/endpoint.d.ts +1 -1
  102. package/dist/leader/leader.d.ts +1 -1
  103. package/dist/leader/repair-oracle.d.ts +2 -2
  104. package/dist/leader/repair-oracle.js +1 -1
  105. package/dist/main.js +14 -6
  106. package/dist/memory-bundle-engine.d.ts +1 -1
  107. package/dist/memory-operator-faces.d.ts +5 -5
  108. package/dist/memory-origin-fence.d.ts +35 -0
  109. package/dist/memory-origin-fence.js +66 -0
  110. package/dist/memory-posture.d.ts +13 -1
  111. package/dist/memory-posture.js +1 -0
  112. package/dist/memory-scope.d.ts +6 -1
  113. package/dist/memory-scope.js +3 -2
  114. package/dist/model-select.d.ts +2 -2
  115. package/dist/observability/fail-open.d.ts +17 -5
  116. package/dist/observability/fail-open.js +17 -5
  117. package/dist/orchestration/hardened-vm-runner.d.ts +2 -2
  118. package/dist/orchestration/workflow-completion-inbox.d.ts +2 -2
  119. package/dist/plugins/background-shell-support.d.ts +2 -2
  120. package/dist/plugins/blob-backend.d.ts +7 -2
  121. package/dist/plugins/blob-backend.js +22 -18
  122. package/dist/plugins/caching-session-store.d.ts +7 -3
  123. package/dist/plugins/checkpoint-store-sql.d.ts +2 -2
  124. package/dist/plugins/file-history-store-sql.d.ts +170 -0
  125. package/dist/plugins/file-history-store-sql.js +827 -0
  126. package/dist/plugins/file-run-store.d.ts +44 -5
  127. package/dist/plugins/file-run-store.js +85 -13
  128. package/dist/plugins/fork-routing-session-store.d.ts +1 -1
  129. package/dist/plugins/fork-routing-session-store.js +2 -2
  130. package/dist/plugins/leader-run-store-sql.d.ts +1 -1
  131. package/dist/plugins/local-session-store.d.ts +9 -1
  132. package/dist/plugins/memory-embedder-fingerprint.d.ts +1 -1
  133. package/dist/plugins/memory-optout-grant-store-sql.d.ts +139 -0
  134. package/dist/plugins/memory-optout-grant-store-sql.js +157 -0
  135. package/dist/plugins/memory-origin-law.d.ts +2 -2
  136. package/dist/plugins/memory-run-store.d.ts +4 -3
  137. package/dist/plugins/memory-run-store.js +2 -1
  138. package/dist/plugins/permission-rule-store-file.d.ts +16 -6
  139. package/dist/plugins/permission-rule-store-file.js +77 -13
  140. package/dist/plugins/permission-rule-store-sql.d.ts +156 -8
  141. package/dist/plugins/permission-rule-store-sql.js +123 -21
  142. package/dist/plugins/pg-pool.js +59 -0
  143. package/dist/plugins/pg-safe-json.d.ts +1 -1
  144. package/dist/plugins/remote-env-device.d.ts +1 -1
  145. package/dist/plugins/remote-env-k8s.d.ts +1 -1
  146. package/dist/plugins/retention-lane-store-sql.d.ts +3 -3
  147. package/dist/plugins/retention-store-sql.d.ts +7 -7
  148. package/dist/plugins/retention-store-sql.js +6 -0
  149. package/dist/plugins/run-store-sql.d.ts +9 -3
  150. package/dist/plugins/run-store-sql.js +2 -2
  151. package/dist/plugins/send-user-file.d.ts +1 -1
  152. package/dist/plugins/session-placement.d.ts +1 -1
  153. package/dist/plugins/sql-errors.d.ts +1 -1
  154. package/dist/plugins/store-backend.d.ts +59 -29
  155. package/dist/plugins/store-backend.js +75 -15
  156. package/dist/plugins/store-contracts.d.ts +5 -3
  157. package/dist/plugins/task-list-store-sql.d.ts +1 -1
  158. package/dist/plugins/tidb-pool.js +59 -0
  159. package/dist/plugins/web-search.d.ts +1 -1
  160. package/dist/prompts-domain-validate.js +1 -1
  161. package/dist/resource-suspend.d.ts +1 -1
  162. package/dist/resource-window.d.ts +3 -3
  163. package/dist/rules-consent.d.ts +231 -9
  164. package/dist/rules-consent.js +411 -13
  165. package/dist/run-local.js +29 -4
  166. package/dist/runs.d.ts +15 -1
  167. package/dist/runs.js +19 -0
  168. package/dist/runtime-caps-resolver.d.ts +36 -1
  169. package/dist/runtime-caps-resolver.js +25 -0
  170. package/dist/runtime-governance.d.ts +1 -1
  171. package/dist/security.d.ts +5 -5
  172. package/dist/security.js +1 -1
  173. package/dist/session-sync.d.ts +52 -37
  174. package/dist/session-sync.js +19 -16
  175. package/dist/shared-memory-scope-authorizer.d.ts +1 -1
  176. package/dist/sighup-idle.d.ts +1 -1
  177. package/dist/spec-fields.d.ts +22 -1
  178. package/dist/spec-fields.js +11 -0
  179. package/dist/store-live-probe.d.ts +2 -2
  180. package/dist/task-a2a.d.ts +1 -1
  181. package/dist/task-cwd.d.ts +1 -1
  182. package/dist/task-mcp.d.ts +3 -3
  183. package/dist/task-settings.d.ts +3 -3
  184. package/dist/task-workflow.d.ts +1 -1
  185. package/dist/text-bidi.d.ts +2 -2
  186. package/dist/tool-approval.d.ts +191 -9
  187. package/dist/tool-approval.js +248 -29
  188. package/dist/trace/core-keyset-guard.d.ts +3 -3
  189. package/dist/trace/engine-notice-wire.d.ts +6 -4
  190. package/dist/trace/engine-notice-wire.js +2 -0
  191. package/dist/trace/ledger-events.d.ts +1 -1
  192. package/dist/trace/ledger-sink.d.ts +2 -2
  193. package/dist/trace/project.d.ts +8 -2
  194. package/dist/trace/project.js +10 -0
  195. package/dist/turn-activity.d.ts +1 -1
  196. package/dist/wall-clock-jump-guard.d.ts +3 -3
  197. package/package.json +3 -3
  198. package/dist/http/tar.d.ts +0 -17
  199. package/dist/http/tar.js +0 -53
  200. package/dist/http/workspace-content.d.ts +0 -11
  201. package/dist/http/workspace-content.js +0 -20
  202. package/dist/plugins/file-snapshot-store-sql.d.ts +0 -170
  203. package/dist/plugins/file-snapshot-store-sql.js +0 -213
@@ -15,7 +15,7 @@ import { scopedIdempotencyKey } from "../idempotency.js";
15
15
  import { streamSseLog } from "../sse-log.js";
16
16
  import { bindSseLifecycle } from "../sse-lifecycle.js";
17
17
  import { buildApprovalPreamble, buildApprovalPreambleSseFrames } from "../../approval-card.js";
18
- import { resolveStreamApprovalGate } from "../../tool-approval.js";
18
+ import { ASK_DECISION_CONSUMER_ABSENT, resolveStreamApprovalGate } from "../../tool-approval.js";
19
19
  import { MAX_DECISION_NOTE_CHARS } from "../../approval-ask-machine.js";
20
20
  import { normalizeRunEventType } from "../../trace/project.js";
21
21
  import { sendJson, sendError, httpErrorCode, sseHeaders } from "../send.js";
@@ -30,6 +30,7 @@ export const RUN_CANCEL_RE = /^\/v1\/runs\/([^/]+)\/cancel$/;
30
30
  export const RUN_STEER_RE = /^\/v1\/runs\/([^/]+)\/steer$/;
31
31
  export const RUN_DETACH_RE = /^\/v1\/runs\/([^/]+)\/detach$/;
32
32
  export const RUN_COMPACT_RE = /^\/v1\/runs\/([^/]+)\/compact$/;
33
+ export const RUN_INTERRUPT_RE = /^\/v1\/runs\/([^/]+)\/interrupt$/;
33
34
  export const ELICIT_RESPOND_RE = /^\/v1\/elicitations\/([^/]+)\/respond$/;
34
35
  export const QUESTION_RESPOND_RE = /^\/v1\/questions\/([^/]+)\/respond$/;
35
36
  export const TOOL_APPROVAL_RESPOND_RE = /^\/v1\/tool-approvals\/([^/]+)\/respond$/;
@@ -51,6 +52,20 @@ const AskDecisionBodySchema = z
51
52
  actor: z.object({ label: z.string().max(ASK_ACTOR_LABEL_MAX) }).strict().optional(),
52
53
  })
53
54
  .strict();
55
+ const ASK_DECISION_CONFIRM_TIMEOUT_MS = 2_000;
56
+ function withDeadline(op, timeoutMs, label) {
57
+ let timer;
58
+ return Promise.race([
59
+ op,
60
+ new Promise((_resolve, reject) => {
61
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
62
+ timer.unref?.();
63
+ }),
64
+ ]).finally(() => {
65
+ if (timer !== undefined)
66
+ clearTimeout(timer);
67
+ });
68
+ }
54
69
  export function projectDecisionActor(raw) {
55
70
  const parsed = AskDecisionActorSchema.safeParse(raw);
56
71
  return parsed.success ? parsed.data : undefined;
@@ -288,7 +303,7 @@ async function handleRunsBody(req, res, url, ctx, miss) {
288
303
  }
289
304
  }
290
305
  else {
291
- const claimed = await rs.markResuming(taskId);
306
+ const claimed = await rs.markResuming(taskId, deps.instanceId ?? "default");
292
307
  if (!claimed) {
293
308
  const now = await rs.getRun(taskId);
294
309
  const st = now?.status;
@@ -387,7 +402,7 @@ async function handleRunsBody(req, res, url, ctx, miss) {
387
402
  const taskId = steerMatch[1];
388
403
  let body;
389
404
  try {
390
- body = (await readJson(req));
405
+ body = ((await readJson(req)) ?? {});
391
406
  }
392
407
  catch (e) {
393
408
  if (e instanceof HttpError) {
@@ -482,6 +497,10 @@ async function handleRunsBody(req, res, url, ctx, miss) {
482
497
  sendError(res, 422, "steering.invalid_content", e instanceof Error ? e.message : "invalid steering content");
483
498
  return;
484
499
  }
500
+ if (code === "steering.blocked_by_hook") {
501
+ sendError(res, 422, "steering.blocked_by_hook", e instanceof Error ? e.message : "the deployment's userPromptSubmit hook refused this steering input (fail-closed)");
502
+ return;
503
+ }
485
504
  if (code === "steering.duplicate_input_id") {
486
505
  parkDuplicateKey = e instanceof Error ? e.message : undefined;
487
506
  sendDuplicateKey();
@@ -529,7 +548,13 @@ async function handleRunsBody(req, res, url, ctx, miss) {
529
548
  return;
530
549
  }
531
550
  if (run.status === "running") {
532
- sendNotRunning("run is active on another replica cross-replica live-steer is not yet supported");
551
+ const provenElsewhere = typeof run.instanceId === "string" && run.instanceId.length > 0 && deps.instanceId !== undefined && run.instanceId !== deps.instanceId;
552
+ if (provenElsewhere) {
553
+ sendNotRunning("run is live on another replica — a live steer must reach the replica holding the stream; resend it there, or use POST /v1/runs/:id/cancel for a run-level stop");
554
+ }
555
+ else {
556
+ sendNotRunning("no live steerable face for this run on this replica — verify/cascade execution lanes expose none (those legs return a result, not a live stream); use POST /v1/runs/:id/cancel for a run-level stop");
557
+ }
533
558
  return;
534
559
  }
535
560
  if (deps.checkpointStore && deps.sessionStorage?.getLeafId) {
@@ -639,6 +664,152 @@ async function handleRunsBody(req, res, url, ctx, miss) {
639
664
  sendNotRunning(`run is ${run.status} — not accepting steers`);
640
665
  return;
641
666
  }
667
+ const interruptMatch = req.method === "POST" ? RUN_INTERRUPT_RE.exec(url) : null;
668
+ if (interruptMatch) {
669
+ if (rateLimited(req, res) || quotaExceeded(req, res) || (await leaseDenied(req, res)))
670
+ return;
671
+ if (!deps.runStore) {
672
+ sendError(res, 501, "capability.run_store_required", "async runs require a durable run store (DB_BACKEND=mysql|pg|local)");
673
+ return;
674
+ }
675
+ const principal = gatedPrincipal(req, deps.config);
676
+ if (deps.config.requirePrincipal && principal === undefined) {
677
+ sendError(res, 401, "auth.principal_required", `missing principal header '${deps.config.principalHeader}'`);
678
+ return;
679
+ }
680
+ const taskId = interruptMatch[1];
681
+ let interruptRaw;
682
+ try {
683
+ interruptRaw = await readJson(req);
684
+ }
685
+ catch (e) {
686
+ if (e instanceof HttpError) {
687
+ sendError(res, e.status, httpErrorCode(e.status, e.code), e.message);
688
+ return;
689
+ }
690
+ sendError(res, 400, "request.invalid_json", "invalid JSON body");
691
+ return;
692
+ }
693
+ const interruptNormalized = interruptRaw === null || interruptRaw === undefined ? {} : interruptRaw;
694
+ if (typeof interruptNormalized !== "object" || Array.isArray(interruptNormalized)) {
695
+ sendError(res, 400, "request.body_shape", "body must be {} (bare halt: cut the in-flight turn and stop the run at this boundary) or { text: string (non-empty) } (cut + steer: the text reaches the model after the cut)");
696
+ return;
697
+ }
698
+ const interruptBody = interruptNormalized;
699
+ const interruptBare = !("text" in interruptBody);
700
+ if (interruptBare && Object.keys(interruptBody).length > 0) {
701
+ sendError(res, 400, "request.body_shape", "body must be {} (bare halt: cut the in-flight turn and stop the run at this boundary) or { text: string (non-empty) } (cut + steer: the text reaches the model after the cut)");
702
+ return;
703
+ }
704
+ let text = "";
705
+ if (!interruptBare) {
706
+ if (typeof interruptBody.text !== "string" || interruptBody.text.length === 0) {
707
+ sendError(res, 400, "request.body_shape", "body must be {} (bare halt: cut the in-flight turn and stop the run at this boundary) or { text: string (non-empty) } (cut + steer: the text reaches the model after the cut)");
708
+ return;
709
+ }
710
+ text = interruptBody.text;
711
+ }
712
+ const interruptIdemRaw = headerStr(req.headers["idempotency-key"]);
713
+ const interruptScoped = interruptIdemRaw ? scopedIdempotencyKey(interruptIdemRaw, source, principal) : undefined;
714
+ const interruptInputId = interruptScoped ? `idem-${createHash("sha256").update(interruptScoped).digest("hex").slice(0, 32)}` : uuidv7();
715
+ const interruptMessageId = interruptInputId;
716
+ const trusted = explicitOperatorOk(principal, deps.config.operatorPrincipals);
717
+ if (!interruptBare) {
718
+ try {
719
+ validatePendingSteer({ text, trusted, inputId: interruptInputId });
720
+ }
721
+ catch (e) {
722
+ if (e instanceof CheckpointError && e.code === "steering.invalid_content") {
723
+ sendError(res, 422, "steering.invalid_content", e.message);
724
+ return;
725
+ }
726
+ throw e;
727
+ }
728
+ }
729
+ const run = await deps.runStore.getRun(taskId);
730
+ if (!run) {
731
+ sendError(res, 404, "not_found.run", runNotFoundMessage(taskId));
732
+ return;
733
+ }
734
+ if (!trusted && run.owner !== null && run.owner !== principal) {
735
+ sendError(res, 404, "not_found.run", RUN_NOT_FOUND_MESSAGE);
736
+ return;
737
+ }
738
+ if (!runSessionAcceptOk(req, res, run, "run.interrupt"))
739
+ return;
740
+ const sendInterruptNotRunning = (error) => sendError(res, 409, "steering.not_running", error);
741
+ const sendNoLiveTurn = (status, streamJustSettled) => {
742
+ if (isParkedRunStatus(status)) {
743
+ sendError(res, 409, "interrupt.nothing_in_flight", "run is parked on a pending decision — there is no in-flight turn to interrupt; to deliver input use POST /v1/runs/:id/steer (it parks onto the checkpoint and injects on resume)");
744
+ return;
745
+ }
746
+ if (streamJustSettled) {
747
+ sendInterruptNotRunning("run just finished on this replica — no in-flight turn to interrupt");
748
+ return;
749
+ }
750
+ if (status === "running") {
751
+ const provenElsewhere = typeof run.instanceId === "string" && run.instanceId.length > 0 && deps.instanceId !== undefined && run.instanceId !== deps.instanceId;
752
+ if (provenElsewhere) {
753
+ sendError(res, 409, "interrupt.not_held", "run is live on another replica — interrupt must reach the replica holding the stream; resend it there, or use POST /v1/runs/:id/cancel for a run-level stop");
754
+ }
755
+ else {
756
+ sendError(res, 409, "interrupt.not_held", "no live interruptible turn face for this run on this replica — verify/cascade execution lanes expose none (those legs return a result, not a live stream); use POST /v1/runs/:id/cancel for a run-level stop");
757
+ }
758
+ return;
759
+ }
760
+ sendInterruptNotRunning(`run is ${status} — not accepting an interrupt`);
761
+ };
762
+ const liveStream = steerableRuns.get(taskId);
763
+ if (!liveStream) {
764
+ sendNoLiveTurn(run.status, false);
765
+ return;
766
+ }
767
+ if (interruptBare) {
768
+ let haltReceipt;
769
+ try {
770
+ haltReceipt = await liveStream.halt();
771
+ }
772
+ catch (e) {
773
+ if (e.code !== "steering.not_running")
774
+ throw e;
775
+ const freshRun = await deps.runStore.getRun(taskId).catch(() => undefined);
776
+ sendNoLiveTurn(freshRun?.status ?? run.status, true);
777
+ return;
778
+ }
779
+ sendJson(res, 202, {
780
+ taskId,
781
+ status: run.status,
782
+ turnCut: haltReceipt.turnCut,
783
+ note: "halt accepted — cut + stop: turnCut reports whether THIS call cut a live turn (repeat calls answer turnCut:false once the stop is latched). The run now ends at this boundary as status:\"completed\" with haltedByUser:true — resume by submitting the next objective on the same session. Each real cut is marked by a task.turn_interrupted event with detail.cause:\"user_halt\"; a halt racing the run's natural last moments reports the natural completion, still carrying the haltedByUser seat.",
784
+ });
785
+ return;
786
+ }
787
+ try {
788
+ await liveStream.steer(text, { trusted, priority: "now", ...(interruptInputId !== undefined ? { inputId: interruptInputId } : {}) });
789
+ }
790
+ catch (e) {
791
+ const code = e.code;
792
+ if (code === "steering.invalid_content") {
793
+ sendError(res, 422, "steering.invalid_content", e instanceof Error ? e.message : "invalid steering content");
794
+ return;
795
+ }
796
+ if (code === "steering.blocked_by_hook") {
797
+ sendError(res, 422, "steering.blocked_by_hook", e instanceof Error ? e.message : "the deployment's userPromptSubmit hook refused this input (fail-closed)");
798
+ return;
799
+ }
800
+ if (code === "steering.duplicate_input_id") {
801
+ sendError(res, 409, "steering.duplicate_input_id", e instanceof Error ? e.message : "this Idempotency-Key was already accepted with different content or at a different tier — reissue with a fresh key");
802
+ return;
803
+ }
804
+ if (code !== "steering.not_running")
805
+ throw e;
806
+ const freshRun = await deps.runStore.getRun(taskId).catch(() => undefined);
807
+ sendNoLiveTurn(freshRun?.status ?? run.status, true);
808
+ return;
809
+ }
810
+ sendJson(res, 202, { taskId, status: run.status, delivery: "queued-immediate", messageId: interruptMessageId, note: "interrupt accepted — the cut is best-effort: a task.turn_interrupted event on the run stream is the proof a turn was actually cut; with nothing in flight the text lands at the next turn boundary" });
811
+ return;
812
+ }
642
813
  const compactMatch = req.method === "POST" ? RUN_COMPACT_RE.exec(url) : null;
643
814
  if (compactMatch) {
644
815
  if (rateLimited(req, res) || quotaExceeded(req, res) || (await leaseDenied(req, res)))
@@ -722,7 +893,7 @@ async function handleRunsBody(req, res, url, ctx, miss) {
722
893
  const taskId = detachMatch[1];
723
894
  let body;
724
895
  try {
725
- body = (await readJson(req));
896
+ body = ((await readJson(req)) ?? {});
726
897
  }
727
898
  catch (e) {
728
899
  if (e instanceof HttpError) {
@@ -945,7 +1116,7 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
945
1116
  }
946
1117
  let body;
947
1118
  try {
948
- body = (await readJson(req));
1119
+ body = ((await readJson(req)) ?? {});
949
1120
  }
950
1121
  catch (e) {
951
1122
  if (e instanceof HttpError) {
@@ -1168,10 +1339,16 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
1168
1339
  }
1169
1340
  return true;
1170
1341
  };
1342
+ const observedSettled = new Map();
1171
1343
  const syncLiveFromDecidedRow = (row) => {
1172
1344
  if (!decidedRowIsAuthoritative(row))
1173
1345
  return false;
1346
+ if (observedSettled.has(row.askId))
1347
+ return true;
1348
+ if (deps.toolApproval?.editedDecisionInFlight(row.askId) === true)
1349
+ return true;
1174
1350
  const settled = deps.toolApproval?.notifyExternalDecision(row.askId, row.decision === "approve");
1351
+ observedSettled.set(row.askId, settled?.settled ?? 0);
1175
1352
  if (row.decision === "approve" && (settled?.settled ?? 0) > 0) {
1176
1353
  recordFailOpen("server.hitl.row-derived-approve-drops-updated-input", "runs:sync-live-from-decided-row");
1177
1354
  }
@@ -1254,7 +1431,13 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
1254
1431
  sendTerminal(row0);
1255
1432
  return;
1256
1433
  }
1434
+ if (body.decision === "approve" && hasUpdatedInput && (deps.toolApproval?.liveConsumerCount(askId) ?? 0) === 0) {
1435
+ deps.logger?.warn?.("ask_decision_edit_undeliverable", { taskId, askId });
1436
+ sendError(res, 409, "conflict.ask_edit_undeliverable", "this decision carries an edited input but no live approval waiter for this ask exists on THIS replica — the edit cannot be bound to the decision (the durable ask row has no field for it), so committing would run the ORIGINAL input instead. Nothing was decided: re-send without updatedInput to approve the original as-is, or send the decision to the replica holding this ask (the live POST /v1/tool-approvals/:approvalId/respond leg is always same-replica)");
1437
+ return;
1438
+ }
1257
1439
  let outcome;
1440
+ const releaseEditMark = body.decision === "approve" && hasUpdatedInput ? deps.toolApproval?.markEditedDecisionInFlight(askId) : undefined;
1258
1441
  try {
1259
1442
  outcome = await askStore.decideAsk(askId, row0.batchId, {
1260
1443
  decision: body.decision,
@@ -1270,6 +1453,25 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
1270
1453
  hadIdempotencyKey: body.idempotencyKey !== undefined,
1271
1454
  error: err instanceof Error ? err.message : String(err ?? ""),
1272
1455
  });
1456
+ if (releaseEditMark !== undefined &&
1457
+ body.idempotencyKey !== undefined &&
1458
+ deps.toolApproval &&
1459
+ deps.toolApproval.editedDecisionInFlightCount(askId) === 1) {
1460
+ try {
1461
+ const confirmed = await withDeadline(askStore.getAsk(askId), ASK_DECISION_CONFIRM_TIMEOUT_MS, "getAsk(ambiguous-commit-confirm)");
1462
+ if (confirmed !== null &&
1463
+ confirmed.state === "DECIDED" &&
1464
+ confirmed.decision === body.decision &&
1465
+ confirmed.idempotencyKey === body.idempotencyKey &&
1466
+ !confirmed.provisional) {
1467
+ const settledNow = deps.toolApproval.notifyExternalDecision(askId, confirmed.decision === "approve", body.updatedInput).settled;
1468
+ deps.logger?.warn?.("ask_decision_edit_settled_after_ambiguous_commit", { taskId, askId, settled: settledNow });
1469
+ }
1470
+ }
1471
+ catch (confirmErr) {
1472
+ deps.logger?.warn?.("ask_decision_edit_confirm_failed", { taskId, askId, error: confirmErr instanceof Error ? confirmErr.message : String(confirmErr ?? "") });
1473
+ }
1474
+ }
1273
1475
  if (body.idempotencyKey !== undefined) {
1274
1476
  sendError(res, 500, "internal.error", "the ask decision store failed around commit — the decision MAY already be persisted; retry with the same idempotencyKey to replay it instead of colliding with it");
1275
1477
  }
@@ -1278,6 +1480,9 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
1278
1480
  }
1279
1481
  return;
1280
1482
  }
1483
+ finally {
1484
+ releaseEditMark?.();
1485
+ }
1281
1486
  if (!outcome.ok) {
1282
1487
  if (outcome.row?.state === "DECIDED" && !syncLiveFromDecidedRow(outcome.row))
1283
1488
  return;
@@ -1311,6 +1516,7 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
1311
1516
  decidedAtMs: outcome.row.decidedAtMs,
1312
1517
  actor,
1313
1518
  ...(forwarded ? { updatedInputForwarded: true } : {}),
1519
+ ...(settled === 0 ? { consumerState: ASK_DECISION_CONSUMER_ABSENT } : {}),
1314
1520
  ...decisionNoteEcho(outcome.row),
1315
1521
  });
1316
1522
  return;
@@ -8,7 +8,7 @@
8
8
  * `pendingStagings`(Phase A→B 的 staging 绑定)、`importLeases`(per-session 导入租约)与它的两个操作。
9
9
  */
10
10
  import type { IncomingMessage, ServerResponse } from "node:http";
11
- import { type SessionRulesRecord } from "@sema-agent/core";
11
+ import { type SessionRulesRecord, type FileHistoryExport } from "@sema-agent/core";
12
12
  import { type StagingHandle } from "../../session-sync-kernel.js";
13
13
  import type { RouteCtx } from "../route-ctx.js";
14
14
  export type PendingStaging = {
@@ -17,17 +17,15 @@ export type PendingStaging = {
17
17
  owner: string | null;
18
18
  resolution?: "overwrite-dst";
19
19
  operatorOk: boolean;
20
- snapshots: Array<{
21
- key: string;
22
- manifest: Array<[string, string]>;
23
- }>;
20
+ /** S-15: the session's file-history envelope (or null — no history travelled). Replaces the E19 per-key manifests. */
21
+ fileHistory: FileHistoryExport | null;
24
22
  policy: SessionRulesRecord[];
25
23
  anchors: Array<{
26
24
  eventId: string;
27
25
  entryId: string;
28
26
  owner: string | null;
29
27
  }>;
30
- /** 对抗复查 A-3(HIGH):Phase A's DECLARED entry-id set — Phase B verifies the streamed NDJSON matches it
28
+ /** 交叉复查 A-3(HIGH):Phase A's DECLARED entry-id set — Phase B verifies the streamed NDJSON matches it
31
29
  * (a different tree under the same staging would otherwise commit while the parked snapshots/anchors
32
30
  * still reference the DECLARED one). */
33
31
  declaredEntryIds: string[];
@@ -1,8 +1,8 @@
1
1
  import { once } from "node:events";
2
2
  import { randomBytes, createHash } from "node:crypto";
3
- import { sessionLogDigest, sessionLogDigestsComparable, StreamingImportValidator, SessionError, SessionPolicyError } from "@sema-agent/core";
3
+ import { sessionLogDigest, sessionLogDigestsComparable, StreamingImportValidator, SessionError, SessionPolicyError, validateFileHistoryExport } from "@sema-agent/core";
4
4
  import { StringDecoder } from "node:string_decoder";
5
- import { exportSessionManifest, fileSnapshotImportFace, replayPolicyRecords, overwriteWipeIncapable, wipeSessionAttendants } from "../../session-sync.js";
5
+ import { exportSessionManifest, fileHistoryImportFace, replayFileHistory, replayPolicyRecords, overwriteWipeIncapable, wipeSessionAttendants } from "../../session-sync.js";
6
6
  import { classifySyncRelationshipByIds, SyncConflictError } from "../../session-sync-kernel.js";
7
7
  import { isUuidShape } from "../../security.js";
8
8
  import { sendJson, sendError, msg } from "../send.js";
@@ -37,7 +37,7 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
37
37
  const { readJson, readRawBody, safeDecode, sessionOwnerScopeForWrite, rateLimited } = ctx.helpers;
38
38
  const { pendingStagings, importLeases, touchImportLease, cleanupStaging } = ctx.local.sessionSync;
39
39
  {
40
- const sm = /^\/v1\/sessions\/([^/]+)\/sync\/(manifest|entries|import|plan|blobs(?:\/([^/]+))?|snapshots\/([^/]+)\/blobs\/([^/]+)|import\/([^/]+)\/entries)$/.exec(url);
40
+ const sm = /^\/v1\/sessions\/([^/]+)\/sync\/(manifest|entries|import|plan|blobs(?:\/([^/]+))?|history\/blobs\/([^/]+)|import\/([^/]+)\/entries)$/.exec(url);
41
41
  if (sm) {
42
42
  const ownerOf = deps.sessionStorage?.ownerOf?.bind(deps.sessionStorage);
43
43
  if (!deps.backend || !ownerOf) {
@@ -130,23 +130,27 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
130
130
  if (sm[4] !== undefined && req.method === "GET") {
131
131
  if (!pullGateOk())
132
132
  return;
133
- const key = safeDecode(sm[4]);
134
- const hash = safeDecode(sm[5]);
135
- if (key === null || hash === null) {
136
- sendError(res, 400, "request.path_malformed", "invalid snapshot key or blob hash");
133
+ const hash = safeDecode(sm[4]);
134
+ if (hash === null) {
135
+ sendError(res, 400, "request.path_malformed", "invalid blob hash");
137
136
  return;
138
137
  }
139
138
  if (!SHA256_HEX_RE.test(hash)) {
140
139
  sendError(res, 400, "request.id_invalid", "blob hash must be 64 lowercase-hex chars");
141
140
  return;
142
141
  }
143
- const fs = deps.fileSnapshotStore;
144
- const manifest = await fs?.exportManifest?.(sessionId, key);
145
- if (!manifest || ![...manifest.values()].includes(hash)) {
142
+ const fs = deps.fileHistoryStore;
143
+ if (fs?.syncBlobFaces !== true) {
146
144
  sendError(res, 404, "not_found.blob", "blob not found");
147
145
  return;
148
146
  }
149
- const bytes = await fs?.getBlob?.(hash);
147
+ const envelope = typeof fs.exportHistory === "function" ? await fs.exportHistory(sessionId) : null;
148
+ const referenced = envelope !== null && envelope !== undefined && envelope.tracked.some((t) => t.versions.some((v) => v.blobHash === hash));
149
+ if (!referenced) {
150
+ sendError(res, 404, "not_found.blob", "blob not found");
151
+ return;
152
+ }
153
+ const bytes = await fs.getBlob?.(hash);
150
154
  if (!bytes) {
151
155
  sendError(res, 404, "not_found.blob", "blob not found");
152
156
  return;
@@ -165,9 +169,9 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
165
169
  sendError(res, 400, "request.id_invalid", "blob hash must be 64 lowercase-hex chars");
166
170
  return;
167
171
  }
168
- const fs = deps.fileSnapshotStore;
169
- if (typeof fs?.putBlob !== "function") {
170
- sendError(res, 501, "capability.snapshot_store_required", "blob upload requires a durable snapshot store");
172
+ const fs = deps.fileHistoryStore;
173
+ if (fs?.syncBlobFaces !== true || typeof fs.putBlob !== "function") {
174
+ sendError(res, 501, "capability.snapshot_store_required", "blob upload requires a durable file-history store");
171
175
  return;
172
176
  }
173
177
  const body = await readRawBody(req, SYNC_BLOB_MAX_BYTES);
@@ -215,11 +219,19 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
215
219
  sendError(res, 400, "request.field_invalid", "entryIds must be an array of strings");
216
220
  return;
217
221
  }
218
- const snapshotsOk = Array.isArray(body.snapshots) && body.snapshots.every((s) => typeof s === "object" && s !== null && typeof s.key === "string" && Array.isArray(s.manifest));
219
- if (!snapshotsOk || !Array.isArray(body.policy) || !Array.isArray(body.anchors)) {
222
+ if (!Array.isArray(body.policy) || !Array.isArray(body.anchors)) {
220
223
  sendError(res, 400, "request.body_shape", "malformed import metadata");
221
224
  return;
222
225
  }
226
+ let fileHistory = null;
227
+ if (body.fileHistory !== undefined && body.fileHistory !== null) {
228
+ const door = validateFileHistoryExport(body.fileHistory);
229
+ if (!door.ok) {
230
+ sendError(res, 400, "request.field_invalid", `fileHistory: ${door.error.message}`);
231
+ return;
232
+ }
233
+ fileHistory = body.fileHistory;
234
+ }
223
235
  if (body.resolution !== undefined && body.resolution !== "overwrite-dst") {
224
236
  sendError(res, 400, "request.field_invalid", "resolution must be 'overwrite-dst'");
225
237
  return;
@@ -227,7 +239,6 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
227
239
  const resolution = body.resolution;
228
240
  const srcLogDigest = typeof body.logDigest === "string" && body.logDigest.length > 0 ? body.logDigest : undefined;
229
241
  const entryIds = body.entryIds;
230
- const snapshots = body.snapshots;
231
242
  const policy = body.policy;
232
243
  const anchors = body.anchors;
233
244
  const dstIds = (await deps.sessionStorage.listEntryIds?.(sessionId)) ?? null;
@@ -251,16 +262,21 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
251
262
  digestSaysDiffer = true;
252
263
  }
253
264
  if (relA.relation === "identical" && !digestSaysDiffer) {
254
- const importFaceIdentical = fileSnapshotImportFace(deps.fileSnapshotStore);
255
- if (importFaceIdentical && deps.fileSnapshotStore?.getBlob && snapshots.length > 0) {
256
- const getBlobA = (h) => deps.fileSnapshotStore.getBlob(h);
257
- for (const snap of snapshots) {
258
- const r = await importFaceIdentical.importManifest(sessionId, snap.key, new Map(snap.manifest), getBlobA);
259
- if (!r.ok) {
260
- sendError(res, 422, r.error.code === "restore_failed" ? "restore_failed" : "missing_blob", `snapshot import failed (${r.error.code})`);
265
+ if (fileHistory !== null && deps.fileHistoryStore?.syncBlobFaces === true && fileHistoryImportFace(deps.fileHistoryStore) && deps.fileHistoryStore.getBlob) {
266
+ if (resolution === "overwrite-dst") {
267
+ const activeNow = await deps.runStore?.getActiveTaskId(sessionId);
268
+ if (activeNow) {
269
+ sendError(res, 409, "session_active", "session has an active run; sync after it settles", { activeTaskId: activeNow });
261
270
  return;
262
271
  }
263
272
  }
273
+ const getBlobA = (h) => deps.fileHistoryStore.getBlob(h);
274
+ const r = await replayFileHistory(deps.fileHistoryStore, sessionId, fileHistory, getBlobA, { allowReplace: resolution === "overwrite-dst" });
275
+ const failCode = r.ok ? undefined : r.error.code === "not_found" ? "missing_blob" : r.error.code;
276
+ if (!r.ok) {
277
+ sendError(res, 422, failCode, `file history import failed (${r.error.code})`);
278
+ return;
279
+ }
264
280
  }
265
281
  const opOk = explicitOperatorOk(gatedPrincipal(req, deps.config), deps.config.operatorPrincipals);
266
282
  if (deps.sessionPolicyStore)
@@ -299,13 +315,13 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
299
315
  return;
300
316
  }
301
317
  }
302
- const fss = deps.fileSnapshotStore;
303
- const canImportSnapshots = fileSnapshotImportFace(fss) !== undefined;
304
- if (canImportSnapshots) {
318
+ const fss = deps.fileHistoryStore;
319
+ if (fileHistory !== null && fileHistoryImportFace(fss) !== undefined) {
305
320
  const distinct = new Set();
306
- for (const snap of snapshots)
307
- for (const [, hash] of snap.manifest)
308
- distinct.add(hash);
321
+ for (const t of fileHistory.tracked)
322
+ for (const v of t.versions)
323
+ if (v.blobHash !== null)
324
+ distinct.add(v.blobHash);
309
325
  const all = [...distinct];
310
326
  if (typeof fss?.hasBlobs === "function") {
311
327
  const present = await fss.hasBlobs(all);
@@ -315,9 +331,9 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
315
331
  return;
316
332
  }
317
333
  }
318
- else {
334
+ else if (typeof fss?.getBlob === "function") {
319
335
  for (const hash of all) {
320
- if (!(await deps.fileSnapshotStore.getBlob(hash))) {
336
+ if (!(await fss.getBlob(hash))) {
321
337
  sendError(res, 422, "missing_blob", "missing blob; PUT all referenced blobs before import");
322
338
  return;
323
339
  }
@@ -332,13 +348,13 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
332
348
  importLeases.set(sessionId, { at: Date.now(), stagingId });
333
349
  pendingStagings.set(stagingId, {
334
350
  handle, realSessionId: sessionId, owner: importingPrincipal, ...(resolution ? { resolution } : {}), operatorOk,
335
- snapshots, policy, anchors, declaredEntryIds: entryIds, openedBy: scope.gateOwner, openedFleetWide: scope.fleetWide,
351
+ fileHistory, policy, anchors, declaredEntryIds: entryIds, openedBy: scope.gateOwner, openedFleetWide: scope.fleetWide,
336
352
  });
337
353
  sendJson(res, 200, { stagingId, relation: relA.relation });
338
354
  return;
339
355
  }
340
- if (sm[6] !== undefined && req.method === "POST") {
341
- const stagingId = safeDecode(sm[6]);
356
+ if (sm[5] !== undefined && req.method === "POST") {
357
+ const stagingId = safeDecode(sm[5]);
342
358
  if (stagingId === null) {
343
359
  sendError(res, 400, "request.id_invalid", "invalid staging id");
344
360
  return;
@@ -352,8 +368,7 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
352
368
  sendError(res, 404, "not_found.staging", "staging not found");
353
369
  return;
354
370
  }
355
- const getBlob = (h) => deps.fileSnapshotStore.getBlob(h);
356
- const importFaceB = fileSnapshotImportFace(deps.fileSnapshotStore);
371
+ const getBlob = (h) => deps.fileHistoryStore?.getBlob ? deps.fileHistoryStore.getBlob(h) : Promise.resolve(undefined);
357
372
  const validator = new StreamingImportValidator();
358
373
  let lineBuf = "";
359
374
  let pendingBatch = [];
@@ -453,17 +468,15 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
453
468
  try {
454
469
  const committed = await pending.handle.commit(pending.owner, pending.resolution ? { resolution: pending.resolution } : undefined);
455
470
  if (pending.resolution === "overwrite-dst" && (committed.relation === "fork" || committed.relation === "stale")) {
456
- await wipeSessionAttendants({ fileSnapshotStore: deps.fileSnapshotStore, sessionPolicyStore: deps.sessionPolicyStore, resumeAnchorStore: deps.resumeAnchorStore }, sessionId);
471
+ await wipeSessionAttendants({ fileHistoryStore: deps.fileHistoryStore, sessionPolicyStore: deps.sessionPolicyStore, resumeAnchorStore: deps.resumeAnchorStore }, sessionId);
457
472
  }
458
- if (importFaceB) {
459
- const importManifest = importFaceB.importManifest;
460
- for (const snap of pending.snapshots) {
461
- const r = await importManifest(sessionId, snap.key, new Map(snap.manifest), getBlob);
462
- if (!r.ok) {
463
- sendError(res, 422, r.error.code === "restore_failed" ? "restore_failed" : "missing_blob", `snapshot import failed (${r.error.code})`);
464
- cleanupStaging(stagingId);
465
- return;
466
- }
473
+ if (pending.fileHistory !== null && deps.fileHistoryStore && fileHistoryImportFace(deps.fileHistoryStore)) {
474
+ const r = await replayFileHistory(deps.fileHistoryStore, sessionId, pending.fileHistory, getBlob, { allowReplace: pending.resolution === "overwrite-dst" });
475
+ const failCode = r.ok ? undefined : r.error.code === "not_found" ? "missing_blob" : r.error.code;
476
+ if (!r.ok) {
477
+ sendError(res, 422, failCode, `file history import failed (${r.error.code})`);
478
+ cleanupStaging(stagingId);
479
+ return;
467
480
  }
468
481
  }
469
482
  const policyStore = deps.sessionPolicyStore;