@agentpulselabs/cli-plugin-pulse 1.1.2 → 1.1.5

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.
@@ -0,0 +1,485 @@
1
+ import { randomBytes, randomUUID } from 'node:crypto';
2
+ import { chmod, link, open, rename, rm, unlink } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { setTimeout as sleep } from 'node:timers/promises';
5
+ import { CUTOVER_FILE, STEPS } from './cutover.js';
6
+ import { HubError } from './errors.js';
7
+ import { readOwnerOnlyJson, writeOwnerOnlyJson } from './storage.js';
8
+
9
+ export const CUTOVER_REQUEST_FILE = 'cutover.request';
10
+ /** A request the daemon renamed off the disk to read it; one a crash leaves behind is refused on start, never run. */
11
+ export const CUTOVER_CLAIMED_FILE = 'cutover.request.claimed';
12
+ /** The request protocol a daemon serves, in every heartbeat; a daemon without it gets the cutover run in-process. */
13
+ export const CUTOVER_PROTOCOL = 1;
14
+ export const REQUEST_STALE_MS = 10 * 60_000;
15
+ /** The exit code of a follow that cannot tell how the request ended (timed out, or the daemon stopped). */
16
+ export const FOLLOW_UNKNOWN_EXIT = 2;
17
+ const FUTURE_SLACK_MS = 60_000;
18
+ const HEALTH_FRESH_MS = 60_000;
19
+ const MODES = new Set(['cutover', 'rollback']);
20
+ const SMOKE_CHANNEL = /^[A-Za-z0-9._-]{1,100}\/[A-Za-z0-9._-]{1,100}$/;
21
+ const HISTORY = 50;
22
+ const TICK_MS = 2_000;
23
+ const ACCEPT_MS = 2 * 60_000;
24
+ // The worst case of one request: holders (2 min), the final import (its 60 min wait), the reconcile (10 min), the
25
+ // facade (90 s) and smoke, then the rollback that follows a failure (facade 90 s, spool 2 min, hub 90 s): about 80 min.
26
+ const FOLLOW_MS = 2 * 3_600_000;
27
+ const POLL_MS = 1_000;
28
+
29
+ const iso = (ms) => new Date(ms).toISOString();
30
+ const atOrAfter = (when, since) => Date.parse(when) >= Date.parse(since);
31
+
32
+ function pidAlive(pid) {
33
+ try {
34
+ process.kill(pid, 0);
35
+ return true;
36
+ } catch (error) {
37
+ return error.code === 'EPERM';
38
+ }
39
+ }
40
+
41
+ /** Rollback steps from which the old hub may have hub.db open: a rollback that reached one is never run again. */
42
+ const RELOADING = new Set(['hub_loading', 'hub_loaded', 'hub_serving']);
43
+
44
+ /** The steps of the rollback journaled since `since` (this request's), by name. */
45
+ function rollbackProgress(journal, since) {
46
+ const rollback = journal.rollback;
47
+ return rollback?.startedAt && atOrAfter(rollback.startedAt, since) ? rollback.steps.map((entry) => entry.name) : [];
48
+ }
49
+
50
+ /** The health state a finished request left. */
51
+ function endState(request) {
52
+ const { outcome } = request;
53
+ if (outcome.state === 'done') return request.mode === 'cutover' ? 'serving' : 'rolled_back';
54
+ return outcome.rolledBack ? 'rolled_back' : 'failed';
55
+ }
56
+
57
+ /**
58
+ * The daemon's side of `pulse cutover` (CUT-03). It serves cutover.request on its own timer, beside the heartbeat, so
59
+ * the runner a cutover switches on can start while the cutover waits for it. One request runs at a time, in this
60
+ * process, with the real Cutover from `createCutover({ log })`, and only while `canTake()` (no restart is pending).
61
+ * A request is claimed by renaming it off the disk, checked, then recorded in cutover.json (`request`, and its id in
62
+ * `requests`) before the claimed file is deleted, so a crash never runs it twice; a stale, duplicate, malformed or
63
+ * pointless one is refused into `refusals`. On start, a request a crash cut off is finished as 1.1.2 would after a
64
+ * failure (the 20:05:57 DECISION): a cutover that had begun rolls back, a rollback runs again, unless either had begun
65
+ * loading the old hub (`hub_loading`): restoring hub.db under it would lose its writes, so boot only checks that the old
66
+ * hub holds the port and records rolled_back, or HUB_ROLLBACK_INTERRUPTED for a human. Each boot action is recorded in
67
+ * the journal's `recovery` and in health.
68
+ */
69
+ export class CutoverService {
70
+ constructor({ directory, createCutover, canTake = () => true, now = Date.now, tickMs = TICK_MS, log = () => {} }) {
71
+ this.directory = directory;
72
+ this.createCutover = createCutover;
73
+ this.canTake = canTake;
74
+ this.now = now;
75
+ this.tickMs = tickMs;
76
+ this.log = log;
77
+ this.journalFile = path.join(directory, CUTOVER_FILE);
78
+ this.requestFile = path.join(directory, CUTOVER_REQUEST_FILE);
79
+ this.claimedFile = path.join(directory, CUTOVER_CLAIMED_FILE);
80
+ this.timer = null;
81
+ this.running = null;
82
+ this.recovered = false;
83
+ this.stopped = false;
84
+ this.active = false;
85
+ this.state = null;
86
+ }
87
+
88
+ start() {
89
+ this.stopped = false;
90
+ this.#schedule(0);
91
+ }
92
+
93
+ /** Stops taking requests. A cutover running now is not waited for: one cut off is finished by the next start. */
94
+ async stop() {
95
+ this.stopped = true;
96
+ clearTimeout(this.timer);
97
+ this.timer = null;
98
+ }
99
+
100
+ /** Whether a request is running: the daemon holds any restart until it ends. */
101
+ busy() {
102
+ return this.active;
103
+ }
104
+
105
+ /** `{ state, step, requestId }` for daemon-health.json, or null before any request. */
106
+ health() {
107
+ return this.state;
108
+ }
109
+
110
+ #schedule(waitMs) {
111
+ clearTimeout(this.timer);
112
+ if (this.stopped) return;
113
+ this.timer = setTimeout(() => {
114
+ this.timer = null;
115
+ void this.tick().finally(() => this.#schedule(this.tickMs));
116
+ }, waitMs);
117
+ this.timer.unref?.();
118
+ }
119
+
120
+ /** One pass: finish what a crash cut off (first pass only), then take one request if there is one. Never rejects. */
121
+ tick() {
122
+ this.running ??= this.#pass().finally(() => {
123
+ this.running = null;
124
+ });
125
+ return this.running;
126
+ }
127
+
128
+ async #pass() {
129
+ try {
130
+ if (!this.recovered) {
131
+ this.recovered = true;
132
+ await this.#recover();
133
+ }
134
+ if (this.stopped || !this.canTake()) return;
135
+ try {
136
+ await rename(this.requestFile, this.claimedFile);
137
+ } catch (error) {
138
+ if (error.code === 'ENOENT') return;
139
+ throw error;
140
+ }
141
+ await this.#take();
142
+ } catch (error) {
143
+ this.log('cutover_service_failed', { code: error.code || 'HUB_CUTOVER_SERVICE_FAILED', message: String(error.message).slice(0, 200) });
144
+ }
145
+ }
146
+
147
+ async #journal() {
148
+ return (await readOwnerOnlyJson(this.journalFile)) || { state: 'none', steps: {} };
149
+ }
150
+
151
+ async #claimed() {
152
+ try {
153
+ return { request: await readOwnerOnlyJson(this.claimedFile) };
154
+ } catch (error) {
155
+ return { request: null, unreadable: error.code === 'HUB_INVALID_CONFIG' ? 'not JSON' : `${error.code || 'unreadable'}: ${String(error.message).slice(0, 120)}` };
156
+ }
157
+ }
158
+
159
+ /** Why this request is refused, or null. */
160
+ #refusal(request, journal) {
161
+ if (!request || typeof request !== 'object') return 'invalid: not a JSON object';
162
+ if (typeof request.id !== 'string' || !/^[A-Za-z0-9._:-]{1,100}$/.test(request.id)) return 'invalid: no usable id';
163
+ if (!MODES.has(request.mode)) return `invalid: mode ${JSON.stringify(request.mode ?? null)} is neither cutover nor rollback`;
164
+ if (request.smokeChannel != null && !(typeof request.smokeChannel === 'string' && SMOKE_CHANNEL.test(request.smokeChannel))) {
165
+ return 'invalid: smokeChannel is not <repo>/<task>';
166
+ }
167
+ const at = Date.parse(request.requestedAt);
168
+ if (!Number.isFinite(at)) return 'invalid: no requestedAt';
169
+ if (at - this.now() > FUTURE_SLACK_MS) return `invalid: requestedAt ${request.requestedAt} is in the future`;
170
+ const ageMs = this.now() - at;
171
+ if (ageMs > REQUEST_STALE_MS) return `stale: requested ${Math.round(ageMs / 60_000)} min ago, more than ${REQUEST_STALE_MS / 60_000} min`;
172
+ if (journal.request?.id === request.id || (journal.requests || []).includes(request.id)) return `duplicate: request ${request.id} was already taken`;
173
+ const ended = journal.request?.outcome?.at;
174
+ if (ended && at < Date.parse(ended)) return `arrived while another request ran: requested ${request.requestedAt}, before request ${journal.request.id} ended at ${ended}`;
175
+ if (journal.state === 'running' && journal.runBy === 'cli' && Number.isInteger(journal.pid) && pidAlive(journal.pid)) {
176
+ return `busy: pid ${journal.pid} is running a cutover in-process (started ${journal.startedAt})`;
177
+ }
178
+ if (request.mode === 'rollback') {
179
+ if (journal.state === 'none' || journal.state === 'rolled_back') return `nothing to roll back: cutover.json is ${journal.state}`;
180
+ if ((journal.rollback?.steps || []).some((entry) => RELOADING.has(entry.name))) {
181
+ return 'a rollback already loaded the old hub again: another would restore hub.db under it; check it by hand';
182
+ }
183
+ }
184
+ return null;
185
+ }
186
+
187
+ async #take() {
188
+ const { request, unreadable } = await this.#claimed();
189
+ const journal = await this.#journal();
190
+ const reason = unreadable ? `invalid: ${unreadable}` : this.#refusal(request, journal);
191
+ if (reason) {
192
+ await this.#refuse(request, reason, journal);
193
+ await rm(this.claimedFile, { force: true });
194
+ return;
195
+ }
196
+ // A restart requested while this was read: the request goes back for the next process, or is refused if it can't.
197
+ if (this.stopped || !this.canTake()) {
198
+ const back = await link(this.claimedFile, this.requestFile).then(() => true, () => false);
199
+ if (!back) await this.#refuse(request, 'the Pulse daemon is restarting; ask again once it is back', journal);
200
+ await rm(this.claimedFile, { force: true });
201
+ return;
202
+ }
203
+ this.active = true;
204
+ try {
205
+ const accepted = {
206
+ id: request.id, mode: request.mode, requestedAt: request.requestedAt, requestedBy: request.requestedBy ?? null,
207
+ ...(request.mode === 'cutover' && request.smokeChannel ? { smokeChannel: request.smokeChannel } : {}),
208
+ acceptedAt: iso(this.now()),
209
+ };
210
+ await writeOwnerOnlyJson(this.journalFile, { ...journal, request: accepted, requests: [...(journal.requests || []), accepted.id].slice(-HISTORY) });
211
+ await rm(this.claimedFile, { force: true });
212
+ this.log('cutover_request_accepted', { id: accepted.id, mode: accepted.mode });
213
+ await this.#perform(accepted, accepted.mode);
214
+ } finally {
215
+ this.active = false;
216
+ }
217
+ }
218
+
219
+ async #refuse(request, reason, journal) {
220
+ const refusal = {
221
+ id: typeof request?.id === 'string' ? request.id : null, mode: request?.mode ?? null, requestedAt: request?.requestedAt ?? null,
222
+ reason, at: iso(this.now()),
223
+ };
224
+ await writeOwnerOnlyJson(this.journalFile, { ...journal, refusals: [...(journal.refusals || []), refusal].slice(-HISTORY) });
225
+ this.state = { state: 'refused', requestId: refusal.id, reason };
226
+ this.log('cutover_request_refused', { id: refusal.id, reason });
227
+ }
228
+
229
+ /**
230
+ * Runs `action` (cutover or rollback) for an accepted request and records its outcome on the journal. `asOutcome`
231
+ * is what a rollback that finishes a cut-off cutover reports, rolled back, instead of a plain done; `recovered` names
232
+ * the boot action that started it, for health.
233
+ */
234
+ async #perform(request, action, { asOutcome = null, recovered = null } = {}) {
235
+ const tag = recovered ? { recovered } : {};
236
+ this.state = { state: 'running', mode: action, step: null, requestId: request.id, since: iso(this.now()), ...tag };
237
+ const log = (event, details = {}) => {
238
+ if (event === 'cutover_step') this.state = { ...this.state, step: details.step };
239
+ if (event === 'rollback_step') this.state = { ...this.state, phase: 'rollback', step: details.name };
240
+ this.log(event, details);
241
+ };
242
+ // Health says how it ended before the journal does, so whoever sees the outcome sees the same in health.
243
+ try {
244
+ const cutover = await this.createCutover({ log });
245
+ if (action === 'cutover') {
246
+ await cutover.run({ smokeChannel: request.smokeChannel ?? null, request });
247
+ this.state = { state: 'serving', step: this.state.step, requestId: request.id, ...tag };
248
+ await this.#finish(request.id, { state: 'done' });
249
+ return;
250
+ }
251
+ const report = await cutover.rollback({ reason: asOutcome?.code ?? 'asked' });
252
+ this.state = { state: 'rolled_back', step: this.state.step, requestId: request.id, ...tag };
253
+ await this.#finish(request.id, asOutcome ? { ...asOutcome, rolledBack: true } : { state: 'done', message: report.message });
254
+ } catch (error) {
255
+ const code = error.code || 'HUB_CUTOVER_FAILED';
256
+ this.state = { state: error.rollback ? 'rolled_back' : 'failed', step: this.state.step, requestId: request.id, code, ...tag };
257
+ this.log('cutover_request_failed', { id: request.id, code });
258
+ await this.#finish(request.id, {
259
+ state: 'failed', code, message: String(error.message).slice(0, 500), rolledBack: Boolean(error.rollback),
260
+ ...(error.details?.checks ? { checks: error.details.checks } : {}),
261
+ });
262
+ }
263
+ }
264
+
265
+ /** Records the request's outcome on the journal, with `extra` journal fields (a boot recovery's). */
266
+ async #finish(id, outcome, extra = {}) {
267
+ const journal = await this.#journal();
268
+ const request = journal.request?.id === id ? journal.request : { id };
269
+ await writeOwnerOnlyJson(this.journalFile, { ...journal, ...extra, request: { ...request, outcome: { ...outcome, at: iso(this.now()) } } });
270
+ }
271
+
272
+ /** A boot recovery that ends the request without running anything: the outcome and what boot did, in both files. */
273
+ async #recovered(request, action, outcome, state, extra = {}) {
274
+ const recovery = { at: iso(this.now()), requestId: request.id, action };
275
+ this.state = { state, requestId: request.id, recovered: action, ...(outcome.code ? { code: outcome.code } : {}) };
276
+ this.log('cutover_recovered', { id: request.id, action });
277
+ await this.#finish(request.id, outcome, { ...extra, recovery });
278
+ }
279
+
280
+ /** On start: a claim a crash left is refused; a request that was running is finished (see the class). */
281
+ async #recover() {
282
+ const claimed = await this.#claimed();
283
+ if (claimed.request || claimed.unreadable) {
284
+ const journal = await this.#journal();
285
+ if (!(claimed.request && (journal.requests || []).includes(claimed.request.id))) {
286
+ await this.#refuse(claimed.request, 'interrupted: the daemon stopped while it was reading this request', journal);
287
+ }
288
+ await rm(this.claimedFile, { force: true });
289
+ }
290
+ const journal = await this.#journal();
291
+ const request = journal.request;
292
+ if (!request?.id) return;
293
+ if (request.outcome) {
294
+ this.state = { state: endState(request), requestId: request.id, ...(request.outcome.code ? { code: request.outcome.code } : {}) };
295
+ return;
296
+ }
297
+ const since = request.acceptedAt;
298
+ const began = Boolean(journal.startedAt) && atOrAfter(journal.startedAt, since);
299
+ const rolledBack = journal.state === 'rolled_back' && atOrAfter(journal.rolledBackAt, since);
300
+ const reloading = rollbackProgress(journal, since).some((name) => RELOADING.has(name));
301
+ const failure = journal.failure ?? { code: 'HUB_CUTOVER_INTERRUPTED', message: 'The Pulse daemon stopped while it ran this cutover.' };
302
+ // What a cutover that ended rolled back reports; a rollback request that ended so is simply done.
303
+ const rolledBackOutcome = (message) => (request.mode === 'rollback'
304
+ ? { state: 'done', message }
305
+ : { state: 'failed', code: failure.code, message: failure.message, rolledBack: true });
306
+ this.active = true;
307
+ try {
308
+ if (request.mode === 'cutover' && journal.state === 'done' && began) return await this.#recovered(request, 'finished_before_stop', { state: 'done' }, 'serving');
309
+ if (rolledBack) return await this.#recovered(request, 'rolled_back_before_stop', rolledBackOutcome(journal.rollback?.message ?? null), 'rolled_back');
310
+ if (request.mode === 'cutover' && !(journal.state === 'running' && began)) {
311
+ return await this.#recovered(request, 'never_began', {
312
+ state: 'failed', code: 'HUB_CUTOVER_INTERRUPTED', message: 'The Pulse daemon stopped before this cutover began; nothing was changed.', rolledBack: false,
313
+ }, 'failed');
314
+ }
315
+ if (reloading) return await this.#verifyReloaded(request, journal, rolledBackOutcome);
316
+ if (request.mode === 'cutover') {
317
+ const cutFailure = journal.failure ?? {
318
+ step: STEPS.find((name) => !journal.steps?.[name]) ?? null, code: 'HUB_CUTOVER_INTERRUPTED',
319
+ message: 'The Pulse daemon stopped while it ran this cutover; it rolled back on start.',
320
+ };
321
+ const recovery = { at: iso(this.now()), requestId: request.id, action: 'rolled_back_on_start' };
322
+ await writeOwnerOnlyJson(this.journalFile, { ...journal, failure: cutFailure, recovery });
323
+ this.log('cutover_interrupted', { id: request.id, mode: 'cutover', step: cutFailure.step });
324
+ return await this.#perform(request, 'rollback', { asOutcome: { state: 'failed', code: 'HUB_CUTOVER_INTERRUPTED', message: cutFailure.message }, recovered: 'rolled_back_on_start' });
325
+ }
326
+ await writeOwnerOnlyJson(this.journalFile, { ...journal, recovery: { at: iso(this.now()), requestId: request.id, action: 'rollback_run_again' } });
327
+ this.log('cutover_interrupted', { id: request.id, mode: 'rollback' });
328
+ return await this.#perform(request, 'rollback', { recovered: 'rollback_run_again' });
329
+ } finally {
330
+ this.active = false;
331
+ }
332
+ }
333
+
334
+ /**
335
+ * The 20:05:57 DECISION: a rollback that had begun loading the old hub is never run again (it would restore hub.db
336
+ * under the running hub). Boot only checks that the old hub holds the port, and records rolled_back if it does.
337
+ */
338
+ async #verifyReloaded(request, journal, rolledBackOutcome) {
339
+ this.state = { state: 'running', mode: 'verify', step: 'hub_loading', requestId: request.id, since: iso(this.now()) };
340
+ let onPort;
341
+ try {
342
+ onPort = await (await this.createCutover({ log: this.log })).verifyRolledBack();
343
+ } catch (error) {
344
+ onPort = { ok: false, detail: `the check failed: ${error.code || 'error'}: ${String(error.message).slice(0, 200)}` };
345
+ }
346
+ if (onPort.ok) {
347
+ const at = iso(this.now());
348
+ const message = `Rolled back to the local hub: the Pulse daemon restarted after the old hub was loaded again, and found it on the port (${onPort.detail}).`;
349
+ return this.#recovered(request, 'verified_old_hub', rolledBackOutcome(message), 'rolled_back', {
350
+ state: 'rolled_back', rolledBackAt: at, rollback: { ...journal.rollback, recoveredOnBoot: true, verified: onPort.detail, message },
351
+ });
352
+ }
353
+ return this.#recovered(request, 'old_hub_not_serving', {
354
+ state: 'failed', code: 'HUB_ROLLBACK_INTERRUPTED', rolledBack: false,
355
+ message: `The Pulse daemon stopped after its rollback began loading the old hub, and the old hub does not hold the port now (${onPort.detail}). It is not rolled back twice, which would restore hub.db under a hub that may have it open: check the hub by hand.`,
356
+ }, 'failed');
357
+ }
358
+ }
359
+
360
+ /** Whether the daemon behind this health file is alive and serves cutover.request. */
361
+ export function takesCutoverRequests(health, now = Date.now()) {
362
+ const ageMs = now - Date.parse(health?.heartbeatAt);
363
+ return ageMs <= HEALTH_FRESH_MS && Number(health?.cutoverProtocol) >= CUTOVER_PROTOCOL;
364
+ }
365
+
366
+ /** Creates `file` owner-only with `value`, whole or not at all, and only if it does not exist yet. */
367
+ async function createExclusive(file, value) {
368
+ const temporary = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`);
369
+ const handle = await open(temporary, 'wx', 0o600);
370
+ try {
371
+ await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, 'utf8');
372
+ await handle.sync();
373
+ } finally {
374
+ await handle.close();
375
+ }
376
+ try {
377
+ await chmod(temporary, 0o600);
378
+ await link(temporary, file);
379
+ return true;
380
+ } catch (error) {
381
+ if (error.code === 'EEXIST') return false;
382
+ throw error;
383
+ } finally {
384
+ await rm(temporary, { force: true });
385
+ }
386
+ }
387
+
388
+ /** Writes an owner-only cutover.request for the daemon, exclusively: one request at a time. */
389
+ export async function writeCutoverRequest({ configDir, mode, smokeChannel = null, health = null, now = Date.now, id = randomUUID(), requestedBy = null }) {
390
+ const pending = (detail) => new HubError('HUB_CUTOVER_PENDING', `Another cutover request is ${detail}; wait for it to finish (see ${path.join(configDir, CUTOVER_FILE)}).`);
391
+ if (health?.cutover?.state === 'running') throw pending(`running in the Pulse daemon (${health.cutover.requestId}${health.cutover.step ? `, at ${health.cutover.step}` : ''})`);
392
+ const request = {
393
+ id, mode, requestedAt: iso(now()),
394
+ requestedBy: requestedBy ?? { pid: process.pid, session: process.env.CURSOR_CONVERSATION_ID || null },
395
+ ...(smokeChannel ? { smokeChannel } : {}),
396
+ };
397
+ if (!(await createExclusive(path.join(configDir, CUTOVER_REQUEST_FILE), request))) throw pending('waiting for the Pulse daemon');
398
+ return request;
399
+ }
400
+
401
+ /**
402
+ * Follows a request through cutover.json, printing each step as `log(event, details)` exactly as an in-process run
403
+ * logs it. It resolves with the journal (a cutover) or the rollback report, or throws: the journal's code for a run
404
+ * that failed (or rolled back), HUB_CUTOVER_REFUSED, or HUB_CUTOVER_NOT_ACCEPTED when nobody took it within
405
+ * `acceptMs` (it is withdrawn). It ends without an outcome, with `exit` FOLLOW_UNKNOWN_EXIT, when the daemon's heartbeat
406
+ * goes stale (it finishes the request when it starts again) or after `timeoutMs`, the worst case of a run.
407
+ */
408
+ export async function followCutoverRequest({
409
+ configDir, request, log = () => {}, now = Date.now, wait = sleep, pollMs = POLL_MS, acceptMs = ACCEPT_MS, timeoutMs = FOLLOW_MS,
410
+ healthFreshMs = HEALTH_FRESH_MS,
411
+ }) {
412
+ const journalFile = path.join(configDir, CUTOVER_FILE);
413
+ const requestFile = path.join(configDir, CUTOVER_REQUEST_FILE);
414
+ const healthFile = path.join(configDir, 'daemon-health.json');
415
+ const started = now();
416
+ const printed = { steps: new Set(), failure: false, rollback: 0 };
417
+ let withDaemon = false;
418
+ const print = (journal) => {
419
+ const since = journal.request.acceptedAt;
420
+ if (request.mode === 'cutover' && journal.startedAt && atOrAfter(journal.startedAt, since)) {
421
+ for (const name of STEPS) {
422
+ if (!journal.steps?.[name] || printed.steps.has(name)) continue;
423
+ printed.steps.add(name);
424
+ const { at: _at, ...detail } = journal.steps[name];
425
+ log('cutover_step', { step: name, ...detail });
426
+ }
427
+ if (journal.failure && !printed.failure) {
428
+ printed.failure = true;
429
+ log('cutover_failed', journal.failure);
430
+ }
431
+ }
432
+ if (rollbackProgress(journal, since).length) {
433
+ for (const entry of journal.rollback.steps.slice(printed.rollback)) log('rollback_step', entry);
434
+ printed.rollback = journal.rollback.steps.length;
435
+ }
436
+ };
437
+ const unknown = (code, message) => new HubError(code, message, { exit: FOLLOW_UNKNOWN_EXIT });
438
+ for (;;) {
439
+ const journal = await readOwnerOnlyJson(journalFile).catch(() => null);
440
+ const refused = journal?.refusals?.find((entry) => entry.id === request.id);
441
+ if (refused) throw new HubError('HUB_CUTOVER_REFUSED', `The Pulse daemon refused request ${request.id}: ${refused.reason}`, { details: { refused } });
442
+ const health = await readOwnerOnlyJson(healthFile).catch(() => null);
443
+ // A long synchronous stretch (a rescan) can delay a heartbeat; a daemon whose pid is alive gets three windows.
444
+ const ageMs = now() - Date.parse(health?.heartbeatAt);
445
+ const stale = !(ageMs <= healthFreshMs) && (!(ageMs <= 3 * healthFreshMs) || !(Number.isInteger(health?.pid) && pidAlive(health.pid)));
446
+ if (journal?.request?.id === request.id) {
447
+ print(journal);
448
+ const outcome = journal.request.outcome;
449
+ if (outcome?.state === 'done') return request.mode === 'cutover' ? journal : journal.rollback;
450
+ if (outcome) {
451
+ throw Object.assign(
452
+ new HubError(outcome.code || 'HUB_CUTOVER_FAILED', outcome.message || 'the cutover failed', { details: { ...(outcome.checks ? { checks: outcome.checks } : {}), rolledBack: Boolean(outcome.rolledBack) } }),
453
+ outcome.rolledBack ? { rollback: journal.rollback ?? null } : {},
454
+ );
455
+ }
456
+ if (stale) {
457
+ throw unknown('HUB_CUTOVER_DAEMON_STOPPED', `The Pulse daemon stopped (last heartbeat ${health?.heartbeatAt ?? 'never'}) while it ran request ${request.id}. When it starts again it finishes the request (a cutover that had begun rolls back); follow it in ${journalFile}.`);
458
+ }
459
+ } else if (!withDaemon && (stale || now() - started >= acceptMs)) {
460
+ // Withdrawn only while it is still ours on the disk: gone means the daemon claimed it, so it is followed.
461
+ const pending = await readOwnerOnlyJson(requestFile).catch(() => null);
462
+ let withdrawn = false;
463
+ if (pending?.id === request.id) {
464
+ try {
465
+ await unlink(requestFile);
466
+ withdrawn = true;
467
+ } catch (error) {
468
+ if (error.code !== 'ENOENT') throw error;
469
+ }
470
+ }
471
+ if (withdrawn) {
472
+ throw new HubError('HUB_CUTOVER_NOT_ACCEPTED', stale
473
+ ? `The Pulse daemon is not running (last heartbeat ${health?.heartbeatAt ?? 'never'}), so request ${request.id} was withdrawn. Start it with \`agentpulse pulse start\`.`
474
+ : `The Pulse daemon did not take request ${request.id} within ${Math.round(acceptMs / 1000)} s, so it was withdrawn. Check it with \`agentpulse pulse status\`.`);
475
+ }
476
+ withDaemon = true;
477
+ } else if (withDaemon && stale) {
478
+ throw unknown('HUB_CUTOVER_DAEMON_STOPPED', `The Pulse daemon stopped (last heartbeat ${health?.heartbeatAt ?? 'never'}) after it claimed request ${request.id}; when it starts again it refuses or finishes it. Follow it in ${journalFile}.`);
479
+ }
480
+ if (now() - started >= timeoutMs) {
481
+ throw unknown('HUB_CUTOVER_FOLLOW_TIMEOUT', `Request ${request.id} is still running in the Pulse daemon after ${Math.round(timeoutMs / 60_000)} min; follow it in ${journalFile} or with \`agentpulse pulse status\`.`);
482
+ }
483
+ await wait(pollMs);
484
+ }
485
+ }
@@ -109,14 +109,17 @@ export function portProbes(tokenFor) {
109
109
  };
