@sema-agent/server 7.73.1 → 7.75.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/USAGE.md +8 -0
  2. package/dist/boot/runner-deps.d.ts +0 -4
  3. package/dist/boot/runner-deps.js +1 -2
  4. package/dist/boot/session-faces.js +0 -2
  5. package/dist/boot/stores.js +3 -16
  6. package/dist/config-types.d.ts +1 -1
  7. package/dist/deployment-governance.d.ts +23 -3
  8. package/dist/deployment-governance.js +23 -2
  9. package/dist/env-facts.d.ts +8 -1
  10. package/dist/http/route-ctx.d.ts +9 -8
  11. package/dist/http/routes/approvals-assistant.js +2 -2
  12. package/dist/http/routes/runs.js +1 -1
  13. package/dist/http/routes/workflows.js +4 -0
  14. package/dist/http/server.js +4 -3
  15. package/dist/main.js +0 -1
  16. package/dist/observability/fail-open.d.ts +2 -2
  17. package/dist/observability/fail-open.js +2 -2
  18. package/dist/observability/run-terminal-log.d.ts +2 -1
  19. package/dist/orchestration/workflow-agent-session-index.d.ts +41 -83
  20. package/dist/orchestration/workflow-agent-session-index.js +14 -33
  21. package/dist/parked-decide.d.ts +30 -33
  22. package/dist/parked-decide.js +11 -13
  23. package/dist/plugins/approval-ask-store-sql.js +1 -1
  24. package/dist/plugins/blob-backend.d.ts +6 -7
  25. package/dist/plugins/checkpoint-store-sql.d.ts +13 -10
  26. package/dist/plugins/checkpoint-store-sql.js +6 -24
  27. package/dist/plugins/file-run-store.d.ts +18 -0
  28. package/dist/plugins/file-run-store.js +27 -0
  29. package/dist/plugins/one-time-migrations.d.ts +66 -0
  30. package/dist/plugins/one-time-migrations.js +193 -0
  31. package/dist/plugins/pg-pool.d.ts +6 -0
  32. package/dist/plugins/pg-pool.js +5 -14
  33. package/dist/plugins/retention-store-sql.js +0 -1
  34. package/dist/plugins/store-backend.d.ts +11 -15
  35. package/dist/plugins/store-backend.js +7 -26
  36. package/dist/plugins/task-attachment-store.d.ts +2 -2
  37. package/dist/plugins/tidb-pool.js +11 -23
  38. package/dist/plugins/workflow-run-store-sql.d.ts +21 -21
  39. package/dist/plugins/workflow-run-store-sql.js +2 -16
  40. package/dist/task-cwd.d.ts +12 -1
  41. package/dist/task-settings.d.ts +6 -0
  42. package/dist/trace/core-keyset-guard.d.ts +11 -2
  43. package/dist/trace/project.d.ts +31 -31
  44. package/dist/trace/project.js +12 -17
  45. package/dist/trace/redact.js +295 -16
  46. package/package.json +3 -3
