@sema-agent/server 7.138.0 → 7.139.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 (215) hide show
  1. package/MIGRATION.md +276 -7
  2. package/README.md +7 -4
  3. package/README.zh-CN.md +6 -4
  4. package/USAGE.md +73 -29
  5. package/deploy/sema-up/README.md +1 -1
  6. package/deploy/sema-up/lib/lanes.json +3 -2
  7. package/dist/adoption/wire.d.ts +3 -3
  8. package/dist/approval-card.d.ts +20 -20
  9. package/dist/bench/s1/arms.d.ts +1 -1
  10. package/dist/bench/s1/arms.js +1 -3
  11. package/dist/bench/s1/run-firm.js +2 -2
  12. package/dist/boot/device-lane.d.ts +8 -3
  13. package/dist/boot/device-lane.js +7 -2
  14. package/dist/boot/execution-env.d.ts +4 -2
  15. package/dist/boot/execution-env.js +16 -24
  16. package/dist/boot/execution-lane.d.ts +1 -1
  17. package/dist/boot/governance-seams.d.ts +3 -2
  18. package/dist/boot/governance-seams.js +9 -11
  19. package/dist/boot/memory-consolidation.js +2 -2
  20. package/dist/boot/resolve-spec.d.ts +1 -1
  21. package/dist/boot/resolve-spec.js +8 -9
  22. package/dist/boot/retention-lane.d.ts +5 -5
  23. package/dist/boot/retention-lane.js +0 -6
  24. package/dist/boot/runner-deps.d.ts +3 -1
  25. package/dist/boot/runner-deps.js +2 -1
  26. package/dist/boot/session-faces.d.ts +0 -3
  27. package/dist/boot/session-faces.js +15 -8
  28. package/dist/boot/stage-02-web-admin-pull.js +2 -2
  29. package/dist/boot/stage-05-execution-env.js +2 -2
  30. package/dist/boot/stage-06-runners.js +0 -1
  31. package/dist/boot/stage-09-leader.js +1 -2
  32. package/dist/boot/stage-10-http-server.js +2 -2
  33. package/dist/boot/stores.js +4 -4
  34. package/dist/boot/web-admin.js +2 -2
  35. package/dist/capabilities/mcp-probe.js +1 -1
  36. package/dist/capabilities/scenarios.d.ts +1 -1
  37. package/dist/capability-facts.d.ts +15 -7
  38. package/dist/capability-facts.js +8 -3
  39. package/dist/config-catalog.js +7 -7
  40. package/dist/config-invariants.d.ts +23 -3
  41. package/dist/config-invariants.js +42 -0
  42. package/dist/config-provider.d.ts +19 -24
  43. package/dist/config-provider.js +27 -12
  44. package/dist/config-types.d.ts +10 -5
  45. package/dist/config.d.ts +33 -0
  46. package/dist/config.js +44 -15
  47. package/dist/delegated-session-owner.d.ts +2 -2
  48. package/dist/deployment-shape.d.ts +1 -1
  49. package/dist/device-enrollment.d.ts +41 -2
  50. package/dist/device-enrollment.js +27 -12
  51. package/dist/device-store.d.ts +58 -85
  52. package/dist/device-store.js +19 -49
  53. package/dist/device-ws-hub.js +2 -1
  54. package/dist/env-name-allowlist-knobs.d.ts +5 -4
  55. package/dist/exec-cut.d.ts +60 -0
  56. package/dist/exec-cut.js +36 -0
  57. package/dist/execution-lane-caps.d.ts +19 -0
  58. package/dist/execution-lane-caps.js +10 -0
  59. package/dist/execution-placement.d.ts +259 -0
  60. package/dist/execution-placement.js +199 -0
  61. package/dist/field-conflict.d.ts +3 -2
  62. package/dist/field-conflict.js +1 -0
  63. package/dist/hosted-posture.d.ts +2 -2
  64. package/dist/http/admission.js +8 -19
  65. package/dist/http/checkpoint-ctx.d.ts +21 -12
  66. package/dist/http/checkpoint-ctx.js +4 -2
  67. package/dist/http/claimed-run.d.ts +1 -1
  68. package/dist/http/durable-run-leg.js +9 -5
  69. package/dist/http/execution-context.d.ts +218 -0
  70. package/dist/http/execution-context.js +330 -0
  71. package/dist/http/resume-legs.js +50 -20
  72. package/dist/http/route-ctx.d.ts +14 -8
  73. package/dist/http/routes/background.d.ts +29 -1
  74. package/dist/http/routes/background.js +29 -2
  75. package/dist/http/routes/devices.d.ts +13 -19
  76. package/dist/http/routes/devices.js +21 -26
  77. package/dist/http/routes/session-sync.js +110 -15
  78. package/dist/http/routes/sessions.d.ts +6 -0
  79. package/dist/http/routes/sessions.js +115 -11
  80. package/dist/http/routes/side-query.d.ts +12 -4
  81. package/dist/http/routes/side-query.js +4 -5
  82. package/dist/http/routes/tasks.js +37 -31
  83. package/dist/http/server.d.ts +35 -19
  84. package/dist/http/server.js +11 -0
  85. package/dist/http/session-inherit-env.d.ts +3 -4
  86. package/dist/http/session-inherit-env.js +1 -1
  87. package/dist/http/wire-types.d.ts +8 -12
  88. package/dist/index.d.ts +1 -0
  89. package/dist/memory-sync-client.d.ts +53 -14
  90. package/dist/memory-sync-client.js +196 -116
  91. package/dist/memory-sync.d.ts +1 -1
  92. package/dist/memory-sync.js +3 -2
  93. package/dist/observability/cost-taxonomy.d.ts +2 -2
  94. package/dist/observability/fail-open.d.ts +12 -0
  95. package/dist/observability/fail-open.js +12 -0
  96. package/dist/observability/run-terminal.js +0 -1
  97. package/dist/orchestration/workflow-notify-journal.js +4 -4
  98. package/dist/org-memory-admission.d.ts +4 -3
  99. package/dist/org-memory-admission.js +6 -6
  100. package/dist/parked-decide.d.ts +34 -9
  101. package/dist/parked-decide.js +12 -19
  102. package/dist/parked-revive-surface.d.ts +12 -15
  103. package/dist/parked-revive-surface.js +3 -6
  104. package/dist/peer-seat.d.ts +13 -7
  105. package/dist/peer-seat.js +37 -3
  106. package/dist/plugins/approval-ask-store-file.d.ts +7 -0
  107. package/dist/plugins/approval-ask-store-file.js +11 -0
  108. package/dist/plugins/approval-ask-store-memory.d.ts +6 -0
  109. package/dist/plugins/approval-ask-store-memory.js +12 -0
  110. package/dist/plugins/approval-ask-store-sql.d.ts +22 -5
  111. package/dist/plugins/approval-ask-store-sql.js +30 -12
  112. package/dist/plugins/caching-session-store.d.ts +6 -2
  113. package/dist/plugins/caching-session-store.js +9 -3
  114. package/dist/plugins/checkpoint-store-sql.d.ts +6 -5
  115. package/dist/plugins/checkpoint-store-sql.js +5 -7
  116. package/dist/plugins/device-store-sql.d.ts +25 -51
  117. package/dist/plugins/device-store-sql.js +49 -103
  118. package/dist/plugins/execution-placement-sql.d.ts +151 -0
  119. package/dist/plugins/execution-placement-sql.js +223 -0
  120. package/dist/plugins/file-history-store-sql.d.ts +7 -6
  121. package/dist/plugins/file-history-store-sql.js +5 -5
  122. package/dist/plugins/file-run-store.d.ts +6 -1
  123. package/dist/plugins/file-run-store.js +4 -0
  124. package/dist/plugins/fork-routing-session-store.d.ts +6 -0
  125. package/dist/plugins/fork-routing-session-store.js +16 -0
  126. package/dist/plugins/local-session-store.d.ts +149 -50
  127. package/dist/plugins/local-session-store.js +382 -107
  128. package/dist/plugins/memory-key-guards.d.ts +4 -0
  129. package/dist/plugins/memory-key-guards.js +1 -0
  130. package/dist/plugins/memory-run-store.d.ts +1 -1
  131. package/dist/plugins/one-time-migrations.d.ts +111 -3
  132. package/dist/plugins/one-time-migrations.js +380 -10
  133. package/dist/plugins/outcome-ledger-sql.d.ts +2 -2
  134. package/dist/plugins/permission-rule-store-sql.d.ts +13 -15
  135. package/dist/plugins/permission-rule-store-sql.js +13 -16
  136. package/dist/plugins/pg-pool.d.ts +7 -3
  137. package/dist/plugins/pg-session-storage.d.ts +31 -20
  138. package/dist/plugins/pg-session-storage.js +120 -52
  139. package/dist/plugins/remote-env-adb.d.ts +20 -4
  140. package/dist/plugins/remote-env-adb.js +70 -62
  141. package/dist/plugins/remote-env-device.d.ts +30 -12
  142. package/dist/plugins/remote-env-device.js +18 -17
  143. package/dist/plugins/remote-env-e2b.d.ts +18 -4
  144. package/dist/plugins/remote-env-e2b.js +41 -42
  145. package/dist/plugins/remote-env-file-error.d.ts +2 -0
  146. package/dist/plugins/remote-env-file-error.js +1 -1
  147. package/dist/plugins/remote-env-host.d.ts +21 -8
  148. package/dist/plugins/remote-env-host.js +66 -70
  149. package/dist/plugins/remote-env-k8s.d.ts +11 -16
  150. package/dist/plugins/remote-env-k8s.js +61 -54
  151. package/dist/plugins/remote-env-local-docker.d.ts +29 -6
  152. package/dist/plugins/remote-env-local-docker.js +46 -59
  153. package/dist/plugins/remote-env-ssh.d.ts +16 -4
  154. package/dist/plugins/remote-env-ssh.js +72 -56
  155. package/dist/plugins/remote-shell.d.ts +100 -3
  156. package/dist/plugins/remote-shell.js +166 -2
  157. package/dist/plugins/retention-store-sql.d.ts +9 -6
  158. package/dist/plugins/retention-store-sql.js +7 -1
  159. package/dist/plugins/run-store-sql.d.ts +13 -3
  160. package/dist/plugins/run-store-sql.js +5 -0
  161. package/dist/plugins/schema-consistency.d.ts +15 -5
  162. package/dist/plugins/schema-consistency.js +14 -8
  163. package/dist/plugins/schema-drift-sentence.d.ts +27 -0
  164. package/dist/plugins/schema-drift-sentence.js +38 -0
  165. package/dist/plugins/session-fork.d.ts +54 -0
  166. package/dist/plugins/session-fork.js +38 -1
  167. package/dist/plugins/session-row-sql.d.ts +22 -0
  168. package/dist/plugins/session-row-sql.js +81 -0
  169. package/dist/plugins/session-store.d.ts +1 -1
  170. package/dist/plugins/store-backend.d.ts +1 -1
  171. package/dist/plugins/store-backend.js +19 -7
  172. package/dist/plugins/store-contracts.d.ts +25 -5
  173. package/dist/plugins/tidb-pool.d.ts +12 -7
  174. package/dist/plugins/tidb-pool.js +9 -3
  175. package/dist/plugins/tidb-session-store.d.ts +57 -26
  176. package/dist/plugins/tidb-session-store.js +118 -50
  177. package/dist/plugins/tool-result-store-sql.d.ts +3 -2
  178. package/dist/plugins/tool-result-store-sql.js +18 -10
  179. package/dist/project-memory.d.ts +9 -15
  180. package/dist/project-memory.js +5 -4
  181. package/dist/request-key-closure.js +1 -1
  182. package/dist/request-value-closure.d.ts +6 -7
  183. package/dist/request-value-closure.js +1 -3
  184. package/dist/run-local.d.ts +6 -0
  185. package/dist/run-local.js +24 -20
  186. package/dist/runs.d.ts +2 -1
  187. package/dist/runs.js +3 -1
  188. package/dist/security.d.ts +38 -12
  189. package/dist/security.js +8 -2
  190. package/dist/server-secret-env.d.ts +9 -5
  191. package/dist/session-children-retire.d.ts +27 -9
  192. package/dist/session-children-retire.js +9 -5
  193. package/dist/session-incarnation.d.ts +19 -9
  194. package/dist/session-incarnation.js +4 -2
  195. package/dist/session-row.d.ts +114 -5
  196. package/dist/session-row.js +55 -5
  197. package/dist/session-titler.d.ts +2 -2
  198. package/dist/task-a2a.d.ts +1 -1
  199. package/dist/task-cwd.d.ts +6 -1
  200. package/dist/task-mcp.d.ts +3 -3
  201. package/dist/tool-approval.d.ts +6 -0
  202. package/dist/tool-approval.js +8 -1
  203. package/dist/trace/project.d.ts +1 -0
  204. package/dist/trace/project.js +1 -0
  205. package/dist/trace/self-minted-wire.d.ts +111 -2
  206. package/dist/trace/self-minted-wire.js +38 -1
  207. package/dist/url-refusal.d.ts +1 -1
  208. package/dist/web-admin-client/apply-effective.d.ts +17 -3
  209. package/dist/web-admin-client/apply-effective.js +20 -10
  210. package/dist/web-admin-client/skills-mcp.js +3 -2
  211. package/dist/web-admin-client/types.d.ts +6 -0
  212. package/package.json +3 -3
  213. package/wire-changes/server@7.139.0.json +348 -0
  214. package/dist/http/device-placement.d.ts +0 -175
  215. package/dist/http/device-placement.js +0 -145
