@sema-agent/server 7.15.0 → 7.16.0-rc.2
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 +36 -2
- package/dist/approval-card.d.ts +65 -0
- package/dist/approval-card.js +54 -6
- package/dist/approval-reconciler.d.ts +17 -1
- package/dist/boot/resolve-spec.js +31 -4
- package/dist/boot/runner-deps.d.ts +41 -2
- package/dist/boot/runner-deps.js +43 -0
- package/dist/boot/session-faces.js +26 -4
- package/dist/boot/shutdown.js +17 -0
- package/dist/boot/stores.js +20 -0
- package/dist/config-center/apply-effective.d.ts +14 -0
- package/dist/config-center/apply-effective.js +81 -1
- package/dist/config-types.d.ts +28 -2
- package/dist/config.js +71 -37
- package/dist/fleet/fleet-terminal-window.d.ts +12 -0
- package/dist/fleet/fleet-terminal-window.js +27 -4
- package/dist/http/active-run-conflict.d.ts +41 -1
- package/dist/http/active-run-conflict.js +24 -10
- package/dist/http/routes/approvals-assistant.d.ts +11 -3
- package/dist/http/routes/approvals-assistant.js +97 -20
- package/dist/http/routes/capabilities.js +8 -1
- package/dist/http/routes/diagnostics.d.ts +18 -0
- package/dist/http/routes/diagnostics.js +26 -0
- package/dist/http/routes/runs.js +63 -17
- package/dist/http/routes/side-query.js +15 -1
- package/dist/http/routes/tasks.js +32 -3
- package/dist/http/server.js +67 -13
- package/dist/leader/fanout.d.ts +18 -0
- package/dist/leader/fanout.js +34 -1
- package/dist/leader/leader.js +9 -5
- package/dist/leader/wire.js +16 -5
- package/dist/main.js +1 -0
- package/dist/model-select.d.ts +43 -1
- package/dist/model-select.js +70 -2
- package/dist/observability/fail-open.d.ts +8 -0
- package/dist/observability/fail-open.js +8 -0
- package/dist/observability/metrics.js +3 -0
- package/dist/parent-watch.d.ts +57 -0
- package/dist/parent-watch.js +108 -0
- package/dist/plugins/checkpoint-store-sql.d.ts +67 -0
- package/dist/plugins/checkpoint-store-sql.js +133 -7
- package/dist/plugins/local-checkpoint-store.js +11 -1
- package/dist/plugins/memory-embedder-fingerprint.d.ts +119 -0
- package/dist/plugins/memory-embedder-fingerprint.js +280 -0
- package/dist/plugins/permission-rule-store-sql.d.ts +0 -3
- package/dist/plugins/permission-rule-store-sql.js +1 -7
- package/dist/plugins/pg-pool.js +3 -0
- package/dist/plugins/store-backend.d.ts +5 -6
- package/dist/plugins/store-backend.js +4 -1
- package/dist/plugins/store-contracts.d.ts +17 -0
- package/dist/plugins/store-contracts.js +33 -0
- package/dist/plugins/tidb-pool.js +7 -0
- package/dist/plugins/tool-result-store-sql.d.ts +18 -13
- package/dist/plugins/tool-result-store-sql.js +50 -29
- package/dist/run-local.js +1 -0
- package/dist/tool-approval.d.ts +9 -0
- package/dist/tool-approval.js +74 -3
- package/dist/trace/project.js +8 -0
- package/dist/trace/redact.d.ts +14 -1
- package/dist/trace/redact.js +14 -2
- package/package.json +3 -3
package/dist/http/server.js
CHANGED
|
@@ -3,7 +3,7 @@ import { once } from "node:events";
|
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
4
|
import { uuidv7, isThinkingLevel, expandTiers, resumeWithVerification, CheckpointError, HAND_TOOL_EFFECTS, RETIRED_TOOL_NAMES, defaultTaskRegistry, validatePendingSteer, subscribeWorkflow, GLOBAL_USAGE_KEY, usageRetryAfterMs } from "@sema-agent/core";
|
|
5
5
|
import { decideParkedAgent, findParkedAgentForCheckpoint } from "../parked-decide.js";
|
|
6
|
-
import { matchCatalogModel } from "../model-select.js";
|
|
6
|
+
import { isModelAllowlisted, matchCatalogModel } from "../model-select.js";
|
|
7
7
|
import {} from "../config-center/facade.js";
|
|
8
8
|
import { HttpError, principalFrom, verifiedPrincipal, setSsoPrincipal, ssoVerifiedPrincipal, setSsoScope, isUuidV7, isDestructiveSessionWrite, decodeCheckpointScope, CHECKPOINT_PUBLIC_SCOPE, PRINCIPAL_TOKEN_HEADER, APPROVAL_MAC_HEADER, APPROVAL_MAC_KID_HEADER } from "../security.js";
|
|
9
9
|
import { verifyDirectDoorProof } from "../principal-jwt.js";
|
|
@@ -117,6 +117,13 @@ const MAX_IMAGE_BASE64_BYTES = 6 * 1024 * 1024; // a single inline image's base6
|
|
|
117
117
|
// name is never mounted, so keeping it here would let the pre-check claim satisfiability for a tool that
|
|
118
118
|
// cannot exist (same derivation hygiene as hands-lane.ts HANDS_BAND_TOOL_NAMES).
|
|
119
119
|
const HAND_TOOL_NAMES = Object.keys(HAND_TOOL_EFFECTS).filter((n) => !RETIRED_TOOL_NAMES.has(n));
|
|
120
|
+
/** #233 / A-002.8 —— 未知模型 400 里 `available` 列表的**名单过滤**:不给用户广告他点不了的模型。
|
|
121
|
+
* 名单缺席/空 ⇒ 与本件之前逐字同集(全目录去 `default` 别名);名单在场 ⇒ 只留名单准的那些。
|
|
122
|
+
* 判定单源 `isModelAllowlisted`(与三个执法点同一只),所以"列出来的"与"点了不被拒的"恒为同一批。 */
|
|
123
|
+
function mentionableNames(config, catalog) {
|
|
124
|
+
const allowlist = config.atModelAllowlist ?? [];
|
|
125
|
+
return Object.keys(catalog).filter((n) => n !== "default" && isModelAllowlisted(n, catalog, allowlist));
|
|
126
|
+
}
|
|
120
127
|
/** [1245] codex-3/4 — the REOPEN-class resume failures: core's runtask reopened the checkpoint (store.reopen,
|
|
121
128
|
* dist-read) BEFORE returning this failed result, so the park is STILL PENDING and decidable. Everything
|
|
122
129
|
* downstream must treat these as a re-park, never terminal: the durable event log writes `suspended` (not
|
|
@@ -125,6 +132,37 @@ const HAND_TOOL_NAMES = Object.keys(HAND_TOOL_EFFECTS).filter((n) => !RETIRED_TO
|
|
|
125
132
|
function isCheckpointReopenedFailure(r) {
|
|
126
133
|
return r.status === "failed" && (r.errorCode === "resume.env_failed" || r.errorCode === "resume.tool_unavailable");
|
|
127
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* 一条 verify/cascade 腿的结果**是不是嵌套 durable park**,以及 park 落在哪个词上(扫描P2 + codex 复审
|
|
137
|
+
* round1 [high] 一,2026-08-12 亲读 core 验真)。`undefined` = 不是 park(照常走终局)。
|
|
138
|
+
*
|
|
139
|
+
* 🔴 判据是 **errorCode**,不是 `verification.unverifiedReason` —— 后者**不覆盖修复轮**:
|
|
140
|
+
* · `verify.js:201/208`(runWithVerification / resumeWithVerification 的**立即** park)铸的 outcome 带
|
|
141
|
+
* `unverifiedReason: status === "needs_review" ? "needs_review" : "suspended"`;
|
|
142
|
+
* · 但 `verify.js:191`(verifier 判 FAIL 后的**修复轮** `runTask` 又停在门上)返回的是
|
|
143
|
+
* `{ ...mapNestedSuspend(current), verification: outcome }`,那个 `outcome` 是**上一轮验证器的
|
|
144
|
+
* FAIL 判决** —— `verdict:"FAIL"` 的臂根本不填 `unverifiedReason`(`verify.js:166` 只在
|
|
145
|
+
* no_verdict / PARTIAL 两种情况填)。于是「按 reason 判 park」对修复轮恒为假,而结果里
|
|
146
|
+
* checkpointToken 还活着 ⇒ 写 `done` + setTerminal ⇒ 释放 task_active 锁 + 孤儿化那张卡。
|
|
147
|
+
*
|
|
148
|
+
* `mapNestedSuspend`(core `agents/suspend-guard.js`)是**每一条**嵌套 park 的必经点,它无条件盖这两个
|
|
149
|
+
* 码之一,并且码本身就带 park 的词 —— 所以它同时是「是不是 park」和「是哪个 park」的单一真源。
|
|
150
|
+
* 两个字面量在 core 的 index 里没有导出,只能照字面锚(与 `runs.ts` / `resource-suspend.ts` 顶注同姿势)。
|
|
151
|
+
*
|
|
152
|
+
* ⚠️ 边界:`checkpoint.reopen_failed` 与 `resume.*` 两类 reopen 失败**不在**这里(它们不是 gate 再停,
|
|
153
|
+
* 由 {@link isCheckpointReopenedFailure} + 店内复核那条腿处置);`verifier_paused` 也不在(那是验证器
|
|
154
|
+
* 自己的会话与凭据,本 run 的活已经做完)。
|
|
155
|
+
*/
|
|
156
|
+
function nestedParkStatusOf(r) {
|
|
157
|
+
switch (r.errorCode) {
|
|
158
|
+
case "unexpected.needs_review": // core UNEXPECTED_NEEDS_REVIEW —— 内层 status 是 needs_review 的 park
|
|
159
|
+
return "needs_review";
|
|
160
|
+
case "unexpected.suspended": // core UNEXPECTED_SUSPENDED —— 内层 status 是 suspended 的 park
|
|
161
|
+
return "suspended";
|
|
162
|
+
default:
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
128
166
|
/** The TRUSTED end-user principal for an owner-gated / per-tenant request. On a direct-door worker the
|
|
129
167
|
* trusted-header path is OFF (x-agent-principal is client-spoofable), so identity comes from the crypto-VERIFIED
|
|
130
168
|
* principal JWT (`x-approval-principal-token`, principal-only proof — no cnf.bnd action binding); otherwise the
|
|
@@ -1191,7 +1229,7 @@ export function createHttpServer(rawDeps) {
|
|
|
1191
1229
|
const bare = deps.config.models ?? {}; // 防御:极简 stub deps 可无 models 域(真 loadConfig 恒有)
|
|
1192
1230
|
const catalog = expandTiers(bare, deps.config.tiers ?? {}) ?? bare;
|
|
1193
1231
|
if (matchCatalogModel(body.model, catalog) === undefined) {
|
|
1194
|
-
sendError(res, 400, "request.unknown_reference", `unknown model "${body.model.slice(0, 120)}" — not in the configured catalog (name, tier word, or id)`, { available:
|
|
1232
|
+
sendError(res, 400, "request.unknown_reference", `unknown model "${body.model.slice(0, 120)}" — not in the configured catalog (name, tier word, or id)`, { available: mentionableNames(deps.config, catalog) });
|
|
1195
1233
|
return null;
|
|
1196
1234
|
}
|
|
1197
1235
|
}
|
|
@@ -1207,7 +1245,7 @@ export function createHttpServer(rawDeps) {
|
|
|
1207
1245
|
const bare = deps.config.models ?? {};
|
|
1208
1246
|
const catalog = expandTiers(bare, deps.config.tiers ?? {}) ?? bare;
|
|
1209
1247
|
if (matchCatalogModel(body.compactionModel, catalog) === undefined) {
|
|
1210
|
-
sendError(res, 400, "request.unknown_reference", `unknown compactionModel "${body.compactionModel.slice(0, 120)}" — not in the configured catalog (name, tier word, or id)`, { available:
|
|
1248
|
+
sendError(res, 400, "request.unknown_reference", `unknown compactionModel "${body.compactionModel.slice(0, 120)}" — not in the configured catalog (name, tier word, or id)`, { available: mentionableNames(deps.config, catalog) });
|
|
1211
1249
|
return null;
|
|
1212
1250
|
}
|
|
1213
1251
|
}
|
|
@@ -1384,9 +1422,15 @@ export function createHttpServer(rawDeps) {
|
|
|
1384
1422
|
if (!isApprovalGateKind(decideGateKind)) { // A-002.1 单一属主
|
|
1385
1423
|
// [2400] HITL-6(clay 裁 2026-08-03,不做兼容):补机读指路——与 [2255]① 的 409 材料同形
|
|
1386
1424
|
// (pendingGate{kind, decidePath},resumeEntryForGate 单源表),客户端凭 decidePath 直达对的
|
|
1387
|
-
// resume 入口,不再解析人话文案。taskId
|
|
1388
|
-
|
|
1389
|
-
|
|
1425
|
+
// resume 入口,不再解析人话文案。taskId 源=账本活行(getActiveTaskId,与 active-run-conflict.ts
|
|
1426
|
+
// 的 409 材料同源)——cp.sourceTaskId 是 core 契约里 issuing worker 的 **session id**(ECHO-ONLY
|
|
1427
|
+
// triage,checkpoint-store.d.ts),拿它当 run taskId 铸路径=打出去必 404([3683]-1 同病族,
|
|
1428
|
+
// 扫描 P1 修)。账本无活行 ⇒ 只带 kind,诚实不铸假路径。
|
|
1429
|
+
const activeForPath = decideGateKind
|
|
1430
|
+
? await deps.runStore?.getActiveTaskId(sessionId).catch(() => undefined)
|
|
1431
|
+
: undefined;
|
|
1432
|
+
const gnPath = decideGateKind && activeForPath
|
|
1433
|
+
? resumeEntryForGate(decideGateKind, { sessionId, taskId: activeForPath })
|
|
1390
1434
|
: null;
|
|
1391
1435
|
return {
|
|
1392
1436
|
status: 409,
|
|
@@ -1811,11 +1855,20 @@ export function createHttpServer(rawDeps) {
|
|
|
1811
1855
|
const vr = await resumeWithVerification(legRunner, token, outcome, { ...taskConfig, signal: cancelCtrl.signal, preemptSignal: preemptCtrl.signal }, resumeObjective, verifyRounds); // 快审 F1:整对象直传
|
|
1812
1856
|
let safe = stripCheckpointToken(vr);
|
|
1813
1857
|
// 🔴 A re-suspend on a LATER gate is NOT terminal: core maps it to status:"failed" +
|
|
1814
|
-
// verification.unverifiedReason
|
|
1815
|
-
// the stream path's
|
|
1816
|
-
// outer block calls setSuspended (keeps the session lock + resumable row). Driving it
|
|
1817
|
-
// release the lock and orphan the second checkpoint (next /decide → 409, task abandoned).
|
|
1818
|
-
|
|
1858
|
+
// verification.unverifiedReason with the checkpoint token still live. Treat it exactly like
|
|
1859
|
+
// the stream path's park branches — write the matching park event and return the park status so the
|
|
1860
|
+
// outer block calls setSuspended/setNeedsReview (keeps the session lock + resumable row). Driving it
|
|
1861
|
+
// terminal would release the lock and orphan the second checkpoint (next /decide → 409, task abandoned).
|
|
1862
|
+
//
|
|
1863
|
+
// 🔴 park 是**两个词**(扫描P2,status/kind 同名词混淆病族),而且判据必须取 **errorCode** 而不是
|
|
1864
|
+
// `verification.unverifiedReason` —— 完整理由(含修复轮那条 reason 恒缺席的腿)见
|
|
1865
|
+
// {@link nestedParkStatusOf} 的顶注,它是这条判据的唯一属主。只认 `unverifiedReason === "suspended"`
|
|
1866
|
+
// 会漏掉两类真 park:plan_review / dry_run 的 `needs_review` 再停,以及 verifier 判 FAIL 后修复轮
|
|
1867
|
+
// 的**任一**再停 —— 两类都会被写成 `done` + setTerminal:锁被释放、活卡成孤儿。
|
|
1868
|
+
// ⚠️ `verifier_paused` 不进这个集合:那是**验证器自己**的会话/凭据(见 core `VerificationOutcome
|
|
1869
|
+
// .verifierPause`),本 run 的活已经做完,与「本 run 再停在自己的门上」是两件事。
|
|
1870
|
+
const reParkStatus = nestedParkStatusOf(vr);
|
|
1871
|
+
const reSuspended = reParkStatus !== undefined;
|
|
1819
1872
|
// [1245] codex-4: reopen-class twin of the stream path (store-confirmed, codex-5) — the checkpoint
|
|
1820
1873
|
// is pending again, so the durable log must record a park, never `done` (the outer row drive
|
|
1821
1874
|
// re-parks via checkpointReopened; the response keeps status:"failed"+retriable, unlike reSuspended
|
|
@@ -1834,7 +1887,8 @@ export function createHttpServer(rawDeps) {
|
|
|
1834
1887
|
await appendPromptManifest(ap, deps.promptManifests, taskId); // [998]②: the verified-resume leg has no stream — persist the re-fired manifest before its terminal/suspend event
|
|
1835
1888
|
if (reSuspended || vrReopened) {
|
|
1836
1889
|
await appendModelUsageDelta(ap, deps.modelUsage, taskId); // E8: persist pre-re-suspend per-model usage
|
|
1837
|
-
|
|
1890
|
+
// reopen 腿恒落 `suspended`(它不是 gate 再停,是同一张卡被重开);reSuspended 腿按真 park 词落名。
|
|
1891
|
+
await ap(reSuspended ? reParkStatus : "suspended", { gate: vr.checkpointGate ?? null, ...(vrReopened ? { reopened: vr.errorCode ?? null } : {}) });
|
|
1838
1892
|
}
|
|
1839
1893
|
else {
|
|
1840
1894
|
// E8: this verified-resume leg is the run's final done → flush the last delta + SUM model_usage into stats.modelUsage
|
|
@@ -1848,7 +1902,7 @@ export function createHttpServer(rawDeps) {
|
|
|
1848
1902
|
}
|
|
1849
1903
|
}
|
|
1850
1904
|
if (reSuspended)
|
|
1851
|
-
return { ...safe, status:
|
|
1905
|
+
return { ...safe, status: reParkStatus };
|
|
1852
1906
|
// Count the gate verdict only when the verifier actually ran (a re-suspend never reached it).
|
|
1853
1907
|
if (vr.verification?.verdict)
|
|
1854
1908
|
deps.metrics?.inc("verifications_total", { verdict: vr.verification.verdict });
|
package/dist/leader/fanout.d.ts
CHANGED
|
@@ -98,6 +98,24 @@ export interface FanOutResult {
|
|
|
98
98
|
/** True if the overall deadline fired and cancelAll ran. */
|
|
99
99
|
cancelled: boolean;
|
|
100
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* 「这条 worker 的 TaskResult 停在门上(durable park)了吗」——`TaskStatus` 的穷举判据。
|
|
103
|
+
*
|
|
104
|
+
* 🔴 park 是**两个词**(扫描P2,status/kind 同名词混淆病族):`suspended`(工具审批 / human /
|
|
105
|
+
* resource_limit 门)与 `needs_review`(`plan_review` 与 dry-run 拦截门)。真源 = core
|
|
106
|
+
* `agents/suspend-guard.js` 的 `isDurablePause`(逐字 `status === "suspended" || status === "needs_review"`),
|
|
107
|
+
* `mapNestedSuspend` / `verify.js` 的 `unverifiedReason` 两处都按这张表分词。
|
|
108
|
+
*
|
|
109
|
+
* 只认 `suspended` 的后果不是「少一个标签」:一条 plan_review 停下的 worker 会被报成 `failed` ⇒
|
|
110
|
+
* fanOut 的 C4 抢占({@link FanOutOptions.cancelOnSuspend})不触发(其余 worker 继续烧钱)、leader 的
|
|
111
|
+
* replan「suspended 波」看不见它、`suspendedSessions` 的豁免名单漏掉它 ⇒ 它那张还能被决议的 checkpoint
|
|
112
|
+
* 与暂停中的环境会被当失败清掉。带验证器的那条腿早已改用「还带着 checkpointToken 就是 park」的判据
|
|
113
|
+
* (leader.ts round-3 review BLOCKER),这里是它当时漏掉的另外半场。
|
|
114
|
+
*
|
|
115
|
+
* `never` 收敛 = core `TaskStatus` 增删成员的**编译期**执行点(同 `store-contracts.ts` 两条判据的姿势);
|
|
116
|
+
* 运行时未知词保守判**非 park**(与既有 `else → failed` 同行为,不静默扩大 park 面)。
|
|
117
|
+
*/
|
|
118
|
+
export declare function isDurablePauseStatus(s: TaskResult["status"]): boolean;
|
|
101
119
|
/**
|
|
102
120
|
* Fan out `subtasks` in parallel. Never throws — every worker resolves to a WorkerReport (failures captured).
|
|
103
121
|
* The caller (Coordinator) merges only `status==="completed"` reports.
|
package/dist/leader/fanout.js
CHANGED
|
@@ -19,8 +19,41 @@ class Semaphore {
|
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* 「这条 worker 的 TaskResult 停在门上(durable park)了吗」——`TaskStatus` 的穷举判据。
|
|
24
|
+
*
|
|
25
|
+
* 🔴 park 是**两个词**(扫描P2,status/kind 同名词混淆病族):`suspended`(工具审批 / human /
|
|
26
|
+
* resource_limit 门)与 `needs_review`(`plan_review` 与 dry-run 拦截门)。真源 = core
|
|
27
|
+
* `agents/suspend-guard.js` 的 `isDurablePause`(逐字 `status === "suspended" || status === "needs_review"`),
|
|
28
|
+
* `mapNestedSuspend` / `verify.js` 的 `unverifiedReason` 两处都按这张表分词。
|
|
29
|
+
*
|
|
30
|
+
* 只认 `suspended` 的后果不是「少一个标签」:一条 plan_review 停下的 worker 会被报成 `failed` ⇒
|
|
31
|
+
* fanOut 的 C4 抢占({@link FanOutOptions.cancelOnSuspend})不触发(其余 worker 继续烧钱)、leader 的
|
|
32
|
+
* replan「suspended 波」看不见它、`suspendedSessions` 的豁免名单漏掉它 ⇒ 它那张还能被决议的 checkpoint
|
|
33
|
+
* 与暂停中的环境会被当失败清掉。带验证器的那条腿早已改用「还带着 checkpointToken 就是 park」的判据
|
|
34
|
+
* (leader.ts round-3 review BLOCKER),这里是它当时漏掉的另外半场。
|
|
35
|
+
*
|
|
36
|
+
* `never` 收敛 = core `TaskStatus` 增删成员的**编译期**执行点(同 `store-contracts.ts` 两条判据的姿势);
|
|
37
|
+
* 运行时未知词保守判**非 park**(与既有 `else → failed` 同行为,不静默扩大 park 面)。
|
|
38
|
+
*/
|
|
39
|
+
export function isDurablePauseStatus(s) {
|
|
40
|
+
switch (s) {
|
|
41
|
+
case "suspended":
|
|
42
|
+
case "needs_review":
|
|
43
|
+
return true;
|
|
44
|
+
case "completed":
|
|
45
|
+
case "failed":
|
|
46
|
+
case "blocked":
|
|
47
|
+
return false;
|
|
48
|
+
default: {
|
|
49
|
+
const unhandled = s;
|
|
50
|
+
void unhandled;
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
22
55
|
function mapStatus(s) {
|
|
23
|
-
return s === "completed" ? "completed" : s
|
|
56
|
+
return s === "completed" ? "completed" : isDurablePauseStatus(s) ? "suspended" : "failed";
|
|
24
57
|
}
|
|
25
58
|
/**
|
|
26
59
|
* Fan out `subtasks` in parallel. Never throws — every worker resolves to a WorkerReport (failures captured).
|
package/dist/leader/leader.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* Deps injected → mock-tested; the real-infra wiring (E2B envs + deepseek runners + git push) lives in the
|
|
13
13
|
* joint dogfood, which this generalizes.
|
|
14
14
|
*/
|
|
15
|
-
import { fanOut } from "./fanout.js";
|
|
15
|
+
import { fanOut, isDurablePauseStatus } from "./fanout.js";
|
|
16
16
|
import { pullDiff } from "./diffout.js";
|
|
17
17
|
import { mergeBranches } from "./merge.js";
|
|
18
18
|
import { Runner, runWithVerification, resumeWithVerification, delimitUntrusted } from "@sema-agent/core";
|
|
@@ -105,10 +105,14 @@ export async function runLeaderTask(task, durableBaseSha, deps) {
|
|
|
105
105
|
}
|
|
106
106
|
return {
|
|
107
107
|
...base,
|
|
108
|
-
// Map the TaskResult status directly (like fanOut's own mapStatus) — a
|
|
109
|
-
// auto-resume left unresumed (a non-resource gate, e.g. human-approval
|
|
110
|
-
// leader's C4 surface/cancel reaction still fires; only completed/timeout pass
|
|
111
|
-
|
|
108
|
+
// Map the TaskResult status directly (like fanOut's own mapStatus) — a durably PARKED result that
|
|
109
|
+
// the auto-resume left unresumed (a non-resource gate, e.g. human-approval or a plan review) stays
|
|
110
|
+
// `suspended` so the leader's C4 surface/cancel reaction still fires; only completed/timeout pass
|
|
111
|
+
// through, else failed. 🔴 park 的判据取自 `fanout.ts` 的 `isDurablePauseStatus`(扫描P2):
|
|
112
|
+
// 原地手抄的 `=== "suspended"` 漏掉 `needs_review`(plan_review / dry-run 门),会把一条还
|
|
113
|
+
// 拿着 checkpoint、环境还暂停着的 worker 报成 failed —— 与本文件带验证器那条腿早就修过的
|
|
114
|
+
// 是同一个缺陷(round-3 review BLOCKER),当时只修了那半场。
|
|
115
|
+
status: r.status === "completed" ? "completed" : isDurablePauseStatus(r.status) ? "suspended" : "failed", // core 5.8.0:TaskResult 无 "timeout"(内部词表的 timeout 仍由 fanout 墙钟臂生产)
|
|
112
116
|
stats: r.stats,
|
|
113
117
|
...(r.status === "completed" ? {} : { error: r.errorMessage ?? r.blockedReason ?? `worker ${r.status}` }),
|
|
114
118
|
...(r.checkpointGate ? { checkpointGate: r.checkpointGate } : {}),
|
package/dist/leader/wire.js
CHANGED
|
@@ -25,6 +25,7 @@ import { pullDiff, safeRev } from "./diffout.js";
|
|
|
25
25
|
import { runLeaderTask } from "./leader.js";
|
|
26
26
|
import { attachRepairLoopDeps } from "./repair-wire.js";
|
|
27
27
|
import { routePlanWithFallback, validateSubtasks } from "./planner.js";
|
|
28
|
+
import { createEngineNoticeSeat } from "../boot/runner-deps.js";
|
|
28
29
|
/** The single-agent `runRepairLoop` is now WIRED LIVE (the canary-enable slice): when `LEADER_REPAIR_LOOP=true`
|
|
29
30
|
* the wire provisions a DEDICATED oracle-seeded grader sandbox per solo worker and attaches a real async
|
|
30
31
|
* `resolve` to `attachRepairLoopDeps` (below). With this `true`, flipping `LEADER_REPAIR_LOOP` engages BOTH the
|
|
@@ -245,6 +246,16 @@ export function createLeaderRunner(cfg) {
|
|
|
245
246
|
const ident = cfg.git ?? { name: "leader", email: "leader@local" };
|
|
246
247
|
const { leaderTimeoutMs, workerLimits, presignTtlSec, resourceSuspend: resourceCfg } = leaderResourceConfig();
|
|
247
248
|
const { repairRounds, repairBudgetUsd, conflictRounds, repairLoopOn, measureGatesOn, repairLoopAttempts, oracleFlakyK, replanBudgetUsd, } = leaderLoopConfig({ repairRounds: cfg.repairRounds, conflictRounds: cfg.conflictRounds });
|
|
249
|
+
// #240② onNotice 席(合并码重扫):本车道的每一只 Runner 都要带,否则 core 的引擎通告在这条腿上落回
|
|
250
|
+
// 裸 `console.warn`(主车道进结构化 `engine_notice` 行)—— 同一部署两条通告通道,按 code 采集的运维面
|
|
251
|
+
// 只看得见一半。转发体取共享基座的**那一个**具名导出,禁在这里另写一份(两份必漂)。
|
|
252
|
+
// ⚠️ 判据是 `warn` **在场**,不是 `cfg.logger` 在场:leader 的 logger 两格都可选,只装了 `info` 的部署
|
|
253
|
+
// 铸这一席等于把通告吞掉 —— 那比不铸还坏(不铸时 core 至少还打自己的 `console.warn`)。
|
|
254
|
+
// 🔴 这条判据现在**整条住在** `createEngineNoticeSeat` 里(codex 复审 R1-[medium],验真后修):此前它是
|
|
255
|
+
// 这里的一行三元,于是只能靠「扫这段源码文本里有没有 `warn`」去守,而 `cfg.logger ? ((m,x)=>cfg.logger?.warn?.(m,x)) : undefined`
|
|
256
|
+
// 这一族变异**文本上照样有 `warn`**、值上却是 truthy 闭包(info-only 部署照铸席、通告被可选链吞掉)。
|
|
257
|
+
// 搬进函数之后判据可被**行为**测,门不再是关键字搜索。
|
|
258
|
+
const onNoticeSeat = createEngineNoticeSeat(cfg.logger);
|
|
248
259
|
// worker resource bounds (limits/maxCostUsd) + presign ttl are derived in leaderResourceConfig(), above.
|
|
249
260
|
// Bounded integration-repair (search 2026-06-14): when the merged tree compiles-clean-but-fails, run a strong
|
|
250
261
|
// agent WITH HANDS in the live merged sandbox to fix the cross-worker integration, then mergeBranches re-runs
|
|
@@ -271,7 +282,7 @@ export function createLeaderRunner(cfg) {
|
|
|
271
282
|
// rootPath: repoDir (Codex review #4) — without it core roots the file tools at executionEnv.cwd
|
|
272
283
|
// (/workspace on Kata), one level above the repo (/workspace/repo). bash can cd, but the dedicated
|
|
273
284
|
// read/edit tools would start too high + lose containment.
|
|
274
|
-
const runner = new Runner({ brain: cfg.brain, models: cfg.models, roles: cfg.roles, pricing: cfg.pricing, executionEnv: rawEnv, rootPath: repoDir });
|
|
285
|
+
const runner = new Runner({ brain: cfg.brain, models: cfg.models, roles: cfg.roles, pricing: cfg.pricing, ...onNoticeSeat, executionEnv: rawEnv, rootPath: repoDir });
|
|
275
286
|
const objective = [
|
|
276
287
|
`The integrated project at ${repoDir} fails its build/test. Make the MINIMAL change to the working tree so this command exits 0 (cd into the repo and run it yourself to confirm):`,
|
|
277
288
|
` ${testCmd}`,
|
|
@@ -327,7 +338,7 @@ export function createLeaderRunner(cfg) {
|
|
|
327
338
|
throw new Error(`oracle still present after remove (${f.path}) — refusing conflict-resolve (measurement integrity)`);
|
|
328
339
|
}
|
|
329
340
|
try {
|
|
330
|
-
const runner = new Runner({ brain: cfg.brain, models: cfg.models, roles: cfg.roles, pricing: cfg.pricing, executionEnv: rawEnv, rootPath: repoDir });
|
|
341
|
+
const runner = new Runner({ brain: cfg.brain, models: cfg.models, roles: cfg.roles, pricing: cfg.pricing, ...onNoticeSeat, executionEnv: rawEnv, rootPath: repoDir });
|
|
331
342
|
const objective = [
|
|
332
343
|
`A parallel worker '${workerId}' ported a module on its own branch, but applying its patch onto the already-integrated tree at ${repoDir} produced a MERGE CONFLICT (git apply --3way). The OTHER workers' patches already applied cleanly — integrate THIS worker's changes too, resolving the overlap.`,
|
|
333
344
|
`Resolve EVERY conflict in the working tree: open each file containing conflict markers (<<<<<<< / ======= / >>>>>>>) and merge BOTH sides' real intent (keep both workers' behaviour — never drop one side just to make it apply). Then apply any rejected hunks recorded in *.rej files by hand, and DELETE every *.rej and *.orig file.`,
|
|
@@ -415,7 +426,7 @@ export function createLeaderRunner(cfg) {
|
|
|
415
426
|
// the Coordinator's parallel merge relies on. Was a straight map() that skipped all of it.
|
|
416
427
|
return validateSubtasks(body.subtasks);
|
|
417
428
|
}
|
|
418
|
-
const planRunner = new Runner({ brain: cfg.brain, models: cfg.models, roles: cfg.roles, pricing: cfg.pricing });
|
|
429
|
+
const planRunner = new Runner({ brain: cfg.brain, models: cfg.models, roles: cfg.roles, pricing: cfg.pricing, ...onNoticeSeat });
|
|
419
430
|
const runRoute = async (prompt) => {
|
|
420
431
|
const res = await planRunner.runTaskStream({
|
|
421
432
|
objective: prompt,
|
|
@@ -566,7 +577,7 @@ export function createLeaderRunner(cfg) {
|
|
|
566
577
|
workerId: sub.workerId, sessionId, branch: sub.branch,
|
|
567
578
|
baseSha, // seedCmd reproduces the durable base — same sha for every worker
|
|
568
579
|
runner: keepCtxWarm(new Runner({
|
|
569
|
-
brain: cfg.brain, models: cfg.models, roles: cfg.roles, pricing: cfg.pricing,
|
|
580
|
+
brain: cfg.brain, models: cfg.models, roles: cfg.roles, pricing: cfg.pricing, ...onNoticeSeat,
|
|
570
581
|
...(cfg.toolResultStore ? { toolResultStore: cfg.toolResultStore } : {}),
|
|
571
582
|
...(cfg.sessionStore ? { sessionStore: cfg.sessionStore } : {}),
|
|
572
583
|
executionEnvFactory: (ctx) => withStaging(envFactory(ctx), stage, async (e) => {
|
|
@@ -589,7 +600,7 @@ export function createLeaderRunner(cfg) {
|
|
|
589
600
|
const baseSha = await sh(env)(`cd ${repo} && git rev-parse HEAD`);
|
|
590
601
|
return {
|
|
591
602
|
workerId: sub.workerId, sessionId, branch: sub.branch, baseSha,
|
|
592
|
-
runner: keepCtxWarm(new Runner({ brain: cfg.brain, models: cfg.models, roles: cfg.roles, pricing: cfg.pricing, ...(cfg.toolResultStore ? { toolResultStore: cfg.toolResultStore } : {}), ...(cfg.sessionStore ? { sessionStore: cfg.sessionStore } : {}), executionEnv: env })),
|
|
603
|
+
runner: keepCtxWarm(new Runner({ brain: cfg.brain, models: cfg.models, roles: cfg.roles, pricing: cfg.pricing, ...onNoticeSeat, ...(cfg.toolResultStore ? { toolResultStore: cfg.toolResultStore } : {}), ...(cfg.sessionStore ? { sessionStore: cfg.sessionStore } : {}), executionEnv: env })),
|
|
593
604
|
diffEnv: env,
|
|
594
605
|
destroy: () => env.destroy().then(() => { }),
|
|
595
606
|
spec: { ...workerLimits, ...sub.spec, ...durableSpec, ...resourceSpec },
|
package/dist/main.js
CHANGED
|
@@ -443,6 +443,7 @@ async function main() {
|
|
|
443
443
|
// 基座只有一份)。差异键在展开后显式列出,每个都有为何不同的理由(见 boot/runner-deps.ts 头注)。
|
|
444
444
|
...createSharedRunnerDeps({
|
|
445
445
|
config,
|
|
446
|
+
logger, // #240②:onNotice 席落共享基座 ⇒ 两 Runner 同源(EngineNotice→logger.warn)
|
|
446
447
|
hands: commitHands, // 交接件⑤:与主 runner 同一个值(基座保证同源)
|
|
447
448
|
brain,
|
|
448
449
|
pricing,
|
package/dist/model-select.d.ts
CHANGED
|
@@ -23,11 +23,53 @@
|
|
|
23
23
|
* 🔴 Was a real defect: resolveSpec previously read ONLY the inline @mention, so `body.model` (the picker + the
|
|
24
24
|
* `--model` flag) was SILENTLY DROPPED — the picker UX was a no-op server-side. This restores it.
|
|
25
25
|
*/
|
|
26
|
-
export declare function resolveTaskModel(bodyModel: unknown, objective: string, catalog: Record<string, unknown
|
|
26
|
+
export declare function resolveTaskModel(bodyModel: unknown, objective: string, catalog: Record<string, unknown>, allowlist?: readonly string[]): {
|
|
27
27
|
model: string;
|
|
28
28
|
cleanedObjective: string;
|
|
29
29
|
unknownExplicit?: string;
|
|
30
|
+
notAllowed?: ModelNotAllowed;
|
|
30
31
|
};
|
|
32
|
+
/** #233 判别式拒因:名单在场且这次**用户选择**落在名单外。`resolved` 已是目录 name 形(归一后)。 */
|
|
33
|
+
export interface ModelNotAllowed {
|
|
34
|
+
/** 调用方发来的原始 ref(name / 档位词 / 别名 / id / `@name`),已做过日志安全的 repr 归一。 */
|
|
35
|
+
requested: string;
|
|
36
|
+
/** 归一到的目录 name(即这次真会跑的模型)。 */
|
|
37
|
+
resolved: string;
|
|
38
|
+
/** 走的是哪条用户通道:`explicit`=body.model/settings.model,`mention`=objective 内联 `@name`。 */
|
|
39
|
+
source: "explicit" | "mention";
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* #233 / A-002.8 —— **归一条款**:membership 判定前把 ref 归一到**终端 catalog 条目**再比。
|
|
43
|
+
*
|
|
44
|
+
* 为什么不能拿字符串直接跟名单比:`expandTiers` 的增广目录里,档位词(`pro`)与 CC 别名(`sonnet`)
|
|
45
|
+
* 的值与 `config.models[<name>]` 是**同一个对象引用**——裸串比较会让 `@pro` 绕过一张按 name 写的名单
|
|
46
|
+
* (同一只模型,两个键)。所以两边(ref 与每个名单条目)都先经 {@link matchCatalogModel} 解到目录条目,
|
|
47
|
+
* 再按 **引用相等** 判同。id 形的 ref 不需要特别处理:`matchCatalogModel` 本身就是 name+id 双键索引,
|
|
48
|
+
* 它会把 id 解成目录键,引用相等接着就对上了。
|
|
49
|
+
*
|
|
50
|
+
* 🔴 **刻意没有「id 相等」退路**(codex 复审 R1 [high],已采纳):目录**允许两条不同 name 的条目共享
|
|
51
|
+
* 同一个上游 `Model.id`,却各自带不同的 `baseUrl` / `apiKeyEnv` / `cost` / provider** —— 那正是多网关、
|
|
52
|
+
* 多账号部署形。按 id 判同会把 `safe` 与 `premium` 合并,于是一张只含 `safe` 的名单会放行 `premium`,
|
|
53
|
+
* 而真正执行的是 `premium` 那份坐标与计费。判据只认**目录条目本身**:两条条目就是两只模型,哪怕上游
|
|
54
|
+
* id 撞车。(引用相等因此不是"优化",是唯一正确的判据;它天然覆盖 tier 词/别名/id 三形。)
|
|
55
|
+
*
|
|
56
|
+
* 缺席/空名单 ⇒ 恒 `true`(ship-dark:缺省 = 旧行为,全放行)。
|
|
57
|
+
* 目录里根本没有的 ref ⇒ 恒 `true`:那是「未知模型」那道门的辖区(fresh 400 / resume 降级留声),
|
|
58
|
+
* 本门只答「在目录里,但准不准点」——两道门同码同拒会让消费端分不清"没这模型"和"不让你点"。
|
|
59
|
+
*
|
|
60
|
+
* ⚠️ 射程:本门是**提交时**门。core 运行时路由(degrade 降级链 / cascade 梯 / roles 解析)按 operator
|
|
61
|
+
* 配置自己走,**不经本门**——那不是一次用户选择,如实声明,不假装是运行时执法。
|
|
62
|
+
*/
|
|
63
|
+
export declare function isModelAllowlisted(ref: string, catalog: Record<string, unknown>, allowlist: readonly string[] | undefined): boolean;
|
|
64
|
+
/**
|
|
65
|
+
* #233 —— ref → **终端 catalog name**(拒因/日志的可读形)。
|
|
66
|
+
*
|
|
67
|
+
* 为什么不是 `matchCatalogModel` 的返回值:增广目录里 `pro`/`opus`/`default` 都是**真实存在的键**,
|
|
68
|
+
* 于是 `matchCatalogModel("pro")` 回的就是 `"pro"` —— 那是用户点的那个别名,不是他真会跑的模型。
|
|
69
|
+
* 取条目自己的 `Model.name`(applyEffective 建目录时 `models[m.name].name === m.name`,env lane 同)
|
|
70
|
+
* 才是终端名;拿不到时如实退回匹配键(诚实缺席,不猜)。
|
|
71
|
+
*/
|
|
72
|
+
export declare function terminalCatalogName(ref: string, catalog: Record<string, unknown>): string | undefined;
|
|
31
73
|
/**
|
|
32
74
|
* [865]② name+id 双键目录索引:返回 ref 命中的目录 **name 键**(spec.model 的规范形),未知 → undefined。
|
|
33
75
|
* - name 命中用 Object.hasOwn(🔴 不用真值索引:Object.prototype 继承键 "constructor"/"__proto__" 等在裸
|
package/dist/model-select.js
CHANGED
|
@@ -24,7 +24,7 @@ import { parseModelMention } from "@sema-agent/core";
|
|
|
24
24
|
* 🔴 Was a real defect: resolveSpec previously read ONLY the inline @mention, so `body.model` (the picker + the
|
|
25
25
|
* `--model` flag) was SILENTLY DROPPED — the picker UX was a no-op server-side. This restores it.
|
|
26
26
|
*/
|
|
27
|
-
export function resolveTaskModel(bodyModel, objective, catalog) {
|
|
27
|
+
export function resolveTaskModel(bodyModel, objective, catalog, allowlist) {
|
|
28
28
|
const mention = parseModelMention(objective, Object.keys(catalog));
|
|
29
29
|
const explicit = typeof bodyModel === "string" ? matchCatalogModel(bodyModel, catalog) : undefined;
|
|
30
30
|
// 未匹配的「在场」ref 一律透出(codex L1:历史 checkpoint 里的空串/非字符串 model 在 RESUME 重放也不许
|
|
@@ -32,12 +32,80 @@ export function resolveTaskModel(bodyModel, objective, catalog) {
|
|
|
32
32
|
const present = bodyModel !== undefined && bodyModel !== null;
|
|
33
33
|
const unknown = present && explicit === undefined;
|
|
34
34
|
const repr = typeof bodyModel !== "string" ? `<non-string:${typeof bodyModel}>` : bodyModel === "" ? "<empty-string>" : bodyModel;
|
|
35
|
+
const model = explicit ?? mention.model ?? "default";
|
|
36
|
+
// #233 / A-002.8:名单只裁**用户逐 turn 选择通道**——显式 ref(body.model / settings.model)与 objective
|
|
37
|
+
// 内联 `@name`。两者都缺席时落 `default`(operator 配置通道),本门**不碰**:名单管"用户可点什么",
|
|
38
|
+
// 不管"operator 配什么"(registry-core 也只校验名单 ⊆ catalog,从不要求 default ∈ 名单)。
|
|
39
|
+
// 不 throw:resume 重放持久化 body 不过 HTTP 门,砖死 resume 比降级糟(本文件 [865]② 同判据)——
|
|
40
|
+
// 判别式拒因交给调用点按 leg 分流(fresh 400 / resume warn 续跑)。
|
|
41
|
+
const picked = explicit !== undefined ? { ref: explicit, source: "explicit", requested: repr } : mention.model !== undefined ? { ref: mention.model, source: "mention", requested: mention.model } : undefined;
|
|
42
|
+
const notAllowed = picked !== undefined && !isModelAllowlisted(picked.ref, catalog, allowlist)
|
|
43
|
+
? { requested: picked.requested, resolved: terminalCatalogName(picked.ref, catalog) ?? picked.ref, source: picked.source }
|
|
44
|
+
: undefined;
|
|
35
45
|
return {
|
|
36
|
-
model
|
|
46
|
+
model,
|
|
37
47
|
cleanedObjective: mention.cleanedText || objective,
|
|
38
48
|
...(unknown ? { unknownExplicit: repr } : {}),
|
|
49
|
+
...(notAllowed ? { notAllowed } : {}),
|
|
39
50
|
};
|
|
40
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* #233 / A-002.8 —— **归一条款**:membership 判定前把 ref 归一到**终端 catalog 条目**再比。
|
|
54
|
+
*
|
|
55
|
+
* 为什么不能拿字符串直接跟名单比:`expandTiers` 的增广目录里,档位词(`pro`)与 CC 别名(`sonnet`)
|
|
56
|
+
* 的值与 `config.models[<name>]` 是**同一个对象引用**——裸串比较会让 `@pro` 绕过一张按 name 写的名单
|
|
57
|
+
* (同一只模型,两个键)。所以两边(ref 与每个名单条目)都先经 {@link matchCatalogModel} 解到目录条目,
|
|
58
|
+
* 再按 **引用相等** 判同。id 形的 ref 不需要特别处理:`matchCatalogModel` 本身就是 name+id 双键索引,
|
|
59
|
+
* 它会把 id 解成目录键,引用相等接着就对上了。
|
|
60
|
+
*
|
|
61
|
+
* 🔴 **刻意没有「id 相等」退路**(codex 复审 R1 [high],已采纳):目录**允许两条不同 name 的条目共享
|
|
62
|
+
* 同一个上游 `Model.id`,却各自带不同的 `baseUrl` / `apiKeyEnv` / `cost` / provider** —— 那正是多网关、
|
|
63
|
+
* 多账号部署形。按 id 判同会把 `safe` 与 `premium` 合并,于是一张只含 `safe` 的名单会放行 `premium`,
|
|
64
|
+
* 而真正执行的是 `premium` 那份坐标与计费。判据只认**目录条目本身**:两条条目就是两只模型,哪怕上游
|
|
65
|
+
* id 撞车。(引用相等因此不是"优化",是唯一正确的判据;它天然覆盖 tier 词/别名/id 三形。)
|
|
66
|
+
*
|
|
67
|
+
* 缺席/空名单 ⇒ 恒 `true`(ship-dark:缺省 = 旧行为,全放行)。
|
|
68
|
+
* 目录里根本没有的 ref ⇒ 恒 `true`:那是「未知模型」那道门的辖区(fresh 400 / resume 降级留声),
|
|
69
|
+
* 本门只答「在目录里,但准不准点」——两道门同码同拒会让消费端分不清"没这模型"和"不让你点"。
|
|
70
|
+
*
|
|
71
|
+
* ⚠️ 射程:本门是**提交时**门。core 运行时路由(degrade 降级链 / cascade 梯 / roles 解析)按 operator
|
|
72
|
+
* 配置自己走,**不经本门**——那不是一次用户选择,如实声明,不假装是运行时执法。
|
|
73
|
+
*/
|
|
74
|
+
export function isModelAllowlisted(ref, catalog, allowlist) {
|
|
75
|
+
if (allowlist === undefined || allowlist.length === 0)
|
|
76
|
+
return true;
|
|
77
|
+
const entry = catalogEntryOf(ref, catalog);
|
|
78
|
+
if (entry === undefined)
|
|
79
|
+
return true;
|
|
80
|
+
for (const name of allowlist) {
|
|
81
|
+
const allowed = catalogEntryOf(name, catalog);
|
|
82
|
+
if (allowed === undefined)
|
|
83
|
+
continue; // 悬空/不可解名单条目(apply 期已 warn):它准不了任何东西
|
|
84
|
+
if (allowed === entry)
|
|
85
|
+
return true; // 引用相等 —— 增广目录里 tier 词/别名/id 最终都落到这同一只条目
|
|
86
|
+
}
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
/** ref(name / 档位词 / 别名 / id)→ 它在目录里的**条目对象**;未知 ⇒ undefined。 */
|
|
90
|
+
function catalogEntryOf(ref, catalog) {
|
|
91
|
+
const name = matchCatalogModel(ref, catalog);
|
|
92
|
+
return name === undefined ? undefined : catalog[name];
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* #233 —— ref → **终端 catalog name**(拒因/日志的可读形)。
|
|
96
|
+
*
|
|
97
|
+
* 为什么不是 `matchCatalogModel` 的返回值:增广目录里 `pro`/`opus`/`default` 都是**真实存在的键**,
|
|
98
|
+
* 于是 `matchCatalogModel("pro")` 回的就是 `"pro"` —— 那是用户点的那个别名,不是他真会跑的模型。
|
|
99
|
+
* 取条目自己的 `Model.name`(applyEffective 建目录时 `models[m.name].name === m.name`,env lane 同)
|
|
100
|
+
* 才是终端名;拿不到时如实退回匹配键(诚实缺席,不猜)。
|
|
101
|
+
*/
|
|
102
|
+
export function terminalCatalogName(ref, catalog) {
|
|
103
|
+
const key = matchCatalogModel(ref, catalog);
|
|
104
|
+
if (key === undefined)
|
|
105
|
+
return undefined;
|
|
106
|
+
const name = catalog[key]?.name;
|
|
107
|
+
return typeof name === "string" && name.length > 0 ? name : key;
|
|
108
|
+
}
|
|
41
109
|
/**
|
|
42
110
|
* [865]② name+id 双键目录索引:返回 ref 命中的目录 **name 键**(spec.model 的规范形),未知 → undefined。
|
|
43
111
|
* - name 命中用 Object.hasOwn(🔴 不用真值索引:Object.prototype 继承键 "constructor"/"__proto__" 等在裸
|
|
@@ -49,6 +49,14 @@ export declare const FAIL_OPEN_TAGS: {
|
|
|
49
49
|
readonly cls: "F";
|
|
50
50
|
readonly note: "CC 规则导入票的**认领回滚**失败(认领之后的某一步没成 ⇒ 本该把认领放回去,而这次放回本身也抛了)。放行的最坏后果 = 这张票留在已认领态、属主这一轮不能重试 —— 恰好等于加认领回滚**之前**的行为,不是新损失;票随 TTL 自然消失,属主重走一次 prepare 即可拿新票(导入按 core 的设计幂等:同一条规则再兑付只是同一个 dot 的重放)。方向上没有任何权限被放宽(规则**没有**落地才走到这条臂),故 F 类;必须留痕,否则「票为什么突然不能用了」在遥测里没有任何痕迹。";
|
|
51
51
|
};
|
|
52
|
+
readonly "server.approvals.rule-suggestions-cell-unreadable": {
|
|
53
|
+
readonly cls: "F";
|
|
54
|
+
readonly note: "运维待批队列(`GET /v1/approvals` / `/stream`)的某一行,`checkpoint.rule_suggestions` 这一格**不是合法 JSON 文本**(手改过的行 / 未来列语义漂了 / 回滚残留)。放行的最坏后果 = **这一行**的「不再询问」候选缺席(展示/分诊材料,永不参与 resume、也不是任何判据),队列其余部分照常;不放行的代价是整只 `rows.map` 抛出 ⇒ 一行坏 cell 打掉整个租户的队列(列表 500、SSE 心跳照常而队列永远空)。故 F 类,但必须留痕:静默吞掉之后「候选为什么消失了」在遥测里没有任何痕迹,而本键存在的全部理由就是「缺席 = 真的没有候选」。";
|
|
55
|
+
};
|
|
56
|
+
readonly "server.approvals.risk-descriptor-cell-unreadable": {
|
|
57
|
+
readonly cls: "F";
|
|
58
|
+
readonly note: "运维待批队列(`GET /v1/approvals` / `/stream`)的某一行,`checkpoint.risk_descriptor` 这一格**不是合法 JSON 文本**(手改过的行 / 回滚残留 / 列语义漂;与同一 `rows.map` 里的 `rule_suggestions` 是同族坏法,两方言都是 TEXT 列)。放行的最坏后果 = **这一行**的分诊 descriptor 落 null:severity 折 0 排到有 descriptor 的行之后、`shadowedRule` 无可脱敏、读面派生的 `governanceForced` 徽章不亮 —— 三者都是**展示/分诊**,不是执法判据(门早已 park,真按 `shellGateDoctrine` 判的 `active-run-conflict.ts` 读的是 checkpoint blob 的 `get()`,不经本读面),且 `governanceForced` 的成文语义本就是「缺席 = 没有治理来源的**证据**」。不放行的代价是整只 `rows.map` 抛出 ⇒ 一行坏 cell 打掉整个租户的队列(列表 500、SSE 心跳照常而队列永远空),连同其余所有行一起消失。故 F 类;tag 与候选那格**分开计**,并计会让「哪一格在坏」在遥测里读不出来。";
|
|
59
|
+
};
|
|
52
60
|
readonly "server.parked-revive.ancestor-classifier-unreachable": {
|
|
53
61
|
readonly cls: "P-DEBT";
|
|
54
62
|
readonly note: "跨副本赎回 parked 审批时,祖先层在 park 时是 **auto-mode 武装**的,但那只分类器是祖先任务上的活闭包(绑着它自己的转写窗+brain),跨进程重建不出来 ⇒ 本仓交一只如实拒答的 decider,core 收到 `unavailable` 后**不产生任何自动裁决**、原样落到祖先冻结审批席那条链(本腿的席位又是无 ALS 的降级形 ⇒ 再 park 给人)。方向:分类器本会 allow 的改成问人(更严),本会 block 的也改成问人(**不是自动放行**,但比自动拒松一档)⇒ 记债不当合法兜底。计数 = 「丢了祖先分类器判决的继承 ask」次数。收口件二选一:core 把分类器判据持久进链条目,或让祖先 decider 有可跨进程重建的形。";
|
|
@@ -72,6 +72,14 @@ export const FAIL_OPEN_TAGS = {
|
|
|
72
72
|
cls: "F",
|
|
73
73
|
note: "CC 规则导入票的**认领回滚**失败(认领之后的某一步没成 ⇒ 本该把认领放回去,而这次放回本身也抛了)。放行的最坏后果 = 这张票留在已认领态、属主这一轮不能重试 —— 恰好等于加认领回滚**之前**的行为,不是新损失;票随 TTL 自然消失,属主重走一次 prepare 即可拿新票(导入按 core 的设计幂等:同一条规则再兑付只是同一个 dot 的重放)。方向上没有任何权限被放宽(规则**没有**落地才走到这条臂),故 F 类;必须留痕,否则「票为什么突然不能用了」在遥测里没有任何痕迹。",
|
|
74
74
|
},
|
|
75
|
+
"server.approvals.rule-suggestions-cell-unreadable": {
|
|
76
|
+
cls: "F",
|
|
77
|
+
note: "运维待批队列(`GET /v1/approvals` / `/stream`)的某一行,`checkpoint.rule_suggestions` 这一格**不是合法 JSON 文本**(手改过的行 / 未来列语义漂了 / 回滚残留)。放行的最坏后果 = **这一行**的「不再询问」候选缺席(展示/分诊材料,永不参与 resume、也不是任何判据),队列其余部分照常;不放行的代价是整只 `rows.map` 抛出 ⇒ 一行坏 cell 打掉整个租户的队列(列表 500、SSE 心跳照常而队列永远空)。故 F 类,但必须留痕:静默吞掉之后「候选为什么消失了」在遥测里没有任何痕迹,而本键存在的全部理由就是「缺席 = 真的没有候选」。",
|
|
78
|
+
},
|
|
79
|
+
"server.approvals.risk-descriptor-cell-unreadable": {
|
|
80
|
+
cls: "F",
|
|
81
|
+
note: "运维待批队列(`GET /v1/approvals` / `/stream`)的某一行,`checkpoint.risk_descriptor` 这一格**不是合法 JSON 文本**(手改过的行 / 回滚残留 / 列语义漂;与同一 `rows.map` 里的 `rule_suggestions` 是同族坏法,两方言都是 TEXT 列)。放行的最坏后果 = **这一行**的分诊 descriptor 落 null:severity 折 0 排到有 descriptor 的行之后、`shadowedRule` 无可脱敏、读面派生的 `governanceForced` 徽章不亮 —— 三者都是**展示/分诊**,不是执法判据(门早已 park,真按 `shellGateDoctrine` 判的 `active-run-conflict.ts` 读的是 checkpoint blob 的 `get()`,不经本读面),且 `governanceForced` 的成文语义本就是「缺席 = 没有治理来源的**证据**」。不放行的代价是整只 `rows.map` 抛出 ⇒ 一行坏 cell 打掉整个租户的队列(列表 500、SSE 心跳照常而队列永远空),连同其余所有行一起消失。故 F 类;tag 与候选那格**分开计**,并计会让「哪一格在坏」在遥测里读不出来。",
|
|
82
|
+
},
|
|
75
83
|
"server.parked-revive.ancestor-classifier-unreachable": {
|
|
76
84
|
cls: "P-DEBT",
|
|
77
85
|
note: "跨副本赎回 parked 审批时,祖先层在 park 时是 **auto-mode 武装**的,但那只分类器是祖先任务上的活闭包(绑着它自己的转写窗+brain),跨进程重建不出来 ⇒ 本仓交一只如实拒答的 decider,core 收到 `unavailable` 后**不产生任何自动裁决**、原样落到祖先冻结审批席那条链(本腿的席位又是无 ALS 的降级形 ⇒ 再 park 给人)。方向:分类器本会 allow 的改成问人(更严),本会 block 的也改成问人(**不是自动放行**,但比自动拒松一档)⇒ 记债不当合法兜底。计数 = 「丢了祖先分类器判决的继承 ask」次数。收口件二选一:core 把分类器判据持久进链条目,或让祖先 decider 有可跨进程重建的形。",
|
|
@@ -253,6 +253,9 @@ export function createMetrics() {
|
|
|
253
253
|
m.counter("budget_exceeded_total", "Tasks failed by a budget/limit ceiling, by errorCode");
|
|
254
254
|
m.counter("cost_quota_rejected_total", "Tasks refused because the principal is over its cumulative cost quota");
|
|
255
255
|
m.counter("degraded_total", "Tasks degraded to a cheaper model (1.40), by reason (budget/rate_limit/breaker_open)");
|
|
256
|
+
// #233 / A-002.8:名单外的模型选择在 **resume 腿**被放行(fresh 提交是 400)。读法:非零 = 还有存量
|
|
257
|
+
// 会话跑在 @-mention 名单之外 —— 那是刻意的(砖死 resume 比降级糟),但治理面必须看得见它有多少。
|
|
258
|
+
m.counter("task_model_not_allowlisted_resume_total", "Resumed legs whose model/compactionModel is outside the @-mention allowlist (allowed to keep running by design — the allowlist gates fresh picks only)");
|
|
256
259
|
m.counter("prompt_constitution_total", "Prompt assembly constitution mode per task (core 1.243 提示词主权): steady state = core; provider-assembled/replaced/legacy warrant eyes");
|
|
257
260
|
m.counter("rewind_snapshot_failed_total", "Per-turn rewind file snapshots that failed, by code (too_large = big working tree, expected/benign; other codes warrant eyes)");
|
|
258
261
|
m.counter("verifications_total", "Tasks run through the verification gate (1.44), by final verdict");
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #219(黑板 [3617]):引擎 **parent 监视**。
|
|
3
|
+
*
|
|
4
|
+
* ## 防的是什么
|
|
5
|
+
*
|
|
6
|
+
* 壳(cli/TUI/桌面)自 spawn 引擎的**同机形**里,壳被 SIGKILL(崩溃、`kill -9`、OOM killer)之后没有
|
|
7
|
+
* 任何清理钩子跑得起来:引擎被 reparent 到 init,**继续活着** —— 占端口、占库连接池、占模型配额,而且
|
|
8
|
+
* 再没有人会给它发 SIGTERM(T11 的"骑进程组"腿只覆盖信号送得到的形;SIGKILL 那一路本来就无钩)。
|
|
9
|
+
* 于是引擎给自己装一只**自查**腿:定期问一句"当年生我的那个 pid 还在吗",不在就**自己**走正常停机。
|
|
10
|
+
*
|
|
11
|
+
* ## 机制(逐条都是契约)
|
|
12
|
+
*
|
|
13
|
+
* 1. **opt-in**:`SEMA_PARENT_PID` 缺席 ⇒ 整件不装配(`boot/shutdown.ts` 连 `createParentWatch` 都不调)。
|
|
14
|
+
* 存量部署逐字零变化。坏值拒启在 `config.ts` 的 `parentPidEnv()`。
|
|
15
|
+
* 2. **探活 = `process.kill(pid, 0)`**:0 号信号不投递任何信号,只做"这个 pid 存在吗 + 我能不能给它
|
|
16
|
+
* 发信号"的检查。两种失败方向**语义相反**,写反了后果不对称:
|
|
17
|
+
* · `ESRCH` = 没有这个进程 ⇒ 父**不在**(这是我们要抓的);
|
|
18
|
+
* · `EPERM` = 进程**在**,只是本进程无权给它发信号(父跑在另一个 uid 下 —— systemd/su/容器里
|
|
19
|
+
* 很常见)⇒ **算活着**。把 EPERM 当"不在",每台这种机器上的引擎会在开机后两拍内自杀。
|
|
20
|
+
* 3. **连续两拍才判死**:单拍抖动(探活恰好撞上一次瞬时错误)不误杀。任何一拍看到"活着"就清零。
|
|
21
|
+
* 4. **判死后走 `drain`**,即 `boot/shutdown.ts` 里 SIGTERM 用的那一条 `drainThenShutdown` —— in-flight
|
|
22
|
+
* run 的结算 / park 语义与人工停机**逐字一致**。本模块自己**从不** `process.exit`。
|
|
23
|
+
* 5. **interval 必须 unref**:监视腿不得成为"进程本该退出却退不掉"的理由(与 drain tick 刻意不 unref
|
|
24
|
+
* 的取向相反 —— 那一只 tick 就是停机驱动,这一只只是观察者)。
|
|
25
|
+
*
|
|
26
|
+
* ## 已知残余(成文,现版不收窄)
|
|
27
|
+
*
|
|
28
|
+
* · **pid 复用**:两拍窗口(≤30s)内父 pid 被系统复用,探活看到的是新进程,监视会判"父还活着"而不自退。
|
|
29
|
+
* · **僵尸父进程**(codex 复审 H,本机 perl fork 实测证实):`kill(pid, 0)` 只证明 **pid 表项**在,不证明
|
|
30
|
+
* 进程还在跑 —— 父退出但**没被它自己的父进程 wait 回收**时留下 zombie(`ps STAT=Z`),表项还在,于是
|
|
31
|
+
* 每一拍都判"活着",监视永不触发。触发条件是壳的**上级**不回收子进程(容器里的朴素 PID 1 是典型),
|
|
32
|
+
* 终端 / launchd / systemd 起的壳不在此列。
|
|
33
|
+
*
|
|
34
|
+
* 两条都源于同一个根:`kill(pid, 0)` 是**弱身份**判据。收窄需要一个真身份锚(`process.ppid` 变化、
|
|
35
|
+
* starttime、pidfd、或壳侧持有型管道),那是**机制换代**而不是参数微调,归设计属主裁。本件在定稿里
|
|
36
|
+
* 就是**尽力自愈**、不是强一致的父子生命周期绑定 —— 要强一致,该由壳侧开一条持有型管道并在断开时收尸。
|
|
37
|
+
*/
|
|
38
|
+
/** 探活拍频。**固定值,不开 env 旋钮**(设计定稿:有真需求再议,防旋钮增殖)。 */
|
|
39
|
+
export declare const PARENT_WATCH_INTERVAL_MS = 15000;
|
|
40
|
+
export interface ParentWatchDeps {
|
|
41
|
+
/** 被监视的父 pid(`config.parentPid`,已在 config 层校验为正整数)。 */
|
|
42
|
+
pid: number;
|
|
43
|
+
/** 探活 seam:抛错 = 探活失败(`.code` 携 errno)。缺省 = `process.kill(pid, 0)`。
|
|
44
|
+
* 存在的理由不是"可配置",是 `ESRCH`/`EPERM` 两支在测试里没有确定性的自然触发法。 */
|
|
45
|
+
probe?: (pid: number) => void;
|
|
46
|
+
/** 已有停机属主(`closing || draining`)时监视腿站下:一次正在进行的优雅 drain 不该被"父也没了"
|
|
47
|
+
* 升级成第二信号硬停(那会斩掉 in-flight leg —— 与本件"和人工停机逐字一致"的取向相反)。 */
|
|
48
|
+
isStopped: () => boolean;
|
|
49
|
+
/** 判死出口 = SIGTERM 那条 graceful drain。 */
|
|
50
|
+
drain: () => void;
|
|
51
|
+
log: (event: string, fields: Record<string, unknown>) => void;
|
|
52
|
+
}
|
|
53
|
+
/** 装配并**立刻**起监视(装配即 `parent_watch_armed`)。返回停机链用的 `stop()`。 */
|
|
54
|
+
export declare function createParentWatch(deps: ParentWatchDeps): {
|
|
55
|
+
stop(): void;
|
|
56
|
+
};
|
|
57
|
+
//# sourceMappingURL=parent-watch.d.ts.map
|