@zq-silk/yui 0.6.13 → 0.6.14

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 (90) hide show
  1. package/README.md +34 -5
  2. package/dist/cli/commandCatalog.js +173 -57
  3. package/dist/cli/helpRenderer.js +3 -1
  4. package/dist/cli.js +99 -11
  5. package/dist/commands/configCommands.js +521 -171
  6. package/dist/commands/deliveryGuardPreflight.js +2 -2
  7. package/dist/commands/executionAuditCommands.js +56 -3
  8. package/dist/commands/projectCommands.js +504 -2
  9. package/dist/commands/releaseCommands.js +0 -1
  10. package/dist/commands/taskActor.js +17 -0
  11. package/dist/commands/taskBaseCommands.js +29 -0
  12. package/dist/commands/taskCommands.js +577 -126
  13. package/dist/commands/taskContextCommand.js +36 -2
  14. package/dist/commands/taskNextActionCommand.js +48 -3
  15. package/dist/commands/taskPublicationCommands.js +319 -0
  16. package/dist/commands/taskRoleRuntimeStatus.js +83 -59
  17. package/dist/commands/telemetryCommands.js +14 -13
  18. package/dist/config/yuiConfig.js +161 -8
  19. package/dist/context/sessionContextBudget.js +68 -0
  20. package/dist/context/wakeNotification.js +65 -0
  21. package/dist/controller/clientRuntime.js +2 -1
  22. package/dist/controller/ephemeralResourceReaper.js +2 -1
  23. package/dist/controller/fileSchedulerStoreAdapter.js +183 -16
  24. package/dist/controller/jobSupervisor.js +5 -4
  25. package/dist/controller/resourceCleanupLinux.js +6 -6
  26. package/dist/controller/resourceInventoryLinux.js +3 -3
  27. package/dist/controller/runtime.js +88 -10
  28. package/dist/controller/updateReconciliation.js +4 -3
  29. package/dist/doctor/doctor.js +26 -7
  30. package/dist/executor/agentConfigurationCatalog.js +18 -0
  31. package/dist/executor/fileRoleLaunchPlanner.js +3 -3
  32. package/dist/lifecycle/contextBudgetRollover.js +81 -0
  33. package/dist/lifecycle/exactRunTerminalization.js +11 -1
  34. package/dist/lifecycle/providerErrorClass.js +33 -12
  35. package/dist/observability/executionAudit.js +214 -6
  36. package/dist/output/table.js +18 -0
  37. package/dist/repository/gitWorkspace.js +92 -0
  38. package/dist/repository/project.js +218 -4
  39. package/dist/repository/taskBaseFreshness.js +318 -0
  40. package/dist/repository/taskWorkspacePreparer.js +16 -2
  41. package/dist/review/deltaRecheck.js +232 -0
  42. package/dist/review/reviewConfig.js +31 -0
  43. package/dist/review/reviewFindingLedger.js +5 -1
  44. package/dist/review/reviewRound.js +156 -1
  45. package/dist/run/providerRetry.js +21 -3
  46. package/dist/run/providerRetryConfig.js +13 -60
  47. package/dist/run/recoveryProjection.js +199 -0
  48. package/dist/runtime/builtinAgentDrivers.js +3 -0
  49. package/dist/runtime/builtinTranscriptUsage.js +76 -32
  50. package/dist/runtime/continuationManager.js +17 -0
  51. package/dist/runtime/index.js +2 -0
  52. package/dist/runtime/launchDiagnostics.js +154 -0
  53. package/dist/runtime/lifecycleReservation.js +13 -0
  54. package/dist/runtime/providerContinuation.js +38 -0
  55. package/dist/runtime/providerContinuationReconciliationService.js +1 -0
  56. package/dist/runtime/providerErrorCodes.js +278 -0
  57. package/dist/runtime/runtimeHealthPolicy.js +20 -0
  58. package/dist/runtime/runtimeObservation.js +1 -0
  59. package/dist/runtime/runtimeProjection.js +115 -23
  60. package/dist/runtime/tmuxAdapters.js +242 -48
  61. package/dist/scheduler/activeRoleRunDelivery.js +139 -3
  62. package/dist/scheduler/activeTaskProgress.js +4 -3
  63. package/dist/scheduler/leaderWakeupProcessor.js +105 -15
  64. package/dist/scheduler/roleRunLiveness.js +2 -1
  65. package/dist/scheduler/roleRunStall.js +3 -2
  66. package/dist/scheduler/taskWake.js +72 -0
  67. package/dist/scheduler/wakeReason.js +64 -0
  68. package/dist/scheduler/wakeupQueue.js +2 -1
  69. package/dist/setup/setupCommand.js +1 -1
  70. package/dist/storage/migration/productionRegistry.js +325 -1
  71. package/dist/storage/sqliteSchema.js +61 -2
  72. package/dist/storage/sqliteStore.js +129 -2
  73. package/dist/storage/storeRpc.js +1 -0
  74. package/dist/storage/taskStore.js +262 -5
  75. package/dist/storage/upgrade/recordVersions.js +6 -1
  76. package/dist/storage/upgrade/sqliteStateMigration.js +22 -2
  77. package/dist/task/completionReadiness.js +282 -0
  78. package/dist/task/publicationReference.js +123 -0
  79. package/dist/task/taskRecordReference.js +3 -1
  80. package/dist/telemetry/telemetryConfig.js +23 -18
  81. package/dist/telemetry/telemetryWiring.js +8 -8
  82. package/dist/tmux/tmuxManager.js +50 -9
  83. package/dist/web/assets/client/i18n.js +4 -0
  84. package/dist/web/assets/client/view.js +18 -0
  85. package/dist/web/webSnapshot.js +100 -10
  86. package/i18n/README.zh-CN.md +5 -5
  87. package/package.json +1 -1
  88. package/skills/yui-leader/SKILL.md +49 -10
  89. package/skills/yui-operator/SKILL.md +13 -3
  90. package/skills/yui-worker/SKILL.md +8 -0
