@sema-agent/server 7.111.0 → 7.112.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/MIGRATION.md CHANGED
@@ -7,6 +7,14 @@
7
7
  > ⚠️ 完整清单在仓库根 `CHANGELOG.md`——它**不随 npm tarball 出包**(本文件随包)。看完整迁移窗的
8
8
  > 权威姿势是源码 tag diff:`git diff v<旧>..v<新>`(每版都推 `v<版本>` tag);npm 包页也镜像 CHANGELOG。
9
9
 
10
+ ## 7.112.0(车UG,S-924)—— `settings.hooks` 里按工具别名写的 matcher 提交当场 400(此前静默不命中)
11
+
12
+ - **🔴 settings 面硬破坏(响亮拒,无迁移代码、无兼容形)**。① *谁受影响*:提交体 `settings.hooks` 里 PreToolUse / PostToolUse / PostToolUseFailure 的 `matcher`
13
+ 是**引擎工具别名**的(今天只有 `ListPeers` → `ListAgents`;别名表读 core 静态工具目录)—— 它们**早就不命中**(post 两事件自 7.107.0 / core 7.36.0,PreToolUse 一直),只是没人告诉写的人。
14
+ ② *怎么改*:把 matcher 改成**正名**(错误句直接给出);`正名|别名` 的滚动期写法不受影响(命中正名)。③ *不改会怎样*:该**新提交** `400 request.field_invalid`(错误句点名别名与正名)——
15
+ 不再是「接受、hook 永不跑」。耐久 resume 重放**不判**(会砖 parked 任务):升级前就 parked 的任务,其存量 body 里的别名 matcher 在续跑腿照旧永不命中、同一份 hooks 里其余钩子照常生效(与 7.111.0 逐字同)。
16
+ 嵌入方经 `resolveSpec` 席挂的工具别名在受理面看不见,不在本门射程。
17
+
10
18
  ## 7.111.0(车TA,S-961 / S-972)—— `wiring_manifest` 帧 +`instructions` 段(additive,零动作)
11
19
 
12
20
  - **wire(additive,零动作)**:`wiring_manifest` 帧(四条腿:流式 SSE / 账本行 / 非流式 `wiringManifest` 回执 / 续跑腿)多一段本服务自铸的 `instructions: { selected: string[] }` = 这条腿选中的项目指令文件(相对 cwd 的链成员);`[]` = 没有,缺席 = 无陈述(契约附录 G.21)。按封闭 schema 校验该帧的消费方加这一可选键;不要它的部署 `PROJECT_MEMORY_ENABLED=false`(连同项目记忆注入一起关)。`task.user_steer_undrained{cause:"crashed"}` 的 `message` 改句序(行动句在前、< 400 字符),`detail` 不变 —— 按文本匹配的脚本改读 `detail.cause`(S-972)。
@@ -35,6 +35,11 @@ export interface ParseHooksConfigResult {
35
35
  /** 人可读的第一条校验错误(HTTP 层用它 400 fail-loud;resume 防御路径用它 warn)。 */
36
36
  error?: string;
37
37
  }
38
+ /**
39
+ * fresh 提交面专用判据(S-924):配置里第一条「永远打不中」的工具事件 matcher 的拒句;没有 ⇒ undefined。
40
+ * 入参是 {@link parseHooksConfig} 已校验过的配置。**只有 `admission.ts` 的 fresh 提交门调它** —— resume 重放不调(理由见上面的头注)。
41
+ */
42
+ export declare function deadToolMatcherIssue(config: HooksConfig): string | undefined;
38
43
  /**
39
44
  * 把 wire 上的 untrusted `settings.hooks` 校验成契约 {@link HooksConfig} + 服务侧上限。
40
45
  * 错误返回 error 字符串而不 throw——submit 路径 400 fail-loud、resume 防御路径 warn-drop,由调用方选。
@@ -1,6 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { hostShell, resolveHostShell, adoptHostForegroundChild } from "../plugins/host-platform.js";
3
- import { HooksConfig, DEFAULT_HOOK_TIMEOUT_SECONDS, HOOK_EVENT_OWNER, } from "@sema-agent/settings-schema/hooks";
3
+ import { HooksConfig, DEFAULT_HOOK_TIMEOUT_SECONDS, HOOK_EVENT_OWNER, HOOK_EVENT_MATCHER_FIELD, } from "@sema-agent/settings-schema/hooks";
4
+ import { describeToolCatalog } from "@sema-agent/core";
4
5
  import { boundedNoticeText } from "../fleet/fleet-bus.js";
5
6
  import { recordFailOpen } from "../observability/fail-open.js";
6
7
  import { redactSecrets, buildDisplaySafeUrl } from "../trace/redact.js";
@@ -56,6 +57,49 @@ function toAssistantTurnError(errorKind) {
56
57
  return "unknown";
57
58
  return Object.prototype.hasOwnProperty.call(BRAIN_TO_TURN_ERROR, errorKind) ? (BRAIN_TO_TURN_ERROR[errorKind] ?? "unknown") : "unknown";
58
59
  }
60
+ const NAME_ALTERNATION = /^[A-Za-z0-9_-]+(?:\|[A-Za-z0-9_-]+)*$/;
61
+ function matcherReadsWireToolName(event) {
62
+ return HOOK_EVENT_OWNER[event] === "engine" && HOOK_EVENT_MATCHER_FIELD[event] === "tool_name";
63
+ }
64
+ let engineToolAliasTable;
65
+ function engineToolAliases() {
66
+ if (engineToolAliasTable !== undefined)
67
+ return engineToolAliasTable;
68
+ const entries = describeToolCatalog();
69
+ const wireNames = new Set(entries.map((e) => e.name));
70
+ const table = new Map();
71
+ for (const e of entries) {
72
+ for (const alias of e.face.aliases ?? []) {
73
+ if (!wireNames.has(alias) && !table.has(alias))
74
+ table.set(alias, e.name);
75
+ }
76
+ }
77
+ engineToolAliasTable = table;
78
+ return table;
79
+ }
80
+ function deadAliasInMatcher(event, matcher) {
81
+ if (matcher === undefined || !matcherReadsWireToolName(event) || !NAME_ALTERNATION.test(matcher))
82
+ return undefined;
83
+ const branches = matcher.split("|");
84
+ const aliases = engineToolAliases();
85
+ for (const branch of branches) {
86
+ const canonical = aliases.get(branch);
87
+ if (canonical !== undefined && !branches.includes(canonical))
88
+ return { alias: branch, canonical };
89
+ }
90
+ return undefined;
91
+ }
92
+ export function deadToolMatcherIssue(config) {
93
+ for (const [event, groups] of Object.entries(config)) {
94
+ for (const group of groups ?? []) {
95
+ const dead = deadAliasInMatcher(event, group.matcher);
96
+ if (dead) {
97
+ return `settings.hooks.${event}: matcher "${dead.alias}" is an alias; tool-event matchers match the tool's wire name (PostToolUse / PostToolUseFailure since 7.107.0) — use "${dead.canonical}"`;
98
+ }
99
+ }
100
+ }
101
+ return undefined;
102
+ }
59
103
  export function parseHooksConfig(raw) {
60
104
  const parsed = HooksConfig.safeParse(raw);
61
105
  if (!parsed.success) {
@@ -7,7 +7,7 @@ import { taskBodyKeyIssue } from "../request-key-closure.js";
7
7
  import { FILE_HISTORY_DISABLED_NO_RESTORE, fileHistoryRestoreConflict, inheritEnvFieldIssue, singleMemberOffFieldIssue } from "../request-value-closure.js";
8
8
  import { selfOrchestrationDenial } from "../task-workflow.js";
9
9
  import { centerEntitlementSourceWired } from "../runtime-caps-resolver.js";
10
- import { parseHooksConfig } from "../hooks/hook-runner.js";
10
+ import { deadToolMatcherIssue, parseHooksConfig } from "../hooks/hook-runner.js";
11
11
  import { judgeRequestWebSearch } from "../plugins/web-search.js";
12
12
  import { normalizeApproachNotice, unknownTaskAgentModelRef, validateTaskAgents } from "../spec-fields.js";
13
13
  import { isValidCwd, MAX_ADDITIONAL_DIRS, MAX_CWD_CHARS } from "../task-cwd.js";
@@ -200,6 +200,11 @@ export function createAdmission(ctx) {
200
200
  sendError(res, 400, "request.field_invalid", hooksParsed.error ?? "settings.hooks: invalid shape");
201
201
  return null;
202
202
  }
203
+ const deadMatcher = deadToolMatcherIssue(hooksParsed.config);
204
+ if (deadMatcher !== undefined) {
205
+ sendError(res, 400, "request.field_invalid", deadMatcher);
206
+ return null;
207
+ }
203
208
  }
204
209
  {
205
210
  const webSearchVerdict = judgeRequestWebSearch(st.webSearch);
@@ -7,8 +7,8 @@
7
7
  * 字节不变)。同居是第三条,也是唯一一条既保住静态序又保住 move-only 的。序由 `const` 的 TDZ 亲自执法:
8
8
  * 腿是提升的函数声明、ctx 是文件尾的 `const`,谁也不会在工厂执行期读到对方。
9
9
  *
10
- * ctx 字段 28 只(registry 7 + helpers 14 + deps/runnerFor/prepareSpec/finalizeTaskResult + 三只配额/租约/用量窗
11
- * 判据)—— 这是**如实披露**的耦合读数(设计稿 §9:把它变小是波 3 的事,本刀不承诺)。型一律从既有型派生
10
+ * ctx 字段 27 只(registry 6 + helpers 14 + deps/runnerFor/prepareSpec/finalizeTaskResult + 三只配额/租约/用量窗
11
+ * 判据;S-947 删 `cancelledViaVerb` 旁表后 28 → 27,与 `test/split-ctx-parity.test.ts` 的读数同)—— 这是**如实披露**的耦合读数(设计稿 §9:把它变小是波 3 的事,本刀不承诺)。型一律从既有型派生
12
12
  * (`RunRegistry` / `RouteHelpers` / `RouteLegs` / `ReturnType<typeof createRequestGuards>`),不手抄签名 ⇒ 不会漂。
13
13
  */
14
14
  import type { IncomingMessage } from "node:http";
@@ -17,7 +17,7 @@ import type { DecideBinding } from "./wire-types.js";
17
17
  import type { CheckpointStoreFull } from "../plugins/store-backend.js";
18
18
  import { type HostDecision } from "../host-decision.js";
19
19
  import { type PlanApprovePermissionModeAfter } from "../task-settings.js";
20
- import type { RouteCtxBase, DriveResumeArgs, PreparedPlanReview } from "./route-ctx.js";
20
+ import type { RouteCtxBase, DriveResumeArgs, PreparedPlanReview, ResumeLegOutcome } from "./route-ctx.js";
21
21
  import { type TrustedRunInternals } from "./durable-run-leg.js";
22
22
  import type { RunRegistry, RouteHelpers, RouteLegs } from "./route-ctx.js";
23
23
  import type { createRequestGuards } from "./request-guards.js";