@@ -1,46 +1,27 @@
1
1
  import { existsSync, fsyncSync, mkdirSync, openSync, readFileSync, truncateSync, writeSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { redactHead } from "../observability/run-terminal-log.js";
4
3
  const nonEmptyString = (v) => typeof v === "string" && v !== "";
5
- export function isRecoverableParkedAgent(a) {
6
- return a.status === "parked" && nonEmptyString(a.sessionId) && nonEmptyString(a.parkedCheckpointToken);
7
- }
8
- export const PARKED_IDENTITY_LABEL_MAX = 200;
9
- export function buildParkedRecoveryIdentity(a, labelMax = PARKED_IDENTITY_LABEL_MAX) {
10
- return {
11
- label: typeof a.label === "string" ? redactHead(a.label, labelMax) : "",
12
- callKey: typeof a.callKey === "string" ? a.callKey : "",
13
- status: "parked",
14
- queuedAt: typeof a.queuedAt === "number" && Number.isFinite(a.queuedAt) ? a.queuedAt : 0,
15
- ...(typeof a.startedAt === "number" && Number.isFinite(a.startedAt) ? { startedAt: a.startedAt } : {}),
16
- sessionId: a.sessionId,
17
- parkedCheckpointToken: a.parkedCheckpointToken,
18
- };
19
- }
20
- export function buildParkedRecoveryIdentities(agents, labelMax = PARKED_IDENTITY_LABEL_MAX) {
21
- return (agents ?? []).filter(isRecoverableParkedAgent).map((a) => buildParkedRecoveryIdentity(a, labelMax));
22
- }
23
- export const PARK_IDENTITY_LOSS_KEY = "serverParkIdentityLoss";
24
- export function buildParkIdentityLoss(parks) {
25
- return { reason: "oversize_slim", parks };
26
- }
27
- export function readParkIdentityLoss(run) {
4
+ export function readRunParks(run) {
28
5
  if (typeof run !== "object" || run === null)
29
6
  return undefined;
30
- const v = run[PARK_IDENTITY_LOSS_KEY];
31
- if (typeof v !== "object" || v === null || Array.isArray(v))
32
- return undefined;
33
- const { reason, parks } = v;
34
- if (reason !== "oversize_slim")
7
+ const v = run.parks;
8
+ if (!Array.isArray(v))
35
9
  return undefined;
36
- if (typeof parks !== "number" || !Number.isFinite(parks) || parks <= 0)
37
- return undefined;
38
- return { reason, parks };
10
+ const out = [];
11
+ for (const raw of v) {
12
+ if (typeof raw !== "object" || raw === null)
13
+ return undefined;
14
+ const { callKey, sessionId, token, originRunId } = raw;
15
+ if (!nonEmptyString(callKey) || !nonEmptyString(sessionId) || !nonEmptyString(token) || !nonEmptyString(originRunId))
16
+ return undefined;
17
+ out.push({ callKey, sessionId, token, originRunId });
18
+ }
19
+ return out;
39
20
  }
40
21
  export function buildParkedAgentSessionRows(runId, run) {
41
22
  const rows = [];
42
23
  for (const a of run.agents ?? []) {
43
- if (!isRecoverableParkedAgent(a))
24
+ if (a.status !== "parked" || !nonEmptyString(a.sessionId))
44
25
  continue;
45
26
  rows.push({ sessionId: a.sessionId, runId, agentKey: typeof a.callKey === "string" ? a.callKey : "", scope: run.scope });
46
27
  }
@@ -9,8 +9,8 @@
9
9
  * 行永 parked、生命周期分叉)。
10
10
  */
11
11
  import type { HostDecision } from "./host-decision.js";
12
- import type { BackgroundAgentRecord, BackgroundAgentStore, CheckpointStore, QuestionAnswer, ResumeOutcome, TaskRegistry, ToolSpec, WorkflowAgentRun, WorkflowRun, WorkflowRunStore } from "@sema-agent/core";
13
- import { type ParkIdentityLoss, type WorkflowAgentSessionIndex } from "./orchestration/workflow-agent-session-index.js";
12
+ import type { BackgroundAgentRecord, BackgroundAgentStore, CheckpointStore, QuestionAnswer, ResumeOutcome, TaskRegistry, ToolSpec, WorkflowRun, WorkflowRunStore } from "@sema-agent/core";
13
+ import { type WorkflowRunParkKey, type WorkflowAgentSessionIndex } from "./orchestration/workflow-agent-session-index.js";
14
14
  export interface ParkedAgentMatch {
15
15
  handle: string;
16
16
  row: BackgroundAgentRecord;
@@ -40,37 +40,37 @@ export type ParkOrigin = {
40
40
  handle: string;
41
41
  row: BackgroundAgentRecord;
42
42
  }
43
- /** `agent` 是**权威实体**(从 run 的 blob 里按赎回键复核出来的那一行);身份位一律从它读 —— 索引行
44
- * 是可被改盘的派生投影,不做第二个身份写者(7.69.0 合并重扫 c1 同族收口)。 */
43
+ /** `park` 是**权威条目**(从 run 记录的真源 `parks[]` 里按赎回键复核出来的那一条);身份位一律从它读
44
+ * —— 索引行是可被改盘的派生投影,不做第二个身份写者(7.69.0 合并重扫 c1 同族收口)。 */
45
45
  | {
46
46
  kind: "workflow";
47
47
  runId: string;
48
48
  run: WorkflowRun;
49
- agent: WorkflowAgentRun;
49
+ park: WorkflowRunParkKey;
50
50
  }
51
51
  /**
52
- * S-192 —— **恢复身份被店的字节上界裁掉**的那条 park(第四种出身,不是第四条车道)。
52
+ * S-252 —— **这条记录的 park 真源读不出来**(第四种出身,不是第四条车道)。
53
53
  *
54
- * 索引行还在(它由**内存里的整只 run** 派生,店内的瘦身它看不见),run 行也还在,但 blob 里已经没有
55
- * 那一行 —— 而 blob 里的 `parkedCheckpointToken` 是 server 侧**复核**「这条 park 仍在此 token 下」的唯一
56
- * 依据(引擎自己的那份赎回键住在 resume journal 的 parked 条目里,resume 时从那里取,见
57
- * {@link denyWorkflowParkIdentityLost} 顶注亲读的 core 坐标)。此前这一格与「索引落后一拍 / 行已在新
58
- * token 下 re-park」两种**正常** miss 完全同形,于是落 legacy 腿拿一个错因 409。
59
- * 现在店在裁掉它的同时落一个闭集标记({@link readParkIdentityLoss}),判别式据它把这一格分出来,
60
- * 三个消费点各自响亮处置(decide 拒 + 不驱动宿主续跑;SLA sweep 照旧静默跳过——它同样复核不了)。
54
+ * 索引行还在、run 行也还在,但记录上没有一份读得出来的 `parks[]`:旧引擎(core < 7.17.0)写下的存量行
55
+ * 根本没有这一位,或者某一条条目的形坏了。而这一位是**引擎自己**的 park 责任表 —— core 的 resume 准入
56
+ * 读不到它就整条拒(`workflow.park_truth_unreadable` / `parks_unreadable`,亲读安装树
57
+ * `dist/orchestration/workflow.js:2217-2218`;条目形坏 ⇒ `park_binding_broken`,`:2222-2224`)。
58
+ * ⇒ 这条决定**结构上**送不出去:答 200「已交给宿主的下一跑」就是一张兑付不了的回执。
59
+ * 此前这一格与「索引落后一拍 / 这条 run 已不再负责它」两种**正常** miss 完全同形,于是落 legacy 腿拿
60
+ * 一个错因 409。判别式据真源的**可读性**把这一格分出来,三个消费点各自响亮处置(decide 拒 + 不驱动
61
+ * 宿主续跑;SLA sweep 照旧静默跳过——它同样复核不了)。
61
62
  */
62
63
  | {
63
- kind: "workflow_identity_lost";
64
+ kind: "workflow_parks_unreadable";
64
65
  runId: string;
65
66
  run: WorkflowRun;
66
- loss: ParkIdentityLoss;
67
67
  };
68
68
  /** 判别式的两个源。各自可缺席(部署没接就是没接)—— 缺席 ⇒ 那条臂不判,不是失败。 */
69
69
  export interface ParkOriginSources {
70
70
  agentStore?: BackgroundAgentStore;
71
71
  /** S-185:workflow 出身的**有界** join 索引(契约见 orchestration/workflow-agent-session-index.ts)。 */
72
72
  workflowIndex?: WorkflowAgentSessionIndex;
73
- /** 索引只回答「去哪条 run 找」,行本体(含赎回键)仍以 run 的 blob 为准 ⇒ 两件必须成对在场。 */
73
+ /** 索引只回答「去哪条 run 找」,条目本体(含赎回键)仍以 run 记录的 `parks[]` 为准 ⇒ 两件必须成对在场。 */
74
74
  workflowRunStore?: Pick<WorkflowRunStore, "get">;
75
75
  warn?: (event: string, fields: Record<string, unknown>) => void;
76
76
  }
@@ -226,26 +226,23 @@ export type WorkflowParkedDecidePlan = {
226
226
  deny: ParkedDecideResult;
227
227
  };
228
228
  /**
229
- * S-192 —— **恢复身份已被裁掉的 workflow park 的响亮拒**(与上面那族拒臂同一种返回形)。
229
+ * S-252 —— **park 真源读不出来的那条记录的响亮拒**(与上面那族拒臂同一种返回形)。
230
230
  *
231
- * 为什么是拒而不是「尽力而为地投递一次」(机理按 core 7.13.0 `dist/orchestration/workflow.js` 亲读,合并复审
232
- * R9/R23 推翻本注此前「引擎手上没有赎回键 ⇒ 重跑」的推断):
233
- * · 投递用的 token 是调用方 checkpoint 的 `cp.token`(路由拿到的那枚),**不是** blob 里那一行;blob 行在本
234
- * 车道的用途只有一个 —— 复核「这条 park 此刻仍在此 token 下」(`agent.parkedCheckpointToken === cp.token`,
235
- * 行已在新 token 下 re-park ⇒ miss 不错配)。行被裁掉 = 这道复核**失明**,200「决定已交给宿主的下一跑」
236
- * 就证不出它兑付的是哪一条 park。
237
- * · 引擎 resume 时的赎回键取自 **resume journal** 的 parked 条目(`parkedResumeFor(cached.parked.terminal.token)`,
238
- * :1327/:1727),不取自 run blob;不带决定的 resume 用同一 token **重新 park**(`parkAgentLeg(reparkRec, …)`,
239
- * :1345/:1738),不是重跑。blob 的 parked 行在引擎侧唯一的读点是 `prior.agents` 那道「journal 缺条目」防线
240
- * (:2118-2130)—— 身份被裁掉后失明的正是它:**只有 journal 也缺该条目时**,resume 才会把那次调用活跑。
241
- * ⇒ 拒,并把真话与真恢复路径说出来:先不带决定 resume(引擎按 journal re-park 进一只新的、小的 run 行),
242
- * 再对同一 checkpoint decide;re-park 后能否再赎回取决于新 blob 是否装得下身份行(不下 ⇒ 再落本臂)。
243
- * 码是新的,与既有 `decide.workflow_*` 同族。
231
+ * 为什么是拒而不是「尽力而为地投递一次」(机理按安装树 core 7.17.1 `dist/orchestration/workflow.js` 亲读):
232
+ * · 记录上的 `parks[]` 是**引擎自己**的 park 责任表,而且是**下一次 resume 的准入输入**:
233
+ * `!Array.isArray(prior.parks)` ⇒ 整条拒 resume(`workflow.park_truth_unreadable` / `parks_unreadable`,
234
+ * :2217-2218);某一条条目形坏 ⇒ `park_binding_broken`(:2222-2224)。两者都不是「少一条 park」,
235
+ * 是**这条 run 再也 resume 不了** —— 而本车道的投递腿恰恰是让宿主去 `Workflow({resumeFromRunId})`。
236
+ * ⇒ 决定结构上送不出去。答 200「已交给宿主的下一跑」就是一张兑付不了的回执:卡还 pending、人以为决完了。
237
+ * ⇒ 拒,并把真话说出来:这条 run 恢复不了,开一条新的;卡仍 pending,什么都没有被伪造。
244
238
  *
245
- * 体上带 `runId` 与被裁掉的 park 数(`parks`)—— 后者是店给的计数,**不含任何身份位**(带了就等于没裁)。
239
+ * 这条臂的受害集 = **存量持久数据**(core < 7.17.0 的引擎写下、升级后还 parked 着的 run 记录)。硬 breaking
240
+ * 三句里持久数据那一句要的就是这个形:响亮拒,不做静默双读(读 `agents[]` 补一份身份正是被退役的双算)。
241
+ *
242
+ * 体上只带 `runId`(那是操作员唯一用得上的坐标;真源都读不出来,任何计数都是编的)。
246
243
  */
247
- export declare function denyWorkflowParkIdentityLost(origin: Extract<ParkOrigin, {
248
- kind: "workflow_identity_lost";
244
+ export declare function denyWorkflowParksUnreadable(origin: Extract<ParkOrigin, {
245
+ kind: "workflow_parks_unreadable";
249
246
  }>): ParkedDecideResult;
250
247
  export declare function planWorkflowParkedDecide(origin: Extract<ParkOrigin, {
251
248
  kind: "workflow";
@@ -1,4 +1,4 @@
1
- import { readParkIdentityLoss } from "./orchestration/workflow-agent-session-index.js";
1
+ import { readRunParks } from "./orchestration/workflow-agent-session-index.js";
2
2
  import { defaultSubagentTailBus } from "./fleet/subagent-tail-bus.js";
3
3
  import { backgroundScopesForCheckpointScope, decodeCheckpointScope } from "./security.js";
4
4
  import { ASK_USER_QUESTION_TOOL_NAME } from "./approval-content-kind.js";
@@ -25,14 +25,13 @@ export async function findParkOrigin(sources, cp) {
25
25
  const run = await runStore.get(row.runId);
26
26
  if (run === null)
27
27
  return undefined;
28
- const agent = (run.agents ?? []).find((a) => a.sessionId === cp.sessionId && a.status === "parked");
29
- if (agent === undefined) {
30
- const loss = readParkIdentityLoss(run);
31
- return loss === undefined ? undefined : { kind: "workflow_identity_lost", runId: row.runId, run, loss };
32
- }
33
- if (agent.parkedCheckpointToken !== cp.token)
28
+ const parks = readRunParks(run);
29
+ if (parks === undefined)
30
+ return { kind: "workflow_parks_unreadable", runId: row.runId, run };
31
+ const park = parks.find((p) => p.sessionId === cp.sessionId && p.token === cp.token);
32
+ if (park === undefined)
34
33
  return undefined;
35
- return { kind: "workflow", runId: row.runId, run, agent };
34
+ return { kind: "workflow", runId: row.runId, run, park };
36
35
  }
37
36
  catch (e) {
38
37
  quiet(sources.warn, "workflow_park_probe_failed", { error: e instanceof Error ? e.message : String(e) });
@@ -86,14 +85,13 @@ export function buildParkedResumeOutcome(req) {
86
85
  hostDecision: req.hostDecision,
87
86
  };
88
87
  }
89
- export function denyWorkflowParkIdentityLost(origin) {
88
+ export function denyWorkflowParksUnreadable(origin) {
90
89
  return {
91
90
  status: 409,
92
91
  body: {
93
- error: "this parked workflow agent's recovery identity was dropped when the run store slimmed an oversize run blob — the server can no longer verify that the row is still parked under this checkpoint token, so the decision is not delivered. The park itself is kept by the engine's resume journal, not by this blob: resume the run without a decision (the engine re-parks the child under the same token into a fresh run row), then decide again; only if that journal entry is also missing would a resume run the call live",
94
- errorCode: "decide.workflow_park_identity_lost",
92
+ error: "this parked workflow agent's run record carries no readable list of the parks the run owns — the engine writes that list when it parks, and it is the only proof that this checkpoint token belongs to that run. A record written by an engine older than the one deployed here (or one whose entries are damaged) is refused by a resume of the run itself for the same reason, so the decision is NOT delivered rather than answered with a receipt nothing can redeem. The approval card stays pending and nothing is forged: this run cannot be resumed — start a new run",
93
+ errorCode: "decide.workflow_parks_unreadable",
95
94
  runId: origin.runId,
96
- parksDropped: origin.loss.parks,
97
95
  },
98
96
  };
99
97
  }
@@ -131,7 +129,7 @@ export function planWorkflowParkedDecide(origin, req) {
131
129
  hostSessionId,
132
130
  runId: origin.runId,
133
131
  outcome: buildParkedResumeOutcome(req),
134
- message: `The human approval that workflow run "${origin.runId}" parked on (agent callKey "${origin.agent.callKey}") has been ${verdict}. ` +
132
+ message: `The human approval that workflow run "${origin.runId}" parked on (agent callKey "${origin.park.callKey}") has been ${verdict}. ` +
135
133
  `Resume that run now: re-invoke Workflow with the SAME script source you launched it from (script / name / scriptPath) ` +
136
134
  `AND resumeFromRunId: "${origin.runId}" — the decision is already held by the deployment and will be applied to the parked ` +
137
135
  `agent; completed steps replay from the journal. Do not re-issue the original call.`,
@@ -96,7 +96,7 @@ export const TIDB_APPROVAL_ASK_STATEMENTS = [
96
96
  gate_bound_call_id VARCHAR(255) NULL,
97
97
  gate_bound_input_hash VARCHAR(255) NULL,
98
98
  idempotency_key VARCHAR(255) NULL,
99
- -- LONGTEXT 照 image-bake/background-agent 的 pending_steer 先例:重放帧载荷可能超 64K TEXT 墙。
99
+ -- LONGTEXT 照 image-bake/background-agent 的载荷列先例:重放帧载荷可能超 64K TEXT 墙。
100
100
  card_json LONGTEXT NOT NULL,
101
101
  -- #363 规则车道素材两列(语义/类型理由逐字见 AskRow.ruleCommand / ruleScopeRoot 的顶注)。
102
102
  -- rule_command 与 permission_rule_approval.command 同款 LONGTEXT:命令行没有入口上限,VARCHAR
@@ -39,9 +39,8 @@ export interface BlobBackend {
39
39
  * dialect twins selected by `dialect`. Keeping the SQL byte-identical means the existing behaviour + the env-gated
40
40
  * real-DB integration suite are unchanged when MinIO is unset (the DEFAULT backend the file-snapshot stores construct).
41
41
  *
42
- * NOTE deleteBlobs here is the BYTE delete ONLY — it does NOT do the `NOT IN (SELECT … snapshot_manifest)` orphan
43
- * filter (that reference check stays in the file-snapshot store, which computes the orphan hash SET from the manifest
44
- * and hands it here). The grace-window `created_at < cutoff` guard is RETAINED on the delete so a just-written blob
42
+ * NOTE deleteBlobs here is the BYTE delete ONLY — it does NOT do the reference (`not in the manifest`) filter: that
43
+ * check stays in the owning store, which computes the orphan hash SET from its own domain's manifest and hands it here. The grace-window `created_at < cutoff` guard is RETAINED on the delete so a just-written blob
45
44
  * that a concurrent in-flight snapshot still needs is never byte-deleted (delete-in-use is data loss; under-delete is
46
45
  * harmless — the next sweep collects it).
47
46
  */
@@ -64,10 +63,10 @@ export declare class SqlBlobBackend implements BlobBackend {
64
63
  getBlob(hash: string): Promise<Uint8Array | undefined>;
65
64
  /** The subset of `hashes` that exist — a single `blob_hash IN (...)` SELECT (idempotency / reap probe). */
66
65
  hasBlobs(hashes: string[]): Promise<Set<string>>;
67
- /** Byte-delete the given hashes (the orphan SET is computed by the caller from snapshot_manifest). Retains the
68
- * grace-window guard so a just-written blob a concurrent in-flight snapshot needs is never delete-in-use — VERBATIM
69
- * the tidb/pg stores' gcOrphanBlobs delete, minus the `NOT IN (SELECT … manifest)` clause (the caller already
70
- * excluded referenced hashes). Best-effort: a delete error is swallowed (under-delete is harmless). */
66
+ /** Byte-delete the given hashes (the orphan SET is computed by the CALLER from its own domain's manifest). Retains
67
+ * the grace-window guard so a just-written blob a concurrent in-flight write needs is never delete-in-use — VERBATIM
68
+ * the tidb/pg stores' gcOrphanBlobs delete, minus the reference clause (the caller already excluded referenced
69
+ * hashes). Best-effort: a delete error is swallowed (under-delete is harmless). */
71
70
  deleteBlobs(hashes: string[]): Promise<number>;
72
71
  }
73
72
  /** SHARED index op(workspace 浏览面 #3,[ref]②)—— per-blob 字节数,读 SQL INDEX 的 `byte_len`
@@ -369,9 +369,9 @@ export declare const DECIDED_REPLAY_SCAN_CAP = 8;
369
369
  * (plan_review / dry_run_review)的判词,那些门的决议入口不是本腿,回放它们等于跨门作答。
370
370
  */
371
371
  export declare function approvalDecisionOfWinner(winner: ResolvedOutcome | undefined, boundCallId: string): "approve" | "deny" | null;
372
- /** `pending_steer` 列承载的两个 CheckpointState 字段(= core `appendPendingSteer` / `readPendingSteerQueue`
373
- * 的入参形)。列是**唯一**权威(suspend 时写的 blob 从不带它们)。 */
374
- type SteerColumnState = Pick<CheckpointState, "pendingSteer" | "pendingSteerQueue">;
372
+ /** `pending_steer_queue` 列承载的 CheckpointState 字段(= core `appendPendingSteer` / `readPendingSteerQueue`
373
+ * 的入参形)。列是**唯一**权威(suspend 时写的 blob 从不带它)。 */
374
+ type SteerColumnState = Pick<CheckpointState, "pendingSteerQueue">;
375
375
  /** 读列结果。`lossy` = 列里有东西但**没能完整读出来**(JSON 坏 / 条目形不认)。两个调用点对它的处置
376
376
  * 刻意相反,见各自的旁注:`get()` 容忍(坏列不许把一条挂起的 run 变成不可 resume),`setPendingSteer`
377
377
  * fail-loud(往一份读不全的队列上追加 = 把别人 park 的指令悄悄写没,正是 [ref] 要消灭的那件事)。 */
@@ -380,14 +380,17 @@ interface SteerColumnRead {
380
380
  lossy: boolean;
381
381
  }
382
382
  /**
383
- * 两个 steering 列 → `{pendingSteer?, pendingSteerQueue?}`(= core `appendPendingSteer`/`readPendingSteerQueue`
384
- * 的入参形)。**两列各管一形**,不是一列两形:
385
- * · `pending_steer`(旧列,本服务只读不写)—— 裸单座 `{text, trusted}`。来源有二:队列化之前的存量行,
386
- * 以及**滚动升级窗里仍在跑的旧副本**写下的座。core 的 `readPendingSteerQueue` 把它折成 member 0。
387
- * · `pending_steer_queue`(新列)—— `PendingSteerEntry[]`。
388
- * 任一列有内容却读不出 ⇒ `lossy`(两个调用点对它的处置见 {@link SteerColumnRead})。
383
+ * steering 列 → `{pendingSteerQueue?}`(= core `appendPendingSteer`/`readPendingSteerQueue` 的入参形)。
384
+ * **一列一形**:`pending_steer_queue` 存 `PendingSteerEntry[]`。
385
+ *
386
+ * 🔴 7.74.0 起只剩这**一**列。队列化之前的裸单座列 `pending_steer` 随它的席位一起退役了(core 7.17.0
387
+ * [ref] d:引擎对带席的行 PRE-CAS 拒 `seat_retired`),存量列由启动期的一次性迁移删掉
388
+ * (`plugins/one-time-migrations.ts`:列上还有非空值 ⇒ **响亮拒启**,清空后 `DROP COLUMN`)。
389
+ * ⇒ 这里刻意**没有**第二条读腿:双读臂的寿命就是那一次迁移,不是永远。
390
+ *
391
+ * 列有内容却读不出 ⇒ `lossy`(两个调用点对它的处置见 {@link SteerColumnRead})。
389
392
  */
390
- export declare function readPendingSteerColumns(seatRaw: unknown, queueRaw: unknown): SteerColumnRead;
393
+ export declare function readPendingSteerQueueColumn(queueRaw: unknown): SteerColumnRead;
391
394
  /** Dual-dialect durable CheckpointStore. See the file header for the dialect-delta ledger. */
392
395
  export declare class SqlCheckpointStore implements CheckpointStore {
393
396
  protected readonly db: SqlDriver;
@@ -210,15 +210,6 @@ function readActorAssertion(v) {
210
210
  const issuer = "issuer" in v ? v.issuer : undefined;
211
211
  return { id, hostAsserted, ...(typeof issuer === "string" ? { issuer } : {}) };
212
212
  }
213
- function readLegacySeat(v) {
214
- if (typeof v !== "object" || v === null)
215
- return undefined;
216
- const text = "text" in v ? v.text : undefined;
217
- const trusted = "trusted" in v ? v.trusted : undefined;
218
- if (typeof text !== "string" || typeof trusted !== "boolean")
219
- return undefined;
220
- return { text, trusted };
221
- }
222
213
  function readQueueEntry(v) {
223
214
  if (typeof v !== "object" || v === null)
224
215
  return undefined;
@@ -243,7 +234,7 @@ function readQueueEntry(v) {
243
234
  ...(priority === "now" || priority === "next" || priority === "later" ? { priority } : {}),
244
235
  };
245
236
  }
246
- export function readPendingSteerColumns(seatRaw, queueRaw) {
237
+ export function readPendingSteerQueueColumn(queueRaw) {
247
238
  let lossy = false;
248
239
  const parse = (raw) => {
249
240
  if (raw === null || raw === undefined)
@@ -258,10 +249,6 @@ export function readPendingSteerColumns(seatRaw, queueRaw) {
258
249
  return undefined;
259
250
  }
260
251
  };
261
- const seatParsed = parse(seatRaw);
262
- const seat = seatParsed === undefined ? undefined : readLegacySeat(seatParsed);
263
- if (seatParsed !== undefined && seat === undefined)
264
- lossy = true;
265
252
  const queueParsed = parse(queueRaw);
266
253
  let queue;
267
254
  if (queueParsed !== undefined) {
@@ -278,10 +265,7 @@ export function readPendingSteerColumns(seatRaw, queueRaw) {
278
265
  }
279
266
  }
280
267
  }
281
- return {
282
- state: { ...(seat !== undefined ? { pendingSteer: seat } : {}), ...(queue !== undefined ? { pendingSteerQueue: queue } : {}) },
283
- lossy,
284
- };
268
+ return { state: { ...(queue !== undefined ? { pendingSteerQueue: queue } : {}) }, lossy };
285
269
  }
286
270
  const STEER_CAS_ATTEMPTS = 8;
287
271
  const EXECUTION_RECORD_CAS_ATTEMPTS = 8;
@@ -375,7 +359,7 @@ export class SqlCheckpointStore {
375
359
  }
376
360
  }
377
361
  async get(token) {
378
- const { rows } = await this.db.query(this.q("SELECT version, status, checkpoint, outcome, reopen_reason, rev, pending_steer, pending_steer_queue, execution_outcome, execution_at_ms FROM checkpoint WHERE token = ?", "SELECT version, status, checkpoint, outcome, reopen_reason, rev, pending_steer, pending_steer_queue, execution_outcome, execution_at_ms FROM checkpoint WHERE token = $1"), [token]);
362
+ const { rows } = await this.db.query(this.q("SELECT version, status, checkpoint, outcome, reopen_reason, rev, pending_steer_queue, execution_outcome, execution_at_ms FROM checkpoint WHERE token = ?", "SELECT version, status, checkpoint, outcome, reopen_reason, rev, pending_steer_queue, execution_outcome, execution_at_ms FROM checkpoint WHERE token = $1"), [token]);
379
363
  const r = rows[0];
380
364
  if (!r)
381
365
  return null;
@@ -396,13 +380,11 @@ export class SqlCheckpointStore {
396
380
  if (r.execution_at_ms != null)
397
381
  cp.executionAtMs = Number(r.execution_at_ms);
398
382
  }
399
- const steer = readPendingSteerColumns(r.pending_steer, r.pending_steer_queue);
383
+ const steer = readPendingSteerQueueColumn(r.pending_steer_queue);
400
384
  if (steer.lossy) {
401
385
  this.logger?.info?.("checkpoint_pending_steer_unreadable", { tokenFp: tokenFingerprint(token) });
402
386
  }
403
387
  if (cp.state) {
404
- if (steer.state.pendingSteer !== undefined)
405
- cp.state.pendingSteer = steer.state.pendingSteer;
406
388
  if (steer.state.pendingSteerQueue !== undefined)
407
389
  cp.state.pendingSteerQueue = steer.state.pendingSteerQueue;
408
390
  }
@@ -454,11 +436,11 @@ export class SqlCheckpointStore {
454
436
  async setPendingSteer(token, scope, steer) {
455
437
  const clean = validatePendingSteer(steer);
456
438
  for (let attempt = 0; attempt < STEER_CAS_ATTEMPTS; attempt++) {
457
- const { rows } = await this.db.query(this.q("SELECT status, pending_steer, pending_steer_queue, pending_steer_rev FROM checkpoint WHERE token = ? AND scope = ?", "SELECT status, pending_steer, pending_steer_queue, pending_steer_rev FROM checkpoint WHERE token = $1 AND scope = $2"), [token, scope]);
439
+ const { rows } = await this.db.query(this.q("SELECT status, pending_steer_queue, pending_steer_rev FROM checkpoint WHERE token = ? AND scope = ?", "SELECT status, pending_steer_queue, pending_steer_rev FROM checkpoint WHERE token = $1 AND scope = $2"), [token, scope]);
458
440
  const row = rows[0];
459
441
  if (!row || row.status !== "pending")
460
442
  return false;
461
- const read = readPendingSteerColumns(row.pending_steer, row.pending_steer_queue);
443
+ const read = readPendingSteerQueueColumn(row.pending_steer_queue);
462
444
  if (read.lossy) {
463
445
  throw new Error(`the parked steering queue on checkpoint ${tokenFingerprint(token)} is unreadable — refusing to append onto it ` +
464
446
  `(appending would silently drop the entries this build cannot parse)`);
@@ -284,6 +284,24 @@ export declare class FileRunStore {
284
284
  private releaseClaim;
285
285
  /** Drop a run entirely (registry + event log + open fd + on-disk dir) — used by deleteBySession. */
286
286
  private dropRun;
287
+ /**
288
+ * 🔴 **一次性迁移(随 7.75.0 连同 `one-time-migrations.ts` 整删)** —— 退役词旧拼法的账本改写,
289
+ * SQL 两孪生那条整表 UPDATE 的 local 形。逐条走**同一只**纯改写函数,所以三条车道改出来的字节一致。
290
+ *
291
+ * 为什么是 store 上的一只动词、而不是外面一个文件走查:本店的账本有**两份**在场 —— 盘上的 jsonl
292
+ * 与 hydrate 进来的内存索引。外部走查只改得到前者,读腿读的却是后者(`getEvents` 从 Map 取),
293
+ * 于是迁移在本次进程里完全不生效、下次重启才"忽然"生效。两份一起改的唯一正确位置就是这里。
294
+ *
295
+ * 写盘用整文件原子替换(不是追加):改的是**已有行**,而 `AppendLog` 只会往后写。替换之前把**全部**
296
+ * append fd 撤掉(rename 之后旧 fd 指向的是被 unlink 的 inode,继续往那儿追加 = 之后的事件全部写进一个
297
+ * 没人看得见的文件),下一次 `appendEvent` 会按惰性规则重新开。撤 fd 复用**既有的那一只** {@link dispose}
298
+ * —— 刻意不在这里另写一段 close+catch:那会是本文件第二处同形的兜底臂,而规则要变少不要变多。
299
+ * 常态下这一撤是空操作:本动词跑在 boot 期,那时一个 append fd 都还没惰性开出来。
300
+ *
301
+ * 幂等:零命中 ⇒ 一个字节都不写、一只 fd 都不撤(第二跑零 I/O)。
302
+ * @returns 被改写的账本**行**数(不是 run 数)。
303
+ */
304
+ migrateRetiredAskOriginRows(): Promise<number>;
287
305
  /** Release every open event-log fd (called by LocalBackend.close so a graceful restart can re-open the data dir). */
288
306
  dispose(): void;
289
307
  }
@@ -7,6 +7,7 @@ import { terminalProjection } from "@sema-agent/core";
7
7
  import { AppendLog, atomicWriteFile, ensureDir, isProcessLive, processFingerprint, readJsonlRecords, sanitizePathComponent, writeThenLink } from "@sema-agent/core";
8
8
  import { recordFailOpen } from "../observability/fail-open.js";
9
9
  import { notifyRunTerminal } from "../observability/run-terminal-log.js";
10
+ import { migrateRetiredAskOrigin } from "./one-time-migrations.js";
10
11
  const ownerEq = (a, b) => (a ?? null) === (b ?? null);
11
12
  const CLAIM_TAKEOVER_ATTEMPTS = 3;
12
13
  const SAFE_PATH_COMPONENT = /^[A-Za-z0-9_.-]+$/;
@@ -841,6 +842,32 @@ export class FileRunStore {
841
842
  }
842
843
  }
843
844
  }
845
+ async migrateRetiredAskOriginRows() {
846
+ const rewrites = new Map();
847
+ for (const [taskId, list] of this.events) {
848
+ let touched = 0;
849
+ const rows = list.map((e) => {
850
+ const data = migrateRetiredAskOrigin(e.type, e.data);
851
+ if (data === undefined)
852
+ return e;
853
+ touched++;
854
+ return { ...e, data };
855
+ });
856
+ if (touched > 0)
857
+ rewrites.set(taskId, { rows, touched });
858
+ }
859
+ if (rewrites.size === 0)
860
+ return 0;
861
+ this.dispose();
862
+ let migrated = 0;
863
+ for (const [taskId, { rows, touched }] of rewrites) {
864
+ mkdirSync(this.runDir(taskId), { recursive: true });
865
+ atomicWriteFile(this.tmpDir, this.eventsPath(taskId), rows.map((e) => `${JSON.stringify(e)}\n`).join(""));
866
+ this.events.set(taskId, rows);
867
+ migrated += touched;
868
+ }
869
+ return migrated;
870
+ }
844
871
  dispose() {
845
872
  for (const log of this.eventLogs.values()) {
846
873
  try {
@@ -0,0 +1,66 @@
1
+ import type { Pool as MysqlPool } from "mysql2/promise";
2
+ import type { Pool as PgPool } from "pg";
3
+ /** 迁移读数(启动日志一行写全;测试按它断言,不靠日志文本)。 */
4
+ export interface OneTimeMigrationReport {
5
+ /** M1:退役快照索引表 —— `dropped` = 本次真 drop 了,`absent` = 早已不在(第二跑/全新库)。 */
6
+ readonly retiredSnapshotTable: "dropped" | "absent";
7
+ /** M1:drop 之前表上的行数(`absent` 时恒 0)。非零 = 这次迁移真删掉了存量行,必须让运维看见。 */
8
+ readonly retiredSnapshotRows: number;
9
+ /** M2:退役单座列 —— `dropped` = 本次真 DROP COLUMN,`absent` = 早已不在。 */
10
+ readonly retiredSteerSeatColumn: "dropped" | "absent";
11
+ /** M3:改名的行数(账本 `tool_end` 行 + 已决 checkpoint 的执行判词列,两者相加)。 */
12
+ readonly askOriginRowsMigrated: number;
13
+ }
14
+ /** 三条迁移都不适用的载体(local 车道没有 SQL 表)。 */
15
+ export declare const NO_SQL_MIGRATIONS: Omit<OneTimeMigrationReport, "askOriginRowsMigrated">;
16
+ /** 启动期的日志口(logger 的子集)。两级各有其用,见 {@link logOneTimeMigrations}。 */
17
+ export interface MigrationLogSink {
18
+ warn(msg: string, meta?: Record<string, unknown>): void;
19
+ info(msg: string, meta?: Record<string, unknown>): void;
20
+ }
21
+ /**
22
+ * M2 的拒启。**具名类型**:`openStoreBackendWithFallback` 对默认推导的 local 有降级臂,而本拒启若与
23
+ * 普通 Error 同形会被吃掉 —— operator 面对的就会是「服务起来了,但那条指令悄悄没了」
24
+ * (`RunStoreStrictHydrateError` 的同族先例,锚类型不锚文案)。
25
+ */
26
+ export declare class RetiredSteerSeatError extends Error {
27
+ readonly rows: number;
28
+ /** 结构化事件名(日志消费端按它分派;文案会变,这个不会)。 */
29
+ readonly code = "checkpoint.pending_steer_seat_retired";
30
+ constructor(rows: number);
31
+ }
32
+ /**
33
+ * SQL 两后端的一次性迁移。**调用点只有一个**(boot/stores.ts,在 `ensureSchema` 之后、任何路由装配
34
+ * 之前)—— 拒启在那一刻仍然是拒启(还没开始服务),而 `ensureSchema` 那条通道的契约是「只发 CREATE」
35
+ * (`test/ensure-schema.test.ts` 的运行时门),增量语句一条都不许进去。
36
+ *
37
+ * ## 并发(codex r1 [medium],亲核复现后修)
38
+ * 一次滚动升级会让 N 个副本同时跑到这里。整段因此跑在**一条连接**上、握着**与 `ensureSchema` 同一把**
39
+ * advisory 锁(MySQL 具名锁 / PG `pg_advisory_lock`):本段是「启动期改 schema 的活」,与 ensure 属同一类,
40
+ * 复用同一把锁 ⇒ 本仓这一类工作只有**一个**锁身份,而且本段也不会与另一个副本的 ensure 交错。
41
+ * 锁拿不到时按既有先例**降级**(不阻塞启动),但降级不等于放任:每条破坏性 DDL 的成功判据是它的
42
+ * **后置条件**({@link execUntil}),所以「另一个副本抢先删掉了同一列」不会把滚动升级变成启动失败循环。
43
+ *
44
+ * ## 次序(codex r1 [medium] 同轮)
45
+ * 读 → 非破坏改写 → 破坏性 DDL。①拒启臂是纯读,拒的时候盘上一个字节没动;②M3 是改值不丢信息的
46
+ * UPDATE,它是最可能超时的一步,放在删表删列**之前**;③真正的破坏排在最后,且失败时把**已完成的
47
+ * 破坏性步骤及其读数**写进拒因 —— 否则「删完表再失败」会让重试看到一张不存在的表、把行数报成 0,
48
+ * 证据永久消失。
49
+ *
50
+ * @throws {RetiredSteerSeatError} M2 探到非空的退役席位(唯一的拒启臂)。
51
+ */
52
+ export declare function runSqlOneTimeMigrations(dialect: "tidb" | "pg", pool: MysqlPool | PgPool): Promise<OneTimeMigrationReport>;
53
+ /**
54
+ * M3 的**纯**改写(local 车道的账本行逐条走它;SQL 两腿在服务端用等价的 JSON 路径表达同一件事)。
55
+ * 不改原对象(调用方与账本共享引用):有变更时换一只信封,原记录一个字节不动;无变更 ⇒ 返回
56
+ * `undefined`,调用方据此**不写盘**(第二跑因此零 I/O)。
57
+ */
58
+ export declare function migrateRetiredAskOrigin(type: string, data: unknown): Record<string, unknown> | undefined;
59
+ /**
60
+ * 启动日志的读数行。零命中(第二跑起的常态)也照发 —— 「跑过了、什么都没改」与「压根没跑」必须可分辨。
61
+ *
62
+ * 🔴 **级别按「这次真动了没有」分**:动了 ⇒ `warn`(有人的持久数据被改写/删除了,运维该看见一次);
63
+ * 没动 ⇒ `info`。把恒常的空转也发成 warn 会训练运维忽略 warn —— 那是在为了本段的可见性去损耗整条告警轴。
64
+ */
65
+ export declare function logOneTimeMigrations(log: MigrationLogSink, backend: string, report: OneTimeMigrationReport): void;
66
+ //# sourceMappingURL=one-time-migrations.d.ts.map