@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
@@ -12,6 +12,7 @@ import { createYieldReceipt } from "../run/yieldReceipt.js";
12
12
  import { recordExecutionLaneResult } from "../execution/executionGroup.js";
13
13
  import { isRuntimeLaunchReservation, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
14
14
  import { runOwnsBlockingProviderContinuation } from "../runtime/runtimeContinuationProjection.js";
15
+ import { runHasActiveRuntimeOperations } from "../runtime/runtimeObservation.js";
15
16
  import { latestRunDurableProgressAt, RUN_RECOVERY_APPLIED_EVENT, RUN_RECOVERY_REQUESTED_EVENT } from "../scheduler/roleRunStall.js";
16
17
  import { markTaskWakeConsumed } from "../scheduler/taskWake.js";
17
18
  import { workItemExecutionGroupById, workItemOwnsUnresolvedExecutionLane, updateWorkItemExecutionGroup, updateWorkItemStatus } from "../workItem/workItem.js";
@@ -352,7 +353,7 @@ export function terminalizeExactTaskRun(store, input, now) {
352
353
  /**
353
354
  * Leader-controlled recovery boundary for one active AgentRun. This primitive
354
355
  * validates every durable fence in one transaction and records only a
355
- * structured request for retry/session replacement. It never writes terminal
356
+ * structured request for same-Run diagnosis/retry. It never writes terminal
356
357
  * bytes, retries a provider input, kills a host, or silently rebinds a native
357
358
  * generation. Explicit termination is the sole action that changes Run state.
358
359
  */
@@ -420,6 +421,35 @@ function recoverExactAgentRunInTransaction(store, input) {
420
421
  if (!matchesRecoverySessionFence(store, input)) {
421
422
  return stateChanged("session-or-launch-fence-mismatch");
422
423
  }
424
+ const recoveryBlocker = exactRecoveryExecutionBlocker(store, current);
425
+ if (recoveryBlocker !== null) {
426
+ return {
427
+ disposition: "blocked",
428
+ action: input.action,
429
+ run: current,
430
+ progressAt: progress.progressAt,
431
+ reason: recoveryBlocker
432
+ };
433
+ }
434
+ if (input.action === "terminate") {
435
+ const sessions = store.getTaskRoleSessionSet(input.taskId, input.roleName);
436
+ const session = sessions?.sessions[input.agentId];
437
+ const providerTurn = sessions?.providerBinding?.turn;
438
+ const exactTerminalEvidence = session?.status === "stopped"
439
+ || session?.status === "broken"
440
+ || providerTurn?.status === "failed"
441
+ || providerTurn?.status === "cancelled"
442
+ || providerTurn?.status === "rejected";
443
+ if (!exactTerminalEvidence) {
444
+ return {
445
+ disposition: "blocked",
446
+ action: input.action,
447
+ run: current,
448
+ progressAt: progress.progressAt,
449
+ reason: "runtime-not-terminal"
450
+ };
451
+ }
452
+ }
423
453
  if (input.providerAcceptance === "ambiguous"
424
454
  && input.action !== "diagnose") {
425
455
  return {
@@ -506,6 +536,42 @@ function recoverExactAgentRunInTransaction(store, input) {
506
536
  progressAt: progress.progressAt
507
537
  };
508
538
  }
539
+ function exactRecoveryExecutionBlocker(store, run) {
540
+ const sessions = store.getTaskRoleSessionSet(run.taskId, run.roleName);
541
+ const binding = sessions?.providerBinding;
542
+ const mailbox = store.getWorkMailbox({
543
+ kind: "role",
544
+ taskId: run.taskId,
545
+ roleName: run.roleName
546
+ });
547
+ if (mailbox?.inputDelivery != null)
548
+ return "provider-input-delivery-unsettled";
549
+ if (runOwnsBlockingProviderContinuation(store.listEvents(run.taskId), {
550
+ taskId: run.taskId,
551
+ roleName: run.roleName,
552
+ runId: run.id,
553
+ agentId: run.effective.agentId
554
+ }))
555
+ return "provider-continuation-writer-owned";
556
+ if (runHasActiveRuntimeOperations(store.listEvents(run.taskId), {
557
+ taskId: run.taskId,
558
+ roleName: run.roleName,
559
+ runId: run.id,
560
+ agentId: run.effective.agentId
561
+ }))
562
+ return "provider-operation-active";
563
+ if (run.deliveredAt !== undefined
564
+ && (binding === null || binding?.turn === null))
565
+ return "provider-turn-state-missing";
566
+ if (binding === null || binding === undefined)
567
+ return null;
568
+ if (["submitting", "accepted", "running", "delivery-unknown"].includes(binding.turn?.status ?? ""))
569
+ return "provider-turn-unsettled";
570
+ if (binding.authority.owner === "human" || binding.authority.owner === "unknown") {
571
+ return "provider-writer-authority-unavailable";
572
+ }
573
+ return null;
574
+ }
509
575
  function matchesRecoverySessionFence(store, input) {
510
576
  const sessions = store.getTaskRoleSessionSet(input.taskId, input.roleName);
511
577
  // Recovery must never proceed without a durable Session fence. A missing
@@ -520,8 +586,7 @@ function matchesRecoverySessionFence(store, input) {
520
586
  return false;
521
587
  if (session.agentId !== input.agentId || session.adapterId !== input.adapterId)
522
588
  return false;
523
- // A dead Session is precisely when replace-session/terminate recovery is
524
- // needed. Preserve its exact identity as the CAS fence; only same-Session
589
+ // Preserve a dead Session's exact identity as the CAS fence; only same-Session
525
590
  // retry is invalid once the native process is stopped or broken.
526
591
  if ((session.status === "stopped" || session.status === "broken")
527
592
  && input.action === "retry")
@@ -363,6 +363,7 @@ export function runExecutionAudit(home, options = {}, ports = createProductionEx
363
363
  let stopped = 0;
364
364
  let other = 0;
365
365
  let resets = 0;
366
+ let conversationSwitches = 0;
366
367
  let lifecycleEvents = 0;
367
368
  let stopFailures = 0;
368
369
  const terminalByRunRelation = {
@@ -393,6 +394,9 @@ export function runExecutionAudit(home, options = {}, ports = createProductionEx
393
394
  continue;
394
395
  if (event.type === "runtime.role-session-reset")
395
396
  resets += 1;
397
+ else if (event.type === "runtime.conversation-switch-resolved"
398
+ && event.payload.status === "applied")
399
+ conversationSwitches += 1;
396
400
  else if (runtimeObservationFromTaskEvent(event)?.kind.startsWith("session.")) {
397
401
  lifecycleEvents += 1;
398
402
  }
@@ -406,6 +410,7 @@ export function runExecutionAudit(home, options = {}, ports = createProductionEx
406
410
  stopped,
407
411
  other,
408
412
  resets,
413
+ conversationSwitches,
409
414
  lifecycleEvents,
410
415
  stopFailures,
411
416
  terminalByRunRelation
@@ -267,6 +267,26 @@ export function isHandoverLockHeld(home) {
267
267
  return !isEnoent(error);
268
268
  }
269
269
  }
270
+ /**
271
+ * True when a live handover is owned by a process other than `allowedOwnerPid`.
272
+ *
273
+ * Foreground maintenance commands acquire the lock and still need to call the
274
+ * Controller they are draining, while every unrelated CLI/managed Session must
275
+ * wait rather than starting a second Controller during the replacement window.
276
+ * An unreadable lock remains a foreign live lock so callers fail closed.
277
+ */
278
+ export function isForeignHandoverLockHeld(home, allowedOwnerPid = process.pid) {
279
+ const lockPath = join(resolve(home), "runtime", "handover.lock");
280
+ try {
281
+ const owner = JSON.parse(readFileSync(lockPath, "utf8"));
282
+ if (!isHandoverLockLive(owner))
283
+ return false;
284
+ return owner.pid !== allowedOwnerPid;
285
+ }
286
+ catch (error) {
287
+ return !isEnoent(error);
288
+ }
289
+ }
270
290
  export function acquireHandoverLock(home) {
271
291
  const lockPath = join(resolve(home), "runtime", "handover.lock");
272
292
  mkdirSync(dirname(lockPath), { recursive: true, mode: 0o700 });
@@ -1,10 +1,11 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { latestRunDurableProgressAt } from "../scheduler/roleRunStall.js";
3
3
  import { actionableExecutionLaneRecoveries } from "../execution/executionHealth.js";
4
+ import { runOwnsBlockingProviderContinuation } from "../runtime/runtimeContinuationProjection.js";
5
+ import { runHasActiveRuntimeOperations } from "../runtime/runtimeObservation.js";
4
6
  export const RUN_RECOVERY_ACTIONS = [
5
7
  "diagnose",
6
8
  "retry",
7
- "replace-session",
8
9
  "terminate"
9
10
  ];
10
11
  /**
@@ -17,13 +18,32 @@ export function readRunRecoveryFacts(store, taskId, runId) {
17
18
  return null;
18
19
  const task = store.getTask(taskId);
19
20
  const sessionSet = store.getTaskRoleSessionSet(taskId, run.roleName);
21
+ const events = store.listEvents(taskId);
22
+ const roleMailbox = store.getWorkMailbox?.({
23
+ kind: "role",
24
+ taskId,
25
+ roleName: run.roleName
26
+ }) ?? null;
20
27
  const progress = latestRunDurableProgressAt(store, taskId, run.roleName, runId);
21
28
  return {
22
29
  run,
23
30
  task: task === null ? null : { id: task.id, status: task.status },
24
31
  sessionSet,
32
+ inputDeliveryUnsettled: roleMailbox?.inputDelivery != null,
33
+ blockingProviderContinuation: runOwnsBlockingProviderContinuation(events, {
34
+ taskId,
35
+ roleName: run.roleName,
36
+ runId: run.id,
37
+ agentId: run.effective.agentId
38
+ }),
39
+ activeRuntimeOperation: runHasActiveRuntimeOperations(events, {
40
+ taskId,
41
+ roleName: run.roleName,
42
+ runId: run.id,
43
+ agentId: run.effective.agentId
44
+ }),
25
45
  progress,
26
- latestProviderObservation: latestRunProviderObservation(store.listEvents(taskId), runId)
46
+ latestProviderObservation: latestRunProviderObservation(events, runId)
27
47
  };
28
48
  }
29
49
  /**
@@ -72,9 +92,12 @@ export function projectRunRecovery(facts) {
72
92
  ? ["accepted", "ambiguous"]
73
93
  : ["rejected", "ambiguous"];
74
94
  const blocked = recoveryBlocker(facts, session, canonicalProgressAt);
75
- const supportedActions = session?.status === "stopped" || session?.status === "broken"
76
- ? RUN_RECOVERY_ACTIONS.filter((action) => action !== "retry")
77
- : RUN_RECOVERY_ACTIONS;
95
+ const sessionTerminal = session?.status === "stopped" || session?.status === "broken";
96
+ const providerTerminal = sessionSet?.providerBinding?.turn?.status === "failed"
97
+ || sessionSet?.providerBinding?.turn?.status === "cancelled"
98
+ || sessionSet?.providerBinding?.turn?.status === "rejected";
99
+ const supportedActions = RUN_RECOVERY_ACTIONS.filter((action) => ((action !== "retry" || !sessionTerminal)
100
+ && (action !== "terminate" || sessionTerminal || providerTerminal)));
78
101
  const actions = blocked === null
79
102
  ? supportedActions.map((action) => buildActionPlan(facts, action, session, canonicalProgressAt))
80
103
  : [];
@@ -139,6 +162,23 @@ function recoveryBlocker(facts, session, canonicalProgressAt) {
139
162
  return "progress-unavailable";
140
163
  if (session === null)
141
164
  return "session-missing";
165
+ if (facts.inputDeliveryUnsettled)
166
+ return "provider-input-delivery-unsettled";
167
+ if (facts.blockingProviderContinuation)
168
+ return "provider-continuation-writer-owned";
169
+ if (facts.activeRuntimeOperation)
170
+ return "provider-operation-active";
171
+ const binding = facts.sessionSet?.providerBinding;
172
+ if (run.deliveredAt !== undefined
173
+ && (binding === null || binding?.turn === null))
174
+ return "provider-turn-state-missing";
175
+ if (binding !== null && binding !== undefined) {
176
+ if (["submitting", "accepted", "running", "delivery-unknown"].includes(binding.turn?.status ?? ""))
177
+ return "provider-turn-unsettled";
178
+ if (binding.authority.owner === "human" || binding.authority.owner === "unknown") {
179
+ return "provider-writer-authority-unavailable";
180
+ }
181
+ }
142
182
  return null;
143
183
  }
144
184
  function buildActionPlan(facts, action, session, canonicalProgressAt) {
@@ -208,6 +248,5 @@ function actionAcceptance(facts, action) {
208
248
  const ACTION_REASONS = {
209
249
  diagnose: "Collect bounded diagnostics before any state-changing recovery.",
210
250
  retry: "Request another provider turn on the same native Session when the failure is transient.",
211
- "replace-session": "Request a fresh native Session when the current one is unusable.",
212
251
  terminate: "Fail the Run explicitly when recovery is not viable."
213
252
  };
@@ -1,18 +1,20 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { appendFileSync, chmodSync, closeSync, fsyncSync, mkdirSync, openSync, rmSync } from "node:fs";
3
- import { readdir, readFile, rename, unlink } from "node:fs/promises";
2
+ import { chmodSync, mkdirSync, rmSync } from "node:fs";
4
3
  import { tmpdir } from "node:os";
5
4
  import { dirname, join, resolve } from "node:path";
6
5
  import { createConnection, createServer } from "node:net";
7
6
  import { createInterface } from "node:readline";
8
- import { callController, ControllerClientError } from "../core/controllerClient.js";
7
+ import { callController, controllerCallMayHaveApplied, ControllerClientError } from "../core/controllerClient.js";
9
8
  import { readHomeFilesystemId } from "../core/homeFilesystemIdentity.js";
9
+ import { callFileTaskController } from "../controller/clientRuntime.js";
10
+ import { isForeignHandoverLockHeld } from "../release/runtimeRelease.js";
10
11
  import { publishStructuredProviderAccepted, publishStructuredProviderActivationTerminal, publishStructuredConversationRecoverability, publishStructuredProviderOpened, publishStructuredProviderTerminal } from "../controller/structuredProviderObservation.js";
11
12
  import { validateAgentHostLaunchPayload } from "./launchBroker.js";
12
13
  import { ProviderDeliveryUnknownError, ProviderConversationMissingError, ProviderTurnRejectedError, startStructuredProviderSession } from "./structuredProviderHost.js";
13
14
  import { sameProviderAuthorityFence, validateProviderAuthorityFence } from "./providerAuthorityFence.js";
14
15
  import { validateRuntimeProcessExitObservation } from "./processExitObservation.js";
15
- import { readRuntimeStopReceipt, removeRuntimeStopReceipt } from "./runtimeStopReceipt.js";
16
+ import { persistRuntimeProcessExitObservation, replayRuntimeProcessExitOutbox } from "./processExitOutbox.js";
17
+ import { readRuntimeStopReceipt, removeRuntimeStopReceipt, writeRuntimeStopReceipt } from "./runtimeStopReceipt.js";
16
18
  import { AGENT_HOST_CONTROL_TIMEOUT_MS, AGENT_HOST_READY_TIMEOUT_MS } from "./runtimeDeadlines.js";
17
19
  export const AGENT_HOST_CONTROL_PROTOCOL = "yui-agent-host/v2";
18
20
  const HOST_CONTROL_MAX_BYTES = 32 * 1024;
@@ -26,6 +28,7 @@ export async function runAgentHost(input) {
26
28
  let session;
27
29
  let sessionPayload;
28
30
  let activeTurnPayload;
31
+ const switchDetachedSessions = new WeakSet();
29
32
  let activationId;
30
33
  let conversationRecoverability = "unknown";
31
34
  let authority;
@@ -116,19 +119,21 @@ export async function runAgentHost(input) {
116
119
  const stopReceipt = readRuntimeStopReceipt(input.home, currentPayload.launchId);
117
120
  const observedAt = new Date().toISOString();
118
121
  const failures = [];
119
- try {
120
- await publishStructuredProviderActivationTerminal({
121
- home: input.home,
122
- environment: currentPayload.environment,
123
- conversationId: providerSession.conversationId,
124
- nativeSessionId: providerSession.nativeSessionId,
125
- activationId: currentActivationId,
126
- status: stopReceipt !== null || hostStopRequested ? "ended" : "failed",
127
- observedAt
128
- });
129
- }
130
- catch (error) {
131
- failures.push(`activation terminal: ${errorText(error)}`);
122
+ if (!switchDetachedSessions.has(providerSession)) {
123
+ try {
124
+ await publishStructuredProviderActivationTerminal({
125
+ home: input.home,
126
+ environment: currentPayload.environment,
127
+ conversationId: providerSession.conversationId,
128
+ nativeSessionId: providerSession.nativeSessionId,
129
+ activationId: currentActivationId,
130
+ status: stopReceipt !== null || hostStopRequested ? "ended" : "failed",
131
+ observedAt
132
+ });
133
+ }
134
+ catch (error) {
135
+ failures.push(`activation terminal: ${errorText(error)}`);
136
+ }
132
137
  }
133
138
  let exitPersisted = false;
134
139
  try {
@@ -163,7 +168,7 @@ export async function runAgentHost(input) {
163
168
  if (stopReceipt !== null && exitPersisted) {
164
169
  removeRuntimeStopReceipt(input.home, currentPayload.launchId);
165
170
  }
166
- if (hostStopRequested)
171
+ if (hostStopRequested || !ownsCurrentSession)
167
172
  return;
168
173
  updateSnapshot(hostSnapshot(failures.length === 0 ? "exited" : "failed", {
169
174
  launchId: currentPayload.launchId,
@@ -196,9 +201,12 @@ export async function runAgentHost(input) {
196
201
  throw new Error("Agent Host still owns an unsettled Provider Turn.");
197
202
  }
198
203
  const requestedAuthority = validateProviderAuthorityFence(providerControl.authority);
199
- if (authority === undefined)
204
+ const replacesCurrentConversation = session !== undefined && providerControl.kind === "new";
205
+ if (!replacesCurrentConversation && authority === undefined)
200
206
  authority = requestedAuthority;
201
- else if (!sameProviderAuthorityFence(authority, requestedAuthority)) {
207
+ else if (!replacesCurrentConversation
208
+ && authority !== undefined
209
+ && !sameProviderAuthorityFence(authority, requestedAuthority)) {
202
210
  throw new Error("Agent Host launch carries a stale Provider authority fence.");
203
211
  }
204
212
  updateSnapshot(hostSnapshot("starting", {
@@ -217,6 +225,43 @@ export async function runAgentHost(input) {
217
225
  let providerAcceptedAttemptId;
218
226
  let durableInitialTurn;
219
227
  try {
228
+ if (replacesCurrentConversation) {
229
+ const previousSession = session;
230
+ const previousPayload = sessionPayload;
231
+ const previousActivationId = activationId ?? previousPayload?.launchId;
232
+ if (previousPayload === undefined || previousActivationId === undefined
233
+ || authority?.owner !== "controller") {
234
+ throw new Error("Agent Host cannot detach an inexact Provider Activation.");
235
+ }
236
+ await detachDurableProviderForConversationSwitch(input.home, {
237
+ taskId: requiredEnvironment(next.environment.YUI_TASK_ID, "Task id"),
238
+ roleName: requiredEnvironment(next.environment.YUI_ROLE, "Role name"),
239
+ runId: requiredEnvironment(next.environment.YUI_RUN_ID, "Run id"),
240
+ agentId: requiredEnvironment(next.environment.YUI_AGENT_ID, "Agent id"),
241
+ launchId: next.launchId,
242
+ previousConversationId: previousSession.conversationId,
243
+ previousNativeSessionId: previousSession.nativeSessionId,
244
+ previousActivationId,
245
+ nextAuthorityEpoch: requestedAuthority.epoch,
246
+ nextAuthorityHolderId: requestedAuthority.holderId,
247
+ observedAt: new Date().toISOString()
248
+ });
249
+ switchDetachedSessions.add(previousSession);
250
+ // The persistent Provider child belongs to the Activation that first
251
+ // created it, not to the most recent resume Run payload. The exit
252
+ // observer carries that Activation launch id, so the stop receipt must
253
+ // use the same identity or the expected switch detach is misclassified
254
+ // as an abnormal child exit and generic cleanup can kill the Host.
255
+ writeRuntimeStopReceipt(input.home, previousActivationId, new Date());
256
+ authority = undefined;
257
+ await terminateProviderSessionForConversationSwitch(previousSession);
258
+ if (session === previousSession)
259
+ session = undefined;
260
+ sessionPayload = undefined;
261
+ activationId = undefined;
262
+ conversationRecoverability = "unknown";
263
+ authority = requestedAuthority;
264
+ }
220
265
  if (session !== undefined) {
221
266
  if (session.adapterId !== providerControl.adapterId
222
267
  || providerControl.mode !== "resume"
@@ -493,30 +538,12 @@ export async function runAgentHost(input) {
493
538
  }
494
539
  };
495
540
  const durableTurn = hostTurnControlParams(currentPayload, currentSession.nativeSessionId, currentAuthority, attemptId);
496
- try {
497
- await beginDurableProviderTurn(input.home, durableTurn);
498
- }
499
- catch (error) {
500
- await callController(input.home, "runtime.provider-turn-submission-resolve", {
501
- ...durableTurn,
502
- status: "rejected",
503
- reason: `Human Turn intent acknowledgement failed before Provider write: ${errorText(error)}`,
504
- observedAt: new Date().toISOString()
505
- }).catch(() => { });
506
- throw error;
507
- }
541
+ await beginDurableProviderTurn(input.home, durableTurn);
508
542
  try {
509
543
  await submitTurn(turnControl);
510
544
  }
511
545
  catch (error) {
512
- await callController(input.home, "runtime.provider-turn-submission-resolve", {
513
- ...durableTurn,
514
- status: error instanceof ProviderDeliveryUnknownError
515
- ? "delivery-unknown"
516
- : "rejected",
517
- reason: errorText(error),
518
- observedAt: new Date().toISOString()
519
- }).catch(() => { });
546
+ await resolveProviderTurnSubmission(input.home, durableTurn, error);
520
547
  throw error;
521
548
  }
522
549
  process.stdout.write("Provider accepted the human Turn; waiting for its terminal boundary.\n");
@@ -670,50 +697,19 @@ async function redeem(home, launchId, ticket) {
670
697
  return validateAgentHostLaunchPayload(result);
671
698
  }
672
699
  async function persistAndSubmitExit(home, observation) {
673
- const directory = resolve(join(home, "runtime", "agent-host-outbox"));
674
- mkdirSync(directory, { recursive: true, mode: 0o700 });
675
- const path = join(directory, `${observation.hostInstanceId}.jsonl`);
676
- const descriptor = openSync(path, "a", 0o600);
677
700
  try {
678
- appendFileSync(descriptor, `${JSON.stringify(observation)}\n`, "utf8");
679
- fsyncSync(descriptor);
680
- }
681
- finally {
682
- closeSync(descriptor);
683
- }
684
- chmodSync(path, 0o600);
685
- await callController(home, "runtime.process-exit-observe", observation);
686
- rmSync(path, { force: true });
687
- }
688
- async function replayExitOutbox(home) {
689
- const directory = resolve(join(home, "runtime", "agent-host-outbox"));
690
- let entries;
691
- try {
692
- entries = await readdir(directory);
701
+ await persistRuntimeProcessExitObservation(home, observation, (value) => callController(home, "runtime.process-exit-observe", value).then(() => undefined));
693
702
  }
694
703
  catch (error) {
695
- if (error.code === "ENOENT")
704
+ // The durable outbox is the acknowledgement during a planned Controller
705
+ // gap. The replacement Controller drains it before accepting new work.
706
+ if (isForeignHandoverLockHeld(home))
696
707
  return;
697
708
  throw error;
698
709
  }
699
- for (const name of entries.filter((value) => value.includes(".jsonl")).sort()) {
700
- const path = join(directory, name);
701
- const claimed = `${path}.claim-${process.pid}-${randomUUID()}`;
702
- try {
703
- await rename(path, claimed);
704
- }
705
- catch (error) {
706
- if (error.code === "ENOENT")
707
- continue;
708
- throw error;
709
- }
710
- const lines = (await readFile(claimed, "utf8")).split("\n").filter(Boolean);
711
- for (const line of lines) {
712
- const observation = validateRuntimeProcessExitObservation(JSON.parse(line));
713
- await callController(home, "runtime.process-exit-observe", observation);
714
- }
715
- await unlink(claimed);
716
- }
710
+ }
711
+ async function replayExitOutbox(home) {
712
+ await replayRuntimeProcessExitOutbox(home, (observation) => callController(home, "runtime.process-exit-observe", observation).then(() => undefined));
717
713
  }
718
714
  /** Internal socket boundary exported for transport-level verification. */
719
715
  export async function openAgentHostControl(home, payload, snapshot, dispatch) {
@@ -906,26 +902,104 @@ function hostTurnControlParams(payload, nativeSessionId, authority, attemptId) {
906
902
  }
907
903
  async function beginDurableProviderTurn(home, durableTurn) {
908
904
  try {
909
- await callController(home, "runtime.provider-turn-begin", durableTurn);
905
+ await callControllerIdempotently(home, "runtime.provider-turn-begin", durableTurn);
906
+ }
907
+ catch (error) {
908
+ if (!(error instanceof ControllerAcknowledgementUnknownError))
909
+ throw error;
910
+ await resolveProviderTurnSubmission(home, durableTurn, new Error(`Provider Turn intent acknowledgement failed before Provider write: ${errorText(error)}`));
911
+ throw error;
912
+ }
913
+ }
914
+ async function detachDurableProviderForConversationSwitch(home, request) {
915
+ await callControllerIdempotently(home, "runtime.conversation-switch-detach", request);
916
+ }
917
+ async function callControllerIdempotently(home, method, request) {
918
+ try {
919
+ await callAgentController(home, method, request);
910
920
  }
911
921
  catch (error) {
912
- if (!(error instanceof ControllerClientError) || error.code !== "INTERNAL_ERROR") {
922
+ if (!(error instanceof ControllerClientError)
923
+ || (error.code !== "INTERNAL_ERROR" && !controllerCallMayHaveApplied(error))) {
913
924
  throw error;
914
925
  }
915
- // The exact attempt is idempotent, so one lost acknowledgement can be
916
- // retried without creating a second Provider write.
917
- await callController(home, "runtime.provider-turn-begin", durableTurn);
926
+ const firstCallMayHaveApplied = controllerCallMayHaveApplied(error);
927
+ // These methods carry exact attempt, launch, and authority fences. A
928
+ // bounded replay confirms a commit whose acknowledgement may have been lost.
929
+ try {
930
+ await callAgentController(home, method, request);
931
+ }
932
+ catch (replayError) {
933
+ if (firstCallMayHaveApplied || controllerCallMayHaveApplied(replayError)) {
934
+ throw new ControllerAcknowledgementUnknownError(`${method} may have been committed, but its acknowledgement could not be confirmed: ${errorText(replayError)}`);
935
+ }
936
+ throw replayError;
937
+ }
938
+ }
939
+ }
940
+ class ControllerAcknowledgementUnknownError extends Error {
941
+ name = "ControllerAcknowledgementUnknownError";
942
+ }
943
+ async function terminateProviderSessionForConversationSwitch(providerSession) {
944
+ providerSession.terminate("SIGTERM");
945
+ const forceKill = setTimeout(() => providerSession.terminate("SIGKILL"), 3_000);
946
+ forceKill.unref();
947
+ let hardTimeout;
948
+ const timeout = new Promise((_resolve, reject) => {
949
+ hardTimeout = setTimeout(() => reject(new Error("Old Provider Activation did not exit after its switch authority was revoked.")), 8_000);
950
+ hardTimeout.unref();
951
+ });
952
+ try {
953
+ await Promise.race([providerSession.waitForExit(), timeout]);
954
+ }
955
+ finally {
956
+ clearTimeout(forceKill);
957
+ if (hardTimeout !== undefined)
958
+ clearTimeout(hardTimeout);
918
959
  }
919
960
  }
920
961
  async function resolveProviderTurnSubmission(home, durableTurn, error) {
921
- await callController(home, "runtime.provider-turn-submission-resolve", {
962
+ const attemptId = durableTurn.attemptId;
963
+ if (typeof attemptId !== "string") {
964
+ throw new Error("Provider Turn resolution has no attempt id.");
965
+ }
966
+ const request = {
922
967
  ...durableTurn,
923
968
  status: error instanceof ProviderDeliveryUnknownError
924
969
  ? "delivery-unknown"
925
970
  : "rejected",
926
971
  reason: errorText(error),
927
972
  observedAt: new Date().toISOString()
928
- }).catch(() => { });
973
+ };
974
+ try {
975
+ await callControllerIdempotently(home, "runtime.provider-turn-submission-resolve", request);
976
+ }
977
+ catch (resolutionError) {
978
+ throw new ProviderDeliveryUnknownError(`Provider submission outcome could not be durably resolved: ${errorText(resolutionError)}. Original outcome: ${errorText(error)}`, attemptId);
979
+ }
980
+ }
981
+ /**
982
+ * Existing in-flight launches keep using the old Controller while it drains.
983
+ * If the socket is already gone under an explicit handover fence, wait for
984
+ * the replacement and retry the domain-idempotent Agent Host operation.
985
+ */
986
+ async function callAgentController(home, method, params) {
987
+ try {
988
+ await callController(home, method, params);
989
+ return;
990
+ }
991
+ catch (error) {
992
+ if (!isControllerUnavailable(error) || !isForeignHandoverLockHeld(home)) {
993
+ throw error;
994
+ }
995
+ }
996
+ await callFileTaskController(home, method, params);
997
+ }
998
+ function isControllerUnavailable(error) {
999
+ return error instanceof ControllerClientError
1000
+ && (error.code === "CONTROLLER_NOT_RUNNING"
1001
+ || error.code === "CONTROLLER_UNAVAILABLE"
1002
+ || error.code === "CONTROLLER_DRAINING");
929
1003
  }
930
1004
  function requiredEnvironment(value, label) {
931
1005
  if (typeof value !== "string" || value.trim().length === 0 || value.includes("\0")) {