@@ -33,21 +33,12 @@ export declare function createResumeLegs(ctx: {
33
33
  }) => Promise<void>, acceptEarly?: boolean, decider?: {
34
34
  principal?: string;
35
35
  explicitOperator: boolean;
36
- }) => Promise<{
37
- status: number;
38
- body: object;
39
- }>;
40
- driveResumeIntoRunLog: (args: DriveResumeArgs) => Promise<{
41
- status: number;
42
- body: object;
43
- }>;
36
+ }) => Promise<ResumeLegOutcome>;
37
+ driveResumeIntoRunLog: (args: DriveResumeArgs) => Promise<ResumeLegOutcome>;
44
38
  driveResumeLeg: (args: DriveResumeArgs, onAccepted?: (receipt: {
45
39
  status: number;
46
40
  body: object;
47
- }) => void) => Promise<{
48
- status: number;
49
- body: object;
50
- }>;
41
+ }) => void) => Promise<ResumeLegOutcome>;
51
42
  mayDiscloseRowError: (cs: CheckpointStoreFull, token: CheckpointToken, decider: {
52
43
  principal?: string;
53
44
  explicitOperator: boolean;
@@ -8,7 +8,7 @@ const sessionFaceUnavailable = async (sessionId) => {
8
8
  import { HttpError, decodeCheckpointScope, CHECKPOINT_PUBLIC_SCOPE } from "../security.js";
9
9
  import { evictIfConflict, parkToolCallId, TurnAnchorCapture, HEARTBEAT_MS, markChildrenStoppedByUserOnAbort } from "../runs.js";
10
10
  import { failureCodeOf, nestedPauseOf, sealResultForExit, outwardPlane } from "../terminal.js";
11
- import { mintCancelledResult } from "../run-cancel-context.js";
11
+ import { buildCancelledLedgerRow, createRunAbort, mintCancelledResult } from "../run-cancel-context.js";
12
12
  import {} from "../host-decision.js";
13
13
  import { readTurnActivityMs, recordTurnActivity } from "../turn-activity.js";
14
14
  import { recordResumeResourceWindow } from "../resource-window.js";
@@ -75,7 +75,7 @@ function notResumableAnswer() {
75
75
  return { status: 409, body: { error: "run is not in a resumable (suspended) state (already resumed, decided, or expired)", errorCode: "conflict.not_resumable" } };
76
76
  }
77
77
  export function createResumeLegs(ctx) {
78
- const { deps, runnerFor, idemCache, inflightRuns, preemptableRuns, cancelledViaVerb, steerableRuns, wakeParkMints, counters, readJson, readRawBody, rateLimited, quotaExceeded, ownerQuotaDenied, leaseDenied, ownerLeaseDenied, usageWindowDenied, safeDecode, isFleetWide, runOwnerOk, sessionOwnerScope, sessionOwnerScopeForWrite, runSessionAcceptOk, prepareSpec, finalizeTaskResult, ownerCostQuotaDenied, ownerFleetLeaseDenied, usageWindowDenialFor } = ctx;
78
+ const { deps, runnerFor, idemCache, inflightRuns, preemptableRuns, steerableRuns, wakeParkMints, counters, readJson, readRawBody, rateLimited, quotaExceeded, ownerQuotaDenied, leaseDenied, ownerLeaseDenied, usageWindowDenied, safeDecode, isFleetWide, runOwnerOk, sessionOwnerScope, sessionOwnerScopeForWrite, runSessionAcceptOk, prepareSpec, finalizeTaskResult, ownerCostQuotaDenied, ownerFleetLeaseDenied, usageWindowDenialFor } = ctx;
79
79
  const resumeFleetScope = (req, auth) => req ? (gatedPrincipal(req, deps.config) ?? "default") : (auth.principal ?? "default");
80
80
  async function resumeCheckpoint(sessionId, decision, reason, hostDecision, req, answer, binding, onResumeCommitted, acceptEarly, decider) {
81
81
  const cs = deps.checkpointStore;
@@ -225,7 +225,7 @@ export function createResumeLegs(ctx) {
225
225
  const noteLateSettleIfAccepted = (out) => {
226
226
  if (lateSettleSink === undefined || lateSettleAnchor === undefined || out.status !== 200)
227
227
  return;
228
- if (out.body?.errorCode === "cancelled")
228
+ if (out.body?.errorCode === "cancelled" && out.decisionCommitted !== true)
229
229
  return;
230
230
  try {
231
231
  lateSettleSink.recordLateSettle({ ...lateSettleAnchor, outcome: decision === "approve" ? "allowed" : "denied", decidedBy: hostDecision.decidedBy, ...(hostDecision.approver !== undefined ? { approver: hostDecision.approver } : {}), settledAtMs: Date.now() });
@@ -472,7 +472,8 @@ export function createResumeLegs(ctx) {
472
472
  let resumeLegLive = false;
473
473
  const subagentHandleEvictions = [];
474
474
  let claimedRow = false;
475
- const cancelCtrl = new AbortController();
475
+ const cancelCtrl = createRunAbort();
476
+ let cancelLedgerAppend;
476
477
  const preemptCtrl = new AbortController();
477
478
  if (taskId && deps.runStore) {
478
479
  claimedRow = freshRunClaimed ? true : await deps.runStore.markResuming(taskId, deps.instanceId ?? "default");
@@ -488,7 +489,7 @@ export function createResumeLegs(ctx) {
488
489
  void rs.heartbeat(taskId, owner).catch(() => undefined);
489
490
  if (!cancelCtrl.signal.aborted) {
490
491
  void rs.isCancelRequested(taskId, owner).then((c) => { if (c)
491
- cancelCtrl.abort(); }).catch(() => undefined);
492
+ cancelCtrl.abort("cancel_verb"); }).catch(() => undefined);
492
493
  }
493
494
  if (!preemptCtrl.signal.aborted) {
494
495
  void rs.isPreemptRequested(taskId, owner).then((p) => { if (p)
@@ -531,6 +532,7 @@ export function createResumeLegs(ctx) {
531
532
  instructionsSegment: instructionLeg.segment,
532
533
  });
533
534
  const vAppend = (type, data) => verifySink.append(type, data);
535
+ cancelLedgerAppend = vAppend;
534
536
  const verifyTailContentMode = resumeTaskConfig.forwardSubagentEvents === true ? "on" : "progress_only";
535
537
  const verifyInternals = {
536
538
  ...(taskId ? { onActivity: () => recordTurnActivity(taskId) } : {}),
@@ -579,7 +581,9 @@ export function createResumeLegs(ctx) {
579
581
  : {}),
580
582
  };
581
583
  resumeLegLive = true;
582
- const vr = await resumeWithVerification(legRunner, token, outcome, { ...resumeTaskConfig, signal: cancelCtrl.signal, preemptSignal: preemptCtrl.signal }, resumeObjective, verifyRounds, verifyInternals);
584
+ const vrEngine = await resumeWithVerification(legRunner, token, outcome, { ...resumeTaskConfig, signal: cancelCtrl.signal, preemptSignal: preemptCtrl.signal }, resumeObjective, verifyRounds, verifyInternals);
585
+ const vrCause = cancelCtrl.cause();
586
+ const vr = vrCause !== undefined && taskId !== undefined ? mintCancelledResult({ taskId, sessionId, startedAtMs: resumeLegStartedAt, message: "cancelled by user", engineResult: vrEngine, cause: vrCause }) : vrEngine;
583
587
  await commitResumeCtx();
584
588
  let safe = sealResultForExit(vr);
585
589
  const vrNested = nestedPauseOf(vr.terminal);
@@ -622,6 +626,7 @@ export function createResumeLegs(ctx) {
622
626
  const persistThinking = deps.config.traceThinking;
623
627
  const serialAppend = createSerialLedgerAppend((s, type, data) => rs.appendEvent(taskId, s, type, data), startSeq);
624
628
  const append = (type, data) => (rs && taskId ? serialAppend(type, data) : Promise.resolve());
629
+ cancelLedgerAppend = append;
625
630
  unregisterEngineNotice = registerEngineNoticeLeg({
626
631
  sessionId,
627
632
  ...(args.engineNotices !== undefined && args.engineNotices.length > 0 ? { pending: [...args.engineNotices] } : {}),
@@ -715,7 +720,10 @@ export function createResumeLegs(ctx) {
715
720
  if (!taskId || !rs)
716
721
  return sealResultForExit(await stream.result());
717
722
  onAccepted?.(acceptedResumingReceipt(taskId, sessionId));
723
+ let causeAtDone;
718
724
  for await (const ev of stream) {
725
+ if (ev.type === "done")
726
+ causeAtDone = cancelCtrl.cause();
719
727
  recordTurnActivity(taskId);
720
728
  fleetPub?.onEvent(ev);
721
729
  await appendPromptManifest(append, deps.promptManifests, taskId);
@@ -823,7 +831,8 @@ export function createResumeLegs(ctx) {
823
831
  }
824
832
  }
825
833
  await flush();
826
- const r = await stream.result();
834
+ const rEngine = await stream.result();
835
+ const r = causeAtDone !== undefined ? mintCancelledResult({ taskId, sessionId, startedAtMs: resumeLegStartedAt, message: "cancelled by user", engineResult: rEngine, cause: causeAtDone }) : rEngine;
827
836
  const rPark = r.terminal.kind === "paused" ? outwardPlane(r.terminal).status : undefined;
828
837
  if (rPark === "suspended") {
829
838
  const parkCallId = await parkToolCallId(r, deps.checkpointStore);
@@ -898,7 +907,11 @@ export function createResumeLegs(ctx) {
898
907
  }
899
908
  }
900
909
  if (claimedRow && taskId && deps.runStore) {
901
- const c = mintCancelledResult({ taskId, sessionId, startedAtMs: resumeLegStartedAt, message: e.message });
910
+ const handleCause = cancelCtrl.cause();
911
+ if (handleCause === undefined)
912
+ recordFailOpen("server.runs.abort-cause-missing", `task=${taskId} arm=resume-aborted`);
913
+ const c = mintCancelledResult({ taskId, sessionId, startedAtMs: resumeLegStartedAt, message: e.message, cause: handleCause ?? "cancel_verb" });
914
+ await cancelLedgerAppend?.("failed", buildCancelledLedgerRow(c)).catch((err) => recordFailOpen("server.runs.terminal-ledger-write-failed", `arm=resume-append-resume-aborted ${errorText(err)}`));
902
915
  await deps.runStore.setTerminal(taskId, "failed", c, e.message).catch(() => undefined);
903
916
  }
904
917
  settleFleet("failed");
@@ -953,9 +966,11 @@ export function createResumeLegs(ctx) {
953
966
  },
954
967
  };
955
968
  }
956
- if (cancelCtrl.signal.aborted) {
969
+ const thrownCause = cancelCtrl.cause();
970
+ if (thrownCause !== undefined) {
957
971
  if (taskId && deps.runStore) {
958
- const c = mintCancelledResult({ taskId, sessionId, startedAtMs: resumeLegStartedAt, message: "cancelled by user" });
972
+ const c = mintCancelledResult({ taskId, sessionId, startedAtMs: resumeLegStartedAt, message: "cancelled by user", cause: thrownCause });
973
+ await cancelLedgerAppend?.("failed", buildCancelledLedgerRow(c)).catch((err) => recordFailOpen("server.runs.terminal-ledger-write-failed", `arm=resume-append-thrown-cancel ${errorText(err)}`));
959
974
  await deps.runStore.setTerminal(taskId, "failed", c, "cancelled by user").catch(() => undefined);
960
975
  }
961
976
  settleFleet("failed");
@@ -1002,6 +1017,7 @@ export function createResumeLegs(ctx) {
1002
1017
  settleFleet(checkpointReopened ? "suspended" : resumePlane.status, fleetRunResiduals(sealed));
1003
1018
  return {
1004
1019
  status: 200,
1020
+ decisionCommitted: true,
1005
1021
  body: {
1006
1022
  taskId, sessionId, status: resumePlane.status,
1007
1023
  bindingEnforced: true,
@@ -1301,7 +1317,7 @@ export function createResumeLegs(ctx) {
1301
1317
  }
1302
1318
  const routeCtxBase = {
1303
1319
  deps,
1304
- registry: { idemCache, inflightRuns, preemptableRuns, cancelledViaVerb, steerableRuns, wakeParkMints, counters },
1320
+ registry: { idemCache, inflightRuns, preemptableRuns, steerableRuns, wakeParkMints, counters },
1305
1321
  helpers: { readJson, readRawBody, rateLimited, quotaExceeded, ownerQuotaDenied, leaseDenied, ownerLeaseDenied, usageWindowDenied, safeDecode, isFleetWide, runOwnerOk, sessionOwnerScope, sessionOwnerScopeForWrite, runSessionAcceptOk },
1306
1322
  local: { images: createImagesLocal(deps), sessions: createSessionsLocal(), sessionSync: createSessionSyncLocal(), capabilities: createCapabilitiesLocal() },
1307
1323
  legs: { prepareSpec, finalizeTaskResult, resumeCheckpoint, driveResumeIntoRunLog, resumePreempted, resumeWake, preparePlanReview, drivePlanReview },
@@ -26,12 +26,15 @@ import type { SessionsLocal } from "./routes/sessions.js";
26
26
  import type { SessionSyncLocal } from "./routes/session-sync.js";
27
27
  import type { PlanApprovePermissionModeAfter } from "../task-settings.js";
28
28
  import type { VerifyRoundsSpec } from "./verify-rounds.js";
29
- /** 跨域可变状态 = lens1 §C2 实测的那 8 条「运行期登记簿」,一个对象。 */
29
+ import type { RunAbort } from "../run-cancel-context.js";
30
+ /** 跨域可变状态 = lens1 §C2 实测的那 8 条「运行期登记簿」,一个对象。
31
+ * S-947:`cancelledViaVerb`(按 taskId 键控的「这次是取消动词」旁表)删除 —— 中止成因改挂在每条腿自己的
32
+ * {@link RunAbort} 句柄上(`inflightRuns` 的值),同寿同删,不再有一张与腿不同寿的旁表。 */
30
33
  export interface RunRegistry {
31
34
  idemCache: IdempotencyCache<SubmitOutcome>;
32
- inflightRuns: Map<string, AbortController>;
35
+ /** 本副本在飞的 durable 腿 → 它的中止句柄(取消动词的同副本快路在这里按 `cancel_verb` 中止它)。 */
36
+ inflightRuns: Map<string, RunAbort>;
33
37
  preemptableRuns: Map<string, AbortController>;
34
- cancelledViaVerb: Set<string>;
35
38
  steerableRuns: Map<string, TaskStream>;
36
39
  wakeParkMints: Map<string, Promise<void>>;
37
40
  /** 裸 number 的登记簿(`let` 拆不出闭包)—— 装进盒子才能跨模块自增/自减。 */
@@ -380,15 +383,9 @@ export interface RouteLegs {
380
383
  decider?: {
381
384
  principal?: string;
382
385
  explicitOperator: boolean;
383
- }): Promise<{
384
- status: number;
385
- body: object;
386
- }>;
386
+ }): Promise<ResumeLegOutcome>;
387
387
  /** 全 resume 家族的共同下半场(lease admission + 写 run log + fleet 发布)。 */
388
- driveResumeIntoRunLog(args: DriveResumeArgs): Promise<{
389
- status: number;
390
- body: object;
391
- }>;
388
+ driveResumeIntoRunLog(args: DriveResumeArgs): Promise<ResumeLegOutcome>;
392
389
  /** assistant 抢占腿的复位(gate-kind 守卫 + 行级属主复核 + 驱动)。 */
393
390
  resumePreempted(sessionId: string, req: IncomingMessage | undefined,
394
391
  /** [ref] 件 S-1:透传给 {@link DriveResumeArgs.acceptEarly}(200 受理语义)。HTTP `/resume` 腿传 `true`。 */
@@ -628,6 +625,26 @@ export type RouteDecl = RouteDispatchDecl | RouteGateDecl;
628
625
  * 两臂逐字节同形,指路价值不丢。钉=audit-fixes-http「[ref]」格(两臂全文比对);文案变更走
629
626
  * api-error-text-freeze 基线流程。
630
627
  */
628
+ /**
629
+ * resume 家族一条腿的结局:HTTP 应答(`status` / `body`)+ 一位**进程内**事实(不上 wire)。
630
+ *
631
+ * `decisionCommitted` —— **这条腿的终局是在引擎受理了这次决定之后才结出来的**([ref] X5,7.112.0):本腿走到了「引擎交回结果」那一步
632
+ * (`resumeStream` / `resumeWithVerification` 解析 = core 的全部 pre-CAS 门 + checkpoint resolve CAS 都过了 —— 受理点的判据属主
633
+ * = {@link DriveResumeArgs.acceptEarly} 顶注),之后才收场。只由 `driveResumeLeg` 走完引擎那一步的终局出口铸;`CheckpointError` 臂、
634
+ * 「中止抛穿」臂、一切 4xx / 拒绝出口一律**不铸**(缺席 ⇒ 证不出提交,读者按「没提交」读 —— fail-closed)。
635
+ *
636
+ * 读者:`/decide` 的两道「这次决定提交了没有」门 —— [ref] 迟到结算留痕(`noteLateSettleIfAccepted`)与 remember 豁免落店
637
+ * (`routes/approvals-assistant.ts`)。它们原先只读 wire 体的 `errorCode !== "cancelled"`(「取消 = 决定没提交」),S-947 给续跑腿补了
638
+ * 「引擎受理之后才被取消」的取消终局之后,`cancelled` 不再等价「没提交」⇒ 两门改读本位:`200 ∧ (errorCode≠cancelled ∨ decisionCommitted)`。
639
+ * 🔴 为什么不放进 `result.cancelContext`(主会话初稿的落点):那只快照的契约是「分诊材料,不是状态机输入:任何门 / CAS / resume 判定都不许
640
+ * 读它」(`plugins/store-contracts.ts` `RunCancelContext` 顶注)—— 这两道门正是判定;而且门读的是 `/decide` 的平面体、看不到结果对象。
641
+ * 受理与否是腿自己的控制流事实,在腿上说,零 wire 变更。
642
+ */
643
+ export interface ResumeLegOutcome {
644
+ status: number;
645
+ body: object;
646
+ decisionCommitted?: true;
647
+ }
631
648
  /**
632
649
  * **「决定/续跑已受理」那一枚 200 收据的单一铸点**(S-218)。
633
650
  *
@@ -764,7 +764,7 @@ export async function handleApprovalsAssistant(req, res, match, ctx) {
764
764
  : undefined;
765
765
  const out = await resumeCheckpoint(sessionId, decision, body.reason ?? undefined, hostDecisionFacts(settledBy, deciderPrincipal), req, answer, binding, grantOnCommit, true, { principal: deciderPrincipal, explicitOperator });
766
766
  const outErrorCode = out.body.errorCode;
767
- if (pendingGrant !== undefined && out.status === 200 && outErrorCode !== "cancelled" && deps.approvalExemptionStore) {
767
+ if (pendingGrant !== undefined && out.status === 200 && (outErrorCode !== "cancelled" || out.decisionCommitted === true) && deps.approvalExemptionStore) {
768
768
  try {
769
769
  await deps.approvalExemptionStore.grant(pendingGrant.sessionId, pendingGrant.toolName, deciderPrincipal ?? null);
770
770
  rememberApplied = true;
@@ -148,7 +148,7 @@ async function acceptLiveUserInput(deps, res, at) {
148
148
  }
149
149
  export async function handleRuns(req, res, match, ctx) {
150
150
  const { deps } = ctx;
151
- const { idemCache, inflightRuns, preemptableRuns, cancelledViaVerb, steerableRuns, wakeParkMints } = ctx.registry;
151
+ const { idemCache, inflightRuns, preemptableRuns, steerableRuns, wakeParkMints } = ctx.registry;
152
152
  const { readJson, rateLimited, quotaExceeded, leaseDenied, usageWindowDenied, runOwnerOk, runSessionAcceptOk } = ctx.helpers;
153
153
  const { prepareSpec } = ctx.legs;
154
154
  const source = ctx.req.source;
@@ -319,7 +319,7 @@ export async function handleRuns(req, res, match, ctx) {
319
319
  }
320
320
  const err = note;
321
321
  try {
322
- const result = mintCancelledResult({ taskId, sessionId: run.sessionId, startedAtMs: Date.parse(run.createdAt), message: err });
322
+ const result = mintCancelledResult({ taskId, sessionId: run.sessionId, startedAtMs: Date.parse(run.createdAt), message: err, cause: "cancel_verb" });
323
323
  let lastErr;
324
324
  for (let attempt = 0;; attempt++) {
325
325
  try {
@@ -368,10 +368,7 @@ export async function handleRuns(req, res, match, ctx) {
368
368
  sendJson(res, 202, { taskId, status: now?.status ?? "failed", cancelRequested: now?.cancelRequested ?? false, note: "run already terminal — cancel is a no-op" });
369
369
  return;
370
370
  }
371
- if (inflightRuns.has(taskId)) {
372
- cancelledViaVerb.add(taskId);
373
- inflightRuns.get(taskId).abort();
374
- }
371
+ inflightRuns.get(taskId)?.abort("cancel_verb");
375
372
  sendJson(res, 202, { taskId, status: "cancelling", cancelRequested: true });
376
373
  }
377
374
  else if (isParkedRunStatus(run.status)) {
@@ -11,8 +11,9 @@
11
11
  * · {@link handleTasksBody} 内的 `runStreamSubmitLeg` —— plain-stream 腿(原 703 行匿名体);
12
12
  * · `runSyncSubmitLeg` —— 同步腿(原 128 行匿名体);
13
13
  * · 同步腿内 `verifyLeg` / `cascadeLeg` / `plainLeg` —— 三条模型驱动腿,判别顺序与短路语义逐字不变。
14
- * 提名**只改名字不改行为**:闭包捕获的自由变量、语句顺序、`cancelledViaVerb` 的 first-writer-wins 取值时点
15
- * ([1.207 codex H2]:在 `inflightRuns` 身份守卫内先取后删)、settle/registry 生命周期注释全部随代码原样迁移。
14
+ * 提名**只改名字不改行为**:闭包捕获的自由变量、语句顺序、settle/registry 生命周期注释全部随代码原样迁移。
15
+ * (S-947 起「这次是不是被 server 中止、为什么」读每腿自己的中止句柄 `RunAbort`,不再有按 taskId 键控的
16
+ * `cancelledViaVerb` 旁表与它「身份守卫内先取后删」的取值时点。)
16
17
  *
17
18
  * 与 runs 域共用 `ctx.legs.prepareSpec`(读体/校验/建 spec 的同一条前段)。
18
19
  */
@@ -10,7 +10,7 @@ import { emitPendingWorkflowCompletions, taskNotificationInboxEntry, taskNotific
10
10
  import { createLedgerSink } from "../../trace/ledger-sink.js";
11
11
  import { registerEngineNoticeLeg } from "../../trace/engine-notice-wire.js";
12
12
  import { clearTurnActivity, readTurnActivityMs, recordTurnActivity } from "../../turn-activity.js";
13
- import { mintCancelledResult } from "../../run-cancel-context.js";
13
+ import { buildCancelledLedgerRow, createRunAbort, mintCancelledResult } from "../../run-cancel-context.js";
14
14
  import { recordResourceWindow } from "../../resource-window.js";
15
15
  import { redactSecrets } from "../../trace/redact.js";
16
16
  import { contextUsageEventData, turnEndEventData, toolStartEventData, toolEndEventData, taskProgressEventData, taskNotificationEventData, compactedEventData, diagnosticsEventData, brainStatusEventData, steeringInjectedEventData, compactionOutcomeEventData, workspaceChangedEventData, wiringManifestFaceEventData, toolRosterDeltaEventData, toolProgressEventData, toolDisclosureEventData, humanInputEventData, appendModelUsageDelta, attachModelUsage, textEndEventData, liveIdentityFields } from "../../trace/project.js";
@@ -57,7 +57,7 @@ function sendSubmitResult(res, resp) {
57
57
  const MULTI_ATTEMPT_NO_EVENT_CHANNEL_NOTICE = "verify / cascade ran with their normal multi-attempt semantics, but the synchronous /v1/tasks endpoint has no event channel — no status/progress frames exist to replay on this shape. For status / progress / delegated-child frames and the terminal done, submit via POST /v1/runs and read GET /v1/runs/:id/events; note that verify / cascade legs carry no per-turn stream there either (no wiring_manifest / text / turn_end frames — the engine runs those legs as result-returning attempts).";
58
58
  export async function handleTasks(req, res, match, ctx) {
59
59
  const { deps } = ctx;
60
- const { idemCache, inflightRuns, cancelledViaVerb, steerableRuns, counters } = ctx.registry;
60
+ const { idemCache, inflightRuns, steerableRuns, counters } = ctx.registry;
61
61
  const { rateLimited, quotaExceeded, leaseDenied, usageWindowDenied } = ctx.helpers;
62
62
  const { prepareSpec, finalizeTaskResult } = ctx.legs;
63
63
  const reqState = ctx.req;
@@ -136,7 +136,7 @@ export async function handleTasks(req, res, match, ctx) {
136
136
  if (earlyDurableTid) {
137
137
  res.write(`event: meta\ndata: ${JSON.stringify({ type: "meta", taskId: earlyDurableTid, ...(prepared.spec.sessionId ? { sessionId: prepared.spec.sessionId } : {}) })}\n\n`);
138
138
  }
139
- const ac = new AbortController();
139
+ const ac = createRunAbort();
140
140
  let closed = false;
141
141
  let detachLogged = false;
142
142
  let disconnectParkGrace;
@@ -158,14 +158,14 @@ export async function handleTasks(req, res, match, ctx) {
158
158
  deps.logger?.info("stream_disconnect_ask_parked", { taskId: earlyDurableTid, sessionId: prepared.spec.sessionId, asks: parked, graceMs: DISCONNECT_PARK_GRACE_MS });
159
159
  disconnectParkGrace = setTimeout(() => {
160
160
  closed = true;
161
- ac.abort();
161
+ ac.abort("ask_park_grace_exhausted");
162
162
  }, DISCONNECT_PARK_GRACE_MS);
163
163
  disconnectParkGrace.unref?.();
164
164
  return;
165
165
  }
166
166
  }
167
167
  closed = true;
168
- ac.abort();
168
+ ac.abort("client_disconnected");
169
169
  };
170
170
  req.on("close", onDisconnect);
171
171
  res.on("close", () => {
@@ -213,13 +213,7 @@ export async function handleTasks(req, res, match, ctx) {
213
213
  let runRowSettled = false;
214
214
  let durableTaskId;
215
215
  let durableHeartbeat;
216
- const cancelledResult = (errorMessage, engineResult) => mintCancelledResult({
217
- taskId: durableTaskId ?? "",
218
- sessionId: prepared.spec.sessionId ?? "",
219
- startedAtMs: streamLegStartedAt,
220
- message: errorMessage,
221
- ...(engineResult !== undefined ? { engineResult } : {}),
222
- });
216
+ const cancelledResult = (errorMessage, cause) => mintCancelledResult({ taskId: durableTaskId ?? "", sessionId: prepared.spec.sessionId ?? "", startedAtMs: streamLegStartedAt, message: errorMessage, cause });
223
217
  let userMsgEntryId;
224
218
  let anchorPut = false;
225
219
  const putRewindAnchor = () => {
@@ -267,10 +261,7 @@ export async function handleTasks(req, res, match, ctx) {
267
261
  usageKey = tid;
268
262
  deps.modelUsage?.register(tid);
269
263
  const owner = principal ?? null;
270
- const abortFromVerb = () => {
271
- cancelledViaVerb.add(tid);
272
- ac.abort();
273
- };
264
+ const abortFromVerb = () => ac.abort("cancel_verb");
274
265
  durableHeartbeat = setInterval(() => {
275
266
  void rs.heartbeat(tid, owner).catch(() => undefined);
276
267
  if (!ac.signal.aborted) {
@@ -292,10 +283,8 @@ export async function handleTasks(req, res, match, ctx) {
292
283
  catch (e) {
293
284
  if (durableHeartbeat)
294
285
  clearInterval(durableHeartbeat);
295
- if (durableTaskId && inflightRuns.get(durableTaskId) === ac) {
286
+ if (durableTaskId && inflightRuns.get(durableTaskId) === ac)
296
287
  inflightRuns.delete(durableTaskId);
297
- cancelledViaVerb.delete(durableTaskId);
298
- }
299
288
  throw e;
300
289
  }
301
290
  let fleetSettled = false;
@@ -315,10 +304,8 @@ export async function handleTasks(req, res, match, ctx) {
315
304
  catch (e) {
316
305
  if (durableHeartbeat)
317
306
  clearInterval(durableHeartbeat);
318
- if (durableTaskId && inflightRuns.get(durableTaskId) === ac) {
307
+ if (durableTaskId && inflightRuns.get(durableTaskId) === ac)
319
308
  inflightRuns.delete(durableTaskId);
320
- cancelledViaVerb.delete(durableTaskId);
321
- }
322
309
  settleFleet("failed");
323
310
  throw e;
324
311
  }
@@ -430,13 +417,13 @@ export async function handleTasks(req, res, match, ctx) {
430
417
  sseData(res, end);
431
418
  if (ev.type === "message_committed" && ev.role === "user")
432
419
  userMsgEntryId = ev.entryId;
433
- const cancelVerbAtDone = ev.type === "done" && durableTaskId !== undefined && cancelledViaVerb.has(durableTaskId);
420
+ const causeAtDone = ev.type === "done" ? ac.cause() : undefined;
434
421
  if (ev.type === "done") {
435
422
  finalResult = await mintSubmitExitResult(ev.result, deps.checkpointStore);
436
423
  if (durableTaskId && finalResult)
437
424
  finalResult = { ...finalResult, taskId: durableTaskId };
438
- if (cancelVerbAtDone && finalResult !== undefined) {
439
- finalResult = cancelledResult("cancelled by user", finalResult);
425
+ if (causeAtDone !== undefined && finalResult !== undefined) {
426
+ finalResult = mintCancelledResult({ taskId: durableTaskId ?? "", sessionId: prepared.spec.sessionId ?? "", startedAtMs: streamLegStartedAt, message: "cancelled by user", cause: causeAtDone, engineResult: finalResult });
440
427
  }
441
428
  finalizeTaskResult(ev.result, principal, prepared.spec.objective, prepared.spec.sessionId);
442
429
  noteRunOutcome(finalResult);
@@ -672,6 +659,7 @@ export async function handleTasks(req, res, match, ctx) {
672
659
  else {
673
660
  putRewindAnchor();
674
661
  const pending = await deps.checkpointStore?.findPendingTokenBySession(prepared.spec.sessionId);
662
+ const dcCause = ac.cause();
675
663
  if (pending) {
676
664
  if (ledgerSink && !ledgerTerminal) {
677
665
  ledgerTerminal = true;
@@ -681,18 +669,28 @@ export async function handleTasks(req, res, match, ctx) {
681
669
  await deps.runStore.setSuspended(durableTaskId, null);
682
670
  settleFleet("suspended");
683
671
  }
684
- else {
672
+ else if (dcCause !== undefined) {
685
673
  const dcErr = "stream client disconnected before completion (run aborted)";
686
- const dc = cancelledResult(dcErr);
674
+ const dc = cancelledResult(dcErr, dcCause);
687
675
  if (ledgerSink && !ledgerTerminal) {
688
676
  ledgerTerminal = true;
689
677
  await ledgerSink.flush().catch(() => undefined);
690
- await ledgerSink.append("failed", { errorMessage: dcErr, errorCode: "cancelled" }).catch(() => undefined);
678
+ await ledgerSink.append("failed", buildCancelledLedgerRow(dc)).catch(() => undefined);
691
679
  }
692
680
  await deps.runStore.setTerminal(durableTaskId, "failed", dc, dcErr);
693
681
  noteRunOutcome(dc);
694
682
  settleFleet("failed");
695
683
  }
684
+ else {
685
+ const err = "stream ended before a terminal event";
686
+ if (ledgerSink && !ledgerTerminal) {
687
+ ledgerTerminal = true;
688
+ await ledgerSink.flush().catch(() => undefined);
689
+ await ledgerSink.append("failed", { errorMessage: err }).catch(() => undefined);
690
+ }
691
+ await deps.runStore.setTerminal(durableTaskId, "failed", null, err);
692
+ settleFleet("failed");
693
+ }
696
694
  }
697
695
  runRowSettled = true;
698
696
  }
@@ -706,10 +704,8 @@ export async function handleTasks(req, res, match, ctx) {
706
704
  steerableRuns.delete(durableTaskId);
707
705
  if (liveStreamRef !== undefined)
708
706
  await deps.userInputLedger?.settleLeg(liveStreamRef);
709
- if (durableTaskId && inflightRuns.get(durableTaskId) === ac) {
707
+ if (durableTaskId && inflightRuns.get(durableTaskId) === ac)
710
708
  inflightRuns.delete(durableTaskId);
711
- cancelledViaVerb.delete(durableTaskId);
712
- }
713
709
  for (const evict of subagentHandleEvictions)
714
710
  evict();
715
711
  if (!runRowSettled && durableTaskId && deps.runStore) {
@@ -717,6 +713,7 @@ export async function handleTasks(req, res, match, ctx) {
717
713
  const row = await deps.runStore.getRun(durableTaskId);
718
714
  if (row?.status === "running") {
719
715
  const pending = prepared.spec.sessionId ? await deps.checkpointStore?.findPendingTokenBySession(prepared.spec.sessionId) : undefined;
716
+ const backstopCause = ac.cause();
720
717
  if (pending) {
721
718
  if (ledgerSink && !ledgerTerminal) {
722
719
  ledgerTerminal = true;
@@ -735,13 +732,13 @@ export async function handleTasks(req, res, match, ctx) {
735
732
  await deps.runStore.setTerminal(durableTaskId, retryPlane.status, finalResult, retryPlane.errorMessage ?? null);
736
733
  settleFleet(retryPlane.status, fleetRunResiduals(finalResult));
737
734
  }
738
- else if (ac.signal.aborted) {
735
+ else if (backstopCause !== undefined) {
739
736
  const err = "stream aborted before completion (run cancelled)";
740
- const c = cancelledResult(err);
737
+ const c = cancelledResult(err, backstopCause);
741
738
  if (ledgerSink && !ledgerTerminal) {
742
739
  ledgerTerminal = true;
743
740
  await ledgerSink.flush().catch(() => undefined);
744
- await ledgerSink.append("failed", { errorMessage: err, errorCode: "cancelled" }).catch(() => undefined);
741
+ await ledgerSink.append("failed", buildCancelledLedgerRow(c)).catch(() => undefined);
745
742
  }
746
743
  await deps.runStore.setTerminal(durableTaskId, "failed", c, err);
747
744
  }
@@ -808,17 +805,14 @@ export async function handleTasks(req, res, match, ctx) {
808
805
  if (deps.checkpointStore && prepared.spec.sessionId) {
809
806
  await deps.checkpointStore.putCtx(prepared.spec.sessionId, { body: prepared.body, memoryScope: prepared.auth?.memoryScope });
810
807
  }
811
- const syncCancelCtrl = new AbortController();
808
+ const syncCancelCtrl = createRunAbort();
812
809
  if (prepared.spec.sessionId)
813
810
  markChildrenStoppedByUserOnAbort(syncCancelCtrl.signal, durableTaskId ?? prepared.spec.sessionId, prepared.auth?.principal);
814
811
  if (durableTaskId && deps.runStore) {
815
812
  const rs = deps.runStore;
816
813
  const tid = durableTaskId;
817
814
  const owner = principal ?? null;
818
- const abortFromVerb = () => {
819
- cancelledViaVerb.add(tid);
820
- syncCancelCtrl.abort();
821
- };
815
+ const abortFromVerb = () => syncCancelCtrl.abort("cancel_verb");
822
816
  durableHeartbeat = setInterval(() => {
823
817
  void rs.heartbeat(tid, owner).catch(() => undefined);
824
818
  if (!syncCancelCtrl.signal.aborted) {
@@ -841,7 +835,6 @@ export async function handleTasks(req, res, match, ctx) {
841
835
  wiringReceipt = wiringManifestFaceEventData(ev, wiringOperatorFace, instructionLeg.segment());
842
836
  };
843
837
  let result;
844
- let cancelVerbLabel = false;
845
838
  counters.uncountedBillableInflight++;
846
839
  try {
847
840
  const verifyLeg = (v) => runWithVerification(prepared.runner, specWithSignal, v);
@@ -858,18 +851,17 @@ export async function handleTasks(req, res, match, ctx) {
858
851
  counters.uncountedBillableInflight--;
859
852
  if (durableHeartbeat)
860
853
  clearInterval(durableHeartbeat);
861
- if (durableTaskId && inflightRuns.get(durableTaskId) === syncCancelCtrl) {
854
+ if (durableTaskId && inflightRuns.get(durableTaskId) === syncCancelCtrl)
862
855
  inflightRuns.delete(durableTaskId);
863
- cancelVerbLabel = cancelledViaVerb.has(durableTaskId);
864
- cancelledViaVerb.delete(durableTaskId);
865
- }
866
856
  }
867
- if (cancelVerbLabel) {
857
+ const syncAbortCause = syncCancelCtrl.cause();
858
+ if (syncAbortCause !== undefined) {
868
859
  result = mintCancelledResult({
869
860
  taskId: durableTaskId ?? "",
870
861
  sessionId: prepared.spec.sessionId ?? "",
871
862
  startedAtMs: syncLegStartedAt,
872
863
  message: "cancelled by user",
864
+ cause: syncAbortCause,
873
865
  engineResult: result,
874
866
  });
875
867
  }
@@ -43,7 +43,6 @@ export function createHttpServer(rawDeps) {
43
43
  }
44
44
  const idemCache = new IdempotencyCache();
45
45
  const inflightRuns = new Map();
46
- const cancelledViaVerb = new Set();
47
46
  const preemptableRuns = new Map();
48
47
  const steerableRuns = new Map();
49
48
  const wakeParkMints = new Map();
@@ -97,7 +96,7 @@ export function createHttpServer(rawDeps) {
97
96
  }
98
97
  const { quotaExceeded, ownerCostQuotaDenied, ownerFleetLeaseDenied, ownerLeaseDenied, ownerQuotaDenied, leaseDenied, usageWindowDenialFor, usageWindowDenied, rateLimited, safeDecode, isFleetWide, sessionOwnerScope, sessionOwnerScopeForWrite, runOwnerOk, runSessionAcceptOk, readJson, readRawBody } = createRequestGuards({ deps });
99
98
  const { prepareSpec } = createAdmission({ deps, readJson, runnerFor });
100
- const { routeCtxBase, resumeCheckpoint, driveResumeIntoRunLog, driveResumeLeg, mayDiscloseRowError, resumePreempted, startFreshHostTurn, resumeWake } = createResumeLegs({ deps, runnerFor, idemCache, inflightRuns, preemptableRuns, cancelledViaVerb, steerableRuns, wakeParkMints, counters, readJson, readRawBody, rateLimited, quotaExceeded, ownerQuotaDenied, leaseDenied, ownerLeaseDenied, usageWindowDenied, safeDecode, isFleetWide, runOwnerOk, sessionOwnerScope, sessionOwnerScopeForWrite, runSessionAcceptOk, prepareSpec, finalizeTaskResult, ownerCostQuotaDenied, ownerFleetLeaseDenied, usageWindowDenialFor });
99
+ const { routeCtxBase, resumeCheckpoint, driveResumeIntoRunLog, driveResumeLeg, mayDiscloseRowError, resumePreempted, startFreshHostTurn, resumeWake } = createResumeLegs({ deps, runnerFor, idemCache, inflightRuns, preemptableRuns, steerableRuns, wakeParkMints, counters, readJson, readRawBody, rateLimited, quotaExceeded, ownerQuotaDenied, leaseDenied, ownerLeaseDenied, usageWindowDenied, safeDecode, isFleetWide, runOwnerOk, sessionOwnerScope, sessionOwnerScopeForWrite, runSessionAcceptOk, prepareSpec, finalizeTaskResult, ownerCostQuotaDenied, ownerFleetLeaseDenied, usageWindowDenialFor });
101
100
  const { denyExpiredApprovals, redeemParkedAsk } = createApprovalSweeps({ deps, resumeCheckpoint, mayDiscloseRowError });
102
101
  const { handle } = createDispatch({ deps, noteActivity, routeCtxBase, counters });
103
102
  const server = http.createServer((req, res) => {
@@ -281,9 +281,13 @@ export declare const FAIL_OPEN_TAGS: {
281
281
  readonly cls: "F";
282
282
  readonly note: "S-107:持久 error 脱后 UTF-8 字节 > 65,000(TiDB TEXT 65,535 字节)⇒ 脱后切并带标记(切在占位里不是泄漏);计数=多少条终态文案被字节封顶截断";
283
283
  };
284
+ readonly "server.runs.abort-cause-missing": {
285
+ readonly cls: "F";
286
+ readonly note: "S-947 候裁 ⑤(主会话裁:默认值保留,miss 臂必须响):续跑腿 `http/resume-legs.ts` 的 `checkpoint.resume_aborted` 臂 —— core 只在本腿递进去的信号**已中止**时铸该码,而那只信号唯一的写者是取消动词(快路 / 耐久旗轮询,句柄登记 `cancel_verb`),所以**只有 core 契约外的形**(桩 / 非 core Runner 直接抛该码,本腿句柄从未中止)才到得了「句柄上读不到成因」这一臂。取消终局照铸,成因取本腿唯一的中止词 `cancel_verb`(取消终局恒带成因由铸点签名保证,不让它缺);detail `task=<taskId>`;计数 = 多少次取消成因是默认值而不是句柄登记的。生产上恒零是证据,不归零 = 有一条不走 core 契约的 Runner 在跑续跑腿。";
287
+ };
284
288
  readonly "server.runs.terminal-ledger-write-failed": {
285
289
  readonly cls: "F";
286
- readonly note: "S-107(全量门 silent-degradation 棘轮抓出的五处静默 `catch {}`):run 终局链里**账本腿**(flush 缓冲 text/reasoning、append `failed` 行)与终态翻转(setTerminal)已不共命运——账本 INSERT 抛不再让 run 卡 running 到 reaper;但那条抛掉的账本行之前是**静默**吞的。detail `arm=` 四臂(flush-clean-cancel / append-clean-cancel / flush-catch / append-thrown-cancel / append-failed):缺的是 run_events 尾巴(缓冲文本或 failed 事件),run 行本身仍是 durable truth,读面/reaper 容忍缺失,故 F 类;计数=多少次终局账本写失败。";
290
+ readonly note: "S-107(全量门 silent-degradation 棘轮抓出的五处静默 `catch {}`):run 终局链里**账本腿**(flush 缓冲 text/reasoning、append `failed` 行)与终态翻转(setTerminal)已不共命运——账本 INSERT 抛不再让 run 卡 running 到 reaper;但那条抛掉的账本行之前是**静默**吞的。detail `arm=` 四臂(flush-clean-cancel / append-clean-cancel / flush-catch / append-thrown-cancel / append-failed);S-947 起续跑腿(`http/resume-legs.ts`)两座同 tag:resume-append-resume-aborted / resume-append-thrown-cancel(取消抛出两臂的 `failed` 行,修前那两臂根本不落账本终局行):缺的是 run_events 尾巴(缓冲文本或 failed 事件),run 行本身仍是 durable truth,读面/reaper 容忍缺失,故 F 类;计数=多少次终局账本写失败。";
287
291
  };
288
292
  readonly "server.redact.ppk-eat-to-end": {
289
293
  readonly cls: "F";
@@ -260,7 +260,8 @@ export const FAIL_OPEN_TAGS = {
260
260
  "server.run-terminal.artifact-capped": { cls: "F", note: "S-107:leader_run 行上的工件字节(salvaged[].patch / candidatePatch.patch / merge.candidatePatch / merge.rej)长度 > ARTIFACT_INPUT_CAP(256KB)⇒ 整份换 «redacted:oversized-artifact»(fail-closed 取证丢弃;原字节仍在 worker 分支/merge 咽喉);计数=多少份工件被整份丢弃" },
261
261
  "server.run-terminal.diag-capped": { cls: "F", note: "S-107:诊断文本座(merge details/measure/grader trace、leader 日志座、planner 样本、journal 预览、workflow_run error cut)长度 > DIAG_INPUT_CAP(256KB)⇒ 整段换 «redacted:oversized-diagnostic» [len=N](fail-closed 取证丢弃;固定窗不 fail-closed 故不开窗);计数=多少段诊断文本被整段丢弃" },
262
262
  "server.run-terminal.output-truncated": { cls: "F", note: "S-107:持久 error 脱后 UTF-8 字节 > 65,000(TiDB TEXT 65,535 字节)⇒ 脱后切并带标记(切在占位里不是泄漏);计数=多少条终态文案被字节封顶截断" },
263
- "server.runs.terminal-ledger-write-failed": { cls: "F", note: "S-107(全量门 silent-degradation 棘轮抓出的五处静默 `catch {}`):run 终局链里**账本腿**(flush 缓冲 text/reasoning、append `failed` 行)与终态翻转(setTerminal)已不共命运——账本 INSERT 抛不再让 run 卡 running 到 reaper;但那条抛掉的账本行之前是**静默**吞的。detail `arm=` 四臂(flush-clean-cancel / append-clean-cancel / flush-catch / append-thrown-cancel / append-failed):缺的是 run_events 尾巴(缓冲文本或 failed 事件),run 行本身仍是 durable truth,读面/reaper 容忍缺失,故 F 类;计数=多少次终局账本写失败。" },
263
+ "server.runs.abort-cause-missing": { cls: "F", note: "S-947 候裁 ⑤(主会话裁:默认值保留,miss 臂必须响):续跑腿 `http/resume-legs.ts` 的 `checkpoint.resume_aborted` 臂 —— core 只在本腿递进去的信号**已中止**时铸该码,而那只信号唯一的写者是取消动词(快路 / 耐久旗轮询,句柄登记 `cancel_verb`),所以**只有 core 契约外的形**(桩 / 非 core Runner 直接抛该码,本腿句柄从未中止)才到得了「句柄上读不到成因」这一臂。取消终局照铸,成因取本腿唯一的中止词 `cancel_verb`(取消终局恒带成因由铸点签名保证,不让它缺);detail `task=<taskId>`;计数 = 多少次取消成因是默认值而不是句柄登记的。生产上恒零是证据,不归零 = 有一条不走 core 契约的 Runner 在跑续跑腿。" },
264
+ "server.runs.terminal-ledger-write-failed": { cls: "F", note: "S-107(全量门 silent-degradation 棘轮抓出的五处静默 `catch {}`):run 终局链里**账本腿**(flush 缓冲 text/reasoning、append `failed` 行)与终态翻转(setTerminal)已不共命运——账本 INSERT 抛不再让 run 卡 running 到 reaper;但那条抛掉的账本行之前是**静默**吞的。detail `arm=` 四臂(flush-clean-cancel / append-clean-cancel / flush-catch / append-thrown-cancel / append-failed);S-947 起续跑腿(`http/resume-legs.ts`)两座同 tag:resume-append-resume-aborted / resume-append-thrown-cancel(取消抛出两臂的 `failed` 行,修前那两臂根本不落账本终局行):缺的是 run_events 尾巴(缓冲文本或 failed 事件),run 行本身仍是 durable truth,读面/reaper 容忍缺失,故 F 类;计数=多少次终局账本写失败。" },
264
265
  "server.redact.ppk-eat-to-end": { cls: "F", note: "S-107 第三十二轮重扫:PuTTY .ppk 容器扫描器(redact.ts redactPpkContainers)在「无合格终止 / 收口后同容器范围再见私钥字段 / 收口后到下一版本头之间夹非空白」三形下从头**吃到输入末尾**(fail-closed 取证丢弃)——终态 errorMessage 座上=整条错误文案变成 «redacted:private-key»,与 redactions_applied_total{private-key} 的诚实替换同形不可分,故要有自己的计数/探针;影子扫不计;计数=多少次整段吃尾。" },
265
266
  "server.approval-card.closed-word-out-of-set": {
266
267
  cls: "F",
@@ -94,16 +94,49 @@ export interface RunCancelContext {
94
94
  elapsedMs?: number;
95
95
  }
96
96
  /**
97
- * 落库/上 wire 的终局结果 = core 的 `TaskResult` **加上** server 自铸的可缺席侧记键。
97
+ * 🔴 S-947(cli [ref] 第 3 件 / L-212)—— **server 为什么中止了这条 run**:取消终局的机读成因,闭集。
98
98
  *
99
- * 今天只有一个成员({@link RunCancelContext});additive 且可缺席,所以旧行、旧消费端逐字不受影响
100
- * (`TaskResult` 仍可原样赋给本型)。⚠️ 这不是给「往 core 结果里塞私货」开的口子:每加一个键都要能
101
- * 回答「为什么它属于 run 账本而不属于 core 的任务结果」——`cancelContext` 的答案是它描述的是
102
- * **server 侧的取消现场**,core 那一侧根本不知道有人按了取消。
99
+ * 病:非 detach 流断连、`POST /v1/runs/:id/cancel`、断连时有 ask 但 park 宽限用尽 —— 三件事在终局上**逐字同形**
100
+ * (`failed` + `terminal.code:"cancelled"`),壳只能按 `errorMessage` 文案猜。
101
+ *
102
+ * 每个词都必须有一个**真站点**(没有站点的词不许进表;普查与站点坐标见契约 §5.0d):
103
+ * · `client_disconnected` —— 非 detach 的 `POST /v1/tasks/stream` 客户端断连,断连那一刻本 run 没有流内未决 ask
104
+ * (`http/routes/tasks.ts` 的断连臂,立即中止);
105
+ * · `ask_park_grace_exhausted` —— 同一断连臂,但断连那一刻有流内未决 ask、已按窗到期同路转 park,宽限计时器用尽时 park 仍没落成;
106
+ * · `cancel_verb` —— `POST /v1/runs/:id/cancel`:同副本快路、跨副本耐久取消旗的轮询(四条腿的心跳 / 登记追平),
107
+ * 以及 park 中的 run 被它直接终局化(那一形不经 abort,成因照样是这条动词)。
108
+ * **刻意不在表里**(普查过、没有真站点):抢占(`preempt` 是优雅让出,终局是 `paused`,不是取消)、有序关停
109
+ * (S-929 只把未决卡转 park,从不中止活 run;进程退出后的行由收割器落「持有者死亡」判决,那不是本仓铸的取消终局)、
110
+ * `interrupt`(引擎的 turn 动词,终局 `completed` + `haltedByUser`)、detach 断连(不中止)。
111
+ *
112
+ * 词表属主 = 本文件(与它所在的 {@link PersistedTaskResult} 同住);**单写者** = `run-cancel-context.ts` 的 `createRunAbort`
113
+ * (中止那一刻登记)与 `mintCancelledResult`(终局铸点读它),自铸 wire 值登记 = `trace/self-minted-wire.ts` 的 `abortCause`
114
+ * (`satisfies Record<AbortCause, …>`:加 / 删一个词而登记没跟 ⇒ tsc 红)。
103
115
  */
104
- export type PersistedTaskResult = TaskResult & {
116
+ export declare const ABORT_CAUSES: readonly ["client_disconnected", "ask_park_grace_exhausted", "cancel_verb"];
117
+ export type AbortCause = (typeof ABORT_CAUSES)[number];
118
+ /**
119
+ * server 自铸、挂在终局结果上的**侧记键**全集(都描述「server 这一侧发生了什么」,core 那一侧无从知道)。
120
+ * 本型是这一族键的**唯一登记处**(`trace/core-keyset-guard.ts` 的辖域线指向这里;那边另有一道编译期钉:
121
+ * core 的 `TaskResult` 哪天长出同名键 ⇒ 当场红 —— 同一个键两个铸者是本型存在要防的那件事)。
122
+ * · `cancelContext`([ref])—— 取消现场快照(分诊材料:多久没动静、起跑多久);
123
+ * · `abortCause`(S-947)—— 这次取消是谁 / 什么发起的({@link AbortCause})。
124
+ * 两键**同一只铸点**(`mintCancelledResult`)、**同一条在场闸**(「只补空座」:终局就是被那次中止打断的那一条,
125
+ * 才挂;与中止赛跑跑完的终局、引擎自带码的失败一律原样立住、两键都不挂)。差别只在缺席纪律:快照一键都证不出 ⇒ 整只缺席;
126
+ * 成因在铸点那一刻恒已知 ⇒ 取消终局上恒在场。
127
+ */
128
+ export interface PersistedResultSideKeys {
105
129
  cancelContext?: RunCancelContext;
106
- };
130
+ abortCause?: AbortCause;
131
+ }
132
+ /**
133
+ * 落库/上 wire 的终局结果 = core 的 `TaskResult` **加上** server 自铸的可缺席侧记键({@link PersistedResultSideKeys})。
134
+ *
135
+ * additive 且可缺席,所以旧行、旧消费端逐字不受影响(`TaskResult` 仍可原样赋给本型)。⚠️ 这不是给「往 core 结果里塞私货」
136
+ * 开的口子:每加一个键都要能回答「为什么它属于 run 账本而不属于 core 的任务结果」—— 两键的答案都是它们描述的是
137
+ * **server 侧的取消现场**,core 那一侧根本不知道有人按了取消、谁按的。落点 = `result` JSON 列(三孪生整只存),**零新列**。
138
+ */
139
+ export type PersistedTaskResult = TaskResult & PersistedResultSideKeys;
107
140
  /**
108
141
  * 🔴 run 店「持有它的实例丢了」那一判的**行上落款**(S-928 codex r1-F1 提为常量):三只 run 店的 `reapStale` 把心跳过期的 running 行翻成
109
142
  * `failed` 并写下这一句。它是 run 店死亡判决留在行上的证据之一(另一只 = {@link RUN_ORPHANED_ERROR})—— 用户输入受理账本的收敛判词
@@ -1,5 +1,6 @@
1
1
  export class StoreBootRefusalError extends Error {
2
2
  }
3
+ export const ABORT_CAUSES = ["client_disconnected", "ask_park_grace_exhausted", "cancel_verb"];
3
4
  export const RUN_STALLED_ERROR = "run stalled (instance lost?)";
4
5
  export const RUN_ORPHANED_ERROR = "run orphaned by engine restart";
5
6
  export function buildRunOrphanedError(heldBy) {
@@ -1,4 +1,4 @@
1
- import type { PersistedTaskResult, RunCancelContext } from "./plugins/store-contracts.js";
1
+ import type { AbortCause, PersistedTaskResult, RunCancelContext } from "./plugins/store-contracts.js";
2
2
  /**
3
3
  * @param taskId 这条 run 的账本 id(turn 活性登记的键)。
4
4
  * @param startedAtMs 这条 run 起跑的墙钟时刻(epoch ms):取消腿手上有行就用行的 `createdAt`,
@@ -43,11 +43,23 @@ export declare const CANCELLED_CODE = "cancelled";
43
43
  * · `cancelContext`:已经有快照就不重铸(重铸会把「上一条引擎事件多久以前」重新计时)。
44
44
  *
45
45
  * ## 为什么不判「这条 failed 是不是 abort 形」
46
- * 判据在**调用点**:调用点问的是「本腿这次是不是被取消动词打中了」(`cancelCtrl.signal.aborted` /
47
- * `cancelledViaVerb`),那是 server 自己的事实。让本函数去嗅 `terminal.message` 的字面就是再造事实
46
+ * 判据在**调用点**:调用点问的是「本腿这次是不是被 server 中止了」—— 本腿 {@link RunAbort} 句柄上登记的成因
47
+ * (`cause()` 在场),那是 server 自己的事实。让本函数去嗅 `terminal.message` 的字面就是再造事实
48
48
  * —— [ref] 二那次事故的同一个形。
49
+ *
50
+ * ## S-947:成因(`abortCause`)与码、快照**同进同退**
51
+ * `cause` 是**必填**:本函数是 server 铸取消终局的唯一出口,而每一次取消都有一个发起者 ⇒ 「本仓铸的取消终局上恒有
52
+ * `abortCause`」由签名保证,不靠调用方记得。它走与补码、补快照**同一条**空座闸 —— 引擎给的终局不是无码的 failed
53
+ * (与中止赛跑跑完了 / 引擎自带码)⇒ 整只原样返回,成因也不挂(那条终局不是这次中止的结果,挂上就是「完成了却带着取消便签」)。
49
54
  */
50
- export declare function mintCancelledResult(args: {
55
+ export declare function mintCancelledResult<R extends PersistedTaskResult>(args: CancelMintArgs & {
56
+ engineResult: R;
57
+ }): R;
58
+ export declare function mintCancelledResult(args: CancelMintArgs & {
59
+ engineResult?: undefined;
60
+ }): PersistedTaskResult;
61
+ /** {@link mintCancelledResult} 的公共实参(`engineResult` 另列:在场与否决定返回型)。 */
62
+ interface CancelMintArgs {
51
63
  /** 这条 run 的账本 id(活性登记的键;账本不在场时给空串,快照按「证不出」处理)。 */
52
64
  taskId: string;
53
65
  /** 会话 id(合成形要写进结果;引擎给了结果时不用)。 */
@@ -56,7 +68,47 @@ export declare function mintCancelledResult(args: {
56
68
  startedAtMs: number;
57
69
  /** 合成形的失败文案(引擎给了终局时不用)。 */
58
70
  message: string;
59
- /** 引擎给出的终局(在场 ⇒ 在它之上补标)。 */
60
- engineResult?: PersistedTaskResult;
61
- }): PersistedTaskResult;
71
+ /** S-947:这次取消的发起者(本腿 {@link RunAbort} 句柄上登记的那一个;park 中直接终局化的取消动词给 `"cancel_verb"`)。 */
72
+ cause: AbortCause;
73
+ }
74
+ /**
75
+ * S-947 —— 没有 `done` 可落的取消形在账本上的那一行(`failed`):**从铸好的取消终局投影**,不手抄。
76
+ *
77
+ * 账本 `failed` 行是平面形(`errorMessage` / `errorCode` 顶层),所以成因也在顶层、与 `errorCode` 并列;有 `done` 可落的
78
+ * 那几形,成因随 `done.result` 走(结果本身带它)。两形同一个来源 = 同一只取消终局,不是两处各写一份。
79
+ * 返回纯数据 ⇒ `build*`。
80
+ */
81
+ export declare function buildCancelledLedgerRow(c: PersistedTaskResult): {
82
+ errorMessage?: string;
83
+ errorCode?: string;
84
+ abortCause?: AbortCause;
85
+ };
86
+ /**
87
+ * S-947 —— 一条 run 腿的**中止句柄**:server 中止这条腿的唯一入口,也是「为什么中止」的唯一登记处。
88
+ *
89
+ * ## 为什么是句柄、不是一张按 taskId 键控的表
90
+ * 修前这件事由 `cancelledViaVerb: Set<taskId>`(「这次是取消动词」一个词)+ 各腿手写的生命周期(登记 / finally 里按身份守卫删 /
91
+ * 同步腿「删之前先取」)承担:一个键控在 **taskId** 上的旁表,要防的病都来自键与腿不同寿 —— 快速续跑在旧腿 finally 之前
92
+ * 用同一 taskId 登记新腿、旧腿收尾删掉新腿的条目;同步腿必须在删之前取值。成因挂在**腿自己的中止控制器**上之后,
93
+ * 这些规则一条都不需要:句柄与腿同寿,换腿就是换句柄,没有可删、可串、可漏的旁表。
94
+ *
95
+ * ## 先到先得(first-writer-wins)
96
+ * 第一次 `abort(cause)` 生效并登记成因;之后的调用全是 no-op(信号早已中止,成因不改写)—— 断连之后又到了取消动词、
97
+ * 取消之后又断连,终局说的都是**真正让这条腿停下来**的那一个。`AbortController.abort()` 自身就是幂等的,本句柄只是在
98
+ * 「第一次」那一刻把词记下。
99
+ *
100
+ * ## 引擎看到的东西不变
101
+ * 中止走 `AbortController.abort()` 的默认原因(引擎、provider 的 fetch、子进程看到的仍是标准 `AbortError`)—— 成因**不**塞进
102
+ * `signal.reason`:那会改掉引擎与 provider 层收到的拒绝值,是在下游改上游接缝的输入(立宪禁区)。
103
+ */
104
+ export interface RunAbort {
105
+ /** 交给引擎的那只信号(`spec.signal` / 审批 / 提问 ALS 的 `abortSignal` / 子代 stoppedBy 归因都用它)。 */
106
+ readonly signal: AbortSignal;
107
+ /** 以 `cause` 中止本腿。已中止 ⇒ no-op(成因不改写)。 */
108
+ abort(cause: AbortCause): void;
109
+ /** 让本腿停下来的那次中止的成因;未中止 ⇒ `undefined`。 */
110
+ cause(): AbortCause | undefined;
111
+ }
112
+ export declare function createRunAbort(): RunAbort;
113
+ export {};
62
114
  //# sourceMappingURL=run-cancel-context.d.ts.map
@@ -23,6 +23,7 @@ export function mintCancelledResult(args) {
23
23
  ...engine,
24
24
  terminal: { ...engine.terminal, code: CANCELLED_CODE },
25
25
  ...(engine.cancelContext === undefined && ctx !== undefined ? { cancelContext: ctx } : {}),
26
+ abortCause: args.cause,
26
27
  };
27
28
  }
28
29
  return {
@@ -32,6 +33,31 @@ export function mintCancelledResult(args) {
32
33
  result: "",
33
34
  stats: { turns: 0, tokens: 0, usageMissing: true },
34
35
  ...(ctx ? { cancelContext: ctx } : {}),
36
+ abortCause: args.cause,
37
+ };
38
+ }
39
+ export function buildCancelledLedgerRow(c) {
40
+ const t = c.terminal;
41
+ return {
42
+ ...(t.kind === "failed" && t.message !== undefined ? { errorMessage: t.message } : {}),
43
+ ...(t.kind === "failed" && t.code !== undefined ? { errorCode: t.code } : {}),
44
+ ...(c.abortCause !== undefined ? { abortCause: c.abortCause } : {}),
45
+ };
46
+ }
47
+ export function createRunAbort() {
48
+ const ctrl = new AbortController();
49
+ let landed;
50
+ return {
51
+ signal: ctrl.signal,
52
+ abort(cause) {
53
+ if (ctrl.signal.aborted)
54
+ return;
55
+ landed = cause;
56
+ ctrl.abort();
57
+ },
58
+ cause() {
59
+ return landed;
60
+ },
35
61
  };
36
62
  }
37
63
  //# sourceMappingURL=run-cancel-context.js.map
package/dist/runs.d.ts CHANGED
@@ -2,6 +2,7 @@ import { defaultTaskRegistry, type CascadeConfig, type CheckpointToken, type Run
2
2
  import type { SendUserFileEmitter } from "./capabilities/send-user-file-tool.js";
3
3
  import type { RunStore } from "./plugins/store-backend.js";
4
4
  import type { Metrics } from "./observability/metrics.js";
5
+ import { type RunAbort } from "./run-cancel-context.js";
5
6
  import type { ModelUsageTracker, PromptManifestTracker } from "./budget.js";
6
7
  import type { ElicitationCoordinator } from "./elicitation.js";
7
8
  import type { QuestionCoordinator } from "./question.js";
@@ -259,10 +260,11 @@ export declare function evictIfConflict(runner: Runner, sessionId: string | unde
259
260
  * text); tool/lifecycle events are recorded individually. Per-token live streaming stays on the
260
261
  * synchronous `/v1/tasks/stream` endpoint. Never throws — failures are recorded on the run.
261
262
  *
262
- * Cancellation (POST /v1/runs/:id/cancel): the run registers an AbortController in `inflight` (same-replica
263
- * fast path — the cancel handler aborts it directly) and its heartbeat tick polls the durable
264
- * `cancel_requested` flag (cross-replica, ≤HEARTBEAT_MS lag). Either way the run settles to
265
- * status "failed" + errorCode "cancelled" (metric label "cancelled"); the entry is removed in `finally`.
263
+ * Cancellation (POST /v1/runs/:id/cancel): the run registers its abort handle (S-947 `RunAbort`) in `inflight`
264
+ * (same-replica fast path — the cancel handler aborts it directly) and its heartbeat tick polls the durable
265
+ * `cancel_requested` flag (cross-replica, ≤HEARTBEAT_MS lag); both abort with `cancel_verb`. Either way the run settles
266
+ * to status "failed" + errorCode "cancelled" + `result.abortCause` (metric label "cancelled"); the entry is removed in
267
+ * `finally`.
266
268
  *
267
269
  * Preemption (POST /v1/assistant/tasks/:id/preempt, [ref] seam #2): a SECOND, INDEPENDENT controller
268
270
  * (`preemptCtrl`, registered in `preemptable`) carries the scheduler's graceful "yield this task" — when raised,
@@ -281,7 +283,7 @@ export declare function evictIfConflict(runner: Runner, sessionId: string | unde
281
283
  export declare function runInBackground(runner: Runner, spec: TaskSpec, runStore: RunStore, taskId: string, metrics?: Metrics, principal?: string, verify?: VerifyRoundsSpec, // 快审 F1:number 参升整对象——cost 顶随传
282
284
  cascade?: CascadeConfig, instrumentDegenerate?: (result: TaskResult) => void, planCacheProbe?: {
283
285
  record: (scope: string | undefined, objective: string, completed: boolean) => void;
284
- }, persistThinking?: boolean, inflight?: Map<string, AbortController>, preemptable?: Map<string, AbortController>, steerable?: Map<string, TaskStream>, modelUsage?: ModelUsageTracker, elicitation?: ElicitationCoordinator,
286
+ }, persistThinking?: boolean, inflight?: Map<string, RunAbort>, preemptable?: Map<string, AbortController>, steerable?: Map<string, TaskStream>, modelUsage?: ModelUsageTracker, elicitation?: ElicitationCoordinator,
285
287
  /** E23: the VERIFIED principal (gatedPrincipal) that may answer this run's elicitations — matches the respond
286
288
  * owner-gate's identity source (NOT the cost-attribution `principal`, which on a direct door is the self-asserted
287
289
  * header → can be null while a JWT principal exists). Falls back to the run owner when not supplied. */
package/dist/runs.js CHANGED
@@ -12,7 +12,7 @@ import { taskNotificationEventData, appendModelUsageDelta, appendPromptManifest,
12
12
  import { createLedgerSink } from "./trace/ledger-sink.js";
13
13
  import { registerEngineNoticeLeg } from "./trace/engine-notice-wire.js";
14
14
  import { recordTurnActivity } from "./turn-activity.js";
15
- import { CANCELLED_CODE, mintCancelledResult } from "./run-cancel-context.js";
15
+ import { buildCancelledLedgerRow, CANCELLED_CODE, createRunAbort, mintCancelledResult } from "./run-cancel-context.js";
16
16
  import { createApprovalCardEmitter, resolveApprovalLeg } from "./tool-approval.js";
17
17
  import { fleetRunResiduals, isFleetAgentTerminalNotification } from "./fleet/fleet-bus.js";
18
18
  import { defaultSubagentTailBus } from "./fleet/subagent-tail-bus.js";
@@ -178,14 +178,16 @@ export async function runInBackground(runner, spec, runStore, taskId, metrics, p
178
178
  const startedAt = Date.now();
179
179
  metrics?.addGauge("runs_active", 1);
180
180
  fleetPublisher?.onStart();
181
- const cancelCtrl = new AbortController();
181
+ const cancelCtrl = createRunAbort();
182
182
  inflight?.set(taskId, cancelCtrl);
183
183
  markChildrenStoppedByUserOnAbort(cancelCtrl.signal, taskId, principal);
184
184
  const preemptEligible = verify === undefined && cascade === undefined;
185
185
  const preemptCtrl = new AbortController();
186
186
  if (preemptEligible)
187
187
  preemptable?.set(taskId, preemptCtrl);
188
- const cancelledResult = (engineResult) => mintCancelledResult({ taskId, sessionId: spec.sessionId ?? "", startedAtMs: startedAt, message: "cancelled by user", ...(engineResult !== undefined ? { engineResult } : {}) });
188
+ const cancelledResult = (cause, engineResult) => engineResult !== undefined
189
+ ? mintCancelledResult({ taskId, sessionId: spec.sessionId ?? "", startedAtMs: startedAt, message: "cancelled by user", cause, engineResult })
190
+ : mintCancelledResult({ taskId, sessionId: spec.sessionId ?? "", startedAtMs: startedAt, message: "cancelled by user", cause });
189
191
  const recordDone = (result, status) => {
190
192
  metrics?.addGauge("runs_active", -1);
191
193
  metrics?.inc("tasks_total", { status });
@@ -222,7 +224,7 @@ export async function runInBackground(runner, spec, runStore, taskId, metrics, p
222
224
  void runStore.heartbeat(taskId, owner).catch(() => { metrics?.inc("run_signal_poll_errors_total", { kind: "heartbeat" }); });
223
225
  if (!cancelCtrl.signal.aborted) {
224
226
  void runStore.isCancelRequested(taskId, owner).then((c) => { if (c)
225
- cancelCtrl.abort(); }).catch(() => { metrics?.inc("run_signal_poll_errors_total", { kind: "cancel" }); });
227
+ cancelCtrl.abort("cancel_verb"); }).catch(() => { metrics?.inc("run_signal_poll_errors_total", { kind: "cancel" }); });
226
228
  }
227
229
  if (preemptEligible && !preemptCtrl.signal.aborted) {
228
230
  void runStore.isPreemptRequested(taskId, owner).then((p) => { if (p)
@@ -233,7 +235,7 @@ export async function runInBackground(runner, spec, runStore, taskId, metrics, p
233
235
  const anchor = new TurnAnchorCapture(captureTurnAnchor, () => metrics?.inc("resume_anchor_capture_failed"), captureUserMessageAnchor);
234
236
  let reached;
235
237
  let observedDone;
236
- let cancelledAtDone = false;
238
+ let causeAtDone;
237
239
  let settledAsCancel = false;
238
240
  let steerableStreamRef;
239
241
  let legLive = true;
@@ -364,7 +366,7 @@ export async function runInBackground(runner, spec, runStore, taskId, metrics, p
364
366
  for await (const ev of stream) {
365
367
  if (ev.type === "done") {
366
368
  observedDone = ev.result;
367
- cancelledAtDone = cancelCtrl.signal.aborted;
369
+ causeAtDone = cancelCtrl.cause();
368
370
  }
369
371
  fleetPublisher?.onEvent(ev);
370
372
  await flushPromptManifest();
@@ -402,7 +404,7 @@ export async function runInBackground(runner, spec, runStore, taskId, metrics, p
402
404
  }
403
405
  evictIfConflict(runner, spec.sessionId, ev.result);
404
406
  {
405
- const settled = cancelledAtDone ? cancelledResult(ev.result) : ev.result;
407
+ const settled = causeAtDone !== undefined ? cancelledResult(causeAtDone, ev.result) : ev.result;
406
408
  settledAsCancel = failureCodeOf(settled.terminal) === CANCELLED_CODE;
407
409
  const safe = await withModelUsage(sealResultForExit(settled));
408
410
  await sink.appendDone(safe);
@@ -448,17 +450,18 @@ export async function runInBackground(runner, spec, runStore, taskId, metrics, p
448
450
  ? sendUserFile.runWithContext({ taskId, emit: (frame) => { const { type, ...rest } = frame; return append(type, rest); } }, withApproval)
449
451
  : withApproval();
450
452
  })));
451
- if (!reached && cancelCtrl.signal.aborted) {
453
+ const cleanCancelCause = cancelCtrl.cause();
454
+ if (!reached && cleanCancelCause !== undefined) {
452
455
  try {
453
456
  await flush();
454
457
  }
455
458
  catch (e) {
456
459
  recordFailOpen("server.runs.terminal-ledger-write-failed", `arm=flush-clean-cancel ${errText(e)}`);
457
460
  }
458
- const c = cancelledResult();
461
+ const c = cancelledResult(cleanCancelCause);
459
462
  settledAsCancel = failureCodeOf(c.terminal) === CANCELLED_CODE;
460
463
  try {
461
- await append("failed", { errorMessage: "cancelled by user", errorCode: CANCELLED_CODE });
464
+ await append("failed", buildCancelledLedgerRow(c));
462
465
  }
463
466
  catch (e) {
464
467
  recordFailOpen("server.runs.terminal-ledger-write-failed", `arm=append-clean-cancel ${errText(e)}`);
@@ -469,6 +472,7 @@ export async function runInBackground(runner, spec, runStore, taskId, metrics, p
469
472
  }
470
473
  }
471
474
  catch (err) {
475
+ const thrownCancelCause = cancelCtrl.cause();
472
476
  const message = errText(err);
473
477
  try {
474
478
  try {
@@ -488,11 +492,11 @@ export async function runInBackground(runner, spec, runStore, taskId, metrics, p
488
492
  else if (reached?.kind === "needs_review") {
489
493
  await runStore.setNeedsReview(taskId, reached.result);
490
494
  }
491
- else if (cancelCtrl.signal.aborted && observedDone === undefined) {
492
- const c = cancelledResult();
495
+ else if (thrownCancelCause !== undefined && observedDone === undefined) {
496
+ const c = cancelledResult(thrownCancelCause);
493
497
  settledAsCancel = failureCodeOf(c.terminal) === CANCELLED_CODE;
494
498
  try {
495
- await append("failed", { errorMessage: "cancelled by user", errorCode: CANCELLED_CODE });
499
+ await append("failed", buildCancelledLedgerRow(c));
496
500
  }
497
501
  catch (e) {
498
502
  recordFailOpen("server.runs.terminal-ledger-write-failed", `arm=append-thrown-cancel ${errText(e)}`);
@@ -58,6 +58,28 @@ export declare const SELF_MINTED_WIRE: {
58
58
  };
59
59
  };
60
60
  };
61
+ /**
62
+ * S-947(7.112.0):server 发起的中止在取消终局上留下的机读成因 —— `PersistedTaskResult.abortCause`,与 `cancelContext` 同住、同铸点、
63
+ * 同在场闸(在场 ⇔ 这条终局就是 server 那次中止铸出来的取消终局)。逐词站点见契约 §5.0d。键本身是 run 结果上的 server 侧记键
64
+ * (`store-contracts.ts` 的 `PersistedResultSideKeys`),不是事件投影面的键 ⇒ 不在 `wire-projection-faces.ts`。
65
+ */
66
+ readonly abortCause: {
67
+ readonly at: "run 结果 result.abortCause(GET /v1/runs/:id → result · done.result · POST /v1/tasks 200 体顶层 · 账本 done 行 result / 无 done 的取消形账本 failed 行顶层)";
68
+ readonly members: {
69
+ client_disconnected: {
70
+ attr: string;
71
+ since: string;
72
+ };
73
+ ask_park_grace_exhausted: {
74
+ attr: string;
75
+ since: string;
76
+ };
77
+ cancel_verb: {
78
+ attr: string;
79
+ since: string;
80
+ };
81
+ };
82
+ };
61
83
  /**
62
84
  * 会话所有者自助抹除面的**已分类拒** → HTTP 状态(S-930 起与 operator 面 `POST /v1/memory/erase` 同一只映射;`since` 记的是**本面**
63
85
  * 自哪一版起这样答 —— 7.107.0 及以前本面只认前两只,其余落 500 `internal.error`)。
@@ -21,6 +21,14 @@ export const SELF_MINTED_WIRE = {
21
21
  ".claude/AGENTS.md": { attr: "-", since: "7.111.0" },
22
22
  },
23
23
  },
24
+ abortCause: {
25
+ at: "run 结果 result.abortCause(GET /v1/runs/:id → result · done.result · POST /v1/tasks 200 体顶层 · 账本 done 行 result / 无 done 的取消形账本 failed 行顶层)",
26
+ members: {
27
+ client_disconnected: { attr: "-", since: "7.112.0" },
28
+ ask_park_grace_exhausted: { attr: "-", since: "7.112.0" },
29
+ cancel_verb: { attr: "-", since: "7.112.0" },
30
+ },
31
+ },
24
32
  sessionMemoryEraseStatus: {
25
33
  at: "POST /v1/sessions/:id/memory/erase(已分类拒 errorCode → HTTP 状态)",
26
34
  members: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "7.111.0",
3
+ "version": "7.112.0",
4
4
  "description": "Sema Server — the server/API implementation layer for Sema, wiring core, registry, model providers, and cloud agent execution. Built on @sema-agent/core.",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",
@@ -61,7 +61,7 @@
61
61
  "build:binary:run-local:darwin-arm64": "bun build --compile --target=bun-darwin-arm64 src/run-local.ts --outfile dist/run-local-darwin-arm64"
62
62
  },
63
63
  "dependencies": {
64
- "@sema-agent/core": "7.38.2",
64
+ "@sema-agent/core": "7.38.3",
65
65
  "@sema-agent/settings-schema": "6.4.0",
66
66
  "e2b": "^2.28.0",
67
67
  "libsodium-wrappers": "^0.8.4",
@@ -77,7 +77,7 @@
77
77
  "sharp": "^0.35.3"
78
78
  },
79
79
  "devDependencies": {
80
- "@sema-agent/sdk": "15.1.1",
80
+ "@sema-agent/sdk": "15.3.0",
81
81
  "@types/libsodium-wrappers": "^0.7.14",
82
82
  "@types/node": "22.10.2",
83
83
  "@types/pg": "^8.20.0",