@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.
- package/ARCHITECTURE.md +603 -133
- package/README.md +806 -31
- package/dist/agent/agent.js +2 -1
- package/dist/agent/argumentPolicy.js +3 -1
- package/dist/agent/launchEnvironment.js +106 -0
- package/dist/agent/managedRuntimeEnvironment.js +34 -0
- package/dist/brief/taskBrief.js +11 -1
- package/dist/cli/agentConfigurationPicker.js +287 -0
- package/dist/cli/commandCatalog.js +488 -60
- package/dist/cli/completion.js +146 -22
- package/dist/cli/helpRenderer.js +3 -1
- package/dist/cli/interactionCandidates.js +53 -15
- package/dist/cli/interactionPolicy.js +267 -30
- package/dist/cli/interactiveSelection.js +6 -2
- package/dist/cli/invocationRouter.js +5 -1
- package/dist/cli/operatorWizard.js +87 -0
- package/dist/cli/roleOptionCatalog.js +1 -0
- package/dist/cli/roleWizard.js +185 -21
- package/dist/cli/updateCommand.js +62 -19
- package/dist/cli/updateOrchestrator.js +539 -0
- package/dist/cli/updatePorts.js +1119 -0
- package/dist/cli/upgradeCommand.js +112 -0
- package/dist/cli.js +1420 -86
- package/dist/commands/agentCommands.js +146 -3
- package/dist/commands/configCommands.js +126 -0
- package/dist/commands/controllerCommands.js +365 -0
- package/dist/commands/globalRoleCommands.js +168 -126
- package/dist/commands/jobCommands.js +18 -8
- package/dist/commands/operatorCommands.js +159 -9
- package/dist/commands/profileCommands.js +203 -0
- package/dist/commands/projectCommands.js +650 -0
- package/dist/commands/roleConfiguration.js +85 -24
- package/dist/commands/roleRuntimeGuard.js +12 -0
- package/dist/commands/roleSkillValidation.js +47 -0
- package/dist/commands/taskActor.js +127 -0
- package/dist/commands/taskCommands.js +4201 -313
- package/dist/commands/taskCompletionGate.js +131 -0
- package/dist/commands/taskContextCommand.js +244 -30
- package/dist/commands/taskInputCommands.js +177 -59
- package/dist/commands/taskIntegrationCommands.js +303 -0
- package/dist/commands/taskOverviewCommand.js +363 -0
- package/dist/commands/taskRoleRuntimeStatus.js +125 -19
- package/dist/commands/textInput.js +15 -0
- package/dist/completion/completionInstaller.js +26 -22
- package/dist/config/yuiConfig.js +4 -3
- package/dist/context/dispatchContext.js +90 -38
- package/dist/context/roleSessionContext.js +119 -0
- package/dist/controller/claudeLifecycleHook.js +203 -0
- package/dist/controller/clientRuntime.js +408 -56
- package/dist/controller/codexLifecycleHook.js +108 -0
- package/dist/controller/controller.js +1089 -32
- package/dist/controller/domainIdentity.js +505 -0
- package/dist/controller/ephemeralResourceReaper.js +131 -0
- package/dist/controller/fileSchedulerStoreAdapter.js +2153 -103
- package/dist/controller/providerHookRunFence.js +127 -0
- package/dist/controller/resourceCleanupLinux.js +286 -0
- package/dist/controller/resourceInventory.js +531 -0
- package/dist/controller/resourceInventoryLinux.js +610 -0
- package/dist/controller/runtime.js +629 -10
- package/dist/controller/runtimeEventInbox.js +564 -0
- package/dist/controller/runtimeEventProcessor.js +248 -0
- package/dist/controller/runtimeLaunchCoordinator.js +477 -0
- package/dist/controller/sessionNotify.js +121 -78
- package/dist/coordination/deadlineScheduler.js +15 -0
- package/dist/coordination/mailboxScheduler.js +108 -0
- package/dist/coordination/workMailbox.js +329 -0
- package/dist/coordination/workMailboxQueue.js +86 -0
- package/dist/core/controllerClient.js +19 -5
- package/dist/core/controllerEndpoint.js +37 -0
- package/dist/core/controllerServer.js +218 -10
- package/dist/core/protocol.js +6 -2
- package/dist/decision/decision.js +2 -1
- package/dist/doctor/doctor.js +681 -32
- package/dist/domain/validation.js +53 -0
- package/dist/errors/cliError.js +5 -3
- package/dist/event/taskEvent.js +7 -3
- package/dist/execution/codexThreadNaming.js +160 -0
- package/dist/execution/executionGroup.js +579 -0
- package/dist/executor/agentAdapter.js +255 -40
- package/dist/executor/agentConfigurationCatalog.js +326 -0
- package/dist/executor/agentConfigurationProbe.js +506 -0
- package/dist/executor/agentExecutor.js +625 -10
- package/dist/executor/codexConfigConflict.js +290 -0
- package/dist/executor/effectiveLaunch.js +340 -0
- package/dist/executor/executorRegistry.js +238 -36
- package/dist/executor/fileRoleLaunchPlanner.js +550 -40
- package/dist/executor/turnCompletion.js +126 -0
- package/dist/input/inputRequest.js +30 -9
- package/dist/integration/changeSet.js +36 -0
- package/dist/integration/checkResult.js +24 -0
- package/dist/integration/gitIntegrationService.js +695 -0
- package/dist/integration/integrationAttempt.js +142 -0
- package/dist/interaction/operatorPresentation.js +96 -0
- package/dist/lifecycle/canonicalLifecycleEvent.js +342 -0
- package/dist/lifecycle/exactRunTerminalization.js +572 -0
- package/dist/lifecycle/providerLifecycleMapping.js +190 -0
- package/dist/lifecycle/taskRoleSessionReset.js +124 -0
- package/dist/message/message.js +23 -7
- package/dist/milestone/milestone.js +2 -1
- package/dist/operator/operatorSessionHistory.js +124 -0
- package/dist/output/agentConfigurationPresentation.js +43 -0
- package/dist/output/rolePresentation.js +34 -10
- package/dist/output/terminal.js +8 -0
- package/dist/output/timePresentation.js +55 -0
- package/dist/profile/agentProfile.js +128 -0
- package/dist/repository/gitWorkspace.js +578 -24
- package/dist/repository/project.js +213 -0
- package/dist/repository/taskWorkspaceCoordinator.js +392 -0
- package/dist/repository/taskWorkspacePreparer.js +1688 -191
- package/dist/review/reviewConfig.js +11 -0
- package/dist/review/reviewRound.js +399 -0
- package/dist/review/taskFinalReviewContract.js +90 -0
- package/dist/role/role.js +124 -23
- package/dist/run/agentRun.js +155 -12
- package/dist/run/runIdentity.js +82 -0
- package/dist/runtime/exactControlPlane.js +472 -0
- package/dist/runtime/index.js +8 -0
- package/dist/runtime/lifecycleReservation.js +38 -0
- package/dist/runtime/ports.js +11 -0
- package/dist/runtime/preallocatedNativeSession.js +13 -0
- package/dist/runtime/promptEnvelope.js +30 -0
- package/dist/runtime/runtimeBinding.js +31 -0
- package/dist/runtime/runtimeOwner.js +14 -0
- package/dist/runtime/sessionLaunchRequest.js +62 -0
- package/dist/runtime/sessionTitle.js +54 -0
- package/dist/runtime/taskRuntimeIsolation.js +643 -0
- package/dist/runtime/tmuxAdapters.js +315 -0
- package/dist/runtime/turnCompletion.js +3 -0
- package/dist/runtime/validation.js +23 -0
- package/dist/scheduler/activeRoleRunDelivery.js +342 -32
- package/dist/scheduler/activeTaskProgress.js +63 -0
- package/dist/scheduler/leaderFailure.js +2 -1
- package/dist/scheduler/leaderWakeupProcessor.js +307 -66
- package/dist/scheduler/operatorInputNotificationProcessor.js +109 -46
- package/dist/scheduler/operatorNotification.js +44 -2
- package/dist/scheduler/ports.js +28 -1
- package/dist/scheduler/roleRunLiveness.js +131 -25
- package/dist/scheduler/roleRunStall.js +951 -0
- package/dist/scheduler/taskExecutionProjection.js +544 -0
- package/dist/scheduler/wakeupQueue.js +3 -0
- package/dist/setup/setupCommand.js +302 -52
- package/dist/storage/compatibleTaskStore.js +102 -0
- package/dist/storage/migration/baseline.js +78 -0
- package/dist/storage/migration/classifier.js +51 -0
- package/dist/storage/migration/compatibleCodec.js +53 -0
- package/dist/storage/migration/engine.js +147 -0
- package/dist/storage/migration/index.js +33 -0
- package/dist/storage/migration/planner.js +154 -0
- package/dist/storage/migration/productionRegistry.js +486 -0
- package/dist/storage/migration/registry.js +169 -0
- package/dist/storage/migration/report.js +54 -0
- package/dist/storage/migration/types.js +31 -0
- package/dist/storage/storageSchema.js +147 -123
- package/dist/storage/storageVersions.js +11 -0
- package/dist/storage/taskStore.js +1793 -197
- package/dist/storage/upgrade/homeClassification.js +156 -0
- package/dist/storage/upgrade/homeMigrationTarget.js +595 -0
- package/dist/storage/upgrade/offlineUpgradeInventory.js +315 -0
- package/dist/storage/upgrade/productionMigrationRegistry.js +6 -0
- package/dist/storage/upgrade/recordVersionScan.js +176 -0
- package/dist/storage/upgrade/recordVersions.js +159 -0
- package/dist/storage/upgrade/switchProgress.js +80 -0
- package/dist/storage/upgrade/upgradeOrchestrator.js +948 -0
- package/dist/storage/upgrade/upgradeReceipt.js +161 -0
- package/dist/storage/upgradeCoordination.js +186 -0
- package/dist/storage/upgradeFence.js +366 -0
- package/dist/task/task.js +132 -26
- package/dist/task/taskRecordReference.js +66 -0
- package/dist/tmux/commandExecutor.js +75 -2
- package/dist/tmux/tmuxManager.js +747 -49
- package/dist/version.js +23 -0
- package/dist/web/assets/assetManifest.js +62 -0
- package/dist/web/assets/client/app.js +631 -0
- package/dist/web/assets/client/components.js +605 -0
- package/dist/web/assets/client/dom.js +14 -0
- package/dist/web/assets/client/format.js +28 -0
- package/dist/web/assets/client/i18n.js +494 -0
- package/dist/web/assets/client/markdown.js +114 -0
- package/dist/web/assets/client/theme.js +32 -0
- package/dist/web/assets/client/view.js +458 -0
- package/dist/web/assets/fontData.js +12 -0
- package/dist/web/assets/fonts.js +12 -0
- package/dist/web/assets/shell.js +114 -0
- package/dist/web/assets/styles/cards.js +135 -0
- package/dist/web/assets/styles/layout.js +47 -0
- package/dist/web/assets/styles/markdown.js +29 -0
- package/dist/web/assets/styles/responsive.js +39 -0
- package/dist/web/assets/styles/tokens.js +101 -0
- package/dist/web/assets/styles/widgets.js +147 -0
- package/dist/web/tmuxWebTerminal.js +158 -0
- package/dist/web/webServer.js +463 -0
- package/dist/web/webSnapshot.js +148 -0
- package/dist/workItem/workItem.js +642 -23
- package/dist/workspace/gitChangeSetCapture.js +86 -0
- package/dist/workspace/workItemChangeSetManager.js +445 -0
- package/dist/worktree/managedWorkspace.js +202 -0
- package/docs/task-local-identity.md +62 -0
- package/i18n/README.zh-CN.md +406 -31
- package/package.json +10 -2
- package/skills/yui-leader/SKILL.md +601 -39
- package/skills/yui-operator/SKILL.md +255 -34
- package/skills/yui-reviewer/SKILL.md +57 -0
- package/skills/yui-worker/SKILL.md +214 -17
- package/dist/commands/repositoryCommands.js +0 -86
- package/dist/operator/operatorContext.js +0 -66
- package/dist/repository/repository.js +0 -55
- package/dist/scheduler/archivedTaskRuntime.js +0 -12
- 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";
|