@zq-silk/yui 0.2.0 → 0.4.2

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 (208) hide show
  1. package/ARCHITECTURE.md +603 -133
  2. package/README.md +806 -31
  3. package/dist/agent/agent.js +2 -1
  4. package/dist/agent/argumentPolicy.js +3 -1
  5. package/dist/agent/launchEnvironment.js +106 -0
  6. package/dist/agent/managedRuntimeEnvironment.js +34 -0
  7. package/dist/brief/taskBrief.js +11 -1
  8. package/dist/cli/agentConfigurationPicker.js +287 -0
  9. package/dist/cli/commandCatalog.js +488 -60
  10. package/dist/cli/completion.js +146 -22
  11. package/dist/cli/helpRenderer.js +3 -1
  12. package/dist/cli/interactionCandidates.js +53 -15
  13. package/dist/cli/interactionPolicy.js +267 -30
  14. package/dist/cli/interactiveSelection.js +6 -2
  15. package/dist/cli/invocationRouter.js +5 -1
  16. package/dist/cli/operatorWizard.js +87 -0
  17. package/dist/cli/roleOptionCatalog.js +1 -0
  18. package/dist/cli/roleWizard.js +185 -21
  19. package/dist/cli/updateCommand.js +62 -19
  20. package/dist/cli/updateOrchestrator.js +539 -0
  21. package/dist/cli/updatePorts.js +1119 -0
  22. package/dist/cli/upgradeCommand.js +112 -0
  23. package/dist/cli.js +1420 -86
  24. package/dist/commands/agentCommands.js +146 -3
  25. package/dist/commands/configCommands.js +126 -0
  26. package/dist/commands/controllerCommands.js +365 -0
  27. package/dist/commands/globalRoleCommands.js +168 -126
  28. package/dist/commands/jobCommands.js +18 -8
  29. package/dist/commands/operatorCommands.js +159 -9
  30. package/dist/commands/profileCommands.js +203 -0
  31. package/dist/commands/projectCommands.js +650 -0
  32. package/dist/commands/roleConfiguration.js +85 -24
  33. package/dist/commands/roleRuntimeGuard.js +12 -0
  34. package/dist/commands/roleSkillValidation.js +47 -0
  35. package/dist/commands/taskActor.js +127 -0
  36. package/dist/commands/taskCommands.js +4201 -313
  37. package/dist/commands/taskCompletionGate.js +131 -0
  38. package/dist/commands/taskContextCommand.js +244 -30
  39. package/dist/commands/taskInputCommands.js +177 -59
  40. package/dist/commands/taskIntegrationCommands.js +303 -0
  41. package/dist/commands/taskOverviewCommand.js +363 -0
  42. package/dist/commands/taskRoleRuntimeStatus.js +125 -19
  43. package/dist/commands/textInput.js +15 -0
  44. package/dist/completion/completionInstaller.js +26 -22
  45. package/dist/config/yuiConfig.js +4 -3
  46. package/dist/context/dispatchContext.js +90 -38
  47. package/dist/context/roleSessionContext.js +119 -0
  48. package/dist/controller/claudeLifecycleHook.js +203 -0
  49. package/dist/controller/clientRuntime.js +408 -56
  50. package/dist/controller/codexLifecycleHook.js +108 -0
  51. package/dist/controller/controller.js +1089 -32
  52. package/dist/controller/domainIdentity.js +505 -0
  53. package/dist/controller/ephemeralResourceReaper.js +131 -0
  54. package/dist/controller/fileSchedulerStoreAdapter.js +2153 -103
  55. package/dist/controller/providerHookRunFence.js +127 -0
  56. package/dist/controller/resourceCleanupLinux.js +286 -0
  57. package/dist/controller/resourceInventory.js +531 -0
  58. package/dist/controller/resourceInventoryLinux.js +610 -0
  59. package/dist/controller/runtime.js +629 -10
  60. package/dist/controller/runtimeEventInbox.js +564 -0
  61. package/dist/controller/runtimeEventProcessor.js +248 -0
  62. package/dist/controller/runtimeLaunchCoordinator.js +477 -0
  63. package/dist/controller/sessionNotify.js +121 -78
  64. package/dist/coordination/deadlineScheduler.js +15 -0
  65. package/dist/coordination/mailboxScheduler.js +108 -0
  66. package/dist/coordination/workMailbox.js +329 -0
  67. package/dist/coordination/workMailboxQueue.js +86 -0
  68. package/dist/core/controllerClient.js +19 -5
  69. package/dist/core/controllerEndpoint.js +37 -0
  70. package/dist/core/controllerServer.js +218 -10
  71. package/dist/core/protocol.js +6 -2
  72. package/dist/decision/decision.js +2 -1
  73. package/dist/doctor/doctor.js +681 -32
  74. package/dist/domain/validation.js +53 -0
  75. package/dist/errors/cliError.js +5 -3
  76. package/dist/event/taskEvent.js +7 -3
  77. package/dist/execution/codexThreadNaming.js +160 -0
  78. package/dist/execution/executionGroup.js +579 -0
  79. package/dist/executor/agentAdapter.js +255 -40
  80. package/dist/executor/agentConfigurationCatalog.js +326 -0
  81. package/dist/executor/agentConfigurationProbe.js +506 -0
  82. package/dist/executor/agentExecutor.js +625 -10
  83. package/dist/executor/codexConfigConflict.js +290 -0
  84. package/dist/executor/effectiveLaunch.js +340 -0
  85. package/dist/executor/executorRegistry.js +238 -36
  86. package/dist/executor/fileRoleLaunchPlanner.js +550 -40
  87. package/dist/executor/turnCompletion.js +126 -0
  88. package/dist/input/inputRequest.js +30 -9
  89. package/dist/integration/changeSet.js +36 -0
  90. package/dist/integration/checkResult.js +24 -0
  91. package/dist/integration/gitIntegrationService.js +695 -0
  92. package/dist/integration/integrationAttempt.js +142 -0
  93. package/dist/interaction/operatorPresentation.js +96 -0
  94. package/dist/lifecycle/canonicalLifecycleEvent.js +342 -0
  95. package/dist/lifecycle/exactRunTerminalization.js +572 -0
  96. package/dist/lifecycle/providerLifecycleMapping.js +190 -0
  97. package/dist/lifecycle/taskRoleSessionReset.js +124 -0
  98. package/dist/message/message.js +23 -7
  99. package/dist/milestone/milestone.js +2 -1
  100. package/dist/operator/operatorSessionHistory.js +124 -0
  101. package/dist/output/agentConfigurationPresentation.js +43 -0
  102. package/dist/output/rolePresentation.js +34 -10
  103. package/dist/output/terminal.js +8 -0
  104. package/dist/output/timePresentation.js +55 -0
  105. package/dist/profile/agentProfile.js +128 -0
  106. package/dist/repository/gitWorkspace.js +578 -24
  107. package/dist/repository/project.js +213 -0
  108. package/dist/repository/taskWorkspaceCoordinator.js +392 -0
  109. package/dist/repository/taskWorkspacePreparer.js +1688 -191
  110. package/dist/review/reviewConfig.js +11 -0
  111. package/dist/review/reviewRound.js +399 -0
  112. package/dist/review/taskFinalReviewContract.js +90 -0
  113. package/dist/role/role.js +124 -23
  114. package/dist/run/agentRun.js +155 -12
  115. package/dist/run/runIdentity.js +82 -0
  116. package/dist/runtime/exactControlPlane.js +472 -0
  117. package/dist/runtime/index.js +8 -0
  118. package/dist/runtime/lifecycleReservation.js +38 -0
  119. package/dist/runtime/ports.js +11 -0
  120. package/dist/runtime/preallocatedNativeSession.js +13 -0
  121. package/dist/runtime/promptEnvelope.js +30 -0
  122. package/dist/runtime/runtimeBinding.js +31 -0
  123. package/dist/runtime/runtimeOwner.js +14 -0
  124. package/dist/runtime/sessionLaunchRequest.js +62 -0
  125. package/dist/runtime/sessionTitle.js +54 -0
  126. package/dist/runtime/taskRuntimeIsolation.js +643 -0
  127. package/dist/runtime/tmuxAdapters.js +315 -0
  128. package/dist/runtime/turnCompletion.js +3 -0
  129. package/dist/runtime/validation.js +23 -0
  130. package/dist/scheduler/activeRoleRunDelivery.js +342 -32
  131. package/dist/scheduler/activeTaskProgress.js +63 -0
  132. package/dist/scheduler/leaderFailure.js +2 -1
  133. package/dist/scheduler/leaderWakeupProcessor.js +307 -66
  134. package/dist/scheduler/operatorInputNotificationProcessor.js +109 -46
  135. package/dist/scheduler/operatorNotification.js +44 -2
  136. package/dist/scheduler/ports.js +28 -1
  137. package/dist/scheduler/roleRunLiveness.js +131 -25
  138. package/dist/scheduler/roleRunStall.js +951 -0
  139. package/dist/scheduler/taskExecutionProjection.js +544 -0
  140. package/dist/scheduler/wakeupQueue.js +3 -0
  141. package/dist/setup/setupCommand.js +302 -52
  142. package/dist/storage/compatibleTaskStore.js +102 -0
  143. package/dist/storage/migration/baseline.js +78 -0
  144. package/dist/storage/migration/classifier.js +51 -0
  145. package/dist/storage/migration/compatibleCodec.js +53 -0
  146. package/dist/storage/migration/engine.js +147 -0
  147. package/dist/storage/migration/index.js +33 -0
  148. package/dist/storage/migration/planner.js +154 -0
  149. package/dist/storage/migration/productionRegistry.js +486 -0
  150. package/dist/storage/migration/registry.js +169 -0
  151. package/dist/storage/migration/report.js +54 -0
  152. package/dist/storage/migration/types.js +31 -0
  153. package/dist/storage/storageSchema.js +147 -123
  154. package/dist/storage/storageVersions.js +11 -0
  155. package/dist/storage/taskStore.js +1793 -197
  156. package/dist/storage/upgrade/homeClassification.js +156 -0
  157. package/dist/storage/upgrade/homeMigrationTarget.js +595 -0
  158. package/dist/storage/upgrade/offlineUpgradeInventory.js +315 -0
  159. package/dist/storage/upgrade/productionMigrationRegistry.js +6 -0
  160. package/dist/storage/upgrade/recordVersionScan.js +176 -0
  161. package/dist/storage/upgrade/recordVersions.js +159 -0
  162. package/dist/storage/upgrade/switchProgress.js +80 -0
  163. package/dist/storage/upgrade/upgradeOrchestrator.js +948 -0
  164. package/dist/storage/upgrade/upgradeReceipt.js +161 -0
  165. package/dist/storage/upgradeCoordination.js +186 -0
  166. package/dist/storage/upgradeFence.js +366 -0
  167. package/dist/task/task.js +132 -26
  168. package/dist/task/taskRecordReference.js +66 -0
  169. package/dist/tmux/commandExecutor.js +75 -2
  170. package/dist/tmux/tmuxManager.js +747 -49
  171. package/dist/version.js +23 -0
  172. package/dist/web/assets/assetManifest.js +62 -0
  173. package/dist/web/assets/client/app.js +631 -0
  174. package/dist/web/assets/client/components.js +605 -0
  175. package/dist/web/assets/client/dom.js +14 -0
  176. package/dist/web/assets/client/format.js +28 -0
  177. package/dist/web/assets/client/i18n.js +494 -0
  178. package/dist/web/assets/client/markdown.js +114 -0
  179. package/dist/web/assets/client/theme.js +32 -0
  180. package/dist/web/assets/client/view.js +458 -0
  181. package/dist/web/assets/fontData.js +12 -0
  182. package/dist/web/assets/fonts.js +12 -0
  183. package/dist/web/assets/shell.js +114 -0
  184. package/dist/web/assets/styles/cards.js +135 -0
  185. package/dist/web/assets/styles/layout.js +47 -0
  186. package/dist/web/assets/styles/markdown.js +29 -0
  187. package/dist/web/assets/styles/responsive.js +39 -0
  188. package/dist/web/assets/styles/tokens.js +101 -0
  189. package/dist/web/assets/styles/widgets.js +147 -0
  190. package/dist/web/tmuxWebTerminal.js +158 -0
  191. package/dist/web/webServer.js +463 -0
  192. package/dist/web/webSnapshot.js +148 -0
  193. package/dist/workItem/workItem.js +642 -23
  194. package/dist/workspace/gitChangeSetCapture.js +86 -0
  195. package/dist/workspace/workItemChangeSetManager.js +445 -0
  196. package/dist/worktree/managedWorkspace.js +202 -0
  197. package/docs/task-local-identity.md +62 -0
  198. package/i18n/README.zh-CN.md +406 -31
  199. package/package.json +10 -2
  200. package/skills/yui-leader/SKILL.md +601 -39
  201. package/skills/yui-operator/SKILL.md +255 -34
  202. package/skills/yui-reviewer/SKILL.md +57 -0
  203. package/skills/yui-worker/SKILL.md +214 -17
  204. package/dist/commands/repositoryCommands.js +0 -86
  205. package/dist/operator/operatorContext.js +0 -66
  206. package/dist/repository/repository.js +0 -55
  207. package/dist/scheduler/archivedTaskRuntime.js +0 -12
  208. package/dist/worktree/roleWorkspace.js +0 -62
