@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.
- package/USAGE.md +8 -0
- package/dist/boot/runner-deps.d.ts +0 -4
- package/dist/boot/runner-deps.js +1 -2
- package/dist/boot/session-faces.js +0 -2
- package/dist/boot/stores.js +3 -16
- package/dist/config-types.d.ts +1 -1
- package/dist/deployment-governance.d.ts +23 -3
- package/dist/deployment-governance.js +23 -2
- package/dist/env-facts.d.ts +8 -1
- package/dist/http/route-ctx.d.ts +9 -8
- package/dist/http/routes/approvals-assistant.js +2 -2
- package/dist/http/routes/runs.js +1 -1
- package/dist/http/routes/workflows.js +4 -0
- package/dist/http/server.js +4 -3
- package/dist/main.js +0 -1
- package/dist/observability/fail-open.d.ts +2 -2
- package/dist/observability/fail-open.js +2 -2
- package/dist/observability/run-terminal-log.d.ts +2 -1
- package/dist/orchestration/workflow-agent-session-index.d.ts +41 -83
- package/dist/orchestration/workflow-agent-session-index.js +14 -33
- package/dist/parked-decide.d.ts +30 -33
- package/dist/parked-decide.js +11 -13
- package/dist/plugins/approval-ask-store-sql.js +1 -1
- package/dist/plugins/blob-backend.d.ts +6 -7
- package/dist/plugins/checkpoint-store-sql.d.ts +13 -10
- package/dist/plugins/checkpoint-store-sql.js +6 -24
- package/dist/plugins/file-run-store.d.ts +18 -0
- package/dist/plugins/file-run-store.js +27 -0
- package/dist/plugins/one-time-migrations.d.ts +66 -0
- package/dist/plugins/one-time-migrations.js +193 -0
- package/dist/plugins/pg-pool.d.ts +6 -0
- package/dist/plugins/pg-pool.js +5 -14
- package/dist/plugins/retention-store-sql.js +0 -1
- package/dist/plugins/store-backend.d.ts +11 -15
- package/dist/plugins/store-backend.js +7 -26
- package/dist/plugins/task-attachment-store.d.ts +2 -2
- package/dist/plugins/tidb-pool.js +11 -23
- package/dist/plugins/workflow-run-store-sql.d.ts +21 -21
- package/dist/plugins/workflow-run-store-sql.js +2 -16
- package/dist/task-cwd.d.ts +12 -1
- package/dist/task-settings.d.ts +6 -0
- package/dist/trace/core-keyset-guard.d.ts +11 -2
- package/dist/trace/project.d.ts +31 -31
- package/dist/trace/project.js +12 -17
- package/dist/trace/redact.js +295 -16
- 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
|
|
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
|
|
31
|
-
if (
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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 (!
|
|
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
|
}
|
package/dist/parked-decide.d.ts
CHANGED
|
@@ -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,
|
|
13
|
-
import { type
|
|
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
|
-
/** `
|
|
44
|
-
*
|
|
43
|
+
/** `park` 是**权威条目**(从 run 记录的真源 `parks[]` 里按赎回键复核出来的那一条);身份位一律从它读
|
|
44
|
+
* —— 索引行是可被改盘的派生投影,不做第二个身份写者(7.69.0 合并重扫 c1 同族收口)。 */
|
|
45
45
|
| {
|
|
46
46
|
kind: "workflow";
|
|
47
47
|
runId: string;
|
|
48
48
|
run: WorkflowRun;
|
|
49
|
-
|
|
49
|
+
park: WorkflowRunParkKey;
|
|
50
50
|
}
|
|
51
51
|
/**
|
|
52
|
-
* S-
|
|
52
|
+
* S-252 —— **这条记录的 park 真源读不出来**(第四种出身,不是第四条车道)。
|
|
53
53
|
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
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: "
|
|
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
|
|
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-
|
|
229
|
+
* S-252 —— **park 真源读不出来的那条记录的响亮拒**(与上面那族拒臂同一种返回形)。
|
|
230
230
|
*
|
|
231
|
-
* 为什么是拒而不是「尽力而为地投递一次」(
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
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
|
-
*
|
|
239
|
+
* 这条臂的受害集 = **存量持久数据**(core < 7.17.0 的引擎写下、升级后还 parked 着的 run 记录)。硬 breaking
|
|
240
|
+
* 三句里持久数据那一句要的就是这个形:响亮拒,不做静默双读(读 `agents[]` 补一份身份正是被退役的双算)。
|
|
241
|
+
*
|
|
242
|
+
* 体上只带 `runId`(那是操作员唯一用得上的坐标;真源都读不出来,任何计数都是编的)。
|
|
246
243
|
*/
|
|
247
|
-
export declare function
|
|
248
|
-
kind: "
|
|
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";
|
package/dist/parked-decide.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
|
29
|
-
if (
|
|
30
|
-
|
|
31
|
-
|
|
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,
|
|
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
|
|
88
|
+
export function denyWorkflowParksUnreadable(origin) {
|
|
90
89
|
return {
|
|
91
90
|
status: 409,
|
|
92
91
|
body: {
|
|
93
|
-
error: "this parked workflow agent's
|
|
94
|
-
errorCode: "decide.
|
|
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.
|
|
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
|
|
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 `
|
|
43
|
-
*
|
|
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
|
|
68
|
-
* grace-window guard so a just-written blob a concurrent in-flight
|
|
69
|
-
* the tidb/pg stores' gcOrphanBlobs delete, minus the
|
|
70
|
-
*
|
|
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
|
-
/** `
|
|
373
|
-
* 的入参形)。列是**唯一**权威(suspend 时写的 blob
|
|
374
|
-
type SteerColumnState = Pick<CheckpointState, "
|
|
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
|
-
*
|
|
384
|
-
*
|
|
385
|
-
*
|
|
386
|
-
*
|
|
387
|
-
*
|
|
388
|
-
*
|
|
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
|
|
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
|
|
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,
|
|
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 =
|
|
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,
|
|
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 =
|
|
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
|