@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,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The upgrade completion receipt — a verifiable, out-of-band marker that a
|
|
3
|
+
* storage switch actually committed.
|
|
4
|
+
*
|
|
5
|
+
* ## Why (the activation-ambiguity problem)
|
|
6
|
+
*
|
|
7
|
+
* `yui update` activates storage by spawning the staged binary's `yui upgrade`.
|
|
8
|
+
* If that child is killed (SIGTERM/OOM) *after* the atomic switch commits but
|
|
9
|
+
* *before* it prints its success JSON, the parent sees empty stdout and cannot
|
|
10
|
+
* tell "nothing happened" from "storage already switched". Treating that as
|
|
11
|
+
* recoverable (source unchanged) is a false claim that can hide a completed
|
|
12
|
+
* migration.
|
|
13
|
+
*
|
|
14
|
+
* The receipt closes that gap: `runStorageUpgrade` writes it the instant the
|
|
15
|
+
* switch commits and clears it only on a clean, fully-verified return. So its
|
|
16
|
+
* presence is a durable "the switch committed but clean completion was not
|
|
17
|
+
* confirmed" signal that the parent can read even when stdout was lost.
|
|
18
|
+
*
|
|
19
|
+
* It lives at a SIBLING path of the Home (`<home>.upgrade-receipt.json`), never
|
|
20
|
+
* inside it, so it survives the switch's `home -> backup` / `staging -> home`
|
|
21
|
+
* renames and is readable by both the child (new binary) and the parent (old
|
|
22
|
+
* binary), which share the same fixed `YUI_HOME` path.
|
|
23
|
+
*/
|
|
24
|
+
import { existsSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
25
|
+
import { basename, dirname, join } from "node:path";
|
|
26
|
+
import { writeTextFileAtomically } from "../durableFile.js";
|
|
27
|
+
/** The sibling receipt path for a Home. */
|
|
28
|
+
export function upgradeReceiptPath(home) {
|
|
29
|
+
return join(dirname(home), `${basename(home)}.upgrade-receipt.json`);
|
|
30
|
+
}
|
|
31
|
+
/** Write the completion receipt atomically, just after the switch commits. */
|
|
32
|
+
export function writeUpgradeReceipt(home, receipt) {
|
|
33
|
+
writeTextFileAtomically(upgradeReceiptPath(home), `${JSON.stringify(receipt, null, 2)}\n`);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Read the completion receipt, or `null` when absent/unreadable. A malformed
|
|
37
|
+
* receipt reads as `null`: it is only ever corroborating evidence, and the
|
|
38
|
+
* caller separately inspects the on-disk schema and backup.
|
|
39
|
+
*/
|
|
40
|
+
export function readUpgradeReceipt(home) {
|
|
41
|
+
const path = upgradeReceiptPath(home);
|
|
42
|
+
if (!existsSync(path))
|
|
43
|
+
return null;
|
|
44
|
+
try {
|
|
45
|
+
const value = JSON.parse(readFileSync(path, "utf8"));
|
|
46
|
+
if (value.switched !== true || typeof value.completedAt !== "string")
|
|
47
|
+
return null;
|
|
48
|
+
return {
|
|
49
|
+
switched: true,
|
|
50
|
+
completedAt: value.completedAt,
|
|
51
|
+
...(typeof value.homePath === "string" ? { homePath: value.homePath } : {}),
|
|
52
|
+
...(typeof value.backupPath === "string" ? { backupPath: value.backupPath } : {}),
|
|
53
|
+
...(Number.isInteger(value.targetLayoutVersion)
|
|
54
|
+
? { targetLayoutVersion: value.targetLayoutVersion }
|
|
55
|
+
: {}),
|
|
56
|
+
...(Number.isInteger(value.targetAggregateVersion)
|
|
57
|
+
? { targetAggregateVersion: value.targetAggregateVersion }
|
|
58
|
+
: {})
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Validate that a receipt genuinely corresponds to the current Home AND its
|
|
67
|
+
* backup before trusting it for a recovery decision (P2-6 / R3-F6). Existence
|
|
68
|
+
* alone is not correspondence: a receipt is trusted only when it carries the
|
|
69
|
+
* current protocol's correlating fields and its backup is a REAL directory at the
|
|
70
|
+
* exact timestamped-sibling path this Home's switch would have produced. Each
|
|
71
|
+
* rule closes a concrete staleness/impersonation gap:
|
|
72
|
+
*
|
|
73
|
+
* - **no receipt** — nothing to correlate.
|
|
74
|
+
* - **missing/foreign homePath** — a legacy receipt without `homePath`, or one
|
|
75
|
+
* naming a different Home, says nothing trustworthy about THIS Home; the
|
|
76
|
+
* current protocol always stamps `homePath`, so its absence is a legacy/degraded
|
|
77
|
+
* marker that must be re-probed, not reused.
|
|
78
|
+
* - **missing backupPath** — a receipt with no backup path is a degraded/legacy
|
|
79
|
+
* marker; without the exact backup to correlate against we cannot confirm it,
|
|
80
|
+
* so we re-probe rather than assert a committed switch.
|
|
81
|
+
* - **backup not the expected sibling** — a `backupPath` that is not
|
|
82
|
+
* `<home>.backup-*` in the Home's own parent directory is unrelated evidence
|
|
83
|
+
* (a copied/foreign receipt); reject it.
|
|
84
|
+
* - **backup absent or not a directory** — the switch it recorded was already
|
|
85
|
+
* rolled back/cleaned, or the path is not a real backup dir; re-probe.
|
|
86
|
+
*
|
|
87
|
+
* On any rejection the caller falls back to an explicit "uncertain → re-probe the
|
|
88
|
+
* real on-disk state" rather than deriving a recovery instruction from stale or
|
|
89
|
+
* unrelated evidence.
|
|
90
|
+
*/
|
|
91
|
+
export function correlateUpgradeReceipt(home) {
|
|
92
|
+
const receipt = readUpgradeReceipt(home);
|
|
93
|
+
if (receipt === null) {
|
|
94
|
+
return { corresponds: false, reason: "no receipt present", receipt: null };
|
|
95
|
+
}
|
|
96
|
+
// The current protocol always records the Home it is about; a receipt without
|
|
97
|
+
// `homePath`, or naming a different Home, is legacy/foreign — never trusted.
|
|
98
|
+
if (receipt.homePath === undefined) {
|
|
99
|
+
return {
|
|
100
|
+
corresponds: false,
|
|
101
|
+
reason: "receipt has no homePath (legacy/degraded marker); re-probing current state",
|
|
102
|
+
receipt
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
if (receipt.homePath !== home) {
|
|
106
|
+
return {
|
|
107
|
+
corresponds: false,
|
|
108
|
+
reason: `receipt names a different Home (${receipt.homePath} != ${home})`,
|
|
109
|
+
receipt
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
// A trustworthy receipt must carry the exact backup its switch created.
|
|
113
|
+
if (receipt.backupPath === undefined) {
|
|
114
|
+
return {
|
|
115
|
+
corresponds: false,
|
|
116
|
+
reason: "receipt has no backupPath; cannot correlate a committed switch — re-probing",
|
|
117
|
+
receipt
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
if (!isExpectedBackupSibling(home, receipt.backupPath)) {
|
|
121
|
+
return {
|
|
122
|
+
corresponds: false,
|
|
123
|
+
reason: `receipt backup ${receipt.backupPath} is not this Home's expected timestamped sibling; unrelated evidence`,
|
|
124
|
+
receipt
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
if (!isRealDirectory(receipt.backupPath)) {
|
|
128
|
+
return {
|
|
129
|
+
corresponds: false,
|
|
130
|
+
reason: `receipt backup ${receipt.backupPath} is absent or not a directory (already restored or cleaned)`,
|
|
131
|
+
receipt
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
return { corresponds: true, receipt };
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* True when `backupPath` is the exact `<home>.backup-<stamp>` sibling in the
|
|
138
|
+
* Home's own parent directory that this Home's atomic switch would produce (see
|
|
139
|
+
* `homeMigrationTarget.atomicSwitchWithBackup`). Rejects any path in a different
|
|
140
|
+
* directory or without the `<basename>.backup-` prefix — i.e. unrelated evidence.
|
|
141
|
+
*/
|
|
142
|
+
function isExpectedBackupSibling(home, backupPath) {
|
|
143
|
+
if (dirname(backupPath) !== dirname(home))
|
|
144
|
+
return false;
|
|
145
|
+
const prefix = `${basename(home)}.backup-`;
|
|
146
|
+
const name = basename(backupPath);
|
|
147
|
+
return name.startsWith(prefix) && name.length > prefix.length;
|
|
148
|
+
}
|
|
149
|
+
/** True when `path` exists and is a real directory (not a file/symlink target miss). */
|
|
150
|
+
function isRealDirectory(path) {
|
|
151
|
+
try {
|
|
152
|
+
return statSync(path).isDirectory();
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/** Best-effort removal of the receipt (on clean success or before a fresh run). */
|
|
159
|
+
export function clearUpgradeReceipt(home) {
|
|
160
|
+
rmSync(upgradeReceiptPath(home), { force: true });
|
|
161
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared coordination boundary for durable runtime-hook admission and the
|
|
3
|
+
* final Home snapshot/switch.
|
|
4
|
+
*
|
|
5
|
+
* The upgrade fence answers "may a new writer start?" but a writer that has
|
|
6
|
+
* already passed that check can still be in the middle of its durable inbox
|
|
7
|
+
* write. This sibling lock supplies the missing linearization point:
|
|
8
|
+
*
|
|
9
|
+
* inbox publish: acquire lock -> check fence/progress -> write -> release
|
|
10
|
+
* upgrade cutover: acquire lock -> check drain -> copy -> switch -> release
|
|
11
|
+
*
|
|
12
|
+
* The lock lives beside (not inside) the Home, so the home -> backup and
|
|
13
|
+
* staging -> home renames cannot move it out from under a live holder. It is
|
|
14
|
+
* a short, non-reentrant-on-disk critical section with a bounded wait. A
|
|
15
|
+
* crashed holder leaves its directory behind; a later entrant may atomically
|
|
16
|
+
* rename it aside only after the owner is provably dead (or an owner-less
|
|
17
|
+
* directory has exceeded the conservative age bound). No TTL is used to
|
|
18
|
+
* evict a live owner, and a live/undeterminable holder fails closed.
|
|
19
|
+
*/
|
|
20
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { randomUUID } from "node:crypto";
|
|
22
|
+
import { dirname, join } from "node:path";
|
|
23
|
+
import { assertHomeWritable, UpgradeFenceError } from "./upgradeFence.js";
|
|
24
|
+
import { STORAGE_SCHEMA_FILE } from "./storageSchema.js";
|
|
25
|
+
import { switchProgressPath } from "./upgrade/switchProgress.js";
|
|
26
|
+
/** Sibling directory used for the shared inbox/cutover critical section. */
|
|
27
|
+
export function upgradeCoordinationLockPath(home) {
|
|
28
|
+
return `${home}.upgrade-coordination.lock`;
|
|
29
|
+
}
|
|
30
|
+
const COORDINATION_LOCK_TIMEOUT_MS = 5_000;
|
|
31
|
+
const COORDINATION_LOCK_RETRY_MS = 10;
|
|
32
|
+
const COORDINATION_LOCK_MIN_AGE_MS = 1_000;
|
|
33
|
+
/**
|
|
34
|
+
* Run a synchronous operation at the shared admission/cutover boundary.
|
|
35
|
+
*
|
|
36
|
+
* The admission check is intentionally performed *after* acquiring the lock.
|
|
37
|
+
* A hook that passed a check before an upgrade placed its fence either owns the
|
|
38
|
+
* lock and finishes before cutover, or waits and receives an explicit
|
|
39
|
+
* UpgradeFenceError after the cutover holder releases it. There is no second
|
|
40
|
+
* scan or retry protocol hidden in this helper.
|
|
41
|
+
*/
|
|
42
|
+
export function withUpgradeCoordinationLock(home, execute) {
|
|
43
|
+
const release = acquireUpgradeCoordinationLock(home);
|
|
44
|
+
try {
|
|
45
|
+
assertUpgradeAdmission(home);
|
|
46
|
+
return execute();
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
release();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Refuse admission when a foreign upgrade fence or an unresolved durable
|
|
54
|
+
* switch-progress marker is present. A marker is actionable only while the
|
|
55
|
+
* filesystem corroborates a missing/uninitialized Home; a stale marker beside
|
|
56
|
+
* an intact Home is safe to ignore, matching update's recovery probe and
|
|
57
|
+
* avoiding a permanent post-promote hook deadlock. A malformed marker with a
|
|
58
|
+
* missing Home still fails closed because its recovery phase cannot be trusted.
|
|
59
|
+
*/
|
|
60
|
+
export function assertUpgradeAdmission(home) {
|
|
61
|
+
assertHomeWritable(home);
|
|
62
|
+
const progressPath = switchProgressPath(home);
|
|
63
|
+
if (!existsSync(progressPath))
|
|
64
|
+
return;
|
|
65
|
+
const homeInitialized = existsSync(join(home, STORAGE_SCHEMA_FILE));
|
|
66
|
+
if (!homeInitialized) {
|
|
67
|
+
throw new UpgradeFenceError("storage switch recovery is in progress; the Home is not yet safe for a new write");
|
|
68
|
+
}
|
|
69
|
+
// A marker with an intact Home is stale relative to the filesystem (for
|
|
70
|
+
// example, a best-effort marker-clear failure after promotion). Reads and
|
|
71
|
+
// normal hook writes may proceed; update reports the marker only when
|
|
72
|
+
// backup+missing-Home evidence corroborates an interrupted switch.
|
|
73
|
+
}
|
|
74
|
+
// A same-process nested call is safe because the critical section is synchronous
|
|
75
|
+
// and cannot interleave with another turn. Cross-process ownership remains
|
|
76
|
+
// governed by the on-disk mkdir boundary.
|
|
77
|
+
const ownedLocks = new Map();
|
|
78
|
+
function acquireUpgradeCoordinationLock(home) {
|
|
79
|
+
const lock = upgradeCoordinationLockPath(home);
|
|
80
|
+
const nested = ownedLocks.get(lock);
|
|
81
|
+
if (nested !== undefined) {
|
|
82
|
+
nested.depth += 1;
|
|
83
|
+
return () => releaseNested(lock, nested);
|
|
84
|
+
}
|
|
85
|
+
mkdirSync(dirname(lock), { recursive: true, mode: 0o700 });
|
|
86
|
+
const deadline = Date.now() + COORDINATION_LOCK_TIMEOUT_MS;
|
|
87
|
+
while (true) {
|
|
88
|
+
try {
|
|
89
|
+
mkdirSync(lock, { mode: 0o700 });
|
|
90
|
+
try {
|
|
91
|
+
writeFileSync(`${lock}/owner`, `${process.pid}\n`, { mode: 0o600 });
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
rmSync(lock, { recursive: true, force: true });
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
const release = () => releaseOwnedLock(lock, process.pid);
|
|
98
|
+
ownedLocks.set(lock, { depth: 1, release });
|
|
99
|
+
return release;
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
if (!isEexist(error))
|
|
103
|
+
throw error;
|
|
104
|
+
if (reclaimStaleCoordinationLock(lock))
|
|
105
|
+
continue;
|
|
106
|
+
if (Date.now() >= deadline) {
|
|
107
|
+
throw new UpgradeFenceError("shared inbox/cutover coordination is held by another process; retry after it exits");
|
|
108
|
+
}
|
|
109
|
+
// Match the bounded wait used by the existing storage lock. This is
|
|
110
|
+
// contention backoff, not a correctness delay or a blind retry of a
|
|
111
|
+
// publish/switch operation.
|
|
112
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, COORDINATION_LOCK_RETRY_MS);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function releaseNested(lock, owned) {
|
|
117
|
+
owned.depth -= 1;
|
|
118
|
+
if (owned.depth === 0) {
|
|
119
|
+
ownedLocks.delete(lock);
|
|
120
|
+
owned.release();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function releaseOwnedLock(lock, ownerPid) {
|
|
124
|
+
const current = readOwnerPid(lock);
|
|
125
|
+
// Never remove a successor's lock if a stale release races with recovery.
|
|
126
|
+
if (current === ownerPid)
|
|
127
|
+
rmSync(lock, { recursive: true, force: true });
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Move an orphaned lock aside atomically. The directory is never deleted in
|
|
131
|
+
* place while another process could be acquiring it: rename-aside gives the
|
|
132
|
+
* next O_EXCL mkdir a clean path and preserves a live replacement.
|
|
133
|
+
*/
|
|
134
|
+
function reclaimStaleCoordinationLock(lock) {
|
|
135
|
+
let age;
|
|
136
|
+
try {
|
|
137
|
+
age = Date.now() - statSync(lock).mtimeMs;
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
return isEnoent(error);
|
|
141
|
+
}
|
|
142
|
+
if (age < COORDINATION_LOCK_MIN_AGE_MS)
|
|
143
|
+
return false;
|
|
144
|
+
const ownerPid = readOwnerPid(lock);
|
|
145
|
+
if (ownerPid !== null && processIsAlive(ownerPid))
|
|
146
|
+
return false;
|
|
147
|
+
const abandoned = `${lock}.reclaim-${process.pid}-${randomUUID()}`;
|
|
148
|
+
try {
|
|
149
|
+
renameSync(lock, abandoned);
|
|
150
|
+
rmSync(abandoned, { recursive: true, force: true });
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
// ENOENT means the holder released it (or another reclaimer won); retry the
|
|
155
|
+
// mkdir. Any other failure is undeterminable and therefore fail-closed.
|
|
156
|
+
return isEnoent(error);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
function readOwnerPid(lock) {
|
|
160
|
+
let raw;
|
|
161
|
+
try {
|
|
162
|
+
raw = readFileSync(`${lock}/owner`, "utf8").trim();
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
if (!/^\d+$/.test(raw))
|
|
168
|
+
return null;
|
|
169
|
+
const pid = Number(raw);
|
|
170
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
171
|
+
}
|
|
172
|
+
function processIsAlive(pid) {
|
|
173
|
+
try {
|
|
174
|
+
process.kill(pid, 0);
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
return error instanceof Error && "code" in error && error.code === "EPERM";
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
function isEexist(error) {
|
|
182
|
+
return error instanceof Error && "code" in error && error.code === "EEXIST";
|
|
183
|
+
}
|
|
184
|
+
function isEnoent(error) {
|
|
185
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
186
|
+
}
|