@sema-agent/server 7.6.0 → 7.7.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.
@@ -7,6 +7,7 @@ import { stablePrompt } from "./prompt.js";
7
7
  import { createTeamTool, teamCoordinatorPrompt, getTeam, teamNames } from "./team.js";
8
8
  import { createWebSearchBackend, webSearchConfigFromSettings } from "../plugins/web-search.js";
9
9
  import { skillsForScenario } from "./skills.js";
10
+ import { pickHandsRunner } from "./hands-lane.js";
10
11
  /**
11
12
  * Sema product identity, prepended to the core default base for the `default` scenario when
12
13
  * {@link ScenarioDeps.brandIdentity} is on. Honest about the substrate (the engine runs whichever model
@@ -88,6 +89,11 @@ export function codePromptProvider(brand) {
88
89
  // [2400] CAPS-OPS-11:autonomousPromptProvider 别名导出已随场景退役删除(真身=codePromptProvider)。
89
90
  // 其 `@deprecated` JSDoc 曾残留于此,被 tsc 误挂到下方 buildScenarios(三处调用点全告 deprecated
90
91
  // ——TODO-3 立案的「迁移」实为孤儿注释,随删除清账;buildScenarios 本身非退役面)。
92
+ /** #196 唯一取用口:场景按自己的判别位取子任务 Runner。场景体内一律不直接摸 `deps.subRunner` /
93
+ * `deps.handslessSubRunner` —— 两处各挑一次就是漂移的成因,判别位必须是唯一开关。 */
94
+ function subRunnerFor(deps, hands) {
95
+ return pickHandsRunner(hands, { full: deps.subRunner, handsless: deps.handslessSubRunner });
96
+ }
91
97
  export function buildScenarios(deps) {
92
98
  // The default scenario filters skills by "default" — NOT by req.scenario (which may be an unknown name
93
99
  // that fell back here): the bundle must be a coherent "default" set, matching how main.ts resolves an
@@ -137,7 +143,9 @@ export function buildScenarios(deps) {
137
143
  // core 1.382 checkpointStore/ensureChildSessionDurable = design/153 parked 状态机的「同车必接」
138
144
  // 一对([1561] 提货单①②,ScenarioDeps 字段注释详述);两者任一缺席 = 净是 pre-153 行为。
139
145
  subagent: {
140
- runner: deps.subRunner,
146
+ // #196:default/code 的委派子代**要**手(CC parity;main.ts subRunner 注里那条 "subagent
147
+ // couldn't run pwd" 缺陷就是这条腿缺手),故按本场景判别位取 full 支。
148
+ runner: subRunnerFor(deps, "full"),
141
149
  background: {
142
150
  registry: defaultTaskRegistry,
143
151
  owner: "unattributed",
@@ -158,6 +166,9 @@ export function buildScenarios(deps) {
158
166
  ...(webSearch ? { webSearch } : {}),
159
167
  }).filter((t) => t.name !== "WebFetch" || deps.requirePrincipal !== true),
160
168
  ],
169
+ // default/code 是手带并集的**故意消费者**(上方 rank-1 BLOCKER 注:CC agent loop MUST have the
170
+ // full-body roster)——#196 判别位在这里表态 full,收窄只落在声明了 none 的场景上。
171
+ hands: "full",
161
172
  skills: skillsForScenario(deps.skills, "default"),
162
173
  // Local/TOC deployment brands the neutral default scenario as Sema (so the local engine answers "who
163
174
  // are you" as Sema, not the model's hallucinated identity). Cloud leaves it neutral unless opted in.
@@ -190,13 +201,15 @@ export function buildScenarios(deps) {
190
201
  // would let runtime-distributed content rewrite the deployment-pinned system prompt and break the
191
202
  // stablePrompt STABLE→VARIABLE prefix-cache discipline (design/12/17).
192
203
  scan: (req) => {
193
- if (!deps.repoClient)
194
- throw new HttpError(501, "scan scenario needs GIT_API_BASEURL configured");
204
+ const repoClient = requireRepoClient(deps, "scan");
195
205
  if (typeof req.repo !== "string" || !req.repo.trim()) {
196
206
  throw new HttpError(400, "scan scenario requires a 'repo' field (owner/name or repo URL)");
197
207
  }
198
208
  return {
199
- tools: [...repoToolsFor(deps.repoClient, parseRepo(req.repo)), nowTool()],
209
+ tools: [...repoToolsFor(repoClient, parseRepo(req.repo)), nowTool()],
210
+ // #196:头注的「工具集即边界」自此在**最终 roster** 上成立(修前 core 把手带 band 并集进来,
211
+ // clone-free 承诺只存在于这份声明里)。
212
+ hands: "none",
200
213
  skills: skillsForScenario(deps.skills, "scan"),
201
214
  };
202
215
  },
@@ -212,8 +225,11 @@ export function buildScenarios(deps) {
212
225
  }
213
226
  const objective = typeof req.objective === "string" ? req.objective : "Discuss the task.";
214
227
  const businessContext = typeof req.businessContext === "string" ? req.businessContext : undefined;
228
+ // #196:成员/synthesizer 是纯讨论人格(模板声明零工具),跑在无手 subRunner 上。
229
+ const hands = "none";
215
230
  return {
216
- tools: [createTeamTool(deps.subRunner, template, { objective, businessContext })],
231
+ tools: [createTeamTool(subRunnerFor(deps, hands), template, { objective, businessContext })],
232
+ hands,
217
233
  skills: skillsForScenario(deps.skills, "team"),
218
234
  promptProvider: teamCoordinatorPrompt,
219
235
  };
@@ -224,6 +240,11 @@ export function buildScenarios(deps) {
224
240
  export function selectScenario(scenarios, name) {
225
241
  return (name && scenarios[name]) || scenarios.default;
226
242
  }
243
+ /** 把属主的判别式摊平成 wire 上的两个键(可用臂整键省略原因,契约=「缺席即没有理由」)。 */
244
+ function availabilityFields(deps, toolset) {
245
+ const verdict = scenarioAvailability(deps, toolset);
246
+ return verdict.available ? { available: true } : { available: false, unavailableReason: verdict.reason };
247
+ }
227
248
  /** 内建五场景详情。default/code/team 工厂对良性请求无副作用可真调(拿真实工具名单);code-review/scan 是
228
249
  * fail-loud 语义(缺 GIT_API 配置/principal 即 throw)→ 探针失败落静态表兜底(表↔工厂一致性由测试锁:
229
250
  * 测试喂 fake deps 真调工厂对账工具名)。enabled 对内建恒 true(约定②)。 */
@@ -237,7 +258,12 @@ export function builtinScenarioDetails(scenarios, deps) {
237
258
  }
238
259
  };
239
260
  const REPO_TOOLS = ["repo_tree", "repo_read_file", "repo_pull_diff", "Now"];
240
- const mk = (name, summary, toolset, tools, promptSummary) => ({ name, source: "builtin", builtin: true, summary, toolset, tools, promptSummary, enabled: true });
261
+ const mk = (name, summary, toolset, tools, promptSummary) => ({
262
+ name, source: "builtin", builtin: true, summary, toolset, tools, promptSummary, enabled: true,
263
+ // [C132]:可用性与上面的静态回退表分家 —— 回退表答的是「工具面长什么样」(缺配置时也照画),
264
+ // 这两个键答的是「现在点下去会不会 501」。同一份属主判据,内建与 center 两条产线共用。
265
+ ...availabilityFields(deps, toolset),
266
+ });
241
267
  return {
242
268
  code: mk("code", "编码主场景([891] 出厂缺省;CC 编码 persona 蒸馏版,default 全量工具面;终验旋钮独立)", "full-body", probe("code", {}, undefined, ["Now"]), deps.brandIdentity
243
269
  ? "Sema 产品身份 + core 编码完全体提示词(CODE_AGENT persona+自治自检;CC 血统对表)"
@@ -250,7 +276,7 @@ export function builtinScenarioDetails(scenarios, deps) {
250
276
  }
251
277
  /** center 条目详情(有效性判定与 centerScenarios 完全同款:无效条目既不进 overlay 也不进详情——
252
278
  * 保证约定①「详情显示的来源=运行实际用的定义」永不错位)。 */
253
- export function centerScenarioDetails(specs, builtinNames) {
279
+ export function centerScenarioDetails(specs, builtinNames, deps) {
254
280
  const out = {};
255
281
  for (const spec of specs ?? []) {
256
282
  if (spec.enabled === false)
@@ -270,24 +296,62 @@ export function centerScenarioDetails(specs, builtinNames) {
270
296
  // 恒空串;字段本身保留(builtin 条目仍有真值,wire 形不变)。
271
297
  promptSummary: "",
272
298
  enabled: true,
299
+ // [C132]:center 声明的 repo 型场景与内建的 scan/code-review 受**同一份**判据管辖
300
+ // ——overlay 不是判据的第二属主。
301
+ ...availabilityFields(deps, spec.toolset),
273
302
  };
274
303
  }
275
304
  return out;
276
305
  }
277
306
  const TOOLSETS = {
278
- none: { build: () => [nowTool()] },
307
+ none: { build: () => [nowTool()], hands: "none" },
279
308
  "repo-readonly": {
280
309
  requiresRepo: true,
310
+ hands: "none",
281
311
  build: (deps, req) => {
282
- if (!deps.repoClient)
283
- throw new HttpError(501, "this scenario needs GIT_API_BASEURL configured");
312
+ const repoClient = requireRepoClient(deps, "this");
284
313
  if (typeof req.repo !== "string" || !req.repo.trim()) {
285
314
  throw new HttpError(400, "this scenario requires a 'repo' field (owner/name or repo URL)");
286
315
  }
287
- return [...repoToolsFor(deps.repoClient, parseRepo(req.repo)), nowTool()];
316
+ return [...repoToolsFor(repoClient, parseRepo(req.repo)), nowTool()];
288
317
  },
289
318
  },
290
319
  };
320
+ const SCENARIO_AVAILABLE = { available: true };
321
+ /**
322
+ * 🔴 场景可用性的**唯一属主**。列举面(`ScenarioDetail.available` / `unavailableReason`)与请求面
323
+ * ({@link requireRepoClient} 的 501 拒绝臂,三处调用点)都只从这里取值——两处各写一份就是本仓反复
324
+ * 吃过的「同一语义两个属主」病:判据一漂,列举面开始说谎而没人先红。一致性由 capabilities.test 的
325
+ * **对表格**逐名钉住(`available:false` ⟺ 良性请求真吃 501),而不是靠这段注释。
326
+ *
327
+ * 判据键 = **toolset**:内建详情与 center 条目都带这个字段,故两条产线天然共用同一份判据。
328
+ * `requiresRepo` 的 toolset 需要部署配好 git 后端(`GIT_API_BASEURL` ⇒ `deps.repoClient`)。
329
+ * 词表外的 toolset(内建的 `full-body`/`team`)不依赖后端 ⇒ 恒可用。
330
+ */
331
+ export function scenarioAvailability(deps, toolset) {
332
+ if (TOOLSETS[toolset]?.requiresRepo === true && deps.repoClient === undefined) {
333
+ return { available: false, reason: "git_client_unconfigured" };
334
+ }
335
+ return SCENARIO_AVAILABLE;
336
+ }
337
+ /** 拒绝文案的唯一属主:闭集穷举 switch(新增原因词不在这里表态即编译红)。 */
338
+ export function scenarioUnavailableMessage(reason, scenarioLabel) {
339
+ switch (reason) {
340
+ case "git_client_unconfigured":
341
+ return `${scenarioLabel} scenario needs GIT_API_BASEURL configured`;
342
+ }
343
+ }
344
+ /** repo 型场景取 git 客户端的唯一口(三处场景工厂同源)。不可用 ⇒ 按属主给的原因与文案 501 响亮拒。 */
345
+ function requireRepoClient(deps, scenarioLabel) {
346
+ const client = deps.repoClient;
347
+ if (client !== undefined)
348
+ return client;
349
+ const verdict = scenarioAvailability(deps, "repo-readonly");
350
+ // 判成 available 却没有客户端 = 属主与消费面脱钩(对表格会先红)。此处仍 fail-loud,不静默放行。
351
+ throw new HttpError(501, verdict.available
352
+ ? `${scenarioLabel} scenario has no repo client although the availability owner reports it available (wiring defect)`
353
+ : scenarioUnavailableMessage(verdict.reason, scenarioLabel));
354
+ }
291
355
  export const SCENARIO_NAME_RE = /^[a-z][a-z0-9-]{1,31}$/;
292
356
  // ([1053] registry-core 0.10.15:SCENARIO_PROMPT_MAX 随 ScenarioEntry.prompt 一并退役)
293
357
  /**
@@ -316,6 +380,7 @@ export function centerScenarios(deps, specs, builtinNames, logger) {
316
380
  const name = spec.name;
317
381
  overlay[name] = (req) => ({
318
382
  tools: toolset.build(deps, req),
383
+ hands: toolset.hands, // #196:表态随 toolset 走(center 声明面不可自选 hands —— 能力仍是代码定死的)
319
384
  skills: skillsForScenario(deps.skills, name),
320
385
  });
321
386
  if (builtinNames.includes(name))
@@ -324,13 +389,12 @@ export function centerScenarios(deps, specs, builtinNames, logger) {
324
389
  return { overlay, shadows };
325
390
  }
326
391
  function codeReview(deps, req) {
327
- if (!deps.repoClient)
328
- throw new HttpError(501, "code-review scenario needs GIT_API_BASEURL configured");
392
+ const repoClient = requireRepoClient(deps, "code-review");
329
393
  if (typeof req.repo !== "string" || !req.repo.trim()) {
330
394
  throw new HttpError(400, "code-review scenario requires a 'repo' field (owner/name or repo URL)");
331
395
  }
332
396
  const coords = parseRepo(req.repo);
333
- const repoTools = repoToolsFor(deps.repoClient, coords);
397
+ const repoTools = repoToolsFor(repoClient, coords);
334
398
  const skills = skillsForScenario(deps.skills, "code-review");
335
399
  const objective = typeof req.objective === "string" ? req.objective : "Review this repository.";
336
400
  // Tiering: `council: true` runs the multi-lens council (L1 parallel lenses + L3 arbiter) — for
@@ -340,11 +404,14 @@ function codeReview(deps, req) {
340
404
  // (deterministic + cheaper + cacheable; see design/35). For OPEN-ENDED tasks where the decomposition
341
405
  // isn't known, core's `createSubagentTool` (model-driven self-delegation, with maxDepth / isolated
342
406
  // context / compressed-report guardrails) is the right tool — wire it in a dedicated scenario then.
407
+ // #196:两条腿(直评 / council)都声明 none —— 直评腿的 lead 只读仓库工具,council 腿的 lead 只调
408
+ // run_council;lens/arbiter 子任务同样只读仓库,故 council 工具拿无手 subRunner。
409
+ const hands = "none";
343
410
  if (req.council === true) {
344
411
  const rounds = clampRounds(req.rounds); // finite-guarded (NaN/±Inf → undefined → council default 1)
345
412
  return {
346
413
  tools: [
347
- createCouncilTool(deps.subRunner, repoTools, objective, {
414
+ createCouncilTool(subRunnerFor(deps, hands), repoTools, objective, {
348
415
  metrics: deps.metrics,
349
416
  logger: deps.logger,
350
417
  debate: req.debate === true, // L1+L2+L3 (peer debate) vs L1+L3
@@ -355,11 +422,12 @@ function codeReview(deps, req) {
355
422
  ...(rounds !== undefined ? { rounds } : {}),
356
423
  }),
357
424
  ],
425
+ hands,
358
426
  skills,
359
427
  promptProvider: coordinatorPrompt,
360
428
  };
361
429
  }
362
- return { tools: repoTools, skills, promptProvider: directReviewerPrompt };
430
+ return { tools: repoTools, hands, skills, promptProvider: directReviewerPrompt };
363
431
  }
364
432
  const reviewBody = "Produce ONE prioritized review: a short summary, then findings grouped by severity " +
365
433
  "(blocker / major / minor), each with `path:line`, the problem, and a concrete fix. Cite real code — never invent files or symbols.";
@@ -144,8 +144,10 @@ function evictIfIdle(store, scope, st) {
144
144
  */
145
145
  /** [3156] 轮2 真红的修:进程内终态行 seed 缓存 —— **写路径顺手喂**(publishFleet 的 terminal 臂),
146
146
  * 读侧零库查询零时序竞态。存在理由:durable pull 的查询键是 caller 的 scope,而 fleetWide 连接
147
- * (无 auth 部署恒是)的可见性判据放行**全部** scope —— 查询键与可见性判据不同源,引擎铸行的
148
- * scope("anon:shell-live")永远不等于 caller 侧的 "default",pull 恒 miss。seed 缓存按「发生过什么」
147
+ * (无 auth 部署恒是)的可见性判据放行**全部** scope —— 查询键与可见性判据不同源。行 scope 的值
148
+ * = 提交方自报的 principal 经引擎透传("anon:shell-live" cli 壳在本地单用户模式**自发的 header
149
+ * 值**,引擎只做 scope=principal 赋值,不发明该字面量;[3225] 后续侦察定谳,勿再写「引擎铸」),
150
+ * 它永远不等于 caller 读侧的 "default",pull 恒 miss。seed 缓存按「发生过什么」
149
151
  * 记(与订阅方无关),读侧再过各连接自己的 visW。不是活跃集:bus 的 Map 零触碰,单写者不变量原样。
150
152
  * 有界:行数 ≤ MAX_ROWS×2、窗长同 WINDOW_MS,超界丢最旧。 */
151
153
  const seededTerminalRows = [];
@@ -174,11 +176,12 @@ function seededRowsInWindow() {
174
176
  export async function recentTerminalWorkflowRows(store, scope, pullScope) {
175
177
  // [3156]:seed 缓存 = 本进程生命周期内全部终态(含 boot recover 判死)的同源可达径——fleetWide 连接
176
178
  // (scope=null,可见性判据放行全 scope)取 seed **全量**,并以 `pullScope`(caller principal,尽力形)
177
- // 做 durable pull 兜底:pull 的查询键与引擎铸行 scope 可能不同源(cli 轮2 实测 "default" vs
178
- // "anon:shell-live" 恒 miss),中了是增益、中不了还有 seed——两径并集,谁都不当唯一真源。
179
+ // 做 durable pull 兜底:pull 的查询键与行 scope(提交方自报 principal 的透传值)可能不同源
180
+ // (cli 轮2 实测 "default" vs 壳自发的 "anon:shell-live" 恒 miss),中了是增益、中不了还有 seed
181
+ // ——两径并集,谁都不当唯一真源。
179
182
  // scoped 连接:seed 按本 scope 滤 + durable pull 本 scope(跨进程兜底)。
180
- // [3193] 病理3:seed 侧不再按 caller scope 预滤——那是第二份可见性判据(F1 病族),与引擎铸行
181
- // scope("anon:shell-live" vs caller "default")恒 miss。本函数只产**候选集**;可见/擦除由路由的
183
+ // [3193] 病理3:seed 侧不再按 caller scope 预滤——那是第二份可见性判据(F1 病族),与行 scope
184
+ // (壳自发 "anon:shell-live" vs caller "default")恒 miss。本函数只产**候选集**;可见/擦除由路由的
182
185
  // visW/stripW 同一套判据裁(与快照活行、增量帧同源),窗与 MAX_ROWS 双界仍在。
183
186
  const seeded = seededRowsInWindow();
184
187
  const effectiveScope = scope ?? pullScope;
@@ -1395,6 +1395,17 @@ export function composeHooks(deployment, task) {
1395
1395
  const t = task;
1396
1396
  const out = {};
1397
1397
  if (d.preToolUse || t.preToolUse) {
1398
+ // 🔴 观察标(core 5.20.0 `Hooks.preToolUseObservational`)的传承折:复合体只有在**每一只在场的
1399
+ // `preToolUse` 都声明了纯观察**时才继续声明。方向是 fail-closed 的——
1400
+ // · 漏传承(该声明没声明)= 退回 5.19.0 行为:多一条链条目,跨副本赎回被响亮拒(吵,但不失守);
1401
+ // · 错传承(有真裁决者却声明了)= core 把复合体的判词整条**拒绝采纳**,客户端 `settings.hooks`
1402
+ // 的 deny/ask 静默蒸发 —— 这是安全轴上的静默 fail-open,比原缺陷坏得多。
1403
+ // 故判据是「无人反对」而不是「有人赞成」:任一在场面未声明 ⇒ 复合体不声明。
1404
+ // 缺席的槽不算反对票(`d.preToolUse` 不在 = 部署侧没有面可裁决,不影响 task 侧的性质)。
1405
+ const bothObservational = (d.preToolUse === undefined || d.preToolUseObservational === true) &&
1406
+ (t.preToolUse === undefined || t.preToolUseObservational === true);
1407
+ if (bothObservational)
1408
+ out.preToolUseObservational = true;
1398
1409
  out.preToolUse = async (toolName, input, ctx) => {
1399
1410
  let current = input;
1400
1411
  const contexts = [];
@@ -15,7 +15,7 @@
15
15
  * 本文件对 server.ts 的引用一律 `import type`(tsc 擦除,非装载边)。
16
16
  */
17
17
  import type { IncomingMessage, ServerResponse } from "node:http";
18
- import type { TaskStream, TaskSpec, TaskResult, QuestionAnswer, CheckpointToken, ResumeOutcome } from "@sema-agent/core";
18
+ import type { TaskStream, TaskSpec, TaskResult, QuestionAnswer, CheckpointToken, ResumeOutcome, Runner } from "@sema-agent/core";
19
19
  import type { FlatServiceDeps, RequestAuth } from "./server.js";
20
20
  import type { TaskRequestBody, DecideBinding } from "./wire-types.js";
21
21
  import type { IdempotencyCache } from "./idempotency.js";
@@ -82,6 +82,9 @@ export interface RouteRequestState {
82
82
  * 命名后好处不只是过 B4 门:两个消费域现在都能对同一个符号做 `import type` 标注,而不是各自重推断结构。 */
83
83
  export interface PreparedTaskSubmission {
84
84
  spec: TaskSpec;
85
+ /** #196:本次提交的执行 Runner —— 场景 hands=none 时是无手孪生(不挂 executionEnvFactory)。
86
+ * 两个消费域一律用它,不再直接摸 `deps.runner`(那正是「场景表态了、执行点没听」的缝)。 */
87
+ runner: Runner;
85
88
  auth?: RequestAuth;
86
89
  verify?: VerifyRoundsSpec;
87
90
  cascade?: boolean;
@@ -91,6 +94,9 @@ export interface PreparedTaskSubmission {
91
94
  /** B4①(命名化):`driveResumeIntoRunLog` 的入参形(resume 家族共用)——原为 9 成员内联匿名对象。 */
92
95
  export interface DriveResumeArgs {
93
96
  token: CheckpointToken;
97
+ /** #196:续跑腿的执行 Runner。resume 会用同一条 resolveSpec 重解析场景,故与首跑选同一只 —— 否则
98
+ * 一次续跑就把手带 band 重新 mount 回来(收窄只在首跑成立 = 没成立)。 */
99
+ runner: Runner;
94
100
  sessionId: string;
95
101
  principal: string | undefined;
96
102
  fleetScope: string;
@@ -48,8 +48,12 @@ async function handleFleetBody(req, res, url, ctx, miss) {
48
48
  // #189:终态行窗的 durable 读交给 streamFleet 在**订阅注册之后**做(见那里的缓冲段)——放在这里
49
49
  // (开流之前)会让"帧已扇出而本连接尚未订阅"的窗口凭空多出一个读的时长,codex R1-H1 红先复现过。
50
50
  // [3156] 轮2 真红修:终态窗的查询键必须与可见性判据**同源**——fleetWide 连接(callerScope=null,
51
- // visW 放行全 scope)此前窗却按 caller principal("default")查,而行的 scope 是引擎铸的
52
- // ("anon:shell-live")⇒ durable pull 恒 miss。fleetWide ⇒ null(窗走进程内 seed 缓存,全 scope)。
51
+ // visW 放行全 scope)此前窗却按 caller principal("default")查,而行的 scope 是提交方自报
52
+ // principal 的透传值(cli 壳本地单用户模式自发 "anon:shell-live";引擎不发明该字面量,[3225]
53
+ // 后续侦察定谳)⇒ durable pull 恒 miss。fleetWide ⇒ null(窗走进程内 seed 缓存,全 scope)。
54
+ // 读侧 `?? "default"` 三处与 core 写侧铸链同源(core prepare-task `spec.principal ?? "default"`,
55
+ // 其 CHANGELOG 成文「Single-tenant spelling is an explicit scope:"default"」)——在 core 撤其
56
+ // 写侧铸点前,server 单方面撤读侧会把无主部署的 durable pull 面弄瞎,故保持同源不动。
53
57
  await streamFleet(req, res, deps.fleetBus, fleetWide ? null : (principal ?? "default"), callerSession || null, observeOnly ? undefined : deps.workflowCompletionInbox, { store: deps.workflowRunStore, scope: fleetWide ? null : (principal ?? "default"), pullScope: principal ?? "default", ready: deps.fleetRecoveryDone });
54
58
  return;
55
59
  }
@@ -326,7 +326,8 @@ async function handleRunsBody(req, res, url, ctx, miss) {
326
326
  rootTaskId: taskId,
327
327
  ...fleetRunLabels(prepared.spec.objective), // BC-1: name = short objective preview (description = live-activity, set by the publisher onEvent tool_start)
328
328
  });
329
- void runInBackground(deps.runner, { ...prepared.spec, sessionId }, runStore, taskId, deps.metrics, prepared.auth?.principal, prepared.verify, prepared.cascade ? cascadeConfig(deps.config.cascadeLadder, prepared.spec.limits?.maxCostUsd) : undefined, deps.instrumentDegenerate, deps.planCacheProbe, deps.config.traceThinking, inflightRuns, preemptableRuns, steerableRuns, deps.modelUsage, deps.elicitation, // E23: per-run elicitation context (onElicit routes inbound MCP elicitations to this run's stream)
329
+ void runInBackground(prepared.runner, // #196:场景 hands 判别位选出的 Runner(hands=none 无手孪生)
330
+ { ...prepared.spec, sessionId }, runStore, taskId, deps.metrics, prepared.auth?.principal, prepared.verify, prepared.cascade ? cascadeConfig(deps.config.cascadeLadder, prepared.spec.limits?.maxCostUsd) : undefined, deps.instrumentDegenerate, deps.planCacheProbe, deps.config.traceThinking, inflightRuns, preemptableRuns, steerableRuns, deps.modelUsage, deps.elicitation, // E23: per-run elicitation context (onElicit routes inbound MCP elicitations to this run's stream)
330
331
  gatedPrincipal(req, deps.config) ?? null, // E23: the VERIFIED principal that may answer (same source the respond gate uses — never the spoofable header)
331
332
  captureTurnAnchor, // E18: per-turn (message eventId → leaf entryId) anchor capture
332
333
  fleetPub, // MF-Fleet: the run-scoped fleet-row publisher (onStart/onEvent/onTerminal across all legs)
@@ -601,7 +601,7 @@ async function handleTasksBody(req, res, url, ctx, miss) {
601
601
  // (mid-flight ctrl+b) and, as a natural consequence, steer/compact — now reach a mid-turn
602
602
  // INTERACTIVE run too (previously only the bg + resume legs registered). Deregistered in the
603
603
  // outer finally (with the fleet settle), so an early throw can't leak a stale handle.
604
- const liveStream = deps.runner.runTaskStream({ ...prepared.spec, signal: ac.signal }, undefined, fwdInternals);
604
+ const liveStream = prepared.runner.runTaskStream({ ...prepared.spec, signal: ac.signal }, undefined, fwdInternals); // #196:场景 hands 判别位选出的 Runner
605
605
  liveStreamRef = liveStream;
606
606
  if (durableTaskId)
607
607
  steerableRuns.set(durableTaskId, liveStream);
@@ -1283,9 +1283,10 @@ async function handleTasksBody(req, res, url, ctx, miss) {
1283
1283
  // ── B3 ── 原文此处是一处三层三元,把三条模型驱动腿挤在五行里。三腿具名化后判别顺序
1284
1284
  // (verify → cascade → plain)、短路语义、传参**逐字不变**;`runWithVerification` 仍是整对象
1285
1285
  // 直传(快审 F1:cost 顶不在重建中丢失),`runCascade` 仍从操作员梯子 + 本任务预算现算配置。
1286
- const verifyLeg = (v) => runWithVerification(deps.runner, specWithSignal, v);
1287
- const cascadeLeg = () => runCascade(deps.runner, specWithSignal, cascadeConfig(deps.config.cascadeLadder, prepared.spec.limits?.maxCostUsd));
1288
- const plainLeg = () => deps.runner.runTask(specWithSignal);
1286
+ // #196:三腿一律跑 `prepared.runner`(场景 hands 判别位选出的那只),不再直接摸 deps.runner
1287
+ const verifyLeg = (v) => runWithVerification(prepared.runner, specWithSignal, v);
1288
+ const cascadeLeg = () => runCascade(prepared.runner, specWithSignal, cascadeConfig(deps.config.cascadeLadder, prepared.spec.limits?.maxCostUsd));
1289
+ const plainLeg = () => prepared.runner.runTask(specWithSignal);
1289
1290
  result = await withPrincipal(principal, () => prepared.verify ? verifyLeg(prepared.verify) : prepared.cascade ? cascadeLeg() : plainLeg());
1290
1291
  }
1291
1292
  finally {
@@ -77,6 +77,12 @@ export interface ServiceCoreDeps {
77
77
  resolveSpec: (body: TaskRequestBody, req: IncomingMessage | undefined, auth?: RequestAuth, opts?: {
78
78
  leg?: "fresh" | "resume";
79
79
  }) => TaskSpec | Promise<TaskSpec>;
80
+ /**
81
+ * #196 场景 hands lane 选路:按 `resolveSpec` 刚产出的 TaskSpec 取本任务的执行 Runner。
82
+ * 装配点(main.ts)把「场景判别位 → Runner 对」的映射整只闭包传进来 —— 本层不解释场景,只取用。
83
+ * 缺席 ⇒ 恒 {@link ServiceDeps.runner}(单 Runner 部署形 = #196 之前的行为,逐字不变;测试桩即此形)。
84
+ */
85
+ runnerFor?: (spec: TaskSpec) => Runner;
80
86
  }
81
87
  /** **durable 持久面**:每个键 present ⇔ 对应路由/能力位在场,absent ⇒ 501(诚实缺席),
82
88
  * 绝不静默降级。装配点 = main.ts 的 store backend 开箱段。 */
@@ -615,6 +615,11 @@ export function createHttpServer(rawDeps) {
615
615
  return;
616
616
  sendError(res, 404, "not_found.route", "not found");
617
617
  }
618
+ /** #196:本任务跑哪只 Runner —— 由装配点按场景 hands 判别位裁定(main.ts 的 `runnerFor` 闭包)。
619
+ * seam 缺席 = 单 Runner 部署形(#196 之前的行为逐字不变),不是降级臂。 */
620
+ function runnerFor(spec) {
621
+ return deps.runnerFor?.(spec) ?? deps.runner;
622
+ }
618
623
  /** Read + validate the body, authorize, and build the spec. Returns null if it already responded. */
619
624
  async function prepareSpec(req, res) {
620
625
  const body = (await readJson(req));
@@ -1205,7 +1210,10 @@ export function createHttpServer(rawDeps) {
1205
1210
  }
1206
1211
  try {
1207
1212
  const auth = deps.authorize ? await deps.authorize({ req, body }) : undefined;
1208
- return { spec: await deps.resolveSpec(body, req, auth, { leg: "fresh" }), auth, verify, cascade, jobId: body.jobId, body };
1213
+ const spec = await deps.resolveSpec(body, req, auth, { leg: "fresh" });
1214
+ // #196:执行 Runner 与 spec 同一次解析产出 —— 两个消费域(tasks 同步/流式、runs 异步)拿的都是这一只,
1215
+ // 不各自再问一遍(再问 = 两处判别,正是漂移的成因)。
1216
+ return { spec, runner: runnerFor(spec), auth, verify, cascade, jobId: body.jobId, body };
1209
1217
  }
1210
1218
  catch (err) {
1211
1219
  if (err instanceof HttpError) {
@@ -1413,9 +1421,12 @@ export function createHttpServer(rawDeps) {
1413
1421
  // Typed pre-check: the pending tool must still exist in the rebuilt config; a scenario
1414
1422
  // redeploy that removed it means the approval can no longer be applied — fail clearly, never a 500/silent.
1415
1423
  // The "hand" tools are NOT in spec.tools — core mounts them at the runner from the executionEnvFactory
1416
- // (core `HAND_TOOL_EFFECTS`, not re-exported from the package root mirrored here). When this deployment
1417
- // has remote exec configured the rebuilt task WILL expose them, so union them in; else gating a hand tool
1418
- // (the common code-agent case) would always 422 on resume. Keep in sync with core's HAND_TOOL_EFFECTS.
1424
+ // (`HAND_TOOL_NAMES` above derives from core's root-exported `HAND_TOOL_EFFECTS`; nothing is mirrored here).
1425
+ // When this deployment has remote exec configured the rebuilt task WILL expose them, so union them in; else
1426
+ // gating a hand tool (the common code-agent case) would always 422 on resume.
1427
+ // #196 旁注:本 union 只看部署有没有 remoteExec,不看本任务的 hands lane —— 对 hands=none 的任务这是
1428
+ // **过近似**(那些工具根本没 mount,也就不可能有它们的 park 行),只影响 422 的 UX 宽严,不放大任何权限;
1429
+ // band 的**完整**名单(含 Monitor/EnterWorktree/… 这些不在 effects 表里的)在 capabilities/hands-lane.ts。
1419
1430
  // [2380] RB-476(5.0.0):Q6 折叠预检退役——满足性匹配按 RAW 名。pre-floor 旧名 checkpoint
1420
1431
  // (`bash`/`run_workflow` 等)在此 422 诚实拒,与 core 自己的 resume.tool_unavailable 同方向
1421
1432
  // ([2364] 令:耐久面响亮牺牲 + P-7 重开,不静默兜)。现役名恒自映射。
@@ -1510,7 +1521,7 @@ export function createHttpServer(rawDeps) {
1510
1521
  // heartbeat, the resumeStream / resumeWithVerification loop, CheckpointError classification, re-suspend vs
1511
1522
  // terminal drive. This HITL /decide path supplies a `policy_ask` outcome; the scheduler preempt path supplies
1512
1523
  // a `resource_limit`/continue one — sharing this prevents the two resume entries from drifting.
1513
- return driveResumeIntoRunLog({ token, sessionId: cp.sessionId, principal: auth.principal, fleetScope: resumeFleetScope(req, auth), taskConfig, resumeObjective, outcome, verifyRounds, ...(onResumeCommitted ? { onResumeCommitted } : {}) });
1524
+ return driveResumeIntoRunLog({ runner: runnerFor(spec), token, sessionId: cp.sessionId, principal: auth.principal, fleetScope: resumeFleetScope(req, auth), taskConfig, resumeObjective, outcome, verifyRounds, ...(onResumeCommitted ? { onResumeCommitted } : {}) });
1514
1525
  }
1515
1526
  /**
1516
1527
  * The resume-drive machinery SHARED by `resumeCheckpoint` (operator /decide, `policy_ask`) and `resumePreempted`
@@ -1524,7 +1535,8 @@ export function createHttpServer(rawDeps) {
1524
1535
  * (setSuspended, keep the lock) vs a terminal (setTerminal), and returns the HTTP {status, body}.
1525
1536
  */
1526
1537
  async function driveResumeIntoRunLog(args) {
1527
- const { token, sessionId, principal, fleetScope, taskConfig, resumeObjective, outcome, verifyRounds } = args;
1538
+ // #196:`runner` = 本次续跑的执行 Runner(场景 hands 判别位裁定,与首跑同源;见 DriveResumeArgs 注)。
1539
+ const { runner: legRunner, token, sessionId, principal, fleetScope, taskConfig, resumeObjective, outcome, verifyRounds } = args;
1528
1540
  // ── #151 车3 刀 3b:resume 腿的 `legKey` 真值(设计稿 §3.2′)──────────────────────────────────
1529
1541
  // 一次 park→resume 的 checkpoint token **就是**这条腿的天然身份:同一 token 重投 = 同一条腿(幂等,
1530
1542
  // askId 不变);新 park ⇒ 新 token ⇒ 新腿 ⇒ 新 askId ⇒ **重新征询**(§3.3「resume 只重放未完成兄弟」)。
@@ -1732,7 +1744,7 @@ export function createHttpServer(rawDeps) {
1732
1744
  if (verifyRounds !== undefined) {
1733
1745
  // resumeWithVerification runs the SAME pre-CAS guard + atomic CAS as resume() internally, so a lost
1734
1746
  // CAS rejects with CheckpointError before any work — handled by the catch below, same as the stream path.
1735
- const vr = await resumeWithVerification(deps.runner, token, outcome, { ...taskConfig, signal: cancelCtrl.signal, preemptSignal: preemptCtrl.signal }, resumeObjective, verifyRounds); // 快审 F1:整对象直传
1747
+ const vr = await resumeWithVerification(legRunner, token, outcome, { ...taskConfig, signal: cancelCtrl.signal, preemptSignal: preemptCtrl.signal }, resumeObjective, verifyRounds); // 快审 F1:整对象直传
1736
1748
  let safe = stripCheckpointToken(vr);
1737
1749
  // 🔴 A re-suspend on a LATER gate is NOT terminal: core maps it to status:"failed" +
1738
1750
  // verification.unverifiedReason:"suspended" with the checkpoint token still live. Treat it exactly like
@@ -1828,7 +1840,7 @@ export function createHttpServer(rawDeps) {
1828
1840
  // (`++seq` is a sync increment — sink-vs-loop appends get unique seqs); only when a durable log exists.
1829
1841
  // false once this leg settles — notifications after that take the durable-inbox path.
1830
1842
  resumeLegLive = true;
1831
- const stream = await deps.runner.resumeStream(token, outcome, { ...taskConfig, signal: cancelCtrl.signal, preemptSignal: preemptCtrl.signal }, {
1843
+ const stream = await legRunner.resumeStream(token, outcome, { ...taskConfig, signal: cancelCtrl.signal, preemptSignal: preemptCtrl.signal }, {
1832
1844
  onForwardEvent: (e) => {
1833
1845
  fleetPub?.onForwardEvent(e);
1834
1846
  // S2 live tail(复审 #1:forward sink 有三条腿——resume 腿上 spawn 的 bg 子代同样带
@@ -2256,7 +2268,7 @@ export function createHttpServer(rawDeps) {
2256
2268
  // A resource-suspended task that was submitted with verify:true is re-gated on completion (parity with the
2257
2269
  // HITL path — the flag rides the persisted ctx.body).
2258
2270
  const verifyRounds = verifyRoundsFromBody(ctx.body);
2259
- return driveResumeIntoRunLog({ token, sessionId: cp.sessionId, principal: auth.principal, fleetScope: resumeFleetScope(req, auth), taskConfig, resumeObjective, outcome: { gate: "resource_limit", decision: "continue" }, verifyRounds });
2271
+ return driveResumeIntoRunLog({ runner: runnerFor(spec), token, sessionId: cp.sessionId, principal: auth.principal, fleetScope: resumeFleetScope(req, auth), taskConfig, resumeObjective, outcome: { gate: "resource_limit", decision: "continue" }, verifyRounds });
2260
2272
  }
2261
2273
  /** design/144 §3:wake 一个 task_done 纯 park——非门决策,解除 park 消费消息续跑。owner 门:非 operator
2262
2274
  * 只能 wake 自己 scope 的 park(404 无 oracle);core 端 gate_pending/nothing_to_deliver 双拒兜底。 */
@@ -2314,6 +2326,7 @@ export function createHttpServer(rawDeps) {
2314
2326
  const spec = await deps.resolveSpec({ ...ctx.body, resumeAt: undefined }, req, auth);
2315
2327
  const { objective: resumeObjective, sessionId: _sessionId, ...taskConfig } = spec;
2316
2328
  return driveResumeIntoRunLog({
2329
+ runner: runnerFor(spec),
2317
2330
  token,
2318
2331
  sessionId: cp.sessionId,
2319
2332
  principal: auth.principal,
@@ -2379,7 +2392,7 @@ export function createHttpServer(rawDeps) {
2379
2392
  ...(editedPlan !== undefined ? { editedPlan } : {}),
2380
2393
  ...(reason ? { reason } : {}),
2381
2394
  };
2382
- return driveResumeIntoRunLog({ token, sessionId: cp.sessionId, principal: auth.principal, fleetScope: resumeFleetScope(req, auth), taskConfig, resumeObjective, outcome, verifyRounds });
2395
+ return driveResumeIntoRunLog({ runner: runnerFor(spec), token, sessionId: cp.sessionId, principal: auth.principal, fleetScope: resumeFleetScope(req, auth), taskConfig, resumeObjective, outcome, verifyRounds });
2383
2396
  }
2384
2397
  function recordTaskResult(result) {
2385
2398
  deps.metrics?.inc("tasks_total", { status: result.status });
package/dist/main.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
- import { Runner, TtlSessionStore, uuidv7, defaultTaskRegistry, createAllowDenyPolicy, workflowsCapability, createWebFetchSummarizer, resolveTaskModel as coreResolveTaskModel, probeSearchBackend, describeStaticWiring } from "@sema-agent/core";
4
+ import { Runner, InMemoryToolResultStore, TtlSessionStore, uuidv7, defaultTaskRegistry, createAllowDenyPolicy, workflowsCapability, createWebFetchSummarizer, resolveTaskModel as coreResolveTaskModel, probeSearchBackend, describeStaticWiring } from "@sema-agent/core";
5
5
  import { createSessionTitler } from "./session-titler.js";
6
6
  import { posIntEnv } from "./session-watch.js";
7
7
  import { selectEnvironmentTool } from "./capabilities/select-environment-tool.js";
@@ -18,6 +18,7 @@ import { assertGateIntentServiceable, hasOperatorGateIntent } from "./approval.j
18
18
  import { loadSkills } from "./capabilities/skills.js";
19
19
  import { GiteaClient } from "./capabilities/repo-tools.js";
20
20
  import { buildScenarios, builtinScenarioDetails } from "./capabilities/scenarios.js";
21
+ import { createHandsLaneRegistry, pickHandsRunner, withoutExecutionEnv } from "./capabilities/hands-lane.js";
21
22
  import { createLogger } from "./observability/logger.js";
22
23
  import { createToolTracer, createPermissionDeniedMeter } from "./observability/tool-trace.js";
23
24
  import { createRegistryJwtVerifier } from "./auth-bridge.js";
@@ -193,16 +194,54 @@ async function main() {
193
194
  const { principalCaps, centerRuntimeCapsResolver, runtimeCapsResolver } = createRuntimeCaps({ config, logger });
194
195
  // design/170 件A(#148 件3③):org 记忆准入装配(目录源三态选择+C12 能力探测,坏配置在此拒启动)。
195
196
  const orgMemoryAdmission = createOrgMemoryAdmissionWiring({ config, logger, metrics });
197
+ // 🔴 codex 复审(#196 finding-2b):`toolResultStore` 在**无 backend** 形下是 undefined,而 core 的
198
+ // Runner 构造函数会在缺席时**每只各自私建**一份 `RunnerSharedToolResultStore`(runtask.js
199
+ // `if (!this.deps.toolResultStore)`)。本部署有多只 Runner(主 / subRunner / hookAgent / #196 的两只无手
200
+ // 孪生),私建 ⇒ 每只一个独立店 ⇒ A 车道 offload 出去的 `ReadToolResult` 引用在 B 车道解引用不到
201
+ // (fork 子任务跨 runner 读父的 offload 是既有可达形,#196 的车道分家又多了一条)。这里显式铸**一份**
202
+ // 共享兜底(参数与 core 私建那只逐字相同:`RunnerSharedToolResultStore` 是 `InMemoryToolResultStore`
203
+ // 的空子类,cap 64M chars),只喂 Runner 侧。
204
+ // ⚠️ 刻意**不动** `toolResultStore` 本身:那个键的「present ⇔ durable 后端在场」语义还管着 E21 purge、
205
+ // reaper 清扫、leader 装配与 /health 的接线自述(改它会让无 backend 的部署自述成 "shared(sql)" = 谎)。
206
+ const runnerOffloadStore = toolResultStore ?? new InMemoryToolResultStore({ maxTotalChars: 64_000_000 });
196
207
  // design/158 A10:RunnerDeps 装配段搬到 src/boot/runner-deps.ts(逐字;runStore 晚绑改取值,见该文件头注)。
197
208
  const runnerDeps = createRunnerDeps({
198
209
  config, logger, metrics, localRoot, promptSource: configCenter.promptSource, rosterStore, backgroundAgentStore, mailboxStore, usageWindowStore, brain,
199
210
  pricing, tracer, outcomeSink, elicitation, question, toolApproval, sessionStore, memoryEngine,
200
- memorySyncRunner, toolResultStore, sessionPolicyStore, runtimeCapsResolver, fileSnapshotStore,
211
+ memorySyncRunner, toolResultStore: runnerOffloadStore, sessionPolicyStore, runtimeCapsResolver, fileSnapshotStore,
201
212
  executionEnvFactory, lspManager, fleetBus, deploymentHooks, workflowRunStore, workflowJournalStore,
202
213
  workflowAgentRegistry, workflowNotifyGate, workflowCompletionInbox, deliverWorkflowCompletion, orgMemoryAdmission,
203
214
  getRunStore: () => runStore,
204
215
  });
205
216
  const runner = new Runner(runnerDeps);
217
+ // #196:`runner` 的无手孪生 —— 同 deps 同实例(sessionStore/toolResultStore/checkpointStore… 全共享,
218
+ // `Runner.sessions` 就是传进去的那只 store,两只 Runner 不会各持一份会话缓存),唯独不挂
219
+ // `executionEnvFactory`。core 的 `handsEnabled = ownedEnv || deps.executionEnv` 是 **per-Runner** 合同,
220
+ // 单 runner 服务全场景时结构上无法逐场景兑现 —— 这只孪生就是兑现它的那一半(设计小票 §2a)。
221
+ // 声明 hands:"none" 的场景(scan / code-review 两腿 / team / center overlay)在 HTTP 执行点被路由到这里。
222
+ //
223
+ // 🔴 两条**已知边界**(codex 复审 2026-08-09 两轮提出,逐条亲验 core dist 后如实记账;两条的部署条件
224
+ // 互不相同 —— 不要把它们并成一句)。
225
+ //
226
+ // ① 会话锁是 **per-Runner** 的(core runtask.js 的 `sessionLocks` Map,每只 Runner 一份)。
227
+ // **条件:无 store backend 的部署形**。有 runStore 时同会话并发提交被服务端的 run 认领挡成 409
228
+ // (routes/tasks.ts:317 / routes/runs.ts:335 的 `createRun` 认领),两车道并发结构上不可达;无
229
+ // backend 的部署没有那道认领,同一 sessionId 上「一条 full + 一条 none」并发就失去了修前由单
230
+ // Runner 提供的串行化。同类缺口本就存在(subRunner 经 ForkRoutingSessionStore 也会碰宿主会话),
231
+ // 本件让它多一条可达路径。补偿:配任一 backend(含 `DB_BACKEND=local`)即恢复认领。
232
+ //
233
+ // ② 升级窗的旧 checkpoint。**条件恰恰相反:必须有 checkpointStore**(= 有 backend + DURABLE_APPROVAL),
234
+ // 且部署在 REMOTE_EXEC 车道上 —— 修前建立的 hands=none 场景 durable checkpoint 会带 workspaceHandle
235
+ // (可挂起 env 的快照形,以及**非**可挂起远端 env 的 `restoreMode:"park_only"` 形,两者都算;审批 park
236
+ // 与 resource suspend 都会写),续跑时被路由到这只无手 Runner,core 在 **CAS 之前**抛
237
+ // `CheckpointError("checkpoint.unsupported_version", "checkpoint has a remote workspaceHandle but no
238
+ // RunnerDeps.executionEnvFactory is wired to rebuild the env")`(runtask.js:3704)。
239
+ // 本仓的分类表把该码归 **TERMINAL**(http/server.ts 的 CheckpointError 分支):run 行被 `setTerminal`
240
+ // 成 failed 并**释放** task_active,响应 409 携带该 errorCode —— 会话**不会**被占住到 TTL,运维重提该
241
+ // 任务即可。故这是**响亮的、有界的**升级窗代价,不是静默损坏;但它是行为面的升级注记,发车说明必须写。
242
+ // ⚠️ 未覆盖登记:本形需要「真 durable 店 + 带 workspaceHandle 的旧 checkpoint」才能驱动,本批未建格
243
+ // (属真双库/迁移测试面)—— 移交主会话裁定是否要做 checkpoint 感知的续跑选路或启动期预检。
244
+ const handslessRunner = new Runner(withoutExecutionEnv(runnerDeps));
206
245
  // codex R10: TRUE ⇒ the Runner just froze a PRIVATE tier-expanded catalog copy (core runtask.js constructor,
207
246
  // dist-read) — in-place model-plane mutation no longer reaches it, so refresh-time plane changes must be
208
247
  // DEFERRED to restart (see appliedPlaneEff / applyEffective deferModelPlane). Captured HERE, at the same
@@ -321,7 +360,7 @@ async function main() {
321
360
  // (子会话住私有 TTL 店,park 前整树拷进 host durable 店——只查不迁的 1.263 形在拆店生产形下恒
322
361
  // 否决 park,cli 真机 14ms~2s expired 即此)。
323
362
  const subRunnerSessions = new ForkRoutingSessionStore(sessionStore, new TtlSessionStore({ defaultTtlDays: 1 / 24 }));
324
- const subRunner = new Runner({
363
+ const subRunnerDeps = {
325
364
  // [1543]§三族A 结构性根治(design/158 冲刺尾件):与主 runnerDeps 重复的 ~15 键统一走
326
365
  // createSharedRunnerDeps 展开——「双点挂载」从人工纪律变结构性保证(新共享键漏配其一=不可能,
327
366
  // 基座只有一份)。差异键在展开后显式列出,每个都有为何不同的理由(见 boot/runner-deps.ts 头注)。
@@ -337,7 +376,7 @@ async function main() {
337
376
  mailboxStore,
338
377
  rosterStore,
339
378
  deploymentHooks,
340
- toolResultStore,
379
+ toolResultStore: runnerOffloadStore,
341
380
  sessionPolicyStore,
342
381
  usageWindowStore,
343
382
  orgMemoryAdmission,
@@ -359,7 +398,13 @@ async function main() {
359
398
  // TaskStop on parked 走 expire CAS 仲裁(1.389 F-1);/decide 可赎回。行为钉 =
360
399
  // test/subagent-park-wiring.test.ts(SOURCE PIN + park 全链 + 旧形反向控制)。
361
400
  checkpointStore: checkpointStore ? checkpointStore : undefined,
362
- });
401
+ };
402
+ const subRunner = new Runner(subRunnerDeps);
403
+ // #196:`subRunner` 的无手孪生(同一份 deps 字面量摘掉 executionEnvFactory —— 差异面只有这一处,
404
+ // 结构上不可能漂移)。council 的 lens/arbiter、team 的成员/synthesizer 走它:那些子任务的人格声明
405
+ // 里一件工具都没有,却在修前照拿全量可写手(设计小票 §1 里最意外的一支)。
406
+ // ⚠️ 与主 subRunner 共享 `subRunnerSessions` 与 `checkpointStore` 同实例 —— park/fork 的行是同一批。
407
+ const handslessSubRunner = new Runner(withoutExecutionEnv(subRunnerDeps));
363
408
  // S6: resolve identity + owned session from the authenticated channel (never the body).
364
409
  const authorize = createAuthorizer(config, sessionStore);
365
410
  // S1/S2: durable async run registry + replayable event log (TiDB-backed).
@@ -598,7 +643,7 @@ async function main() {
598
643
  // design/158 S4:标注 `: ScenarioDeps` —— 无标注的字面量连自己的键名都不校验(打错/多写一个键
599
644
  // 只是多一个没人读的属性,编译期无声),这是 1.304 落错家那一族的另一半土壤。
600
645
  const scenarioDeps = {
601
- runner, subRunner, model: "default", skills, repoClient, requirePrincipal: config.requirePrincipal,
646
+ runner, subRunner, handslessSubRunner, model: "default", skills, repoClient, requirePrincipal: config.requirePrincipal,
602
647
  webSearch: webSearch ? webSearch : undefined, metrics, logger, webFetchSummarize,
603
648
  // core 1.364 写半场:与 RunnerDeps.backgroundAgentStore 同实例(组装区注释;半接=静默死特性)。
604
649
  backgroundAgentStore: backgroundAgentStore ? backgroundAgentStore : undefined,
@@ -898,8 +943,12 @@ async function main() {
898
943
  // 帽只在 watch 面在场时才有意义(缺席时路由 501,读不到它)——保持与原条件段同门,不单独接线。
899
944
  sessionEventsMaxConnections: sessionWatchRegistry ? posIntEnv(process.env.SESSION_EVENTS_MAX_CONNS, 256, 100_000) : undefined,
900
945
  };
946
+ // #196:hands lane 登记簿 —— resolveSpec 登记本请求场景的表态,HTTP 执行点凭 spec 取回选 Runner。
947
+ // 两处**同一实例**(交给 createResolveSpec 与 runnerFor 闭包),配对由这一行结构性保证。
948
+ const handsLanes = createHandsLaneRegistry();
901
949
  // design/158 A10:resolveSpec 段搬到 src/boot/resolve-spec.ts(逐字;两处活引用改取值,见该文件头注)。
902
950
  const resolveSpec = createResolveSpec({
951
+ handsLanes,
903
952
  config, logger, metrics, localRoot, scenarios, principalCaps, centerRuntimeCapsResolver,
904
953
  getCenterPrompts: () => configCenter.getCenterPrompts(),
905
954
  getKeyResolver: () => configCenter.getKeyResolver(),
@@ -910,7 +959,9 @@ async function main() {
910
959
  sessionEnvSelection,
911
960
  liveQuestionFace: question, // #152:活体问答面在场 ⇒ durable question 门按活流分腿(见 ResolveSpecCtx 注)
912
961
  });
913
- const server = createHttpServer({ runner, config, resolveSpec, stores, coordinators, seams, observability, governance, deployment, knobs });
962
+ // #196:执行点选路。resolveSpec 已按场景判别位登记 lane;`full` 恒回主 runner(修前行为逐字不变)
963
+ const runnerFor = (spec) => pickHandsRunner(handsLanes.laneOf(spec), { full: runner, handsless: handslessRunner });
964
+ const server = createHttpServer({ runner, runnerFor, config, resolveSpec, stores, coordinators, seams, observability, governance, deployment, knobs });
914
965
  // D-D SLA-timer: wire the server's deny-sweep into the reaper holder declared above (the reaper is defined
915
966
  // before the server, so it calls through this late-bound reference).
916
967
  runDenySweep = server.denyExpiredApprovals;