@sema-agent/server 7.108.0 → 7.109.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 (46) hide show
  1. package/MIGRATION.md +43 -1
  2. package/USAGE.md +18 -28
  3. package/dist/approval-ask-machine.d.ts +9 -25
  4. package/dist/approval-ask-machine.js +5 -2
  5. package/dist/approval-card.d.ts +32 -3
  6. package/dist/approval-card.js +29 -2
  7. package/dist/bench/s1/live-deps.d.ts +4 -5
  8. package/dist/bench/s1/live-deps.js +9 -10
  9. package/dist/boot/leader.js +0 -2
  10. package/dist/boot/resolve-spec.d.ts +1 -1
  11. package/dist/boot/resolve-spec.js +3 -8
  12. package/dist/capabilities/scenarios.d.ts +3 -2
  13. package/dist/config-catalog.js +1 -3
  14. package/dist/config-provider.js +1 -0
  15. package/dist/config-types.d.ts +2 -9
  16. package/dist/config.js +2 -5
  17. package/dist/http/admission.js +8 -2
  18. package/dist/http/route-ctx.d.ts +3 -3
  19. package/dist/http/routes/capabilities.js +1 -0
  20. package/dist/http/server.d.ts +2 -2
  21. package/dist/http/wire-types.d.ts +8 -0
  22. package/dist/leader/diffup.d.ts +53 -0
  23. package/dist/leader/diffup.js +95 -1
  24. package/dist/leader/leader.d.ts +0 -6
  25. package/dist/leader/leader.js +1 -1
  26. package/dist/leader/repair-wire.js +1 -1
  27. package/dist/leader/wire.d.ts +3 -10
  28. package/dist/leader/wire.js +39 -13
  29. package/dist/observability/fail-open.d.ts +1 -5
  30. package/dist/observability/fail-open.js +1 -5
  31. package/dist/plugins/approval-ask-store-file.d.ts +2 -1
  32. package/dist/plugins/approval-ask-store-file.js +3 -0
  33. package/dist/plugins/approval-ask-store-memory.d.ts +2 -1
  34. package/dist/plugins/approval-ask-store-memory.js +7 -1
  35. package/dist/plugins/approval-ask-store-sql.d.ts +18 -0
  36. package/dist/plugins/approval-ask-store-sql.js +7 -0
  37. package/dist/request-key-closure.js +1 -1
  38. package/dist/run-local.js +2 -3
  39. package/dist/runtime-governance.d.ts +3 -3
  40. package/dist/tool-approval.d.ts +37 -15
  41. package/dist/tool-approval.js +8 -7
  42. package/dist/trace/core-keyset-guard.d.ts +1 -1
  43. package/dist/trace/project.js +2 -3
  44. package/dist/unattended-permission-mode.d.ts +54 -93
  45. package/dist/unattended-permission-mode.js +8 -61
  46. package/package.json +3 -3
@@ -9,7 +9,7 @@ import type { ReadFaceSource } from "./read-face-posture.js";
9
9
  import type { PostureSource } from "./posture-source.js";
10
10
  import type { SealedCustodyPoison } from "./key-resolver.js";
11
11
  import type { ModelEntryRefusal } from "./model-entry-refusal.js";
12
- import type { PermissionMode, A2aServerSpec, CompliancePosture, CrossSessionDialogExpiry, CrossSessionInboundSetting, FileHistoryRetentionPolicy, LockedKey, McpServerSpec, Model, ModelRoles, RetentionPolicy, ShellGateDoctrine, WriteProtectedEntry } from "@sema-agent/core";
12
+ import type { A2aServerSpec, CompliancePosture, CrossSessionDialogExpiry, CrossSessionInboundSetting, FileHistoryRetentionPolicy, LockedKey, McpServerSpec, Model, ModelRoles, RetentionPolicy, ShellGateDoctrine, WriteProtectedEntry } from "@sema-agent/core";
13
13
  import type { ApprovalHmacKey, PrincipalJwtKey } from "./auth-keys.js";
14
14
  import type { ElicitationThrottle } from "./elicitation.js";
15
15
  import type { QuestionThrottle } from "./question.js";
