@zq-silk/yui 0.12.0 → 0.12.1

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 (53) hide show
  1. package/README.md +8 -9
  2. package/dist/cli/commandCatalog.js +11 -6
  3. package/dist/cli/interactionPolicy.js +5 -6
  4. package/dist/cli/updateCommand.js +3 -1
  5. package/dist/cli/updateOrchestrator.js +173 -28
  6. package/dist/cli/updatePorts.js +137 -8
  7. package/dist/cli/upgradeCommand.js +19 -9
  8. package/dist/cli.js +51 -14
  9. package/dist/commands/configCommands.js +1 -1
  10. package/dist/commands/executionAuditCommands.js +2 -1
  11. package/dist/commands/taskCommands.js +80 -29
  12. package/dist/commands/taskContextCommand.js +6 -2
  13. package/dist/commands/taskRoleRuntimeStatus.js +31 -7
  14. package/dist/config/configCatalog.js +1 -1
  15. package/dist/controller/clientRuntime.js +38 -2
  16. package/dist/controller/controller.js +23 -15
  17. package/dist/controller/fileSchedulerStoreAdapter.js +248 -138
  18. package/dist/controller/runtime.js +56 -1
  19. package/dist/controller/runtimeHookRunFence.js +19 -4
  20. package/dist/controller/structuredProviderObservation.js +20 -3
  21. package/dist/core/controllerClient.js +20 -2
  22. package/dist/core/controllerServer.js +1 -0
  23. package/dist/executor/agentExecutor.js +48 -46
  24. package/dist/executor/fileRoleLaunchPlanner.js +94 -30
  25. package/dist/lifecycle/exactRunTerminalization.js +68 -3
  26. package/dist/observability/executionAudit.js +5 -0
  27. package/dist/release/runtimeRelease.js +20 -0
  28. package/dist/run/recoveryProjection.js +45 -6
  29. package/dist/runtime/agentHost.js +159 -85
  30. package/dist/runtime/conversationSwitch.js +277 -0
  31. package/dist/runtime/index.js +1 -1
  32. package/dist/runtime/launchBroker.js +12 -0
  33. package/dist/runtime/processExitOutbox.js +88 -0
  34. package/dist/runtime/providerRuntimeIdentity.js +29 -1
  35. package/dist/runtime/runtimeHealthPolicy.js +5 -5
  36. package/dist/runtime/runtimeObservation.js +15 -0
  37. package/dist/runtime/runtimeProjection.js +6 -7
  38. package/dist/runtime/tmuxAdapters.js +4 -1
  39. package/dist/scheduler/activeRoleRunDelivery.js +31 -196
  40. package/dist/scheduler/leaderWakeupProcessor.js +39 -133
  41. package/dist/scheduler/roleRunStall.js +53 -17
  42. package/dist/storage/sqliteSchema.js +57 -24
  43. package/dist/storage/sqliteStore.js +23 -4
  44. package/dist/storage/upgrade/homeClassification.js +52 -0
  45. package/dist/storage/upgrade/offlineUpgradeInventory.js +145 -7
  46. package/dist/storage/upgrade/upgradeOrchestrator.js +333 -12
  47. package/dist/task/nextAction.js +0 -34
  48. package/dist/web/webSnapshot.js +3 -1
  49. package/package.json +1 -1
  50. package/skills/yui-leader/SKILL.md +7 -4
  51. package/skills/yui-operator/SKILL.md +4 -4
  52. package/skills/yui-reviewer/SKILL.md +7 -4
  53. package/dist/lifecycle/taskRoleSessionReset.js +0 -118
@@ -36,6 +36,7 @@ import { createRuntimeResourceActivityTracker } from "./resourceInventory.js";
36
36
  import { SessionOwnerReconciliation } from "./sessionOwnerReconciliation.js";
37
37
  import { launchBrokerForHome } from "../runtime/launchBroker.js";
38
38
  import { classifyRuntimeProcessExit, validateRuntimeProcessExitObservation } from "../runtime/processExitObservation.js";
39
+ import { replayRuntimeProcessExitOutbox } from "../runtime/processExitOutbox.js";
39
40
  import { appendGlobalProcessExitObservation } from "../runtime/globalProcessExitStore.js";
40
41
  import { builtinAgentDriverRegistry } from "../runtime/builtinAgentDrivers.js";
41
42
  import { createRuntimeObservation, runtimeObservationFromTaskEvent } from "../runtime/runtimeObservation.js";
@@ -385,6 +386,12 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
385
386
  environment: options.environment
386
387
  });
387
388
  const lifecycleDispatcher = createRuntimeLifecycleDispatcher(store, schedulerStore, sessionHost, options.dispatcher, signalRuntimeCleanup, launchCoordinator, planner);