110
110
  }
111
111
 
112
- /** The production seams of `pulse cutover`: launchd, lsof, the Phase 2 import and reconcile, and the local port. */
113
- export async function createCutover({ configDir, log = () => {}, port = 8890 }) {
112
+ /**
113
+ * The production seams of `pulse cutover`: launchd, lsof, the Phase 2 import and reconcile, and the local port.
114
+ * `serve` is the daemon's own runner switch when the daemon runs it (CUT-03); in-process it is left to the heartbeat.
115
+ */
116
+ export async function createCutover({ configDir, log = () => {}, port = 8890, serve = undefined }) {
114
117
  const config = await readOwnerOnlyJson(path.join(configDir, 'config.json'));
115
118
  if (!config) throw new HubError('HUB_NOT_INSTALLED', 'Pulse is not installed here; run `agentpulse pulse install`');
116
119
  const hubHome = config.cloudFirst?.hubHome || config.mirror?.hub?.dir || (await discoverLocalHub({ config })).dir;
117
120
  const tokenFor = () => hubToken(hubHome);
118
121
  return new Cutover({
119
- configDir, hubHome, port, log,
122
+ configDir, hubHome, port, log, serve,
120
123
  services: new ServiceManager({}),
121
124
  probe: { holders: (files) => lsofHolders(files), portOwners: (p) => lsofPortOwners(p) },
122
125
  cloudFirst: new CloudFirstCommands({ configDir }),
@@ -1,4 +1,6 @@
1
1
  import path from 'node:path';
2
+ import { CUTOVER_PROTOCOL, CutoverService } from './cutoverRequest.js';
3
+ import { createCutover } from './cutoverWiring.js';
2
4
  import { createProductionDependencies } from './daemonComposition.js';
3
5
  import { DaemonLock } from './daemonLock.js';
4
6
  import { createHeartbeat, MIRROR_UPDATE_STEP, TERMINAL_STATES } from './daemonLoop.js';
@@ -60,7 +62,8 @@ const inside = (child, parent) => {
60
62
  * - start_failed: it could not start while it was holding restarts back.
61
63
  * A code or request restart drains (no new claim; running jobs finish and settle) for up to drainMs; a stall aborts at
62
64
  * once. Every trigger goes through one check that runs each tickMs. While the daemon has restarted too often it holds
63
- * the restart (health says restart_loop) until the window clears. SIGTERM always wins and exits 0.
65
+ * the restart (health says restart_loop) until the window clears, and while it runs a cutover request (CUT-03) it holds
66
+ * it until the cutover ends, whose own deadlines bound it. SIGTERM always wins and exits 0.
64
67
  */
65
68
  export async function runDaemon({
66
69
  directory,
@@ -74,6 +77,8 @@ export async function runDaemon({
74
77
  createMirror = (options) => new MirrorController(options),
75
78
  createShadow = (options) => new ShadowService(options),
76
79
  createRunner = (options) => new RunnerService(options),
80
+ createCutoverService = (options) => new CutoverService(options),
81
+ cutoverFactory = createCutover,
77
82
  dependencyOptions = {},
78
83
  runtimeOptions = {},
79
84
  lockOptions = {},
@@ -85,7 +90,7 @@ export async function runDaemon({
85
90
  const lock = new DaemonLock({ directory, ...lockOptions });
86
91
  await lock.acquire();
87
92
  // The first cycle (token exchange, reconnect, claim) can outlast `pulse start`'s wait; say we are up before it.
88
- await lock.heartbeat({ state: 'starting' });
93
+ await lock.heartbeat({ state: 'starting', cutoverProtocol: CUTOVER_PROTOCOL });
89
94
  let startingHealthAt = Date.now();
90
95
  const guard = new RestartGuard({ directory, now });
91
96
  await guard.recordStart();
@@ -99,6 +104,11 @@ export async function runDaemon({
99
104
  let runtime = null;
100
105
  let shadow = null;
101
106
  let runnerService = null;
107
+ let cutoverService = null;
108
+ let cutoverDeferralLogged = false;
109
+ // Every read of config.json is numbered, so the runner never applies one older than it already has (CUT-03).
110
+ let configGeneration = 0;
111
+ let heartbeatGeneration = 0;
102
112
  let stopServices = async () => {};
103
113
  let timer = null;
104
114
  let watcher = null;
@@ -207,6 +217,14 @@ export async function runDaemon({
207
217
  const requestedAt = await consumeRestartRequest(directory);
208
218
  if (requestedAt !== null && requestedAt < bootedAt) log('restart_request_satisfied', { requestedAt: new Date(requestedAt).toISOString() });
209
219
  else if (requestedAt !== null) pending ??= { reason: 'request' };
220
+ if (pending && cutoverService?.busy()) {
221
+ if (!cutoverDeferralLogged) {
222
+ cutoverDeferralLogged = true;
223
+ log('restart_deferred', { reason: pending.reason, cutover: cutoverService.health()?.requestId ?? null });
224
+ }
225
+ return;
226
+ }
227
+ cutoverDeferralLogged = false;
210
228
  if (pending) restart(pending.reason);
211
229
  } catch (error) {
212
230
  log('restart_check_failed', { code: error.code || 'HUB_RESTART_CHECK_FAILED' });
@@ -217,11 +235,15 @@ export async function runDaemon({
217
235
 
218
236
  const extraHealth = () => {
219
237
  const loop = degraded();
238
+ const cutover = cutoverService?.health();
220
239
  return {
221
240
  ...(loop ? { degraded: loop } : {}),
222
241
  ...(restarting ? { restarting: { ...restarting, draining: runtime?.activeJobIds() ?? [] } } : {}),
242
+ ...(pending && cutoverService?.busy() ? { restartDeferred: { reason: pending.reason, untilCutoverEnds: cutover?.requestId ?? null } } : {}),
223
243
  ...(shadow ? { shadow: shadow.health() } : {}),
224
244
  ...(runnerService ? { runner: runnerService.health() } : {}),
245
+ cutoverProtocol: CUTOVER_PROTOCOL,
246
+ ...(cutover ? { cutover } : {}),
225
247
  };
226
248
  };
227
249
 
@@ -325,19 +347,34 @@ export async function runDaemon({
325
347
  shadow = createShadow({ directory, transportFactory: production.transportFactory, tokenStore: production.tokenStore, log: mirrorLog });
326
348
  runnerService = createRunner({ directory, transportFactory: production.transportFactory, log });
327
349
  heartbeat = createHeartbeat({
328
- readConfig: () => readOwnerOnlyJson(configFile),
350
+ readConfig: () => {
351
+ heartbeatGeneration = ++configGeneration;
352
+ return readOwnerOnlyJson(configFile);
353
+ },
329
354
  runtime,
330
355
  mirror,
331
356
  shadow,
332
- runner: runnerService,
357
+ runner: { update: (config) => runnerService.update(config, { generation: heartbeatGeneration }) },
333
358
  lock,
334
359
  ledger,
335
360
  ownerStore,
336
361
  progress: cycleProgress,
337
362
  extraHealth,
338
363
  });
364
+ // A cutover request runs here, beside the heartbeat; its facade step starts this daemon's runner at once.
365
+ const serve = async () => {
366
+ const generation = ++configGeneration;
367
+ runnerService.update(await readOwnerOnlyJson(configFile), { generation });
368
+ };
369
+ cutoverService = createCutoverService({
370
+ directory,
371
+ log,
372
+ canTake: () => !exiting && !restarting && !pending,
373
+ createCutover: ({ log: stepLog }) => cutoverFactory({ configDir: directory, log: stepLog, serve }),
374
+ });
375
+ cutoverService.start();
339
376
  stopServices = async ({ abortCode }) => {
340
- const results = await Promise.allSettled([mirror.stop(), shadow.stop(), runnerService.stop(), runtime.shutdown({ abortCode })]);
377
+ const results = await Promise.allSettled([cutoverService.stop(), mirror.stop(), shadow.stop(), runnerService.stop(), runtime.shutdown({ abortCode })]);
341
378
  const failed = results.find((result) => result.status === 'rejected');
342
379
  if (failed) throw failed.reason;
343
380
  };
@@ -367,7 +404,7 @@ export async function runDaemon({
367
404
  // A cycle that ends after shutdown released the lock has no health left to report.
368
405
  if (exiting) return;
369
406
  await Promise.resolve()
370
- .then(() => lock.heartbeat({ state: 'error', code: error.code || 'HUB_DAEMON_LOOP_FAILED' }))
407
+ .then(() => lock.heartbeat({ state: 'error', code: error.code || 'HUB_DAEMON_LOOP_FAILED', ...extraHealth() }))
371
408
  .catch(() => {});
372
409
  wait = t.errorRetryMs;
373
410
  } finally {