@@ -1920,13 +1920,6 @@ export interface ServiceConfigFlat {
1920
1920
  * 把批关掉(同腿后续的卡人批不动)、把兄弟连坐成 VOID(裸 `false` = 人拒)。
1921
1921
  */
1922
1922
  unattendedApprovalPolicy: UnattendedApprovalPolicy;
1923
- /**
1924
- * S-877 候裁 15 → B(`UNATTENDED_PERMISSION_MODE`)—— 本仓**自铸** spec、无调用方可表态、无审批席的腿(leader worker / leader 修复腿 /
1925
- * run-local)的 `TaskSpec.permissionMode`。闭集 = core `PERMISSION_MODES`,缺省 `bypassPermissions`(写门 / 形状层 = 7.105.0;写保护经 `createUnattendedWriteProtectionPolicy` 从问改拒;其余六类释放 = 宽化,候裁 18 D),坏值拒启。
1926
- * 规则 / 三问 / 写面 / 射程外**一处成文** = `src/unattended-permission-mode.ts` 模块头注;唯一读函数 `unattendedPermissionMode(config)`。
1927
- * 纯部署级:不读任何客户端表态、不参与托管姿态(这些腿不是请求)。
1928
- */
1929
- unattendedPermissionMode: PermissionMode;
1930
1923
  /** [ref]① (clay A 案 = workflow 权限全面 CC parity): workflow 子 agent 的默认权限基线与主 LLM 同权(base `{}`)。
1931
1924
  * 安全论证:主 LLM 与子 agent 同 root 同信任域,主 LLM 本就能写这棵树([ref] ask 门照管),单独钳子 agent 的
1932
1925
  * 安全增益≈0(只防绕路不防直路的门不是边界);实测产品代价=[ref]A 死锁。`WORKFLOW_AGENTS_READONLY=true` =
@@ -2113,7 +2106,7 @@ export type ServiceStoreConfig = Pick<ServiceConfigFlat, "sessionBackend" | "ses
2113
2106
  export type ServiceModelPlaneConfig = Pick<ServiceConfigFlat, "gatewayBaseUrl" | "gatewayApiKey" | "gatewayFallbackUrls" | "gatewayMaxRetries" | "anthropic" | "resilience" | "model" | "envModel" | "models" | "modelApiKeyEnv" | "modelApiKeys" | "modelRefusals" | "modelQuotaWeights" | "tiers" | "projects" | "roles" | "atModelAllowlist" | "cascadeLadder" | "degrade">;
2114
2107
  /** 组:approval(审批 / HITL 门)。`directDoorActive` 无 env 解析腿(装配层三域合取的产物),但语义上
2115
2108
  * 就是本组的门状态,故进组;`parseApprovalDomain` 的返回类型相应是 `Omit<…, "directDoorActive">`。 */
2116
- export type ServiceApprovalConfig = Pick<ServiceConfigFlat, "approvalRequire" | "approvalDeny" | "approvalTimeoutSec" | "approvalAutoBudget" | "approvalNeverAuto" | "approvalHmacKeys" | "durableApproval" | "durableApprovalSource" | "streamAskWindowMsSource" | "directApprovalDoor" | "directDoorActive" | "resourceSuspend" | "resourceSuspendTtlSec" | "resourceSuspendTotalTokens" | "resourceSuspendTotalBudgetUsd" | "resourceSuspendMaxSlices" | "askQuestionEnabled" | "questionThrottle" | "toolApprovalEnabled" | "permissionRulesEnabled" | "permissionRulesEnabledExplicit" | "streamAskWindowMarginMs" | "streamApproval" | "unattendedApprovalPolicy" | "unattendedPermissionMode" | "mcpElicitation" | "sensitiveWritePatterns" | "writeProtectedPaths" | "manualModeShellGate" | "permissionsDisableAutoMode" | "autoModeClassifierFallback" | "autoModeClassifierTimeoutMs" | "crossSessionInbound" | "crossSessionDialogExpiry">;
2109
+ export type ServiceApprovalConfig = Pick<ServiceConfigFlat, "approvalRequire" | "approvalDeny" | "approvalTimeoutSec" | "approvalAutoBudget" | "approvalNeverAuto" | "approvalHmacKeys" | "durableApproval" | "durableApprovalSource" | "streamAskWindowMsSource" | "directApprovalDoor" | "directDoorActive" | "resourceSuspend" | "resourceSuspendTtlSec" | "resourceSuspendTotalTokens" | "resourceSuspendTotalBudgetUsd" | "resourceSuspendMaxSlices" | "askQuestionEnabled" | "questionThrottle" | "toolApprovalEnabled" | "permissionRulesEnabled" | "permissionRulesEnabledExplicit" | "streamAskWindowMarginMs" | "streamApproval" | "unattendedApprovalPolicy" | "mcpElicitation" | "sensitiveWritePatterns" | "writeProtectedPaths" | "manualModeShellGate" | "permissionsDisableAutoMode" | "autoModeClassifierFallback" | "autoModeClassifierTimeoutMs" | "crossSessionInbound" | "crossSessionDialogExpiry">;
2117
2110
  /** 组:memory(记忆面 + TOC 同步腿)。 */
2118
2111
  export type ServiceMemoryConfig = Pick<ServiceConfigFlat, "memoryEngineEnabled" | "memoryEngineDir" | "memoryEngineRemoteLaneAllowed" | "memoryEngineBackend" | "memoryScope" | "memoryPersistenceCapable" | "memorySync" | "memoryEmbedder" | "memoryOrgAdmissionMode" | "memoryDelegationEvidence" | "memoryProvenance" | "memoryCapturePolicy" | "memoryConsolidationDriver" | "memoryOrgDirectoryJson" | "memoryOrgGrantTtlMs" | "memoryOrgUnavailableBackoffMs" | "projectMemoryEnabled" | "syncImportLeaseStaleSec">;
2119
2112
  /** 组:auth(鉴权 / 身份 / 治理棒)。`commandPolicy` 只有 sema-registry 腿(无 env 标量形),故 env 解析
package/dist/config.js CHANGED
@@ -27,7 +27,6 @@ import { modelRouteEndpointRefusal } from "./model-route-endpoint.js";
27
27
  import { buildDisplaySafeUrl, redactSecrets } from "./trace/redact.js";
28
28
  import { allowlistedEnv } from "./server-secret-env.js";
29
29
  import { ENV_NAME_ALLOWLIST_KNOBS, ENV_NAME_ALLOWLIST_KNOB_NAMES, describeAllowlistPostures } from "./env-name-allowlist-knobs.js";
30
- import { parseUnattendedPermissionMode } from "./unattended-permission-mode.js";
31
30
  export { RETENTION_MODES, MEMORY_CONSOLIDATION_MIN_INTERVAL_SEC } from "./config-types.js";
32
31
  function csv(name) {
33
32
  return (process.env[name] ?? "")
@@ -135,6 +134,7 @@ export const RETIRED_ENVS = [
135
134
  ["CONFIG_LKG_DISABLED", "CONFIG_LKG_ENABLED", "replaced", "negative-name alias retired in server 3.0.0 — unset it; the polarity is inverted: CONFIG_LKG_DISABLED=true is CONFIG_LKG_ENABLED=false (the positive name defaults on)"],
136
135
  ["HOST_BG_DISABLED", "HOST_BG_ENABLED", "replaced", "negative-name alias retired in server 3.0.0 — unset it; the polarity is inverted: HOST_BG_DISABLED=true is HOST_BG_ENABLED=false (the positive name defaults on)"],
137
136
  ["HOST_EXEC_SPOOL_DISABLED", "HOST_EXEC_SPOOL_ENABLED", "replaced", "negative-name alias retired in server 3.0.0 — unset it; the polarity is inverted: HOST_EXEC_SPOOL_DISABLED=true is HOST_EXEC_SPOOL_ENABLED=false (the positive name defaults on)"],
137
+ ["UNATTENDED_PERMISSION_MODE", "since server 7.109.0 the unattended legs (leader workers and their repair legs, run-local, and their durable resumes) always run permissionMode \"default\" with an approver that answers by ask class: a machine answers only \"ordinary\" asks, and every other class goes to the leg's approver seat (run-local's terminal prompt) or parks / is refused — there is no mode to choose", "removed"],
138
138
  ];
139
139
  for (const row of RETIRED_ENVS)
140
140
  Object.freeze(row);
@@ -1327,8 +1327,6 @@ function unattendedApprovalPolicyEnv() {
1327
1327
  function parseApprovalDomain(ctx) {
1328
1328
  const { postureOn, singleUserTurnkey, infraReady } = ctx;
1329
1329
  const unattendedPolicy = unattendedApprovalPolicyEnv();
1330
- const unattendedMode = parseUnattendedPermissionMode(process.env.UNATTENDED_PERMISSION_MODE);
1331
- CONFIG_NOTICES.push({ level: "info", event: "unattended_permission_mode", fields: { value: unattendedMode.value, source: unattendedMode.source } });
1332
1330
  const streamAskWindowMsSource = postureSourceOfNumEnv(process.env.STREAM_ASK_WINDOW_MS, singleUserTurnkey);
1333
1331
  const toolApprovalEnabled = postureOn("TOOL_APPROVAL_ENABLED");
1334
1332
  let streamAskWindowMsEffectiveSource = streamAskWindowMsSource;
@@ -1429,7 +1427,6 @@ function parseApprovalDomain(ctx) {
1429
1427
  streamAskWindowMsSource: streamAskWindowMsEffectiveSource,
1430
1428
  durableApprovalSource: postureSourceOfKnob("DURABLE_APPROVAL"),
1431
1429
  unattendedApprovalPolicy: unattendedPolicy,
1432
- unattendedPermissionMode: unattendedMode.value,
1433
1430
  approvalTimeoutSec: numEnv("APPROVAL_TIMEOUT_SEC", "0"),
1434
1431
  };
1435
1432
  }
@@ -2299,7 +2296,7 @@ const MODEL_PLANE_GROUP_KEYS = [
2299
2296
  const APPROVAL_GROUP_KEYS = [
2300
2297
  "approvalRequire", "approvalDeny", "approvalTimeoutSec", "approvalAutoBudget", "approvalNeverAuto",
2301
2298
  "approvalHmacKeys", "durableApproval", "durableApprovalSource", "streamAskWindowMsSource", "directApprovalDoor", "directDoorActive", "resourceSuspend",
2302
- "resourceSuspendTtlSec", "resourceSuspendTotalTokens", "resourceSuspendTotalBudgetUsd", "resourceSuspendMaxSlices", "askQuestionEnabled", "questionThrottle", "toolApprovalEnabled", "permissionRulesEnabled", "permissionRulesEnabledExplicit", "streamAskWindowMarginMs", "streamApproval", "unattendedApprovalPolicy", "unattendedPermissionMode", "mcpElicitation",
2299
+ "resourceSuspendTtlSec", "resourceSuspendTotalTokens", "resourceSuspendTotalBudgetUsd", "resourceSuspendMaxSlices", "askQuestionEnabled", "questionThrottle", "toolApprovalEnabled", "permissionRulesEnabled", "permissionRulesEnabledExplicit", "streamAskWindowMarginMs", "streamApproval", "unattendedApprovalPolicy", "mcpElicitation",
2303
2300
  "sensitiveWritePatterns", "writeProtectedPaths", "manualModeShellGate", "permissionsDisableAutoMode", "autoModeClassifierFallback", "autoModeClassifierTimeoutMs", "crossSessionInbound", "crossSessionDialogExpiry",
2304
2301
  ];
2305
2302
  const MEMORY_GROUP_KEYS = [
@@ -458,8 +458,14 @@ export function createAdmission(ctx) {
458
458
  sendError(res, 400, "request.field_invalid", "retainBackgroundProcesses must be a boolean");
459
459
  return null;
460
460
  }
461
- if (body.excludeAllTools !== undefined && typeof body.excludeAllTools !== "boolean") {
462
- sendError(res, 400, "request.field_invalid", "excludeAllTools must be a boolean");
461
+ for (const key of ["excludeAllTools", "disableSkills"]) {
462
+ if (body[key] !== undefined && typeof body[key] !== "boolean") {
463
+ sendError(res, 400, "request.field_invalid", `${key} must be a boolean`);
464
+ return null;
465
+ }
466
+ }
467
+ if (body.disableSkills === true && ((body.skills?.length ?? 0) > 0 || (body.agents ?? []).some((a) => (a.skills?.length ?? 0) > 0))) {
468
+ sendError(res, 400, "request.field_conflict", "disableSkills: true means this run installs no skill at all (not the deployment's, not the request's), so it cannot come with a non-empty skills or agents[].skills — send one or the other");
463
469
  return null;
464
470
  }
465
471
  for (const key of ["excludeTools", "deferTools"]) {
@@ -150,9 +150,9 @@ export interface CheckpointCtx {
150
150
  /**
151
151
  * 🔴 S-877 候裁 18(codex r3 F1)—— 这条会话的 run 是**无人值守腿**铸的(今天唯一的写者 = leader worker 的 durable 块,
152
152
  * `leader/wire.ts`),**服务端写、不来自请求体**(`body` 是提交体;本位是它的兄弟键,客户端够不着)。
153
- * 续跑家族据此请 `resolveSpec` 带 `unattendedLeg: true`,由它按续跑 Runner 的同名座经 `createUnattendedLeg` / `withUnattendedLeg`
154
- * 重挂整条腿(模式 = 旋钮、写面 roots、模式释放写保护时的写保护拒层)—— 拒层是活闭包、进不了 checkpoint,而 core 在 spec 不说
155
- * 模式时取 checkpoint 记下的那一个(`seat ?? recorded`),不重挂 = 续跑落 bypass 而拒层缺席。写回口(`DriveResumeArgs.resumeCtx`)
153
+ * 续跑家族据此请 `resolveSpec` 带 `unattendedLeg: true`,由它经首跑同一只合并口 `withUnattendedLeg` 重挂整条腿(S-934 ①:`default` +
154
+ * 按类作答的 onAsk)—— onAsk 是活闭包、进不了 checkpoint,而 core 在 spec 不说模式时取 checkpoint 记下的那一个(`seat ?? recorded`),
155
+ * 不重挂 = 续跑落 checkpoint 记下的旧模式(7.106.0–7.108.0 的行记 `bypassPermissions`)而按类作答缺席。写回口(`DriveResumeArgs.resumeCtx`)
156
156
  * 传原 ctx / 其展开 ⇒ 标随行。缺席 = 不是无人值守腿(HTTP 提交腿;7.106.0 前落盘的行 —— 那时这些腿不铸模式,checkpoint 里也没有)。
157
157
  */
158
158
  unattendedLeg?: true;
@@ -138,6 +138,7 @@ export async function handleCapabilities(req, res, match, ctx) {
138
138
  taskAgents: deps.config.requirePrincipal !== true,
139
139
  retainBackgroundProcesses: deps.config.requirePrincipal !== true,
140
140
  interactiveTools: true,
141
+ disableSkills: true,
141
142
  projectContext: cwdHonored(deps.config),
142
143
  callerCwd: cwdHonored(deps.config) || deviceCwdHonored(deps.config),
143
144
  taskWriteFaceOpen: taskWriteFaceRefusal(deps.config) === undefined && autonomyOverrides(deps.config.autonomy).writeFace !== "roots",
@@ -105,8 +105,8 @@ export interface ServiceCoreDeps {
105
105
  * ⚠️ 7.69.0(R-1)起 **resume 家族也递这一位** —— 四条 resume 腿收敛到 `resolveResumeSpec` 一只 resolver,
106
106
  * 通告口接一次、四腿同得(见 http/server.ts 的 `resolveResumeSpec` 顶注);此处旧注写作「resume 家族缺席」
107
107
  * 已过期,按新行为改。
108
- * `opts.unattendedLeg`(S-877 候裁 18,codex r3 F1):续跑家族在 ctx 带无人值守标(`CheckpointCtx.unattendedLeg`)时递 `true` ——
109
- * 装配器在**全部折叠之后**经 `createUnattendedLeg` / `withUnattendedLeg` 重挂那条腿(表 / 根取续跑 Runner 的同名座)。只由续跑腿
108
+ * `opts.unattendedLeg`(S-877 候裁 18,codex r3 F1;S-934 ① 改形):续跑家族在 ctx 带无人值守标(`CheckpointCtx.unattendedLeg`)时递 `true` ——
109
+ * 装配器在**全部折叠之后**经首跑同一只合并口 `withUnattendedLeg` 重挂那条腿(`default` + 按类作答的 onAsk)。只由续跑腿
110
110
  * 按服务端写的 ctx 位递,绝不从请求体读。 */
111
111
  resolveSpec: (body: TaskRequestBody, req: IncomingMessage | undefined, auth?: RequestAuth, opts?: {
112
112
  leg?: "fresh" | "resume";
@@ -293,6 +293,14 @@ export interface TaskRequestBody {
293
293
  * 探测:本键在不在受理面由**键闭集**自己回答 —— 没接线的 server 版本对它 400 `request.body_shape`
294
294
  * 并逐字列名,所以**不加能力位**(一个键一条探测路,不长第二条)。 */
295
295
  excludeAllTools?: boolean;
296
+ /** S-946(cli L-920 请托;CC 2.1.280 `--disable-slash-commands`「Disable all skills」,cli280.js:240760 整张命令表置空):
297
+ * **这一发一只技能都不装** —— 场景出厂技能(启动期烤入 + 中心下发)、调用方 `skills`、子代定义的 `agents[].skills`
298
+ * 一个都不进 `TaskSpec`。`true` ⇒ `TaskSpec.skills = []`(core 对空表:零 `<skills>` 块、`Skill` 工具不挂);
299
+ * `false` / 缺席 = 技能照旧。值形门与 {@link excludeAllTools} **同一只**(非 boolean ⇒ 400 `request.field_invalid`);
300
+ * 与非空 `skills` 或任一非空 `agents[].skills` 同带 ⇒ 400 `request.field_conflict`(体内自相矛盾,拒在受理门)。
301
+ * 续跑重放逐字重放存量体 ⇒ 同一只写点同判(坏形 defensive:只认 literal `true`)。无租户门(收窄,不跨租户)。
302
+ * 探测位 `GET /v1/capabilities` 的 `disableSkills`(与本键同一个词)。 */
303
+ disableSkills?: boolean;
296
304
  /** [ref]② (core 1.314 工具面控制批): roster 真卸载清单(schema 不上 wire,manifest 收窄)。wire 工具名;
297
305
  * tighten-only(core 继承不变量 union 进子 spawn)。数组-of-string fail-loud at submit。 */
298
306
  excludeTools?: string[];
@@ -27,6 +27,59 @@ export declare function buildUploadScript(p: {
27
27
  }): string;
28
28
  /** The objective suffix that makes the upload the worker's last task step (no URL through the model). */
29
29
  export declare function uploadStepSuffix(workDir: string): string;
30
+ /**
31
+ * S-949 —— the INCREMENTAL upload loop's lifetime protocol (single writer; the wire only runs its two verbs).
32
+ *
33
+ * While a worker env lives, a background loop re-runs the staged upload script every {@link UPLOAD_LOOP_PERIOD_SEC}
34
+ * seconds, so the worker's fixed diff key tracks its latest COMMITTED progress and salvage survives an env hard-crash
35
+ * (#16 tail: run 019eca71's "4 commits vaporized"). The loop must NOT outlive the env: the pre-S-949 form
36
+ * (`nohup sh -c 'while true; …' &`) relied on "the pod dies, the loop dies", which holds only where the env IS a
37
+ * pod — on an env whose shell is the host's (`RemoteHostExecutionEnv`, core `NodeExecutionEnv`) teardown does not
38
+ * reap detached processes (by contract), and the loop became a ppid-1 orphan (254 of them on one box, 2026-10-01).
39
+ *
40
+ * ONE mechanism for every lane, carried entirely by the env's ordinary `exec` (no lane branch, no process-group or
41
+ * `/proc` assumption, never a signal to a pid that might have been reused):
42
+ * - **run token** `.leader-upload-loop.gen` — the generation allowed to run. `start` draws the next number from
43
+ * the monotonic counter `.leader-upload-loop.seq` (never reset by `stop` — a counter that restarted at 1 after a
44
+ * `stop` past its bound would hand a fresh loop the generation of a revoked loop still finishing its upload, and
45
+ * that one would carry on), writes it here BEFORE forking and hands it to the loop; the loop runs only while this
46
+ * file still names its generation. So a later `start` (a resumed leg re-staging the same workspace) supersedes an
47
+ * earlier loop, `stop` (which removes the file) ends every loop, and a workspace deleted out from under it ends it
48
+ * too — the token check IS the loop's only exit, there is no second mechanism;
49
+ * - **one pid file per generation** `.leader-upload-loop.pid.<gen>` — written by `start` right after the fork (the
50
+ * wire awaits `start`, so it is on disk before any `stop` can run) and removed by that loop as it exits. `stop`
51
+ * waits until no generation is left, so a superseded loop whose upload was still in flight at the re-stage is
52
+ * waited for too (codex r1 F2). It only PROBES a pid (`kill -0`): a loop that died abnormally does not hold it up;
53
+ * - **bounded uploads** — the loop runs each upload with a PUT time bound ({@link UPLOAD_LOOP_UPLOAD_MAX_SEC},
54
+ * `curl --max-time`), so a hung object store cannot pin a revoked loop alive (codex r1 F1: on a host env that was
55
+ * an orphan stuck in curl). `stopWaitSec` must exceed that bound plus one tick (the builder refuses otherwise);
56
+ * - **ordering** — the loop checks the token right before every upload and runs it in the foreground, so once
57
+ * `stop` returns 0 no loop upload of any generation is running or can start: the teardown belt upload that follows
58
+ * is the last write to the diff key. A `stop` past its bound (a hung `git format-patch`, or a pid file whose pid
59
+ * now belongs to someone else) exits non-zero — the wire warns and still runs the belt (dropping the belt would
60
+ * lose the uncommitted-work capture it exists for).
61
+ */
62
+ /** Seconds between two loop runs of the upload script. Single writer: the wire passes THIS constant; tests inject a
63
+ * short period through {@link buildUploadLoopScript}'s parameter. Deliberately not a deployment knob. */
64
+ export declare const UPLOAD_LOOP_PERIOD_SEC = 90;
65
+ /** Where the loop script is staged — next to the upload script, same `workDir` rule. */
66
+ export declare function uploadLoopScriptPath(workDir: string): string;
67
+ /** The two lifecycle verbs the wire runs through the env's ordinary exec (closed set — `run` is the loop's own,
68
+ * internal entry point and is never issued from outside). */
69
+ export type UploadLoopVerb = "start" | "stop";
70
+ /** The shell line for one lifecycle verb. `stop` revokes the run token FIRST and independently of the script (a loop
71
+ * whose script file went missing still sees its token gone and exits), then lets the script's `stop` wait for it. */
72
+ export declare function uploadLoopCommand(workDir: string, verb: UploadLoopVerb): string;
73
+ /** The staged loop script (see the protocol note above). The three bounds are interpolated into the shell ⇒
74
+ * validated here, fail-closed: positive integers, and `stopWaitSec > uploadMaxSec + 1` (stop outlasts one bounded
75
+ * upload plus the loop's one-second tick — otherwise "stop returned ⇒ no upload in flight" would be false by
76
+ * construction). */
77
+ export declare function buildUploadLoopScript(p: {
78
+ workDir: string;
79
+ periodSec: number;
80
+ uploadMaxSec?: number;
81
+ stopWaitSec?: number;
82
+ }): string;
30
83
  /**
31
84
  * Fetch the worker-uploaded patch over a presigned GET (control plane; creds stay adapter-held). Returns the
32
85
  * same result shape as pullDiff so merge consumes either path. 🔴 The presigned URL is a short-lived bearer
@@ -8,9 +8,10 @@ export function buildUploadScript(p) {
8
8
  "#!/bin/sh",
9
9
  "# leader diff-out (design/68 F2(a)) — exports this worker's branch as a patch series. Do not edit.",
10
10
  "set -e",
11
+ 'case "$1" in ""|*[!0-9]*) M="" ;; *) M="--max-time $1" ;; esac',
11
12
  `cd ${p.repoDir}`,
12
13
  `git format-patch --stdout ${p.baseSha}..HEAD > ${p.workDir}/.leader-out.patch`,
13
- `curl -fsS -X PUT -T ${p.workDir}/.leader-out.patch '${putUrl}'`,
14
+ `curl -fsS $M -X PUT -T ${p.workDir}/.leader-out.patch '${putUrl}'`,
14
15
  `echo leader-diff-uploaded`,
15
16
  "",
16
17
  ].join("\n");
@@ -18,6 +19,99 @@ export function buildUploadScript(p) {
18
19
  export function uploadStepSuffix(workDir) {
19
20
  return `\n\nMANDATORY FINAL STEP: after your final commit, run exactly: sh ${workDir}/.leader-upload.sh — it exports your branch diff for integration. Do not modify, inspect, or skip it; if it fails, fix your git state (commit your work) and run it again.`;
20
21
  }
22
+ export const UPLOAD_LOOP_PERIOD_SEC = 90;
23
+ const UPLOAD_LOOP_UPLOAD_MAX_SEC = 25;
24
+ const UPLOAD_LOOP_STOP_WAIT_SEC = 30;
25
+ export function uploadLoopScriptPath(workDir) {
26
+ return `${workDir}/.leader-upload-loop.sh`;
27
+ }
28
+ const uploadLoopTokenPath = (workDir) => `${workDir}/.leader-upload-loop.gen`;
29
+ export function uploadLoopCommand(workDir, verb) {
30
+ const script = uploadLoopScriptPath(workDir);
31
+ switch (verb) {
32
+ case "start":
33
+ return `sh ${script} start`;
34
+ case "stop":
35
+ return `rm -f ${uploadLoopTokenPath(workDir)}; sh ${script} stop`;
36
+ }
37
+ }
38
+ export function buildUploadLoopScript(p) {
39
+ const uploadMaxSec = p.uploadMaxSec ?? UPLOAD_LOOP_UPLOAD_MAX_SEC;
40
+ const stopWaitSec = p.stopWaitSec ?? UPLOAD_LOOP_STOP_WAIT_SEC;
41
+ for (const [name, n] of [["periodSec", p.periodSec], ["uploadMaxSec", uploadMaxSec], ["stopWaitSec", stopWaitSec]]) {
42
+ if (!Number.isInteger(n) || n < 1)
43
+ throw new Error(`buildUploadLoopScript: ${name} must be a positive integer, got ${JSON.stringify(n)}`);
44
+ }
45
+ if (stopWaitSec <= uploadMaxSec + 1) {
46
+ throw new Error(`buildUploadLoopScript: stopWaitSec (${stopWaitSec}) must exceed uploadMaxSec (${uploadMaxSec}) + 1 — stop has to outlast one bounded upload plus the loop's tick`);
47
+ }
48
+ const script = uploadLoopScriptPath(p.workDir);
49
+ return [
50
+ "#!/bin/sh",
51
+ "# leader incremental upload loop (S-949) — re-runs .leader-upload.sh while this env lives. Do not edit.",
52
+ "# start: launch ONE detached loop (an earlier loop here sees its generation superseded and exits); record its pid.",
53
+ "# stop: revoke the run token, then wait until no loop of any generation is left (uploads in flight finish first).",
54
+ "# run: the loop itself (internal) — runs only while the token file still names its generation.",
55
+ `G=${uploadLoopTokenPath(p.workDir)}`,
56
+ `Q=${p.workDir}/.leader-upload-loop.seq`,
57
+ `P=${p.workDir}/.leader-upload-loop.pid`,
58
+ "stop_all() {",
59
+ ' rm -f "$G"',
60
+ " i=0",
61
+ " while :; do",
62
+ " live=",
63
+ ' for f in "$P".*; do',
64
+ ' [ -f "$f" ] || continue',
65
+ " p=",
66
+ ' read -r p 2>/dev/null < "$f"',
67
+ ' if [ -n "$p" ] && kill -0 "$p" 2>/dev/null; then live="$live $p"; else rm -f "$f"; fi',
68
+ " done",
69
+ ' [ -z "$live" ] && return 0',
70
+ ` if [ "$i" -ge ${stopWaitSec} ]; then`,
71
+ ` echo "leader upload loop(s) still running ${stopWaitSec}s after the run token was revoked (pid$live)" >&2`,
72
+ " return 1",
73
+ " fi",
74
+ " sleep 1",
75
+ " i=$((i + 1))",
76
+ " done",
77
+ "}",
78
+ 'case "$1" in',
79
+ "start)",
80
+ ' command -v nohup >/dev/null 2>&1 || { echo "nohup not found — upload loop not started" >&2; exit 1; }',
81
+ " g=0",
82
+ ' [ -f "$Q" ] && read -r g < "$Q"',
83
+ " case $g in ''|*[!0-9]*) g=0 ;; esac",
84
+ " g=$((g + 1))",
85
+ ' echo "$g" > "$Q"',
86
+ ' echo "$g" > "$G"',
87
+ ` nohup sh ${script} run "$g" </dev/null >/dev/null 2>&1 &`,
88
+ ' echo "$!" > "$P.$g"',
89
+ " ;;",
90
+ "run)",
91
+ " n=0",
92
+ ' while read -r g 2>/dev/null < "$G" && [ "$g" = "$2" ]; do',
93
+ ` if [ "$n" -ge ${p.periodSec} ]; then`,
94
+ " n=0",
95
+ ` sh ${p.workDir}/.leader-upload.sh ${uploadMaxSec} >/dev/null 2>&1 || true`,
96
+ " else",
97
+ " sleep 1",
98
+ " n=$((n + 1))",
99
+ " fi",
100
+ " done",
101
+ ' rm -f "$P.$2"',
102
+ " exit 0",
103
+ " ;;",
104
+ "stop)",
105
+ " stop_all",
106
+ " ;;",
107
+ "*)",
108
+ ' echo "usage: sh $0 start|stop" >&2',
109
+ " exit 2",
110
+ " ;;",
111
+ "esac",
112
+ "",
113
+ ].join("\n");
114
+ }
21
115
  export async function fetchUploadedDiff(getUrl, opts = {}) {
22
116
  const maxBytes = opts.maxBytes ?? DEFAULT_MAX_DIFF_BYTES;
23
117
  const f = opts.fetchImpl ?? fetch;
@@ -16,7 +16,6 @@ import { type RunnerLike, type WorkerReport } from "./fanout.js";
16
16
  import { type ExecStreamLike } from "./diffout.js";
17
17
  import { type MergeDeps, type MergeResult } from "./merge.js";
18
18
  import { type TaskSpec, type VerifyConfig, type CheckpointToken, type TerminalCause, type ToolSpec, type RepairTerminal, type OracleTier, type RepairResult } from "@sema-agent/core";
19
- import { type UnattendedLeg } from "../unattended-permission-mode.js";
20
19
  /** [ref] Slice 6: drive a worker's resource-suspend auto-resume to a terminal (or bound) state. Exported
21
20
  * for unit tests. Loops `resume(token, slice)` WHILE the result's terminal cause is a park on a
22
21
  * `resource_limit` gate. Exits on: a terminal cause that is not a park, a park on any OTHER gate
@@ -68,11 +67,6 @@ export interface ProvisionedWorker {
68
67
  export interface LeaderDeps {
69
68
  /** Plan a task into N disjoint-file sub-tasks (MVP: simple/hand-authored; adaptive replan = scale). */
70
69
  plan: (task: string) => Promise<SubtaskSpec[]>;
71
- /** S-877 候裁 15 / 18 —— 本车道有手的 run 并进的无人值守腿(模式 = 运维旋钮 `UNATTENDED_PERMISSION_MODE`、写面 roots、模式释放写保护时的
72
- * 写保护拒层),由 `wire.ts` 经 `createUnattendedLeg` 铸一次递进来。本文件自己起的有手 run(模块门修复)与 `repair-wire.ts` 的单代理修复环按
73
- * `sub.spec` 起 run、不经 provisionWorker —— 两处都经 `withUnattendedLeg` 并进这一份。**必填**(缺席 = core「Absent = default」⇒ 无人可答的腿上
74
- * 写与非只读命令被当场拒,codex F1 的病)。 */
75
- unattendedLeg: UnattendedLeg;
76
70
  /** Provision a leader-owned worker (its env seeded with base at `branch` + an env-bound runner). */
77
71
  provisionWorker: (sub: SubtaskSpec) => Promise<ProvisionedWorker>;
78
72
  /** Ephemeral no-creds integration sandbox + the credentialed Coordinator push (merge(b) deps). */
@@ -201,7 +201,7 @@ export async function runLeaderTask(task, durableBaseSha, deps) {
201
201
  }
202
202
  deps.logger?.info?.("module_repair", { workerId: r.workerId, attempt: attempt + 1, reason: redactHead(verdict.reason ?? "", 500) });
203
203
  try {
204
- const rr = await w.runner.runTaskStream(withUnattendedLeg({ ...sub.spec, objective: deps.moduleGate.repairObjective(sub, safeVerdictReason), sessionId: `${w.sessionId}-r${attempt + 1}` }, deps.unattendedLeg)).result();
204
+ const rr = await w.runner.runTaskStream(withUnattendedLeg({ ...sub.spec, objective: deps.moduleGate.repairObjective(sub, safeVerdictReason), sessionId: `${w.sessionId}-r${attempt + 1}` })).result();
205
205
  if (r.stats && rr?.stats)
206
206
  r.stats = { ...r.stats, tokens: (r.stats.tokens ?? 0) + (rr.stats.tokens ?? 0), costMicroUsd: (r.stats.costMicroUsd ?? 0) + (rr.stats.costMicroUsd ?? 0) };
207
207
  }
@@ -87,7 +87,7 @@ export function attachRepairLoopDeps(deps, resolve) {
87
87
  deps.repairLoop = {
88
88
  run: async (solo, sub) => {
89
89
  const cfg = await resolve(solo, sub);
90
- const implSpec = withUnattendedLeg({ ...sub.spec, sessionId: solo.sessionId }, deps.unattendedLeg);
90
+ const implSpec = withUnattendedLeg({ ...sub.spec, sessionId: solo.sessionId });
91
91
  return runRepairLoopForLeader(solo, implSpec, cfg);
92
92
  },
93
93
  };
@@ -1,4 +1,4 @@
1
- import { type Brain, type Model, type ModelRoles, type ModelPricing, type TaskSpec, type ExecutionEnvFactory, type RemoteExecutionEnv, type ToolResultStore, type SessionStore, type ExecutionEnv, type RunnerDeps, type PermissionMode } from "@sema-agent/core";
1
+ import { type Brain, type Model, type ModelRoles, type ModelPricing, type TaskSpec, type ExecutionEnvFactory, type RemoteExecutionEnv, type ToolResultStore, type SessionStore, type ExecutionEnv, type RunnerDeps } from "@sema-agent/core";
2
2
  import type { CheckpointStoreFull } from "../plugins/store-backend.js";
3
3
  import { type LeaderDeps, type LeaderResult } from "./leader.js";
4
4
  import { type DeploymentPostureSeats } from "../boot/runner-deps.js";
@@ -19,14 +19,6 @@ export interface LeaderWireConfig {
19
19
  * REMOTE_EXEC (e2b or k8s/Kata) — workers and the integration sandbox run on envs from THIS factory.
20
20
  * Requires `s3` (diff-out is the worker's self-upload; there is no leader-held env to pull from). */
21
21
  envFactory?: ExecutionEnvFactory;
22
- /**
23
- * S-877 候裁 15 → B —— 本车道**有手**的每一条 run(worker 两车道、集成沙箱修复、模块门修复、单代理修复环)的
24
- * `TaskSpec.permissionMode`。**必填**:缺席 = core「Absent = `default`」⇒ 写与非只读命令在无人可答的这条腿上被
25
- * headless 当场拒(codex F1 的病);必填让每一个构造本配置的地方都得表态,不许静默落回那一形。
26
- * 唯一取值点 = `boot/leader.ts` 调 `unattendedPermissionMode(config)`(`src/unattended-permission-mode.ts`,与 run-local 同一只);
27
- * 本文件经 `createUnattendedLeg` / `withUnattendedLeg` 铸并(模式 + 写面 roots + 候裁 18 的写保护拒层)。planner 路由无手,不读。
28
- */
29
- unattendedPermissionMode: PermissionMode;
30
22
  /** Durable suspend/resume for SUB-WORKERS ([ref] C4): when the seat yields a value, that worker task carries
31
23
  * the durable question/ask policy + checkpoint store, so a gated ask/AskUserQuestion SUSPENDS the worker
32
24
  * (checkpoint + paused env) instead of failing — the trigger replan-lite C4 arbitrates on. Mirrors main.ts's
@@ -165,7 +157,8 @@ export interface LeaderWireConfig {
165
157
  /** The env surface wire needs (both the E2B and k8s adapters implement core's RemoteExecutionEnv). */
166
158
  type WireEnv = Pick<RemoteExecutionEnv, "exec" | "writeFile">;
167
159
  /**
168
- * [ref] —— leader 自铸沙盒的**回收闸**(集成沙盒 / repair-loop grader 各一只)。
160
+ * [ref] —— leader 自铸沙盒的**回收闸**(集成沙盒 / repair-loop grader 各一只;判据本身 S-949 起抽成下方
161
+ * {@link assertLeaderOwnedEnvReapable},worker 腿同判)。
169
162
  *
170
163
  * core 契约:`ExecutionEnv` 基面**没有** `destroy`;只有实现了 `RemoteExecutionEnv` 的适配器才有
171
164
  * (`core/remote-env.d.ts`),而「这只 env 到底有没有」是**运行期结构事实**,core 自己也只用结构探测
@@ -5,13 +5,13 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
5
5
  import { join } from "node:path";
6
6
  import { tmpdir } from "node:os";
7
7
  import { Runner, combinePolicies, createDurableQuestionPolicy, QUESTION_AWAITS_RESUME, delimitUntrusted, hasDestroy, } from "@sema-agent/core";
8
- import { createUnattendedLeg, withUnattendedLeg } from "../unattended-permission-mode.js";
8
+ import { withUnattendedLeg } from "../unattended-permission-mode.js";
9
9
  import { RemoteContainerExecutionEnv } from "../plugins/remote-env-e2b.js";
10
10
  import { E2B_SANDBOX_HOME } from "../execution-lane-caps.js";
11
11
  import { createDurableAskPolicy } from "../approval.js";
12
12
  import { gitSubprocessEnv } from "../server-secret-env.js";
13
13
  import { presignS3Url } from "../plugins/s3-presign.js";
14
- import { buildUploadScript, uploadStepSuffix, fetchUploadedDiff, withStaging } from "./diffup.js";
14
+ import { buildUploadScript, buildUploadLoopScript, uploadLoopCommand, uploadLoopScriptPath, uploadStepSuffix, fetchUploadedDiff, withStaging, UPLOAD_LOOP_PERIOD_SEC } from "./diffup.js";
15
15
  import { pullDiff, safeRev } from "./diffout.js";
16
16
  import { runLeaderTask } from "./leader.js";
17
17
  import { attachRepairLoopDeps } from "./repair-wire.js";
@@ -37,10 +37,13 @@ const asIntegrationEnv = (env) => ({
37
37
  throw r.error; },
38
38
  });
39
39
  export function createEnvReaper(env, what) {
40
+ assertLeaderOwnedEnvReapable(env, what);
41
+ return async () => void (await env.destroy());
42
+ }
43
+ function assertLeaderOwnedEnvReapable(env, what) {
40
44
  if (!hasDestroy(env)) {
41
- throw new Error(`${what}: the execution-env factory returned an env with no destroy() — the leader OWNS this dedicated sandbox and this closure is its only teardown, so it would leak (and keep billing) until the backend's own timeout reaps it. Deploy a factory whose env implements RemoteExecutionEnv.destroy for the leader lane.`);
45
+ throw new Error(`${what}: the execution-env factory returned an env with no destroy() — the leader OWNS this dedicated sandbox and destroy() is its only teardown (the leader's own reaper for the integration / grader sandboxes; core's run teardown for a worker, where the staged incremental-upload loop and the belt upload ride it), so without it the sandbox and whatever the leader started inside it leak until the backend's own timeout reaps them — and a host-shell env has no such timeout. Deploy a factory whose env implements RemoteExecutionEnv.destroy for the leader lane.`);
42
46
  }
43
- return async () => void (await env.destroy());
44
47
  }
45
48
  export async function stageWorkerEnv(env, opts) {
46
49
  const { seedCmd, repo, branch, uploadScriptPath, uploadScript } = opts;
@@ -133,13 +136,32 @@ export function createCoordinatorPush(durableRemote, targetRef, ident) {
133
136
  export function createLeaderRunner(cfg) {
134
137
  const W = cfg.workspace ?? "/home/user";
135
138
  const repo = `${W}/repo`;
139
+ const uploadLoopScript = buildUploadLoopScript({ workDir: W, periodSec: UPLOAD_LOOP_PERIOD_SEC });
140
+ const startUploadLoop = async (env, workerId) => {
141
+ try {
142
+ const wr = await env.writeFile(uploadLoopScriptPath(W), uploadLoopScript);
143
+ if (!wr.ok)
144
+ throw wr.error;
145
+ await sh(env)(uploadLoopCommand(W, "start"));
146
+ }
147
+ catch (e) {
148
+ cfg.logger?.warn?.("leader_upload_loop_start_failed", { workerId, err: redactHead(e instanceof Error ? e.message : String(e), 2000) });
149
+ }
150
+ };
151
+ const stopUploadLoop = async (env, workerId) => {
152
+ try {
153
+ await sh(env)(uploadLoopCommand(W, "stop"));
154
+ }
155
+ catch (e) {
156
+ cfg.logger?.warn?.("leader_upload_loop_stop_failed", { workerId, err: redactHead(e instanceof Error ? e.message : String(e), 2000) });
157
+ }
158
+ };
136
159
  const ident = cfg.git ?? { name: "leader", email: "leader@local" };
137
160
  const { leaderTimeoutMs, workerLimits, presignTtlSec, resourceSuspend: resourceCfg } = leaderResourceConfig();
138
161
  const { repairRounds, repairBudgetUsd, repairLoopOn, measureGatesOn, repairLoopAttempts, oracleFlakyK, replanBudgetUsd, } = leaderLoopConfig({ repairRounds: cfg.repairRounds });
139
162
  const onNoticeSeat = createEngineNoticeSeat(cfg.logger);
140
163
  const governanceSeat = cfg.governance ?? {};
141
164
  const deploymentPostureSeat = () => cfg.deploymentPosture?.() ?? {};
142
- const unattendedLeg = () => createUnattendedLeg(cfg.unattendedPermissionMode, deploymentPostureSeat());
143
165
  const tracerSeat = cfg.tracer ? { tracer: cfg.tracer } : {};
144
166
  const usageWindowSeat = cfg.usageWindows && cfg.usageWindowStore ? { usageWindows: cfg.usageWindows, usageWindowStore: cfg.usageWindowStore } : {};
145
167
  const promptSourceSeat = cfg.promptSource ? { promptSource: cfg.promptSource } : {};
@@ -176,7 +198,7 @@ export function createLeaderRunner(cfg) {
176
198
  ...principalSpec(),
177
199
  sessionId: `leader-repair-${Date.now()}-${round}`,
178
200
  limits: { maxCostUsd: repairBudgetUsd, maxWalltimeMs: Math.max(300_000, Math.floor(leaderTimeoutMs / 2)) },
179
- }, unattendedLeg()))
201
+ }))
180
202
  .result();
181
203
  if (res.terminal.kind !== "completed")
182
204
  throw new Error(`repair round ${round} did not complete (status=${outwardPlane(res.terminal).status})`);
@@ -311,7 +333,7 @@ export function createLeaderRunner(cfg) {
311
333
  const script = buildUploadScript({ repoDir: repo, baseSha, putUrl, workDir: W });
312
334
  const stage = async (env) => {
313
335
  await stageWorkerEnv(env, { seedCmd, repo, branch: sub.branch, uploadScriptPath: `${W}/.leader-upload.sh`, uploadScript: script });
314
- void sh(env)(`nohup sh -c 'while true; do sleep 90; sh ${W}/.leader-upload.sh >/dev/null 2>&1 || true; done' </dev/null >/dev/null 2>&1 &`).catch(() => { });
336
+ await startUploadLoop(env, sub.workerId);
315
337
  };
316
338
  return {
317
339
  workerId: sub.workerId, sessionId, branch: sub.branch,
@@ -320,12 +342,17 @@ export function createLeaderRunner(cfg) {
320
342
  brain: cfg.brain, models: cfg.models, roles: cfg.roles, pricing: cfg.pricing, ...onNoticeSeat, ...governanceSeat, ...deploymentPostureSeat(), ...tracerSeat, ...usageWindowSeat, ...promptSourceSeat, ...onErrorSeat,
321
343
  ...(cfg.toolResultStore ? { toolResultStore: cfg.toolResultStore } : {}),
322
344
  ...(cfg.sessionStore ? { sessionStore: cfg.sessionStore } : {}),
323
- executionEnvFactory: async (ctx) => withStaging(await envFactory(ctx), stage, async (e) => {
324
- await sh(e)(`cd ${repo} && git add -A && git -c user.name='leader-belt' -c user.email='belt@leader' commit -qm 'belt: auto-commit uncommitted worker output' || true; sh ${W}/.leader-upload.sh`);
325
- }),
345
+ executionEnvFactory: async (ctx) => {
346
+ const env = await envFactory(ctx);
347
+ assertLeaderOwnedEnvReapable(env, "leader worker sandbox");
348
+ return withStaging(env, stage, async (e) => {
349
+ await stopUploadLoop(e, sub.workerId);
350
+ await sh(e)(`cd ${repo} && git add -A && git -c user.name='leader-belt' -c user.email='belt@leader' commit -qm 'belt: auto-commit uncommitted worker output' || true; sh ${W}/.leader-upload.sh`);
351
+ });
352
+ },
326
353
  })),
327
354
  fetchDiff: () => fetchUploadedDiff(getUrl),
328
- spec: withUnattendedLeg({ ...workerLimits, ...sub.spec, ...durableSpec, ...resourceSpec, ...principalSpec(), objective: `${sub.spec.objective}${uploadStepSuffix(W)}` }, unattendedLeg()),
355
+ spec: withUnattendedLeg({ ...workerLimits, ...sub.spec, ...durableSpec, ...resourceSpec, ...principalSpec(), objective: `${sub.spec.objective}${uploadStepSuffix(W)}` }),
329
356
  };
330
357
  }
331
358
  if (!cfg.e2bApiKey)
@@ -340,7 +367,7 @@ export function createLeaderRunner(cfg) {
340
367
  runner: keepCtxWarm(new Runner({ brain: cfg.brain, models: cfg.models, roles: cfg.roles, pricing: cfg.pricing, ...onNoticeSeat, ...governanceSeat, ...deploymentPostureSeat(), ...tracerSeat, ...usageWindowSeat, ...promptSourceSeat, ...onErrorSeat, ...(cfg.toolResultStore ? { toolResultStore: cfg.toolResultStore } : {}), ...(cfg.sessionStore ? { sessionStore: cfg.sessionStore } : {}), executionEnv: env })),
341
368
  diffEnv: env,
342
369
  destroy: () => env.destroy().then(() => { }),
343
- spec: withUnattendedLeg({ ...workerLimits, ...sub.spec, ...durableSpec, ...resourceSpec, ...principalSpec() }, unattendedLeg()),
370
+ spec: withUnattendedLeg({ ...workerLimits, ...sub.spec, ...durableSpec, ...resourceSpec, ...principalSpec() }),
344
371
  };
345
372
  };
346
373
  const provisionIntegrationSandbox = async () => {
@@ -382,7 +409,6 @@ export function createLeaderRunner(cfg) {
382
409
  const deps = {
383
410
  plan,
384
411
  provisionWorker,
385
- unattendedLeg: unattendedLeg(),
386
412
  provisionIntegrationSandbox,
387
413
  push,
388
414
  testCmd: body.testCmd,
@@ -25,10 +25,6 @@ export declare const FAIL_OPEN_TAGS: {
25
25
  readonly cls: "F";
26
26
  readonly note: "S-297(`http/routes/sessions.ts lastLegMcpFor`,`/mcp` 面板的可选键 `lastLegMcp`):按会话取最近 run 的索引读 / 那条 run 的账本读**抛错或超期** ⇒ 该键缺席,面板本体照常 200。放行的最坏后果=这一屏少一格「上一次真挂上了什么」(壳退回翻账本),不是门、不是权限、不是凭证 —— 而反方向(让附加键的读失败打掉整张面板)会给一口此前零 MCP 配置下根本不碰账本的**既有**读面新加一条挂掉的理由。缺席本就是本键 wire 上的一等状态(消费端必须会读),所以这条兜底不产生任何新的消费端形态;计数让「某台部署的账本一直读不出来」显形。";
27
27
  };
28
- readonly "server.unattended-leg.mode-releases-protective-asks": {
29
- readonly cls: "P-DEBT";
30
- readonly note: "S-877 四续 ⑧(候裁 18 D 的已披露残余,`unattended-permission-mode.ts` 的 `createUnattendedLeg`):无人值守腿(leader worker / 修复腿 / run-local / 续跑重挂)的旋钮 `UNATTENDED_PERMISSION_MODE` 缺省 `bypassPermissions` 释放六类保护问(`probe` / `tool_marks_mcp` / `tool_marks_a2a` / `transcript_write` / `rule_unreadable` / `artifact_shared_read`;写保护一类已改拒)—— 7.105.0 这些腿上它们照问、无人答即拒。放行的最坏后果=无人值守的写 / 读 / MCP·A2A 标记工具调用不再被这几道保护问拦下,`rule_unreadable` 是「规则店读不出照放」(安全轴 fail-open 方向)。core 对这几类没有拒座;S-906(core 7.37.0 按类作答)到货后本臂与旋钮一并硬删。计数 = 铸出的这类腿条数,detail = `mode=<词> released=<类…>`。";
31
- };
32
28
  readonly "server.workflow-park-index.sync-failed": {
33
29
  readonly cls: "P-DEBT";
34
30
  readonly note: "S-185 车CM(codex r1-F2 [high],验真后登记):workflow 出身 park 的 join 索引(`workflow_agent_session` / local 账本)在**一次成功的 run 写之后**同步失败 ⇒ 该次同步被吞,重试要等这条 run 的**下一次**写。而 park 恰好常常是这条 run 的**最后**一次写(park = run 终局 `failed`)⇒ 那条 park 可能永久 join 不到,`/decide` 结构性退回本批之前的行为(409 `conflict.resume_context_unavailable`)——**方向是 fail-closed**(不误配、不伪造、不放行任何东西),丢的是「人能不能把这条批准送出去」这个能力。归 P 类的理由是它决定**车道选择**(缺行 ⇒ 选中 legacy 腿),归 DEBT 的理由是真正的解要一条**按子代 checkpoint 键的耐久决议回执 + 重启期回填**(与 codex r1-F3 同一件欠账,另车)。计数非零 = 有 park 因此决不掉,运维要按 detail 里的 runId 去查。";
@@ -295,7 +291,7 @@ export declare const FAIL_OPEN_TAGS: {
295
291
  };
296
292
  readonly "server.approval-card.closed-word-out-of-set": {
297
293
  readonly cls: "F";
298
- readonly note: "S-249(`approval-card.ts` 的边界窄读族;今天两个成员:`readRuleOffersAbsence`,与 S-721 起的 `readClosedWordSeat` —— 一座一只 core 成员筛,座是参数:`mandate`(core 7.32.0 #1092,活卡帧 / 活挂起行 / 运维队列行同一只,闭集筛 = core `isPersistedRuleMandate`)与 S-755 起的 `origin`(运维队列行读 park 行孪生座,闭集筛 = core `isAskOrigin`,与 inbox 行 core 投影处同一只筛);detail 前缀 `ruleOffersAbsence=` / `mandate=` / `origin=` 判别是哪一座):活卡帧 / `card_json` / (S-700 起)运维队列行(`parkRowFacts` 读寄存行上的 park 孪生座;队列是热读面 ⇒ 同一行每读一次计一次)的一个**闭集词**座上,键在场而词不在本仓认得的那张表里(世代差,或第三方 producer)⇒ 该座**按缺席**处置。放行的最坏后果=卡上少一行「为什么这次没有规则候选」的解释(壳退回不显示该行),它**不改变任何裁决**——门在引擎里早已判完,本座是展示/观测面;反方向(把一个消费端词表外的值原样上卡)更坏:下游按闭集写的分支会落进 default 臂或渲染出生词。本臂存在的全部理由是**判别**:修前「上游发了个生词」与「上游根本没发这个键」在遥测上同形,而前者意味着本仓的词表该跟着 core 加词了(型面另有两向编译围栏,但那只在**重新编译**时说话,跑在线上的那一版不会自己红)。detail 带座名与那个词(经 redactSecrets 截 40);数 / 布尔等原始值原样,对象 / 数组 / 函数 / null 只报形名(`<object>` / `<array>` / `<function>` / `<null>`,S-700 codex r1:格式化从不调用值自己的转换方法 —— 持久层里一格 `{\"toString\":null}` 曾让诊断本身抛出、打掉整只队列)。";
294
+ readonly note: "S-249(`approval-card.ts` 的边界窄读族;今天三个成员:`readRuleOffersAbsence`、S-934 ② 起的 `readAskClassPair`(core 7.37.0 #1214 的类对 `askClass` / `classes`,活卡帧 / 呈卡帧的卡 / 运维队列行同一只,成员筛 = core `isAskClass`,两键同在同缺,半对 / 首员不符同样算读不出;detail 前缀 `askClass=` / `classes=`),与 S-721 起的 `readClosedWordSeat` —— 一座一只 core 成员筛,座是参数:`mandate`(core 7.32.0 #1092,活卡帧 / 活挂起行 / 运维队列行同一只,闭集筛 = core `isPersistedRuleMandate`)与 S-755 起的 `origin`(运维队列行读 park 行孪生座,闭集筛 = core `isAskOrigin`,与 inbox 行 core 投影处同一只筛);detail 前缀 `ruleOffersAbsence=` / `mandate=` / `origin=` 判别是哪一座):活卡帧 / `card_json` / (S-700 起)运维队列行(`parkRowFacts` 读寄存行上的 park 孪生座;队列是热读面 ⇒ 同一行每读一次计一次)的一个**闭集词**座上,键在场而词不在本仓认得的那张表里(世代差,或第三方 producer)⇒ 该座**按缺席**处置。放行的最坏后果=卡上少一行「为什么这次没有规则候选」的解释(壳退回不显示该行),它**不改变任何裁决**——门在引擎里早已判完,本座是展示/观测面;反方向(把一个消费端词表外的值原样上卡)更坏:下游按闭集写的分支会落进 default 臂或渲染出生词。本臂存在的全部理由是**判别**:修前「上游发了个生词」与「上游根本没发这个键」在遥测上同形,而前者意味着本仓的词表该跟着 core 加词了(型面另有两向编译围栏,但那只在**重新编译**时说话,跑在线上的那一版不会自己红)。detail 带座名与那个词(经 redactSecrets 截 40);数 / 布尔等原始值原样,对象 / 数组 / 函数 / null 只报形名(`<object>` / `<array>` / `<function>` / `<null>`,S-700 codex r1:格式化从不调用值自己的转换方法 —— 持久层里一格 `{\"toString\":null}` 曾让诊断本身抛出、打掉整只队列)。";
299
295
  };
300
296
  readonly "server.pg-pool.idle-client-error": {
301
297
  readonly cls: "F";
@@ -16,10 +16,6 @@ export const FAIL_OPEN_TAGS = {
16
16
  cls: "F",
17
17
  note: "S-297(`http/routes/sessions.ts lastLegMcpFor`,`/mcp` 面板的可选键 `lastLegMcp`):按会话取最近 run 的索引读 / 那条 run 的账本读**抛错或超期** ⇒ 该键缺席,面板本体照常 200。放行的最坏后果=这一屏少一格「上一次真挂上了什么」(壳退回翻账本),不是门、不是权限、不是凭证 —— 而反方向(让附加键的读失败打掉整张面板)会给一口此前零 MCP 配置下根本不碰账本的**既有**读面新加一条挂掉的理由。缺席本就是本键 wire 上的一等状态(消费端必须会读),所以这条兜底不产生任何新的消费端形态;计数让「某台部署的账本一直读不出来」显形。",
18
18
  },
19
- "server.unattended-leg.mode-releases-protective-asks": {
20
- cls: "P-DEBT",
21
- note: "S-877 四续 ⑧(候裁 18 D 的已披露残余,`unattended-permission-mode.ts` 的 `createUnattendedLeg`):无人值守腿(leader worker / 修复腿 / run-local / 续跑重挂)的旋钮 `UNATTENDED_PERMISSION_MODE` 缺省 `bypassPermissions` 释放六类保护问(`probe` / `tool_marks_mcp` / `tool_marks_a2a` / `transcript_write` / `rule_unreadable` / `artifact_shared_read`;写保护一类已改拒)—— 7.105.0 这些腿上它们照问、无人答即拒。放行的最坏后果=无人值守的写 / 读 / MCP·A2A 标记工具调用不再被这几道保护问拦下,`rule_unreadable` 是「规则店读不出照放」(安全轴 fail-open 方向)。core 对这几类没有拒座;S-906(core 7.37.0 按类作答)到货后本臂与旋钮一并硬删。计数 = 铸出的这类腿条数,detail = `mode=<词> released=<类…>`。",
22
- },
23
19
  "server.workflow-park-index.sync-failed": {
24
20
  cls: "P-DEBT",
25
21
  note: "S-185 车CM(codex r1-F2 [high],验真后登记):workflow 出身 park 的 join 索引(`workflow_agent_session` / local 账本)在**一次成功的 run 写之后**同步失败 ⇒ 该次同步被吞,重试要等这条 run 的**下一次**写。而 park 恰好常常是这条 run 的**最后**一次写(park = run 终局 `failed`)⇒ 那条 park 可能永久 join 不到,`/decide` 结构性退回本批之前的行为(409 `conflict.resume_context_unavailable`)——**方向是 fail-closed**(不误配、不伪造、不放行任何东西),丢的是「人能不能把这条批准送出去」这个能力。归 P 类的理由是它决定**车道选择**(缺行 ⇒ 选中 legacy 腿),归 DEBT 的理由是真正的解要一条**按子代 checkpoint 键的耐久决议回执 + 重启期回填**(与 codex r1-F3 同一件欠账,另车)。计数非零 = 有 park 因此决不掉,运维要按 detail 里的 runId 去查。",
@@ -268,7 +264,7 @@ export const FAIL_OPEN_TAGS = {
268
264
  "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} 的诚实替换同形不可分,故要有自己的计数/探针;影子扫不计;计数=多少次整段吃尾。" },
269
265
  "server.approval-card.closed-word-out-of-set": {
270
266
  cls: "F",
271
- note: "S-249(`approval-card.ts` 的边界窄读族;今天两个成员:`readRuleOffersAbsence`,与 S-721 起的 `readClosedWordSeat` —— 一座一只 core 成员筛,座是参数:`mandate`(core 7.32.0 #1092,活卡帧 / 活挂起行 / 运维队列行同一只,闭集筛 = core `isPersistedRuleMandate`)与 S-755 起的 `origin`(运维队列行读 park 行孪生座,闭集筛 = core `isAskOrigin`,与 inbox 行 core 投影处同一只筛);detail 前缀 `ruleOffersAbsence=` / `mandate=` / `origin=` 判别是哪一座):活卡帧 / `card_json` / (S-700 起)运维队列行(`parkRowFacts` 读寄存行上的 park 孪生座;队列是热读面 ⇒ 同一行每读一次计一次)的一个**闭集词**座上,键在场而词不在本仓认得的那张表里(世代差,或第三方 producer)⇒ 该座**按缺席**处置。放行的最坏后果=卡上少一行「为什么这次没有规则候选」的解释(壳退回不显示该行),它**不改变任何裁决**——门在引擎里早已判完,本座是展示/观测面;反方向(把一个消费端词表外的值原样上卡)更坏:下游按闭集写的分支会落进 default 臂或渲染出生词。本臂存在的全部理由是**判别**:修前「上游发了个生词」与「上游根本没发这个键」在遥测上同形,而前者意味着本仓的词表该跟着 core 加词了(型面另有两向编译围栏,但那只在**重新编译**时说话,跑在线上的那一版不会自己红)。detail 带座名与那个词(经 redactSecrets 截 40);数 / 布尔等原始值原样,对象 / 数组 / 函数 / null 只报形名(`<object>` / `<array>` / `<function>` / `<null>`,S-700 codex r1:格式化从不调用值自己的转换方法 —— 持久层里一格 `{\"toString\":null}` 曾让诊断本身抛出、打掉整只队列)。",
267
+ note: "S-249(`approval-card.ts` 的边界窄读族;今天三个成员:`readRuleOffersAbsence`、S-934 ② 起的 `readAskClassPair`(core 7.37.0 #1214 的类对 `askClass` / `classes`,活卡帧 / 呈卡帧的卡 / 运维队列行同一只,成员筛 = core `isAskClass`,两键同在同缺,半对 / 首员不符同样算读不出;detail 前缀 `askClass=` / `classes=`),与 S-721 起的 `readClosedWordSeat` —— 一座一只 core 成员筛,座是参数:`mandate`(core 7.32.0 #1092,活卡帧 / 活挂起行 / 运维队列行同一只,闭集筛 = core `isPersistedRuleMandate`)与 S-755 起的 `origin`(运维队列行读 park 行孪生座,闭集筛 = core `isAskOrigin`,与 inbox 行 core 投影处同一只筛);detail 前缀 `ruleOffersAbsence=` / `mandate=` / `origin=` 判别是哪一座):活卡帧 / `card_json` / (S-700 起)运维队列行(`parkRowFacts` 读寄存行上的 park 孪生座;队列是热读面 ⇒ 同一行每读一次计一次)的一个**闭集词**座上,键在场而词不在本仓认得的那张表里(世代差,或第三方 producer)⇒ 该座**按缺席**处置。放行的最坏后果=卡上少一行「为什么这次没有规则候选」的解释(壳退回不显示该行),它**不改变任何裁决**——门在引擎里早已判完,本座是展示/观测面;反方向(把一个消费端词表外的值原样上卡)更坏:下游按闭集写的分支会落进 default 臂或渲染出生词。本臂存在的全部理由是**判别**:修前「上游发了个生词」与「上游根本没发这个键」在遥测上同形,而前者意味着本仓的词表该跟着 core 加词了(型面另有两向编译围栏,但那只在**重新编译**时说话,跑在线上的那一版不会自己红)。detail 带座名与那个词(经 redactSecrets 截 40);数 / 布尔等原始值原样,对象 / 数组 / 函数 / null 只报形名(`<object>` / `<array>` / `<function>` / `<null>`,S-700 codex r1:格式化从不调用值自己的转换方法 —— 持久层里一格 `{\"toString\":null}` 曾让诊断本身抛出、打掉整只队列)。",
272
268
  },
273
269
  "server.pg-pool.idle-client-error": {
274
270
  cls: "F",
@@ -1,6 +1,6 @@
1
1
  import type { Logger } from "../observability/logger.js";
2
2
  import type { AskState } from "../approval-ask-machine.js";
3
- import type { AskRow, AskTerminalClaimIntent, AskTerminalClaimOutcome, AskTransitionPatch, ApprovalAskStore, BatchRow, BindGateInput, BindResult, DecideAskInput, DecideResult, EnsureAskResult, ExpireResult, NewAskRow } from "./approval-ask-store-sql.js";
3
+ import type { AskCallCoordinate, AskRow, AskTerminalClaimIntent, AskTerminalClaimOutcome, AskTransitionPatch, ApprovalAskStore, BatchRow, BindGateInput, BindResult, DecideAskInput, DecideResult, EnsureAskResult, ExpireResult, NewAskRow } from "./approval-ask-store-sql.js";
4
4
  /** 数据根下的目录名。**按店命名**,与兄弟 File 店同规(`approval-nonces` / `approval-ask-audit` /
5
5
  * `approval-exemptions` / `resume-anchors` / `checkpoint-ctx` / …)—— 首版写的是族名 `approvals`,而那
6
6
  * 正是读者会去找**持久 park 行**的地方(它们其实在 `checkpoint-ctx`),且持久数据面的名字按硬 breaking
@@ -175,6 +175,7 @@ export declare class FileApprovalAskStore implements ApprovalAskStore {
175
175
  listByState(state: AskState, limit: number): Promise<AskRow[]>;
176
176
  listPendingByTask(taskId: string, signal?: AbortSignal): Promise<AskRow[]>;
177
177
  listPendingBySession(sessionId: string, owner: string | null, signal?: AbortSignal): Promise<AskRow[]>;
178
+ listByCall(call: AskCallCoordinate): Promise<AskRow[]>;
178
179
  getAsk(askId: string): Promise<AskRow | null>;
179
180
  getByIdempotencyKey(taskId: string, key: string): Promise<AskRow | null>;
180
181
  getBatch(batchId: string): Promise<BatchRow | null>;