@@ -6,9 +6,6 @@ import { parkDecisionFingerprint } from "../orchestration/workflow-park-decision
6
6
  import { headReadOf } from "../session-incarnation.js";
7
7
  import { createSessionWorkspaceRecorder, legSessionWitnessOf, sessionWorkspaceStoreOf } from "../session-workspace.js";
8
8
  const DELIVERY_NOT_RECORDED = "workflow park decide: this delivery no longer holds the host session (its claim was released) — the decision was not recorded and this delivery was not started";
9
- const headUnavailable = async (sessionId) => {
10
- throw new Error(`parked decide cannot verify which occupant session ${sessionId} belongs to: this deployment's session store has no head read (headOf; every built-in store has one) — refusing rather than resuming unverified`);
11
- };
12
9
  import { HttpError, decodeCheckpointScope, CHECKPOINT_PUBLIC_SCOPE } from "../security.js";
13
10
  import { graftParkToolCallId, parkToolCallId, TurnAnchorCapture, HEARTBEAT_MS, markChildrenStoppedByUserOnAbort } from "../runs.js";
14
11
  import { failureCodeOf, nestedPauseOf, sealResultForExit, outwardPlane } from "../terminal.js";
@@ -50,7 +47,7 @@ import { createImagesLocal } from "./routes/images.js";
50
47
  import { createCapabilitiesLocal } from "./routes/capabilities.js";
51
48
  import { gatedPrincipal, explicitOperatorOk, runIdentityOf } from "./principal-gate.js";
52
49
  import { buildActiveRunConflict, resumeEntryForGate } from "./active-run-conflict.js";
53
- import { commitPlacement, devicePlacementErrorBody } from "./device-placement.js";
50
+ import { commitExecutionContext, isRootSessionGoneError, resolveExecutionContext, rootSessionAbsentAnswer, stampSpecRoot } from "./execution-context.js";
54
51
  import { commitSessionInheritEnv } from "./session-inherit-env.js";
55
52
  import { ASK_USER_QUESTION_TOOL_NAME, isApprovalGateKind, isEngineQuestionRow } from "../approval-content-kind.js";
56
53
  import { deploymentListAsks } from "../approval.js";
