@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
@@ -11,6 +11,7 @@ import { spaceCommitAlive } from "../workspace-space-commit.js";
11
11
  import { incarnationLogPrefix, redactIncarnations } from "../session-incarnation.js";
12
12
  import { WORKSPACE_CACHE_SPACE_LABEL, workspaceCacheClaimOf, workspaceCacheEnv } from "../workspace-cache.js";
13
13
  import { recordFailOpen } from "../observability/fail-open.js";
14
+ import { SANDBOX_IPV6_DEFAULT, sandboxEgressGaps, sandboxPodSysctls } from "../sandbox-egress-policy.js";
14
15
  import { FileError, ExecutionError, RemoteExecutionError, withRetry, RollingTailBuffer, markTruncated, } from "@sema-agent/core";
15
16
  import { fileErrorFromExec, classifyFsStderr } from "./remote-env-file-error.js";
16
17
  import { createPosixShellFs } from "./posix-shell-fs.js";
@@ -73,7 +74,38 @@ function spaceLaneOf(policy, occupant) {
73
74
  function spaceReadFailed(what, detail, cause) {
74
75
  return { ok: false, error: new RemoteExecutionError("connect_failed", `workspace space ${what} read failed${detail}`, cause instanceof Error ? cause : undefined) };
75
76
  }
77
+ function restoreFailureIsTransient(cls) {
78
+ switch (cls) {
79
+ case "timeout":
80
+ case "transport":
81
+ case "store_busy":
82
+ return true;
83
+ case "download":
84
+ case "untar":
85
+ return false;
86
+ default: {
87
+ const unreachable = cls;
88
+ throw new Error(`unhandled restore failure class: ${String(unreachable)}`);
89
+ }
90
+ }
91
+ }
92
+ const RESTORE_MAX_ATTEMPTS = { park: 1, space: 2 };
76
93
  const RESTORE_DOWNLOAD_EXIT = 22;
94
+ const RESTORE_TRANSFER_EXIT = 75;
95
+ const RESTORE_STORE_BUSY_STATUSES = [408, 429, 500, 502, 503, 504];
96
+ const RESTORE_STORE_BUSY_EXIT = 69;
97
+ function restoreClassOfExit(exitCode) {
98
+ switch (exitCode) {
99
+ case RESTORE_STORE_BUSY_EXIT:
100
+ return "store_busy";
101
+ case RESTORE_DOWNLOAD_EXIT:
102
+ return "download";
103
+ case RESTORE_TRANSFER_EXIT:
104
+ return "transport";
105
+ default:
106
+ return "untar";
107
+ }
108
+ }
77
109
  const SPACE_COMMIT_POLL_MS = 1000;
78
110
  class FenceCallAborted extends Error {
79
111
  constructor() {
@@ -92,6 +124,8 @@ export function errorMessageOf(e) {
92
124
  return typeof m === "string" && m ? m : String(e);
93
125
  }
94
126
  export { exitCodeFromStatus } from "./k8s-exec-protocol.js";
127
+ export const K8S_SANDBOX_NAMESPACE_DEFAULT = "sandboxes";
128
+ const SANDBOX_POD_LABELS = { app: "sema-sandbox", "managed-by": "sema-server" };
95
129
  export class RemoteK8sExecutionEnv {
96
130
  capabilities;
97
131
  cwd;
@@ -101,6 +135,7 @@ export class RemoteK8sExecutionEnv {
101
135
  handleSnapshotId;
102
136
  inFlightExecs = 0;
103
137
  connectPromise;
138
+ restoreRefusal;
104
139
  homeDir;
105
140
  cdPath = "unknown";
106
141
  cfg;
@@ -119,7 +154,7 @@ export class RemoteK8sExecutionEnv {
119
154
  token: config.token,
120
155
  caCert: config.caCert,
121
156
  insecureTls: config.insecureTls ?? false,
122
- namespace: config.namespace ?? "sandboxes",
157
+ namespace: config.namespace ?? K8S_SANDBOX_NAMESPACE_DEFAULT,
123
158
  image: config.image,
124
159
  runtimeClass: config.runtimeClass ?? "kata-qemu",
125
160
  mountPath: config.mountPath ?? K8S_WORKSPACE_MOUNT_DEFAULT,
@@ -130,6 +165,7 @@ export class RemoteK8sExecutionEnv {
130
165
  dataTimeoutMs: dataTimeoutMsOf(config),
131
166
  cpu: config.cpu ?? "1",
132
167
  memory: config.memory ?? "2Gi",
168
+ ipv6: config.ipv6 ?? SANDBOX_IPV6_DEFAULT,
133
169
  retry: { maxAttempts: config.retry?.maxAttempts ?? 3, backoffMs: config.retry?.backoffMs ?? 1000 },
134
170
  ...(config.s3Snapshot ? { s3Snapshot: config.s3Snapshot } : {}),
135
171
  ...(config.sessionId ? { sessionId: config.sessionId } : {}),
@@ -256,6 +292,8 @@ export class RemoteK8sExecutionEnv {
256
292
  case "unreadable":
257
293
  return this.provision(signal);
258
294
  case "open": {
295
+ if (this.restoreRefusal)
296
+ return this.restoreRefusal;
259
297
  const settled = await this.awaitSpaceCommit(lane.space, signal);
260
298
  if (!settled.ok)
261
299
  return settled;
@@ -439,6 +477,23 @@ export class RemoteK8sExecutionEnv {
439
477
  return { ok: false, reason: "delete_failed", detail: `claim delete-by-label failed: ${errorMessageOf(e)}` };
440
478
  }
441
479
  }
480
+ async egressPolicyGaps() {
481
+ const read = async (apiPath) => {
482
+ try {
483
+ const r = await this.request("GET", apiPath);
484
+ return r.status >= 200 && r.status < 300 ? { ok: true, json: r.json } : { ok: false, detail: `HTTP ${r.status}` };
485
+ }
486
+ catch (e) {
487
+ return { ok: false, detail: errorMessageOf(e) };
488
+ }
489
+ };
490
+ const nodes = await read("/api/v1/nodes");
491
+ const policies = await read(`/apis/networking.k8s.io/v1/namespaces/${this.cfg.namespace}/networkpolicies`);
492
+ const probe = { metadata: { labels: { ...SANDBOX_POD_LABELS } }, spec: {} };
493
+ if (this.cfg.podSpecPatch?.labels)
494
+ applyPodSpecPatch(probe, { labels: this.cfg.podSpecPatch.labels });
495
+ return sandboxEgressGaps({ nodes, policies, podLabels: probe.metadata.labels, ipv6: this.cfg.ipv6 });
496
+ }
442
497
  reclaimDeadlinePassed(what) {
443
498
  return `reclaim deadline (${this.cfg.dataTimeoutMs} ms, the snapshot time bound, one per reclaim call) passed — ${what}`;
444
499
  }
@@ -457,15 +512,17 @@ export class RemoteK8sExecutionEnv {
457
512
  async createPod(signal, cache) {
458
513
  const ns = this.cfg.namespace;
459
514
  const podEnv = { ...(cache ? workspaceCacheEnv(cache.mount) : {}), ...(this.cfg.podEnv ?? {}) };
515
+ const sysctls = sandboxPodSysctls(this.cfg.ipv6);
460
516
  const pod = {
461
517
  apiVersion: "v1",
462
518
  kind: "Pod",
463
519
  metadata: {
464
520
  generateName: "sbx-",
465
- labels: { app: "sema-sandbox", "managed-by": "sema-server" },
521
+ labels: { ...SANDBOX_POD_LABELS },
466
522
  },
467
523
  spec: {
468
524
  ...(this.cfg.runtimeClass ? { runtimeClassName: this.cfg.runtimeClass } : {}),
525
+ ...(sysctls ? { securityContext: { sysctls: sysctls.map((x) => ({ ...x })) } } : {}),
469
526
  restartPolicy: "Never",
470
527
  automountServiceAccountToken: false,
471
528
  activeDeadlineSeconds: Math.ceil(this.cfg.timeoutMs / 1000),
@@ -843,39 +900,73 @@ export class RemoteK8sExecutionEnv {
843
900
  const presigned = this.presignSnapshot({ ...target, method: "GET", snapshotId });
844
901
  if (!presigned.ok)
845
902
  return presigned;
846
- const c = await this.provision(abortSignal);
847
- if (!c.ok)
848
- return c;
849
903
  const url = presigned.value;
850
- const cmd = `code=$(curl -s -o /tmp/ws.tgz -w '%{http_code}' ${shellQuote(url)}); ` +
904
+ const cmd = `code=$(curl -s -o /tmp/ws.tgz -w '%{http_code}' ${shellQuote(url)}); rc=$?; ` +
905
+ `if [ "$rc" != "0" ]; then echo "snapshot GET got no whole answer (curl exit $rc, HTTP $code)" >&2; exit ${RESTORE_TRANSFER_EXIT}; fi; ` +
851
906
  `if [ "$code" = "404" ]; then echo EMPTY_SNAPSHOT; exit 0; fi; ` +
907
+ `case "$code" in ${RESTORE_STORE_BUSY_STATUSES.join("|")}) echo "snapshot GET HTTP $code" >&2; exit ${RESTORE_STORE_BUSY_EXIT};; esac; ` +
852
908
  `if [ "$code" != "200" ]; then echo "snapshot GET HTTP $code" >&2; exit ${RESTORE_DOWNLOAD_EXIT}; fi; ` +
853
909
  `tar xzf /tmp/ws.tgz -C ${shellQuote(this.cfg.mountPath)} && rm -f /tmp/ws.tgz`;
854
- const r = await this.runExec(cmd, { timeoutMs: this.cfg.dataTimeoutMs, livenessMs: 0 });
855
- const failure = !r.ok
856
- ? { cls: r.error.code === "timeout" ? "timeout" : "transport", detail: `workspace snapshot restore failed: ${this.redactUrl(r.error.message, url)}`, cause: r.error }
857
- : r.value.exitCode !== 0
858
- ? { cls: r.value.exitCode === RESTORE_DOWNLOAD_EXIT ? "download" : "untar", detail: `workspace snapshot restore exited ${r.value.exitCode}: ${this.redactUrl(r.value.stderr, url).slice(0, 300)}` }
859
- : undefined;
860
- if (failure) {
910
+ for (let attempt = 1;; attempt++) {
911
+ const c = await this.provision(abortSignal);
912
+ if (!c.ok)
913
+ return c;
914
+ const r = await this.runExec(cmd, { timeoutMs: this.cfg.dataTimeoutMs, livenessMs: 0 });
915
+ const failure = !r.ok
916
+ ? { cls: r.error.code === "timeout" ? "timeout" : "transport", detail: `workspace snapshot restore failed: ${this.redactUrl(r.error.message, url)}`, cause: r.error }
917
+ : r.value.exitCode !== 0
918
+ ? { cls: restoreClassOfExit(r.value.exitCode), detail: `workspace snapshot restore exited ${r.value.exitCode}: ${this.redactUrl(r.value.stderr, url).slice(0, 300)}` }
919
+ : undefined;
920
+ if (!failure) {
921
+ if (target.lane === "space" && r.ok && r.value.stdout.includes("EMPTY_SNAPSHOT"))
922
+ await this.dropDanglingLatest(target.space, snapshotId);
923
+ return ok(this.workspaceHandle());
924
+ }
861
925
  const name = this.podName;
862
926
  this.podName = undefined;
863
927
  this.podIP = undefined;
864
928
  if (name)
865
929
  await this.deletePod(name).catch(() => { });
866
- if (target.lane === "park")
867
- return { ok: false, error: new RemoteExecutionError("connect_failed", failure.detail, failure.cause) };
868
- this.spaceLine("workspace_restore_failed", target.space, { snapshotId, class: failure.cls, boundMs: this.cfg.dataTimeoutMs, error: failure.detail });
869
- return {
870
- ok: false,
871
- error: new RemoteExecutionError("connect_failed", `${failure.detail} — the workspace's latest snapshot (${snapshotId}) is in the store but did not come back (${failure.cls}, bound ${this.cfg.dataTimeoutMs}ms), ` +
872
- "so this run does not start on an empty /workspace. Ways out: raise WORKSPACE_SNAPSHOT_TIMEOUT_MS if the archive is large, " +
873
- "or set WORKSPACE_PERSIST=off for one run (it starts from an empty /workspace and deletes no snapshot)", failure.cause),
874
- };
930
+ if (attempt < RESTORE_MAX_ATTEMPTS[target.lane] && restoreFailureIsTransient(failure.cls)) {
931
+ if (await this.restorePause(abortSignal))
932
+ continue;
933
+ return { ok: false, error: new RemoteExecutionError("aborted", "connect aborted while retrying the workspace restore") };
934
+ }
935
+ switch (target.lane) {
936
+ case "park":
937
+ return { ok: false, error: new RemoteExecutionError("connect_failed", failure.detail, failure.cause) };
938
+ case "space": {
939
+ const tries = attempt === 1 ? "1 attempt" : `${attempt} attempts`;
940
+ this.spaceLine("workspace_restore_failed", target.space, { snapshotId, class: failure.cls, attempts: attempt, boundMs: this.cfg.dataTimeoutMs, error: failure.detail });
941
+ this.restoreRefusal = {
942
+ ok: false,
943
+ error: new RemoteExecutionError("connect_failed", `${failure.detail} — the workspace's latest snapshot (${snapshotId}) is in the store but did not come back (${failure.cls}, ${tries}, bound ${this.cfg.dataTimeoutMs}ms each), ` +
944
+ "so this run does not start on an empty /workspace. Ways out: raise WORKSPACE_SNAPSHOT_TIMEOUT_MS if the archive is large, " +
945
+ "or set WORKSPACE_PERSIST=off for one run (it starts from an empty /workspace and deletes no snapshot)", failure.cause),
946
+ };
947
+ return this.restoreRefusal;
948
+ }
949
+ default: {
950
+ const unreachable = target;
951
+ throw new Error(`unhandled snapshot lane: ${String(unreachable.lane)}`);
952
+ }
953
+ }
875
954
  }
876
- if (target.lane === "space" && r.ok && r.value.stdout.includes("EMPTY_SNAPSHOT"))
877
- await this.dropDanglingLatest(target.space, snapshotId);
878
- return ok(this.workspaceHandle());
955
+ }
956
+ restorePause(signal) {
957
+ return new Promise((resolve) => {
958
+ if (signal?.aborted)
959
+ return resolve(false);
960
+ const onAbort = () => {
961
+ clearTimeout(timer);
962
+ resolve(false);
963
+ };
964
+ const timer = setTimeout(() => {
965
+ signal?.removeEventListener("abort", onAbort);
966
+ resolve(true);
967
+ }, this.cfg.retry.backoffMs);
968
+ signal?.addEventListener("abort", onAbort, { once: true });
969
+ });
879
970
  }
880
971
  async dropDanglingLatest(space, snapshotId) {
881
972
  const prefix = this.cfg.s3Snapshot?.keyPrefix ?? "kata";
@@ -1583,6 +1674,9 @@ export function createSpaceReclaimer(config, deps) {
1583
1674
  },
1584
1675
  };
1585
1676
  }
1677
+ export async function checkSandboxEgressPolicy(config, deps) {
1678
+ return new RemoteK8sExecutionEnv(config, deps).egressPolicyGaps();
1679
+ }
1586
1680
  export function k8sExecutionEnvFactory(config, deps) {
1587
1681
  return (ctx) => new RemoteK8sExecutionEnv({ ...config, sessionId: ctx.sessionId }, deps);
1588
1682
  }
@@ -7,10 +7,11 @@ import type { StoreBackend } from "./store-backend.js";
7
7
  * - `memory` (default): core's in-memory `TtlSessionStore` (lost on restart, 7-day idle TTL).
8
8
  * - `tidb`: the durable "Session event-log center" (L1) — append-only events + wake/resume +
9
9
  * cross-instance CAS (F2), on whichever SQL engine `DB_BACKEND` selects (TiDB or PG, via the store backend).
10
- * Optionally wrapped in a warm cache (reuse a hot session across turns; requires session affinity) —
11
- * set `SESSION_CACHE_TTL_SEC=0` to disable.
10
+ * Optionally wrapped in a warm cache (reuse a hot session across turns; a hit is served only while the store's
11
+ * head still names the cached leaf, so it is correct under any routing across replicas) — set
12
+ * `SESSION_CACHE_TTL_SEC=0` to disable.
12
13
  */
13
- export declare function createSessionStore(config: ServiceConfig, backend?: StoreBackend, metrics?: {
14
+ export declare function createSessionStore(config: Pick<ServiceConfig, "sessionBackend" | "sessionCacheTtlSec">, backend?: Pick<StoreBackend, "session">, metrics?: {
14
15
  inc(name: string, labels?: Record<string, string>, n?: number): void;
15
16
  }): SessionStore;
16
17
  /**
@@ -6,7 +6,7 @@ export function createSessionStore(config, backend, metrics) {
6
6
  throw new Error("a DB session backend requires a store backend (DB_BACKEND=mysql|pg + a reachable DB).");
7
7
  const store = backend.session();
8
8
  return config.sessionCacheTtlSec > 0
9
- ? new CachingSessionStore(store, { ttlSec: config.sessionCacheTtlSec, onConflictEvict: metrics ? () => metrics.inc("stale_cache_conflict_total") : undefined })
9
+ ? new CachingSessionStore(store, { ttlSec: config.sessionCacheTtlSec, onStaleHit: metrics ? () => metrics.inc("session_cache_stale_hit_total") : undefined })
10
10
  : store;
11
11
  }
12
12
  return new TtlSessionStore({ defaultTtlDays: 7 });
@@ -5,6 +5,7 @@ import { type OutcomeSink } from "./file-outcome-sink.js";
5
5
  import { type InboxWarn } from "./workflow-run-store-sql.js";
6
6
  import { FileRunStore } from "./file-run-store.js";
7
7
  import { type OneTimeMigrationReport } from "./one-time-migrations.js";
8
+ import type { SessionHeadReader } from "./caching-session-store.js";
8
9
  import { LocalCheckpointStore } from "./local-checkpoint-store.js";
9
10
  import { FileResumeAnchorStore } from "./file-resume-anchor-store.js";
10
11
  import { type ApprovalExemptionStore } from "./approval-exemption-store.js";
@@ -144,7 +145,11 @@ export interface StoreBackend {
144
145
  /** Protocol-truth kind (clay 2026-07-06 wording): "mysql" = any MySQL-protocol server (MySQL / TiDB /
145
146
  * MariaDB) behind the one mysql2 pool — the startup summary prints durable(mysql) for all of them. */
146
147
  readonly kind: "mysql" | "pg" | "local";
147
- /** Connect + create schema (also the reachability probe). */
148
+ /** Connect + create schema (also the reachability probe). On the SQL backends it first checks the database for the
149
+ * schema that the one-time migrations of server 7.74.0–7.125.x retired and **refuses** with `RetiredSchemaPresentError`
150
+ * (code `config.schema_mismatch`, exported from the package root) before it issues the first CREATE; only then does it create.
151
+ * The check lives in this method itself, so the public sequence `createStoreBackend()` → `ensureSchema()` → `runOneTimeMigrations()`
152
+ * needs no step of its own. The LOCAL backend has no SQL pool and nothing to probe. */
148
153
  ensureSchema(): Promise<void>;
149
154
  close(): Promise<void>;
150
155
  run(): RunStore;
@@ -337,8 +342,9 @@ export interface StoreBackend {
337
342
  breaker?(onWriteFail?: (streak: number) => void): BreakerStateStore;
338
343
  costQuota?(limitMicroUsd: number, windowMs: number, onDegraded?: CounterDegradeHook): CostQuotaStore;
339
344
  rateLimiter?(limit: number, onDegraded?: CounterDegradeHook): RateLimiterStore;
340
- /** Session store (caller wraps caching). DB-backed on tidb/pg; the in-memory/file `LocalSessionStore` on local. */
341
- session(): SessionStore;
345
+ /** Session store (caller wraps caching). DB-backed on tidb/pg; the in-memory/file `LocalSessionStore` on local. Carries the
346
+ * head read the warm cache checks every hit against. */
347
+ session(): SessionStore & SessionHeadReader;
342
348
  /** The raw mysql2 pool — ONLY on the TiDB backend (undefined on PG). For the few TiDB-specific raw-SQL
343
349
  * consumers outside the store abstraction (session audit, degenerate-output instrument); those features are
344
350
  * no-ops on PG until ported. */
@@ -381,8 +387,9 @@ export declare function assertCloudSnapshotBlobPosture(kind: "mysql" | "pg" | "l
381
387
  * 而兄弟行都写 `shared(${kind})`。)
382
388
  */
383
389
  export declare function counterStoreLabel(store: unknown, kind: string | undefined): string;
384
- /** Build the durable-store backend for `config.dbBackend`. Does NOT connect (the pool connects lazily; the
385
- * reachability probe is the caller's first `ensureSchema`). Returns undefined when no DB is configured.
390
+ /** Build the durable-store backend for `config.dbBackend`. Does NOT connect (the pool connects lazily); the next
391
+ * step is `ensureSchema()` (retired-schema check, then the CREATEs; it is also the reachability probe), then
392
+ * `runOneTimeMigrations()`. Returns undefined when no DB is configured.
386
393
  *
387
394
  * `onWarn` (additive, optional) is the boot-time warning sink the LOCAL backend needs for core's
388
395
  * permission-rule DISCLOSURE face ([ref] §1): an unreadable / checksum-mismatched / symlinked rule file
@@ -12,7 +12,7 @@ import { FileRunStore } from "./file-run-store.js";
12
12
  import { StoreBootRefusalError } from "./store-contracts.js";
13
13
  import { buildStoreOpenRefusalTail } from "./store-open-refusal.js";
14
14
  import { acquireLocalBackendWithWait } from "./store-lock-wait.js";
15
- import { NO_SQL_MIGRATIONS, runSqlOneTimeMigrations } from "./one-time-migrations.js";
15
+ import { NO_SQL_MIGRATIONS, assertRetiredSchemaAbsentOnPool, runSqlOneTimeMigrations } from "./one-time-migrations.js";
16
16
  import { LocalSessionStore, createLocalSessionRepo } from "./local-session-store.js";
17
17
  import { LocalCheckpointStore } from "./local-checkpoint-store.js";
18
18
  import { FileResumeAnchorStore } from "./file-resume-anchor-store.js";
@@ -337,11 +337,20 @@ export function createStoreBackend(config, onWarn, onError) {
337
337
  if (!config.pg)
338
338
  return undefined;
339
339
  const pool = createPgPool(pgPoolOptions(config.pg, config.dbQueryTimeoutMs), onWarn);
340
- return new PgBackend(pool, config);
340
+ return withRetiredSchemaProbe(new PgBackend(pool, config), { dialect: "pg", pool });
341
341
  }
342
342
  if (!config.tidb)
343
343
  return undefined;
344
- return new TiDBBackend(createTidbPool(config), config);
344
+ const pool = createTidbPool(config);
345
+ return withRetiredSchemaProbe(new TiDBBackend(pool, config), { dialect: "tidb", pool });
346
+ }
347
+ function withRetiredSchemaProbe(inner, target) {
348
+ const guarded = Object.create(inner);
349
+ guarded.ensureSchema = async () => {
350
+ await assertRetiredSchemaAbsentOnPool(target);
351
+ await inner.ensureSchema();
352
+ };
353
+ return guarded;
345
354
  }
346
355
  export async function openStoreBackendWithFallback(config, logger) {
347
356
  const sqlEngine = config.dbBackend === "mysql" || config.dbBackend === "pg" ? config.dbBackend : undefined;
@@ -36,6 +36,9 @@ import type { PeerSessionTempo, TaskResult, TaskStatus } from "@sema-agent/core"
36
36
  * 收法:**一个基类,一条重抛**。新 File 店的拒启只要 `extends StoreBootRefusalError` 就自动落在正确的
37
37
  * 一侧;忘了继承是**可见**的(它是一个必须显式选择的父类),而忘了往 if 链里加一行是不可见的。
38
38
  * (core 的 `AdoptionError` 不在本仓,改不了它的继承链 ⇒ 降级臂保留它那一条 `instanceof`,共两条。)
39
+ *
40
+ * 族的含义是「**确定的拒启表态**」,不限 File 店:SQL `ensureSchema()` 里库带着已退役 schema 的拒启(`RetiredSchemaPresentError`)也属此族。三处读它,读法一致 ——
41
+ * 降级臂不吞(穿过 `mayFallback`)、SQL 开库的等待窗不重试它(`store-open-wait.ts`:再等也等不来另一个答案)、「库打不开」的拒句不改写它(它自己的拒句才是真话)。
39
42
  */
40
43
  export declare class StoreBootRefusalError extends Error {
41
44
  }
@@ -1,4 +1,5 @@
1
1
  import { STORE_LOCK_POLL_MS } from "./store-lock-wait.js";
2
+ import { StoreBootRefusalError } from "./store-contracts.js";
2
3
  const REAL_CLOCK = {
3
4
  now: () => performance.now(),
4
5
  sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
@@ -46,6 +47,8 @@ export async function openWithinWait(open, waitMs, onWait, clock = REAL_CLOCK) {
46
47
  throw lastFailure.error;
47
48
  throw new StoreOpenWindowExpired(waitMs);
48
49
  case "failed": {
50
+ if (outcome.error instanceof StoreBootRefusalError)
51
+ throw outcome.error;
49
52
  if (lastFailure === undefined)
50
53
  onWait({ waitMs, error: outcome.error instanceof Error ? outcome.error.message : String(outcome.error) });
51
54
  lastFailure = { error: outcome.error };
@@ -37,13 +37,26 @@ export type ScenarioBodyKey = (typeof TASK_BODY_SCENARIO_KEYS)[number];
37
37
  export type NamedKeysOf<T> = {
38
38
  [K in keyof T as string extends K ? never : number extends K ? never : K]: T[K];
39
39
  };
40
- /**
41
- * FRESH 提交面的**顶层**键闭集裁决:受理集之外的键 ⇒ 返回拒体(调用方 400 `request.body_shape`);
42
- * 全部受理 ⇒ `null`。
43
- *
44
- * 判据边界(与 settings 那只同源,有意窄):只判**键名**;体本身非对象 ⇒ 本门不判(留给既有的
45
- * 「missing 'objective' string」那道门,免得同一个体被两句话抢答)。
46
- */
40
+ /** FRESH 提交面(`POST /v1/tasks` · `/v1/tasks/stream` · `/v1/runs`)的顶层键裁决。 */
47
41
  export declare function taskBodyKeyIssue(raw: unknown): KeyClosureIssue | null;
42
+ declare const RUN_STEER_BODY_KEYS: readonly ["mode", "priority", "text"];
43
+ declare const CONTENT_STEER_BODY_KEYS: readonly ["content"];
44
+ declare const INTERRUPT_STEER_BODY_KEYS: readonly ["text"];
45
+ /** `POST /v1/runs/:id/steer` 的体形(键 = 受理集,值形由路由逐键判)。 */
46
+ export type RunSteerBody = {
47
+ readonly [K in (typeof RUN_STEER_BODY_KEYS)[number]]?: unknown;
48
+ };
49
+ /** 子代 steer / resume 与 workflow 子代 steer 的体形。 */
50
+ export type ContentSteerBody = {
51
+ readonly [K in (typeof CONTENT_STEER_BODY_KEYS)[number]]?: unknown;
52
+ };
53
+ /** `POST /v1/runs/:id/interrupt` 带 text 形的体形。 */
54
+ export type InterruptSteerBody = {
55
+ readonly [K in (typeof INTERRUPT_STEER_BODY_KEYS)[number]]?: unknown;
56
+ };
57
+ /** 送话口的三种体形(闭集;词外是编译错)。 */
58
+ export type SteerBodyForm = "run" | "content" | "interrupt";
59
+ /** 送话口的顶层键裁决(四扇门同一只;`form` 选受理集)。 */
60
+ export declare function steerBodyKeyIssue(raw: unknown, form: SteerBodyForm): KeyClosureIssue | null;
48
61
  export {};
49
62
  //# sourceMappingURL=request-key-closure.d.ts.map
@@ -32,26 +32,42 @@ const TASK_BODY_DECLARED_KEYS = [
32
32
  ];
33
33
  const TASK_BODY_SCENARIO_KEYS = ["repo"];
34
34
  const ACCEPTED_BODY = new Set([...TASK_BODY_DECLARED_KEYS, ...TASK_BODY_SCENARIO_KEYS]);
35
- const UNSUPPORTED_BODY = new Set(["handsReadOnly", "readDenyPatterns", "readFace", "shellGate", "writeFace"]);
36
- export function taskBodyKeyIssue(raw) {
35
+ const UNSUPPORTED_BODY_KEYS = ["handsReadOnly", "readDenyPatterns", "readFace", "shellGate", "userAttachments", "writeFace"];
36
+ const UNSUPPORTED_BODY = new Set(UNSUPPORTED_BODY_KEYS);
37
+ const UNSUPPORTED_LABEL = "unsupported (an engine seat this service does not read from the request body — set by the deployment, or not offered in this version)";
38
+ function closedBodyKeyIssue(raw, accepted, door) {
37
39
  if (raw === null || raw === undefined || typeof raw !== "object" || Array.isArray(raw))
38
40
  return null;
39
41
  const buckets = { unknown: [], unsupported: [] };
40
- scanKeyClosure(raw, "", ACCEPTED_BODY, UNSUPPORTED_BODY, buckets);
42
+ scanKeyClosure(raw, "", accepted, UNSUPPORTED_BODY, buckets);
41
43
  if (buckets.unknown.length === 0 && buckets.unsupported.length === 0)
42
44
  return null;
43
45
  buckets.unknown.sort();
44
46
  buckets.unsupported.sort();
45
47
  const parts = [
46
- ...(buckets.unknown.length > 0 ? [keyClosureClause(buckets.unknown, "unknown (not a task-request key — check the spelling)")] : []),
47
- ...(buckets.unsupported.length > 0 ? [keyClosureClause(buckets.unsupported, "unsupported (an engine seat this service derives, never reads from the body)")] : []),
48
+ ...(buckets.unknown.length > 0 ? [keyClosureClause(buckets.unknown, `unknown (not ${door} key — check the spelling)`)] : []),
49
+ ...(buckets.unsupported.length > 0 ? [keyClosureClause(buckets.unsupported, UNSUPPORTED_LABEL)] : []),
48
50
  ];
49
51
  return {
50
52
  message: `body: unrecognized top-level key(s) — refused rather than dropped, because a dropped key reads as "in effect" to the caller. ` +
51
53
  `${parts.join("; ")}. ` +
52
- `Accepted: ${[...ACCEPTED_BODY].sort().join(",")}`,
54
+ `Accepted: ${[...accepted].sort().join(",")}`,
53
55
  unknownKeys: buckets.unknown.slice(0, MAX_KEY_ECHO),
54
56
  unsupportedKeys: buckets.unsupported.slice(0, MAX_KEY_ECHO),
55
57
  };
56
58
  }
59
+ export function taskBodyKeyIssue(raw) {
60
+ return closedBodyKeyIssue(raw, ACCEPTED_BODY, "a task-request");
61
+ }
62
+ const RUN_STEER_BODY_KEYS = ["mode", "priority", "text"];
63
+ const CONTENT_STEER_BODY_KEYS = ["content"];
64
+ const INTERRUPT_STEER_BODY_KEYS = ["text"];
65
+ const STEER_DOORS = {
66
+ run: { accepted: new Set(RUN_STEER_BODY_KEYS), door: "a steer-request" },
67
+ content: { accepted: new Set(CONTENT_STEER_BODY_KEYS), door: "a steer-request" },
68
+ interrupt: { accepted: new Set(INTERRUPT_STEER_BODY_KEYS), door: "an interrupt-request" },
69
+ };
70
+ export function steerBodyKeyIssue(raw, form) {
71
+ return closedBodyKeyIssue(raw, STEER_DOORS[form].accepted, STEER_DOORS[form].door);
72
+ }
57
73
  //# sourceMappingURL=request-key-closure.js.map
@@ -0,0 +1,51 @@
1
+ /**
2
+ * **一次提交的「受理」读法** —— 标题器只在引擎**受理**这条 run 之后起(一条律,唯一属主 = 本文件)。
3
+ *
4
+ * ## 受理是哪一刻
5
+ * 引擎把这次提交的 objective 收进会话的那一帧:`human_input{ carrier: "objective", delivery: "applied" }`。它是第一次模型调用
6
+ * 之前的最后一帧;在它之前引擎能拒的一切 —— prepare 门(`config.tool_face_control` / `config.user_invoked_skill_unknown` /
7
+ * `config.memory_capture_unsupported` …)、第一回合前的内联图片准入(`config.image_invalid`)、部署的 UserPromptSubmit 拦截、
8
+ * 开跑前的预算 / 墙钟 —— 都还没让这次输入进会话。`wiring_manifest` 不是受理:它在 prepare 通过时就出了,图片准入与提示拦截
9
+ * 都在它之后(core `run-identity-wiring.js` 推 wiring_manifest,`run-leg.js` 才做图片准入、推 human_input、调模型)。
10
+ *
11
+ * 只回结果、不交流的腿(verify / cascade:core 在自己里面排空每条腿的流,宿主手里没有流)看不到那一帧。它们的受理证据是结果本身:
12
+ * 结果里跑过 turn(`stats.turns > 0`)⇒ objective 进过会话、模型答过。**token 不是证据**:被 UserPromptSubmit 拦下的 objective 不进会话,
13
+ * 而同一条 run 的收尾压缩可以在已有历史的会话上花 token(turns 0、tokens > 0)—— 按 token 判会把被拦下的输入送去起名。
14
+ * 两种证据说的是同一件事,本文件把它们收成一只闩:先到的那一个算数,只算一次。
15
+ *
16
+ * ## 受理之后 / 之前
17
+ * · 受理 ⇒ 标题器起一次(会话没有标题时它才真去生成;判据在标题器里,本文件不重复)。
18
+ * · 没受理就收场(零工作的拒、受理前取消 / 拦截)⇒ 标题器不起:被拒的输入不花一次模型调用,也不被送去起名。
19
+ * 这次提交在提交门铸下的会话行照旧留下(无标题、零消息),与此前同 —— 「提交没让引擎受理就收场」的会话去留是另一件事,不在本文件。
20
+ *
21
+ * ## 谁喂它
22
+ * 三条提交腿(同步 / 流式 / durable)各在两处喂同一只:持流的那一形把每一帧交给 {@link RunAcceptance.observe};终局落行之后调一次
23
+ * {@link RunAcceptance.settle}。腿上不再有「什么时候起标题」的判断,标题器的唯一调用点是本文件的闩。座由提交门在 `prepareSpec` 里
24
+ * 随 spec 一起铸(`PreparedTaskSubmission.acceptance`);不经提交门的内部车道铸一只不带标题器的座,也就不起标题。
25
+ */
26
+ export interface RunAcceptanceDeps {
27
+ /** 标题器(缺席 = 本部署不自动起名)。 */
28
+ sessionTitler?: {
29
+ maybeTitle(sessionId: string, objective: string, model?: string): void;
30
+ };
31
+ }
32
+ export interface RunAcceptanceInput {
33
+ sessionId?: string;
34
+ objective: string;
35
+ /** 本 turn 真实驱动的模型 id(标题器与 turn 同链路)。 */
36
+ model?: string;
37
+ }
38
+ export interface RunAcceptance {
39
+ /** 持流的腿逐帧调用(判哪一帧算受理只在这里)。不抛、不等。 */
40
+ observe(ev: {
41
+ type: string;
42
+ }): void;
43
+ /** 终局落行之后:还没在流上见过受理帧、而结果里跑过 turn(只回结果的腿)⇒ 受理。只读 `stats.turns`。不抛、不等。 */
44
+ settle(result: {
45
+ readonly stats: {
46
+ readonly turns: number;
47
+ };
48
+ }): void;
49
+ }
50
+ export declare function createRunAcceptance(deps: RunAcceptanceDeps, input: RunAcceptanceInput): RunAcceptance;
51
+ //# sourceMappingURL=run-acceptance.d.ts.map
@@ -0,0 +1,27 @@
1
+ function isObjectiveApplied(ev) {
2
+ if (ev.type !== "human_input")
3
+ return false;
4
+ const h = ev;
5
+ return h.carrier === "objective" && h.delivery === "applied";
6
+ }
7
+ export function createRunAcceptance(deps, input) {
8
+ let accepted = false;
9
+ const accept = () => {
10
+ if (accepted)
11
+ return;
12
+ accepted = true;
13
+ if (input.sessionId)
14
+ deps.sessionTitler?.maybeTitle(input.sessionId, input.objective, input.model);
15
+ };
16
+ return {
17
+ observe(ev) {
18
+ if (!accepted && isObjectiveApplied(ev))
19
+ accept();
20
+ },
21
+ settle(result) {
22
+ if (result.stats.turns > 0)
23
+ accept();
24
+ },
25
+ };
26
+ }
27
+ //# sourceMappingURL=run-acceptance.js.map
package/dist/runs.d.ts CHANGED
@@ -9,6 +9,7 @@ import type { QuestionCoordinator } from "./question.js";
9
9
  import { type ToolApprovalCoordinator } from "./tool-approval.js";
10
10
  import { type FleetRunPublisher } from "./fleet/fleet-bus.js";
11
11
  import { type WorkflowCompletionInbox } from "./orchestration/workflow-completion-inbox.js";
12
+ import type { RunAcceptance } from "./run-acceptance.js";
12
13
  import type { VerifyRoundsSpec } from "./http/verify-rounds.js";
13
14
  import type { UserInputLedger } from "./user-input-ledger.js";
14
15
  /**
@@ -91,14 +92,6 @@ export declare function taskHandleStop(registry: typeof defaultTaskRegistry, han
91
92
  * 语义上这两种时序是同一件事:本请求赢下了这条 session,而这条 session 的连接是被人断掉的。
92
93
  */
93
94
  export declare function markChildrenStoppedByUserOnAbort(signal: AbortSignal, taskId: string, principal: string | null | undefined): void;
94
- /**
95
- * A session CAS conflict is swallowed by core into a failed `TaskResult` (it is not thrown from
96
- * `runTaskStream`), so detect it from the result. Prefer the structured `code` (core ≥1.8); fall
97
- * back to the conflict wording only for older results without one.
98
- */
99
- export declare function isSessionConflictResult(result: {
100
- terminal: TerminalCause;
101
- }): boolean;
102
95
  export declare function resumeAtHttpStatus(result: {
103
96
  terminal: TerminalCause;
104
97
  }): number | undefined;
@@ -202,8 +195,6 @@ export declare function parkToolCallId(result: unknown, cs: ParkCheckpointReader
202
195
  * 不塞进 `checkpointGate`——那是 core 的类型,server 不改其形)。非 park / 无 token / tool-less /
203
196
  * 读失败 ⇒ **同一引用**原样返回(键缺席)。graft 不负责 strip——组合序恒为 graft → strip。 */
204
197
  export declare function graftParkToolCallId<T>(result: T, cs: ParkCheckpointReader | undefined): Promise<T>;
205
- /** Evict a stale warm-cache entry after a cross-instance write conflict (broken-affinity backstop). */
206
- export declare function evictIfConflict(runner: Runner, sessionId: string | undefined, result: TaskResult): void;
207
198
  /**
208
199
  * Drive a task to completion in the background, persisting its events to the durable run log (S2).
209
200
  *
@@ -328,7 +319,12 @@ trustedInternals?: Pick<RunInternals, "workflowParkedResume">,
328
319
  * 已收敛、未告知的崩溃丢失铸成 `task.user_steer_undrained` / `task.user_followup_undrained`(`cause:"crashed"`,码按受理时的车道选);腿收场时把本腿上的受理 settle 掉。
329
320
  * 缺席 ⇒ 两手都不做(与本参数存在之前逐字相同)。
330
321
  */
331
- userInputLedger?: UserInputLedger): Promise<void>;
322
+ userInputLedger?: UserInputLedger,
323
+ /**
324
+ * 这次提交的受理座(`src/run-acceptance.ts`):标题器只在引擎受理之后起。普通流形逐帧喂;只回结果的 verify / cascade 形(以及终局写口的
325
+ * 重试腿)终局落行之后收尾一次。缺席 ⇒ 不起标题(起标题此前由路由层在建行时做)。
326
+ */
327
+ acceptance?: RunAcceptance): Promise<void>;
332
328
  /** How often a running instance refreshes its run's updated_at (liveness, independent of events).
333
329
  * Must stay strictly below `runStaleSec` (asserted at startup AND on every hot config candidate) or the
334
330
  * reaper would race live runs. [ref]:数值的单源是 `config-invariants.ts`(那条不变量的属主),两个消费面