@@ -4,13 +4,17 @@ import { activeLiveRoleAgentSession, bindTaskRoleProviderRuntime, bindTaskRoleRu
4
4
  import { createProviderRuntimeBinding, endProviderActivation, startProviderActivation, updateProviderConversationRecoverability } from "../runtime/providerRuntimeIdentity.js";
5
5
  import { hasRecentTurnId } from "../executor/turnCompletion.js";
6
6
  import { createTaskEvent } from "../event/taskEvent.js";
7
+ import { buildTaskWakeEnvelope } from "../context/wakeNotification.js";
8
+ import { createTaskWake, fallbackWakeCursor, latestTaskWake } from "../scheduler/taskWake.js";
9
+ import { rolloverTaskRoleSessionForContextBudget } from "../lifecycle/contextBudgetRollover.js";
10
+ import { resolveContextBudget } from "../config/yuiConfig.js";
7
11
  import { answerInputRequest } from "../input/inputRequest.js";
8
12
  import { activeRoleAgentBinding, updateRoleStatus } from "../role/role.js";
9
13
  import { effectiveLaunchWithTaskMainWorkspace, effectiveLaunchSnapshotsCompatible, effectiveLaunchSnapshotsCompatibleForTaskMain, resolveEffectiveLaunch, validateEffectiveLaunchSnapshot } from "../executor/effectiveLaunch.js";
10
14
  import { SYSTEM_OPERATOR_ROLE } from "../role/systemRoles.js";
11
15
  import { failAgentRun, markAgentRunDelivered, markAgentRunPushed, reopenRunForProviderRetry, withProviderRetry } from "../run/agentRun.js";
12
16
  import { classifyProviderError, isRetryableProviderErrorClass } from "../lifecycle/providerErrorClass.js";
13
- import { providerRetryIsDue, scheduleProviderRetry } from "../run/providerRetry.js";
17
+ import { providerRetryBudgetExhausted, providerRetryIsDue, scheduleProviderRetry } from "../run/providerRetry.js";
14
18
  import { providerRetryConfig } from "../run/providerRetryConfig.js";
15
19
  import { terminalizeExactTaskRun } from "../lifecycle/exactRunTerminalization.js";
16
20
  import { createCanonicalLifecycleEvent, foldCanonicalLifecycleEvent } from "../lifecycle/canonicalLifecycleEvent.js";
@@ -18,6 +22,7 @@ import { recordLeaderFailure } from "../scheduler/leaderFailure.js";
18
22
  import { createLeaderRecoveryNotification, createLeaderStallNotification } from "../scheduler/operatorNotification.js";
19
23
  import { pendingWakeupsMatch } from "../scheduler/pendingWakeup.js";
20
24
  import { queueLeaderWakeup } from "../scheduler/wakeupQueue.js";
25
+ import { wakeReason } from "../scheduler/wakeReason.js";
21
26
  import { foldRunProgressFacts, latestRunDurableProgressAt, latestRunEventTime, latestStallEvidenceKey, clearMatchingLeaderStallAttention, RUN_PROGRESS_EVENT, RUN_RECOVERED_EVENT, RUN_STALLED_EVENT } from "../scheduler/roleRunStall.js";
22
27
  import { projectProviderContinuations } from "../runtime/runtimeContinuationProjection.js";
23
28
  import { providerContinuationKey } from "../runtime/providerContinuation.js";
@@ -26,7 +31,7 @@ import { recordExecutionLaneResult } from "../execution/executionGroup.js";
26
31
  import { formatAgentRunReceiptId, formatTaskRecordReference } from "../task/taskRecordReference.js";
27
32
  import { bindExecution, claimPending, claimInputDelivery as claimMailboxInputDelivery, completeInputDelivery as completeMailboxInputDelivery, completeProcessing, mailboxHasPending, mailboxHasWork, pendingLane, markInputDeliveryPushed as markMailboxInputDeliveryPushed, markInputDeliveryUnknown as markMailboxInputDeliveryUnknown, releaseInputDelivery as releaseMailboxInputDelivery, resolveInputDeliveryNotAccepted as resolveMailboxInputDeliveryNotAccepted, releaseProcessing } from "../coordination/workMailbox.js";
28
33
  import { enqueueWork } from "../coordination/workMailboxQueue.js";
29
- import { RUNTIME_CLEANUP_REQUIRED_REASON, RUNTIME_LAUNCH_RESERVED_REASON, RUNTIME_LIFECYCLE_OWNER, hasRuntimeCleanupObligation, hasRuntimeLifecycleWork, isRuntimeLaunchReservation, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
34
+ import { RUNTIME_CLEANUP_REQUIRED_REASON, RUNTIME_LAUNCH_RESERVED_REASON, RUNTIME_LIFECYCLE_OWNER, RuntimeLifecycleBusyError, hasRuntimeCleanupObligation, hasRuntimeLifecycleWork, isRuntimeLaunchReservation, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
30
35
  import { nativeSessionIdForLaunch } from "../runtime/preallocatedNativeSession.js";
31
36
  import { builtinAgentDriverRegistry } from "../runtime/builtinAgentDrivers.js";
32
37
  import { RUNTIME_OBSERVATION_TASK_EVENT, createRuntimeObservation, runtimeObservationFenceMatches, runtimeObservationFromTaskEvent, runtimeObservationRunFenceMatches, runtimeObservationTaskEventPayload } from "../runtime/runtimeObservation.js";
@@ -138,6 +143,9 @@ export class FileSchedulerStoreAdapter {
138
143
  launchId: input.fence.launchId,
139
144
  nativeSessionId: input.fence.nativeSessionId,
140
145
  runId: input.fence.runId,
146
+ ...(failureEvidence.errorCode === undefined
147
+ ? {}
148
+ : { errorCode: failureEvidence.errorCode }),
141
149
  error: failureEvidence.code,
142
150
  ...(failureEvidence.details === undefined
143
151
  ? {}
@@ -535,6 +543,35 @@ export class FileSchedulerStoreAdapter {
535
543
  getTaskBrief(taskId) { return this.store.getTaskBrief(taskId); }
536
544
  listDecisions(taskId) { return this.store.listDecisions(taskId); }
537
545
  listMilestones(taskId) { return this.store.listMilestones(taskId); }
546
+ getTaskWakeEnvelope(taskId) {
547
+ return this.store.transaction((reader) => {
548
+ const pending = reader.getPendingWakeup(taskId);
549
+ if (pending === null)
550
+ return null;
551
+ const task = reader.getTask(taskId);
552
+ if (task === null)
553
+ return null;
554
+ const latest = latestTaskWake(reader.listTaskWakes(taskId));
555
+ const fromCursor = latest?.toCursor ?? fallbackWakeCursor({
556
+ taskCreatedAt: task.createdAt,
557
+ leaderRunCreatedAt: reader.listAgentRuns(taskId)
558
+ .filter((run) => run.roleName === "leader")
559
+ .at(-1)?.createdAt
560
+ });
561
+ return buildTaskWakeEnvelope(reader, {
562
+ taskId,
563
+ wakeId: reader.peekNextTaskWakeId(taskId),
564
+ reasons: pending.reasons,
565
+ fromCursor
566
+ });
567
+ });
568
+ }
569
+ rolloverTaskRoleSessionForContextBudget(input) {
570
+ return this.store.transaction((reader) => rolloverTaskRoleSessionForContextBudget(reader, input.taskId, input.roleName, { peakTokens: input.peakTokens, hardTokens: input.hardTokens }, input.now));
571
+ }
572
+ getContextBudget() {
573
+ return resolveContextBudget(this.store.getConfig().contextBudget);
574
+ }
538
575
  listRoles(taskId) {
539
576
  return this.store.listRoles(taskId).map((role) => mapRole(this.store, role));
540
577
  }
@@ -583,7 +620,7 @@ export class FileSchedulerStoreAdapter {
583
620
  const answered = answerInputRequest(request, { choiceKey }, "agent-timeout", now);
584
621
  store.saveInputRequest(task.id, answered);
585
622
  store.saveEvent(task.id, createTaskEvent(store.nextEventId(task.id), task.id, "input.auto-answered", { requestId: request.id, choiceKey }, now));
586
- queueLeaderWakeup(store, task.id, `input-timeout:${request.id}`, now);
623
+ queueLeaderWakeup(store, task.id, wakeReason("input-timeout", request.id), now);
587
624
  resolved.push({ inputRequestId: request.id, taskId: task.id, choiceKey });
588
625
  }
589
626
  return resolved;
@@ -684,7 +721,7 @@ export class FileSchedulerStoreAdapter {
684
721
  ]);
685
722
  }
686
723
  else {
687
- queueLeaderWakeup(store, task.id, "role-run-stalled", input.now);
724
+ queueLeaderWakeup(store, task.id, wakeReason("role-run-stalled"), input.now);
688
725
  }
689
726
  return "raised";
690
727
  });
@@ -809,6 +846,20 @@ export class FileSchedulerStoreAdapter {
809
846
  enqueueLeaderWakeup(taskId, reason, now) {
810
847
  return this.store.transaction((store) => (queueLeaderWakeup(store, taskId, reason, now)));
811
848
  }
849
+ /**
850
+ * Records that a Leader wake was suppressed by scheduler single-flight
851
+ * (the Role runtime lifecycle lane was busy). The wake stays durable and
852
+ * is retried after the lane settles; this event is the audit trail that
853
+ * separates scheduler backpressure from real Run failures.
854
+ */
855
+ recordWakeSuppression(taskId, reason, now) {
856
+ this.store.transaction((store) => {
857
+ const task = store.getTask(taskId);
858
+ if (task === null)
859
+ return;
860
+ store.saveEvent(taskId, createTaskEvent(store.nextEventId(taskId), taskId, "wake.suppressed", { reason }, now));
861
+ });
862
+ }
812
863
  listActiveDurableJobs() {
813
864
  return this.store.listActiveDurableJobs();
814
865
  }
@@ -1135,6 +1186,24 @@ export class FileSchedulerStoreAdapter {
1135
1186
  store.saveRole(input.task.id, updateRoleStatus(role, "running", input.now));
1136
1187
  bindTaskRoleRunInFlight(store, role, input.run, input.now);
1137
1188
  store.saveEvent(input.task.id, createTaskEvent(store.nextEventId(input.task.id), input.task.id, "run.dispatched", runLaunchEventPayload(input.run), input.now));
1189
+ if (input.wakeId !== undefined && input.wakeFromCursor !== undefined) {
1190
+ if (store.peekNextTaskWakeId(input.task.id) !== input.wakeId) {
1191
+ return "state-changed";
1192
+ }
1193
+ const allocatedWakeId = store.nextTaskWakeId(input.task.id);
1194
+ if (allocatedWakeId !== input.wakeId) {
1195
+ throw new Error(`Leader TaskWake allocation changed unexpectedly: ${input.task.id}.`);
1196
+ }
1197
+ store.saveTaskWake(input.task.id, createTaskWake({
1198
+ id: allocatedWakeId,
1199
+ taskId: input.task.id,
1200
+ reasons: input.wakeup.reasons,
1201
+ fromCursor: input.wakeFromCursor,
1202
+ toCursor: input.now.toISOString(),
1203
+ runId: input.run.id,
1204
+ now: input.now
1205
+ }));
1206
+ }
1138
1207
  if (input.session !== null && input.session.nativeSessionId !== undefined) {
1139
1208
  saveTaskSession(store, role, {
1140
1209
  ...input.session,
@@ -1405,7 +1474,7 @@ export class FileSchedulerStoreAdapter {
1405
1474
  clearMatchingLeaderStallAttention(store, task.id, currentRun.id);
1406
1475
  store.saveRole(task.id, updateRoleStatus(role, "exited", input.now));
1407
1476
  stopTaskSessionIfPresent(store, task.id, role.name, currentRun.effective.agentId, input.now);
1408
- queueLeaderWakeup(store, task.id, "review-failed", input.now);
1477
+ queueLeaderWakeup(store, task.id, wakeReason("review-failed"), input.now);
1409
1478
  return "failed";
1410
1479
  }
1411
1480
  const target = { kind: "role", taskId: task.id, roleName: role.name };
@@ -1453,7 +1522,7 @@ export class FileSchedulerStoreAdapter {
1453
1522
  }
1454
1523
  store.saveRole(task.id, updateRoleStatus(role, "exited", input.now));
1455
1524
  stopTaskSessionIfPresent(store, task.id, role.name, currentRun.effective.agentId, input.now);
1456
- queueLeaderWakeup(store, task.id, role.name === "leader" ? "leader-run-failed" : "role-run-failed", input.now);
1525
+ queueLeaderWakeup(store, task.id, wakeReason(role.name === "leader" ? "leader-run-failed" : "role-run-failed"), input.now);
1457
1526
  return "failed";
1458
1527
  });
1459
1528
  }
@@ -1486,7 +1555,7 @@ export class FileSchedulerStoreAdapter {
1486
1555
  }
1487
1556
  if (existing !== null
1488
1557
  && (existing.processing !== null || existing.pending !== null)) {
1489
- throw new Error("Runtime lifecycle work is already pending.");
1558
+ throw new RuntimeLifecycleBusyError("Runtime lifecycle work is already pending.");
1490
1559
  }
1491
1560
  enqueueWork(store, target, RUNTIME_LAUNCH_RESERVED_REASON, now, input.owner.scope === "task"
1492
1561
  ? [{ type: "task", id: input.owner.taskId }]
@@ -1747,6 +1816,18 @@ export class FileSchedulerStoreAdapter {
1747
1816
  sessions = rememberRoleAgentCompletedTurn(sessions, input.agentId, input.nativeSessionId, input.turnId, now);
1748
1817
  store.saveTaskRoleSessionSet(sessions);
1749
1818
  completeRuntimeHookReservation(store, owner, input.launchId);
1819
+ // The in-flight fence was cleared by an in-place provider retry
1820
+ // decision. If the active Run is still in its retry window and this
1821
+ // completion belongs to it, the recovered CLI finished the original
1822
+ // turn: clear the retry projection so the deadline does not re-push
1823
+ // the prompt. The Run stays active for the normal completion path.
1824
+ const activeRun = store.getActiveAgentRun(task.id, role.name);
1825
+ if (activeRun !== null
1826
+ && activeRun.providerRetry !== undefined
1827
+ && input.runId === activeRun.id) {
1828
+ const { providerRetry: _removed, ...rest } = activeRun;
1829
+ store.saveAgentRun({ ...rest, updatedAt: now.toISOString() });
1830
+ }
1750
1831
  return { session: sessions.sessions[input.agentId], duplicate: false };
1751
1832
  }
1752
1833
  // A provider Turn is an activation boundary, not a Yui Run outcome.
@@ -1871,11 +1952,12 @@ export class FileSchedulerStoreAdapter {
1871
1952
  * the Run, or `null` to fall through to the existing terminalization path.
1872
1953
  */
1873
1954
  providerRetryDecision(store, input, summary, now) {
1874
- const config = providerRetryConfig();
1955
+ const config = providerRetryConfig(this.store.getConfig());
1875
1956
  if (config.mode === "off")
1876
1957
  return null;
1877
1958
  const classification = classifyProviderError({
1878
1959
  adapterId: input.adapterId,
1960
+ ...(input.errorCode === undefined ? {} : { errorCode: input.errorCode }),
1879
1961
  error: input.error,
1880
1962
  errorDetails: input.errorDetails,
1881
1963
  summary: input.lastAssistantMessage
@@ -1890,6 +1972,7 @@ export class FileSchedulerStoreAdapter {
1890
1972
  if (adapterEnabled) {
1891
1973
  this.recordProviderRetryClassified(store, input, classification.errorClass, {
1892
1974
  wouldRetry: String(retryable),
1975
+ basis: classification.basis,
1893
1976
  shadow: String(shadow)
1894
1977
  }, now);
1895
1978
  }
@@ -1903,6 +1986,20 @@ export class FileSchedulerStoreAdapter {
1903
1986
  const run = store.getAgentRun(input.taskId, input.runId);
1904
1987
  if (run === null || run.status !== "active")
1905
1988
  return null;
1989
+ // A retry lineage that has used its total budget terminalizes with one
1990
+ // structured failure instead of looping. The Run falls through to the
1991
+ // normal terminalization path below.
1992
+ if (run.providerRetry !== undefined
1993
+ && providerRetryBudgetExhausted(run.providerRetry, now, config.maxWindowMs)) {
1994
+ this.recordProviderRetryClassified(store, input, classification.errorClass, {
1995
+ wouldRetry: "false",
1996
+ shadow: "false",
1997
+ basis: classification.basis,
1998
+ note: "retry-budget-exhausted",
1999
+ attempt: String(run.providerRetry.attempt)
2000
+ }, now);
2001
+ return null;
2002
+ }
1906
2003
  const retry = scheduleProviderRetry(run.providerRetry, {
1907
2004
  errorClass: classification.errorClass,
1908
2005
  launchId: input.launchId,
@@ -1911,9 +2008,9 @@ export class FileSchedulerStoreAdapter {
1911
2008
  }, now);
1912
2009
  store.saveAgentRun(withProviderRetry(run, retry));
1913
2010
  // Clear the in-flight fence so the original Session can be re-bound and
1914
- // re-pushed by the delivery path; the Session itself stays alive.
1915
2011
  const sessions = store.getTaskRoleSessionSet(input.taskId, input.roleName);
1916
- if (sessions !== null) {
2012
+ // re-pushed by the delivery path; the Session itself stays alive.
2013
+ if (sessions !== null && sessions.inFlight !== null) {
1917
2014
  store.saveTaskRoleSessionSet(clearTaskRoleRun(sessions, {
1918
2015
  agentId: input.agentId,
1919
2016
  runId: input.runId,
@@ -1939,6 +2036,7 @@ export class FileSchedulerStoreAdapter {
1939
2036
  this.recordProviderRetryClassified(store, input, classification.errorClass, {
1940
2037
  wouldRetry: "true",
1941
2038
  shadow: "false",
2039
+ basis: classification.basis,
1942
2040
  attempt: String(retry.attempt),
1943
2041
  nextAttemptAt: retry.nextAttemptAt
1944
2042
  }, now);
@@ -2001,6 +2099,7 @@ export class FileSchedulerStoreAdapter {
2001
2099
  : [...taskIds].sort((left, right) => (left.localeCompare(right, undefined, { numeric: true })));
2002
2100
  const due = this.listPendingProviderRetries(selectedTaskIds).filter((entry) => Date.parse(entry.nextAttemptAt) <= now.getTime());
2003
2101
  const reopened = [];
2102
+ const config = providerRetryConfig(this.store.getConfig());
2004
2103
  for (const entry of due) {
2005
2104
  this.store.transaction((store) => {
2006
2105
  const run = store.getAgentRun(entry.taskId, entry.runId);
@@ -2013,6 +2112,36 @@ export class FileSchedulerStoreAdapter {
2013
2112
  }
2014
2113
  const sessions = store.getTaskRoleSessionSet(entry.taskId, entry.roleName);
2015
2114
  const session = sessions?.sessions[run.effective.agentId];
2115
+ // Budget exhausted: one structured terminal failure, never a loop.
2116
+ if (providerRetryBudgetExhausted(run.providerRetry, now, config.maxWindowMs)) {
2117
+ const summary = `Provider retry budget exhausted after ${run.providerRetry.attempt} attempt(s) `
2118
+ + `(run ${run.id}, error class ${run.providerRetry.errorClass}, `
2119
+ + `last error: ${run.providerRetry.lastErrorSummary}). `
2120
+ + `The original Session requires an explicit Leader recovery decision.`;
2121
+ const result = terminalizeExactTaskRun(store, {
2122
+ taskId: entry.taskId,
2123
+ roleName: entry.roleName,
2124
+ agentId: run.effective.agentId,
2125
+ runId: run.id,
2126
+ receiptId: formatAgentRunReceiptId(entry.taskId, run.id),
2127
+ ...(run.providerRetry.nativeSessionId === undefined
2128
+ ? {}
2129
+ : { nativeSessionId: run.providerRetry.nativeSessionId }),
2130
+ ...(run.providerRetry.launchId === undefined
2131
+ ? {}
2132
+ : { launchId: run.providerRetry.launchId }),
2133
+ outcome: { status: "failed", summary }
2134
+ }, now);
2135
+ if (result.disposition !== "applied")
2136
+ return;
2137
+ store.saveEvent(entry.taskId, createTaskEvent(store.nextEventId(entry.taskId), entry.taskId, "runtime.provider-retry-budget-exhausted", {
2138
+ runId: run.id,
2139
+ attempt: String(run.providerRetry.attempt),
2140
+ errorClass: run.providerRetry.errorClass
2141
+ }, now));
2142
+ enqueueWork(store, { kind: "role", taskId: entry.taskId, roleName: "leader" }, "role-run-failed", now, [{ type: "run", taskId: entry.taskId, id: run.id }]);
2143
+ return;
2144
+ }
2016
2145
  const identityMatches = session !== undefined
2017
2146
  && session.adapterId === run.effective.adapterId
2018
2147
  && (run.providerRetry.nativeSessionId === undefined
@@ -2022,10 +2151,41 @@ export class FileSchedulerStoreAdapter {
2022
2151
  const sessionAlive = identityMatches
2023
2152
  && session.status !== "stopped"
2024
2153
  && session.status !== "broken";
2025
- if (!sessionAlive) {
2026
- // Session-dead: stop in-place retry and raise a precise, actionable
2027
- // replacement blocker for the Leader.
2154
+ if (sessionAlive) {
2155
+ // The CLI process is still alive. Enter a grace period: let the
2156
+ // Provider reconnect and finish the turn itself instead of sending
2157
+ // a duplicate prompt. Reschedule with the next backoff; a late
2158
+ // completion is attributed to this Run by observeRuntimeTurnCompleted.
2159
+ const retry = scheduleProviderRetry(run.providerRetry, {
2160
+ errorClass: run.providerRetry.errorClass,
2161
+ ...(run.providerRetry.launchId === undefined
2162
+ ? {}
2163
+ : { launchId: run.providerRetry.launchId }),
2164
+ ...(run.providerRetry.nativeSessionId === undefined
2165
+ ? {}
2166
+ : { nativeSessionId: run.providerRetry.nativeSessionId }),
2167
+ lastErrorSummary: run.providerRetry.lastErrorSummary
2168
+ }, now);
2169
+ store.saveAgentRun(withProviderRetry(run, retry));
2170
+ store.saveEvent(entry.taskId, createTaskEvent(store.nextEventId(entry.taskId), entry.taskId, "runtime.provider-retry-waiting", {
2171
+ runId: run.id,
2172
+ attempt: String(retry.attempt),
2173
+ ...(retry.nextAttemptAt === undefined
2174
+ ? {}
2175
+ : { nextAttemptAt: retry.nextAttemptAt })
2176
+ }, now));
2177
+ return;
2178
+ }
2179
+ // The process is gone. If the adapter supports resume and the
2180
+ // projection carries a native Session id, relaunch with resume: the
2181
+ // conversation identity stays the same, only the launch generation
2182
+ // changes. Otherwise terminalize with a precise replacement blocker.
2183
+ const canResume = run.providerRetry.nativeSessionId !== undefined
2184
+ && this.drivers.requireByAdapterId(run.effective.adapterId)
2185
+ .capabilities.control.resume;
2186
+ if (!canResume) {
2028
2187
  const summary = `Provider retry stopped: the original Session is gone `
2188
+ + `and the adapter cannot resume it `
2029
2189
  + `(run ${run.id}, attempt ${run.providerRetry.attempt}, `
2030
2190
  + `last error: ${run.providerRetry.lastErrorSummary}). `
2031
2191
  + `A replacement Session requires an explicit Leader recovery decision.`;
@@ -2049,10 +2209,17 @@ export class FileSchedulerStoreAdapter {
2049
2209
  enqueueWork(store, { kind: "role", taskId: entry.taskId, roleName: "leader" }, "role-run-failed", now, [{ type: "run", taskId: entry.taskId, id: run.id }]);
2050
2210
  return;
2051
2211
  }
2052
- // Reopen the Run on its original Session: reset transport markers,
2053
- // switch to resume, and mark the projection in-flight. The delivery
2054
- // pass re-pushes the exact same input in this same pass.
2212
+ // Resume relaunch: reopen the Run in resume mode on the same native
2213
+ // Session. The delivery pass relaunches the process with resume and
2214
+ // re-pushes the exact same input; only the launch generation changes.
2055
2215
  store.saveAgentRun(reopenRunForProviderRetry(run, now));
2216
+ store.saveEvent(entry.taskId, createTaskEvent(store.nextEventId(entry.taskId), entry.taskId, "runtime.provider-retry-resume", {
2217
+ runId: run.id,
2218
+ attempt: String(run.providerRetry.attempt),
2219
+ ...(run.providerRetry.nativeSessionId === undefined
2220
+ ? {}
2221
+ : { nativeSessionId: run.providerRetry.nativeSessionId })
2222
+ }, now));
2056
2223
  // The retry decision settled the original delivery claim; re-queue the
2057
2224
  // Role mailbox so the delivery pass can claim and re-push the Run.
2058
2225
  enqueueWork(store, { kind: "role", taskId: entry.taskId, roleName: entry.roleName }, "provider-retry-repush", now, [{ type: "run", taskId: entry.taskId, id: run.id }]);
@@ -18,6 +18,7 @@ import { fileURLToPath } from "node:url";
18
18
  import { writeTextFileAtomically } from "../storage/durableFile.js";
19
19
  import { cancelQueuedDurableJob, completeDurableJob, isDurableJobTerminal, markDurableJobUnknown, markDurableJobWakeupNotified, startDurableJob, touchDurableJobHeartbeat } from "../job/durableJob.js";
20
20
  import { readLinuxProcessStartIdentity } from "./domainIdentity.js";
21
+ import { wakeReason } from "../scheduler/wakeReason.js";
21
22
  const DEFAULT_STEP_TIMEOUT_MS = 30 * 60_000;
22
23
  const HEARTBEAT_STALE_MS = 2 * 60_000;
23
24
  const SIGKILL_GRACE_MS = 30_000;
@@ -114,7 +115,7 @@ export class DurableJobSupervisor {
114
115
  // atomic (same transaction). Compose cancel + wakeupNotified and
115
116
  // pass the wakeup param so the adapter enqueues the Leader mailbox
116
117
  // entry in the same transaction.
117
- const terminal = this.#store.transitionDurableJob(job.taskId, job.id, (current) => markDurableJobWakeupNotified(cancelQueuedDurableJob(current, now), now), now, { reason: "job-finished", refs: wakeupRefs(job) });
118
+ const terminal = this.#store.transitionDurableJob(job.taskId, job.id, (current) => markDurableJobWakeupNotified(cancelQueuedDurableJob(current, now), now), now, { reason: wakeReason("job-finished"), refs: wakeupRefs(job) });
118
119
  this.#deliverTerminalEvent(terminal);
119
120
  return;
120
121
  }
@@ -290,7 +291,7 @@ export class DurableJobSupervisor {
290
291
  // the adapter enqueues the Leader mailbox entry in the same transaction.
291
292
  // A separate #notifyWakeup pass would lose the wakeup if the Controller
292
293
  // died between the terminal write and the flag flip.
293
- const terminal = this.#store.transitionDurableJob(job.taskId, job.id, (current) => markDurableJobWakeupNotified(completeDurableJob(current, result, now), now), now, { reason: "job-finished", refs: wakeupRefs(job) });
294
+ const terminal = this.#store.transitionDurableJob(job.taskId, job.id, (current) => markDurableJobWakeupNotified(completeDurableJob(current, result, now), now), now, { reason: wakeReason("job-finished"), refs: wakeupRefs(job) });
294
295
  this.#deliverTerminalEvent(terminal);
295
296
  this.#sigkillAt.delete(this.#sigkillKey(job));
296
297
  }
@@ -303,12 +304,12 @@ export class DurableJobSupervisor {
303
304
  // The runner died without exit.json, but its step checkpoint covered
304
305
  // every planned step: the outcome is still provable from durable
305
306
  // evidence. No false pass — the checkpoint is the runner's own record.
306
- const terminal = this.#store.transitionDurableJob(job.taskId, job.id, (current) => markDurableJobWakeupNotified(completeDurableJob(current, { ...proven, evidenceSource: "checkpoint" }, now), now), now, { reason: "job-finished", refs: wakeupRefs(job) });
307
+ const terminal = this.#store.transitionDurableJob(job.taskId, job.id, (current) => markDurableJobWakeupNotified(completeDurableJob(current, { ...proven, evidenceSource: "checkpoint" }, now), now), now, { reason: wakeReason("job-finished"), refs: wakeupRefs(job) });
307
308
  this.#deliverTerminalEvent(terminal);
308
309
  }
309
310
  else {
310
311
  const completedSteps = checkpoint?.completedSteps ?? [];
311
- const terminal = this.#store.transitionDurableJob(job.taskId, job.id, (current) => markDurableJobWakeupNotified(markDurableJobUnknown(current, "runner process exited without writing exit.json", completedSteps, now), now), now, { reason: "job-finished", refs: wakeupRefs(job) });
312
+ const terminal = this.#store.transitionDurableJob(job.taskId, job.id, (current) => markDurableJobWakeupNotified(markDurableJobUnknown(current, "runner process exited without writing exit.json", completedSteps, now), now), now, { reason: wakeReason("job-finished"), refs: wakeupRefs(job) });
312
313
  this.#deliverTerminalEvent(terminal);
313
314
  }
314
315
  this.#sigkillAt.delete(this.#sigkillKey(job));
@@ -14,7 +14,7 @@ export async function cleanControllerResource(resource, options = {}) {
14
14
  }
15
15
  assertEphemeralDomainFence(resource);
16
16
  const environment = options.environment ?? process.env;
17
- const ports = options.ports ?? linuxCleanupPorts(environment);
17
+ const ports = options.ports ?? linuxCleanupPorts(environment, options.tmuxBin ?? "tmux");
18
18
  const cleanup = async () => {
19
19
  if (resource.artifact !== undefined) {
20
20
  cleanArtifact(resource, ports, environment);
@@ -151,7 +151,7 @@ async function waitForProcesses(resource, ports, graceMs, pollMs) {
151
151
  await ports.sleep(Math.min(pollMs, graceMs - elapsed));
152
152
  }
153
153
  }
154
- function linuxCleanupPorts(environment) {
154
+ function linuxCleanupPorts(environment, tmuxBin) {
155
155
  return {
156
156
  processStartIdentity: readProcessStartIdentity,
157
157
  signal: (pid, signal) => process.kill(pid, signal),
@@ -169,7 +169,7 @@ function linuxCleanupPorts(environment) {
169
169
  socketActive: unixSocketIsActive,
170
170
  removeArtifact: (path) => unlinkSync(path),
171
171
  inspectTmuxServerPanes: async (resource) => {
172
- const manager = tmuxManagerForResource(resource, environment);
172
+ const manager = tmuxManagerForResource(resource, environment, tmuxBin);
173
173
  return manager.inspectRolePaneInventory().map((pane) => (`${pane.taskId}/${pane.roleName}`));
174
174
  },
175
175
  killPane: async (resource) => {
@@ -177,7 +177,7 @@ function linuxCleanupPorts(environment) {
177
177
  if (target === undefined) {
178
178
  throw new Error(`Role pane identity is unavailable: ${resource.id}.`);
179
179
  }
180
- const manager = tmuxManagerForResource(resource, environment);
180
+ const manager = tmuxManagerForResource(resource, environment, tmuxBin);
181
181
  const pane = manager.inspectRolePaneInventory().find((candidate) => (candidate.target === target));
182
182
  if (pane === undefined)
183
183
  return;
@@ -200,14 +200,14 @@ function linuxCleanupPorts(environment) {
200
200
  }
201
201
  };
202
202
  }
203
- function tmuxManagerForResource(resource, environment) {
203
+ function tmuxManagerForResource(resource, environment, tmuxBin) {
204
204
  const home = resource.yuiHome;
205
205
  if (home === undefined) {
206
206
  throw new Error(`Tmux resource home is unavailable: ${resource.id}.`);
207
207
  }
208
208
  const executor = new NodeCommandExecutor();
209
209
  const commandEnvironment = tmuxSocketEnvironment(environment);
210
- return new TmuxManager(environment.YUI_TMUX_BIN ?? "tmux", {
210
+ return new TmuxManager(tmuxBin, {
211
211
  run: (command, args, options) => executor.run(command, args, {
212
212
  ...options,
213
213
  environment: {
@@ -53,7 +53,7 @@ export async function scanControllerResourceInventory(options) {
53
53
  const panes = options.panes !== undefined
54
54
  ? options.panes
55
55
  : tmuxArtifact?.active === true
56
- ? inspectHomePanes(home, environment, warnings)
56
+ ? inspectHomePanes(home, environment, options.tmuxBin ?? "tmux", warnings)
57
57
  : [];
58
58
  const discovery = await inspectDiscovery(home, matchingProcesses, activeSockets);
59
59
  if (discovery.status === "valid"
@@ -726,11 +726,11 @@ function loadHomeState(home, warnings, options) {
726
726
  function numericCompare(left, right) {
727
727
  return left.localeCompare(right, undefined, { numeric: true });
728
728
  }
729
- function inspectHomePanes(home, environment, warnings) {
729
+ function inspectHomePanes(home, environment, tmuxBin, warnings) {
730
730
  try {
731
731
  const executor = new NodeCommandExecutor();
732
732
  const commandEnvironment = tmuxSocketEnvironment(environment);
733
- return new TmuxManager(environment.YUI_TMUX_BIN ?? "tmux", {
733
+ return new TmuxManager(tmuxBin, {
734
734
  run: (command, args, options) => executor.run(command, args, {
735
735
  ...options,
736
736
  environment: {
@@ -1,4 +1,4 @@
1
- import { reconciliationIntervalMilliseconds } from "../config/yuiConfig.js";
1
+ import { reconciliationIntervalMilliseconds, resolveTmuxBin } from "../config/yuiConfig.js";
2
2
  import { createHash } from "node:crypto";
3
3
  import { resolve } from "node:path";
4
4
  import { isDeepStrictEqual } from "node:util";
@@ -7,7 +7,8 @@ import { AGENT_OPERATIONAL_ENVIRONMENT_NAMES, nativeAgentEnvironmentNames, YUI_M
7
7
  import { hasRuntimeCleanupObligation, runtimeLifecycleSignalKey, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
8
8
  import { agentProcessReadinessProbe, ExecutorRegistry } from "../executor/executorRegistry.js";
9
9
  import { activeLiveRoleAgentSession, roleAgentSessionResumeMode } from "../executor/agentExecutor.js";
10
- import { effectiveLaunchSnapshotsCompatible, effectiveLaunchSnapshotsCompatibleForTaskMain, resolveEffectiveLaunch } from "../executor/effectiveLaunch.js";
10
+ import { effectiveLaunchSnapshotsCompatible, effectiveLaunchSnapshotsCompatibleForTaskMain, effectiveLaunchConfig, resolveEffectiveLaunch } from "../executor/effectiveLaunch.js";
11
+ import { AgentConfigurationCatalogService, validateAgentLaunchConfiguration } from "../executor/agentConfigurationCatalog.js";
11
12
  import { isTaskOwnedWorkspace, managedWorkspaceIdentity, sameManagedWorkspaceIdentity } from "../worktree/managedWorkspace.js";
12
13
  import { FileRoleLaunchPlanner } from "../executor/fileRoleLaunchPlanner.js";
13
14
  import { openCompatibleFileTaskStore } from "../storage/compatibleTaskStore.js";
@@ -100,13 +101,13 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
100
101
  ? new ResourceInventoryClient()
101
102
  : undefined;
102
103
  const schedulerStore = options.schedulerStore
103
- ?? new FileSchedulerStoreAdapter(store, openSchedulerTelemetry(home, options.environment ?? process.env));
104
+ ?? new FileSchedulerStoreAdapter(store, openSchedulerTelemetry(home, store.getConfig()));
104
105
  const domainIdentity = options.domainIdentity
105
106
  ?? ephemeralDomainFromEnvironment(options.environment ?? process.env);
106
107
  const planner = options.planner ?? new FileRoleLaunchPlanner(home, store, {
107
108
  environment: options.environment
108
109
  });
109
- const tmux = options.tmux ?? new TmuxManager(options.environment?.YUI_TMUX_BIN ?? process.env.YUI_TMUX_BIN ?? "tmux", new NodeCommandExecutor(), {
110
+ const tmux = options.tmux ?? new TmuxManager(resolveTmuxBin(store.getConfig().tmuxBin), new NodeCommandExecutor(), {
110
111
  yuiHome: home,
111
112
  ...(domainIdentity === undefined
112
113
  ? {}
@@ -125,7 +126,57 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
125
126
  tmux,
126
127
  onWarning: options.onError
127
128
  });
129
+ // The runtime inbox is also the low-latency discovery source during a
130
+ // scheduler-owned launch. Waiting only for the post-pass durable projection
131
+ // would deadlock behind the same pass that is currently starting the host.
132
+ const runtimeEventInbox = new FileRuntimeEventInbox(home);
133
+ const catalogs = options.catalogs
134
+ ?? new AgentConfigurationCatalogService(home, {
135
+ environment: options.environment ?? process.env
136
+ });
128
137
  const sessionHost = options.sessionHost ?? new TmuxSessionHost(planner, tmux, {
138
+ validateLaunch: async (request) => {
139
+ const agent = store.getConfiguredAgent(request.agentId);
140
+ if (agent === null)
141
+ return;
142
+ const resolved = await catalogs.resolve({
143
+ agent,
144
+ cwd: request.workspace,
145
+ config: effectiveLaunchConfig(request.effective)
146
+ });
147
+ validateAgentLaunchConfiguration(resolved.catalog, effectiveLaunchConfig(request.effective));
148
+ },
149
+ waitForNativeSession: async (request, signal) => {
150
+ const owner = request.owner;
151
+ if (owner.scope !== "task") {
152
+ throw new Error("Native session discovery requires a Task runtime owner.");
153
+ }
154
+ while (!signal.aborted) {
155
+ const session = schedulerStore.getRoleSession(owner.taskId, owner.roleName, request.agentId);
156
+ if (session !== null
157
+ && session.launchId === request.launchId
158
+ && typeof session.nativeSessionId === "string"
159
+ && session.nativeSessionId.trim().length > 0) {
160
+ return session.nativeSessionId;
161
+ }
162
+ for (const event of runtimeEventInbox.list()) {
163
+ if (event.type === "runtime-observation"
164
+ && event.observation.kind === "session.started"
165
+ && event.observation.fence.taskId === owner.taskId
166
+ && event.observation.fence.roleName === owner.roleName
167
+ && event.observation.fence.agentId === request.agentId
168
+ && event.observation.fence.launchId === request.launchId
169
+ && typeof event.observation.fence.nativeSessionId === "string"
170
+ && event.observation.fence.nativeSessionId.trim().length > 0) {
171
+ return event.observation.fence.nativeSessionId;
172
+ }
173
+ }
174
+ await abortableDelay(50, signal);
175
+ }
176
+ throw new Error("Native session discovery was aborted.");
177
+ },
178
+ inactivityTimeoutMs: positiveIntegerOption(options.environment?.YUI_LAUNCH_INACTIVITY_TIMEOUT_MS
179
+ ?? process.env.YUI_LAUNCH_INACTIVITY_TIMEOUT_MS, 300_000),
129
180
  onHostCreated: ({ binding, pane }) => {
130
181
  sessionOwners.recordHostOwner({
131
182
  owner: binding.owner,
@@ -216,6 +267,7 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
216
267
  currentHome: home,
217
268
  scope: "current",
218
269
  panes,
270
+ tmuxBin: resolveTmuxBin(store.getConfig().tmuxBin),
219
271
  ...(options.environment === undefined
220
272
  ? {}
221
273
  : { environment: options.environment })
@@ -304,6 +356,7 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
304
356
  // explicit `controller cleanup --all` inventory operation.
305
357
  scope: "current",
306
358
  environment: options.environment,
359
+ tmuxBin: resolveTmuxBin(store.getConfig().tmuxBin),
307
360
  // When the worker backend is active, the reaper's scan runs in the
308
361
  // inventory worker too (same cadence, same inventory shape).
309
362
  ...(inventoryClient === undefined
@@ -328,12 +381,11 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
328
381
  environment: options.environment
329
382
  });
330
383
  const lifecycleDispatcher = createRuntimeLifecycleDispatcher(store, schedulerStore, sessionHost, options.dispatcher, signalRuntimeCleanup, launchCoordinator, planner);
331
- // f7/rr5: Share one inbox between the supervisor's terminal channel and
332
- // the runtime event processor. When a Job reaches a terminal state, the
333
- // supervisor enqueues a durable-job-terminal event; the processor drains
334
- // it on the next pass, waking the Controller immediately instead of
335
- // waiting for the poll interval.
336
- const runtimeEventInbox = new FileRuntimeEventInbox(home);
384
+ // f7/rr5: This same inbox feeds the supervisor's terminal channel and the
385
+ // runtime event processor. When a Job reaches a terminal state, the
386
+ // supervisor enqueues a durable-job-terminal event; the processor drains it
387
+ // on the next pass, waking the Controller immediately instead of waiting for
388
+ // the poll interval.
337
389
  const jobSupervisor = new DurableJobSupervisor({
338
390
  store: schedulerStore,
339
391
  process: createLinuxProcessPort(),
@@ -874,6 +926,32 @@ function requiredParam(value) {
874
926
  }
875
927
  return value;
876
928
  }
929
+ function abortableDelay(milliseconds, signal) {
930
+ return new Promise((resolve, reject) => {
931
+ const timer = setTimeout(() => {
932
+ signal.removeEventListener("abort", onAbort);
933
+ resolve();
934
+ }, milliseconds);
935
+ const onAbort = () => {
936
+ clearTimeout(timer);
937
+ reject(new Error("Native session discovery was aborted."));
938
+ };
939
+ if (signal.aborted) {
940
+ onAbort();
941
+ return;
942
+ }
943
+ signal.addEventListener("abort", onAbort, { once: true });
944
+ });
945
+ }
946
+ function positiveIntegerOption(value, fallback) {
947
+ if (value === undefined || value.trim().length === 0)
948
+ return fallback;
949
+ const parsed = Number(value);
950
+ if (!Number.isSafeInteger(parsed) || parsed <= 0) {
951
+ throw new Error(`Invalid native session discovery timeout: ${value}`);
952
+ }
953
+ return parsed;
954
+ }
877
955
  function applicationError(code, message) {
878
956
  const error = Object.assign(new Error(message), { code });
879
957
  error.name = "CoreApplicationError";