@@ -102,6 +99,7 @@ const WORKER_SWAP_REDEEMABLE = {
102
99
  unknown_model_ref: true,
103
100
  not_a_model_ref: true,
104
101
  unknown_role_word: true,
102
+ no_model_for_role: true,
105
103
  };
106
104
  function checkpointRowRedeemableElsewhere(reason) {
107
105
  return reason !== undefined && WORKER_SWAP_REDEEMABLE[reason] === true;
@@ -416,10 +414,30 @@ export function createResumeLegs(ctx) {
416
414
  checkpointStore: cs,
417
415
  registry: defaultTaskRegistry,
418
416
  reviveSurface: parkedSurface,
419
- headOf: headReadOf(deps.sessionStorage) ?? headUnavailable,
420
417
  ...(deps.parkedKnownAgentTypes ? { knownAgentTypes: deps.parkedKnownAgentTypes } : {}),
421
418
  ...(deps.parkedReviveInheritedGate ? { rebuildInheritedGate: deps.parkedReviveInheritedGate } : {}),
422
419
  ...(deps.toolApproval ? { approverSeatFor: windowZeroSeatOf(deps.toolApproval) } : {}),
420
+ execution: {
421
+ resolve: async (root) => {
422
+ let ctx;
423
+ try {
424
+ ctx = await resolveExecutionContext(deps, { kind: "continuation", root, principal: decodeCheckpointScope(cp.scope) });
425
+ }
426
+ catch (e) {
427
+ if (isRootSessionGoneError(e))
428
+ return { ok: false, gone: true };
429
+ if (e instanceof HttpError)
430
+ return { ok: false, gone: false, answer: resumeRefusalWithCard(resumeBlockedByPolicyAnswer(httpErrorRefusal(e)), true) };
431
+ throw e;
432
+ }
433
+ const occupant = ctx.occupant;
434
+ if (occupant === undefined) {
435
+ throw new Error(`parked decide: root session ${root} resolved without an occupant reading — the parked revive leg is wired to a session store without an execution placement seat (sessionStorage.executionPlacement), which createHttpServer should have refused at startup; refusing rather than resuming with nobody judging the root session's occupant after the claim`);
436
+ }
437
+ return { ok: true, ctx: { ...ctx, occupant } };
438
+ },
439
+ commitDeps: deps,
440
+ },
423
441
  ...(deps.logger ? { warn: parkWarn } : {}),
424
442
  }, { ...parkedReq, selectedGate: captureSelectedGate() }, { handle: origin.handle, row: origin.row }));
425
443
  void ctxPromise.catch(() => { });
@@ -520,7 +538,7 @@ export function createResumeLegs(ctx) {
520
538
  hostDecision,
521
539
  };
522
540
  const verifyRounds = verifyRoundsFromBody(ctx.body);
523
- const driven = await driveResumeIntoRunLog({ peerLease: resolved.peerLease, runner: runnerFor(spec), token, sessionId: cp.sessionId, principal: auth.principal, fleetScope: resumeFleetScope(req, auth), taskConfig, resumeObjective, outcome, commitment: { kind: "gate_resolved", ...captureSelectedGate() }, verifyRounds, resumeCtx: { sessionId, ctx }, ...(engineNotices.length > 0 ? { engineNotices } : {}), ...(boundGrant ? { onResumeCommitted: boundGrant } : {}), ...(acceptEarly === true ? { acceptEarly: true } : {}), ...(req !== undefined ? { callerInitiated: true } : {}), ...(ownerLeaseAdmittedHere ? { ownerLeaseAdmitted: true } : {}) });
541
+ const driven = await driveResumeIntoRunLog({ peerLease: resolved.peerLease, execution: resolved.execution, runner: runnerFor(spec), token, sessionId: cp.sessionId, principal: auth.principal, fleetScope: resumeFleetScope(req, auth), taskConfig, resumeObjective, outcome, commitment: { kind: "gate_resolved", ...captureSelectedGate() }, verifyRounds, resumeCtx: { sessionId, ctx }, ...(engineNotices.length > 0 ? { engineNotices } : {}), ...(boundGrant ? { onResumeCommitted: boundGrant } : {}), ...(acceptEarly === true ? { acceptEarly: true } : {}), ...(req !== undefined ? { callerInitiated: true } : {}), ...(ownerLeaseAdmittedHere ? { ownerLeaseAdmitted: true } : {}) });
524
542
  noteLateSettleIfAccepted(driven);
525
543
  return driven;
526
544
  }
@@ -528,17 +546,19 @@ export function createResumeLegs(ctx) {
528
546
  resolved.peerLease.release();
529
547
  }
530
548
  }
531
- const ONE_SHOT_SUBMIT_KEYS = ["resumeAt", "restoreFiles", "acceptPartialRestore", "rewindFilesTo", "rewindFiles", "userAttachments", "images"];
549
+ const ONE_SHOT_SUBMIT_KEYS = ["resumeAt", "restoreFiles", "acceptPartialRestore", "rewindFilesTo", "userAttachments", "images"];
532
550
  const ONE_SHOT_SUBMIT_STRIP = Object.fromEntries(ONE_SHOT_SUBMIT_KEYS.map((k) => [k, undefined]));
533
551
  const resolveResumeSpec = async (ctx, req, auth, lease) => {
534
552
  const engineNotices = [];
535
- const spec = await deps.resolveSpec({ ...ctx.body, ...ONE_SHOT_SUBMIT_STRIP }, req, auth, {
553
+ const execution = await resolveExecutionContext(deps, { kind: "continuation", root: auth.sessionId, principal: auth.principal });
554
+ const spec = stampSpecRoot(await deps.resolveSpec({ ...ctx.body, ...ONE_SHOT_SUBMIT_STRIP }, req, auth, {
536
555
  leg: "resume",
537
556
  onNotice: (n) => engineNotices.push(n),
538
557
  ...(ctx.unattendedLeg === true ? { unattendedLeg: true } : {}),
539
558
  lease,
540
- });
541
- return { spec, engineNotices };
559
+ execution,
560
+ }), execution);
561
+ return { spec, engineNotices, execution };
542
562
  };
