@sema-agent/server 7.93.6 → 7.94.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/stores.js +6 -3
- package/dist/boot/workflow-orchestration.js +5 -1
- package/dist/config-catalog.js +1 -1
- package/dist/http/resume-ctx-read.d.ts +40 -0
- package/dist/http/resume-ctx-read.js +25 -0
- package/dist/http/resume-legs.js +24 -14
- package/dist/http/routes/images.js +12 -0
- package/dist/http/routes/runs.js +18 -4
- package/dist/http/routes/workflows.js +3 -2
- package/dist/observability/corrupt-read-seat.d.ts +56 -0
- package/dist/observability/corrupt-read-seat.js +16 -0
- package/dist/observability/fail-open.d.ts +4 -0
- package/dist/observability/fail-open.js +4 -0
- package/dist/orchestration/workflow-journal-entry.d.ts +24 -6
- package/dist/orchestration/workflow-journal-entry.js +17 -10
- package/dist/plugins/approval-ask-store-sql.js +2 -2
- package/dist/plugins/checkpoint-store-sql.d.ts +16 -3
- package/dist/plugins/checkpoint-store-sql.js +7 -9
- package/dist/plugins/image-bake-store-sql.js +85 -13
- package/dist/plugins/image-index-sql.js +75 -12
- package/dist/plugins/local-checkpoint-store.d.ts +23 -2
- package/dist/plugins/local-checkpoint-store.js +40 -33
- package/dist/plugins/local-session-store.d.ts +14 -0
- package/dist/plugins/local-session-store.js +12 -7
- package/dist/plugins/local-sidecar-quarantine.d.ts +10 -0
- package/dist/plugins/local-sidecar-quarantine.js +15 -0
- package/dist/plugins/local-task-attachment-store.d.ts +1 -1
- package/dist/plugins/local-task-attachment-store.js +5 -2
- package/dist/plugins/mailbox-store-sql.d.ts +6 -3
- package/dist/plugins/mailbox-store-sql.js +45 -43
- package/dist/plugins/memory-embedder-fingerprint.js +8 -12
- package/dist/plugins/memory-engine-pg.js +34 -16
- package/dist/plugins/memory-engine-tidb.js +19 -12
- package/dist/plugins/memory-engine-vector-util.d.ts +26 -0
- package/dist/plugins/memory-engine-vector-util.js +3 -0
- package/dist/plugins/memory-sync-store-pg.d.ts +6 -0
- package/dist/plugins/memory-sync-store-pg.js +14 -10
- package/dist/plugins/memory-sync-store-tidb.js +7 -11
- package/dist/plugins/pg-session-storage.js +26 -28
- package/dist/plugins/session-event-payload.d.ts +37 -0
- package/dist/plugins/session-event-payload.js +20 -0
- package/dist/plugins/sql-json-column.d.ts +124 -22
- package/dist/plugins/sql-json-column.js +22 -1
- package/dist/plugins/sql-row-helpers.d.ts +10 -16
- package/dist/plugins/sql-row-helpers.js +0 -29
- package/dist/plugins/store-backend.js +10 -12
- package/dist/plugins/store-contracts.d.ts +4 -6
- package/dist/plugins/store-contracts.js +0 -60
- package/dist/plugins/task-attachment-store.d.ts +21 -12
- package/dist/plugins/task-attachment-store.js +12 -0
- package/dist/plugins/tidb-session-storage.d.ts +2 -2
- package/dist/plugins/tidb-session-storage.js +12 -11
- package/dist/plugins/tidb-session-store.d.ts +2 -2
- package/dist/plugins/tidb-session-store.js +20 -22
- package/dist/plugins/usage-window-store-sql.d.ts +9 -1
- package/dist/plugins/usage-window-store-sql.js +23 -9
- package/dist/plugins/workflow-journal-store-sql.js +16 -4
- package/dist/run-local.js +2 -10
- package/dist/usage-analytics.d.ts +14 -1
- package/dist/usage-analytics.js +11 -17
- package/package.json +2 -2
package/USAGE.md
CHANGED
|
@@ -842,6 +842,14 @@ ENGINE_ORPHAN_LINGER_MS=2000 # 可选,默认 2s:成孤儿之后还要静多久
|
|
|
842
842
|
引擎进程消失 2.0s、`LOCK` 归还 1.96s。最后那一段「停机收尾」本身还有自己的闸:`LOCK` 归还在收尾链
|
|
843
843
|
尾部的 `backend.close()`,正常亚秒级,但卡死的连接池会被 `hardShutdown` 的 **15s 终局闸**兜住 ⇒ 最坏
|
|
844
844
|
上界是「≤10s + 收尾闸」。自动重起的壳请按「撞锁就退避重试」写,别把 10s 当硬保证。
|
|
845
|
+
- 🔴 **`DRAIN_GRACE_MS` 的生效值分车道 —— 别照抄出厂默认**:出厂 `600000`(10 min)是**服务器部署**的
|
|
846
|
+
取值(半夜一次滚动重启,宁可等长一点也不要砍掉一条在跑的 turn)。而**壳自 spawn 的引擎会被壳注入更短
|
|
847
|
+
的窗**:sema-cli 在装子进程 env 时把它填成一个分钟级的值(2026-09-22 亲读壳侧实现:`60000`;那是**壳的**
|
|
848
|
+
常量,以你手上那版壳为准 —— 单机形下这个窗直接等于"退出后多久不能重开",见上一条的撞锁退避),
|
|
849
|
+
**用户/运维显式设的值原样尊重、壳不覆盖**。⇒ 排壳形的关机/重开预算时,以**进程实际
|
|
850
|
+
生效值**为准:`GET /v1/config/catalog` 里 `DRAIN_GRACE_MS` 那一行的 `effectiveValue` 就是本进程真在用的
|
|
851
|
+
数(它读的是活 config,不是这份文档里的默认值),启动期的夹取/警告行同源。本文档其余各处谈这条排空腿
|
|
852
|
+
时都不带数字,数字只有这一处 —— 照抄默认值去算壳形预算会差一个数量级。
|
|
845
853
|
- **自退留痕 `<数据根>/exit-last.json`**:自退在 **drain 开始那一刻**就把判据写进数据根(`orphan` /
|
|
846
854
|
`reason` / `idleForMs` / `attached` / `inflight` / `lingerMs` + `version` / `at` / `pid`)。壳的 exit
|
|
847
855
|
watch 往往在流断之后才跑,只看退出码分不清"它自己退的"与"它崩了";读到这个文件就能把这个 pid 归入
|
package/dist/boot/stores.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import { FileBackgroundAgentStore, FileMailboxStore, FileRosterStore, FileUsageWindowStore, InMemoryUsageWindowStore, mailboxCrossProcessMountVerdict } from "@sema-agent/core";
|
|
3
|
+
import { createCorruptReadSeat } from "../observability/corrupt-read-seat.js";
|
|
3
4
|
import { createMemorySyncRunner, createMemorySyncTransport } from "../memory-sync-client.js";
|
|
4
5
|
import { memoryEmbedderFor } from "../plugins/memory-embedder.js";
|
|
5
6
|
import { ensurePgMemoryEmbedderMetaSchema, reconcileEmbedderFingerprint } from "../plugins/memory-embedder-fingerprint.js";
|
|
@@ -231,7 +232,7 @@ export async function openStores(ctx) {
|
|
|
231
232
|
}
|
|
232
233
|
else if (backend?.kind === "local") {
|
|
233
234
|
rosterStore = new FileRosterStore(join(config.localDataRoot ?? localRoot, "roster.json"), {
|
|
234
|
-
onCorruptRead: (
|
|
235
|
+
onCorruptRead: createCorruptReadSeat(logger.warn.bind(logger), "roster_corrupt_read"),
|
|
235
236
|
});
|
|
236
237
|
}
|
|
237
238
|
if (rosterStore)
|
|
@@ -251,7 +252,9 @@ export async function openStores(ctx) {
|
|
|
251
252
|
backgroundAgentStore = new TiDBBackgroundAgentStore(mysqlPool);
|
|
252
253
|
}
|
|
253
254
|
else if (backend?.kind === "local") {
|
|
254
|
-
backgroundAgentStore = new FileBackgroundAgentStore(config.localDataRoot ?? localRoot
|
|
255
|
+
backgroundAgentStore = new FileBackgroundAgentStore(config.localDataRoot ?? localRoot, {
|
|
256
|
+
onCorruptRead: createCorruptReadSeat(logger.warn.bind(logger), "background_agent_corrupt_read"),
|
|
257
|
+
});
|
|
255
258
|
}
|
|
256
259
|
if (backgroundAgentStore)
|
|
257
260
|
logger.info("background_agent_store_enabled", { backend: pgPool ? "pg" : mysqlPool ? "tidb" : "file" });
|
|
@@ -305,7 +308,7 @@ export async function openStores(ctx) {
|
|
|
305
308
|
}
|
|
306
309
|
else if (backend?.kind === "local") {
|
|
307
310
|
mailboxStore = new FileMailboxStore(config.localDataRoot ?? localRoot, {
|
|
308
|
-
onCorruptRead: (
|
|
311
|
+
onCorruptRead: createCorruptReadSeat(logger.warn.bind(logger), "mailbox_corrupt_read"),
|
|
309
312
|
});
|
|
310
313
|
mailboxRecipientLifecycle = createDropOnlyRecipientLifecycle(mailboxStore);
|
|
311
314
|
}
|
|
@@ -2,6 +2,7 @@ import { FileWorkflowRunStore, InMemoryWorkflowRunStore } from "@sema-agent/core
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { FleetEventBus } from "../fleet/fleet-bus.js";
|
|
4
4
|
import { LocalWorkflowAgentSessionIndex } from "../orchestration/workflow-agent-session-index.js";
|
|
5
|
+
import { createCorruptReadSeat } from "../observability/corrupt-read-seat.js";
|
|
5
6
|
import { FileWorkflowCompletionInbox, InMemoryWorkflowCompletionInbox, resolveCompletionRoute } from "../orchestration/workflow-completion-inbox.js";
|
|
6
7
|
import { FileWorkflowNotifyJournalStore, JournalingWorkflowRunStore, WorkflowNotifyGate } from "../orchestration/workflow-notify-journal.js";
|
|
7
8
|
import { SubagentSteerRegistry } from "../orchestration/subagent-steer.js";
|
|
@@ -13,7 +14,10 @@ export function createWorkflowOrchestration(ctx) {
|
|
|
13
14
|
const baseWorkflowRunStore = config.selfOrchestrationEnabled
|
|
14
15
|
? config.workflowRunStoreBackend === "memory"
|
|
15
16
|
? new InMemoryWorkflowRunStore()
|
|
16
|
-
: (sqlWorkflowRunStore ??
|
|
17
|
+
: (sqlWorkflowRunStore ??
|
|
18
|
+
new FileWorkflowRunStore(join(config.localDataRoot ?? localRoot, "workflows"), {
|
|
19
|
+
onCorruptRead: createCorruptReadSeat(logger.warn.bind(logger), "workflow_run_corrupt_read"),
|
|
20
|
+
}))
|
|
17
21
|
: undefined;
|
|
18
22
|
const workflowNotifyJournal = config.selfOrchestrationEnabled && config.workflowRunStoreBackend !== "memory"
|
|
19
23
|
? (sqlWorkflowRunStore ? backend.notifyJournal() : new FileWorkflowNotifyJournalStore(join(config.localDataRoot ?? localRoot, "workflows")))
|
package/dist/config-catalog.js
CHANGED
|
@@ -127,7 +127,7 @@ export const CONFIG_CATALOG = [
|
|
|
127
127
|
r("DOCKER_PATH", "orchestration", "path", "docker 二进制路径"),
|
|
128
128
|
r("DOCKER_PIDS_LIMIT", "orchestration", "number", "local-docker fork-bomb 背栓(--pids-limit;非法值回 adapter 默认 512+warn)", { danger: SEC, derivedDefaultNote: "缺席 ⇒ adapter 默认 512" }),
|
|
129
129
|
r("DOCKER_SANDBOX_ENV", "orchestration", "csv", "转发进容器的本进程 env **名字**表(值只在 worker env,不进 spec/center)", { danger: SEC }),
|
|
130
|
-
r("DRAIN_GRACE_MS", "limitsHttp", "number", "SIGTERM 优雅排空窗(ms;期间新计费提交 503+Retry-After)", { staticDefault: String(10 * 60 * 1000), configKey: "drainGraceMs", center: cLimits("drainGraceMs") }),
|
|
130
|
+
r("DRAIN_GRACE_MS", "limitsHttp", "number", "SIGTERM 优雅排空窗(ms;期间新计费提交 503+Retry-After)。出厂默认是**服务器部署**的取值;壳自 spawn 的引擎会被壳注入更短的窗 ⇒ 排壳形关机预算时以本行的 effectiveValue(= 本进程实际生效值)为准,不照抄 staticDefault", { staticDefault: String(10 * 60 * 1000), configKey: "drainGraceMs", center: cLimits("drainGraceMs") }),
|
|
131
131
|
r("DURABLE_APPROVAL", "approval", "boolean", "durable suspend/resume HITL(需 SQL 店;决定 checkpointStore 与 /v1/approvals 面的存在性)", { danger: SEC, derivedDefaultNote: "posture 三态(S-178):显式词恒赢,缺席 = 单用户(REQUIRE_PRINCIPAL≠true)且有耐久店 ⇒ on / 多租户或无耐久店 ⇒ off;生效来源见 GET /v1/diagnostics/wiring 的 serverGates.durableApproval.source", configKey: "durableApproval" }),
|
|
132
132
|
r("E2B_ALLOW_NET", "orchestration", "boolean", "e2b 沙箱出网许可", { danger: SEC, derivedDefaultNote: "缺席 ⇒ 不铸键(adapter 默认允许)" }),
|
|
133
133
|
r("E2B_API_KEY", "orchestration", "secret", "E2B 车道 API 凭据(REMOTE_EXEC=e2b 必需件)", { danger: [...SEC, ...BILL] }),
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **续跑上下文这一次读的结局**(S-577 第二半,7.94.0)—— 属主模块,所有消费腿共用这一只读口。
|
|
3
|
+
*
|
|
4
|
+
* 🔴 **为什么必须有这只铸点**:此前各消费腿各写一句 `.catch(() => null)`(还有一处把异常文案拼上 wire),
|
|
5
|
+
* 于是店层在 S-577 里把坏体从「答缺席」改成「响亮拒」之后,那些 `catch` 会把那次拒**原样吞回缺席** ——
|
|
6
|
+
* 机制上「坏体不再与缺席同答」成立,**用户面上不成立**(用户拿到的还是同一个 409)。判据要钉用户实际
|
|
7
|
+
* 拿到的码,这只铸点就是让那句判据有地方落(test [ref] 的判据订正,当拍采纳)。
|
|
8
|
+
*
|
|
9
|
+
* 三态与各自的理由:
|
|
10
|
+
* · `ok` —— 读出来了。
|
|
11
|
+
* · `absent` —— 行不在(`reapCtx` 按设计在 run 终态后删掉它),**或**读腿抛了一个**不是坏体**的错
|
|
12
|
+
* (连接 / 超时 / 权限 / 店抖动):证不出它在就当它不在,**这是刻意保守**([ref] 方向锚:
|
|
13
|
+
* 拒绝可重试,搁浅不可)。
|
|
14
|
+
* · `corrupt` —— 读腿抛了**坏体类**错(属主谓词 `isJsonColumnBodyProblem`:`not-json` / `malformed`)。
|
|
15
|
+
* **不折成缺席**:两者的运维动作完全不同(前者去查那一行,后者什么都不用做)。
|
|
16
|
+
* ⚠️ `not-text`(投影漂 = 我方的码病)**刻意不进这一态**,原样上抛、响亮成 500 —— 不许伪装成
|
|
17
|
+
* 「数据坏了」,更不许折成缺席(那会让一次代码漂看起来像「这条会话没有上下文」)。
|
|
18
|
+
*/
|
|
19
|
+
import type { CheckpointCtx } from "./route-ctx.js";
|
|
20
|
+
export type ResumeCtxRead = {
|
|
21
|
+
readonly kind: "ok";
|
|
22
|
+
readonly ctx: CheckpointCtx;
|
|
23
|
+
} | {
|
|
24
|
+
readonly kind: "absent";
|
|
25
|
+
} | {
|
|
26
|
+
readonly kind: "corrupt";
|
|
27
|
+
readonly where: string;
|
|
28
|
+
};
|
|
29
|
+
export declare function readResumeCtx(load: Promise<CheckpointCtx | null>): Promise<ResumeCtxRead>;
|
|
30
|
+
/** 坏体的对外答复 —— 与会话策略面**同码**(`session-policy-wire.ts` 的 `corrupt` ⇒ 500),不铸新词。
|
|
31
|
+
* 文案只说「哪一行读不出来 + 该找谁」,不把店层 detail 拼上 wire(内部机件不上 wire,S-376 同律)。 */
|
|
32
|
+
export declare function resumeCtxCorruptAnswer(where: string): {
|
|
33
|
+
status: number;
|
|
34
|
+
body: {
|
|
35
|
+
error: string;
|
|
36
|
+
errorCode: string;
|
|
37
|
+
where: string;
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
//# sourceMappingURL=resume-ctx-read.d.ts.map
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { isJsonColumnBodyProblem, SqlJsonColumnError } from "../plugins/sql-json-column.js";
|
|
2
|
+
export async function readResumeCtx(load) {
|
|
3
|
+
try {
|
|
4
|
+
const ctx = await load;
|
|
5
|
+
return ctx == null ? { kind: "absent" } : { kind: "ok", ctx };
|
|
6
|
+
}
|
|
7
|
+
catch (e) {
|
|
8
|
+
if (isJsonColumnBodyProblem(e))
|
|
9
|
+
return { kind: "corrupt", where: e.where };
|
|
10
|
+
if (e instanceof SqlJsonColumnError)
|
|
11
|
+
throw e;
|
|
12
|
+
return { kind: "absent" };
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function resumeCtxCorruptAnswer(where) {
|
|
16
|
+
return {
|
|
17
|
+
status: 500,
|
|
18
|
+
body: {
|
|
19
|
+
error: "the stored resume context for this session cannot be read — nothing was decided and nothing was resumed. Ask whoever operates this deployment to look at the row named in `where`: it has to be repaired or removed before this session can resume.",
|
|
20
|
+
errorCode: "corrupt",
|
|
21
|
+
where,
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
//# sourceMappingURL=resume-ctx-read.js.map
|
package/dist/http/resume-legs.js
CHANGED
|
@@ -35,6 +35,7 @@ import { gatedPrincipal, explicitOperatorOk } from "./principal-gate.js";
|
|
|
35
35
|
import { buildActiveRunConflict, resumeEntryForGate } from "./active-run-conflict.js";
|
|
36
36
|
import { commitPlacement, devicePlacementErrorBody } from "./device-placement.js";
|
|
37
37
|
import { ASK_USER_QUESTION_TOOL_NAME } from "../approval-content-kind.js";
|
|
38
|
+
import { readResumeCtx, resumeCtxCorruptAnswer } from "./resume-ctx-read.js";
|
|
38
39
|
function isCheckpointReopenedFailure(r) {
|
|
39
40
|
const code = failureCodeOf(r.terminal);
|
|
40
41
|
return code === "resume.env_failed" || code === "resume.tool_unavailable" || code === "resume.tool_contract_mismatch";
|
|
@@ -278,13 +279,10 @@ export function createResumeLegs(ctx) {
|
|
|
278
279
|
: await startFreshHostTurn(plan.hostSessionId, plan.message, admittedPayer, req, parkedResume, plan.runId);
|
|
279
280
|
}
|
|
280
281
|
}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
catch (e) {
|
|
286
|
-
return { status: 409, body: { error: `resume context load failed: ${e instanceof Error ? e.message : String(e)}`, errorCode: "conflict.resume_context_unavailable" } };
|
|
287
|
-
}
|
|
282
|
+
const ctxRead0 = await readResumeCtx(ctxPromise);
|
|
283
|
+
if (ctxRead0.kind === "corrupt")
|
|
284
|
+
return resumeCtxCorruptAnswer(ctxRead0.where);
|
|
285
|
+
const ctx = ctxRead0.kind === "ok" ? ctxRead0.ctx : null;
|
|
288
286
|
if (!ctx)
|
|
289
287
|
return { status: 409, body: { error: "resume context missing — cannot rebuild task config", errorCode: "conflict.resume_context_unavailable" } };
|
|
290
288
|
const auth = { sessionId: cp.sessionId, principal: decodeCheckpointScope(cp.scope), memoryScope: ctx.memoryScope };
|
|
@@ -1036,9 +1034,12 @@ export function createResumeLegs(ctx) {
|
|
|
1036
1034
|
if (gateKind !== RESUMABLE_GATE_KIND) {
|
|
1037
1035
|
return { status: 409, body: { error: `task is suspended on a '${gateKind ?? "unknown"}' gate, not a resumable resource/preempt suspension — an approval gate must be decided via POST /v1/approvals/:id/decide`, errorCode: "gate_not_resumable" } };
|
|
1038
1036
|
}
|
|
1039
|
-
const
|
|
1040
|
-
if (
|
|
1037
|
+
const ctxRead = await readResumeCtx(cs.getCtx(sessionId));
|
|
1038
|
+
if (ctxRead.kind === "corrupt")
|
|
1039
|
+
return resumeCtxCorruptAnswer(ctxRead.where);
|
|
1040
|
+
if (ctxRead.kind === "absent")
|
|
1041
1041
|
return { status: 409, body: { error: "resume context missing — cannot rebuild task config", errorCode: "conflict.resume_context_unavailable" } };
|
|
1042
|
+
const ctx = ctxRead.ctx;
|
|
1042
1043
|
const auth = { sessionId: cp.sessionId, principal: decodeCheckpointScope(cp.scope), memoryScope: ctx.memoryScope };
|
|
1043
1044
|
const { spec, engineNotices } = await resolveResumeSpec(ctx.body, req, auth);
|
|
1044
1045
|
const { objective: resumeObjective, sessionId: _sessionId, ...taskConfig } = spec;
|
|
@@ -1050,7 +1051,10 @@ export function createResumeLegs(ctx) {
|
|
|
1050
1051
|
if (!deps.runStore) {
|
|
1051
1052
|
return { status: 501, body: { error: "delivering this decision requires a durable run store (DB_BACKEND=mysql|pg|local)", errorCode: "capability.run_store_required" } };
|
|
1052
1053
|
}
|
|
1053
|
-
const
|
|
1054
|
+
const ctxRead = await readResumeCtx(cs.getCtx(sessionId));
|
|
1055
|
+
if (ctxRead.kind === "corrupt")
|
|
1056
|
+
return resumeCtxCorruptAnswer(ctxRead.where);
|
|
1057
|
+
const ctx = ctxRead.kind === "ok" ? ctxRead.ctx : null;
|
|
1054
1058
|
if (!ctx) {
|
|
1055
1059
|
return {
|
|
1056
1060
|
status: 409,
|
|
@@ -1140,9 +1144,12 @@ export function createResumeLegs(ctx) {
|
|
|
1140
1144
|
throw e;
|
|
1141
1145
|
}
|
|
1142
1146
|
}
|
|
1143
|
-
const
|
|
1144
|
-
if (
|
|
1147
|
+
const ctxRead = await readResumeCtx(cs.getCtx(sessionId));
|
|
1148
|
+
if (ctxRead.kind === "corrupt")
|
|
1149
|
+
return resumeCtxCorruptAnswer(ctxRead.where);
|
|
1150
|
+
if (ctxRead.kind === "absent")
|
|
1145
1151
|
return { status: 409, body: { error: "resume context missing — cannot rebuild task config", errorCode: "conflict.resume_context_unavailable" } };
|
|
1152
|
+
const ctx = ctxRead.ctx;
|
|
1146
1153
|
const auth = { sessionId: cp.sessionId, principal: decodeCheckpointScope(cp.scope), memoryScope: ctx.memoryScope };
|
|
1147
1154
|
const { spec, engineNotices } = await resolveResumeSpec(ctx.body, req, auth);
|
|
1148
1155
|
const { objective: resumeObjective, sessionId: _sessionId, ...taskConfig } = spec;
|
|
@@ -1222,9 +1229,12 @@ export function createResumeLegs(ctx) {
|
|
|
1222
1229
|
},
|
|
1223
1230
|
};
|
|
1224
1231
|
}
|
|
1225
|
-
const
|
|
1226
|
-
if (
|
|
1232
|
+
const ctxRead = await readResumeCtx(cs.getCtx(sessionId));
|
|
1233
|
+
if (ctxRead.kind === "corrupt")
|
|
1234
|
+
return resumeCtxCorruptAnswer(ctxRead.where);
|
|
1235
|
+
if (ctxRead.kind === "absent")
|
|
1227
1236
|
return { status: 409, body: { error: "resume context missing — cannot rebuild task config", errorCode: "conflict.resume_context_unavailable" } };
|
|
1237
|
+
const ctx = ctxRead.ctx;
|
|
1228
1238
|
const auth = { sessionId: cp.sessionId, principal: decodeCheckpointScope(cp.scope), memoryScope: ctx.memoryScope };
|
|
1229
1239
|
const effMode = storedBodyPermissionMode(ctx.body);
|
|
1230
1240
|
if (permissionModeAfter !== undefined && effMode !== "plan") {
|
|
@@ -331,6 +331,18 @@ export async function handleImages(req, res, match, ctx) {
|
|
|
331
331
|
sendError(res, 400, "request.field_invalid", "profile, repo, digest are required");
|
|
332
332
|
return;
|
|
333
333
|
}
|
|
334
|
+
if (b.bands !== undefined && b.bands !== null && !Array.isArray(b.bands)) {
|
|
335
|
+
sendError(res, 400, "request.field_invalid", `bands must be an array when present`);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
const badObjectMeta = ["toolchainVersions", "capabilities", "podContract"].find((k) => {
|
|
339
|
+
const v = b[k];
|
|
340
|
+
return v !== undefined && v !== null && (typeof v !== "object" || Array.isArray(v));
|
|
341
|
+
});
|
|
342
|
+
if (badObjectMeta !== undefined) {
|
|
343
|
+
sendError(res, 400, "request.field_invalid", `${badObjectMeta} must be an object when present`);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
334
346
|
const id = await idx.registerBuilding({
|
|
335
347
|
profile: b.profile,
|
|
336
348
|
bands: Array.isArray(b.bands) ? b.bands : [],
|
package/dist/http/routes/runs.js
CHANGED
|
@@ -29,6 +29,7 @@ import { assertNever } from "../route-ctx.js";
|
|
|
29
29
|
import { mintAndDriveDurableRun } from "../durable-run-leg.js";
|
|
30
30
|
import { RUN_NOT_FOUND_MESSAGE, runNotFoundMessage } from "../route-ctx.js";
|
|
31
31
|
import { withPrincipal } from "../../observability/principal-context.js";
|
|
32
|
+
import { readResumeCtx } from "../resume-ctx-read.js";
|
|
32
33
|
const ASK_PARKING_RETRY_AFTER_SEC = 2;
|
|
33
34
|
const ASK_ACTOR_LABEL_MAX = 256;
|
|
34
35
|
const AskDecisionActorSchema = ActorAssertionWire.strip();
|
|
@@ -588,8 +589,12 @@ export async function handleRuns(req, res, match, ctx) {
|
|
|
588
589
|
const leafId = await Promise.resolve(deps.sessionStorage.getLeafId(run.sessionId)).catch(() => undefined);
|
|
589
590
|
if (leafId === undefined || leafId === null)
|
|
590
591
|
return;
|
|
591
|
-
const
|
|
592
|
-
if (
|
|
592
|
+
const resumeRead = await readResumeCtx(Promise.resolve(cs.getCtx(run.sessionId)));
|
|
593
|
+
if (resumeRead.kind === "corrupt") {
|
|
594
|
+
mintResult.corruptCtxWhere = resumeRead.where;
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
if (resumeRead.kind === "absent") {
|
|
593
598
|
mintResult.noResumeCtx = true;
|
|
594
599
|
return;
|
|
595
600
|
}
|
|
@@ -607,8 +612,13 @@ export async function handleRuns(req, res, match, ctx) {
|
|
|
607
612
|
createdAt: Date.now(),
|
|
608
613
|
sourceTaskId: taskId,
|
|
609
614
|
});
|
|
610
|
-
const
|
|
611
|
-
if (
|
|
615
|
+
const stillRead = await readResumeCtx(Promise.resolve(cs.getCtx(run.sessionId)));
|
|
616
|
+
if (stillRead.kind === "corrupt") {
|
|
617
|
+
await cs.expire(wakeToken, cpScope).catch(() => undefined);
|
|
618
|
+
mintResult.corruptCtxWhere = stillRead.where;
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
if (stillRead.kind !== "ok") {
|
|
612
622
|
await cs.expire(wakeToken, cpScope).catch(() => undefined);
|
|
613
623
|
mintResult.noResumeCtx = true;
|
|
614
624
|
return;
|
|
@@ -638,6 +648,10 @@ export async function handleRuns(req, res, match, ctx) {
|
|
|
638
648
|
sendDuplicateKey();
|
|
639
649
|
return;
|
|
640
650
|
}
|
|
651
|
+
if (mintResult.corruptCtxWhere !== undefined) {
|
|
652
|
+
sendError(res, 500, "corrupt", "run already ended and this session's stored resume context cannot be read — no wake card was minted and nothing was queued. Ask whoever operates this deployment to repair or remove the row named in `where` before steering this session again.", { where: mintResult.corruptCtxWhere });
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
641
655
|
if (mintResult.noResumeCtx) {
|
|
642
656
|
sendError(res, 409, "conflict.resume_context_unavailable", "run already ended and this session's resume context is gone — a steer queued now could never be delivered by POST /v1/sessions/:id/wake; submit a new task instead");
|
|
643
657
|
return;
|
|
@@ -2,7 +2,8 @@ import { persistedPlaneOf } from "../../terminal.js";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { getWorkflowRun, subscribeWorkflow, deriveAgentDisplayStatus, callKeyOrdinal } from "@sema-agent/core";
|
|
4
4
|
import { redactSteerIn, STEER_IN_MAX_CHARS, STEER_IN_MAX_REQUEST_CHARS } from "../../orchestration/workflow-agent-steer.js";
|
|
5
|
-
import {
|
|
5
|
+
import { journalEntryOfBody, journalEntryPayload, journalRowPayloadOf, readJournalRowBody } from "../../orchestration/workflow-journal-entry.js";
|
|
6
|
+
import { decodeJsonColumn } from "../../plugins/sql-json-column.js";
|
|
6
7
|
import { redactSecrets } from "../../trace/redact.js";
|
|
7
8
|
import { createHash } from "node:crypto";
|
|
8
9
|
import { redactErrorMessage, redactHead } from "../../observability/run-terminal.js";
|
|
@@ -108,7 +109,7 @@ export async function handleWorkflows(req, res, match, ctx) {
|
|
|
108
109
|
return oversizeStub(row.callKey, row.resultBytes);
|
|
109
110
|
}
|
|
110
111
|
try {
|
|
111
|
-
const { payload, parked } = journalEntryPayload(
|
|
112
|
+
const { payload, parked } = journalEntryPayload(journalEntryOfBody(row.callKey, decodeJsonColumn(row.resultJson, readJournalRowBody, `workflow_journal.result for entry ${row.callKey}`)));
|
|
112
113
|
return projectEntry(row.callKey, payload, parked);
|
|
113
114
|
}
|
|
114
115
|
catch {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 🔴 **腐读披露座的唯一属主**(S-569,core 7.28.1 提货批;#1013 F4 的 server 半场)。
|
|
3
|
+
*
|
|
4
|
+
* ## core 那边发生了什么
|
|
5
|
+
* core 对**读不出来的**持久文件是 documented fail-open —— 当作「缺席」继续跑(抛会让整条任务死,也会
|
|
6
|
+
* 自锁修复写)。7.27.0(#1013 F4)把这条 fail-open 的披露面收成**一只共用 guard**
|
|
7
|
+
* (`stores/file/corrupt-read-sink.js`:`CorruptReadInfo` + `guardCorruptSink`),并给每一只容错 File 店
|
|
8
|
+
* 都开了座 —— core 自己的原话是那只 guard「hand-written four times」,其中三份包住了 async sink 的
|
|
9
|
+
* rejection、mailbox 那份只包住同步 throw。**同一个病,server 这侧也有**:座的载荷投影当时在本仓被
|
|
10
|
+
* 手抄了四遍(roster / mailbox / run-local 与 store-backend 的两只 FileStorageBackend)。
|
|
11
|
+
*
|
|
12
|
+
* ## 为什么是一个属主,而不是第五、六、七、八份手抄([ref])
|
|
13
|
+
* 7.27.0 之后 server 真构造的店里又多出**四只空座**(`FileCheckpointStore` / `FileWorkflowRunStore` /
|
|
14
|
+
* `FileBackgroundAgentStore` / `FileWorkflowJournalStore`)。按老形接线 = 八处各写一遍
|
|
15
|
+
* `{ path, reason, ...可选成员 }` 的转发 —— 而这八处**本来就该是同一句话**:
|
|
16
|
+
*
|
|
17
|
+
* 「一次持久读因为字节读不出来被当成了缺席。ENOENT 不触发(那是真缺席,不是腐读)。」
|
|
18
|
+
*
|
|
19
|
+
* 载荷形不是本仓铸的:`FileStorageCorruptReadInfo` 是 **core 的导出**,并且是 core 亲口说的**最宽**那一只
|
|
20
|
+
* ——"the widest shape any store this backend constructs can deliver, so ONE sink reaches all of them…
|
|
21
|
+
* the two-field store seats are satisfied by it"。于是本属主就按那只最宽形收参:两个窄座
|
|
22
|
+
* (`CorruptReadInfo = {path, reason}`)与 roster/mailbox 的内联形都因**可选成员**而逐字接受它
|
|
23
|
+
* (`{path,reason}` 可赋给最宽形 ⇒ 吃最宽形的函数可赋给窄座)。**本仓因此零镜像类型**。
|
|
24
|
+
*
|
|
25
|
+
* ## 刻意不做的两件事
|
|
26
|
+
* · **不在这里下统一结论**:后果按脸各不相同(少了会话规则这一层 / 列表静默少行 / rewind 边界读成缺席 /
|
|
27
|
+
* durable park 丢一行 / resume 读到「无事可重放」于是把活重跑),把其中一种写成「整个任务无约束」会误导
|
|
28
|
+
* 事故定级,而且对另外几张脸根本不成立(codex R2-medium 的原案)。所以 `note` 是**按脸传进来的一句话**,
|
|
29
|
+
* 不是本属主里的一张词表 —— 属主里放一张「事件名 → 后果句」的表,等于把调用点的知识搬进属主再抄回去。
|
|
30
|
+
* · **不接 `recordFailOpen`**:那条通道是给 F 类(装饰/缓存)fail-open 的计数与探针用的;这里的 fail-open
|
|
31
|
+
* 是 core 的裁定、本仓不改它,server 的职责只有一条 —— **不许无声**([ref] 安全轴纪律)。响亮 warn 就是
|
|
32
|
+
* 那条职责的兑现;要不要再进 fail-open 台账是另一件事、另一台车的设计。
|
|
33
|
+
*
|
|
34
|
+
* ⚠️ core 那只 guard 的纪律同样适用:sink 是**无绑定**调用的(`this` 是 undefined)。本属主返回的是箭头
|
|
35
|
+
* 闭包,天然满足;但**不要**把某个对象的裸方法引用直接塞进座 —— 读 `this` 会抛,而那个抛会被 core 的
|
|
36
|
+
* guard 吞掉,于是披露**静默丢失**(比没接座更难查)。
|
|
37
|
+
*/
|
|
38
|
+
import type { FileStorageCorruptReadInfo } from "@sema-agent/core";
|
|
39
|
+
import type { Logger } from "./logger.js";
|
|
40
|
+
/**
|
|
41
|
+
* 铸一只腐读披露座。
|
|
42
|
+
*
|
|
43
|
+
* @param warn 日志座。形取 `Logger["warn"]` —— 本仓日志 sink 形的既有属主,`plugins/store-backend.ts` 的
|
|
44
|
+
* `BootLogSink` 结构上就是它,所以这里**不引入第三种拼法**。
|
|
45
|
+
* @param event warn 事件名。**留在调用点**:它是那只店的身份,接线锚(test/roster-corrupt-read.test.ts)
|
|
46
|
+
* 正是靠「构造实参里能读到这个名字」来证明座不是个空回调。
|
|
47
|
+
* @param note 这张脸的后果句(可选)。缺席 ⇒ 不写这个键。
|
|
48
|
+
*/
|
|
49
|
+
export declare function createCorruptReadSeat(warn: Logger["warn"], event: string, note?: string): (info: FileStorageCorruptReadInfo) => void;
|
|
50
|
+
/**
|
|
51
|
+
* `FileStorageBackend` 那两只座共用的后果句 —— 一个座覆盖 core 转发的三张脸,所以共同事实只有一句,
|
|
52
|
+
* 差别只在第三张脸叫什么(`run-local` 腿看到的是 file-snapshot,HTTP 服务腿看到的是 file-history)。
|
|
53
|
+
* 这一句从前在两处各抄一份、只差一个词;现在词从参数来,句子只有一份。
|
|
54
|
+
*/
|
|
55
|
+
export declare function buildDurableAbsenceNote(thirdFace: string): string;
|
|
56
|
+
//# sourceMappingURL=corrupt-read-seat.d.ts.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function createCorruptReadSeat(warn, event, note) {
|
|
2
|
+
return (info) => warn(event, {
|
|
3
|
+
path: info.path,
|
|
4
|
+
reason: info.reason,
|
|
5
|
+
...(info.sessionId !== undefined ? { sessionId: info.sessionId } : {}),
|
|
6
|
+
...(info.principal !== undefined ? { principal: info.principal } : {}),
|
|
7
|
+
...(note !== undefined ? { note } : {}),
|
|
8
|
+
});
|
|
9
|
+
}
|
|
10
|
+
export function buildDurableAbsenceNote(thirdFace) {
|
|
11
|
+
return ("a durable read was treated as ABSENT because the bytes were unreadable (never a plain ENOENT). " +
|
|
12
|
+
"Consequence depends on which store read it: session-policy = that run lost its SUBTRACTIVE session-rule " +
|
|
13
|
+
"layer (deployment policy / hooks / shell gate still applied); session repo = a listing silently omitted " +
|
|
14
|
+
`rows; ${thirdFace}. Inspect the named path before deciding.`);
|
|
15
|
+
}
|
|
16
|
+
//# sourceMappingURL=corrupt-read-seat.js.map
|
|
@@ -137,6 +137,10 @@ export declare const FAIL_OPEN_TAGS: {
|
|
|
137
137
|
readonly cls: "F";
|
|
138
138
|
readonly note: "S-550(`plugins/run-store-sql.ts` 的 `usageScan`,`/v1/usage/*` 的扫窗腿):窗内某一行的 `task_run.result` 这一格读不出形 ⇒ 这一行**计 tasks、不计 token/cost**(`stats` 键缺席),其余行照常。放行的最坏后果 = 用量总数偏低一行的量(`projectUsageStats` 的成文降级口径逐字如此,三个后端共用);不放行的代价是**一行坏 cell 打掉整个租户的用量页**(扫窗上限 50k 行)。⚠️ 同一列在 `getRun` / `listRuns` 两条腿上**刻意相反**(抛,不容忍):那两条腿上这一格是提交方要的**答案**,把它折成「跑完了没有结果」是在替一次不知道的读编一个答案;而这里它只是一个加数。故 F 类;**只容忍体的问题**,`not-text`(读腿漏了投影)与连接 / 超时原样上抛。";
|
|
139
139
|
};
|
|
140
|
+
readonly "server.sql-json.memory-engine.embedding": {
|
|
141
|
+
readonly cls: "F";
|
|
142
|
+
readonly note: "S-550 车③a(`plugins/memory-engine-pg.ts` 的 `search()` 便携档):一行 `agent_memory_engine_entry.embedding` 这一格读不出形(撕裂字节 / 手改 / 不是 JSON 数组 / `null` 字面)⇒ **这一行退到词面档打分**,行**从不消失**(这一列的成文口径逐字如此:any missing/invalid side falls open to the lexical floor for THIS row)。放行的最坏后果 = 这一行的排名按词面算、可能靠后;不放行的代价是**一行坏 cell 打掉整个 scope 的记忆检索**(`search()` 是 `rows` 的整只循环)。这一列不携任何授权 / 来源 / 曝光带事实(那些全在 `frontmatter`,它照旧**拒**)。故 F 类;**只容忍体的问题**,`not-text`(读腿漏了投影)与连接 / 超时原样上抛。⚠️ 如实交代:改调前 `not-json` 经裸 `JSON.parse` **抛**、而「解出来不是数组」只降级 —— 同一列两个方向;本车把两支收成一支(降级 + 留痕),这是 S-550 整批**唯一**一处把原本 fail-closed 的臂改成容忍。";
|
|
143
|
+
};
|
|
140
144
|
readonly "server.retention.sweep-report-dropped": {
|
|
141
145
|
readonly cls: "F";
|
|
142
146
|
readonly note: "留存 sweep 的**失败上报**自己抛了(poison error 的 toString / 一条抛异常的 logger transport)⇒ 那一条 warn 与 stuck 指标发不出去。放行的最坏后果是**可观测性**损失而不是数据面损失:删除与审计的正确性完全在店事务里,本臂只影响运维看不看得见「这个域连败了」。不放行的后果反而更坏 —— 一个从上报里逃出去的异常会打断 per-domain 循环、饿死后面每一个域(reapers 的同款保护逐字同理由)。";
|
|
@@ -128,6 +128,10 @@ export const FAIL_OPEN_TAGS = {
|
|
|
128
128
|
cls: "F",
|
|
129
129
|
note: "S-550(`plugins/run-store-sql.ts` 的 `usageScan`,`/v1/usage/*` 的扫窗腿):窗内某一行的 `task_run.result` 这一格读不出形 ⇒ 这一行**计 tasks、不计 token/cost**(`stats` 键缺席),其余行照常。放行的最坏后果 = 用量总数偏低一行的量(`projectUsageStats` 的成文降级口径逐字如此,三个后端共用);不放行的代价是**一行坏 cell 打掉整个租户的用量页**(扫窗上限 50k 行)。⚠️ 同一列在 `getRun` / `listRuns` 两条腿上**刻意相反**(抛,不容忍):那两条腿上这一格是提交方要的**答案**,把它折成「跑完了没有结果」是在替一次不知道的读编一个答案;而这里它只是一个加数。故 F 类;**只容忍体的问题**,`not-text`(读腿漏了投影)与连接 / 超时原样上抛。",
|
|
130
130
|
},
|
|
131
|
+
"server.sql-json.memory-engine.embedding": {
|
|
132
|
+
cls: "F",
|
|
133
|
+
note: "S-550 车③a(`plugins/memory-engine-pg.ts` 的 `search()` 便携档):一行 `agent_memory_engine_entry.embedding` 这一格读不出形(撕裂字节 / 手改 / 不是 JSON 数组 / `null` 字面)⇒ **这一行退到词面档打分**,行**从不消失**(这一列的成文口径逐字如此:any missing/invalid side falls open to the lexical floor for THIS row)。放行的最坏后果 = 这一行的排名按词面算、可能靠后;不放行的代价是**一行坏 cell 打掉整个 scope 的记忆检索**(`search()` 是 `rows` 的整只循环)。这一列不携任何授权 / 来源 / 曝光带事实(那些全在 `frontmatter`,它照旧**拒**)。故 F 类;**只容忍体的问题**,`not-text`(读腿漏了投影)与连接 / 超时原样上抛。⚠️ 如实交代:改调前 `not-json` 经裸 `JSON.parse` **抛**、而「解出来不是数组」只降级 —— 同一列两个方向;本车把两支收成一支(降级 + 留痕),这是 S-550 整批**唯一**一处把原本 fail-closed 的臂改成容忍。",
|
|
134
|
+
},
|
|
131
135
|
"server.retention.sweep-report-dropped": {
|
|
132
136
|
cls: "F",
|
|
133
137
|
note: "留存 sweep 的**失败上报**自己抛了(poison error 的 toString / 一条抛异常的 logger transport)⇒ 那一条 warn 与 stuck 指标发不出去。放行的最坏后果是**可观测性**损失而不是数据面损失:删除与审计的正确性完全在店事务里,本臂只影响运维看不看得见「这个域连败了」。不放行的后果反而更坏 —— 一个从上报里逃出去的异常会打断 per-domain 循环、饿死后面每一个域(reapers 的同款保护逐字同理由)。",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* 收敛到这里之后,第三臂到货 ⇒ 本文件**一处**编译红。
|
|
17
17
|
*
|
|
18
18
|
* 🔴 **park 臂的形是 fail-closed 的**:core 的类型要求 `parked.terminal.kind === "paused"`。从盘上读回来的
|
|
19
|
-
* 字节没有类型,所以 {@link
|
|
19
|
+
* 字节没有类型,所以 {@link readJournalRowBody} 在铸 park 臂时**亲验**这一位,不合就判坏——它是 LOAD-BEARING
|
|
20
20
|
* 面上的坏行(一条说自己 park 了却拿不出暂停终局的记录,既不能驱动、也不能当成跑完),吞掉它会让 resume
|
|
21
21
|
* 在一个被钉住的子代 session 旁边重新起一只活跑,正是 core 那条「一次性、响亮」的 schema 台阶要防的形。
|
|
22
22
|
*
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
* 而不是把一个暂停终局当成可重放结果)。
|
|
26
26
|
*/
|
|
27
27
|
import type { TaskResult, WorkflowJournalEntry } from "@sema-agent/core";
|
|
28
|
+
import type { JsonColumnReader } from "../plugins/sql-json-column.js";
|
|
28
29
|
/** park 臂的载荷型(core 的 `WorkflowJournalEntry` park 成员逐字):一只 `terminal` 收敛到 `paused` 的 `TaskResult`。 */
|
|
29
30
|
export type ParkedTaskResult = TaskResult & {
|
|
30
31
|
terminal: Extract<TaskResult["terminal"], {
|
|
@@ -63,16 +64,33 @@ export declare function journalEntryPayload(entry: WorkflowJournalEntry): {
|
|
|
63
64
|
*/
|
|
64
65
|
export declare function journalRowPayloadOf(entry: WorkflowJournalEntry): string;
|
|
65
66
|
/**
|
|
66
|
-
*
|
|
67
|
-
* 与 HTTP journal 读面共读它)。
|
|
67
|
+
* 盘上一行 payload 的**形读者**(S-550 车③a:`workflow_journal.result` 这一列的唯一判据)。
|
|
68
68
|
*
|
|
69
69
|
* 判别式 = 解出来的对象上有没有**自有** `parked` 键。它与 `TaskResult` 结构上不相交(core 的 `TaskResult`
|
|
70
70
|
* 没有这个成员),所以历史字节(裸 TaskResult)恒判 result 臂 —— 不需要任何「老字节 = result」的兼容规则,
|
|
71
71
|
* 判别式自己就说清了。
|
|
72
72
|
*
|
|
73
|
-
* park 臂验 `terminal.kind === "paused"`;不合 ⇒
|
|
73
|
+
* park 臂验 `terminal.kind === "paused"`;不合 ⇒ 判坏,由 LOAD-BEARING 的读腿把它当店故障传上去。
|
|
74
74
|
* 一条「说自己 park 了却拿不出暂停终局」的行既驱动不了、也不能当跑完,吞掉它会让 resume 在被钉住的子代
|
|
75
|
-
* session 旁边重新起一只活跑 ——
|
|
75
|
+
* session 旁边重新起一只活跑 —— 与 {@link journalRowPayloadOf} 那道信封屏障要防的是同一件事。
|
|
76
|
+
*
|
|
77
|
+
* 🔴 **「一只 JSON 对象」这一条也必须判**(S-550):此前回装点是裸 `JSON.parse` + `as TaskResult`,于是
|
|
78
|
+
* 一行 `null` 字面 / JSON 字符串标量 / 双重编码的 `result` 会被当成一条**合法的已完成记录**端给 resume
|
|
79
|
+
* (`{callKey, result: null}`)—— 那正是「一次已完成的子代被当成跑过而不再跑」与「一条坏行冒充跑完」
|
|
80
|
+
* 两种最坏形的同一个入口。
|
|
81
|
+
*/
|
|
82
|
+
export declare const readJournalRowBody: JsonColumnReader<JournalRowBody>;
|
|
83
|
+
/** {@link readJournalRowBody} 收窄出来的两臂(判别位已定,载荷已验形)。 */
|
|
84
|
+
export type JournalRowBody = {
|
|
85
|
+
parked: true;
|
|
86
|
+
payload: ParkedTaskResult;
|
|
87
|
+
} | {
|
|
88
|
+
parked: false;
|
|
89
|
+
payload: TaskResult;
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* {@link journalRowPayloadOf} 的逆的**后半**:已解码、已验形的载荷 → 记录。**唯一**的铸点(SQL 店的 `load`
|
|
93
|
+
* 与 HTTP journal 读面共读它;两处各自经 `decodeJsonColumn(text, readJournalRowBody, …)` 把文本收成这只体)。
|
|
76
94
|
*/
|
|
77
|
-
export declare function
|
|
95
|
+
export declare function journalEntryOfBody(callKey: string, body: JournalRowBody): WorkflowJournalEntry;
|
|
78
96
|
//# sourceMappingURL=workflow-journal-entry.d.ts.map
|
|
@@ -5,16 +5,23 @@ export function journalRowPayloadOf(entry) {
|
|
|
5
5
|
const { payload, parked } = journalEntryPayload(entry);
|
|
6
6
|
return JSON.stringify(parked ? { parked: payload } : payload);
|
|
7
7
|
}
|
|
8
|
-
export
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const payload = raw.parked;
|
|
12
|
-
const terminal = payload.terminal;
|
|
13
|
-
if (terminal === undefined || terminal.kind !== "paused") {
|
|
14
|
-
throw new Error(`workflow journal: entry ${callKey} is recorded as PARKED but its stored terminal is "${terminal === undefined ? "absent" : terminal.kind}" (expected "paused") — refusing to admit it (a park row that cannot be driven must not be replayed as a completed result)`);
|
|
15
|
-
}
|
|
16
|
-
return { callKey, parked: { ...payload, terminal } };
|
|
8
|
+
export const readJournalRowBody = (body) => {
|
|
9
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
|
10
|
+
return { problem: "expected a JSON object (a TaskResult, or the `{parked: …}` envelope)" };
|
|
17
11
|
}
|
|
18
|
-
|
|
12
|
+
if (!Object.hasOwn(body, "parked"))
|
|
13
|
+
return { value: { parked: false, payload: body } };
|
|
14
|
+
const payload = body.parked;
|
|
15
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
|
|
16
|
+
return { problem: "the `parked` envelope does not carry an object" };
|
|
17
|
+
}
|
|
18
|
+
const terminal = payload.terminal;
|
|
19
|
+
if (terminal === undefined || terminal.kind !== "paused") {
|
|
20
|
+
return { problem: `recorded as PARKED but its stored terminal is "${terminal === undefined ? "absent" : terminal.kind}" (expected "paused") — a park row that cannot be driven must not be replayed as a completed result` };
|
|
21
|
+
}
|
|
22
|
+
return { value: { parked: true, payload: { ...payload, terminal } } };
|
|
23
|
+
};
|
|
24
|
+
export function journalEntryOfBody(callKey, body) {
|
|
25
|
+
return body.parked ? { callKey, parked: body.payload } : { callKey, result: body.payload };
|
|
19
26
|
}
|
|
20
27
|
//# sourceMappingURL=workflow-journal-entry.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { mysqlDriver, pgDriver, dialectJsonEncoder } from "./sql-driver.js";
|
|
2
2
|
import { canAskTransition } from "../approval-ask-machine.js";
|
|
3
3
|
import { isDupKeyError, isMissingColumnError } from "./sql-errors.js";
|
|
4
|
-
import { decodeJsonColumn, foldJsonColumn, jsonColumnText,
|
|
4
|
+
import { decodeJsonColumn, foldJsonColumn, jsonColumnText, readJsonObjectColumn, readTriStateJsonValueColumn } from "./sql-json-column.js";
|
|
5
5
|
import { ensureIndexes } from "./ensure-index.js";
|
|
6
6
|
export function assertIntegralEpochMs(field, value) {
|
|
7
7
|
if (!Number.isSafeInteger(value)) {
|
|
@@ -284,7 +284,7 @@ function mapAskRowBody(r, askId) {
|
|
|
284
284
|
decisionActor: r.decision_actor == null ? null : decodeJsonColumn(r.decision_actor, readJsonObjectColumn, `${APPROVAL_ASK_TABLE}.decision_actor for ask ${askId}`),
|
|
285
285
|
decisionNote: strOrNull(r.decision_note),
|
|
286
286
|
settledBy: strOrNull(r.settled_by),
|
|
287
|
-
...(r.updated_input == null ? {} : { updatedInput: decodeJsonColumn(r.updated_input,
|
|
287
|
+
...(r.updated_input == null ? {} : { updatedInput: decodeJsonColumn(r.updated_input, readTriStateJsonValueColumn, `${APPROVAL_ASK_TABLE}.updated_input for ask ${askId}`) }),
|
|
288
288
|
decidedAtMs: numOrNull(r.decided_at_ms),
|
|
289
289
|
deniedReason: strOrNull(r.denied_reason),
|
|
290
290
|
gateToken: strOrNull(r.gate_token),
|
|
@@ -760,9 +760,22 @@ export declare class SqlCheckpointStore implements CheckpointStore {
|
|
|
760
760
|
peekPendingScope(sessionId: string, binding?: PendingRowBinding): Promise<string | null | undefined>;
|
|
761
761
|
/** Upsert the resume rebuild inputs for a session — written at SUBMIT (durable mode on) and, since S-433 F2,
|
|
762
762
|
* once per resume leg at its CAS win point (`DriveResumeArgs.resumeCtx`). Unconditional per `session_id`:
|
|
763
|
-
* the ordering fence lives at the CALLER's CAS win, not in a version column here.
|
|
764
|
-
|
|
765
|
-
|
|
763
|
+
* the ordering fence lives at the CALLER's CAS win, not in a version column here.
|
|
764
|
+
*
|
|
765
|
+
* 🔴 `ctx: object`, not `unknown` (S-577): the read leg judges this column as a JSON **object**
|
|
766
|
+
* ({@link decodeCtxColumn}), so a writer that took `unknown` would be the "writes fine, reads back
|
|
767
|
+
* refused" asymmetry this repo keeps paying for — and `putCtx(sessionId, null)` was exactly the
|
|
768
|
+
* reachable arm that made the JSON literal `null` (indistinguishable from "no such row" at every
|
|
769
|
+
* consumption point) a zero-corruption possibility. All six call sites already pass an object.
|
|
770
|
+
*
|
|
771
|
+
* 🔴 The TYPE is not the whole gate (codex adversarial review [medium], verified then fixed as a class):
|
|
772
|
+
* `object` admits arrays, `Date`, and objects whose `toJSON()` yields a scalar or `null` — each of those
|
|
773
|
+
* serializes to bytes THIS STORE'S READ LEG REFUSES, and the upsert is unconditional, so the write would
|
|
774
|
+
* SUCCEED and replace a readable durable row with an unreadable one (caller sees 2xx, the next resume
|
|
775
|
+
* 409s forever). So the serialized value is judged by {@link assertJsonColumnWritable} — the SAME shape
|
|
776
|
+
* reader the read leg uses — BEFORE the statement runs: refuse loudly, leave the row on disk untouched. */
|
|
777
|
+
putCtx(sessionId: string, ctx: object): Promise<void>;
|
|
778
|
+
getCtx<T extends object = object>(sessionId: string): Promise<T | null>;
|
|
766
779
|
/**
|
|
767
780
|
* GC ctx rows whose lifecycle is over (bound to the checkpoint, don't leak):
|
|
768
781
|
* a session with NO pending checkpoint, NO active run, AND not touched since `cutoff`.
|