@@ -0,0 +1,948 @@
1
+ /**
2
+ * The storage upgrade orchestrator.
3
+ *
4
+ * This composes the generic migration engine (`../migration`) with the real
5
+ * Home-bound target and the admission fence into the user-facing `yui upgrade`
6
+ * and the storage half of `yui update`:
7
+ *
8
+ * preflight (classify, read-only)
9
+ * -> plan (delegated to the engine; missing/future step = fail-closed)
10
+ * -> [offline only] authoritative active Run/Session/lifecycle inventory
11
+ * -> [execute] admission fence (new writers refused)
12
+ * -> close pre-admitted writers + final offline inventory
13
+ * -> [execute] coordination lock (outside Home; shared with inbox publish)
14
+ * -> quiesce (record/stop prior Controller; verify no foreign writer /
15
+ * unfinished lifecycle)
16
+ * -> re-pin revision (under `.state.lock`)
17
+ * -> snapshot -> validate gate -> atomic switch + backup
18
+ * -> [execute] post-switch health check (fresh FileTaskStore loader)
19
+ * -> restore old Controller on pre-switch block, or start the replacement
20
+ * only after a committed switch and successful verification
21
+ *
22
+ * Compatible-old returns before constructing a migration target. The internal
23
+ * update preflight returns after classification, compatible-source validation,
24
+ * or the authoritative offline inventory appropriate to the classified path, so
25
+ * it remains safe while the exact old Controller is still running and never
26
+ * claims staged-output validation. User-facing
27
+ * `--dry-run` runs the staged validation gate and succeeds only when the engine
28
+ * itself returns `dry-run`; every other engine outcome is preserved as a blocker.
29
+ *
30
+ * Quiesce uses only explicit, deterministic signals — the public
31
+ * `controller.stop`/shutdownAndDrain, the real `.state.lock`, unfinished runtime
32
+ * lifecycle mailboxes — never a broad process kill and never a TTL/idle
33
+ * heuristic. Any signal that is not clear fails closed and leaves the original
34
+ * authoritative input byte-for-byte unchanged.
35
+ */
36
+ import { spawn } from "node:child_process";
37
+ import { readFileSync, readdirSync } from "node:fs";
38
+ import { join } from "node:path";
39
+ import { describeReport, runMigration } from "../migration/index.js";
40
+ import { validateCompatibleFileTaskStore } from "../compatibleTaskStore.js";
41
+ import { stopFileTaskController, ensureFileTaskController, ensureFileTaskControllerIdentity } from "../../controller/clientRuntime.js";
42
+ import { callController } from "../../core/controllerClient.js";
43
+ import { FileTaskStore, STORAGE_STATE_FILE, withStorageWriteLock } from "../taskStore.js";
44
+ import { clearUpgradeFence, placeUpgradeFence, readUpgradeFence, UpgradeFenceError } from "../upgradeFence.js";
45
+ import { withUpgradeCoordinationLock } from "../upgradeCoordination.js";
46
+ import { clearUpgradeReceipt, writeUpgradeReceipt, upgradeReceiptPath } from "./upgradeReceipt.js";
47
+ import { switchProgressPath } from "./switchProgress.js";
48
+ import { classifyHome } from "./homeClassification.js";
49
+ import { createHomeMigrationTarget, describeActiveRuntime, homeRuntimeIsActive, inspectHomeRuntime } from "./homeMigrationTarget.js";
50
+ import { inspectOfflineUpgradeInventory } from "./offlineUpgradeInventory.js";
51
+ /**
52
+ * Run the storage upgrade for one Home. Never throws for an expected blocker;
53
+ * it returns a structured `blocked` result instead. It only throws on a truly
54
+ * unexpected fault, and even then never after the atomic switch has committed.
55
+ */
56
+ export async function runStorageUpgrade(options) {
57
+ const { home, registry, latest, mode } = options;
58
+ const now = options.now ?? (() => new Date());
59
+ const callerPid = options.callerPid ?? process.pid;
60
+ // 1) Preflight — read-only classification.
61
+ const classification = classifyHome({ home, registry, latest });
62
+ // An uninitialized Home has no storage to migrate. The classifier reports it as
63
+ // USABLE (nothing is wrong; doctor may present it as-is), but for the UPGRADE
64
+ // path that verdict would collapse into a silent no-op against a Home that was
65
+ // never `yui setup`. Return a structured, actionable blocker instead — never a
66
+ // false success and never an unclassified runtime error (P2-7).
67
+ if (classification.uninitialized === true) {
68
+ return withClassification({
69
+ outcome: "blocked",
70
+ stage: "uninitialized",
71
+ message: "Yui storage is not initialized for this Home; there is nothing to upgrade.",
72
+ action: "Run `yui setup` to initialize storage, then re-run the upgrade if needed."
73
+ }, classification);
74
+ }
75
+ const verdict = classification.classification.verdict;
76
+ if (verdict === "CORRUPTED") {
77
+ return withClassification({
78
+ outcome: "blocked",
79
+ stage: "corruption",
80
+ message: `Storage is corrupted: ${classification.classification.detail}`,
81
+ action: "Restore from a backup or a healthy Home; upgrade cannot proceed."
82
+ }, classification);
83
+ }
84
+ if (verdict === "NEEDS_NEW_VERSION") {
85
+ const blocker = classification.classification.blocker;
86
+ return withClassification({
87
+ outcome: "blocked",
88
+ stage: blocker.reason === "future-version" ? "future-version" : "missing-step",
89
+ message: blocker.message,
90
+ action: blocker.action
91
+ }, classification);
92
+ }
93
+ // An all-compatible chain is an online-load contract, not a migration plan.
94
+ // Validate the declared old source shape and its in-memory normalization before
95
+ // ANY mode reports compatible, including the internal update preflight. This is
96
+ // read-only and safe while the exact old Controller is still running; it creates
97
+ // no migration target or staged Home and touches no lifecycle state.
98
+ if (verdict === "COMPATIBLE") {
99
+ try {
100
+ validateCompatibleFileTaskStore(home, { registry, latest });
101
+ if (mode === "update-preflight") {
102
+ return {
103
+ outcome: "update-preflight",
104
+ status: "compatible",
105
+ stepCount: classification.classification.stepCount,
106
+ classification
107
+ };
108
+ }
109
+ return { outcome: "compatible", classification };
110
+ }
111
+ catch (error) {
112
+ const detail = `Compatible source validation failed: ${messageOf(error)}`;
113
+ const invalid = {
114
+ ...classification,
115
+ classification: {
116
+ verdict: "CORRUPTED",
117
+ status: "unsupported",
118
+ detail
119
+ }
120
+ };
121
+ return withClassification({
122
+ outcome: "blocked",
123
+ stage: "corruption",
124
+ message: detail,
125
+ action: "Do not activate the new binary. The declared old shape did not pass its strict read-only validator."
126
+ }, invalid);
127
+ }
128
+ }
129
+ // `yui update` invokes this explicit internal contract while the exact old
130
+ // Controller is still running. It performs classification for a current Home,
131
+ // compatible-source validation above for a compatible Home, and classification
132
+ // plus the authoritative offline inventory for a migration path. It never
133
+ // constructs a migration target, so there is no staging copy, backup, fence,
134
+ // runtime/Controller lifecycle probe, staged-output loader validation, or switch.
135
+ if (mode === "update-preflight") {
136
+ if (verdict === "USABLE") {
137
+ return {
138
+ outcome: "update-preflight",
139
+ status: "already-current",
140
+ stepCount: 0,
141
+ classification
142
+ };
143
+ }
144
+ // verdict === MIGRATABLE: a parent update may stop the old Controller only
145
+ // after this authoritative offline inventory is clear.
146
+ const inventory = await readOfflineInventory(options, home);
147
+ if (inventory.total > 0) {
148
+ return withClassification(offlineInventoryBlocker(inventory, true), classification);
149
+ }
150
+ return {
151
+ outcome: "update-preflight",
152
+ status: "migration-required",
153
+ stepCount: classification.classification.stepCount,
154
+ classification
155
+ };
156
+ }
157
+ if (verdict === "MIGRATABLE") {
158
+ const inventory = await readOfflineInventory(options, home);
159
+ if (inventory.total > 0) {
160
+ return withClassification(offlineInventoryBlocker(inventory, true), classification);
161
+ }
162
+ }
163
+ const target = createHomeMigrationTarget({
164
+ home,
165
+ latest,
166
+ now,
167
+ callerPid,
168
+ ...(options.renameImpl === undefined ? {} : { renameImpl: options.renameImpl }),
169
+ ...(options.switchFaultHook === undefined ? {} : { switchFaultHook: options.switchFaultHook })
170
+ });
171
+ // 2) A USABLE (already-current) Home has nothing to migrate; the engine
172
+ // confirms with a no-op and we never fence, drain, or switch.
173
+ if (verdict === "USABLE") {
174
+ const report = runMigration({ registry, target, latest, mode: "dry-run" });
175
+ if (report.outcome === "already-current") {
176
+ return { outcome: "already-current", classification, report };
177
+ }
178
+ // A USABLE verdict with a runnable plan cannot happen (USABLE == no-op), but
179
+ // if the registry disagrees we fail closed rather than switch unexpectedly.
180
+ return {
181
+ ...withClassification({
182
+ outcome: "blocked",
183
+ stage: "missing-step",
184
+ message: "Classifier and planner disagree about whether an upgrade is needed.",
185
+ action: "Re-run `yui doctor`; do not force an upgrade."
186
+ }, classification),
187
+ report
188
+ };
189
+ }
190
+ // verdict === "MIGRATABLE": a complete offline step path exists and the
191
+ // authoritative inventory was re-read clear before any lifecycle mutation.
192
+ if (mode === "dry-run") {
193
+ return dryRun(options, classification, target);
194
+ }
195
+ return execute(options, classification, target, callerPid, now);
196
+ }
197
+ function describeOfflineBlockers(inventory) {
198
+ const lines = inventory.blockers.map((blocker, index) => {
199
+ const identity = [
200
+ blocker.taskId === undefined ? undefined : `task=${blocker.taskId}`,
201
+ blocker.roleName === undefined ? undefined : `role=${blocker.roleName}`,
202
+ blocker.runId === undefined ? undefined : `run=${blocker.runId}`,
203
+ blocker.nativeSessionId === undefined
204
+ ? undefined
205
+ : `nativeSession=${blocker.nativeSessionId}`,
206
+ blocker.launchId === undefined ? undefined : `launch=${blocker.launchId}`
207
+ ].filter((value) => value !== undefined).join(" ");
208
+ const reason = blocker.reason === "pending-inbox"
209
+ ? "pending durable inbox"
210
+ : blocker.reason === "pending-mailbox"
211
+ ? "pending lifecycle mailbox"
212
+ : blocker.reason;
213
+ return `${index + 1}. ${identity.length === 0 ? "identity=unknown" : identity} ` +
214
+ `reason=${reason}`;
215
+ });
216
+ return `Offline migration blocked by ${inventory.total} active runtime item(s). ` +
217
+ lines.join("; ");
218
+ }
219
+ async function readOfflineInventory(options, home) {
220
+ try {
221
+ return await (options.inspectOfflineInventory
222
+ ?? ((targetHome) => inspectOfflineUpgradeInventory(targetHome)))(home);
223
+ }
224
+ catch {
225
+ return {
226
+ total: 1,
227
+ blockers: [{ reason: "native-session-unknown" }]
228
+ };
229
+ }
230
+ }
231
+ function offlineInventoryBlocker(inventory, sceneUnchanged) {
232
+ const lifecycleOnly = inventory.blockers.every(({ reason }) => (reason === "pending-mailbox" || reason === "pending-inbox"));
233
+ return {
234
+ outcome: "blocked",
235
+ stage: lifecycleOnly ? "drain-incomplete" : "active-sessions",
236
+ message: describeOfflineBlockers(inventory),
237
+ action: sceneUnchanged
238
+ ? "No binary, Controller, fence, or Home change was made. Keep working; when every " +
239
+ "listed runtime obligation is clear, re-run `yui update` so preflight is repeated."
240
+ : "The Home was not switched. Let every listed runtime obligation settle, then re-run " +
241
+ "`yui update`; do not kill, reset, rebind, or retry the blocked runtime blindly.",
242
+ blockers: inventory.blockers,
243
+ retryCommand: "yui update",
244
+ ...(sceneUnchanged ? { sceneUnchanged: true } : {})
245
+ };
246
+ }
247
+ /** Dry run: validate through the staged gate, then discard; never switch. */
248
+ function dryRun(options, classification, target) {
249
+ // Refuse to reuse a stale staging directory from an interrupted run.
250
+ target.discardFreshOutput();
251
+ const report = runMigration({
252
+ registry: options.registry,
253
+ target,
254
+ latest: options.latest,
255
+ mode: "dry-run"
256
+ });
257
+ target.discardFreshOutput();
258
+ if (report.outcome === "dry-run") {
259
+ return { outcome: "dry-run", classification, report };
260
+ }
261
+ if (report.outcome === "failed") {
262
+ return { ...blockedFromFailedReport(report, classification), report };
263
+ }
264
+ // A dry-run success is proven only by the engine's exact `dry-run` variant,
265
+ // which carries staged-output and loader-gate evidence. In particular, a live
266
+ // runtime returns before read/transform/write/validate; preserve that outcome
267
+ // as a blocker instead of wrapping it in a false outer success.
268
+ return { ...blockedFromEngineReport(report, classification), report };
269
+ }
270
+ /** Execute: fence -> coordination/quiesce -> re-pin -> switch -> post-verify. */
271
+ async function execute(options, classification, target, callerPid, now) {
272
+ const { home } = options;
273
+ // 3) Admission fence — from here, new baseline CLI and Controller writers are
274
+ // refused at the storage commit choke point. The fencing process is exempt.
275
+ // A live foreign fence is expected coordination contention, not a generic
276
+ // runtime failure. Return a structured blocker without removing or retrying
277
+ // the other upgrader's fence.
278
+ let releaseFence;
279
+ try {
280
+ releaseFence = placeUpgradeFence(home, {
281
+ reason: "storage upgrade in progress",
282
+ createdAt: now().toISOString(),
283
+ ownerPid: callerPid
284
+ });
285
+ }
286
+ catch (error) {
287
+ if (!(error instanceof UpgradeFenceError))
288
+ throw error;
289
+ return withClassification({
290
+ outcome: "blocked",
291
+ stage: "coordination",
292
+ message: `Upgrade coordination could not be acquired: ${error.message}`,
293
+ action: "Another upgrade is already coordinating this Home. Wait for it to finish, then retry; "
294
+ + "do not remove its live fence or retry blindly."
295
+ }, classification);
296
+ }
297
+ const externallyQuiesced = options.controllerLifecycle === "externally-quiesced";
298
+ let controllerWasRunning = false;
299
+ let controllerStopConfirmed = false;
300
+ let controllerIdentity;
301
+ let result;
302
+ let unexpected;
303
+ const switchState = { committed: false };
304
+ try {
305
+ if (!externallyQuiesced) {
306
+ // Record the lifecycle fact before stopping anything. An unavailable or
307
+ // malformed status is unknown, not "stopped"; fail closed without attempting
308
+ // a blind stop/retry.
309
+ const controllerStatus = options.controllerStatus
310
+ ?? ((h) => defaultControllerStatus(h, options.controllerOptions));
311
+ let status;
312
+ try {
313
+ status = await controllerStatus(home);
314
+ }
315
+ catch (error) {
316
+ result = withClassification(controllerLifecycleBlocker("Controller status could not be verified", error), classification);
317
+ }
318
+ if (result === undefined && !isControllerLifecycleStatus(status)) {
319
+ result = withClassification(controllerLifecycleBlocker("Controller status was malformed", new Error("expected a boolean running field")), classification);
320
+ }
321
+ if (result === undefined && status.running && !isControllerLaunchIdentity(status.identity)) {
322
+ result = withClassification(controllerLifecycleBlocker("Controller launch identity could not be authenticated", new Error("executable/argv/version identity is unavailable")), classification);
323
+ }
324
+ if (result === undefined && status.running && !isPositivePid(status.pid)) {
325
+ result = withClassification(controllerLifecycleBlocker("Controller PID could not be authenticated", new Error("a positive status PID is unavailable for fenced stop")), classification);
326
+ }
327
+ if (result === undefined) {
328
+ controllerWasRunning = status.running;
329
+ controllerIdentity = status.running ? status.identity : undefined;
330
+ controllerStopConfirmed = !controllerWasRunning;
331
+ }
332
+ // 4) Quiesce — drain the Controller with the public stop, then require that
333
+ // no foreign writer, no live Controller, and no unfinished runtime lifecycle
334
+ // remain. Any unclear signal fails closed with the source unchanged. A stop
335
+ // request is issued at most once; timeout/rejection is a structured blocker,
336
+ // never a retry loop.
337
+ const stopController = options.stopController
338
+ ?? ((h, expectedPid) => defaultStopController(h, expectedPid, options.controllerOptions));
339
+ if (result === undefined && controllerWasRunning) {
340
+ try {
341
+ const expectedPid = status.pid;
342
+ const stopped = await stopController(home, expectedPid);
343
+ if (!confirmedControllerStopped(stopped, expectedPid)) {
344
+ result = withClassification(controllerLifecycleBlocker("Controller stop did not confirm a drained process", new Error(`stop did not confirm captured PID ${expectedPid} with stopped:true`)), classification);
345
+ }
346
+ else {
347
+ controllerStopConfirmed = true;
348
+ }
349
+ }
350
+ catch (error) {
351
+ result = withClassification(controllerLifecycleBlocker("Controller stop/drain failed", error), classification);
352
+ }
353
+ }
354
+ }
355
+ else {
356
+ // The parent update already captured and drained the old Controller. This
357
+ // staged child must not inspect or start one from its temporary install.
358
+ controllerStopConfirmed = true;
359
+ }
360
+ if (result === undefined) {
361
+ // Close the only admission race left by the read-only preflight. A writer
362
+ // which acquired `.state.lock` before the fence must finish before this
363
+ // pin; every later commit sees the fence and fails. Re-read the complete
364
+ // offline inventory only after that boundary and before staging/switching.
365
+ try {
366
+ repinRevision(home);
367
+ }
368
+ catch (error) {
369
+ result = withClassification({
370
+ outcome: "blocked",
371
+ stage: "active-runtime",
372
+ message: `The pre-fence writer window is undeterminable and could not be closed safely: ` +
373
+ `${messageOf(error)}.`,
374
+ action: "The Home was not switched. Inspect the exact storage-lock owner and retry only " +
375
+ "after it is settled; do not remove a live lock or retry blindly."
376
+ }, classification);
377
+ }
378
+ }
379
+ if (result === undefined) {
380
+ const inventory = await readOfflineInventory(options, home);
381
+ if (inventory.total > 0) {
382
+ result = withClassification(offlineInventoryBlocker(inventory, false), classification);
383
+ }
384
+ }
385
+ if (result === undefined) {
386
+ try {
387
+ result = executeFenced(options, classification, target, callerPid, now, switchState);
388
+ }
389
+ catch (error) {
390
+ // Any exception after the atomic rename is a post-switch ambiguity. It
391
+ // must not cross the generic restore path: the old Controller is unsafe
392
+ // against a Home whose authority has already moved.
393
+ if (switchState.committed) {
394
+ result = withClassification(postSwitchAmbiguity(home, switchState.backupPath, error), classification);
395
+ }
396
+ else {
397
+ unexpected = error;
398
+ }
399
+ }
400
+ }
401
+ }
402
+ finally {
403
+ try {
404
+ releaseFence();
405
+ }
406
+ catch (error) {
407
+ if (switchState.committed && result === undefined) {
408
+ result = withClassification(postSwitchAmbiguity(home, switchState.backupPath, error), classification);
409
+ }
410
+ else if (unexpected === undefined) {
411
+ unexpected = error;
412
+ }
413
+ }
414
+ }
415
+ // Restoring the old Controller must happen after the upgrade fence is
416
+ // released, otherwise its startup scheduler would be blocked by our own
417
+ // fence. A thrown unexpected fault remains a throw, but the old lifecycle is
418
+ // still restored exactly once before it escapes.
419
+ if (unexpected !== undefined) {
420
+ if (switchState.committed) {
421
+ return withClassification(postSwitchAmbiguity(home, switchState.backupPath, unexpected), classification);
422
+ }
423
+ if (controllerWasRunning && controllerStopConfirmed) {
424
+ await restoreController(home, options, unexpected, controllerIdentity);
425
+ }
426
+ throw unexpected;
427
+ }
428
+ if (result === undefined) {
429
+ throw new Error("Storage upgrade did not produce a result.");
430
+ }
431
+ if (result.outcome === "upgraded") {
432
+ const startController = options.startController
433
+ ?? ((h) => defaultStartController(h, options.controllerOptions));
434
+ if (!externallyQuiesced && controllerWasRunning) {
435
+ try {
436
+ // New Controller startup is deliberately after switch + post-verify.
437
+ await startController(home);
438
+ }
439
+ catch (error) {
440
+ // Keep the completion receipt and backup as durable evidence when the
441
+ // storage switch is verified but the replacement Controller cannot be
442
+ // started. The old Controller is not safe to resume on the new Home.
443
+ return {
444
+ ...withClassification(postSwitchAmbiguity(home, result.backupPath, new Error(`The replacement Controller could not start after the committed switch: ${messageOf(error)}`)), classification),
445
+ report: result.report
446
+ };
447
+ }
448
+ }
449
+ // No further uncertainty remains once the replacement Controller (when one
450
+ // existed) is ready; clear the receipt only at this final boundary.
451
+ try {
452
+ options.postSwitchFaultHook?.("receipt-clear");
453
+ clearUpgradeReceipt(home);
454
+ }
455
+ catch (error) {
456
+ return {
457
+ ...withClassification(postSwitchAmbiguity(home, result.backupPath, error), classification),
458
+ report: result.report
459
+ };
460
+ }
461
+ return result;
462
+ }
463
+ if (!externallyQuiesced
464
+ &&
465
+ controllerWasRunning
466
+ && controllerStopConfirmed
467
+ && result.outcome === "blocked"
468
+ // Once the switch committed (post-verify) or became ambiguous, the old
469
+ // Controller is not safe to resume against this Home.
470
+ && result.stage !== "post-verify"
471
+ && result.stage !== "switch-ambiguous") {
472
+ try {
473
+ // A blocked/failed pre-switch attempt leaves the old Home authoritative;
474
+ // restore the Controller that was running before quiesce, once and only
475
+ // once. Ambiguous switch is excluded: its Home authority is unknown.
476
+ await restoreController(home, options, new Error(result.message), controllerIdentity);
477
+ }
478
+ catch (error) {
479
+ return {
480
+ ...withClassification({
481
+ outcome: "blocked",
482
+ stage: "active-runtime",
483
+ message: `${result.message} The previously running Controller could not be restored: `
484
+ + `${messageOf(error)}.`,
485
+ action: "Keep the old Home quiesced and resolve the Controller startup failure; "
486
+ + "do not resume writes until the Controller and Home are verified."
487
+ }, classification),
488
+ ...(result.report === undefined ? {} : { report: result.report })
489
+ };
490
+ }
491
+ }
492
+ return result;
493
+ }
494
+ /** Execute the fenced, coordinated migration after Controller quiesce. */
495
+ function executeFenced(options, classification, target, callerPid, now, switchState) {
496
+ const { home, registry, latest } = options;
497
+ try {
498
+ // The read-only drain proof, revision pin, complete-home copy, and both
499
+ // switch renames share one sibling coordination lock with inbox publish.
500
+ return withUpgradeCoordinationLock(home, () => {
501
+ const quiesce = verifyQuiesced(home, callerPid);
502
+ if (quiesce !== null)
503
+ return withClassification(quiesce, classification);
504
+ // Re-pin the final revision under the write lock, after drain, so the
505
+ // snapshot reflects the last committed state (no check-then-migrate race).
506
+ repinRevision(home);
507
+ // Snapshot -> validate gate -> atomic switch + timestamped backup.
508
+ target.discardFreshOutput();
509
+ const report = runMigration({ registry, target, latest, mode: "execute" });
510
+ if (report.outcome === "failed") {
511
+ target.discardFreshOutput();
512
+ return { ...blockedFromFailedReport(report, classification), report };
513
+ }
514
+ if (report.outcome === "switch-ambiguous") {
515
+ // The switch was left partially applied. Do not write a completion
516
+ // receipt; the interrupted switch-progress marker is the durable signal.
517
+ return { ...blockedFromSwitchAmbiguous(report, classification), report };
518
+ }
519
+ if (report.outcome !== "migrated") {
520
+ target.discardFreshOutput();
521
+ return { ...blockedFromEngineReport(report, classification), report };
522
+ }
523
+ // From this line onward the old Home has moved to its backup and the new
524
+ // Home is authoritative. Set the guard before any receipt/health work so
525
+ // even an injected post-switch failure cannot restore the old Controller.
526
+ switchState.committed = true;
527
+ switchState.backupPath = report.switch.backupPath;
528
+ // The atomic switch has COMMITTED. Keep a durable receipt until the
529
+ // post-switch loader and (when applicable) replacement Controller both
530
+ // succeed; this closes the activation ambiguity window.
531
+ options.postSwitchFaultHook?.("receipt-write");
532
+ writeUpgradeReceipt(home, {
533
+ switched: true,
534
+ homePath: home,
535
+ completedAt: now().toISOString(),
536
+ targetLayoutVersion: latest.layout,
537
+ targetAggregateVersion: latest.aggregate,
538
+ ...(report.switch.backupPath === undefined
539
+ ? {}
540
+ : { backupPath: report.switch.backupPath })
541
+ });
542
+ options.postSwitchFaultHook?.("post-verify");
543
+ const postVerify = postSwitchHealthCheck(home);
544
+ if (postVerify !== null) {
545
+ return {
546
+ ...withClassification(postSwitchAmbiguity(home, report.switch.backupPath, new Error(postVerify.message)), classification),
547
+ report
548
+ };
549
+ }
550
+ return {
551
+ outcome: "upgraded",
552
+ classification,
553
+ report,
554
+ ...(report.switch.backupPath === undefined
555
+ ? {}
556
+ : { backupPath: report.switch.backupPath })
557
+ };
558
+ });
559
+ }
560
+ catch (error) {
561
+ if (!(error instanceof UpgradeFenceError))
562
+ throw error;
563
+ return withClassification({
564
+ outcome: "blocked",
565
+ stage: "coordination",
566
+ message: `Upgrade coordination could not be acquired: ${error.message}`,
567
+ action: "Wait for the other writer or switch recovery to finish, then retry; "
568
+ + "the authoritative Home was not switched by this attempt."
569
+ }, classification);
570
+ }
571
+ }
572
+ /**
573
+ * Verify the Home is quiesced after the Controller drain. Returns `null` when
574
+ * clear, or a fail-closed blocker when any live runtime or unfinished lifecycle
575
+ * obligation remains. Never kills anything.
576
+ *
577
+ * The source may still be at an older schema here, so it reads runtime signals
578
+ * and the raw `state.json` mailboxes directly rather than through the
579
+ * version-gated store.
580
+ */
581
+ function verifyQuiesced(home, callerPid) {
582
+ const signals = inspectHomeRuntime(home, callerPid);
583
+ if (homeRuntimeIsActive(signals)) {
584
+ return {
585
+ outcome: "blocked",
586
+ stage: "active-runtime",
587
+ message: `Cannot upgrade: ${describeActiveRuntime(signals)}`,
588
+ action: "Stop all Yui activity for this Home (and clear any stale .state.lock / "
589
+ + "runtime/controller.json only after confirming no process owns it), then retry."
590
+ };
591
+ }
592
+ // Unfinished runtime lifecycle obligations block. This is TWO independent
593
+ // durable lanes (per task-1 / message-8 §3, either non-empty fails closed):
594
+ // 1. the aggregate `state.json` mailboxes (runtime lifecycle lanes), and
595
+ // 2. the DURABLE runtime inbox `runtime/inbox/*` — authoritative, not-yet-
596
+ // applied native-hook events. A healthy Controller drains the inbox, but
597
+ // the no-Controller / stale-event path (fully supported) reaches here with
598
+ // inbox entries still on disk, and those must not be silently discarded by
599
+ // an atomic switch. We prove the inbox empty READ-ONLY (never acknowledging
600
+ // or quarantining as part of the check — that would mutate the source).
601
+ const pendingRuntime = countPendingRuntimeMailboxes(home);
602
+ const pendingInbox = countPendingDurableInbox(home);
603
+ if (pendingRuntime > 0 || pendingInbox > 0) {
604
+ const parts = [];
605
+ if (pendingRuntime > 0)
606
+ parts.push(`${pendingRuntime} pending mailbox(es)`);
607
+ if (pendingInbox > 0)
608
+ parts.push(`${pendingInbox} pending durable inbox entr(ies)`);
609
+ return {
610
+ outcome: "blocked",
611
+ stage: "drain-incomplete",
612
+ message: `Runtime lifecycle work is not drained (${parts.join("; ")}).`,
613
+ action: "Let the Controller finish draining the runtime inbox and mailboxes (or start it so it "
614
+ + "can), then retry the upgrade. The authoritative Home is unchanged."
615
+ };
616
+ }
617
+ return null;
618
+ }
619
+ /**
620
+ * Count runtime lifecycle mailboxes with pending/processing work, read directly
621
+ * from the raw `state.json` so it works on a not-yet-migrated source. A mailbox
622
+ * is a runtime lifecycle lane when its target kind is `role-runtime` or
623
+ * `global-role-runtime`; it has work when `pending` or `processing` is set.
624
+ */
625
+ function countPendingRuntimeMailboxes(home) {
626
+ let raw;
627
+ try {
628
+ raw = readFileSync(join(home, STORAGE_STATE_FILE), "utf8");
629
+ }
630
+ catch {
631
+ return 0;
632
+ }
633
+ let mailboxes;
634
+ try {
635
+ const state = JSON.parse(raw);
636
+ if (typeof state.mailboxes !== "object" || state.mailboxes === null)
637
+ return 0;
638
+ mailboxes = state.mailboxes;
639
+ }
640
+ catch {
641
+ return 0;
642
+ }
643
+ let count = 0;
644
+ for (const value of Object.values(mailboxes)) {
645
+ if (typeof value !== "object" || value === null)
646
+ continue;
647
+ const mailbox = value;
648
+ const kind = mailbox.target?.kind;
649
+ const isRuntimeLane = kind === "role-runtime" || kind === "global-role-runtime";
650
+ const hasWork = mailbox.pending !== null || mailbox.processing !== null;
651
+ if (isRuntimeLane && hasWork)
652
+ count += 1;
653
+ }
654
+ return count;
655
+ }
656
+ /**
657
+ * Count pending entries in the DURABLE runtime inbox, READ-ONLY (R3-F4). This
658
+ * proves the inbox is drained before an atomic switch replaces the whole Home,
659
+ * so authoritative not-yet-applied native-hook events are never silently lost.
660
+ *
661
+ * It deliberately does NOT go through `FileRuntimeEventInbox.list()`, which
662
+ * quarantines malformed entries as a side effect — the quiesce check must not
663
+ * mutate the source. Instead it counts, purely by directory listing:
664
+ * - any committed event file (`*.json`) in `runtime/inbox`,
665
+ * - any in-progress temporary write (`.<id>.tmp-*`) in `runtime/inbox`, and
666
+ * - any quarantined-but-unresolved entry under `runtime/inbox-invalid`.
667
+ * Any of these being non-zero means lifecycle work is not fully drained. A
668
+ * missing inbox directory (or an unreadable one) counts as zero pending here for
669
+ * the inbox itself, but an unreadable directory is surfaced as a conservative
670
+ * single pending entry so an undeterminable inbox fails closed rather than open.
671
+ */
672
+ function countPendingDurableInbox(home) {
673
+ const inboxDir = join(home, "runtime", "inbox");
674
+ const invalidDir = join(home, "runtime", "inbox-invalid");
675
+ let count = 0;
676
+ count += countInboxDirectoryEntries(inboxDir, /* countTemporary */ true);
677
+ count += countInboxDirectoryEntries(invalidDir, /* countTemporary */ true);
678
+ return count;
679
+ }
680
+ /**
681
+ * Count durable entries in one inbox directory. A `.json` file or (when
682
+ * `countTemporary`) a `.tmp-` in-progress write is pending. Returns 0 when the
683
+ * directory is provably absent; returns 1 (fail-closed) when it exists but cannot
684
+ * be listed, so an undeterminable inbox never reads as "empty".
685
+ */
686
+ function countInboxDirectoryEntries(directory, countTemporary) {
687
+ let entries;
688
+ try {
689
+ entries = readdirSync(directory);
690
+ }
691
+ catch (error) {
692
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
693
+ return 0;
694
+ return 1; // present but unreadable: fail closed.
695
+ }
696
+ let count = 0;
697
+ for (const name of entries) {
698
+ if (name.endsWith(".json"))
699
+ count += 1;
700
+ else if (countTemporary && name.includes(".tmp-"))
701
+ count += 1;
702
+ else if (!name.startsWith("."))
703
+ count += 1; // any other real entry (e.g. quarantined copies).
704
+ }
705
+ return count;
706
+ }
707
+ /**
708
+ * Re-read and pin the committed revision under the write lock, after drain. It
709
+ * takes the same lock the store uses (via {@link withStorageWriteLock}, which is
710
+ * not version-gated) and reads the committed revision without mutating, so it
711
+ * serializes the snapshot against the last committed write without a store.
712
+ */
713
+ function repinRevision(home) {
714
+ return withStorageWriteLock(home, () => readCommittedRevision(home));
715
+ }
716
+ function readCommittedRevision(home) {
717
+ try {
718
+ const raw = readFileSync(join(home, STORAGE_STATE_FILE), "utf8");
719
+ const value = JSON.parse(raw);
720
+ return Number.isInteger(value.revision) ? value.revision : 0;
721
+ }
722
+ catch {
723
+ return 0;
724
+ }
725
+ }
726
+ /** Post-switch health check: a fresh loader must parse the promoted Home. */
727
+ function postSwitchHealthCheck(home) {
728
+ try {
729
+ const store = new FileTaskStore(home);
730
+ store.getConfig();
731
+ store.listTasks();
732
+ store.listProjects();
733
+ store.listConfiguredAgents();
734
+ store.listWorkMailboxes();
735
+ return null;
736
+ }
737
+ catch (error) {
738
+ return {
739
+ outcome: "blocked",
740
+ stage: "post-verify",
741
+ message: `Post-switch health check failed: ${error instanceof Error ? error.message : String(error)}`,
742
+ action: "The migrated Home did not load. Restore the timestamped backup to recover the "
743
+ + "original Home; do not resume writes until the backup is restored."
744
+ };
745
+ }
746
+ }
747
+ function blockedFromFailedReport(report, classification) {
748
+ const stage = report.stage === "switch" ? "switch" : "validate";
749
+ return withClassification({
750
+ outcome: "blocked",
751
+ stage,
752
+ message: `Migration failed at ${report.stage}: ${report.error}`,
753
+ action: "The source Home is unchanged. Delete any staged output and retry; if it recurs, "
754
+ + "restore from backup and report the failure."
755
+ }, classification);
756
+ }
757
+ /**
758
+ * Build a blocker for a partially-applied, ambiguous switch: the original was
759
+ * moved to the backup but neither the promotion nor its rollback completed. This
760
+ * is reported at the `switch` stage with the exact backup-restore command and an
761
+ * explicit statement that the Home is NOT unchanged (P1-4).
762
+ */
763
+ function blockedFromSwitchAmbiguous(report, classification) {
764
+ return withClassification({
765
+ outcome: "blocked",
766
+ stage: "switch-ambiguous",
767
+ message: `Storage switch is AMBIGUOUS and partially applied: ${report.error}`,
768
+ action: `Do NOT assume the Home is unchanged. The original Home is at ${report.backupPath}; `
769
+ + `restore it to recover: mv "${report.backupPath}" "${report.homePath}". The interrupted `
770
+ + `switch marker "${switchProgressPath(report.homePath)}" records this ambiguity; verify `
771
+ + `with "yui doctor" after restoring.`
772
+ }, classification);
773
+ }
774
+ /**
775
+ * A structured blocker for failures after the atomic Home switch. The receipt
776
+ * and progress marker are named explicitly so an operator can reconcile the
777
+ * committed Home; the generic old-Controller restore path must never run here.
778
+ */
779
+ function postSwitchAmbiguity(home, backupPath, error) {
780
+ const receiptPath = upgradeReceiptPath(home);
781
+ const progressPath = switchProgressPath(home);
782
+ const backup = backupPath === undefined ? "the timestamped Home backup" : `backup ${backupPath}`;
783
+ return {
784
+ outcome: "blocked",
785
+ stage: "post-verify",
786
+ switchCommitted: true,
787
+ ...(backupPath === undefined ? {} : { backupPath }),
788
+ recoveryEvidence: {
789
+ ...(backupPath === undefined ? {} : { backupPath }),
790
+ receiptPath,
791
+ progressPath
792
+ },
793
+ message: `The storage switch committed, but post-switch completion could not be confirmed: `
794
+ + `${messageOf(error)}. The old Controller was not restored.`,
795
+ action: `Do not start the old Controller against the migrated Home. Inspect ${backup}, receipt `
796
+ + `"${receiptPath}", and switch-progress marker "${progressPath}"; verify the Home, then `
797
+ + (backupPath === undefined
798
+ ? "complete or explicitly recover the switch before resuming writes."
799
+ : `restore the backup explicitly with mv "${backupPath}" "${home}" if verification fails.`)
800
+ };
801
+ }
802
+ function blockedFromEngineReport(report, classification) {
803
+ return withClassification({
804
+ outcome: "blocked",
805
+ stage: report.outcome === "active-runtime" ? "active-runtime" : "missing-step",
806
+ message: describeReport(report),
807
+ action: "Resolve the reported condition and retry the upgrade."
808
+ }, classification);
809
+ }
810
+ function withClassification(blocker, classification) {
811
+ return { ...blocker, classification };
812
+ }
813
+ async function defaultStopController(home, expectedPid, controllerOptions) {
814
+ return stopFileTaskController(home, {
815
+ ...(controllerOptions ?? {}),
816
+ expectedPid
817
+ });
818
+ }
819
+ async function defaultStartController(home, controllerOptions) {
820
+ return ensureFileTaskController(home, controllerOptions ?? {});
821
+ }
822
+ async function defaultControllerStatus(home, controllerOptions) {
823
+ const call = controllerOptions?.call ?? callController;
824
+ let raw;
825
+ try {
826
+ raw = await call(home, "controller.status", {});
827
+ }
828
+ catch (error) {
829
+ // ENOENT/CONTROLLER_NOT_RUNNING is the only definitive stopped fact. A
830
+ // transport timeout or invalid discovery is unknown-active and must block;
831
+ // never infer a stopped Controller from a stale or malformed artifact.
832
+ if (controllerErrorCode(error) === "CONTROLLER_NOT_RUNNING") {
833
+ return { running: false };
834
+ }
835
+ if (controllerOptions?.call === undefined
836
+ && controllerErrorCode(error) === "CONTROLLER_DISCOVERY_INVALID") {
837
+ return controllerStatusFromRuntime(home, error);
838
+ }
839
+ throw error;
840
+ }
841
+ if (!isRecord(raw) || typeof raw.running !== "boolean") {
842
+ throw new Error("Controller status response is invalid.");
843
+ }
844
+ const identity = raw.running
845
+ ? await authenticatedControllerIdentity(home, call, raw)
846
+ : undefined;
847
+ return {
848
+ running: raw.running,
849
+ ...(identity === undefined ? {} : { identity }),
850
+ ...(isPositivePid(raw.pid) ? { pid: raw.pid } : {})
851
+ };
852
+ }
853
+ function controllerStatusFromRuntime(home, cause) {
854
+ const runtime = inspectHomeRuntime(home);
855
+ // A malformed/stale artifact is accepted as stopped only when the
856
+ // layout-agnostic runtime probe proves its named process is dead. Unknown or
857
+ // live discovery remains unknown-active; it never receives an inferred
858
+ // executable/argv/version identity.
859
+ if (runtime.liveController === null)
860
+ return { running: false };
861
+ throw new Error(`Controller runtime discovery could not be resolved: ${messageOf(cause)}`, { cause });
862
+ }
863
+ function confirmedControllerStopped(value, expectedPid) {
864
+ // Existing test seams historically returned void after a successful drain;
865
+ // retain that narrow compatibility while requiring explicit confirmation for
866
+ // any structured result.
867
+ return value === undefined || (isRecord(value)
868
+ && value.stopped === true
869
+ && value.pid === expectedPid);
870
+ }
871
+ function isControllerLifecycleStatus(value) {
872
+ return isRecord(value) && typeof value.running === "boolean";
873
+ }
874
+ function controllerLifecycleBlocker(prefix, error) {
875
+ return {
876
+ outcome: "blocked",
877
+ stage: "active-runtime",
878
+ message: `${prefix}: ${messageOf(error)}.`,
879
+ action: "The Controller may still be active or draining. Do not retry stop blindly; "
880
+ + "inspect the Controller and runtime, then retry once the Home is quiesced."
881
+ };
882
+ }
883
+ async function restoreController(home, options, cause, identity) {
884
+ if (identity === undefined) {
885
+ throw new Error("The previously running Controller identity was not captured; refusing to restore with a new executable.", { cause });
886
+ }
887
+ const restore = options.restoreController
888
+ ?? ((h, i) => defaultRestoreController(h, i, options.controllerOptions));
889
+ try {
890
+ await restore(home, identity);
891
+ }
892
+ catch (error) {
893
+ throw new Error(`Storage upgrade failed and the previously running Controller could not be restored: `
894
+ + `${messageOf(error)}`, { cause });
895
+ }
896
+ }
897
+ async function authenticatedControllerIdentity(home, call, status) {
898
+ const inline = status.identity;
899
+ if (isControllerLaunchIdentity(inline))
900
+ return inline;
901
+ const raw = await call(home, "controller.identity", {});
902
+ if (!isControllerLaunchIdentity(raw)) {
903
+ throw new Error("Authenticated Controller identity is unavailable; refusing to stop an un-restorable process.");
904
+ }
905
+ if (typeof status.version === "string" && raw.version !== status.version) {
906
+ throw new Error(`Controller identity version ${raw.version} does not match status version ${status.version}.`);
907
+ }
908
+ return raw;
909
+ }
910
+ /** Start the exact captured process command and await an authenticated readiness proof. */
911
+ async function defaultRestoreController(home, identity, controllerOptions) {
912
+ await ensureFileTaskControllerIdentity(home, identity, {
913
+ ...(controllerOptions ?? {}),
914
+ spawnController: (_restoredHome, environment) => {
915
+ const child = spawn(identity.executablePath, [...identity.args], {
916
+ env: environment,
917
+ detached: true,
918
+ stdio: "ignore"
919
+ });
920
+ child.unref();
921
+ }
922
+ });
923
+ }
924
+ function controllerErrorCode(error) {
925
+ return isRecord(error) && typeof error.code === "string" ? error.code : undefined;
926
+ }
927
+ function isPositivePid(value) {
928
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
929
+ }
930
+ function isControllerLaunchIdentity(value) {
931
+ return isRecord(value)
932
+ && typeof value.executablePath === "string"
933
+ && value.executablePath.length > 0
934
+ && Array.isArray(value.args)
935
+ && value.args.every((arg) => typeof arg === "string")
936
+ && typeof value.version === "string"
937
+ && value.version.length > 0;
938
+ }
939
+ function isRecord(value) {
940
+ return typeof value === "object" && value !== null && !Array.isArray(value);
941
+ }
942
+ function messageOf(error) {
943
+ return error instanceof Error ? error.message : String(error);
944
+ }
945
+ /** Read the current fence for a Home (re-exported for command wiring). */
946
+ export { readUpgradeFence, clearUpgradeFence };
947
+ /** Read/locate the completion receipt (re-exported for update orchestration). */
948
+ export { readUpgradeReceipt, correlateUpgradeReceipt, upgradeReceiptPath } from "./upgradeReceipt.js";