543
563
  const refuseStoredBodyReplay = (sessionId, refusal) => {
544
564
  deps.logger?.warn?.("resume_refused_stored_body", { sessionId, ...storedBodyReplayLogFields(refusal) });
@@ -550,8 +570,8 @@ export function createResumeLegs(ctx) {
550
570
  return { ok: false, answer: refuseStoredBodyReplay(auth.sessionId, replay) };
551
571
  const lease = deps.peerDesk.lease("resume");
552
572
  try {
553
- const { spec, engineNotices } = await resolveResumeSpec(ctx, req, auth, lease);
554
- return { ok: true, spec, engineNotices, peerLease: lease };
573
+ const { spec, engineNotices, execution } = await resolveResumeSpec(ctx, req, auth, lease);
574
+ return { ok: true, spec, engineNotices, execution, peerLease: lease };
555
575
  }
556
576
  catch (e) {
557
577
  lease.release();
@@ -583,7 +603,7 @@ export function createResumeLegs(ctx) {
583
603
  }
584
604
  async function driveResumeLeg(args, onAccepted) {
585
605
  const resumeLegStartedAt = Date.now();
586
- const { runner: legRunner, token, sessionId, principal, fleetScope, taskConfig, resumeObjective, outcome, verifyRounds, resumeCtx } = args;
606
+ const { runner: legRunner, token, sessionId, principal, fleetScope, taskConfig, resumeObjective, outcome, verifyRounds, resumeCtx, execution } = args;
587
607
  const reparkStatus = outcome.gate === "plan_review" ? "needs_review" : "suspended";
588
608
  const commitResumeCtx = () => Promise.resolve().then(() => deps.checkpointStore.putCtx(resumeCtx.sessionId, sealCheckpointCtx({ ...resumeCtx.ctx, writerVersion: serviceVersion() }, deps.checkpointCtxSealing, resumeCtx.sessionId))).catch((e) => {
589
609
  deps.metrics?.inc("resume_ctx_not_persisted_total");
@@ -627,12 +647,14 @@ export function createResumeLegs(ctx) {
627
647
  if (created.existing) {
628
648
  throw new Error(`wake fresh run: createRun observed an existing row for the freshly minted taskId ${freshId} (no idempotency key was sent) — refusing to treat it as a busy session`);
629
649
  }
650
+ if (created.sessionAbsent)
651
+ return rootSessionAbsentAnswer();
630
652
  if (!created.ok) {
631
653
  return { status: 409, body: await buildActiveRunConflict({ runStore: deps.runStore, checkpointStore: deps.checkpointStore, governance: deps.config, runStaleSec: deps.config.runStaleSec, turnActivity: readTurnActivityMs }, sessionId, created.activeTaskId) };
632
654
  }
633
- const placementReject = await commitPlacement(deps, deps.runStore, freshId, undefined);
655
+ const placementReject = await commitExecutionContext(deps, { kind: "run", runStore: deps.runStore, taskId: freshId }, execution);
634
656
  if (placementReject)
635
- return { status: placementReject.status, body: devicePlacementErrorBody(placementReject) };
657
+ return placementReject;
636
658
  taskId = freshId;
637
659
  freshRunClaimed = true;
638
660
  }
@@ -643,11 +665,12 @@ export function createResumeLegs(ctx) {
643
665
  if (deps.toolApproval && taskId) {
644
666
  const toolApproval = deps.toolApproval;
645
667
  resumeTaskConfig.onAsk = approvalLeg.unattended
646
- ? toolApproval.boundUnattendedAsk({ owner: principal ?? null, taskId, sessionId }, approvalLeg.unattended)
668
+ ? toolApproval.boundUnattendedAsk({ owner: principal ?? null, taskId, sessionId, executionLane: execution.lane }, approvalLeg.unattended)
647
669
  : toolApproval.boundAsk({
648
670
  owner: principal ?? null,
649
671
  taskId,
650
672
  sessionId,
673
+ executionLane: execution.lane,
651
674
  ...(approvalLeg.active ? { legKey, ...(legDeadlineMonotonic !== undefined ? { legDeadlineMonotonic } : {}) } : {}),
652
675
  });
653
676
  }
@@ -713,6 +736,11 @@ export function createResumeLegs(ctx) {
713
736
  claimedRow = freshRunClaimed ? true : await deps.runStore.markResuming(taskId, deps.instanceId ?? "default");
714
737
  if (!claimedRow)
715
738
  return notResumableAnswer();
739
+ if (!freshRunClaimed) {
740
+ const placementReject = await commitExecutionContext(deps, { kind: "run", runStore: deps.runStore, taskId }, execution);
741
+ if (placementReject)
742
+ return placementReject;
743
+ }
716
744
  markChildrenStoppedByUserOnAbort(cancelCtrl.signal, taskId, principal);
717
745
  fleetPub?.onStart();
718
746
  const rs = deps.runStore;
@@ -1140,6 +1168,7 @@ export function createResumeLegs(ctx) {
1140
1168
  owner: principal ?? null,
1141
1169
  abortSignal: cancelCtrl.signal,
1142
1170
  sessionId,
1171
+ executionLane: execution.lane,
1143
1172
  emitCard: createApprovalCardEmitter({ appendDurable: (f) => approvalEmit(f) }),
1144
1173
  emitRevoke: (f) => approvalEmit(f),
1145
1174
  legKey,
@@ -1328,7 +1357,7 @@ export function createResumeLegs(ctx) {
1328
1357
  const { objective: resumeObjective, sessionId: _sessionId, ...taskConfig } = spec;
1329
1358
  const verifyRounds = verifyRoundsFromBody(ctx.body);
1330
1359
  const selectedGate = durableRowIdentity("decision-receipt", cp.checkpointId, token);
1331
- return await driveResumeIntoRunLog({ peerLease: resolved.peerLease, runner: runnerFor(spec), token, sessionId: cp.sessionId, principal: auth.principal, fleetScope: resumeFleetScope(req, auth), taskConfig, resumeObjective, outcome: { gate: "resource_limit", decision: "continue" }, commitment: { kind: "gate_resolved", ...selectedGate }, verifyRounds, resumeCtx: { sessionId, ctx }, ...(engineNotices.length > 0 ? { engineNotices } : {}), ...(acceptEarly === true ? { acceptEarly: true } : {}), ...(req !== undefined ? { callerInitiated: true } : {}) });
1360
+ return await driveResumeIntoRunLog({ peerLease: resolved.peerLease, execution: resolved.execution, runner: runnerFor(spec), token, sessionId: cp.sessionId, principal: auth.principal, fleetScope: resumeFleetScope(req, auth), taskConfig, resumeObjective, outcome: { gate: "resource_limit", decision: "continue" }, commitment: { kind: "gate_resolved", ...selectedGate }, verifyRounds, resumeCtx: { sessionId, ctx }, ...(engineNotices.length > 0 ? { engineNotices } : {}), ...(acceptEarly === true ? { acceptEarly: true } : {}), ...(req !== undefined ? { callerInitiated: true } : {}) });
1332
1361
  }
1333
1362
  finally {
1334
1363
  resolved.peerLease.release();
@@ -1367,9 +1396,9 @@ export function createResumeLegs(ctx) {
1367
1396
  return resolved.answer;
1368
1397
  try {
1369
1398
  const { spec, engineNotices } = resolved;
1370
- const hostSpec = { ...spec, objective, sessionId };
1399
+ const hostSpec = { ...spec, objective };
1371
1400
  const out = await mintAndDriveDurableRun(routeCtxBase, deps.runStore, {
1372
- prepared: { spec: hostSpec, runner: runnerFor(hostSpec), auth, body: ctx.body, acceptance: createRunAcceptance({}, { objective }), peerLease: resolved.peerLease, ...(engineNotices.length > 0 ? { engineNotices } : {}) },
1401
+ prepared: { spec: hostSpec, runner: runnerFor(hostSpec), auth, body: ctx.body, acceptance: createRunAcceptance({}, { objective }), peerLease: resolved.peerLease, execution: resolved.execution, ...(engineNotices.length > 0 ? { engineNotices } : {}) },
1373
1402
  fleetScope: resumeFleetScope(req, auth),
1374
1403
  elicitOwner: principal ?? null,
1375
1404
  source: "workflow_parked_decide",
@@ -1455,6 +1484,7 @@ export function createResumeLegs(ctx) {
1455
1484
  const { objective: resumeObjective, sessionId: _sessionId, ...taskConfig } = spec;
1456
1485
  return await driveResumeIntoRunLog({
1457
1486
  peerLease: resolved.peerLease,
1487
+ execution: resolved.execution,
1458
1488
  runner: runnerFor(spec),
1459
1489
  token,
1460
1490
  sessionId: cp.sessionId,
@@ -1581,7 +1611,7 @@ export function createResumeLegs(ctx) {
1581
1611
  hostDecision,
1582
1612
  };
1583
1613
  const selectedGate = durableRowIdentity("decision-receipt", cp.checkpointId, token);
1584
- const out = await driveResumeIntoRunLog({ peerLease: resolved.peerLease, runner: runnerFor(spec), token, sessionId: cp.sessionId, principal: auth.principal, fleetScope: resumeFleetScope(req, auth), taskConfig, resumeObjective, outcome, commitment: { kind: "gate_resolved", ...selectedGate }, verifyRounds, resumeCtx: { sessionId, ctx: rebuildCtx }, ...(engineNotices.length > 0 ? { engineNotices } : {}), ...(acceptEarly === true ? { acceptEarly: true } : {}), ...(req !== undefined ? { callerInitiated: true } : {}) });
1614
+ const out = await driveResumeIntoRunLog({ peerLease: resolved.peerLease, execution: resolved.execution, runner: runnerFor(spec), token, sessionId: cp.sessionId, principal: auth.principal, fleetScope: resumeFleetScope(req, auth), taskConfig, resumeObjective, outcome, commitment: { kind: "gate_resolved", ...selectedGate }, verifyRounds, resumeCtx: { sessionId, ctx: rebuildCtx }, ...(engineNotices.length > 0 ? { engineNotices } : {}), ...(acceptEarly === true ? { acceptEarly: true } : {}), ...(req !== undefined ? { callerInitiated: true } : {}) });
1585
1615
  return out;
1586
1616
  }
1587
1617
  finally {
@@ -32,6 +32,7 @@ import type { VerifyRoundsSpec } from "./verify-rounds.js";
32
32
  import type { RunAbort } from "../run-cancel-context.js";
33
33
  import type { RunIdentity } from "../identity-source.js";
34
34
  import type { PeerLease } from "../peer-seat.js";
35
+ import type { RootedTaskSpec } from "./execution-context.js";
35
36
  import type { GateCommitment, GateSettlementEvidence } from "../gate-settlement.js";
36
37
  /** 跨域可变状态 = lens1 §C2 实测的那 8 条「运行期登记簿」,一个对象。
37
38
  * [ref]:`cancelledViaVerb`(按 taskId 键控的「这次是取消动词」旁表)删除 —— 中止成因改挂在每条腿自己的
@@ -117,11 +118,13 @@ export interface RouteRequestState {
117
118
  /** B4①(命名化):`prepareSpec` 的返回形(tasks.ts/runs.ts 两域共同消费的属性面)——原为 6 成员内联匿名对象。
118
119
  * 命名后好处不只是过 B4 门:两个消费域现在都能对同一个符号做 `import type` 标注,而不是各自重推断结构。 */
119
120
  export interface PreparedTaskSubmission {
120
- spec: TaskSpec;
121
+ /** 盖过根的 spec(`http/execution-context.ts` `stampSpecRoot` 是唯一写者):`sessionId` 恒在 = 这次提交的根会话。 */
122
+ spec: RootedTaskSpec;
121
123
  /** [ref]:本次提交的执行 Runner —— 场景 hands=none 时是无手孪生(不挂 executionEnvFactory)。
122
124
  * 两个消费域一律用它,不再直接摸 `deps.runner`(那正是「场景表态了、执行点没听」的缝)。 */
123
125
  runner: Runner;
124
- auth?: RequestAuth;
126
+ /** authorizer 交出的身份与根(必填席,恒在;续跑家族的内部车道交它从 checkpoint 重建的那一只)。 */
127
+ auth: RequestAuth;
125
128
  verify?: VerifyRoundsSpec;
126
129
  cascade?: boolean;
127
130
  jobId?: string;
@@ -137,13 +140,11 @@ export interface PreparedTaskSubmission {
137
140
  */
138
141
  warnings?: readonly EntryProblem[];
139
142
  /**
140
- * [ref](device lane 首绑写协议,design §4.3.2 两阶段 placement)—— **已校验、未写库**的 placement 意图。
141
- *
142
- * 缺席的两种含义都等价于「本腿没有 placement 要提交」:①本部署不是 device 车道(绝大多数);
143
- * ②本腿不是 fresh 根提交(resume 家族根本不经 `prepareSpec`)。在场 ⇒ 每个 `runStore.createRun(`
144
- * 成功点**必须**把它交给 `commitPlacement`(唯一写入属主;类级机器门钉住每个调用点都接了)。
143
+ * 这次执行的**执行上下文**(唯一解析器 `http/execution-context.ts` `resolveExecutionContext` 的产物:根 · 住客 · 放置态 · 车道 · device 车道的设备意图)。
144
+ * 提交门在装配之前铸(fresh);`/decide` 的宿主新 turn 交续跑折叠器铸的那一只(continuation)。每个 `runStore.createRun(` 成功点**必须**把它交给
145
+ * 认领之后的唯一写口 `commitExecutionContext`(类级机器门钉住每个调用点都接了)。
145
146
  */
146
- bindingIntent?: import("./device-placement.js").DevicePlacementIntent;
147
+ execution: import("./execution-context.js").RootedExecutionContext;
147
148
  /**
148
149
  * 这次提交的**受理座**(`run-acceptance.ts`):标题器只在引擎受理之后起。提交门(`prepareSpec`)随 spec 一起铸;
149
150
  * 三条提交腿只喂它(持流的形逐帧 `observe`,终局落行之后 `settle` 一次),不再各自判「什么时候起标题」。
@@ -226,6 +227,11 @@ export interface WorkflowDecisionDelivery {
226
227
  /** B4①(命名化):`driveResumeIntoRunLog` 的入参形(resume 家族共用)——原为 9 成员内联匿名对象。 */
227
228
  export interface DriveResumeArgs {
228
229
  token: CheckpointToken;
230
+ /**
231
+ * 这条续跑的执行上下文(续跑折叠器经唯一解析器铸的 continuation 形:根 = checkpoint 的会话)。本腿认领之后(wake 的建行 / `markResuming` CAS 赢)
232
+ * 交给唯一写口 `commitExecutionContext`(按解析器那一读的住客再断言 writer(L) / absent 落格;住客换了 ⇒ 释放 + 404)。
233
+ */
234
+ execution: import("./execution-context.js").RootedExecutionContext;
229
235
  /**
230
236
  * 🔴 **[ref] 合并复审 F2 [high] —— 「本次续跑真正用的那个体」,由本腿在赢下 CAS 之后写回耐久层**
231
237
  * (验真后修;必填 —— `driveResumeIntoRunLog` 今天恰有**四个**调用点:legacy `/decide`(`resumeCheckpoint`)、
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * [ref](core 7.32.0 [ref] 的 server 半场;wire 形 [ref] / [ref] / [ref] 定)—— **会话后台任务的两只读口**:
3
3
  *
4
- * `GET /v1/sessions/:id/background` → 200 `{ rows: [{ id, description?, type, status, createdAt, sessionScoped, retained? }] }`
4
+ * `GET /v1/sessions/:id/background` → 200 `{ rows: [{ id, description?, type, status, createdAt, sessionScoped, retained?, sessionId? }] }`
5
5
  * `POST /v1/sessions/:id/background/stop` body `{ includeRetained: boolean }` → 200 `{ receipts: [{ id, outcome }] }`
6
6
  *
7
7
  * 消费方:cli [ref] ①(删前核登记)/ [ref](退出三选一:「Exit and stop tasks」= `includeRetained:true`,`sema kill` =
@@ -28,6 +28,16 @@
28
28
  * oracle);显式 operator 可读可停任何会话(监督动作)。零模型工作 ⇒ 不进 billable、不吃 drain 503;停止口是 mutating ⇒
29
29
  * `rateLimited`。能力位 `background: { listFace, exitFaces }` 与两口的 501 从同一个谓词推导(会话属主面在场)。
30
30
  *
31
+ * ── 结清口:子代结清只有一只协调器 ─────────────────────────────────────────────────────────────────
32
+ * `POST /v1/sessions/:id/children/retire`(无体)→ 200 `{ children: string[] }`:**显式**结清以本会话为根的后台子代(running 停掉、parked
33
+ * 作废卡并对账翻终态),与 `DELETE /v1/sessions/:id` 同调**一只**预检 `probeSessionChildren` 与**一只**协调器 `retireSessionChildren`
34
+ * (`session-children-retire.ts`)、同一个 scope(`sessionChildrenScope`)。预检有本副本结束不了的 ⇒ 409 `conflict.session_children_alive{remaining}`
35
+ * (与删除门同码同投影,一只都不动);停不净(时序失败)⇒ 与删除门同一失败契约(通用 500,重试收敛)。`children` = 首轮观察到、且已全部
36
+ * 结清的子代,每项经名册的 id 投影 `rosterAgentId`(与换绑 / 导入两门 409 体的 `children` 同形同投影)。它不判根认领(停止口也不判):
37
+ * 根 run 活着时它可再派子代 —— 由换绑 / 导入门写事务内的认领读兜。鉴权 / 限流 / 无凭据写门与停止口同。
38
+ * **停止口的已发语义逐字不动**:它只中止 running 的后台 agent / workflow 并收 shell,停驻子代与待决卡留着 —— 「停别的、留卡待决」与
39
+ * 「把树结清」是两个意图、两只口。
40
+ *
31
41
  * 🔴 **停止口在无凭据写门内**(`credentialGated: ["POST"]`;车OL F1 = 7.102.0 合并复审 gpt-6 腿 [high]):门的两个合取项它都中
32
42
  * —— ① 授权的唯一输入是 principal 头(属主判 `sessionOwnerOk` 与 operator 臂 `explicitOperatorOk` 读的都是 `gatedPrincipal`,
33
43
  * 无 service 凭证的部署上它收调用方自报的头);② 不可撤销、爆炸半径跨租户(知道会话 id + 自报它的属主 ⇒ 停掉那个会话的后台
@@ -53,12 +63,24 @@ export interface SessionBackgroundRow {
53
63
  createdAt: number;
54
64
  sessionScoped: boolean;
55
65
  retained?: true;
66
+ /** 后台 agent 行的子会话 id(引擎绑定之后才有;缺席 ⇒ 键不在)。 */
67
+ sessionId?: string;
56
68
  }
57
69
  /** 停止口的一条受据(`outcome` 逐字透传 core 闭集)。 */
58
70
  export interface SessionBackgroundReceipt {
59
71
  id: string;
60
72
  outcome: BackgroundShellHardKillReceipt["outcome"];
61
73
  }
74
+ /** 结清口的路径(换绑 / 导入两门的子代半 409 以它点名下一步;与执行面拒体带 `forkPath` 同一形)。 */
75
+ export declare function childrenRetirePath(sessionId: string): string;
76
+ /**
77
+ * 子代半命中的拒体两键(换绑 409 `device.rebind_active_run`、导入 409 `session_active` 同一只):`children` = 预检那一次枚举观察到的活子代
78
+ * (`probeSessionChildren` 的 `liveChildren`)经名册 id 投影,`retirePath` = 结清口。
79
+ */
80
+ export declare function liveChildrenRefusal(sessionId: string, liveChildren: readonly string[]): {
81
+ children: string[];
82
+ retirePath: string;
83
+ };
62
84
  export declare function handleBackground(req: IncomingMessage, res: ServerResponse, match: RouteMatch<BackgroundRouteId>, ctx: RouteCtx): Promise<void>;
63
85
  export declare const BACKGROUND_ROUTES: readonly [{
64
86
  readonly id: "session-background-list";
@@ -71,6 +93,12 @@ export declare const BACKGROUND_ROUTES: readonly [{
71
93
  readonly label: "/v1/sessions/:id/background/stop";
72
94
  readonly credentialGated: readonly ["POST"];
73
95
  readonly methods: readonly ["POST"];
96
+ }, {
97
+ readonly id: "session-children-retire";
98
+ readonly pattern: RegExp;
99
+ readonly label: "/v1/sessions/:id/children/retire";
100
+ readonly credentialGated: readonly ["POST"];
101
+ readonly methods: readonly ["POST"];
74
102
  }];
75
103
  /** 本域可分派行的 `id` 闭集 —— handler 的 `switch` 按它判穷尽(漏一口 = 编译红)。 */
76
104
  export type BackgroundRouteId = RouteIdsOf<typeof BACKGROUND_ROUTES>;
@@ -1,6 +1,8 @@
1
1
  import { z } from "zod";
2
2
  import { defaultTaskRegistry } from "@sema-agent/core";
3
3
  import { reapSessionBackgroundHard } from "../../background-session-reap.js";
4
+ import { probeSessionChildren, retireSessionChildren, sessionChildrenScope, SessionChildrenStopIncompleteError } from "../../session-children-retire.js";
5
+ import { rosterAgentId } from "./agents-roster.js";
4
6
  import { redactSecrets } from "../../trace/redact.js";
5
7
  import { sendJson, sendError } from "../send.js";
6
8
  import { gatedPrincipal, explicitOperatorOk, sendPrincipalRefusal } from "../principal-gate.js";
@@ -20,6 +22,7 @@ const ROW_KEY_DISPOSITION = {
20
22
  description: "keep",
21
23
  toolUseId: "strip",
22
24
  updatedAt: "strip",
25
+ sessionId: "keep",
23
26
  };
24
27
  void ROW_KEY_DISPOSITION;
25
28
  function projectRow(r) {
@@ -31,13 +34,20 @@ function projectRow(r) {
31
34
  createdAt: r.createdAt,
32
35
  sessionScoped: r.sessionScoped,
33
36
  ...(r.retained === true ? { retained: true } : {}),
37
+ ...(r.sessionId !== undefined ? { sessionId: r.sessionId } : {}),
34
38
  };
35
39
  }
40
+ export function childrenRetirePath(sessionId) {
41
+ return `/v1/sessions/${encodeURIComponent(sessionId)}/children/retire`;
42
+ }
43
+ export function liveChildrenRefusal(sessionId, liveChildren) {
44
+ return { children: liveChildren.map((handle) => rosterAgentId({ handle })), retirePath: childrenRetirePath(sessionId) };
45
+ }
36
46
  const StopBodySchema = z.object({ includeRetained: z.boolean() }).strict();
37
47
  export async function handleBackground(req, res, match, ctx) {
38
48
  const { deps } = ctx;
39
49
  const id = match.decl.id;
40
- if (id === "session-background-stop" && ctx.helpers.rateLimited(req, res))
50
+ if ((id === "session-background-stop" || id === "session-children-retire") && ctx.helpers.rateLimited(req, res))
41
51
  return;
42
52
  const principal = gatedPrincipal(req, deps.config);
43
53
  if (deps.config.requirePrincipal && principal === undefined) {
@@ -71,7 +81,7 @@ export async function handleBackground(req, res, match, ctx) {
71
81
  const operator = explicitOperatorOk(principal, deps.config);
72
82
  if (!operator && !ctx.helpers.sessionOwnerOk(req, res, await sessionOwnerCellOf(owned.sessionStorage, sessionId, owner)))
73
83
  return;
74
- const scope = owner ?? "default";
84
+ const scope = sessionChildrenScope(owner);
75
85
  switch (id) {
76
86
  case "session-background-list": {
77
87
  const rows = defaultTaskRegistry.list({ owner: sessionId, sessionId, scope }, { limit: LIST_LIMIT }).map(projectRow);
@@ -83,6 +93,22 @@ export async function handleBackground(req, res, match, ctx) {
83
93
  sendJson(res, 200, { receipts: receipts.map((r) => ({ id: r.taskId, outcome: r.outcome })) });
84
94
  return;
85
95
  }
96
+ case "session-children-retire": {
97
+ const sources = { agentStore: deps.backgroundAgentStore, checkpointStore: deps.checkpointStore, registry: defaultTaskRegistry };
98
+ const reach = await probeSessionChildren(sources, sessionId, scope);
99
+ if (!reach.reachable) {
100
+ deps.logger?.warn("session_children_retire_unreachable", { sessionId, remaining: reach.unreachableHandles.length, handles: reach.unreachableHandles });
101
+ sendError(res, 409, "conflict.session_children_alive", "this session still has background agents running where this replica cannot stop them (on another replica, for example), so nothing was retired — `remaining` lists their ids (the same ids `GET /v1/agents/roster` shows); retry once they finish, or stop them where they run", { remaining: reach.unreachableHandles.map((handle) => rosterAgentId({ handle })) });
102
+ return;
103
+ }
104
+ const out = await retireSessionChildren(sources, sessionId, scope);
105
+ if (out.stopped === "incomplete") {
106
+ deps.logger?.warn("session_children_retire_incomplete", { sessionId, remaining: out.remainingHandles.length, handles: out.remainingHandles });
107
+ throw new SessionChildrenStopIncompleteError(sessionId, out.remainingHandles.length);
108
+ }
109
+ sendJson(res, 200, { children: out.observed.map((handle) => rosterAgentId({ handle })) });
110
+ return;
111
+ }
86
112
  default:
87
113
  return assertNever(id);
88
114
  }
@@ -90,5 +116,6 @@ export async function handleBackground(req, res, match, ctx) {
90
116
  export const BACKGROUND_ROUTES = [
91
117
  { id: "session-background-list", pattern: /^\/v1\/sessions\/(?<sessionId>[^/]+)\/background$/, label: "/v1/sessions/:id/background", methods: ["GET"] },
92
118
  { id: "session-background-stop", pattern: /^\/v1\/sessions\/(?<sessionId>[^/]+)\/background\/stop$/, label: "/v1/sessions/:id/background/stop", credentialGated: ["POST"], methods: ["POST"] },
119
+ { id: "session-children-retire", pattern: /^\/v1\/sessions\/(?<sessionId>[^/]+)\/children\/retire$/, label: "/v1/sessions/:id/children/retire", credentialGated: ["POST"], methods: ["POST"] },
93
120
  ];
94
121
  //# sourceMappingURL=background.js.map
@@ -8,19 +8,19 @@
8
8
  * **hub 在场性**判 —— v1 部署形 = 单副本(基线稿 §4.6),汇聚端的活连接集就是本副本的 presence
9
9
  * 真源;`name` = displayName,空串诚实缺席;`lastSeenAt` ISO-8601)。**吊销行不列**:revoked 是
10
10
  * 终态(重注册 = 新 deviceId),列出来只会被壳当换绑目标然后撞 404。
11
- * · `GET /v1/devices/sessions/:rootSessionId` —— S-4 绑定读面(壳换绑 UX 的全部材料):
12
- * `{ rootSessionId, boundDevice: { deviceId, rev, boundAt } }`。稿 §4 允许「并入 session 读面」或
13
- * 「独立读面」两形,选**独立读面**:sessions 的 GET 面承诺「无参请求字节不变」(routes/sessions.ts
14
- * 的 additive contract 注),且 audit 投影由 boot/session-faces 组装、不持 device 店 —— 并入即跨
15
- * 装配缝加一次店读;独立读面把 device wire 族收在一个文件里,户口与门序自洽。
16
- * · `POST /v1/devices/sessions/:rootSessionId/rebind` —— 显式换绑动词(v2 稿 §2 门序五步,逐字):
17
- * ① gatedPrincipal + owner 门(非属主 404 反枚举同串,[ref] 纪律)
11
+ * · 绑定读面在会话族:`GET /v1/sessions/:id/execution-placement`(`routes/sessions.ts`;体 = 执行放置格的 wire 投影,
12
+ * device 车道上是 `{ role: "writer", lane: "device", device: { deviceId, rev } }`)。绑定是根会话行上的执行格,读只有会话店的执行席一只。
13
+ * · `POST /v1/devices/sessions/:rootSessionId/rebind` —— 显式换绑动词(v2 稿 §2 门序五步):
14
+ * ① gatedPrincipal + owner 门 —— 一次行读(会话店执行席:住客 + 格),属主门判**格上**两属主列(非属主 404 反枚举同串,[ref] 纪律)
18
15
  * ② 目标设备在场门(不存在 / 不是该域的 / 已吊销 ⇒ 404 **同串** —— 设备 id 也是不可枚举面)
19
- * ③ 无活跃 run 门(**CAS 前判、CAS 语句不兼职**;durable 判据 = run store 的 session claim
20
- * `getActiveTaskId`,hub 内存在途计数不作门 —— 内存态跨副本不可见;拒 = 409
21
- * `device.rebind_active_run` 体带 `activeTaskId`)
22
- * ④ 前态 CAS(店内单事务:`rev` 恒变列 + `session_rebound` 审计行;输 = 409
23
- * `device.rebind_rev_conflict` 体带 `currentRev` —— [ref] currentPending 哲学,壳一跳重定位)
16
+ * ③ 无活跃 run 门:池级 `getActiveTaskId` 读一次作**早拒**(零审计行);**保证**在设备店的换绑事务里 —— 同一连接先锁根会话行、
17
+ * 再锁定读 `task_active`,有认领 ⇒ 不写(读写序律;hub 内存在途计数不作门 —— 内存态跨副本不可见);两处同拒 409
18
+ * `device.rebind_active_run` 体带 `activeTaskId`
19
+ * ③′ 子代半(写前一次观察):以根为根的活后台子代(running / parked;`probeSessionChildren` 那一次枚举,scope = 会话属主 `sessionChildrenScope`,
20
+ * 与删除门同读)⇒ 同码 409,体 `children`(名册 id)+ `retirePath`(显式结清口 —— 先结清、再换绑)
21
+ * ④ 前态 CAS(店内单事务:按门那一读的住客 ∧ 格上两属主列 ∧ rev 定址,`execution_rev` 恒变列 + `session_rebound` 审计行;输 = 409
22
+ * `device.rebind_rev_conflict` 体带 `currentRev` —— [ref] currentPending 哲学,壳一跳重定位;门后会话被删并同 id 重登记 ⇒ 住客不符 ⇒ 设备 404,
23
+ * 永不写到新住客的格上)
24
24
  * ⑤ 赢 = 200 `{ rootSessionId, deviceId, rev, workspaceCarryover: "none" }`。
25
25
  * 幂等形:toDeviceId ≡ 当前 ∧ expectedRev ≡ 当前 rev ⇒ 200 回显不加 rev(真幂等,不写审计)。
26
26
  * `workspaceCarryover: "none"` 是 **wire 冻结字面**(v2 稿 §5):换绑只改路由,不搬字节 —— 壳据
@@ -34,7 +34,7 @@
34
34
  * 才构成一个 device 域;缺 tenant claim 的凭据(纯 principal 头 / 静态 token 直连)**判不出域** ⇒
35
35
  * 非 operator 一律响亮 403(fail-closed + loud,[ref]:静默空列表是假健康)——基线稿对 enrollment 的
36
36
  * 同向裁定是「缺 tenant/global scope 明确拒注册」。enrollment/管理端点与**首绑写协议**都从
37
- * 这同一个函数取域([ref] 起它住在 `http/device-placement.ts` —— 提交腿也要用,放在路由域里是层序倒挂),
37
+ * 这同一个函数取域(它住在 `http/execution-context.ts` —— 提交腿的设备准入也要用,放在路由域里是层序倒挂),
38
38
  * 不得手抄第二份派生。
39
39
  *
40
40
  * ── 反枚举 404 的单一铸造点 ──────────────────────────────────────────────────────────────────────
@@ -81,12 +81,6 @@ export declare const DEVICES_ROUTES: readonly [{
81
81
  readonly pattern: RegExp;
82
82
  readonly label: "/v1/devices/sessions/:id/rebind";
83
83
  readonly methods: readonly ["POST"];
84
- }, {
85
- readonly id: "device-session";
86
- readonly pattern: RegExp;
87
- readonly label: "/v1/devices/sessions/:id";
88
- readonly credentialGated: readonly ["GET"];
89
- readonly methods: readonly ["GET"];
90
84
  }, {
91
85
  readonly prefix: "/v1/devices/";
92
86
  readonly credentialGated: readonly ["POST"];
@@ -3,8 +3,12 @@ import { explicitOperatorOk, gatedPrincipal, sendPrincipalRefusal } from "../pri
3
3
  import { assertNever } from "../route-ctx.js";
4
4
  import * as facts from "../../capability-facts.js";
5
5
  import { DEVICE_PLATFORM_OS, DEVICE_PUBKEY_MAX_BYTES, DEVICE_TEXT_LIMITS, assertDeviceIdShape, assertDevicePubkeyShape, assertRootSessionIdShape, isDeviceAbsolutePath, sameDeviceOwner, } from "../../device-store.js";
6
+ import { boundDeviceOf } from "../../execution-placement.js";
7
+ import { defaultTaskRegistry } from "@sema-agent/core";
8
+ import { probeSessionChildren, sessionChildrenScope } from "../../session-children-retire.js";
9
+ import { liveChildrenRefusal } from "./background.js";
6
10
  import { DEFAULT_ENROLL_TOKEN_TTL_SEC } from "../../device-enrollment.js";
7
- import { deviceOwnerDomainOf } from "../device-placement.js";
11
+ import { deviceOwnerDomainOf } from "../execution-context.js";
8
12
  import { echoKeyName } from "../../request-key-closure.js";
9
13
  const DEVICE_NOT_FOUND_MESSAGE = "device not found";
10
14
  const NO_DEVICE_DOMAIN_MESSAGE = "device management needs a caller domain: this credential carries no verified tenant scope claim (registry auth-bridge JWT `scope`) and is not an explicit operator — connect through the registry auth bridge, or configure OPERATOR_PRINCIPALS";
@@ -32,9 +36,6 @@ function deviceListRow(row, online) {
32
36
  ...(row.lastSeenAtMs !== null ? { lastSeenAt: new Date(row.lastSeenAtMs).toISOString() } : {}),
33
37
  };
34
38
  }
35
- function boundDeviceOf(row) {
36
- return { deviceId: row.deviceId, rev: row.rev, boundAt: new Date(row.boundAtMs).toISOString() };
37
- }
38
39
  function sendDeviceRejection(res, rej) {
39
40
  if (rej.retryAfterSec !== undefined)
40
41
  res.setHeader("retry-after", String(rej.retryAfterSec));
@@ -112,7 +113,6 @@ function parseEnrollBody(res, body) {
112
113
  export async function handleDevices(req, res, match, ctx) {
113
114
  switch (match.decl.id) {
114
115
  case "devices-list":
115
- case "device-session":
116
116
  case "device-session-rebind":
117
117
  case "device-enroll-tokens":
118
118
  case "device-enroll":
@@ -248,31 +248,19 @@ export async function handleDevices(req, res, match, ctx) {
248
248
  return;
249
249
  }
250
250
  const rootSessionId = decoded;
251
- if (match.decl.id === "device-session") {
252
- if (!operator && domain === undefined) {
253
- sendError(res, 403, "auth.forbidden", NO_DEVICE_DOMAIN_MESSAGE);
254
- return;
255
- }
256
- const binding = await store.getSessionBinding(rootSessionId);
257
- if (!binding || (!operator && !sameDeviceOwner(binding.owner, domain))) {
258
- sendDeviceNotFound(res);
259
- return;
260
- }
261
- sendJson(res, 200, { rootSessionId, boundDevice: boundDeviceOf(binding) });
262
- return;
263
- }
264
251
  if (!operator && domain === undefined) {
265
252
  sendError(res, 403, "auth.forbidden", NO_DEVICE_DOMAIN_MESSAGE);
266
253
  return;
267
254
  }
268
- const binding = await store.getSessionBinding(rootSessionId);
269
- if (!binding || (!operator && !sameDeviceOwner(binding.owner, domain))) {
255
+ const placement = await enrollment.readPlacement(rootSessionId);
256
+ const binding = placement === undefined ? undefined : boundDeviceOf(placement.cell);
257
+ if (placement === undefined || !binding || (!operator && !sameDeviceOwner(binding.owner, domain))) {
270
258
  sendDeviceNotFound(res);
271
259
  return;
272
260
  }
273
261
  const body = (await readJson(req));
274
262
  if (typeof body !== "object" || body === null || typeof body.toDeviceId !== "string" || body.expectedRev === undefined) {
275
- sendError(res, 400, "request.body_shape", "body must be { toDeviceId: string, expectedRev: number } — expectedRev is the binding rev read from GET /v1/devices/sessions/{rootSessionId} (explicit prior state; two concurrent operators must not silently stomp each other)");
263
+ sendError(res, 400, "request.body_shape", "body must be { toDeviceId: string, expectedRev: number } — expectedRev is the device rev read from GET /v1/sessions/{sessionId}/execution-placement (explicit prior state; two concurrent operators must not silently stomp each other)");
276
264
  return;
277
265
  }
278
266
  if (typeof body.expectedRev !== "number" || !Number.isInteger(body.expectedRev) || body.expectedRev < 0) {
@@ -295,16 +283,24 @@ export async function handleDevices(req, res, match, ctx) {
295
283
  sendError(res, 409, "device.rebind_active_run", "this session has a non-terminal run — rebinding now would leave in-flight work addressed at another workspace; cancel the run (or let it finish), then retry", { activeTaskId });
296
284
  return;
297
285
  }
298
- const result = await store.rebindSession({ rootSessionId, toDeviceId, expectedRev, owner: binding.owner });
286
+ const tree = await probeSessionChildren({ agentStore: deps.backgroundAgentStore, checkpointStore: deps.checkpointStore, registry: defaultTaskRegistry }, rootSessionId, sessionChildrenScope(placement.occupant.owner));
287
+ if (tree.liveChildren.length > 0) {
288
+ sendError(res, 409, "device.rebind_active_run", "this session still has background agents running or waiting on an approval under it — rebinding now would leave them addressed at the old device; retire them (POST retirePath) or let them finish, then retry", liveChildrenRefusal(rootSessionId, tree.liveChildren));
289
+ return;
290
+ }
291
+ const result = await store.rebindDeviceExecution({ root: rootSessionId, occupant: placement.occupant, owner: binding.owner, expectedRev, targetDeviceId: toDeviceId });
299
292
  switch (result.outcome) {
300
293
  case "rebound":
301
294
  case "idempotent":
302
- sendJson(res, 200, { rootSessionId, deviceId: result.row.deviceId, rev: result.row.rev, workspaceCarryover: "none" });
295
+ sendJson(res, 200, { rootSessionId, deviceId: result.device.id, rev: result.device.rev, workspaceCarryover: "none" });
303
296
  return;
304
297
  case "rev_conflict":
305
- sendError(res, 409, "device.rebind_rev_conflict", "expectedRev does not match the binding's current rev — the session↔device binding changed since it was read; re-read it and retry with currentRev", { currentRev: result.row.rev });
298
+ sendError(res, 409, "device.rebind_rev_conflict", "expectedRev does not match the binding's current rev — the session↔device binding changed since it was read; re-read it and retry with currentRev", { currentRev: result.device.rev });
299
+ return;
300
+ case "active_run":
301
+ sendError(res, 409, "device.rebind_active_run", "this session has a non-terminal run — rebinding now would leave in-flight work addressed at another workspace; cancel the run (or let it finish), then retry", { activeTaskId: result.activeTaskId });
306
302
  return;
307
- case "binding_not_found":
303
+ case "gone":
308
304
  case "device_not_found":
309
305
  sendDeviceNotFound(res);
310
306
  return;
@@ -321,7 +317,6 @@ export const DEVICES_ROUTES = [
321
317
  { id: "device-enroll", path: "/v1/devices/enroll", credentialGated: ["POST"], methods: ["POST"] },
322
318
  { id: "device-revoke", pattern: /^\/v1\/devices\/(?<deviceId>[^/]+)\/revoke$/, label: "/v1/devices/:deviceId/revoke", methods: ["POST"] },
323
319
  { id: "device-session-rebind", pattern: /^\/v1\/devices\/sessions\/(?<rootSessionId>[^/]+)\/rebind$/, label: "/v1/devices/sessions/:id/rebind", methods: ["POST"] },
324
- { id: "device-session", pattern: /^\/v1\/devices\/sessions\/(?<rootSessionId>[^/]+)$/, label: "/v1/devices/sessions/:id", credentialGated: ["GET"], methods: ["GET"] },
325
320
  { prefix: "/v1/devices/", credentialGated: ["POST"] },
326
321
  ];
327
322
  //# sourceMappingURL=devices.js.map