@sema-agent/server 3.20.0 → 3.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/approval-hmac.d.ts +9 -9
  2. package/dist/approval-hmac.js +0 -31
  3. package/dist/approval.js +8 -1
  4. package/dist/boot/config-center.d.ts +3 -2
  5. package/dist/boot/resolve-spec.js +4 -4
  6. package/dist/boot/session-faces.js +3 -2
  7. package/dist/boot/workflow-orchestration.js +1 -1
  8. package/dist/budget.d.ts +2 -2
  9. package/dist/budget.js +11 -6
  10. package/dist/fleet/fleet-bus.d.ts +2 -0
  11. package/dist/fleet/fleet-bus.js +34 -7
  12. package/dist/hooks/hook-llm.d.ts +2 -2
  13. package/dist/hooks/hook-llm.js +1 -1
  14. package/dist/http/active-run-conflict.d.ts +68 -0
  15. package/dist/http/active-run-conflict.js +89 -0
  16. package/dist/http/principal-gate.d.ts +7 -3
  17. package/dist/http/principal-gate.js +7 -5
  18. package/dist/http/route-ctx.d.ts +34 -25
  19. package/dist/http/routes/approvals-assistant.js +5 -5
  20. package/dist/http/routes/images.js +8 -8
  21. package/dist/http/routes/runs.js +3 -1
  22. package/dist/http/routes/tasks.js +5 -3
  23. package/dist/http/server.d.ts +2 -2
  24. package/dist/http/server.js +2 -2
  25. package/dist/key-resolver.d.ts +7 -1
  26. package/dist/key-resolver.js +0 -23
  27. package/dist/leader/wire.d.ts +19 -1
  28. package/dist/leader/wire.js +48 -18
  29. package/dist/main.js +2 -2
  30. package/dist/parked-decide.js +4 -1
  31. package/dist/plugins/file-run-store.js +21 -8
  32. package/dist/plugins/host-platform.d.ts +11 -24
  33. package/dist/plugins/host-platform.js +14 -0
  34. package/dist/plugins/memory-engine-tidb.js +16 -0
  35. package/dist/plugins/memory-run-store.js +19 -8
  36. package/dist/plugins/remote-env-adb.js +12 -5
  37. package/dist/plugins/remote-env-local-docker.js +3 -7
  38. package/dist/plugins/remote-env-ssh.js +17 -2
  39. package/dist/plugins/run-store-sql.js +18 -12
  40. package/dist/plugins/store-backend.d.ts +1 -1
  41. package/dist/plugins/store-backend.js +2 -2
  42. package/dist/plugins/tool-result-store-sql.d.ts +7 -1
  43. package/dist/plugins/tool-result-store-sql.js +9 -8
  44. package/dist/plugins/workflow-run-store-sql.d.ts +11 -6
  45. package/dist/plugins/workflow-run-store-sql.js +18 -8
  46. package/dist/session-sync.js +6 -3
  47. package/package.json +2 -2
@@ -30,13 +30,19 @@ import type { ApprovalHmacKey } from "./auth-keys.js";
30
30
  * 之所以到今天才把规则写死:此前四个字段全是**受限字母表**(uuid / 调用 id / 十六进制 / 枚举),转义怎么
31
31
  * 写都一样、规则不写也测不出来;`reason` 是第一个进入签名载荷的自由文本,转义从"无所谓"变成"承重"。
32
32
  */
33
- export declare function approvalHmacMessage(env: {
33
+ /** The canonical 5-field envelope both {@link approvalHmacMessage} (the signer's/verifier's shared byte-rule) and
34
+ * {@link verifyApprovalHmac} take — ONE named type so the two signatures cannot silently drift into two
35
+ * structurally-similar-but-not-identical inline shapes (drift there would only surface at RUNTIME as a MAC
36
+ * mismatch, never at compile time — see the file-header canonical-message note for why each field's presence
37
+ * is load-bearing). */
38
+ export interface ApprovalEnvelope {
34
39
  sessionId: string;
35
40
  boundCallId?: string | null;
36
41
  boundInputHash?: string | null;
37
42
  decision: string;
38
43
  reason?: string | null;
39
- }): string;
44
+ }
45
+ export declare function approvalHmacMessage(env: ApprovalEnvelope): string;
40
46
  /** 签名载荷里 `reason` 的字符上限。**拒**而不是截 —— 截断在这条路径上**结构上不可行**:服务端截过的字节
41
47
  * 与签名方签的字节必然不同,MAC 恒不匹配,"截断"只会把一个可诊断的 413 变成一个费解的 401。
42
48
  * 取 4096 与本仓既有的人写文本上限同档(`MAX_ELICIT_MESSAGE_CHARS` / `MAX_AGENT_TEXT_CHARS`)。 */
@@ -47,11 +53,5 @@ export declare const MAX_APPROVAL_REASON_CHARS = 4096;
47
53
  * signed with a key being rotated out still verifies during the overlap). `timingSafeEqual` avoids a timing
48
54
  * oracle. An empty key-set ⇒ false (caller decides whether absence = skip-because-inactive or fail-closed).
49
55
  */
50
- export declare function verifyApprovalHmac(env: {
51
- sessionId: string;
52
- boundCallId?: string | null;
53
- boundInputHash?: string | null;
54
- decision: string;
55
- reason?: string | null;
56
- }, mac: string, kid: string | undefined, keys: ApprovalHmacKey[]): boolean;
56
+ export declare function verifyApprovalHmac(env: ApprovalEnvelope, mac: string, kid: string | undefined, keys: ApprovalHmacKey[]): boolean;
57
57
  //# sourceMappingURL=approval-hmac.d.ts.map
@@ -25,37 +25,6 @@
25
25
  * already-resolved token → the resolve CAS fails already_resolved), so no separate replay cache is needed.
26
26
  */
27
27
  import { createHmac, timingSafeEqual } from "node:crypto";
