@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
@@ -1,7 +1,8 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { isDeepStrictEqual } from "node:util";
3
- import { activeLiveRoleAgentSession, bindTaskRoleProviderRuntime, bindTaskRoleRun, clearTaskRoleProviderRuntimeForCleanup, clearTaskRoleRun, createRoleSessionSet, markTaskRoleRunDelivered, markTaskRoleRunPushed, prepareTaskRoleRunRedispatch, recordRoleAgentSession, recordTaskRoleTurnBoundary, rememberRoleAgentCompletedTurn, updateRoleAgentSessionStatus, updateTaskRoleProviderRuntime } from "../executor/agentExecutor.js";
4
- import { acceptProviderTurn, beginProviderTurn, createProviderRuntimeBinding, endProviderActivation, currentProviderActivation, currentProviderConversation, markProviderTurnDeliveryUnknown, rejectProviderTurn, settleProviderTurn, startProviderActivation, supersedeProviderConversation, updateProviderConversationRecoverability } from "../runtime/providerRuntimeIdentity.js";
3
+ import { activeLiveRoleAgentSession, bindTaskRoleProviderRuntime, bindTaskRoleRun, clearTaskRoleRun, createRoleSessionSet, markTaskRoleRunDelivered, markTaskRoleRunPushed, prepareTaskRoleRunRedispatch, recordRoleAgentSession, replaceTaskRoleAgentSessionForConversationSwitch, recordTaskRoleTurnBoundary, rememberRoleAgentCompletedTurn, stopTaskRoleRuntimeAfterPhysicalExit, updateRoleAgentSessionStatus, updateTaskRoleProviderRuntime } from "../executor/agentExecutor.js";
4
+ import { acceptProviderTurn, beginProviderTurn, createProviderRuntimeBinding, endProviderActivation, currentProviderActivation, currentProviderConversation, markProviderTurnDeliveryUnknown, rejectProviderTurn, settleProviderTurnSubmission, settleProviderTurn, startProviderActivation, supersedeProviderConversation, updateProviderConversationRecoverability } from "../runtime/providerRuntimeIdentity.js";
5
+ import { CONVERSATION_SWITCH_DETACHED_EVENT, CONVERSATION_SWITCH_RESOLVED_EVENT, conversationDetachmentBasis, conversationReplacementBasis, pendingConversationSwitch, roleSessionDispatchModeWithConversationSwitch } from "../runtime/conversationSwitch.js";
5
6
  import { decideProviderRecovery } from "../runtime/providerRecoveryDecision.js";
6
7
  import { hasRecentTurnId } from "../executor/turnCompletion.js";
7
8
  import { createTaskEvent } from "../event/taskEvent.js";
@@ -21,11 +22,11 @@ import { classifyRuntimeProcessExit, validateRuntimeProcessExitObservation } fro
21
22
  import { terminalizeExactTaskRun } from "../lifecycle/exactRunTerminalization.js";
22
23
  import { createCanonicalLifecycleEvent, foldCanonicalLifecycleEvent } from "../lifecycle/canonicalLifecycleEvent.js";
23
24
  import { recordLeaderFailure } from "../scheduler/leaderFailure.js";
24
- import { enqueueOperatorEvent, recordLeaderAttentionRequired, routeRoleEvent } from "../scheduler/operatorEvent.js";
25
+ import { recordLeaderAttentionRequired, routeRoleEvent } from "../scheduler/operatorEvent.js";
25
26
  import { pendingWakeupsMatch } from "../scheduler/pendingWakeup.js";
26
27
  import { queueLeaderWakeup } from "../scheduler/wakeupQueue.js";
27
28
  import { wakeReason } from "../scheduler/wakeReason.js";
28
- import { foldRunProgressFacts, latestRunDurableProgressAt, latestRunEventTime, latestStallEvidenceKey, RUN_PROGRESS_EVENT, RUN_RECOVERED_EVENT, RUN_STALLED_EVENT } from "../scheduler/roleRunStall.js";
29
+ import { foldRunProgressFacts, latestRunDurableProgressAt, latestRunEventTime, latestStallEvidenceKey, isRoleRunStalled, RUN_PROGRESS_EVENT, RUN_DIAGNOSTIC_FINISHED_EVENT, RUN_RECOVERED_EVENT, RUN_STALLED_EVENT } from "../scheduler/roleRunStall.js";
29
30
  import { projectProviderContinuations } from "../runtime/runtimeContinuationProjection.js";
30
31
  import { providerContinuationKey } from "../runtime/providerContinuation.js";
31
32
  import { currentWorkItemExecutionGroup, updateWorkItemExecutionGroup, updateWorkItemStatus, workItemOwnsUnresolvedExecutionLane } from "../workItem/workItem.js";
