@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.
- package/README.md +9 -0
- package/package.json +3 -2
- package/src/commands/pulse/cutover.js +34 -10
- package/src/daemon/main.js +1 -0
- package/src/hub/core/db.mjs +21 -5
- package/src/hub/workers/manager.mjs +44 -6
- package/src/lib/contract.sha256 +1 -1
- package/src/lib/cutover.js +51 -16
- package/src/lib/cutoverRequest.js +485 -0
- package/src/lib/cutoverWiring.js +6 -3
- package/src/lib/daemonProcess.js +43 -6
- package/src/lib/daemonRuntime.js +25 -7
- package/src/lib/hubDbReader.js +6 -0
- package/src/lib/hubScan.js +1 -0
- package/src/lib/ledger.js +21 -12
- package/src/lib/lifecycle.js +3 -1
- package/src/lib/runnerStore.js +61 -49
- package/src/lib/serviceManager.js +3 -1
- package/src/runner/bridge.js +8 -3
- package/src/runner/capture.js +83 -20
- package/src/runner/channelDiscipline.js +48 -14
- package/src/runner/channelWalk.js +42 -0
- package/src/runner/deliveryStates.js +22 -9
- package/src/runner/directoryCache.js +34 -0
- package/src/runner/facade.js +71 -14
- package/src/runner/hubCapture.js +105 -56
- package/src/runner/loopMonitor.js +89 -0
- package/src/runner/runnerHub.js +3 -1
- package/src/runner/runnerProcess.js +58 -16
- package/src/runner/runnerService.js +29 -4
- package/src/runner/serial.js +5 -0
- package/src/runner/spool.js +46 -11
|
@@ -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
|
+
}
|
package/src/lib/cutoverWiring.js
CHANGED
|
@@ -109,14 +109,17 @@ export function portProbes(tokenFor) {
|
|
|
109
109
|
};
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
/**
|
|
113
|
-
|
|
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 }),
|
package/src/lib/daemonProcess.js
CHANGED
|
@@ -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
|
|
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: () =>
|
|
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 {
|