28
- /**
29
- * The canonical message an approval MAC signs — a fixed-order, JSON-escaped tuple so no field value (an id, a
30
- * hash, a free-text note) can ambiguate the boundary with the next (same discipline as the approvals-stream diff
31
- * key). 🔴 Bound to CLIENT-VISIBLE values only: `sessionId` (in the /decide URL) + `boundCallId`/`boundInputHash`
32
- * (surfaced by listPending) + `reason` (the caller's own body). It must NOT include the checkpointToken — that is
33
- * the unexposed resume credential the client can never see (it resumes by sessionId), so a checkpointToken-bound
34
- * message would be uncomputable by the signer.
35
- *
36
- * ── `reason` 入签(三方裁 (a):cli [1797] / core [1801]§四 / server;clay「干净切」令 [1802])─────────────
37
- * `reason` 是随决定落账的自由文本(审计面的一部分)。它此前**在信封之外** ⇒ 能改线上字节的一方(反代 /
38
- * 被劫持的客户端 —— 正是 HMAC 要防的威胁模型)可以在操作员批准同一个动作的那次决定里,把落账理由换成
39
- * 别的:动作没变,**审计记录变了**。三方裁定纳入签名载荷,不留 `reasonUnsigned` 宽限代际(未上生产)。
40
- *
41
- * ── 🔴 字节构造规则(权威定义;跨仓签名器照此实现,docs/ASSISTANT-WIRE-CONTRACT.md §6.2 同源)──────────
42
- * 被 HMAC 的是下面这个字符串的 **UTF-8 字节**:
43
- * 1. **恒 5 元素、定序**的紧凑 JSON 数组(元素间**无空格**):
44
- * `[sessionId, boundCallId|null, boundInputHash|null, decision, reason|null]`
45
- * —— 恒定长度是刻意的:签名器里少一个"有 reason 才追加"的条件分支,就少一类跨实现漂移。
46
- * 2. 缺席 ⇒ `null`;**`""`(空串)是与 `null` 不同的被签值**(否则"把理由抹成空"不改变 MAC)。
47
- * 3. 字符串转义 = RFC 8259 最小集:`"`→`\"`、`\`→`\\`、U+0000–U+001F → `\n`/`\t`/`\uXXXX`。
48
- * 4. **非 ASCII 原样输出 UTF-8,不转 `\uXXXX`**。⚠️ Python 的 `json.dumps` 缺省 `ensure_ascii=True` 会踩
49
- * —— 必须 `json.dumps(t, ensure_ascii=False, separators=(",", ":"))`。
50
- * 5. **`<` `>` `&` 不转义**。⚠️ Go 的 `json.Marshal` 缺省 HTML 转义会踩 —— 必须
51
- * `enc := json.NewEncoder(w); enc.SetEscapeHTML(false)`(并去掉它追加的换行)。
52
- * 6. 孤代理项按 ES2019 well-formed 语义转成 `\udXXX`(JS 原生行为,其它语言按同规则)。
53
- * 7. MAC = HMAC-SHA256(上述字节),**小写十六进制**上 wire。
54
- * 逐字节黄金向量(含中文/引号/换行/制表/`<`/`&`/emoji)钉在 `test/approval-hmac.test.ts`,签名器对拍用。
55
- *
56
- * 之所以到今天才把规则写死:此前四个字段全是**受限字母表**(uuid / 调用 id / 十六进制 / 枚举),转义怎么
57
- * 写都一样、规则不写也测不出来;`reason` 是第一个进入签名载荷的自由文本,转义从"无所谓"变成"承重"。
58
- */
59
28
  export function approvalHmacMessage(env) {
60
29
  return JSON.stringify([env.sessionId, env.boundCallId ?? null, env.boundInputHash ?? null, env.decision, env.reason ?? null]);
61
30
  }
package/dist/approval.js CHANGED
@@ -8,6 +8,8 @@
8
8
  * dropped at the task deadline instead of holding the worker — design/06 F4 self-protection.
9
9
  */
10
10
  import { createApprovalPolicy, canonicalToolName, uuidv7 } from "@sema-agent/core";
11
+ import { createLogger } from "./observability/logger.js";
12
+ const logger = createLogger();
11
13
  /**
12
14
  * 运维**是否表达了门意图** —— boot 的单用户 allow-all 基线只在"零门意图"时才允许铺开,
13
15
  * 所以这个谓词漏一格 = 那一格的意图被 allow-all 静默吞掉。
@@ -170,7 +172,12 @@ export async function waitForDecision(store, id, scope, signal, pollMs) {
170
172
  let consecutiveErrors = 0;
171
173
  for (;;) {
172
174
  if (signal?.aborted) {
173
- await store.decide(id, scope, "expired", "task aborted before decision", null).catch(() => undefined);
175
+ // C1(failloud):最小对症=失败留痕,不必重试——这是最后一次尝试把行标 expired,任务本身已经在
176
+ // abort 路径上收尾,重试没有下一个时机。裸吞会让一次真实的店写故障(TiDB blip)在观测面上
177
+ // 与"成功标了 expired"毫无区别;补 error 留痕,行为(deny)不变。
178
+ await store.decide(id, scope, "expired", "task aborted before decision", null).catch((e) => {
179
+ logger.error("approval_expire_write_failed", { id, scope, err: e instanceof Error ? e.message : String(e) });
180
+ });
174
181
  return false;
175
182
  }
176
183
  let s;
@@ -1,9 +1,10 @@
1
- import { CenterPromptSource, type Model } from "@sema-agent/core";
1
+ import { CenterPromptSource } from "@sema-agent/core";
2
2
  import { buildPricing } from "../budget.js";
3
3
  import { type PromptsDomainFaces } from "../capabilities/center-prompts.js";
4
4
  import { type Scenario, type ScenarioDeps, type ScenarioDetail } from "../capabilities/scenarios.js";
5
5
  import type { LoadedSkill } from "../capabilities/skills.js";
6
6
  import { type ServiceConfig } from "../config.js";
7
+ import { type ModelKeyRef } from "../key-resolver.js";
7
8
  import type { Logger } from "../observability/logger.js";
8
9
  import type { Metrics } from "../observability/metrics.js";
9
10
  import { type RestartSignal } from "../config-center/facade.js";
@@ -42,7 +43,7 @@ export interface ConfigCenterRuntime {
42
43
  pricing: ReturnType<typeof buildPricing>;
43
44
  }): void;
44
45
  /** 晚绑取值:refresh 热应用会整个换引用(hook LLM / resolveSpec 每次现取)。 */
