@ran-sh/dsh-crew 1.1.0 → 1.2.0-rc.4
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/.claude-plugin/marketplace.json +16 -17
- package/.claude-plugin/plugin.json +7 -1
- package/README.md +44 -15
- package/README.zh.md +37 -12
- package/agents/ds-flash.md +16 -18
- package/agents/ds-pro.md +16 -24
- package/agents/ds-reviewer.md +15 -15
- package/agents/ds-worker.md +16 -16
- package/bin/dsh-crew.mjs +16 -12
- package/codex/AGENTS.md +37 -85
- package/codex/agents/ds-flash.toml +18 -14
- package/codex/agents/ds-pro.toml +18 -15
- package/codex/agents/ds-reviewer.toml +17 -13
- package/codex/agents/ds-worker.toml +17 -13
- package/codex/prompts/dsh-config.md +17 -1
- package/codex/prompts/dsh-status.md +5 -1
- package/commands/config.md +16 -4
- package/commands/status.md +6 -2
- package/docs/installation.md +54 -9
- package/docs/readiness-matrix.md +14 -6
- package/docs/ui-surfaces.md +32 -17
- package/lib/client.js +5434 -4844
- package/official-web-bridge/lib/client.js +567 -479
- package/official-web-bridge/overlay-entry.mjs +59 -0
- package/package.json +6 -3
- package/scripts/build-client.mjs +2 -0
- package/scripts/setup.mjs +8 -7
- package/scripts/verify-crew-ui-polish.mjs +145 -0
- package/scripts/verify-history-ui.mjs +97 -0
- package/scripts/verify-npm-install.mjs +1 -1
- package/src/client/history-panel.tsx +108 -0
- package/src/client/host-readiness.mjs +15 -6
- package/src/client/index.tsx +86 -64
- package/src/client/model-callability-view.mjs +17 -0
- package/src/client/panel-chrome.tsx +40 -0
- package/src/client/quick-panel.tsx +88 -73
- package/src/client/readiness-envelope.mjs +34 -0
- package/src/config-readiness.mjs +181 -115
- package/src/extension-contract.mjs +44 -26
- package/src/history/admission-gate.mjs +51 -0
- package/src/history/archive-store.mjs +261 -0
- package/src/history/cleanup-plan.mjs +64 -0
- package/src/history/http.mjs +32 -0
- package/src/history/operation.mjs +86 -0
- package/src/history/runner.mjs +51 -0
- package/src/history/runtime.mjs +28 -0
- package/src/history/service.mjs +137 -0
- package/src/history/state.mjs +27 -0
- package/src/hub/index.mjs +141 -24
- package/src/hub-client.mjs +17 -15
- package/src/image-output.mjs +71 -0
- package/src/install/install-legacy.mjs +126 -23
- package/src/install/npx-lifecycle.mjs +373 -64
- package/src/install/official-frontend-assets.mjs +78 -0
- package/src/install/payload-content.mjs +88 -0
- package/src/install/windows-startup.mjs +107 -19
- package/src/install/windows-supervisor-adapter.mjs +443 -0
- package/src/install/windows-supervisor-lifecycle.mjs +782 -0
- package/src/install/zcode.mjs +53 -14
- package/src/job-contracts.mjs +37 -31
- package/src/jobs.mjs +2 -2
- package/src/mcp-runtime.mjs +74 -9
- package/src/model-callability-contract.mjs +79 -0
- package/src/multimodal.mjs +0 -0
- package/src/official-web-bridge.mjs +0 -2
- package/src/provider-health.mjs +130 -124
- package/src/provider-profile-store.mjs +12 -0
- package/src/provider-settings-store.mjs +347 -5
- package/src/readiness-matrix.mjs +20 -15
- package/src/runtime-identity-contract.mjs +34 -0
- package/src/runtime-identity.mjs +14 -4
- package/src/runtime-readiness-snapshot.mjs +285 -87
- package/src/server.mjs +86 -76
- package/src/standalone-sdk.mjs +23 -0
- package/src/supervisor/restart-request.mjs +64 -10
- package/src/workflow-runtime.mjs +28 -13
- package/windows/start-dsh-crew.cmd +57 -57
- package/windows/start-dsh-crew.ps1 +327 -55
- package/windows/supervisor-control.ps1 +467 -0
- package/zcode/AGENTS.md +4 -1
- package/zcode/agents/ds-reviewer.md +31 -36
- package/zcode/agents/ds-worker.md +31 -39
- package/zcode/commands/dsh-config.md +17 -6
- package/zcode/commands/dsh-status.md +5 -4
- package/.mcp.json +0 -8
|
@@ -0,0 +1,782 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
renameSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from 'node:fs';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
|
+
|
|
12
|
+
export const WINDOWS_SUPERVISOR_HANDOFF_SCHEMA = 1;
|
|
13
|
+
export const WINDOWS_SUPERVISOR_HANDOFF_PHASES = Object.freeze([
|
|
14
|
+
'planned',
|
|
15
|
+
'backend-stopped',
|
|
16
|
+
'old-watcher-stopped',
|
|
17
|
+
'new-watcher-started',
|
|
18
|
+
'verified',
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
const HANDOFF_PHASE_SET = new Set(WINDOWS_SUPERVISOR_HANDOFF_PHASES);
|
|
22
|
+
const HASH_RE = /^[a-f0-9]{64}$/i;
|
|
23
|
+
|
|
24
|
+
function nonEmptyString(value, max = 32_768) {
|
|
25
|
+
return typeof value === 'string' && value.trim().length > 0 && value.length <= max;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function validHash(value) {
|
|
29
|
+
return typeof value === 'string' && HASH_RE.test(value);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normalizedHash(value) {
|
|
33
|
+
return value.toLowerCase();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function normalizedWindowsPath(value) {
|
|
37
|
+
return value.replaceAll('/', '\\').replace(/\\+$/u, '').toLowerCase();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function validProcessStart(value) {
|
|
41
|
+
return typeof value === 'string' && /^\d+$/u.test(value) && value !== '0';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function validWatcher(value) {
|
|
45
|
+
return value !== null
|
|
46
|
+
&& typeof value === 'object'
|
|
47
|
+
&& Number.isInteger(value.pid)
|
|
48
|
+
&& value.pid > 0
|
|
49
|
+
&& validProcessStart(value.process_started_at_utc_ticks)
|
|
50
|
+
&& validHash(value.helper_hash);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function normalizedWatcher(value) {
|
|
54
|
+
return {
|
|
55
|
+
pid: value.pid,
|
|
56
|
+
process_started_at_utc_ticks: String(value.process_started_at_utc_ticks),
|
|
57
|
+
helper_hash: normalizedHash(value.helper_hash),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function validTarget(value) {
|
|
62
|
+
return value !== null
|
|
63
|
+
&& typeof value === 'object'
|
|
64
|
+
&& nonEmptyString(value.helper_path)
|
|
65
|
+
&& validHash(value.helper_hash)
|
|
66
|
+
&& nonEmptyString(value.control_path)
|
|
67
|
+
&& validHash(value.control_hash);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function normalizedTarget(value) {
|
|
71
|
+
return {
|
|
72
|
+
helper_path: value.helper_path.trim(),
|
|
73
|
+
helper_hash: normalizedHash(value.helper_hash),
|
|
74
|
+
control_path: value.control_path.trim(),
|
|
75
|
+
control_hash: normalizedHash(value.control_hash),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function sameWatcher(left, right) {
|
|
80
|
+
return validWatcher(left)
|
|
81
|
+
&& validWatcher(right)
|
|
82
|
+
&& left.pid === right.pid
|
|
83
|
+
&& String(left.process_started_at_utc_ticks) === String(right.process_started_at_utc_ticks)
|
|
84
|
+
&& normalizedHash(left.helper_hash) === normalizedHash(right.helper_hash);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function sameTarget(left, right) {
|
|
88
|
+
return validTarget(left)
|
|
89
|
+
&& validTarget(right)
|
|
90
|
+
&& normalizedWindowsPath(left.helper_path) === normalizedWindowsPath(right.helper_path)
|
|
91
|
+
&& normalizedHash(left.helper_hash) === normalizedHash(right.helper_hash)
|
|
92
|
+
&& normalizedWindowsPath(left.control_path) === normalizedWindowsPath(right.control_path)
|
|
93
|
+
&& normalizedHash(left.control_hash) === normalizedHash(right.control_hash);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function validateJournal(journal) {
|
|
97
|
+
if (journal === null || typeof journal !== 'object' || Array.isArray(journal)) return false;
|
|
98
|
+
if (journal.schema_version !== WINDOWS_SUPERVISOR_HANDOFF_SCHEMA) return false;
|
|
99
|
+
if (!nonEmptyString(journal.handoff_id, 512)) return false;
|
|
100
|
+
if (!HANDOFF_PHASE_SET.has(journal.phase)) return false;
|
|
101
|
+
if (!nonEmptyString(journal.lease, 512)) return false;
|
|
102
|
+
if (!validTarget(journal.target)) return false;
|
|
103
|
+
if (typeof journal.updated_at !== 'number' || !Number.isFinite(journal.updated_at) || journal.updated_at < 0) return false;
|
|
104
|
+
if (journal.old === null) return journal.runtime_id === null;
|
|
105
|
+
return validWatcher(journal.old) && nonEmptyString(journal.runtime_id, 2_048);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function journalFileFor({ appRoot, journalFile }) {
|
|
109
|
+
if (nonEmptyString(journalFile)) return journalFile;
|
|
110
|
+
return windowsSupervisorHandoffJournalPath(appRoot);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function atomicWrite(file, content) {
|
|
114
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
115
|
+
const temp = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
116
|
+
try {
|
|
117
|
+
writeFileSync(temp, content, { encoding: 'utf8', flag: 'wx' });
|
|
118
|
+
renameSync(temp, file);
|
|
119
|
+
} finally {
|
|
120
|
+
try { rmSync(temp, { force: true }); } catch { /* best effort */ }
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function windowsSupervisorHandoffJournalPath(appRoot) {
|
|
125
|
+
if (!nonEmptyString(appRoot)) throw new TypeError('appRoot is required');
|
|
126
|
+
return join(appRoot, 'supervisor', 'handoff.json');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function windowsSupervisorHandoffLockPath(appRoot) {
|
|
130
|
+
return `${windowsSupervisorHandoffJournalPath(appRoot)}.lock`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function readWindowsSupervisorHandoffJournal({ appRoot, journalFile } = {}) {
|
|
134
|
+
let file;
|
|
135
|
+
try {
|
|
136
|
+
file = journalFileFor({ appRoot, journalFile });
|
|
137
|
+
} catch (error) {
|
|
138
|
+
return { ok: false, code: 'SUPERVISOR_HANDOFF_JOURNAL_PATH_INVALID', error: error.message, journal: null };
|
|
139
|
+
}
|
|
140
|
+
if (!existsSync(file)) return { ok: true, state: 'absent', journal: null };
|
|
141
|
+
try {
|
|
142
|
+
const journal = JSON.parse(readFileSync(file, 'utf8'));
|
|
143
|
+
if (!validateJournal(journal)) {
|
|
144
|
+
return { ok: false, code: 'SUPERVISOR_HANDOFF_JOURNAL_MALFORMED', error: 'handoff journal schema is invalid', journal: null };
|
|
145
|
+
}
|
|
146
|
+
return { ok: true, state: 'present', journal };
|
|
147
|
+
} catch (error) {
|
|
148
|
+
return { ok: false, code: 'SUPERVISOR_HANDOFF_JOURNAL_MALFORMED', error: error.message, journal: null };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function writeWindowsSupervisorHandoffJournal({ appRoot, journalFile, journal } = {}) {
|
|
153
|
+
if (!validateJournal(journal)) {
|
|
154
|
+
return { ok: false, code: 'SUPERVISOR_HANDOFF_JOURNAL_MALFORMED', error: 'refusing to persist an invalid handoff journal' };
|
|
155
|
+
}
|
|
156
|
+
let file;
|
|
157
|
+
try {
|
|
158
|
+
file = journalFileFor({ appRoot, journalFile });
|
|
159
|
+
atomicWrite(file, `${JSON.stringify(journal, null, 2)}\n`);
|
|
160
|
+
const reread = readWindowsSupervisorHandoffJournal({ appRoot, journalFile: file });
|
|
161
|
+
if (!reread.ok || reread.state !== 'present' || reread.journal.handoff_id !== journal.handoff_id || reread.journal.phase !== journal.phase) {
|
|
162
|
+
return { ok: false, code: 'SUPERVISOR_HANDOFF_JOURNAL_VERIFY_FAILED', error: 'handoff journal did not round-trip exactly' };
|
|
163
|
+
}
|
|
164
|
+
return { ok: true, file, journal: reread.journal };
|
|
165
|
+
} catch (error) {
|
|
166
|
+
return { ok: false, code: 'SUPERVISOR_HANDOFF_JOURNAL_WRITE_FAILED', error: error.message };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function clearWindowsSupervisorHandoffJournal({ appRoot, journalFile, handoffId } = {}) {
|
|
171
|
+
const current = readWindowsSupervisorHandoffJournal({ appRoot, journalFile });
|
|
172
|
+
if (!current.ok) return current;
|
|
173
|
+
if (current.state === 'absent') return { ok: true, state: 'absent', idempotent: true };
|
|
174
|
+
if (nonEmptyString(handoffId, 512) && current.journal.handoff_id !== handoffId) {
|
|
175
|
+
return {
|
|
176
|
+
ok: false,
|
|
177
|
+
code: 'SUPERVISOR_HANDOFF_JOURNAL_IDENTITY_MISMATCH',
|
|
178
|
+
error: 'refusing to clear a different handoff transaction',
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
const file = journalFileFor({ appRoot, journalFile });
|
|
182
|
+
try {
|
|
183
|
+
rmSync(file, { force: true });
|
|
184
|
+
if (existsSync(file)) {
|
|
185
|
+
return { ok: false, code: 'SUPERVISOR_HANDOFF_JOURNAL_CLEAR_FAILED', error: 'handoff journal is still present' };
|
|
186
|
+
}
|
|
187
|
+
return { ok: true, state: 'cleared' };
|
|
188
|
+
} catch (error) {
|
|
189
|
+
return { ok: false, code: 'SUPERVISOR_HANDOFF_JOURNAL_CLEAR_FAILED', error: error.message };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function defaultAcquireLock({ journalFile, now }) {
|
|
194
|
+
const file = `${journalFile}.lock`;
|
|
195
|
+
const token = randomUUID();
|
|
196
|
+
const record = { schema_version: 1, token, pid: process.pid, acquired_at: now };
|
|
197
|
+
const tryCreate = () => {
|
|
198
|
+
const pending = `${file}.pending.${process.pid}.${randomUUID()}`;
|
|
199
|
+
try {
|
|
200
|
+
mkdirSync(pending);
|
|
201
|
+
writeFileSync(join(pending, 'owner.json'), `${JSON.stringify(record)}\n`, { flag: 'wx' });
|
|
202
|
+
renameSync(pending, file);
|
|
203
|
+
return { ok: true, token, file };
|
|
204
|
+
} catch (error) {
|
|
205
|
+
try { rmSync(pending, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
206
|
+
return {
|
|
207
|
+
ok: false,
|
|
208
|
+
code: ['EEXIST', 'EPERM', 'ENOTEMPTY'].includes(error?.code) ? 'LOCK_HELD' : 'LOCK_FAILED',
|
|
209
|
+
error: error?.message,
|
|
210
|
+
file,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
215
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
216
|
+
const created = tryCreate();
|
|
217
|
+
if (created.ok || created.code !== 'LOCK_HELD') return created;
|
|
218
|
+
const reclaimed = reclaimDefaultLock({ file });
|
|
219
|
+
if (!reclaimed.ok) return reclaimed;
|
|
220
|
+
}
|
|
221
|
+
return { ok: false, code: 'LOCK_HELD', file };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function acquireWindowsSupervisorHandoffLock({ appRoot, journalFile, now = Date.now() } = {}) {
|
|
225
|
+
let file;
|
|
226
|
+
try { file = journalFileFor({ appRoot, journalFile }); } catch (error) {
|
|
227
|
+
return { ok: false, code: 'LOCK_FAILED', error: error.message };
|
|
228
|
+
}
|
|
229
|
+
return defaultAcquireLock({ journalFile: file, now: timestamp(now) });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function lockOwnerAlive(record) {
|
|
233
|
+
const pid = Number(record?.pid);
|
|
234
|
+
if (!Number.isInteger(pid) || pid < 1) return false;
|
|
235
|
+
try {
|
|
236
|
+
process.kill(pid, 0);
|
|
237
|
+
return true;
|
|
238
|
+
} catch (error) {
|
|
239
|
+
return error?.code !== 'ESRCH';
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function reclaimDefaultLock({ file }) {
|
|
244
|
+
let current;
|
|
245
|
+
try { current = JSON.parse(readFileSync(join(file, 'owner.json'), 'utf8')); } catch {
|
|
246
|
+
return { ok: false, code: 'LOCK_FAILED', error: 'handoff lock is malformed', file };
|
|
247
|
+
}
|
|
248
|
+
if (!nonEmptyString(current?.token, 512) || lockOwnerAlive(current)) {
|
|
249
|
+
return { ok: false, code: 'LOCK_HELD', file };
|
|
250
|
+
}
|
|
251
|
+
const quarantine = `${file}.stale.${process.pid}.${randomUUID()}`;
|
|
252
|
+
try {
|
|
253
|
+
// Directory rename is the atomic claim. Exactly one contender can move
|
|
254
|
+
// the canonical lock; all others retry acquisition without ever deleting
|
|
255
|
+
// a replacement owner's lock.
|
|
256
|
+
renameSync(file, quarantine);
|
|
257
|
+
} catch (error) {
|
|
258
|
+
if (error?.code === 'ENOENT') return { ok: true, reclaimed: false };
|
|
259
|
+
return {
|
|
260
|
+
ok: false,
|
|
261
|
+
code: ['EEXIST', 'EPERM', 'ENOTEMPTY'].includes(error?.code) ? 'LOCK_HELD' : 'LOCK_FAILED',
|
|
262
|
+
error: error?.message,
|
|
263
|
+
file,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
try {
|
|
268
|
+
const claimed = JSON.parse(readFileSync(join(quarantine, 'owner.json'), 'utf8'));
|
|
269
|
+
if (claimed?.token !== current.token || claimed?.pid !== current.pid) {
|
|
270
|
+
return { ok: false, code: 'LOCK_FAILED', error: 'handoff lock identity changed during reclaim', file };
|
|
271
|
+
}
|
|
272
|
+
rmSync(quarantine, { recursive: true, force: true });
|
|
273
|
+
return { ok: true, reclaimed: true };
|
|
274
|
+
} catch (error) {
|
|
275
|
+
return { ok: false, code: 'LOCK_FAILED', error: error?.message, file };
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function defaultReleaseLock({ lock }) {
|
|
280
|
+
if (!lock?.file || !lock?.token) return { ok: false, code: 'LOCK_IDENTITY_INCOMPLETE' };
|
|
281
|
+
const quarantine = `${lock.file}.release.${process.pid}.${randomUUID()}`;
|
|
282
|
+
try {
|
|
283
|
+
const record = JSON.parse(readFileSync(join(lock.file, 'owner.json'), 'utf8'));
|
|
284
|
+
if (record?.token !== lock.token) return { ok: false, code: 'LOCK_IDENTITY_MISMATCH' };
|
|
285
|
+
renameSync(lock.file, quarantine);
|
|
286
|
+
const claimed = JSON.parse(readFileSync(join(quarantine, 'owner.json'), 'utf8'));
|
|
287
|
+
if (claimed?.token !== lock.token) return { ok: false, code: 'LOCK_IDENTITY_MISMATCH' };
|
|
288
|
+
rmSync(quarantine, { recursive: true, force: true });
|
|
289
|
+
return { ok: !existsSync(lock.file) && !existsSync(quarantine) };
|
|
290
|
+
} catch (error) {
|
|
291
|
+
return { ok: false, code: 'LOCK_RELEASE_FAILED', error: error.message };
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function releaseWindowsSupervisorHandoffLock(lock) {
|
|
296
|
+
return defaultReleaseLock({ lock });
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function validatePreAcquiredLock(lock, journalFile) {
|
|
300
|
+
const expectedFile = `${journalFile}.lock`;
|
|
301
|
+
if (!lock?.ok || !nonEmptyString(lock.token, 512) || !nonEmptyString(lock.file) || lock.file !== expectedFile) {
|
|
302
|
+
return { ok: false, code: 'LOCK_FAILED', error: 'pre-acquired handoff lock identity is invalid' };
|
|
303
|
+
}
|
|
304
|
+
try {
|
|
305
|
+
const record = JSON.parse(readFileSync(join(lock.file, 'owner.json'), 'utf8'));
|
|
306
|
+
return record?.token === lock.token
|
|
307
|
+
? lock
|
|
308
|
+
: { ok: false, code: 'LOCK_FAILED', error: 'pre-acquired handoff lock ownership changed' };
|
|
309
|
+
} catch (error) {
|
|
310
|
+
return { ok: false, code: 'LOCK_FAILED', error: error?.message };
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function timestamp(now) {
|
|
315
|
+
const value = typeof now === 'function' ? now() : now;
|
|
316
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : Date.now();
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function classificationOf(observed) {
|
|
320
|
+
const value = observed?.classification ?? observed?.kind ?? null;
|
|
321
|
+
if (value === 'ready-target' || value === 'current-ready') return 'target-ready';
|
|
322
|
+
if (value === 'starting-target') return 'target-starting';
|
|
323
|
+
return value;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function hookFailure(code, phase, result, fallback) {
|
|
327
|
+
return {
|
|
328
|
+
ok: false,
|
|
329
|
+
code,
|
|
330
|
+
phase,
|
|
331
|
+
error: result?.error ?? fallback,
|
|
332
|
+
detail: result?.detail ?? null,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function invokeHook(hooks, name, payload, phase) {
|
|
337
|
+
if (typeof hooks?.[name] !== 'function') {
|
|
338
|
+
return { ok: false, code: 'SUPERVISOR_HANDOFF_HOOK_MISSING', phase, error: `required hook ${name} is unavailable` };
|
|
339
|
+
}
|
|
340
|
+
try {
|
|
341
|
+
return await hooks[name](payload);
|
|
342
|
+
} catch (error) {
|
|
343
|
+
return { ok: false, code: 'SUPERVISOR_HANDOFF_HOOK_FAILED', phase, hook: name, error: error?.message ?? String(error) };
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function exactWatcherVerified(result, expected) {
|
|
348
|
+
if (result?.ok !== true) return false;
|
|
349
|
+
const reported = result.watcher ?? result.identity ?? null;
|
|
350
|
+
return sameWatcher(reported, expected);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function portIsFree(result) {
|
|
354
|
+
return result === true || (result?.ok === true && result.free === true);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function receiptIdentity(result) {
|
|
358
|
+
return {
|
|
359
|
+
lease: result?.lease ?? result?.detail?.lease ?? null,
|
|
360
|
+
runtime_id: result?.runtime_id
|
|
361
|
+
?? result?.detail?.runtime_id
|
|
362
|
+
?? result?.detail?.stopped_runtime_id
|
|
363
|
+
?? null,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function matchingMaintenanceReceipt(result, journal, allowedStates) {
|
|
368
|
+
if (result?.ok !== true || !allowedStates.has(result.state)) return false;
|
|
369
|
+
const identity = receiptIdentity(result);
|
|
370
|
+
return identity.lease === journal.lease && identity.runtime_id === journal.runtime_id;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function targetWatcherFrom(observed, target) {
|
|
374
|
+
const watcher = observed?.watcher ?? observed?.identity ?? null;
|
|
375
|
+
if (!validWatcher(watcher)) return null;
|
|
376
|
+
if (normalizedHash(watcher.helper_hash) !== normalizedHash(target.helper_hash)) return null;
|
|
377
|
+
return normalizedWatcher(watcher);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function readyContract(result, target, priorRuntimeId) {
|
|
381
|
+
if (result?.ok !== true) return null;
|
|
382
|
+
const watcher = targetWatcherFrom(result, target);
|
|
383
|
+
const runtime = result.runtime ?? result;
|
|
384
|
+
if (!watcher || !nonEmptyString(runtime?.runtime_id, 2_048)) return null;
|
|
385
|
+
if (priorRuntimeId !== null && runtime.runtime_id === priorRuntimeId) return null;
|
|
386
|
+
if (runtime.execution_plane !== undefined && runtime.execution_plane !== 'hub-3210') return null;
|
|
387
|
+
if (runtime.listen_port !== undefined && runtime.listen_port !== 3210) return null;
|
|
388
|
+
return { watcher, runtime_id: runtime.runtime_id };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function normalizedJournal(journal) {
|
|
392
|
+
return {
|
|
393
|
+
schema_version: WINDOWS_SUPERVISOR_HANDOFF_SCHEMA,
|
|
394
|
+
handoff_id: journal.handoff_id,
|
|
395
|
+
phase: journal.phase,
|
|
396
|
+
lease: journal.lease,
|
|
397
|
+
runtime_id: journal.runtime_id,
|
|
398
|
+
old: journal.old === null ? null : normalizedWatcher(journal.old),
|
|
399
|
+
target: normalizedTarget(journal.target),
|
|
400
|
+
updated_at: journal.updated_at,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Crash-resumable orchestration for replacing a legacy Windows Crew watcher.
|
|
406
|
+
* Every process/network action is injected. This module owns only the durable
|
|
407
|
+
* journal, exact-identity checks around hook results, and phase progression.
|
|
408
|
+
*/
|
|
409
|
+
export async function runWindowsSupervisorHandoff({
|
|
410
|
+
appRoot,
|
|
411
|
+
journalFile,
|
|
412
|
+
target,
|
|
413
|
+
hooks = {},
|
|
414
|
+
now = Date.now,
|
|
415
|
+
createHandoffId = randomUUID,
|
|
416
|
+
createLease = randomUUID,
|
|
417
|
+
preAcquiredLock = null,
|
|
418
|
+
forceRestart = false,
|
|
419
|
+
} = {}) {
|
|
420
|
+
if (!validTarget(target)) {
|
|
421
|
+
return { ok: false, code: 'SUPERVISOR_HANDOFF_TARGET_INVALID', error: 'target helper/control path and hash identity is incomplete' };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
let file;
|
|
425
|
+
try {
|
|
426
|
+
file = journalFileFor({ appRoot, journalFile });
|
|
427
|
+
} catch (error) {
|
|
428
|
+
return { ok: false, code: 'SUPERVISOR_HANDOFF_JOURNAL_PATH_INVALID', error: error.message };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const acquire = preAcquiredLock
|
|
432
|
+
? validatePreAcquiredLock(preAcquiredLock, file)
|
|
433
|
+
: typeof hooks.acquireLock === 'function'
|
|
434
|
+
? await invokeHook(hooks, 'acquireLock', { appRoot, journalFile: file }, 'lock')
|
|
435
|
+
: defaultAcquireLock({ journalFile: file, now: timestamp(now) });
|
|
436
|
+
if (acquire?.ok !== true) {
|
|
437
|
+
return {
|
|
438
|
+
ok: false,
|
|
439
|
+
code: acquire?.code === 'LOCK_HELD' ? 'SUPERVISOR_HANDOFF_BUSY' : 'SUPERVISOR_HANDOFF_LOCK_FAILED',
|
|
440
|
+
error: acquire?.error ?? 'another supervisor handoff owns the lock',
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const release = async () => {
|
|
445
|
+
if (!preAcquiredLock && typeof hooks.releaseLock === 'function') {
|
|
446
|
+
try { await hooks.releaseLock({ appRoot, journalFile: file, lock: acquire }); } catch { /* result already decided */ }
|
|
447
|
+
} else {
|
|
448
|
+
defaultReleaseLock({ lock: acquire });
|
|
449
|
+
}
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
try {
|
|
453
|
+
const stored = readWindowsSupervisorHandoffJournal({ appRoot, journalFile: file });
|
|
454
|
+
if (!stored.ok) return stored;
|
|
455
|
+
|
|
456
|
+
let current = stored.journal ? normalizedJournal(stored.journal) : null;
|
|
457
|
+
let plannedWatcherAlreadyVerified = false;
|
|
458
|
+
let finalReady = null;
|
|
459
|
+
let fresh = current?.old === null;
|
|
460
|
+
|
|
461
|
+
if (current && !sameTarget(current.target, target)) {
|
|
462
|
+
return {
|
|
463
|
+
ok: false,
|
|
464
|
+
code: 'SUPERVISOR_HANDOFF_JOURNAL_IDENTITY_MISMATCH',
|
|
465
|
+
phase: current.phase,
|
|
466
|
+
error: 'durable handoff targets a different helper path or hash',
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const persist = (phase) => {
|
|
471
|
+
current = normalizedJournal({ ...current, phase, updated_at: timestamp(now) });
|
|
472
|
+
const written = writeWindowsSupervisorHandoffJournal({ appRoot, journalFile: file, journal: current });
|
|
473
|
+
if (!written.ok) return written;
|
|
474
|
+
current = normalizedJournal(written.journal);
|
|
475
|
+
return { ok: true };
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
if (!current) {
|
|
479
|
+
const observed = await invokeHook(hooks, 'classifyHeartbeat', { target: normalizedTarget(target) }, 'observe');
|
|
480
|
+
if (observed?.ok !== true) {
|
|
481
|
+
return hookFailure(observed?.code ?? 'SUPERVISOR_HANDOFF_HEARTBEAT_UNAVAILABLE', 'observe', observed, 'heartbeat classification failed');
|
|
482
|
+
}
|
|
483
|
+
let classification = classificationOf(observed);
|
|
484
|
+
|
|
485
|
+
if (classification === 'target-ready') {
|
|
486
|
+
const watcher = targetWatcherFrom(observed, target);
|
|
487
|
+
if (!watcher || !nonEmptyString(observed.runtime_id, 2_048)) {
|
|
488
|
+
return hookFailure('SUPERVISOR_HANDOFF_TARGET_IDENTITY_MISMATCH', 'observe', observed, 'ready watcher does not match the target helper identity');
|
|
489
|
+
}
|
|
490
|
+
const ready = forceRestart ? null : await invokeHook(hooks, 'verifyReady', {
|
|
491
|
+
handoff_id: null,
|
|
492
|
+
target: normalizedTarget(target),
|
|
493
|
+
previous_runtime_id: null,
|
|
494
|
+
}, 'observe');
|
|
495
|
+
const verified = readyContract(ready, target, null);
|
|
496
|
+
if (verified && !forceRestart) {
|
|
497
|
+
return { ok: true, state: 'VERIFIED', idempotent: true, runtime_id: verified.runtime_id, watcher: verified.watcher };
|
|
498
|
+
}
|
|
499
|
+
// The watcher/control generation is current but the owned 3210 may
|
|
500
|
+
// still be running the previously activated payload. Reuse the exact
|
|
501
|
+
// durable handoff transaction to stop/restart that runtime instead of
|
|
502
|
+
// treating a safe, recoverable version skew as terminal.
|
|
503
|
+
classification = 'stale';
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (classification === 'legacy' || classification === 'stale') {
|
|
507
|
+
if (!validWatcher(observed.watcher) || !nonEmptyString(observed.runtime_id, 2_048)) {
|
|
508
|
+
return hookFailure('SUPERVISOR_HANDOFF_OLD_WATCHER_MALFORMED', 'observe', observed, 'legacy watcher identity is incomplete');
|
|
509
|
+
}
|
|
510
|
+
const old = normalizedWatcher(observed.watcher);
|
|
511
|
+
const exact = await invokeHook(hooks, 'verifyExactWatcher', { expected: old, role: 'old' }, 'observe');
|
|
512
|
+
if (!exactWatcherVerified(exact, old)) {
|
|
513
|
+
return hookFailure('SUPERVISOR_HANDOFF_OLD_WATCHER_MISMATCH', 'observe', exact, 'legacy watcher PID/start/hash could not be proven');
|
|
514
|
+
}
|
|
515
|
+
current = {
|
|
516
|
+
schema_version: WINDOWS_SUPERVISOR_HANDOFF_SCHEMA,
|
|
517
|
+
handoff_id: createHandoffId(),
|
|
518
|
+
phase: 'planned',
|
|
519
|
+
lease: createLease(),
|
|
520
|
+
runtime_id: observed.runtime_id,
|
|
521
|
+
old,
|
|
522
|
+
target: normalizedTarget(target),
|
|
523
|
+
updated_at: timestamp(now),
|
|
524
|
+
};
|
|
525
|
+
if (!validateJournal(current)) {
|
|
526
|
+
return { ok: false, code: 'SUPERVISOR_HANDOFF_PLAN_INVALID', error: 'generated handoff plan is invalid' };
|
|
527
|
+
}
|
|
528
|
+
const written = writeWindowsSupervisorHandoffJournal({ appRoot, journalFile: file, journal: current });
|
|
529
|
+
if (!written.ok) return written;
|
|
530
|
+
current = normalizedJournal(written.journal);
|
|
531
|
+
plannedWatcherAlreadyVerified = true;
|
|
532
|
+
} else if (classification === 'absent') {
|
|
533
|
+
const free = await invokeHook(hooks, 'verifyPortFree', { port: 3210 }, 'absent');
|
|
534
|
+
if (!portIsFree(free)) {
|
|
535
|
+
return hookFailure('SUPERVISOR_HANDOFF_PORT_NOT_FREE', 'absent', free, 'watcher is absent but port 3210 is not provably free');
|
|
536
|
+
}
|
|
537
|
+
fresh = true;
|
|
538
|
+
current = {
|
|
539
|
+
schema_version: WINDOWS_SUPERVISOR_HANDOFF_SCHEMA,
|
|
540
|
+
handoff_id: createHandoffId(),
|
|
541
|
+
phase: 'old-watcher-stopped',
|
|
542
|
+
lease: createLease(),
|
|
543
|
+
runtime_id: null,
|
|
544
|
+
old: null,
|
|
545
|
+
target: normalizedTarget(target),
|
|
546
|
+
updated_at: timestamp(now),
|
|
547
|
+
};
|
|
548
|
+
const written = writeWindowsSupervisorHandoffJournal({ appRoot, journalFile: file, journal: current });
|
|
549
|
+
if (!written.ok) return written;
|
|
550
|
+
current = normalizedJournal(written.journal);
|
|
551
|
+
} else {
|
|
552
|
+
return hookFailure('SUPERVISOR_HANDOFF_HEARTBEAT_CONFLICT', 'observe', observed, `unsupported heartbeat classification: ${classification ?? 'missing'}`);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
for (let transitions = 0; transitions < 6; transitions += 1) {
|
|
557
|
+
if (current.phase === 'verified') {
|
|
558
|
+
const cleared = clearWindowsSupervisorHandoffJournal({ appRoot, journalFile: file, handoffId: current.handoff_id });
|
|
559
|
+
if (!cleared.ok) return cleared;
|
|
560
|
+
return {
|
|
561
|
+
ok: true,
|
|
562
|
+
state: 'VERIFIED',
|
|
563
|
+
handoff_id: current.handoff_id,
|
|
564
|
+
resumed: stored.state === 'present',
|
|
565
|
+
...(fresh ? { fresh: true } : {}),
|
|
566
|
+
...(finalReady ? { runtime_id: finalReady.runtime_id, watcher: finalReady.watcher } : {}),
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (current.phase === 'planned') {
|
|
571
|
+
if (!plannedWatcherAlreadyVerified) {
|
|
572
|
+
const exact = await invokeHook(hooks, 'verifyExactWatcher', { expected: current.old, role: 'old' }, current.phase);
|
|
573
|
+
if (!exactWatcherVerified(exact, current.old)) {
|
|
574
|
+
if (exact?.code === 'PROCESS_NOT_FOUND') {
|
|
575
|
+
const observed = await invokeHook(hooks, 'classifyHeartbeat', { target: current.target }, current.phase);
|
|
576
|
+
if (observed?.ok !== true) {
|
|
577
|
+
return hookFailure(observed?.code ?? 'SUPERVISOR_HANDOFF_HEARTBEAT_UNAVAILABLE', current.phase, observed, 'old watcher disappeared and replacement state is unavailable');
|
|
578
|
+
}
|
|
579
|
+
const classification = classificationOf(observed);
|
|
580
|
+
if (classification === 'target-ready') {
|
|
581
|
+
const ready = await invokeHook(hooks, 'verifyReady', {
|
|
582
|
+
handoff_id: current.handoff_id,
|
|
583
|
+
target: current.target,
|
|
584
|
+
previous_runtime_id: current.runtime_id,
|
|
585
|
+
}, current.phase);
|
|
586
|
+
finalReady = readyContract(ready, current.target, current.runtime_id);
|
|
587
|
+
if (!finalReady) {
|
|
588
|
+
return hookFailure('SUPERVISOR_HANDOFF_READY_VERIFY_FAILED', current.phase, ready, 'replacement target watcher/runtime did not pass the strict contract');
|
|
589
|
+
}
|
|
590
|
+
const verified = persist('verified');
|
|
591
|
+
if (!verified.ok) return verified;
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
if (classification === 'target-starting') {
|
|
595
|
+
const targetWatcher = targetWatcherFrom(observed, current.target);
|
|
596
|
+
const targetExact = targetWatcher
|
|
597
|
+
? await invokeHook(hooks, 'verifyExactWatcher', { expected: targetWatcher, role: 'target' }, current.phase)
|
|
598
|
+
: null;
|
|
599
|
+
if (!targetWatcher || !exactWatcherVerified(targetExact, targetWatcher)) {
|
|
600
|
+
return hookFailure('SUPERVISOR_HANDOFF_TARGET_IDENTITY_MISMATCH', current.phase, targetExact ?? observed, 'replacement target watcher identity is not exact');
|
|
601
|
+
}
|
|
602
|
+
const launched = persist('new-watcher-started');
|
|
603
|
+
if (!launched.ok) return launched;
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
if (classification === 'absent') {
|
|
607
|
+
const free = await invokeHook(hooks, 'verifyPortFree', { port: 3210 }, current.phase);
|
|
608
|
+
if (!portIsFree(free)) {
|
|
609
|
+
return hookFailure('SUPERVISOR_HANDOFF_PORT_NOT_FREE', current.phase, free, 'old watcher is absent but port 3210 is not provably free');
|
|
610
|
+
}
|
|
611
|
+
const maintenance = await invokeHook(hooks, 'maintenanceStatus', {
|
|
612
|
+
lease: current.lease,
|
|
613
|
+
runtime_id: current.runtime_id,
|
|
614
|
+
}, current.phase);
|
|
615
|
+
if (maintenance?.ok !== true) {
|
|
616
|
+
return hookFailure(maintenance?.code ?? 'SUPERVISOR_HANDOFF_MAINTENANCE_STATUS_FAILED', current.phase, maintenance, 'maintenance session state is unavailable');
|
|
617
|
+
}
|
|
618
|
+
if (maintenance.state === 'matching'
|
|
619
|
+
&& receiptIdentity(maintenance).lease === current.lease
|
|
620
|
+
&& receiptIdentity(maintenance).runtime_id === current.runtime_id) {
|
|
621
|
+
const stopped = persist('backend-stopped');
|
|
622
|
+
if (!stopped.ok) return stopped;
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
if (maintenance.state === 'absent') {
|
|
626
|
+
current = normalizedJournal({
|
|
627
|
+
...current,
|
|
628
|
+
lease: createLease(),
|
|
629
|
+
runtime_id: null,
|
|
630
|
+
old: null,
|
|
631
|
+
updated_at: timestamp(now),
|
|
632
|
+
});
|
|
633
|
+
fresh = true;
|
|
634
|
+
const absent = persist('old-watcher-stopped');
|
|
635
|
+
if (!absent.ok) return absent;
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
return hookFailure('SUPERVISOR_HANDOFF_MAINTENANCE_CONFLICT', current.phase, maintenance, 'an unrelated maintenance session owns the stopped window');
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
return hookFailure('SUPERVISOR_HANDOFF_OLD_WATCHER_MISMATCH', current.phase, exact, 'planned legacy watcher is no longer exact');
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
plannedWatcherAlreadyVerified = false;
|
|
645
|
+
const stopped = await invokeHook(hooks, 'maintenanceStop', {
|
|
646
|
+
handoff_id: current.handoff_id,
|
|
647
|
+
lease: current.lease,
|
|
648
|
+
runtime_id: current.runtime_id,
|
|
649
|
+
old: current.old,
|
|
650
|
+
target: current.target,
|
|
651
|
+
}, current.phase);
|
|
652
|
+
if (!matchingMaintenanceReceipt(stopped, current, new Set(['STOPPED']))) {
|
|
653
|
+
if (stopped?.code === 'MAINTENANCE_IDENTITY_CHANGED') {
|
|
654
|
+
const rebound = await invokeHook(hooks, 'classifyHeartbeat', { target: current.target }, current.phase);
|
|
655
|
+
const classification = classificationOf(rebound);
|
|
656
|
+
const reboundWatcher = rebound?.watcher ?? rebound?.identity ?? null;
|
|
657
|
+
if (rebound?.ok === true
|
|
658
|
+
&& ['legacy', 'stale', 'target-ready'].includes(classification)
|
|
659
|
+
&& sameWatcher(reboundWatcher, current.old)
|
|
660
|
+
&& nonEmptyString(rebound.runtime_id, 2_048)
|
|
661
|
+
&& rebound.runtime_id !== current.runtime_id) {
|
|
662
|
+
current = normalizedJournal({
|
|
663
|
+
...current,
|
|
664
|
+
lease: createLease(),
|
|
665
|
+
runtime_id: rebound.runtime_id,
|
|
666
|
+
updated_at: timestamp(now),
|
|
667
|
+
});
|
|
668
|
+
const reboundPersisted = writeWindowsSupervisorHandoffJournal({ appRoot, journalFile: file, journal: current });
|
|
669
|
+
if (!reboundPersisted.ok) return reboundPersisted;
|
|
670
|
+
current = normalizedJournal(reboundPersisted.journal);
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
return hookFailure('SUPERVISOR_HANDOFF_STOP_RECEIPT_MISMATCH', current.phase, stopped, 'maintenance stop did not return the durable lease/runtime identity');
|
|
675
|
+
}
|
|
676
|
+
const free = await invokeHook(hooks, 'verifyPortFree', { port: 3210 }, current.phase);
|
|
677
|
+
if (!portIsFree(free)) {
|
|
678
|
+
return hookFailure('SUPERVISOR_HANDOFF_PORT_NOT_FREE', current.phase, free, 'maintenance stop did not leave port 3210 provably free');
|
|
679
|
+
}
|
|
680
|
+
const persisted = persist('backend-stopped');
|
|
681
|
+
if (!persisted.ok) return persisted;
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
if (current.phase === 'backend-stopped') {
|
|
686
|
+
const exact = await invokeHook(hooks, 'verifyExactWatcher', { expected: current.old, role: 'old' }, current.phase);
|
|
687
|
+
if (!exactWatcherVerified(exact, current.old)) {
|
|
688
|
+
if (exact?.code === 'PROCESS_NOT_FOUND') {
|
|
689
|
+
const free = await invokeHook(hooks, 'verifyPortFree', { port: 3210 }, current.phase);
|
|
690
|
+
if (portIsFree(free)) {
|
|
691
|
+
const persisted = persist('old-watcher-stopped');
|
|
692
|
+
if (!persisted.ok) return persisted;
|
|
693
|
+
continue;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
return hookFailure('SUPERVISOR_HANDOFF_OLD_WATCHER_MISMATCH', current.phase, exact, 'backend is stopped but watcher identity changed');
|
|
697
|
+
}
|
|
698
|
+
const stopped = await invokeHook(hooks, 'stopExactWatcher', { expected: current.old }, current.phase);
|
|
699
|
+
if (stopped?.ok !== true || !sameWatcher(stopped.watcher, current.old)) {
|
|
700
|
+
return hookFailure('SUPERVISOR_HANDOFF_WATCHER_STOP_FAILED', current.phase, stopped, 'exact legacy watcher was not stopped');
|
|
701
|
+
}
|
|
702
|
+
const persisted = persist('old-watcher-stopped');
|
|
703
|
+
if (!persisted.ok) return persisted;
|
|
704
|
+
continue;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
if (current.phase === 'old-watcher-stopped') {
|
|
708
|
+
const observed = await invokeHook(hooks, 'classifyHeartbeat', { target: current.target }, current.phase);
|
|
709
|
+
if (observed?.ok !== true) {
|
|
710
|
+
return hookFailure(observed?.code ?? 'SUPERVISOR_HANDOFF_HEARTBEAT_UNAVAILABLE', current.phase, observed, 'could not classify watcher after legacy stop');
|
|
711
|
+
}
|
|
712
|
+
const classification = classificationOf(observed);
|
|
713
|
+
if (classification === 'absent') {
|
|
714
|
+
const free = await invokeHook(hooks, 'verifyPortFree', { port: 3210 }, current.phase);
|
|
715
|
+
if (!portIsFree(free)) {
|
|
716
|
+
return hookFailure('SUPERVISOR_HANDOFF_PORT_NOT_FREE', current.phase, free, 'target launch requires a provably free port');
|
|
717
|
+
}
|
|
718
|
+
const launched = await invokeHook(hooks, 'launchTargetWatcher', { target: current.target }, current.phase);
|
|
719
|
+
if (launched?.ok !== true || !targetWatcherFrom(launched, current.target)) {
|
|
720
|
+
return hookFailure('SUPERVISOR_HANDOFF_TARGET_LAUNCH_FAILED', current.phase, launched, 'launched watcher does not match the target helper hash');
|
|
721
|
+
}
|
|
722
|
+
} else if ((classification === 'target-starting' || classification === 'target-ready') && targetWatcherFrom(observed, current.target)) {
|
|
723
|
+
// A crash may happen after launch but before the phase write. The
|
|
724
|
+
// exact target heartbeat is sufficient to advance without spawning
|
|
725
|
+
// a second watcher.
|
|
726
|
+
} else {
|
|
727
|
+
return hookFailure('SUPERVISOR_HANDOFF_WATCHER_CONFLICT', current.phase, observed, 'an unexpected watcher owns the supervisor slot');
|
|
728
|
+
}
|
|
729
|
+
const persisted = persist('new-watcher-started');
|
|
730
|
+
if (!persisted.ok) return persisted;
|
|
731
|
+
continue;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
if (current.phase === 'new-watcher-started') {
|
|
735
|
+
const observed = await invokeHook(hooks, 'classifyHeartbeat', { target: current.target, expect_target: true }, current.phase);
|
|
736
|
+
if (observed?.ok !== true) {
|
|
737
|
+
return hookFailure(observed?.code ?? 'SUPERVISOR_HANDOFF_HEARTBEAT_UNAVAILABLE', current.phase, observed, 'target watcher heartbeat is unavailable');
|
|
738
|
+
}
|
|
739
|
+
const classification = classificationOf(observed);
|
|
740
|
+
const watcher = targetWatcherFrom(observed, current.target);
|
|
741
|
+
if (!watcher || (classification !== 'target-starting' && classification !== 'target-ready')) {
|
|
742
|
+
return hookFailure('SUPERVISOR_HANDOFF_TARGET_IDENTITY_MISMATCH', current.phase, observed, 'new watcher heartbeat does not match the target helper');
|
|
743
|
+
}
|
|
744
|
+
const exact = await invokeHook(hooks, 'verifyExactWatcher', { expected: watcher, role: 'target' }, current.phase);
|
|
745
|
+
if (!exactWatcherVerified(exact, watcher)) {
|
|
746
|
+
return hookFailure('SUPERVISOR_HANDOFF_TARGET_IDENTITY_MISMATCH', current.phase, exact, 'target watcher PID/start/hash could not be proven');
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
if (current.old !== null && classification !== 'target-ready') {
|
|
750
|
+
const started = await invokeHook(hooks, 'maintenanceStart', {
|
|
751
|
+
handoff_id: current.handoff_id,
|
|
752
|
+
lease: current.lease,
|
|
753
|
+
runtime_id: current.runtime_id,
|
|
754
|
+
target: current.target,
|
|
755
|
+
}, current.phase);
|
|
756
|
+
if (!matchingMaintenanceReceipt(started, current, new Set(['STARTED', 'VERIFIED']))) {
|
|
757
|
+
return hookFailure('SUPERVISOR_HANDOFF_START_RECEIPT_MISMATCH', current.phase, started, 'maintenance start did not preserve the durable lease/runtime identity');
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const ready = await invokeHook(hooks, 'verifyReady', {
|
|
762
|
+
handoff_id: current.handoff_id,
|
|
763
|
+
target: current.target,
|
|
764
|
+
previous_runtime_id: current.runtime_id,
|
|
765
|
+
}, current.phase);
|
|
766
|
+
finalReady = readyContract(ready, current.target, current.runtime_id);
|
|
767
|
+
if (!finalReady) {
|
|
768
|
+
return hookFailure('SUPERVISOR_HANDOFF_READY_VERIFY_FAILED', current.phase, ready, 'target watcher/runtime readiness contract did not verify');
|
|
769
|
+
}
|
|
770
|
+
const persisted = persist('verified');
|
|
771
|
+
if (!persisted.ok) return persisted;
|
|
772
|
+
continue;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
return { ok: false, code: 'SUPERVISOR_HANDOFF_JOURNAL_MALFORMED', phase: current.phase, error: 'unknown durable phase' };
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
return { ok: false, code: 'SUPERVISOR_HANDOFF_TRANSITION_LIMIT', phase: current.phase, error: 'handoff did not converge' };
|
|
779
|
+
} finally {
|
|
780
|
+
await release();
|
|
781
|
+
}
|
|
782
|
+
}
|