@@ -36,7 +37,7 @@ import { enqueueWork } from "../coordination/workMailboxQueue.js";
36
37
  import { RUNTIME_CLEANUP_REQUIRED_REASON, RUNTIME_LAUNCH_RESERVED_REASON, RUNTIME_LIFECYCLE_OWNER, RuntimeLifecycleBusyError, hasRuntimeCleanupObligation, hasRuntimeLifecycleWork, isRuntimeLaunchReservation, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
37
38
  import { nativeSessionIdForLaunch } from "../runtime/preallocatedNativeSession.js";
38
39
  import { builtinAgentDriverRegistry } from "../runtime/builtinAgentDrivers.js";
39
- import { RUNTIME_OBSERVATION_TASK_EVENT, createRuntimeObservation, isRuntimeTokenEvidence, runtimeObservationFenceMatches, runtimeObservationFromTaskEvent, runtimeObservationRunFenceMatches, runtimeObservationTaskEventPayload } from "../runtime/runtimeObservation.js";
40
+ import { RUNTIME_OBSERVATION_TASK_EVENT, createRuntimeObservation, isRuntimeTokenEvidence, runtimeObservationFenceMatches, runtimeObservationFromTaskEvent, runtimeObservationRunFenceMatches, runtimeObservationTaskEventPayload, runHasActiveRuntimeOperations } from "../runtime/runtimeObservation.js";
40
41
  import { projectRuntimeTaskEvents } from "../runtime/runtimeProjection.js";
41
42
  import { contextSnapshotRef } from "../context/contextSnapshot.js";
42
43
  import { contextSnapshotDeltaRefIds, freezeRunContextSnapshot } from "../context/runContextPack.js";
@@ -386,7 +387,7 @@ export class FileSchedulerStoreAdapter {
386
387
  const message = [
387
388
  `Leader native Session became unavailable while Run ${active.id} remains active.`,
388
389
  "Yui preserved the Run because Session/host termination is not a workflow outcome.",
389
- "Inspect native-child and mailbox facts, then explicitly continue, reset, or request user input."
390
+ "Inspect native-child and mailbox facts, then explicitly diagnose, recover the Run, request a Conversation switch, or request user input."
390
391
  ].join(" ");
391
392
  recordLeaderAttentionRequired(store, {
392
393
  taskId: input.fence.taskId,
@@ -718,6 +719,27 @@ export class FileSchedulerStoreAdapter {
718
719
  // the fold as present and skips the per-candidate fallback scans.
719
720
  return latestRunDurableProgressAt(view, taskId, roleName, runId, projected.runFacts.get(runId) ?? {});
720
721
  }
722
+ recordRoleRunDiagnostic(input) {
723
+ return this.store.transaction((store) => {
724
+ const task = store.getTask(input.taskId);
725
+ const run = store.getActiveAgentRun(input.taskId, input.roleName);
726
+ if (task === null || task.status !== "active"
727
+ || run === null || run.id !== input.runId || run.status !== "active") {
728
+ return "state-changed";
729
+ }
730
+ const latest = latestRunEventTime(store.listEvents(input.taskId), RUN_DIAGNOSTIC_FINISHED_EVENT, input.runId);
731
+ if (latest !== undefined && Date.parse(latest) >= Date.parse(input.startedAt)) {
732
+ return "already-recorded";
733
+ }
734
+ store.saveEvent(input.taskId, createTaskEvent(store.nextEventId(input.taskId), input.taskId, RUN_DIAGNOSTIC_FINISHED_EVENT, {
735
+ runId: input.runId,
736
+ roleName: input.roleName,
737
+ outcome: input.outcome,
738
+ startedAt: input.startedAt
739
+ }, input.now));
740
+ return "recorded";
741
+ });
742
+ }
721
743
  recordRoleRunStall(input) {
722
744
  return this.store.transaction((store) => {
723
745
  const task = store.getTask(input.taskId);
@@ -749,15 +771,9 @@ export class FileSchedulerStoreAdapter {
749
771
  progressAt: input.progressAt,
750
772
  idleMs: String(Math.max(0, Math.floor(input.idleMs))),
751
773
  evidenceKey: input.evidenceKey,
752
- status: "needs-attention"
774
+ status: "diagnostic-only"
753
775
  }, input.now);
754
776
  store.saveEvent(task.id, event);
755
- if (role.name === "leader") {
756
- enqueueOperatorEvent(store, event, "leader-run-stalled", input.now);
757
- }
758
- else {
759
- queueLeaderWakeup(store, task.id, wakeReason("role-run-stalled"), input.now);
760
- }
761
777
  return "raised";
762
778
  });
763
779
  }
@@ -785,10 +801,9 @@ export class FileSchedulerStoreAdapter {
785
801
  || (typeof event.payload.progressAt === "string"
786
802
  && Number.isFinite(Date.parse(event.payload.progressAt))
787
803
  && Date.parse(event.payload.progressAt) >= Date.parse(input.progressAt)))));
788
- const stalledAt = latestRunEventTime(events, RUN_STALLED_EVENT, run.id);
789
- const recoveredAt = latestRunEventTime(events, RUN_RECOVERED_EVENT, run.id);
790
- const recovered = stalledAt !== undefined
791
- && (recoveredAt === undefined || Date.parse(recoveredAt) <= Date.parse(stalledAt));
804
+ // Advisory 30-minute diagnostics are intentionally not lifecycle
805
+ // episodes and therefore must never synthesize run.recovered.
806
+ const recovered = isRoleRunStalled(events, run.id);
792
807
  if (!existing) {
793
808
  store.saveEvent(task.id, createTaskEvent(store.nextEventId(task.id), task.id, RUN_PROGRESS_EVENT, {
794
809
  runId: run.id,
@@ -813,11 +828,92 @@ export class FileSchedulerStoreAdapter {
813
828
  const sessions = this.store.getTaskRoleSessionSet(taskId, roleName);
814
829
  return sessions !== null && sessions.inFlight !== null;
815
830
  }
831
+ detachAgentHostProviderForConversationSwitch(input) {
832
+ return this.store.transaction((store) => {
833
+ const sessions = store.getTaskRoleSessionSet(input.taskId, input.roleName);
834
+ const run = store.getAgentRun(input.taskId, input.runId);
835
+ const mailbox = store.getWorkMailbox({
836
+ kind: "role",
837
+ taskId: input.taskId,
838
+ roleName: input.roleName
839
+ });
840
+ const binding = sessions?.providerBinding;
841
+ const session = sessions?.sessions[input.agentId];
842
+ const task = store.getTask(input.taskId);
843
+ const role = store.getRole(input.taskId, input.roleName);
844
+ const activeRun = store.getActiveAgentRun(input.taskId, input.roleName);
845
+ if (task === null || task.status !== "active"
846
+ || role === null || role.activeAgentId !== input.agentId
847
+ || sessions === null || sessions === undefined
848
+ || run === null || run.status !== "active" || run.mode !== "new"
849
+ || activeRun?.id !== run.id
850
+ || run.effective.agentId !== input.agentId
851
+ || session?.nativeSessionId !== input.previousNativeSessionId
852
+ || binding === null || binding === undefined
853
+ || currentProviderConversation(binding).conversationId !== input.previousConversationId
854
+ || sessions.inFlight?.runId !== input.runId
855
+ || mailbox?.processing?.batchId !== sessions.inFlight.receiptId
856
+ || mailbox.processing.owner !== "controller"
857
+ || mailbox.processing.executionRef?.type !== "run"
858
+ || mailbox.processing.executionRef.taskId !== input.taskId
859
+ || mailbox.processing.executionRef.id !== input.runId
860
+ || mailbox.inputDelivery !== null
861
+ || pendingConversationSwitch(store.listEvents(input.taskId), input.roleName, sessions) === null
862
+ || !runtimeHookMatchesReservation(store, { scope: "task", taskId: input.taskId, roleName: input.roleName }, input.launchId)
863
+ || input.nextAuthorityHolderId !== input.launchId) {
864
+ throw new Error("Provider Conversation detachment no longer matches the exact switch Run.");
865
+ }
866
+ const target = binding.activations.find((entry) => (entry.activationId === input.previousActivationId
867
+ && entry.conversationId === input.previousConversationId));
868
+ if (target === undefined) {
869
+ throw new Error("Provider Conversation detachment targets an unknown Activation.");
870
+ }
871
+ if (target.status !== "active") {
872
+ if (binding.authority.owner !== "none"
873
+ || input.nextAuthorityEpoch !== binding.authority.epoch + 1) {
874
+ throw new Error("Provider Conversation detachment replay has a stale authority fence.");
875
+ }
876
+ return "already-detached";
877
+ }
878
+ if (conversationDetachmentBasis({
879
+ sessions,
880
+ events: store.listEvents(input.taskId),
881
+ mailbox,
882
+ roleName: input.roleName,
883
+ runId: input.runId,
884
+ runMode: run.mode
885
+ }) !== "actor-request"
886
+ || binding.authority.owner !== "controller"
887
+ || binding.authority.holderId !== target.activationId
888
+ || input.nextAuthorityEpoch !== binding.authority.epoch + 2) {
889
+ throw new Error("Provider Conversation detachment is not authorized at this boundary.");
890
+ }
891
+ const detached = endProviderActivation(binding, target.activationId, {
892
+ status: "ended",
893
+ endedAt: input.now.toISOString(),
894
+ reason: "conversation-switch"
895
+ });
896
+ store.saveTaskRoleSessionSet(updateTaskRoleProviderRuntime(sessions, detached, input.now));
897
+ store.saveEvent(input.taskId, createTaskEvent(store.nextEventId(input.taskId), input.taskId, CONVERSATION_SWITCH_DETACHED_EVENT, {
898
+ runId: input.runId,
899
+ roleName: input.roleName,
900
+ launchId: input.launchId,
901
+ conversationId: input.previousConversationId,
902
+ activationId: input.previousActivationId
903
+ }, input.now));
904
+ return "detached";
905
+ });
906
+ }
816
907
  beginAgentHostProviderTurn(input) {
817
908
  this.store.transaction((store) => {
818
909
  const sessions = store.getTaskRoleSessionSet(input.taskId, input.roleName);
819
910
  const session = sessions?.sessions[input.agentId];
820
911
  const binding = sessions?.providerBinding;
912
+ const mailbox = store.getWorkMailbox({
913
+ kind: "role",
914
+ taskId: input.taskId,
915
+ roleName: input.roleName
916
+ });
821
917
  if (sessions === null || sessions === undefined
822
918
  || binding === null || binding === undefined
823
919
  || sessions.inFlight?.runId !== input.runId
@@ -827,7 +923,8 @@ export class FileSchedulerStoreAdapter {
827
923
  || currentProviderConversation(binding).conversationId !== input.nativeSessionId
828
924
  || binding.authority.owner !== input.authorityOwner
829
925
  || binding.authority.epoch !== input.authorityEpoch
830
- || binding.authority.holderId !== input.holderId) {
926
+ || binding.authority.holderId !== input.holderId
927
+ || !processingOwnsExactInitialDelivery(mailbox, input.taskId, input.runId, input.attemptId)) {
831
928
  throw new AgentHostProviderTurnFenceError("Agent Host Provider Turn carries a stale durable writer fence. Release and reacquire Provider authority before retrying input.");
832
929
  }
833
930
  const exactReplay = binding.turn?.attemptId === input.attemptId
@@ -852,21 +949,15 @@ export class FileSchedulerStoreAdapter {
852
949
  if (sessions === null || sessions === undefined
853
950
  || binding === null || binding === undefined
854
951
  || binding.runId !== input.runId
855
- || binding.turn?.attemptId !== input.attemptId
856
- || binding.turn.status !== "submitting") {
952
+ || binding.turn?.attemptId !== input.attemptId) {
857
953
  throw new Error("Agent Host Provider Turn submission is no longer current.");
858
954
  }
859
- const updated = input.status === "delivery-unknown"
860
- ? markProviderTurnDeliveryUnknown(binding, {
861
- attemptId: input.attemptId,
862
- observedAt: input.now.toISOString(),
863
- reason: input.reason
864
- })
865
- : rejectProviderTurn(binding, {
866
- attemptId: input.attemptId,
867
- rejectedAt: input.now.toISOString(),
868
- reason: input.reason
869
- });
955
+ const updated = settleProviderTurnSubmission(binding, {
956
+ attemptId: input.attemptId,
957
+ status: input.status,
958
+ reason: input.reason,
959
+ resolvedAt: input.now.toISOString()
960
+ });
870
961
  store.saveTaskRoleSessionSet(updateTaskRoleProviderRuntime(sessions, updated, input.now));
871
962
  });
872
963
  }
@@ -889,68 +980,6 @@ export class FileSchedulerStoreAdapter {
889
980
  ...binding.authority
890
981
  };
891
982
  }
892
- beginRoleRunProviderTurn(input) {
893
- return this.store.transaction((store) => {
894
- const sessions = store.getTaskRoleSessionSet(input.taskId, input.roleName);
895
- const session = sessions?.sessions[input.agentId];
896
- const binding = sessions?.providerBinding;
897
- if (sessions === null || sessions === undefined
898
- || binding === null || binding === undefined
899
- || sessions.inFlight?.runId !== input.runId
900
- || binding.runId !== input.runId
901
- || session?.launchId !== input.launchId
902
- || session.nativeSessionId !== input.nativeSessionId
903
- || currentProviderConversation(binding).conversationId !== input.nativeSessionId
904
- || binding.authority.owner !== "controller")
905
- return false;
906
- store.saveTaskRoleSessionSet(updateTaskRoleProviderRuntime(sessions, beginProviderTurn(binding, {
907
- attemptId: input.attemptId,
908
- authorityEpoch: binding.authority.epoch,
909
- submittedAt: input.now.toISOString()
910
- }), input.now));
911
- return true;
912
- });
913
- }
914
- resolveRoleRunProviderSubmission(input) {
915
- return this.store.transaction((store) => {
916
- const sessions = store.getTaskRoleSessionSet(input.taskId, input.roleName);
917
- const binding = sessions?.providerBinding;
918
- if (sessions === null || sessions === undefined
919
- || binding === null || binding === undefined
920
- || binding.runId !== input.runId
921
- || binding.turn?.attemptId !== input.attemptId
922
- || binding.turn.status !== "submitting")
923
- return false;
924
- const updated = input.status === "delivery-unknown"
925
- ? markProviderTurnDeliveryUnknown(binding, {
926
- attemptId: input.attemptId,
927
- observedAt: input.now.toISOString(),
928
- reason: input.reason
929
- })
930
- : rejectProviderTurn(binding, {
931
- attemptId: input.attemptId,
932
- rejectedAt: input.now.toISOString(),
933
- reason: input.reason
934
- });
935
- store.saveTaskRoleSessionSet(updateTaskRoleProviderRuntime(sessions, updated, input.now));
936
- return true;
937
- });
938
- }
939
- isRoleGenerationProviderReady(input) {
940
- // A session.ready observation for this exact generation is the durable
941
- // pre-input fence. This reads folded Driver truth
942
- // only — no liveness, screen, or pane/PID inference. Served from the
943
- // revision projection: a stale miss only repeats an idempotent first push.
944
- return this.listEvents(input.taskId).some((event) => {
945
- const observation = runtimeObservationFromTaskEvent(event);
946
- return observation?.kind === "session.ready"
947
- && observation.fence.roleName === input.roleName
948
- && observation.fence.agentId === input.agentId
949
- && (input.launchId === undefined || observation.fence.launchId === input.launchId)
950
- && (input.nativeSessionId === undefined
951
- || observation.fence.nativeSessionId === input.nativeSessionId);
952
- });
953
- }
954
983
  peekNextAgentRunId(taskId) {
955
984
  return this.store.peekNextAgentRunId(taskId);
956
985
  }
@@ -1211,12 +1240,6 @@ export class FileSchedulerStoreAdapter {
1211
1240
  }
1212
1241
  const owner = runtimeOwnerFromTarget(target);
1213
1242
  markRuntimeOwnerSessionStopped(store, owner, now);
1214
- if (owner.scope === "task") {
1215
- const sessions = store.getTaskRoleSessionSet(owner.taskId, owner.roleName);
1216
- if (sessions !== null && sessions.providerBinding !== null) {
1217
- store.saveTaskRoleSessionSet(clearTaskRoleProviderRuntimeForCleanup(sessions, now));
1218
- }
1219
- }
1220
1243
  saveRuntimeLifecycleMailbox(store, mailbox);
1221
1244
  return true;
1222
1245
  });
@@ -1353,7 +1376,9 @@ export class FileSchedulerStoreAdapter {
1353
1376
  now: input.now
1354
1377
  }));
1355
1378
  }
1356
- if (input.session !== null && input.session.nativeSessionId !== undefined) {
1379
+ if (input.run.mode !== "new"
1380
+ && input.session !== null
1381
+ && input.session.nativeSessionId !== undefined) {
1357
1382
  saveTaskSession(store, role, {
1358
1383
  ...input.session,
1359
1384
  nativeSessionId: input.session.nativeSessionId
@@ -1450,9 +1475,13 @@ export class FileSchedulerStoreAdapter {
1450
1475
  if (input.session !== null && input.session.nativeSessionId !== undefined) {
1451
1476
  const existing = store.getRoleSession(input.task.id, input.role.name);
1452
1477
  matchingPreparedRuntimeReservation(store, input);
1453
- if (existing?.nativeSessionId !== input.session.nativeSessionId
1454
- || (input.launchId !== undefined && existing.launchId !== input.launchId)
1455
- || existing.status !== "running") {
1478
+ const defersConversationReplacement = active.mode === "new"
1479
+ && existing !== null
1480
+ && existing.nativeSessionId !== input.session.nativeSessionId;
1481
+ if (!defersConversationReplacement
1482
+ && (existing?.nativeSessionId !== input.session.nativeSessionId
1483
+ || (input.launchId !== undefined && existing.launchId !== input.launchId)
1484
+ || existing.status !== "running")) {
1456
1485
  saveTaskSession(store, role, {
1457
1486
  ...input.session,
1458
1487
  nativeSessionId: input.session.nativeSessionId
@@ -1496,7 +1525,8 @@ export class FileSchedulerStoreAdapter {
1496
1525
  || !preparedReservationMatches(runtimeMailbox, input.taskId, input.runId, input.launchId)) {
1497
1526
  return "state-changed";
1498
1527
  }
1499
- const summary = `Role delivery retry limit exhausted before exact Run input delivery: ${input.runId}.`;
1528
+ const summary = input.summary
1529
+ ?? `Role delivery retry limit exhausted before exact Run input delivery: ${input.runId}.`;
1500
1530
  const result = terminalizeExactTaskRun(store, {
1501
1531
  taskId: input.taskId,
1502
1532
  roleName: input.roleName,
@@ -1512,11 +1542,13 @@ export class FileSchedulerStoreAdapter {
1512
1542
  if (result.disposition !== "applied" || result.run === null) {
1513
1543
  return "state-changed";
1514
1544
  }
1515
- enqueueWork(store, runtimeLifecycleTarget({
1516
- scope: "task",
1517
- taskId: input.taskId,
1518
- roleName: input.roleName
1519
- }), RUNTIME_CLEANUP_REQUIRED_REASON, input.now, [{ type: "task", id: input.taskId }]);
1545
+ if (input.cleanupRequired !== false) {
1546
+ enqueueWork(store, runtimeLifecycleTarget({
1547
+ scope: "task",
1548
+ taskId: input.taskId,
1549
+ roleName: input.roleName
1550
+ }), RUNTIME_CLEANUP_REQUIRED_REASON, input.now, [{ type: "task", id: input.taskId }]);
1551
+ }
1520
1552
  const terminal = result.run;
1521
1553
  const deliveryFailureEvent = createTaskEvent(store.nextEventId(input.taskId), input.taskId, "runtime.role-delivery-failed", {
1522
1554
  runId: terminal.id,
@@ -2430,7 +2462,7 @@ export class FileSchedulerStoreAdapter {
2430
2462
  /**
2431
2463
  * Issue 04: reopens each due retry on its original Native Session. A Run
2432
2464
  * whose Session is proven dead terminalizes with an exact replacement
2433
- * blocker; a live Session is reset for the existing delivery path, which
2465
+ * blocker; a live Session is reopened for the existing delivery path, which
2434
2466
  * re-pushes the exact same input in the same pass.
2435
2467
  */
2436
2468
  resolveDueProviderRetries(now, taskIds) {
@@ -2463,9 +2495,10 @@ export class FileSchedulerStoreAdapter {
2463
2495
  || !providerRetryIsDue(run.providerRetry, now)) {
2464
2496
  return;
2465
2497
  }
2466
- let sessions = store.getTaskRoleSessionSet(entry.taskId, entry.roleName);
2498
+ const sessions = store.getTaskRoleSessionSet(entry.taskId, entry.roleName);
2467
2499
  const providerBinding = sessions?.providerBinding;
2468
2500
  let recoveryMode = "resume";
2501
+ let replacementRequired = false;
2469
2502
  if (providerBinding === null || providerBinding === undefined) {
2470
2503
  deferUnprovenProviderRecovery(store, run, entry.roleName, "Provider Conversation identity is missing; automatic recovery is fenced.", now);
2471
2504
  return;
@@ -2510,13 +2543,7 @@ export class FileSchedulerStoreAdapter {
2510
2543
  deferUnprovenProviderRecovery(store, run, entry.roleName, "Provider replacement lacks an exact missing-Conversation observation.", now);
2511
2544
  return;
2512
2545
  }
2513
- recoveryMode = "new";
2514
- const currentSession = sessions?.sessions[run.effective.agentId];
2515
- if (sessions !== null && currentSession !== undefined
2516
- && currentSession.status !== "broken") {
2517
- sessions = updateRoleAgentSessionStatus(sessions, run.effective.agentId, "broken", now);
2518
- store.saveTaskRoleSessionSet(sessions);
2519
- }
2546
+ replacementRequired = true;
2520
2547
  }
2521
2548
  const session = sessions?.sessions[run.effective.agentId];
2522
2549
  const identityMatches = session !== undefined
@@ -2549,7 +2576,12 @@ export class FileSchedulerStoreAdapter {
2549
2576
  routeRoleEvent(store, attentionEvent, entry.roleName, "provider-retry-native-resume-unproven", now);
2550
2577
  return;
2551
2578
  }
2552
- if (hasUnsettledRuntimeOperations(store, entry.taskId, run.id)) {
2579
+ if (runHasActiveRuntimeOperations(store.listEvents(entry.taskId), {
2580
+ taskId: entry.taskId,
2581
+ roleName: entry.roleName,
2582
+ runId: run.id,
2583
+ agentId: run.effective.agentId
2584
+ })) {
2553
2585
  const deferred = deferProviderRetry(run.providerRetry, now);
2554
2586
  if (deferred === null) {
2555
2587
  finalizeProviderRetryDeadline(store, run, "Provider retry stopped because tool/subagent outcome remained uncertain through the bounded episode deadline.", "operation-outcome-uncertain", now);
@@ -2558,6 +2590,21 @@ export class FileSchedulerStoreAdapter {
2558
2590
  store.saveAgentRun(withProviderRetry(run, deferred));
2559
2591
  return;
2560
2592
  }
2593
+ if (replacementRequired) {
2594
+ try {
2595
+ recoveryMode = roleSessionDispatchModeWithConversationSwitch(sessions, store.listEvents(entry.taskId), roleMailbox, entry.roleName, run.effective.agentId, run.effective);
2596
+ }
2597
+ catch (error) {
2598
+ deferUnprovenProviderRecovery(store, run, entry.roleName, error instanceof Error
2599
+ ? error.message
2600
+ : "Independent Conversation-switch evaluation failed closed.", now);
2601
+ return;
2602
+ }
2603
+ if (recoveryMode !== "new") {
2604
+ deferUnprovenProviderRecovery(store, run, entry.roleName, "Independent Conversation-switch evaluation kept the current Conversation.", now);
2605
+ return;
2606
+ }
2607
+ }
2561
2608
  // Reopen the Run on its original Session: reset transport markers,
2562
2609
  // switch to resume, and mark the projection in-flight. The delivery
2563
2610
  // pass re-pushes the exact same input in this same pass.
@@ -2667,7 +2714,30 @@ export class FileSchedulerStoreAdapter {
2667
2714
  recordCanonicalObservationObsolete(store, input, "bind-state-missing", now);
2668
2715
  return "obsolete";
2669
2716
  }
2670
- const bound = recordRoleAgentSession(sessions, {
2717
+ const mailbox = store.getWorkMailbox({
2718
+ kind: "role",
2719
+ taskId,
2720
+ roleName: input.fence.roleName
2721
+ });
2722
+ const existingNativeSessionId = sessions.sessions[input.fence.agentId]
2723
+ ?.nativeSessionId;
2724
+ const replacingNativeSession = existingNativeSessionId !== undefined
2725
+ && existingNativeSessionId !== decision.outcome.nativeSessionId;
2726
+ const replacementBasis = replacingNativeSession
2727
+ ? conversationReplacementBasis({
2728
+ sessions,
2729
+ events: store.listEvents(taskId),
2730
+ mailbox,
2731
+ roleName: input.fence.roleName,
2732
+ runId: run.id,
2733
+ runMode: run.mode
2734
+ })
2735
+ : null;
2736
+ if (replacingNativeSession && replacementBasis === null) {
2737
+ recordCanonicalObservationObsolete(store, input, "conversation-switch-not-authorized", now);
2738
+ return "obsolete";
2739
+ }
2740
+ const sessionInput = {
2671
2741
  agentId: input.fence.agentId,
2672
2742
  adapterId,
2673
2743
  nativeSessionId: decision.outcome.nativeSessionId,
@@ -2675,9 +2745,20 @@ export class FileSchedulerStoreAdapter {
2675
2745
  policy: "fixed",
2676
2746
  status: "running",
2677
2747
  effective: run.effective
2678
- }, now);
2679
- const withProvider = bindOrSupersedeProviderRuntime(bound, input, now);
2748
+ };
2749
+ const bound = replacementBasis === null
2750
+ ? recordRoleAgentSession(sessions, sessionInput, now)
2751
+ : replaceTaskRoleAgentSessionForConversationSwitch(sessions, sessionInput, now);
2752
+ const withProvider = bindOrSupersedeProviderRuntime(bound, input, now, replacementBasis ?? undefined);
2680
2753
  store.saveTaskRoleSessionSet(withProvider);
2754
+ if (replacementBasis === "actor-request") {
2755
+ resolveConversationSwitchRequest(store, taskId, input.fence.roleName, sessions, "applied", now, {
2756
+ conversationId: input.fence.conversationId
2757
+ ?? decision.outcome.nativeSessionId,
2758
+ activationId: input.fence.activationId ?? input.fence.launchId,
2759
+ launchId: input.fence.launchId
2760
+ });
2761
+ }
2681
2762
  const driver = this.drivers.require(input.fence.driverId);
2682
2763
  if (driver.capabilities.observation.sessionBootstrap === "discovered"
2683
2764
  && run.pushedAt === undefined) {
@@ -2898,7 +2979,6 @@ export class FileSchedulerStoreAdapter {
2898
2979
  return null;
2899
2980
  const sessionSet = store.getTaskRoleSessionSet(fence.taskId, fence.roleName);
2900
2981
  const session = sessionSet?.sessions[fence.agentId] ?? null;
2901
- const boundNativeSessionId = session?.nativeSessionId;
2902
2982
  if (runId === undefined) {
2903
2983
  return {
2904
2984
  fence,
@@ -2907,7 +2987,9 @@ export class FileSchedulerStoreAdapter {
2907
2987
  pushed: false,
2908
2988
  accepted: false,
2909
2989
  terminal: false,
2910
- ...(boundNativeSessionId === undefined ? {} : { boundNativeSessionId })
2990
+ ...(session?.nativeSessionId === undefined
2991
+ ? {}
2992
+ : { boundNativeSessionId: session.nativeSessionId })
2911
2993
  };
2912
2994
  }
2913
2995
  const run = store.getAgentRun(fence.taskId, runId);
@@ -2919,8 +3001,15 @@ export class FileSchedulerStoreAdapter {
2919
3001
  || inFlight.agentId !== run.effective.agentId)
2920
3002
  return null;
2921
3003
  const owner = { scope: "task", taskId: fence.taskId, roleName: fence.roleName };
2922
- const launchId = session?.launchId
2923
- ?? (runtimeHookMatchesReservation(store, owner, fence.launchId) ? fence.launchId : undefined);
3004
+ const freshConversationLaunch = run.mode === "new"
3005
+ && runtimeHookMatchesReservation(store, owner, fence.launchId);
3006
+ const boundNativeSessionId = freshConversationLaunch
3007
+ ? undefined
3008
+ : session?.nativeSessionId;
3009
+ const launchId = freshConversationLaunch
3010
+ ? fence.launchId
3011
+ : session?.launchId
3012
+ ?? (runtimeHookMatchesReservation(store, owner, fence.launchId) ? fence.launchId : undefined);
2924
3013
  if (launchId === undefined)
2925
3014
  return null;
2926
3015
  const expectedFence = {
@@ -3037,14 +3126,6 @@ export class FileSchedulerStoreAdapter {
3037
3126
  return "apply";
3038
3127
  }
3039
3128
  }
3040
- function hasUnsettledRuntimeOperations(store, taskId, runId) {
3041
- return store.listEvents(taskId).some((event) => {
3042
- const observation = runtimeObservationFromTaskEvent(event);
3043
- return observation !== null
3044
- && observation.fence.runId === runId
3045
- && observation.kind === "operation.started";
3046
- });
3047
- }
3048
3129
  function runtimeTurnFailureSummary(input) {
3049
3130
  return [
3050
3131
  "Agent runtime turn failed.",
@@ -3270,9 +3351,12 @@ function markRuntimeOwnerSessionStopped(store, owner, now) {
3270
3351
  if (sessions === null)
3271
3352
  return false;
3272
3353
  const active = sessions.sessions[sessions.activeAgentId];
3273
- if (active === undefined || active.status === "stopped")
3354
+ if (active === undefined)
3274
3355
  return false;
3275
- store.saveTaskRoleSessionSet(updateRoleAgentSessionStatus(sessions, sessions.activeAgentId, "stopped", now));
3356
+ const stopped = stopTaskRoleRuntimeAfterPhysicalExit(sessions, now);
3357
+ if (stopped === sessions)
3358
+ return false;
3359
+ store.saveTaskRoleSessionSet(stopped);
3276
3360
  return true;
3277
3361
  }
3278
3362
  const sessions = store.getGlobalRoleSessionSet(owner.roleName);
@@ -3688,6 +3772,19 @@ function schedulerRoleSessionsMatch(current, expected) {
3688
3772
  && current.launchId === expected.launchId
3689
3773
  && isDeepStrictEqual(current.effective, expected.effective);
3690
3774
  }
3775
+ /**
3776
+ * The durable Run/mailbox claim is the pre-Host delivery intent. The Host may
3777
+ * create a ProviderTurn only while that exact reservation still owns the Run;
3778
+ * this closes the gap where a Session could start without any user Turn.
3779
+ */
3780
+ function processingOwnsExactInitialDelivery(mailbox, taskId, runId, attemptId) {
3781
+ const processing = mailbox?.processing;
3782
+ return processing?.batchId === attemptId
3783
+ && processing.owner === "controller"
3784
+ && processing.executionRef?.type === "run"
3785
+ && processing.executionRef.taskId === taskId
3786
+ && processing.executionRef.id === runId;
3787
+ }
3691
3788
  function matchesStallSessionFence(current, expected) {
3692
3789
  if (current === null || expected === null)
3693
3790
  return current === expected;
@@ -3838,7 +3935,7 @@ function canonicalStructuredProviderTurnId(sessions, observedTurnId, attemptId)
3838
3935
  ? turn.turnId
3839
3936
  : observedTurnId;
3840
3937
  }
3841
- function bindOrSupersedeProviderRuntime(sessions, input, now) {
3938
+ function bindOrSupersedeProviderRuntime(sessions, input, now, replacementBasis) {
3842
3939
  if (sessions.inFlight === null)
3843
3940
  return sessions;
3844
3941
  const conversationId = input.fence.conversationId ?? input.fence.nativeSessionId;
@@ -3873,9 +3970,22 @@ function bindOrSupersedeProviderRuntime(sessions, input, now) {
3873
3970
  conversationId,
3874
3971
  activationId,
3875
3972
  switchedAt: input.observedAt ?? input.receivedAt,
3876
- noUnsettledInputDelivery
3973
+ noUnsettledInputDelivery,
3974
+ ...(replacementBasis === undefined ? {} : { basis: replacementBasis })
3877
3975
  }), now);
3878
3976
  }
3977
+ function resolveConversationSwitchRequest(store, taskId, roleName, previousSessions, status, now, replacement) {
3978
+ const request = pendingConversationSwitch(store.listEvents(taskId), roleName, previousSessions);
3979
+ if (request === null)
3980
+ return;
3981
+ store.saveEvent(taskId, createTaskEvent(store.nextEventId(taskId), taskId, CONVERSATION_SWITCH_RESOLVED_EVENT, {
3982
+ requestId: request.requestId,
3983
+ roleName,
3984
+ generation: request.generation,
3985
+ status,
3986
+ ...(replacement === undefined ? {} : replacement)
3987
+ }, now));
3988
+ }
3879
3989
  function rejectSubmittingProviderTurn(store, target, delivery, now, reason) {
3880
3990
  if (target.kind !== "role"
3881
3991
  || delivery.executionRef.type !== "run"