389
+ // Process-exit observations are persisted by the Agent Host before socket
390
+ // delivery. Drain them while this Controller is still the only storage
391
+ // writer and before it begins accepting new work after a handover.
392
+ await replayRuntimeProcessExitOutbox(home, async (observation) => {
393
+ await lifecycleDispatcher("runtime.process-exit-observe", observation);
394
+ });
388
395
  // f7/rr5: This same inbox feeds the supervisor's terminal channel and the
389
396
  // runtime event processor. When a Job reaches a terminal state, the
390
397
  // supervisor enqueues a durable-job-terminal event; the processor drains it
@@ -577,6 +584,11 @@ export function createRuntimeLifecycleDispatcher(store, schedulerStore, sessionH
577
584
  }
578
585
  return { recorded: true };
579
586
  }
587
+ if (method === "runtime.conversation-switch-detach") {
588
+ const value = conversationSwitchDetachParams(params);
589
+ const outcome = schedulerStore.detachAgentHostProviderForConversationSwitch(value);
590
+ return { recorded: outcome === "detached", outcome };
591
+ }
580
592
  if (method === "runtime.provider-turn-submission-resolve") {
581
593
  const value = providerTurnControlParams(params);
582
594
  const status = params.status;
@@ -671,6 +683,8 @@ export function createRuntimeLifecycleDispatcher(store, schedulerStore, sessionH
671
683
  || !Number.isSafeInteger(hostPid) || hostPid <= 0) {
672
684
  throw applicationError("INVALID_PARAMS", "Launch redemption identity is invalid.");
673
685
  }
686
+ // The launch payload is validated at reservation time and every member
687
+ // of its discriminated Provider-control union is JSON serializable.
674
688
  return launchBrokerForHome(store.rootDirectory()).redeem(launchId, ticket);
675
689
  }