45
- getKeyResolver(): ((model: Model) => Promise<{
46
+ getKeyResolver(): ((model: ModelKeyRef) => Promise<{
46
47
  apiKey: string;
47
48
  } | undefined>) | undefined;
48
49
  /** 晚绑取值:center 提示词面(热采用,新任务边界生效)。 */
@@ -30,7 +30,7 @@ import { applyLongtailDefer } from "../capabilities/tool-defer.js";
30
30
  import { acceptShellScratchpadDir, buildEnvFacts, egressForRemoteExec, ensureScratchpadDir, resumeFactsForLane } from "../env-facts.js";
31
31
  import { FleetEventBus } from "../fleet/fleet-bus.js";
32
32
  import { composeHooks, createTaskHooks } from "../hooks/hook-runner.js";
33
- import { explicitOperator } from "../http/server.js";
33
+ import { explicitOperatorOk } from "../http/server.js";
34
34
  import { matchCatalogModel, modelSupportsImages, resolveTaskModel } from "../model-select.js";
35
35
  import { PerTaskImageRegistry, resolveSandboxImageRef } from "../per-task-image.js";
36
36
  import { isRemoteScratchpadLane, remoteScratchpadDirFor } from "../plugins/remote-scratchpad.js";
@@ -533,7 +533,7 @@ export function createResolveSpec(ctx) {
533
533
  // design/131 (core 1.246): per-task resilience INTENT flags. allowDegrade/allowFailover are
534
534
  // caller-facing (a bench/eval run wants true failure shapes); bypassBreaker is operator-only (normalizer
535
535
  // drops it for non-operators — it punches through a SHARED breaker). All-absent = byte-compat.
536
- resilience: normalizeResilience(body.resilience, explicitOperator(auth?.principal, config.operatorPrincipals)),
536
+ resilience: normalizeResilience(body.resilience, explicitOperatorOk(auth?.principal, config.operatorPrincipals)),
537
537
  // design/132 (core 1.249): one-shot end-game verification nudge ("re-run the final artifact through
538
538
  // its real entrypoint before finishing"). OPT-IN by core's own judgment (default ON lost the evidence case:
539
539
  // +1 turn on every interactive write task) — the AUTONOMY caller declares it (harness/scheduler lanes).
@@ -962,11 +962,11 @@ export function createResolveSpec(ctx) {
962
962
  profile: taskImageProfile,
963
963
  ...(capsNeeded && capsNeeded.length > 0 ? { capabilitiesNeeded: capsNeeded } : {}),
964
964
  // principal comes from the auth channel (trusted header / verified JWT), NEVER the body — so a caller
965
- // cannot widen its own visibility. explicitOperator (NOT isOperator): an empty OPERATOR_PRINCIPALS must
965
+ // cannot widen its own visibility. explicitOperatorOk (NOT isOperator): an empty OPERATOR_PRINCIPALS must
966
966
  // yield operator=false here — isOperator([],p)=true-for-all would let any caller resolve tenant-scoped
967
967
  // images on an operator-less deployment (adversarial-review HIGH-1; the bake/direct-door boot guards do
968
968
  // NOT cover per-task image selection).
969
- viewer: { operator: explicitOperator(auth.principal, config.operatorPrincipals), tenantId: auth.principal ?? null },
969
+ viewer: { operator: explicitOperatorOk(auth.principal, config.operatorPrincipals), tenantId: auth.principal ?? null },
970
970
  index: imageIndex,
971
971
  });
972
972
  if (!resolved.ok)
@@ -63,8 +63,9 @@ export function createSessionFaces(ctx) {
63
63
  })
64
64
  : undefined;
65
65
  // S2 fast path([1208]③ 兑现):同副本 append 落点(tidb/pg persist post-commit)经 leaf-bus 直推
66
- // notifyLocal——单副本部署零延迟;owner 不随 bus 传(写点无廉价 owner 读),探针围栏仍是租户权威
67
- // (session-leaf-bus 契约注)。local lane 无写钩,探针道照旧。
66
+ // notifyLocal——单副本部署零延迟;owner bus 传(commit-time row owner,session-leaf-bus 头注契约:
67
+ // "Owner IS carried"),notifyLocal 据此做 generation 围栏( A 世代不会假匹配新租户的叶子)。
68
+ // local lane 无写钩,探针道照旧。
68
69
  setLeafAdvanceListener(sessionWatchRegistry ? (sid, leaf, owner, seq) => sessionWatchRegistry.notifyLocal(sid, leaf, owner, seq) : undefined);
69
70
  const purgeSession = ownerAware.deleteSession
70
71
  ? async (sessionId, owner) => {
@@ -33,7 +33,7 @@ export function createWorkflowOrchestration(ctx) {
33
33
  // present — a failover-landed session sees the run's history + its pending completion push on ANY replica.
34
34
  // Explicit WORKFLOW_RUN_STORE=file/memory still wins (single-box File posture unchanged: local backend has
35
35
  // no workflowRun() so auto falls to File there).
36
- const sqlWorkflowRunStore = config.workflowRunStoreBackend === "auto" ? backend?.workflowRun?.() : undefined;
36
+ const sqlWorkflowRunStore = config.workflowRunStoreBackend === "auto" ? backend?.workflowRun?.((msg, meta) => logger.warn(msg, meta)) : undefined; // C5: oversize-slim 留痕接 logger(completionInbox :82 同款)
37
37
  const baseWorkflowRunStore = config.selfOrchestrationEnabled
38
38
  ? config.workflowRunStoreBackend === "memory"
39
39
  ? new InMemoryWorkflowRunStore()
package/dist/budget.d.ts CHANGED
@@ -83,7 +83,7 @@ export { type PromptManifestRecord, promptManifestRecordOf, configAssembledRecor
83
83
  * fires for EVERY call (top-level + async + council sub-tasks) → `model_cost_micro_usd_total` is the authoritative
84
84
  * spend, and (when a {@link ModelUsageTracker} is supplied) the per-task `modelUsage` echo's source.
85
85
  */
86
- export declare function createTracer(metrics: Pick<Metrics, "inc" | "observe">, costQuota?: QuotaTracker, modelUsage?: ModelUsageTracker, fleetUsage?: FleetUsageAccumulator, fleetLease?: FleetLeaseManager, quotaWeightFor?: (model: string) => number, promptManifests?: PromptManifestTracker): TracerHook;
86
+ export declare function createTracer(metrics: Pick<Metrics, "inc" | "observe">, costQuota?: QuotaTracker, modelUsage?: ModelUsageTracker, fleetUsage?: Pick<FleetUsageAccumulator, "record">, fleetLease?: Pick<FleetLeaseManager, "recordSpend">, quotaWeightFor?: (model: string) => number, promptManifests?: PromptManifestTracker): TracerHook;
87
87
  /**
88
88
  * [1469] side-query 记账 seam(codex R3 high:sideQuery 不走 core tracer——brain.stream 直调不发
89
89
  * brain.call——createTracer 的四路 sink(costQuota/model_cost 指标/fleetUsage 批报/fleetLease 本地扣减)
@@ -106,7 +106,7 @@ export declare function cacheFamilyOfMirror(model: {
106
106
  promptCacheFamily?: string;
107
107
  };
108
108
  } | undefined): "input-includes-cached" | "input-excludes-cached" | undefined;
109
- export declare function createSideQueryAccountant(metrics: Pick<Metrics, "inc">, costQuota?: QuotaTracker, fleetUsage?: FleetUsageAccumulator, fleetLease?: FleetLeaseManager, quotaWeightFor?: (model: string) => number): (principal: string | undefined, r: {
109
+ export declare function createSideQueryAccountant(metrics: Pick<Metrics, "inc">, costQuota?: QuotaTracker, fleetUsage?: Pick<FleetUsageAccumulator, "record">, fleetLease?: Pick<FleetLeaseManager, "recordSpend">, quotaWeightFor?: (model: string) => number): (principal: string | undefined, r: {
110
110
  model: string;
111
111
  family?: "input-includes-cached" | "input-excludes-cached";
112
112
  usage?: {
package/dist/budget.js CHANGED
@@ -320,7 +320,9 @@ export function cacheFamilyOfMirror(model) {
320
320
  return "input-includes-cached";
321
321
  return model.api === "anthropic-messages" || model.api === "bedrock-converse-stream" ? "input-excludes-cached" : "input-includes-cached";
322
322
  }
323
- export function createSideQueryAccountant(metrics, costQuota, fleetUsage, fleetLease, quotaWeightFor) {
323
+ export function createSideQueryAccountant(metrics, costQuota,
324
+ // 依赖面收窄到真消费的方法(§X 同 ModelKeyRef 一式):全形对象照传(结构超集),测试桩零铸型。
325
+ fleetUsage, fleetLease, quotaWeightFor) {
324
326
  return (principal, r) => {
325
327
  const input = typeof r.usage?.input === "number" ? r.usage.input : 0;
326
328
  const output = typeof r.usage?.output === "number" ? r.usage.output : 0;
@@ -331,8 +333,11 @@ export function createSideQueryAccountant(metrics, costQuota, fleetUsage, fleetL
331
333
  // 大幅少收(lease 可被 cache-heavy side query 绕穿)。family 未知(catalog 查不到)走**保守臂**
332
334
  // (含 cache——OpenAI 族此臂多收,方向与 cost 残差一致:宁多勿少,记档同一条)。
333
335
  const promptTokens = r.family === "input-includes-cached" ? input : input + cacheRead + cacheWrite;
334
- const micro = Math.round((typeof r.usage?.cost?.total === "number" ? r.usage.cost.total : 0) * 1_000_000);
335
- if (micro > 0) {
336
+ // RB-368/#59 同判据(createTracer 上方注释同款纪律):`usage.cost.total` 整键缺席 = 未知(unpriced 部署的
337
+ // side query 真形),不是「花了 $0」——折 0 就是把「不知道」编造成「免费」。已知(哪怕显式 0)才带键。
338
+ const costTotal = typeof r.usage?.cost?.total === "number" ? r.usage.cost.total : undefined;
339
+ const micro = costTotal !== undefined ? Math.round(costTotal * 1_000_000) : undefined;
340
+ if (micro !== undefined && micro > 0) {
336
341
  metrics.inc("model_cost_micro_usd_total", { model: r.model }, micro);
337
342
  if (principal)
338
343
  costQuota?.add(principal, micro);
@@ -340,9 +345,9 @@ export function createSideQueryAccountant(metrics, costQuota, fleetUsage, fleetL
340
345
  const qwRaw = quotaWeightFor?.(r.model);
341
346
  const qw = typeof qwRaw === "number" && Number.isFinite(qwRaw) && qwRaw > 0 ? qwRaw : 1;
342
347
  const weightedTokens = Math.round((promptTokens + output) * qw);
343
- if (weightedTokens > 0 || micro > 0) {
344
- fleetUsage?.record(principal, r.model, "", { inputTokens: promptTokens, outputTokens: output, costMicroUsd: micro, weightedTokens, quotaWeightAtUse: qw }); // taskId ""(无 run 语义;accumulator 对 "" 不计 runsSeen);inputTokens=归一化总输入含 cache(fleet-bus 同口径)
345
- fleetLease?.recordSpend(principal, { weightedTokens, costMicroUsd: micro });
348
+ if (weightedTokens > 0 || (micro !== undefined && micro > 0)) {
349
+ fleetUsage?.record(principal, r.model, "", { inputTokens: promptTokens, outputTokens: output, ...(micro !== undefined ? { costMicroUsd: micro } : {}), weightedTokens, quotaWeightAtUse: qw }); // taskId ""(无 run 语义;accumulator 对 "" 不计 runsSeen);inputTokens=归一化总输入含 cache(fleet-bus 同口径);RB-368 透传缺席
350
+ fleetLease?.recordSpend(principal, { weightedTokens, ...(micro !== undefined ? { costMicroUsd: micro } : {}) }); // RB-368 透传缺席
346
351
  }
347
352
  };
348
353
  }
@@ -291,6 +291,8 @@ export declare class FleetEventBus {
291
291
  releaseBackgroundChildLane(scope: string | undefined, childUuid: string): void;
292
292
  /** run-leg 车道:该子代是否已有 BCE 真行(有 ⇒ 让位,不铸复合 id 行)。 */
293
293
  backgroundChildLaneRow(scope: string | undefined, childUuid: string): string | undefined;
294
+ /** [2268] 让位迁移料的读面:迁移「只补缺席位」需要看得见存活行现有什么(BCE 是权威车道,不覆盖)。 */
295
+ taskRow(id: string): Readonly<FleetTaskRow> | undefined;
294
296
  /** Upsert a workflow row (MERGE by `id`) + fan out. */
295
297
  publishWorkflow(delta: Partial<FleetWorkflowRow> & {
296
298
  id: string;
@@ -86,6 +86,10 @@ export class FleetEventBus {
86
86
  backgroundChildLaneRow(scope, childUuid) {
87
87
  return this.bceClaims.get(FleetEventBus.bceKey(scope, childUuid));
88
88
  }
89
+ /** [2268] 让位迁移料的读面:迁移「只补缺席位」需要看得见存活行现有什么(BCE 是权威车道,不覆盖)。 */
90
+ taskRow(id) {
91
+ return this.tasks.get(id);
92
+ }
89
93
  // └────────────────────────────────────────────────────────────────────────────────────────────────┘
90
94
  /** Upsert a workflow row (MERGE by `id`) + fan out. */
91
95
  publishWorkflow(delta) {
@@ -172,20 +176,43 @@ export function fleetRunPublisher(bus, run) {
172
176
  * registry ⇒ 永远没有 BCE 真行,让位就等于让这些 agent 从面板消失。那一形保留复合行,并在帧上带
173
177
  * `sourceLane:"run-leg"` 明示这是过渡形——消费端不必再自己发明集合级去重判据。
174
178
  */
175
- const yieldToBackgroundChildLane = (childTaskId, cid) => {
176
- if (bus.backgroundChildLaneRow(run.scope, childTaskId) === undefined)
179
+ const yieldToBackgroundChildLane = (childTaskId, cid, tickName) => {
180
+ const bceRowId = bus.backgroundChildLaneRow(run.scope, childTaskId);
181
+ if (bceRowId === undefined)
177
182
  return false;
178
183
  if (children.has(cid)) {
179
184
  // 我们早于 claim 铸过一行 —— 清场(BCE 侧的后缀扫是同一意图的反应式兜底,两者幂等叠加无害)
180
185
  bus.removeTask(cid);
181
186
  children.delete(cid);
182
- childStartedAt.delete(cid);
187
+ // ⚠️ childStartedAt 故意不删 —— 它是下面 elapsed 供给的计时基准([2268])
183
188
  for (const [k, v] of childByToolCall)
184
189
  if (v === cid)
185
190
  childByToolCall.delete(k);
186
191
  }
192
+ // 🔴 [2268] 迁移料([2269] 认领「迁移」臂):此前让位=删「有名的 run-leg 行」留「无名的 BCE 行」
193
+ // ((unnamed)/0s/详情空白三症状同源)。tick 手上的显示料补给存活行,**只补缺席位** —— BCE 是权威
194
+ // 车道,它已有的 name/真 elapsed 一律不碰。elapsed 供给是持续的(本闸每拍都过),不是一次性冻结;
195
+ // elapsedDonor 记住「这行的 elapsed 是我们供的」,避免把 BCE 自己的真 elapsed 误当缺席重置。
196
+ // transcriptId 不在 run-leg tick 手上,迁不了(诚实范围;那半归 BCE 行自身后续 tick)。
197
+ const row = bus.taskRow(bceRowId);
198
+ if (row) {
199
+ const donorName = row.name === undefined && tickName !== undefined ? redactSecrets(tickName) : undefined;
200
+ if (!childStartedAt.has(cid))
201
+ childStartedAt.set(cid, Date.now());
202
+ if (elapsedDonor.has(cid) || row.elapsedMs === undefined || row.elapsedMs === 0)
203
+ elapsedDonor.add(cid);
204
+ const elapsed = elapsedDonor.has(cid) ? Date.now() - childStartedAt.get(cid) : undefined;
205
+ if (donorName !== undefined || elapsed !== undefined) {
206
+ bus.publishTask({
207
+ id: bceRowId,
208
+ ...(donorName !== undefined ? { name: donorName, agentType: donorName } : {}),
209
+ ...(elapsed !== undefined ? { elapsedMs: elapsed } : {}),
210
+ });
211
+ }
212
+ }
187
213
  return true;
188
214
  };
215
+ const elapsedDonor = new Set(); // [2268] 我们在为哪些 cid 供 elapsed(见上)
189
216
  const sumTokens = (usage) => {
190
217
  const u = usage;
191
218
  if (!u)
@@ -219,8 +246,8 @@ export function fleetRunPublisher(bus, run) {
219
246
  else if (ev.type === "task_progress" && ev.taskId) {
220
247
  // core 1.147 subagent usage tick (per-turn, cumulative) → a CHILD fleet row nested under the run.
221
248
  const cid = childId(ev.taskId);
222
- if (yieldToBackgroundChildLane(ev.taskId, cid))
223
- return; // [2070]① BCE 真行在场 ⇒ 让位不铸
249
+ if (yieldToBackgroundChildLane(ev.taskId, cid, ev.name))
250
+ return; // [2070]① BCE 真行在场 ⇒ 让位不铸([2268] 随迁显示料)
224
251
  children.add(cid);
225
252
  // BC-2 (core 1.151): use the subagent's sanitized display `name` (taskName/agent-type) for the child row.
226
253
  // [2070]③:name 缺席时**不铸**(此前回落 taskId=子代 uuid,人眼乱码)——行 IDENTITY 一直是 `cid`
@@ -273,8 +300,8 @@ export function fleetRunPublisher(bus, run) {
273
300
  if (ev.type !== "task_progress" || !ev.taskId)
274
301
  return;
275
302
  const cid = childId(ev.taskId);
276
- if (yieldToBackgroundChildLane(ev.taskId, cid))
277
- return; // [2070]① BCE 真行在场 ⇒ 让位不铸
303
+ if (yieldToBackgroundChildLane(ev.taskId, cid, ev.name))
304
+ return; // [2070]① BCE 真行在场 ⇒ 让位不铸([2268] 随迁显示料)
278
305
  children.add(cid);
279
306
  const parentId = ev.parentTaskId && ev.parentTaskId !== run.rootTaskId ? childId(ev.parentTaskId) : run.runId;
280
307
  // [WF2-A] redact the forwarded subagent name for parity with the top-level run name (see onEvent above).
@@ -1,4 +1,4 @@
1
- import type { Model } from "@sema-agent/core";
1
+ import type { ModelKeyRef } from "../key-resolver.js";
2
2
  import type { ServiceConfig } from "../config-types.js";
3
3
  import type { HookLlmCall } from "./hook-runner.js";
4
4
  /**
@@ -32,7 +32,7 @@ export interface HookLlm {
32
32
  export declare function createHookLlm(deps: {
33
33
  config: ServiceConfig;
34
34
  /** 与 brain 同一 hot-refreshed per-model key 面(main.ts 热应用会整个换引用,故取函数不取值)。 */
35
- getKeyResolver: () => ((model: Model) => Promise<{
35
+ getKeyResolver: () => ((model: ModelKeyRef) => Promise<{
36
36
  apiKey: string;
37
37
  } | undefined>) | undefined;
38
38
  metrics: {
@@ -74,7 +74,7 @@ export function createHookLlm(deps) {
74
74
  let perModelKey;
75
75
  if (keyResolver) {
76
76
  try {
77
- perModelKey = (await keyResolver({ name: pick.catalogRef }))?.apiKey;
77
+ perModelKey = (await keyResolver({ name: pick.catalogRef }))?.apiKey; // ModelKeyRef: legal mint, no cast (B1)
78
78
  }
79
79
  catch (e) {
80
80
  return { ok: false, error: `per-model key resolution for "${pick.catalogRef}" failed — refusing to fall back to the shared route credential (${String(e)})` };
@@ -0,0 +1,68 @@
1
+ /**
2
+ * [2252]/[2255]①/[2257] 追加件:409 `conflict.session_active_run` 的**真出路材料**装配(单源)。
3
+ *
4
+ * 事故形:park(suspended/needs_review)占着 session claim,客户端每条新提交吃 409,而旧文案只教
5
+ * cancel(毁灭当前 run)。对 parked 会话,「继续」的唯一合法动作是去**对的** resume 入口决议——但四条
6
+ * resume 入口各认各的 gate.kind,客户端不知道等的是哪种门,只能试错。本模块把答案放进 409 响应体:
7
+ * activeTaskStatus(区分「插话/取消」与「决议/取消」两族出路)
8
+ * pendingGate { kind, decidePath }(parked 且真有 pending checkpoint 时)
9
+ *
10
+ * 🔴 token 永不上 wire(approvals-assistant 纪律:resume 寻址=sessionId,checkpoint token 是秘密能力)。
11
+ * 🔴 失败方向:材料是 best-effort 增强——store 面任何失败都不得挡 409 本体,退化为旧形状,绝不 throw。
12
+ * 🔴 gateKind→入口映射是**这张表在仓里的唯一函数形**;它的知识此前散在 server.ts 三处 guard 文案里
13
+ * (`gate_not_tool_approval`/`gate_not_resumable`/`wake.gate_pending` 的指路句)。三处文案与本表若漂移,
14
+ * test/session-active-conflict-materials.test.ts 的分门用例会红。认不出的 kind ⇒ null(诚实缺席,不铸假门)。
15
+ */
16
+ /** 与 runs.ts/tasks.ts 三个 409 位共享的旧文案(byte-frozen:api-error-text-freeze 门认这句)。 */
17
+ export declare const ACTIVE_RUN_CONFLICT_BASE_TEXT = "session already has an active run \u2014 POST /v1/runs/{activeTaskId}/cancel stops it (same-instance interactive runs abort immediately)";
18
+ export interface ActiveRunConflictBody {
19
+ error: string;
20
+ errorCode: "conflict.session_active_run";
21
+ activeTaskId: string | null;
22
+ activeTaskStatus?: string;
23
+ pendingGate?: {
24
+ kind: string;
25
+ decidePath: string;
26
+ };
27
+ }
28
+ /** gate.kind → 它的那一个 resume 入口(sessionId/taskId 寻址,无秘密)。 */
29
+ export declare function resumeEntryForGate(kind: string, ids: {
30
+ sessionId: string;
31
+ taskId: string;
32
+ }): string | null;
33
+ /** token 泛型:真身是 branded CheckpointToken(秘密能力,只在本函数内部流转,绝不进响应体)。 */
34
+ /**
35
+ * SSE 车道的 done 帧 result(拒绝形)。与 409 body **同一铸体处** —— 此前 tasks.ts 在 res.write 里
36
+ * 手搓内联对象挑键,形状没有名字、没有类型、只活在那一行字符串里(conditional-spread 死键的老坑形)。
37
+ * 这里铸,SDK 的 done 帧 result 类型与本形同车对齐(sdk-api-guarantee-duty),live-contract 套件对已发包真跑。
38
+ */
39
+ export interface ActiveRunConflictDoneResult {
40
+ status: "failed";
41
+ errorMessage: string;
42
+ activeTaskId: string | null;
43
+ activeTaskStatus?: string;
44
+ pendingGate?: {
45
+ kind: string;
46
+ decidePath: string;
47
+ };
48
+ }
49
+ export declare function toDoneFrameResult(body: ActiveRunConflictBody): ActiveRunConflictDoneResult;
50
+ interface ConflictProbeDeps<TToken> {
51
+ runStore?: {
52
+ getRun?: (id: string) => Promise<{
53
+ status?: string;
54
+ } | null | undefined>;
55
+ } | undefined;
56
+ checkpointStore?: {
57
+ peekPendingScope?: (sessionId: string) => Promise<string | null | undefined>;
58
+ findPendingTokenBySession?: (sessionId: string, scope?: string) => Promise<TToken | null | undefined>;
59
+ get?: (token: TToken) => Promise<{
60
+ gate?: {
61
+ kind?: string;
62
+ };
63
+ } | null | undefined>;
64
+ } | undefined;
65
+ }
66
+ export declare function buildActiveRunConflict<TToken>(deps: ConflictProbeDeps<TToken>, sessionId: string, activeTaskId: string | null | undefined): Promise<ActiveRunConflictBody>;
67
+ export {};
68
+ //# sourceMappingURL=active-run-conflict.d.ts.map
@@ -0,0 +1,89 @@
1
+ /**
2
+ * [2252]/[2255]①/[2257] 追加件:409 `conflict.session_active_run` 的**真出路材料**装配(单源)。
3
+ *
4
+ * 事故形:park(suspended/needs_review)占着 session claim,客户端每条新提交吃 409,而旧文案只教
5
+ * cancel(毁灭当前 run)。对 parked 会话,「继续」的唯一合法动作是去**对的** resume 入口决议——但四条
6
+ * resume 入口各认各的 gate.kind,客户端不知道等的是哪种门,只能试错。本模块把答案放进 409 响应体:
7
+ * activeTaskStatus(区分「插话/取消」与「决议/取消」两族出路)
8
+ * pendingGate { kind, decidePath }(parked 且真有 pending checkpoint 时)
9
+ *
10
+ * 🔴 token 永不上 wire(approvals-assistant 纪律:resume 寻址=sessionId,checkpoint token 是秘密能力)。
11
+ * 🔴 失败方向:材料是 best-effort 增强——store 面任何失败都不得挡 409 本体,退化为旧形状,绝不 throw。
12
+ * 🔴 gateKind→入口映射是**这张表在仓里的唯一函数形**;它的知识此前散在 server.ts 三处 guard 文案里
13
+ * (`gate_not_tool_approval`/`gate_not_resumable`/`wake.gate_pending` 的指路句)。三处文案与本表若漂移,
14
+ * test/session-active-conflict-materials.test.ts 的分门用例会红。认不出的 kind ⇒ null(诚实缺席,不铸假门)。
15
+ */
16
+ /** 与 runs.ts/tasks.ts 三个 409 位共享的旧文案(byte-frozen:api-error-text-freeze 门认这句)。 */
17
+ export const ACTIVE_RUN_CONFLICT_BASE_TEXT = "session already has an active run — POST /v1/runs/{activeTaskId}/cancel stops it (same-instance interactive runs abort immediately)";
18
+ /** gate.kind → 它的那一个 resume 入口(sessionId/taskId 寻址,无秘密)。 */
19
+ export function resumeEntryForGate(kind, ids) {
20
+ switch (kind) {
21
+ case "tool_approval":
22
+ case "human":
23
+ case "irreversible_ask":
24
+ case "policy_ask":
25
+ return `/v1/approvals/${ids.sessionId}/decide`;
26
+ case "plan_review":
27
+ return `/v1/assistant/tasks/${ids.taskId}/plan_review`;
28
+ case "resource_limit":
29
+ return `/v1/assistant/tasks/${ids.taskId}/resume`;
30
+ case "task_done":
31
+ return `/v1/sessions/${ids.sessionId}/wake`;
32
+ default:
33
+ return null; // 未知门型:宁缺毋假 —— 客户端仍有 activeTaskStatus + cancel 这条保底真路
34
+ }
35
+ }
36
+ export function toDoneFrameResult(body) {
37
+ return {
38
+ status: "failed",
39
+ errorMessage: body.error,
40
+ activeTaskId: body.activeTaskId,
41
+ ...(body.activeTaskStatus !== undefined ? { activeTaskStatus: body.activeTaskStatus } : {}),
42
+ ...(body.pendingGate !== undefined ? { pendingGate: body.pendingGate } : {}),
43
+ };
44
+ }
45
+ export async function buildActiveRunConflict(deps, sessionId, activeTaskId) {
46
+ const base = {
47
+ error: ACTIVE_RUN_CONFLICT_BASE_TEXT,
48
+ errorCode: "conflict.session_active_run",
49
+ activeTaskId: activeTaskId ?? null,
50
+ };
51
+ if (!activeTaskId)
52
+ return base;
53
+ try {
54
+ const row = await deps.runStore?.getRun?.(activeTaskId);
55
+ const status = row?.status;
56
+ if (status !== "running" && status !== "suspended" && status !== "needs_review")
57
+ return base;
58
+ if (status === "running") {
59
+ return {
60
+ ...base,
61
+ error: "session already has an active run — POST /v1/runs/{activeTaskId}/steer injects a message into the running turn (queued, applied at the next turn boundary), or POST /v1/runs/{activeTaskId}/cancel stops it (same-instance interactive runs abort immediately)",
62
+ activeTaskStatus: status,
63
+ };
64
+ }
65
+ // parked:找 pending checkpoint 的门型,指到它的那一个 resume 入口
66
+ const cs = deps.checkpointStore;
67
+ let pendingGate;
68
+ if (cs?.findPendingTokenBySession) {
69
+ const scope = (await cs.peekPendingScope?.(sessionId)) ?? undefined;
70
+ const token = scope === null ? null : await cs.findPendingTokenBySession(sessionId, scope);
71
+ const kind = token ? (await cs.get?.(token))?.gate?.kind : undefined;
72
+ const decidePath = kind ? resumeEntryForGate(kind, { sessionId, taskId: activeTaskId }) : null;
73
+ if (kind && decidePath)
74
+ pendingGate = { kind, decidePath };
75
+ }
76
+ return {
77
+ ...base,
78
+ error: pendingGate
79
+ ? `session already has an active run — it is parked waiting for a decision (${status}); resolve it via POST ${pendingGate.decidePath}, or POST /v1/runs/{activeTaskId}/cancel abandons it`
80
+ : `session already has an active run — it is parked (${status}); POST /v1/runs/{activeTaskId}/cancel abandons it (its pending decision could not be located on this instance)`,
81
+ activeTaskStatus: status,
82
+ ...(pendingGate ? { pendingGate } : {}),
83
+ };
84
+ }
85
+ catch {
86
+ return base; // 材料装配的任何失败都退化为旧形状 —— 增强绝不成为新故障点
87
+ }
88
+ }
89
+ //# sourceMappingURL=active-run-conflict.js.map
@@ -14,7 +14,10 @@ export declare function bearerPresentedButUnverified(req: IncomingMessage, confi
14
14
  export declare function gatedPrincipal(req: IncomingMessage, config: ServiceConfig): string | undefined;
15
15
  /** The EXPLICIT operator gate (IMAGE-API-DESIGN.md §P2.4a MUST-FIX) — NOT the bare `isOperator` (whose empty-list
16
16
  * "true-for-all" is a world-writable RCE door the instant OPERATOR_PRINCIPALS is empty). Requires a non-empty
17
- * operator set AND a present principal IN it. The same form the preempt/resume endpoints already use. */
17
+ * operator set AND a present principal IN it. The same form the preempt/resume endpoints already use.
18
+ * 🔴 SINGLE mint point (E1): a byte-identical twin `explicitOperator` used to live below (image-visibility lane)
19
+ * and two more hand-inlined copies sat in approvals-assistant — a future hardening of this judgment would have
20
+ * missed them. All call sites now consume THIS function; do not re-introduce a sibling. */
18
21
  export declare function explicitOperatorOk(principal: string | undefined, operatorPrincipals: readonly string[]): boolean;
19
22
  /** May `principal` act as an OPERATOR on the F4 approval queue? Empty `operatorPrincipals` = legacy
20
23
  * behavior (the shared service token IS the operator boundary → everyone authenticated is an operator).
@@ -24,6 +27,7 @@ export declare function isOperator(principal: string | undefined, operatorPrinci
24
27
  * yields `false` (no one is an operator). Use this where empty-operators-means-all would be a SECURITY hole rather
25
28
  * than a back-compat convenience: image VISIBILITY scoping (a tenant-scoped image must not leak to every caller
26
29
  * just because OPERATOR_PRINCIPALS is unset). Adversarial-review HIGH: `isOperator([], p)=true` made
27
- * `latestPublished(..., {operator:true})` bypass tenant visibility for any caller on an operator-less deployment. */
28
- export declare function explicitOperator(principal: string | undefined, operatorPrincipals: readonly string[]): boolean;
30
+ * `latestPublished(..., {operator:true})` bypass tenant visibility for any caller on an operator-less deployment.
31
+ * (The byte-identical `explicitOperator` twin that used to live here was folded into `explicitOperatorOk` above —
32
+ * one judgment, one mint point.) */
29
33
  //# sourceMappingURL=principal-gate.d.ts.map
@@ -61,7 +61,10 @@ export function gatedPrincipal(req, config) {
61
61
  }
62
62
  /** The EXPLICIT operator gate (IMAGE-API-DESIGN.md §P2.4a MUST-FIX) — NOT the bare `isOperator` (whose empty-list
63
63
  * "true-for-all" is a world-writable RCE door the instant OPERATOR_PRINCIPALS is empty). Requires a non-empty
64
- * operator set AND a present principal IN it. The same form the preempt/resume endpoints already use. */
64
+ * operator set AND a present principal IN it. The same form the preempt/resume endpoints already use.
65
+ * 🔴 SINGLE mint point (E1): a byte-identical twin `explicitOperator` used to live below (image-visibility lane)
66
+ * and two more hand-inlined copies sat in approvals-assistant — a future hardening of this judgment would have
67
+ * missed them. All call sites now consume THIS function; do not re-introduce a sibling. */
65
68
  export function explicitOperatorOk(principal, operatorPrincipals) {
66
69
  return operatorPrincipals.length > 0 && principal !== undefined && operatorPrincipals.includes(principal);
67
70
  }
@@ -77,8 +80,7 @@ export function isOperator(principal, operatorPrincipals) {
77
80
  * yields `false` (no one is an operator). Use this where empty-operators-means-all would be a SECURITY hole rather
78
81
  * than a back-compat convenience: image VISIBILITY scoping (a tenant-scoped image must not leak to every caller
79
82
  * just because OPERATOR_PRINCIPALS is unset). Adversarial-review HIGH: `isOperator([], p)=true` made
80
- * `latestPublished(..., {operator:true})` bypass tenant visibility for any caller on an operator-less deployment. */
81
- export function explicitOperator(principal, operatorPrincipals) {
82
- return operatorPrincipals.length > 0 && principal !== undefined && operatorPrincipals.includes(principal);
83
- }
83
+ * `latestPublished(..., {operator:true})` bypass tenant visibility for any caller on an operator-less deployment.
84
+ * (The byte-identical `explicitOperator` twin that used to live here was folded into `explicitOperatorOk` above —
85
+ * one judgment, one mint point.) */
84
86
  //# sourceMappingURL=principal-gate.js.map