676
690
  if (method === "runtime.replace-agent-environment") {
@@ -735,6 +749,9 @@ export function createRuntimeLifecycleDispatcher(store, schedulerStore, sessionH
735
749
  const activeRun = request.scope === "task"
736
750
  ? store.getActiveAgentRun(request.taskId, request.roleName)
737
751
  : null;
752
+ if (request.scope === "task" && activeRun === null) {
753
+ throw applicationError("INVALID_PARAMS", "Task Role runtime attachment requires an admitted active Run; it cannot create an empty Provider Conversation.");
754
+ }
738
755
  const managedWorkspace = request.scope === "task"
739
756
  ? activeRun?.workspace
740
757
  ?? currentDesiredManagedWorkspace(store, request.taskId, request.roleName)
@@ -754,8 +771,21 @@ export function createRuntimeLifecycleDispatcher(store, schedulerStore, sessionH
754
771
  throw applicationError("INVALID_PARAMS", `Configured Agent does not match Role: ${effective.agentId}.`);
755
772
  }
756
773
  validateLifecycleEnvironment(request.environment, agent);
757
- const mode = roleAgentSessionResumeMode(sessions, effective.agentId, effective);
758
774
  const session = sessions?.sessions[effective.agentId];
775
+ const managedTurnDispatch = activeRun !== null && (activeRun.pushedAt === undefined
776
+ || activeRun.providerRetry?.state === "dispatching"
777
+ || activeRun.controlRequest?.state === "dispatching");
778
+ // An ensure call may reattach the already-admitted Run to its existing
779
+ // Conversation, but it may not manufacture a fresh Conversation without a
780
+ // pending managed Turn. Fresh replacement remains owned by Run dispatch.
781
+ const mode = activeRun === null
782
+ ? roleAgentSessionResumeMode(sessions, effective.agentId, effective)
783
+ : managedTurnDispatch ? activeRun.mode : "resume";
784
+ if (request.scope === "task" && mode === "resume"
785
+ && (session?.nativeSessionId === undefined
786
+ || session.nativeSessionId.trim().length === 0)) {
787
+ throw applicationError("INVALID_PARAMS", "Task Role runtime attachment cannot resume because its Provider Conversation identity is missing.");
788
+ }
759
789
  const owner = request.scope === "task"
760
790
  ? { scope: "task", taskId: request.taskId, roleName: request.roleName }
761
791
  : { scope: "global", roleName: request.roleName };
@@ -1105,6 +1135,31 @@ function providerTurnControlParams(params) {
1105
1135
  now: new Date(observedAt)
1106
1136
  };
1107
1137
  }
1138
+ function conversationSwitchDetachParams(params) {
1139
+ if (typeof params !== "object" || params === null || Array.isArray(params)) {
1140
+ throw applicationError("INVALID_PARAMS", "Provider Conversation detachment params are invalid.");
1141
+ }
1142
+ const value = params;
1143
+ const nextAuthorityEpoch = value.nextAuthorityEpoch;
1144
+ const observedAt = requiredParam(value.observedAt);
1145
+ if (!Number.isSafeInteger(nextAuthorityEpoch) || nextAuthorityEpoch < 1
1146
+ || !Number.isFinite(Date.parse(observedAt))) {
1147
+ throw applicationError("INVALID_PARAMS", "Provider Conversation detachment fence is invalid.");
1148
+ }
1149
+ return {
1150
+ taskId: requiredParam(value.taskId),
1151
+ roleName: requiredParam(value.roleName),
1152
+ runId: requiredParam(value.runId),
1153
+ agentId: requiredParam(value.agentId),
1154
+ launchId: requiredParam(value.launchId),
1155
+ previousConversationId: requiredParam(value.previousConversationId),
1156
+ previousNativeSessionId: requiredParam(value.previousNativeSessionId),
1157
+ previousActivationId: requiredParam(value.previousActivationId),
1158
+ nextAuthorityEpoch: nextAuthorityEpoch,
1159
+ nextAuthorityHolderId: requiredParam(value.nextAuthorityHolderId),
1160
+ now: new Date(observedAt)
1161
+ };
1162
+ }
1108
1163
  function abortableDelay(milliseconds, signal) {
1109
1164
  return new Promise((resolve, reject) => {
1110
1165
  const timer = setTimeout(() => {
@@ -4,6 +4,7 @@ import { nativeSessionIdForLaunch } from "../runtime/preallocatedNativeSession.j
4
4
  import { runtimeObservationFromTaskEvent } from "../runtime/runtimeObservation.js";
5
5
  import { YUI_CONTROL_PLANE_DESCRIPTOR, YUI_TASK_RUNTIME_DESCRIPTOR, assertExactTaskRuntimeEnvironment, exactControlPlaneDigest, parseExactControlPlaneDescriptor, refreshReusedTaskRuntimeDescriptorSource } from "../runtime/exactControlPlane.js";
6
6
  import { agentRunDeliveryReceiptId } from "../run/agentRun.js";
7
+ import { conversationReplacementBasis } from "../runtime/conversationSwitch.js";
7
8
  /**
8
9
  * Resolves turn identity from the current durable in-flight fence. The one
9
10
  * exception is a Driver-declared startup Session Hook, which can arrive before
@@ -101,14 +102,27 @@ export function resolveRuntimeHookRunFence(environment, adapterId, payloadNative
101
102
  const startupRun = startupRunId === undefined
102
103
  ? null
103
104
  : store.getAgentRun(taskId, startupRunId);
105
+ const replacementStartup = options.startupSession !== undefined
106
+ && session !== undefined
107
+ && sessions !== null
108
+ && startupReservation
109
+ && startupRun?.mode === "new"
110
+ && conversationReplacementBasis({
111
+ sessions,
112
+ events: store.listEvents(taskId),
113
+ mailbox: roleMailbox,
114
+ roleName,
115
+ runId: startupRun.id,
116
+ runMode: startupRun.mode
117
+ }) !== null;
104
118
  const preallocatedStartup = options.startupSession === "preallocated"
105
119
  && expectedNativeSessionId !== undefined
106
- && session === undefined
120
+ && (session === undefined || replacementStartup)
107
121
  && startupReservation
108
122
  && nativeSessionId === nativeSessionIdForLaunch(home, launchId, agentId, adapterId);
109
123
  const discoveredStartup = options.startupSession === "discovered"
110
124
  && expectedNativeSessionId === undefined
111
- && session === undefined
125
+ && (session === undefined || replacementStartup)
112
126
  && startupReservation;
113
127
  const terminalRunId = options.terminal === true && acceptedBinding === null
114
128
  ? requireIdentity(environment.YUI_RUN_ID ?? runtime?.runId, "Run id")
@@ -154,6 +168,7 @@ export function resolveRuntimeHookRunFence(environment, adapterId, payloadNative
154
168
  if (acceptedBinding === null
155
169
  && runtime !== undefined
156
170
  && session !== undefined
171
+ && !replacementStartup
157
172
  && sessionLaunchId !== undefined
158
173
  && typeof runtimeSource === "string"
159
174
  && !runtimeSource.trimStart().startsWith("{")
@@ -187,7 +202,7 @@ export function resolveRuntimeHookRunFence(environment, adapterId, payloadNative
187
202
  if (run.effective.workspace.root !== workspace) {
188
203
  throw new Error("Runtime observation Hook workspace does not match the durable Run snapshot.");
189
204
  }
190
- if (session !== undefined && acceptedBinding === null) {
205
+ if (session !== undefined && acceptedBinding === null && !replacementStartup) {
191
206
  if (session.adapterId !== adapterId
192
207
  || session.launchId !== effectiveLaunchId
193
208
  || session.nativeSessionId !== nativeSessionId
@@ -195,7 +210,7 @@ export function resolveRuntimeHookRunFence(environment, adapterId, payloadNative
195
210
  throw new Error("Runtime observation Hook Session does not match its durable generation.");
196
211
  }
197
212
  }
198
- else if (session === undefined && acceptedBinding === null) {
213
+ else if (acceptedBinding === null && (session === undefined || replacementStartup)) {
199
214
  if (!discoveredStartup && !preallocatedStartup) {
200
215
  throw new Error("Runtime observation Hook launch is not durably reserved.");
201
216
  }
@@ -3,6 +3,7 @@ import { callController } from "../core/controllerClient.js";
3
3
  import { builtinAgentDriverRegistry } from "../runtime/builtinAgentDrivers.js";
4
4
  import { createRuntimeObservation, runtimeObservationSemanticKey } from "../runtime/runtimeObservation.js";
5
5
  import { runtimeLifecycleSignalKey } from "../runtime/lifecycleReservation.js";
6
+ import { isForeignHandoverLockHeld } from "../release/runtimeRelease.js";
6
7
  import { FileRuntimeEventInbox } from "./runtimeEventInbox.js";
7
8
  import { resolveRuntimeHookRunFence } from "./runtimeHookRunFence.js";
8
9
  let structuredSequence = 0;
@@ -256,10 +257,26 @@ async function persistAndApply(home, observations, taskId, roleName) {
256
257
  const inbox = new FileRuntimeEventInbox(home);
257
258
  for (const entry of observations)
258
259
  inbox.enqueueObservation(entry);
260
+ // The immutable inbox is authoritative. During a release/update handover the
261
+ // old Controller is draining and the replacement is not ready yet; leave the
262
+ // entries for normal inbox replay instead of turning a healthy Provider Turn
263
+ // into a transport failure.
264
+ if (isForeignHandoverLockHeld(home))
265
+ return;
259
266
  for (const entry of observations) {
260
- const result = await callController(home, "runtime.observation-apply", entry, {
261
- timeoutMs: 10_000
262
- });
267
+ let result;
268
+ try {
269
+ result = await callController(home, "runtime.observation-apply", entry, {
270
+ timeoutMs: 10_000
271
+ });
272
+ }
273
+ catch (error) {
274
+ // Close the race where the handover begins after the first check but
275
+ // before this socket call. The durable entry remains pending for replay.
276
+ if (isForeignHandoverLockHeld(home))
277
+ return;
278
+ throw error;
279
+ }
263
280
  // A fast Provider can accept the initial Turn before the scheduler call
264
281
  // that launched this Host has returned and committed `run.pushed`. The
265
282
  // immutable inbox entry already makes that exact fenced fact durable;
@@ -14,6 +14,15 @@ export class ControllerClientError extends Error {
14
14
  this.name = "ControllerClientError";
15
15
  }
16
16
  }
17
+ /** The Controller may have committed the request before this client lost its acknowledgement. */
18
+ export function controllerCallMayHaveApplied(error) {
19
+ return error instanceof ControllerClientError
20
+ && [
21
+ "CONTROLLER_TIMEOUT",
22
+ "CONTROLLER_UNAVAILABLE",
23
+ "INVALID_RESPONSE"
24
+ ].includes(error.code);
25
+ }
17
26
  export async function readControllerDiscovery(home) {
18
27
  const discoveryPath = join(home, CONTROLLER_DISCOVERY_PATH);
19
28
  try {
@@ -210,6 +219,7 @@ function exchange(socketPath, requestLine, expectedId, timeoutMs) {
210
219
  const socket = createConnection(socketPath);
211
220
  let buffer = Buffer.alloc(0);
212
221
  let settled = false;
222
+ let deliveryStarted = false;
213
223
  const timer = setTimeout(() => {
214
224
  fail(new ControllerClientError("CONTROLLER_TIMEOUT", "Controller request timed out."));
215
225
  }, timeoutMs);
@@ -229,7 +239,13 @@ function exchange(socketPath, requestLine, expectedId, timeoutMs) {
229
239
  socket.destroy();
230
240
  reject(error);
231
241
  };
232
- socket.on("connect", () => socket.write(requestLine));
242
+ socket.on("connect", () => {
243
+ // Once write begins, a missing response cannot prove that the Controller
244
+ // did not commit the request. Callers must use their domain identity to
245
+ // decide whether an explicit retry is safe.
246
+ deliveryStarted = true;
247
+ socket.write(requestLine);
248
+ });
233
249
  socket.on("data", (chunk) => {
234
250
  if (settled)
235
251
  return;
@@ -260,7 +276,9 @@ function exchange(socketPath, requestLine, expectedId, timeoutMs) {
260
276
  fail(invalidResponse());
261
277
  });
262
278
  socket.on("error", () => {
263
- fail(new ControllerClientError("CONTROLLER_UNAVAILABLE", "Controller is unavailable."));
279
+ fail(deliveryStarted
280
+ ? new ControllerClientError("CONTROLLER_DELIVERY_UNKNOWN", "Controller request delivery is unknown.")
281
+ : new ControllerClientError("CONTROLLER_UNAVAILABLE", "Controller is unavailable."));
264
282
  });
265
283
  });
266
284
  }
@@ -833,6 +833,7 @@ function safeApplicationErrorCode(code) {
833
833
  case "METHOD_NOT_FOUND":
834
834
  case "NOT_FOUND":
835
835
  case "UNAUTHORIZED":
836
+ case "CONTROLLER_DRAINING":
836
837
  return code;
837
838
  default:
838
839
  return undefined;
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { hasRecentTurnId, rememberRecentTurnId, validatePendingTurnCompletion, validateRecentTurnIds } from "./turnCompletion.js";
3
3
  import { effectiveLaunchSnapshotsCompatible, effectiveLaunchSnapshotsCompatibleForTaskSession, validateEffectiveLaunchSnapshot } from "./effectiveLaunch.js";
4
- import { rebindProviderRuntimeRun, validateProviderRuntimeBinding } from "../runtime/providerRuntimeIdentity.js";
4
+ import { currentProviderActivation, endProviderActivation, rebindProviderRuntimeRun, validateProviderRuntimeBinding } from "../runtime/providerRuntimeIdentity.js";
5
5
  import { builtinDriverIdForAdapter } from "../runtime/builtinAgentDrivers.js";
6
6
  export function createRoleSessionSet(owner, activeAgentId, now) {
7
7
  const base = {
@@ -120,6 +120,20 @@ export function recordRoleAgentSession(set, input, now) {
120
120
  validateRoleSessionSet(updated);
121
121
  return updated;
122
122
  }
123
+ /**
124
+ * Atomically archives the quiescent old native Session and binds the Provider
125
+ * Conversation selected by an already-authorized switch. Authorization is
126
+ * deliberately owned by the Controller caller, not inferred here.
127
+ */
128
+ export function replaceTaskRoleAgentSessionForConversationSwitch(set, input, now) {
129
+ validateRoleSessionSet(set);
130
+ const existing = set.sessions[input.agentId];
131
+ if (existing === undefined || existing.nativeSessionId === input.nativeSessionId) {
132
+ return recordRoleAgentSession(set, input, now);
133
+ }
134
+ const terminalized = updateRoleAgentSessionStatus(set, input.agentId, "stopped", now);
135
+ return recordRoleAgentSession(terminalized, input, now);
136
+ }
123
137
  /**
124
138
  * Session titles and previews can originate in native Agent output. Keep them
125
139
  * single-line and inert before they are persisted or rendered in a terminal.
@@ -179,25 +193,6 @@ export function retireTaskRoleSessionsForWorkspace(set, now) {
179
193
  updatedAt: timestamp
180
194
  });
181
195
  }
182
- /**
183
- * Clears the Provider transport identity after its physical runtime is proven
184
- * stopped, without retiring workspace-bound Session records. Workspace
185
- * retirement remains a separate, stricter transaction after every supported
186
- * placeholder has been terminalized.
187
- */
188
- export function clearTaskRoleProviderRuntimeForCleanup(set, now) {
189
- validateRoleSessionSet(set);
190
- if (set.inFlight !== null) {
191
- throw new Error("Cannot clear a Task Role Provider runtime with unsettled Run state.");
192
- }
193
- if (set.providerBinding === null)
194
- return set;
195
- return validateRoleSessionSet({
196
- ...set,
197
- providerBinding: null,
198
- updatedAt: requireDate(now, "Provider Runtime cleanup timestamp")
199
- });
200
- }
201
196
  /**
202
197
  * Terminalizes only the aggregate-16 Claude placeholder shape after the
203
198
  * caller has fenced the Task store and proved that the exact Role has no live
@@ -307,7 +302,10 @@ export function bindTaskRoleRun(set, fence, preparedAt, mode) {
307
302
  throw new Error("Task Role session set already has an in-flight Run.");
308
303
  }
309
304
  const timestamp = requireDate(preparedAt, "Task Role Run preparedAt");
310
- const providerBinding = mode === "resume" && set.providerBinding !== null
305
+ // A fresh Conversation is a two-phase replacement: keep the old binding as
306
+ // current evidence until the new Provider session is observed and atomically
307
+ // superseded. Homes with no prior Conversation still start from null.
308
+ const providerBinding = set.providerBinding !== null
311
309
  ? rebindProviderRuntimeRun(set.providerBinding, normalized.runId)
312
310
  : null;
313
311
  const updated = {
@@ -372,6 +370,35 @@ export function updateTaskRoleProviderRuntime(set, binding, updatedAt) {
372
370
  updatedAt: requireDate(updatedAt, "Provider Runtime Binding timestamp")
373
371
  });
374
372
  }
373
+ /**
374
+ * Records an exact local Host exit without forgetting the Provider
375
+ * Conversation. The ended Activation releases writer authority, while the
376
+ * current Conversation and any unsettled Turn remain durable recovery fences.
377
+ */
378
+ export function stopTaskRoleRuntimeAfterPhysicalExit(set, now) {
379
+ validateRoleSessionSet(set);
380
+ const active = set.sessions[set.activeAgentId];
381
+ if (active === undefined)
382
+ return set;
383
+ const activation = set.providerBinding === null
384
+ ? null
385
+ : currentProviderActivation(set.providerBinding);
386
+ if (active.status === "stopped") {
387
+ if (activation !== null) {
388
+ throw new Error("Stopped Task Role Session cannot retain a live Provider Activation.");
389
+ }
390
+ return set;
391
+ }
392
+ let updated = updateRoleAgentSessionStatus(set, set.activeAgentId, "stopped", now);
393
+ if (activation !== null) {
394
+ updated = updateTaskRoleProviderRuntime(updated, endProviderActivation(updated.providerBinding, activation.activationId, {
395
+ status: "ended",
396
+ endedAt: now.toISOString(),
397
+ reason: "runtime-physical-exit"
398
+ }), now);
399
+ }
400
+ return updated;
401
+ }
375
402
  export function markTaskRoleRunPushed(set, fence, pushedAt) {
376
403
  validateRoleSessionSet(set);
377
404
  assertTaskRoleSessionSet(set);
@@ -515,31 +542,6 @@ export function terminalizeTaskRoleRunSession(set, fence, terminalAt) {
515
542
  }
516
543
  return updated;
517
544
  }
518
- /**
519
- * Resets the current native generation after its active Run is terminal.
520
- * The Controller separately owns verified process cleanup.
521
- */
522
- export function resetTaskRoleSession(set, now) {
523
- validateRoleSessionSet(set);
524
- const timestamp = requireDate(now, "Task Role Session reset timestamp");
525
- const current = set.sessions[set.activeAgentId];
526
- const sessions = { ...set.sessions };
527
- delete sessions[set.activeAgentId];
528
- const history = current === undefined
529
- ? set.history
530
- : [
531
- ...(set.history ?? []),
532
- { ...current, status: "broken", updatedAt: timestamp }
533
- ];
534
- return validateRoleSessionSet({
535
- ...set,
536
- sessions,
537
- ...(history === undefined ? {} : { history }),
538
- inFlight: null,
539
- providerBinding: null,
540
- updatedAt: timestamp
541
- });
542
- }
543
545
  export function settleTaskRoleCompletion(set, expected, settledAt) {
544
546
  validateRoleSessionSet(set);
545
547
  assertTaskRoleSessionSet(set);
@@ -12,7 +12,9 @@ import { compileRoleSessionContext, roleSessionKind } from "../context/roleSessi
12
12
  import { materializeSessionBootstrap } from "../context/sessionBootstrapManifest.js";
13
13
  import { serializeRunBootstrapEnvelope, serializeRunHostRecoveryEnvelope } from "../context/runContextContract.js";
14
14
  import { serializeProviderRetryEnvelope } from "../run/providerRetry.js";
15
+ import { serializeWorkflowOutcomeRequestEnvelope } from "../run/runControlRequest.js";
15
16
  import { prefixYuiTitleInput } from "../run/runIdentity.js";
17
+ import { agentRunDeliveryReceiptId } from "../run/agentRun.js";
16
18
  import { resolveAgentAdapter } from "./agentAdapter.js";
17
19
  import { resolveTaskRoleSessionTitle } from "../runtime/sessionTitle.js";
18
20
  import { nativeSessionIdForLaunch } from "../runtime/preallocatedNativeSession.js";
@@ -25,7 +27,8 @@ import { parseTaskRuntimeIsolationDescriptor, taskRuntimeIsolationEnvironment }
25
27
  import { ResourceRegistrar } from "../resources/resourceRegistrar.js";
26
28
  import { builtinAgentDriverRegistry, builtinDriverIdForAdapter } from "../runtime/builtinAgentDrivers.js";
27
29
  import { managedRuntimeAdmission } from "../runtime/agentDriver.js";
28
- import { formatAgentRunReceiptId } from "../task/taskRecordReference.js";
30
+ import { freshConversationLaunchAllowed } from "../runtime/conversationSwitch.js";
31
+ import { currentProviderActivation } from "../runtime/providerRuntimeIdentity.js";
29
32
  import { assertCodexLaunchOverridesAvailable, inspectCodexLaunchConfig } from "./codexConfigConflict.js";
30
33
  /** Builds managed native Agent launches from the authoritative Task records. */
31
34
  export class FileRoleLaunchPlanner {
@@ -224,6 +227,20 @@ export class FileRoleLaunchPlanner {
224
227
  if (input.mode === "resume" && !compatibleExisting) {
225
228
  throw new Error(`Task Role resume effective snapshot drifted: ${task.id}/${role.name}.`);
226
229
  }
230
+ if (input.mode === "new" && sessionSet !== null
231
+ && !freshConversationLaunchAllowed({
232
+ sessions: sessionSet,
233
+ events: this.store.listEvents(task.id),
234
+ mailbox: this.store.getWorkMailbox({
235
+ kind: "role",
236
+ taskId: task.id,
237
+ roleName: role.name
238
+ }),
239
+ roleName: role.name,
240
+ ...(input.runId === undefined ? {} : { candidateRunId: input.runId })
241
+ })) {
242
+ throw new Error(`Fresh Provider Conversation is not authorized: ${task.id}/${role.name}.`);
243
+ }
227
244
  return this.#compile(role, input, { scope: "task", taskId: task.id }, resolveTaskRoleSessionTitle(input.mode === "resume" ? existing?.title : undefined, task, role.name), input.mode === "resume" && compatibleExisting ? existing.nativeSessionId : undefined, runWorkspace, effective, {
228
245
  purpose: activeRun?.purpose ?? "execution"
229
246
  });
@@ -464,10 +481,10 @@ export class FileRoleLaunchPlanner {
464
481
  if (runtimeDescriptor !== undefined && runtimeDescriptorSource !== undefined) {
465
482
  this.#writeExactTaskRuntimeDescriptor(runtimeDescriptor, runtimeDescriptorSource);
466
483
  }
467
- // rr13/rr26: Generate a per-Session DurableJob caller key for every
468
- // task-scope launch, including resume. Resume/ensure may reuse a live
469
- // host, so its candidate hash is committed by TmuxSessionHost only when
470
- // hostCreated=true; otherwise the live process keeps its existing key.
484
+ // Generate a candidate per-Provider-process DurableJob caller key for
485
+ // every task-scope launch. A reused live Conversation keeps the key that
486
+ // its process inherited; a new Host or a Conversation replacement commits
487
+ // the fresh candidate only after Provider dispatch is observed.
471
488
  let jobCallerKey;
472
489
  if (owner.scope === "task" && (input.mode === "new" || input.mode === "resume")) {
473
490
  jobCallerKey = randomBytes(32).toString("hex");
@@ -476,35 +493,62 @@ export class FileRoleLaunchPlanner {
476
493
  // write, whether it creates or resumes the native Conversation. Keeping
477
494
  // resume on the same launch handshake prevents a durable AgentRun from
478
495
  // existing without a corresponding Provider Turn.
479
- const carriesInitialTurn = managedControl
480
- && managedRun?.pushedAt === undefined;
496
+ if (managedControl && (managedRun === null || managedRun.status !== "active")) {
497
+ throw new Error(`Managed Run is no longer active: ${input.runId}.`);
498
+ }
499
+ const carriesInitialTurn = managedControl && (managedRun.pushedAt === undefined
500
+ || managedRun.providerRetry?.state === "dispatching"
501
+ || managedRun.controlRequest?.state === "dispatching");
481
502
  const providerAuthority = managedControl
482
- ? this.#providerAuthorityForLaunch(owner.taskId, role.name, input.launchId)
503
+ ? this.#providerAuthorityForLaunch(owner.taskId, role.name, input.launchId, input.mode)
483
504
  : undefined;
484
505
  const providerNativeSessionId = binding.adapterId === "claude"
485
506
  ? preallocatedNativeSessionId
486
507
  : resumeNativeSessionId;
487
- const providerControl = managedControl
508
+ const initialTurn = carriesInitialTurn
488
509
  ? {
489
- schemaVersion: 1,
490
- adapterId: binding.adapterId,
491
- transport: managedCompiled.transport,
492
- mode: resumeNativeSessionId === undefined ? "new" : "resume",
493
- ...(providerNativeSessionId === undefined
494
- ? {}
495
- : { nativeSessionId: providerNativeSessionId }),
496
- ...(sessionTitle === undefined ? {} : { sessionTitle }),
497
- authority: providerAuthority,
498
- ...(carriesInitialTurn
499
- ? {
500
- initialTurn: {
501
- attemptId: formatAgentRunReceiptId(owner.taskId, input.runId),
502
- boundedText: managedRunLaunchEnvelope(managedRun, input.mode, sessionTitle)
503
- }
504
- }
505
- : {})
510
+ attemptId: agentRunDeliveryReceiptId(managedRun),
511
+ boundedText: managedRunLaunchEnvelope(managedRun, input.mode, sessionTitle)
506
512
  }
507
513
  : undefined;
514
+ const providerControl = !managedControl
515
+ ? undefined
516
+ : initialTurn === undefined
517
+ ? {
518
+ schemaVersion: 1,
519
+ adapterId: binding.adapterId,
520
+ transport: managedCompiled.transport,
521
+ kind: "ensure",
522
+ mode: "resume",
523
+ nativeSessionId: requireText(providerNativeSessionId, "Managed Provider ensure native session id"),
524
+ ...(sessionTitle === undefined ? {} : { sessionTitle }),
525
+ authority: providerAuthority
526
+ }
527
+ : resumeNativeSessionId === undefined
528
+ ? {
529
+ schemaVersion: 1,
530
+ adapterId: binding.adapterId,
531
+ transport: managedCompiled.transport,
532
+ kind: "new",
533
+ mode: "new",
534
+ ...(providerNativeSessionId === undefined
535
+ ? {}
536
+ : { nativeSessionId: providerNativeSessionId }),
537
+ ...(sessionTitle === undefined ? {} : { sessionTitle }),
538
+ authority: providerAuthority,
539
+ initialTurn
540
+ }
541
+ : {
542
+ schemaVersion: 1,
543
+ adapterId: binding.adapterId,
544
+ transport: managedCompiled.transport,
545
+ kind: "resume",
546
+ mode: "resume",
547
+ nativeSessionId: requireText(providerNativeSessionId, "Managed Provider resume native session id"),
548
+ ...(sessionTitle === undefined ? {} : { sessionTitle }),
549
+ authority: providerAuthority,
550
+ initialTurn
551
+ };
508
552
  const launch = {
509
553
  command,
510
554
  args,
@@ -572,13 +616,26 @@ export class FileRoleLaunchPlanner {
572
616
  : {})
573
617
  };
574
618
  }
575
- #providerAuthorityForLaunch(taskId, roleName, launchId) {
619
+ #providerAuthorityForLaunch(taskId, roleName, launchId, mode) {
576
620
  const activationId = requireText(launchId, "Managed Provider Activation id");
577
621
  const binding = this.store.getTaskRoleSessionSet(taskId, roleName)?.providerBinding;
578
622
  if (binding === null || binding === undefined) {
579
623
  return { epoch: 1, owner: "controller", holderId: activationId };
580
624
  }
581
625
  if (binding.authority.owner === "controller") {
626
+ if (mode === "new") {
627
+ const activation = currentProviderActivation(binding);
628
+ if (activation === null || binding.authority.holderId !== activation.activationId) {
629
+ throw new Error(`Provider Activation is not exact: ${taskId}/${roleName}.`);
630
+ }
631
+ // An actor-requested switch first ends the idle old Activation (+1)
632
+ // and then binds the replacement Activation (+1).
633
+ return {
634
+ epoch: binding.authority.epoch + 2,
635
+ owner: "controller",
636
+ holderId: activationId
637
+ };
638
+ }
582
639
  return {
583
640
  epoch: binding.authority.epoch,
584
641
  owner: "controller",
@@ -642,9 +699,16 @@ function managedRunLaunchEnvelope(run, mode, title) {
642
699
  roleName: run.roleName,
643
700
  retry: run.providerRetry
644
701
  })
645
- : mode === "resume" && run.pushedAt !== undefined
646
- ? serializeRunHostRecoveryEnvelope(run.bootstrapEnvelope)
647
- : serializeRunBootstrapEnvelope(run.bootstrapEnvelope);
702
+ : run.controlRequest?.state === "dispatching"
703
+ ? serializeWorkflowOutcomeRequestEnvelope({
704
+ taskId: run.taskId,
705
+ runId: run.id,
706
+ roleName: run.roleName,
707
+ request: run.controlRequest
708
+ })
709
+ : mode === "resume" && run.pushedAt !== undefined
710
+ ? serializeRunHostRecoveryEnvelope(run.bootstrapEnvelope)
711
+ : serializeRunBootstrapEnvelope(run.bootstrapEnvelope);
648
712
  return title === undefined
649
713
  ? body
650
714
  : prefixYuiTitleInput(body, title);