@celilo/cli 0.18.0 → 0.20.0

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.
Files changed (34) hide show
  1. package/CELILO_SUBSYSTEMS.md +4 -2
  2. package/package.json +4 -4
  3. package/src/api/remote-client.test.ts +86 -2
  4. package/src/api/serve.ts +242 -38
  5. package/src/api/sessions.test.ts +196 -0
  6. package/src/api/sessions.ts +278 -0
  7. package/src/cli/commands/apt-upgrade.test.ts +20 -1
  8. package/src/cli/commands/apt-upgrade.ts +12 -2
  9. package/src/cli/commands/backup-sweep.ts +25 -9
  10. package/src/cli/commands/events.ts +150 -4
  11. package/src/cli/commands/module-update.test.ts +72 -1
  12. package/src/cli/commands/module-update.ts +68 -22
  13. package/src/cli/commands/system-migrate.test.ts +56 -0
  14. package/src/cli/commands/system-migrate.ts +52 -4
  15. package/src/cli/completion.ts +2 -0
  16. package/src/cli/index.ts +27 -3
  17. package/src/db/migration-status.test.ts +114 -0
  18. package/src/db/migration-status.ts +78 -0
  19. package/src/db/schema-introspection.ts +8 -1
  20. package/src/services/backup-metadata.ts +17 -0
  21. package/src/services/backup-staging.test.ts +98 -0
  22. package/src/services/backup-staging.ts +73 -1
  23. package/src/services/backup-sweep.test.ts +15 -0
  24. package/src/services/backup-sweep.ts +17 -1
  25. package/src/services/bus-interview-park.test.ts +179 -0
  26. package/src/services/bus-interview.ts +17 -6
  27. package/src/services/events-daemon.test.ts +244 -0
  28. package/src/services/events-daemon.ts +295 -8
  29. package/src/services/fleet-checks.test.ts +75 -4
  30. package/src/services/fleet-checks.ts +82 -12
  31. package/src/services/interview-errors.ts +37 -0
  32. package/src/services/remote-responder.test.ts +83 -0
  33. package/src/services/remote-responder.ts +31 -10
  34. package/src/services/responder-probe.ts +3 -1
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Does the reaper actually fire, and does the command say the right thing?
3
+ *
4
+ * A parked session holds a live child process and possibly a module-operation
5
+ * lock. The TTL reaper is the only thing standing between that and the 20-day
6
+ * lock outage `module operations` was built for — so a reaper nobody has watched
7
+ * fire is decorative. This drives a real `module update` sweep into a park,
8
+ * expires its session deliberately, and asserts on what comes back.
9
+ *
10
+ * What it asserts is the *value*, not liveness. Three outcomes are confusable
11
+ * here and only one is correct:
12
+ *
13
+ * declined — someone said no. (`{value: false}`)
14
+ * unanswered — nobody was listening at all. (responder-probe throw)
15
+ * abandoned — the question stood; the deadline passed; nobody decided.
16
+ *
17
+ * Reporting an abandonment as "operator declined" is the original celilo#609
18
+ * bug wearing a different hat, so that string is asserted absent.
19
+ *
20
+ * WATCHED RED: with `abandonSession` emitting `{value: false}` instead of
21
+ * `{abandoned}` — i.e. a reaper that resolves the question the old way — this
22
+ * file fails on `expect(report).not.toContain('operator declined')`. With the
23
+ * reaper removed entirely it fails by timeout, the sweep never resuming.
24
+ */
25
+
26
+ import { afterEach, beforeEach, expect, test } from 'bun:test';
27
+ import { mkdtempSync, rmSync } from 'node:fs';
28
+ import { tmpdir } from 'node:os';
29
+ import { join } from 'node:path';
30
+ import { type Bus, type BusEvent, defineEvents, openBus } from '@celilo/event-bus';
31
+ import { handleModuleUpdate } from '../cli/commands/module-update';
32
+ import { getDb } from '../db/client';
33
+ import { modules } from '../db/schema';
34
+ import { RESPONDER_PROBE_EVENT } from '../services/responder-probe';
35
+ import { SessionWriter, listSessions, reapExpiredSessions } from './sessions';
36
+
37
+ const NO_SCHEMAS = defineEvents({});
38
+ const QUERY_TYPE = 'interview.required.module-upgrade:iptables.apply_breaking';
39
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
40
+
41
+ let dir: string;
42
+ let busDbPath: string;
43
+ let server: ReturnType<typeof Bun.serve>;
44
+ let registryUrl: string;
45
+
46
+ /** A responder that proves liveness but never answers the question. */
47
+ function probeOnlyResponder(dbPath: string): { bus: Bus; close: () => void } {
48
+ const bus = openBus({ dbPath, events: NO_SCHEMAS });
49
+ const watch = bus.watch(RESPONDER_PROBE_EVENT, async (event) => {
50
+ if (event.replyFor !== null) return;
51
+ bus.emitRaw(
52
+ `${event.type}.reply`,
53
+ { kind: 'daemon', emittedBy: 'reaper-test' },
54
+ { replyFor: event.id, emittedBy: 'reaper-test' },
55
+ );
56
+ });
57
+ return {
58
+ bus,
59
+ close: () => {
60
+ watch.close();
61
+ bus.close();
62
+ },
63
+ };
64
+ }
65
+
66
+ beforeEach(() => {
67
+ dir = mkdtempSync(join(tmpdir(), 'celilo-reap-'));
68
+ busDbPath = join(dir, 'events.db');
69
+ process.env.CELILO_DB_PATH = join(dir, 'test.db');
70
+ process.env.CELILO_DATA_DIR = dir;
71
+ process.env.CELILO_ORIGINAL_CWD = dir;
72
+ process.env.EVENT_BUS_DB = busDbPath;
73
+
74
+ getDb()
75
+ .insert(modules)
76
+ .values({
77
+ id: 'iptables',
78
+ name: 'iptables',
79
+ sourcePath: join(dir, 'installed'),
80
+ version: '1.0.2+9',
81
+ manifestData: { celilo_contract: '1.0', id: 'iptables', name: 'iptables', version: '1.0.2' },
82
+ })
83
+ .run();
84
+
85
+ server = Bun.serve({
86
+ port: 0,
87
+ fetch(req) {
88
+ if (new URL(req.url).pathname === '/index/ip/ta/iptables') {
89
+ return new Response(
90
+ `${JSON.stringify({ name: 'iptables', vers: '2.0.0+1', deps: [], cksum: 'x' })}\n`,
91
+ );
92
+ }
93
+ return new Response('not found', { status: 404 });
94
+ },
95
+ });
96
+ registryUrl = `http://localhost:${server.port}`;
97
+ });
98
+
99
+ afterEach(() => {
100
+ server.stop(true);
101
+ rmSync(dir, { recursive: true, force: true });
102
+ process.env.CELILO_DB_PATH = undefined;
103
+ process.env.CELILO_DATA_DIR = undefined;
104
+ process.env.CELILO_ORIGINAL_CWD = undefined;
105
+ process.env.EVENT_BUS_DB = undefined;
106
+ });
107
+
108
+ test('an expired session is reaped and the command reports abandoned, not declined', async () => {
109
+ const responder = probeOnlyResponder(busDbPath);
110
+ const observer = openBus({ dbPath: busDbPath, events: NO_SCHEMAS });
111
+
112
+ try {
113
+ let settled = false;
114
+ const sweep = handleModuleUpdate([], { registry: registryUrl }).then((r) => {
115
+ settled = true;
116
+ return r;
117
+ });
118
+
119
+ await sleep(600);
120
+ expect(settled).toBe(false);
121
+
122
+ const query = observer.recentEvents({ type: QUERY_TYPE })[0] as BusEvent;
123
+ expect(query).toBeDefined();
124
+
125
+ // A session parked on that question, already past its deadline.
126
+ const session = SessionWriter.create({
127
+ principal: 'tester',
128
+ argv: ['module', 'update'],
129
+ ttlMs: -1,
130
+ });
131
+ session.park({
132
+ eventId: String(query.id),
133
+ eventType: QUERY_TYPE,
134
+ question: 'Apply breaking update for iptables (1.0.2+9 → 2.0.0+1)?',
135
+ questionKey: 'module-upgrade:iptables.apply_breaking',
136
+ });
137
+
138
+ const reaped = reapExpiredSessions({ busDbPath });
139
+ expect(reaped.map((r) => r.sessionId)).toEqual([session.id]);
140
+
141
+ // The command resumes — and says nobody decided, not that anyone declined.
142
+ const result = await sweep;
143
+ const report = result.success ? (result.message ?? '') : (result.error ?? '');
144
+ expect(report).toContain('Abandoned:');
145
+ expect(report).toContain('never decided');
146
+ expect(report).not.toContain('operator declined');
147
+ // ...and not the *unanswered* wording either: a responder was listening.
148
+ expect(report).not.toContain('could not be answered');
149
+ expect(report).toContain('iptables');
150
+ // A breaking update that silently didn't land must not read as success.
151
+ expect(result.success).toBe(false);
152
+ // The module is untouched — nothing was applied on nobody's authority.
153
+ expect(getDb().select().from(modules).all()[0].version).toBe('1.0.2+9');
154
+
155
+ // The record is retained, so a repeatedly-parking command is detectable.
156
+ const retained = listSessions();
157
+ expect(retained).toHaveLength(1);
158
+ expect(retained[0].state).toBe('abandoned');
159
+ expect(retained[0].question).toContain('iptables');
160
+ } finally {
161
+ observer.close();
162
+ responder.close();
163
+ }
164
+ }, 30_000);
165
+
166
+ test('a question answered before the deadline is never overwritten by the reaper', async () => {
167
+ const observer = openBus({ dbPath: busDbPath, events: NO_SCHEMAS });
168
+ try {
169
+ const query = observer.emitRaw(QUERY_TYPE, { scope: 'x', key: 'y' });
170
+ observer.emitRaw(
171
+ `${QUERY_TYPE}.reply`,
172
+ { value: true },
173
+ { replyFor: query.id, emittedBy: 'a-real-decider' },
174
+ );
175
+
176
+ const session = SessionWriter.create({
177
+ principal: 'tester',
178
+ argv: ['module', 'update'],
179
+ ttlMs: -1,
180
+ });
181
+ session.park({
182
+ eventId: String(query.id),
183
+ eventType: QUERY_TYPE,
184
+ question: 'Apply breaking update for iptables?',
185
+ });
186
+
187
+ reapExpiredSessions({ busDbPath });
188
+
189
+ // Exactly one reply, and it is the decision that was actually made.
190
+ const replies = observer.repliesFor(query.id);
191
+ expect(replies).toHaveLength(1);
192
+ expect((replies[0].payload as { value: unknown }).value).toBe(true);
193
+ } finally {
194
+ observer.close();
195
+ }
196
+ });
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Parked-session registry for `api-serve` (celilo#609).
3
+ *
4
+ * A command that parks on an unanswerable question must outlive the ssh session
5
+ * that started it, so a *different* responder can answer later and the caller
6
+ * can come back for the outcome. Two consequences shape this file:
7
+ *
8
+ * 1. **The registry is on disk, not in memory.** Each ssh connection is its own
9
+ * `api-serve` process, so the process that later handles `attach` is not the
10
+ * one holding the parked child. A directory under the data dir is the
11
+ * handoff: metadata in `session.json`, the command's output appended to
12
+ * `output.ndjson`. The attaching process replays the file and tails it.
13
+ * 2. **The record is retained after the session ends.** A repeatedly-parking
14
+ * command is only detectable if the abandonments are still there to see —
15
+ * the same reason `module operations` keeps its rows.
16
+ *
17
+ * ponytail: files + polling rather than a socket/IPC server. Same process model,
18
+ * a fraction of the machinery, and the buffered output has to be durable across
19
+ * processes anyway. Move to a socket only if attach latency becomes a problem.
20
+ */
21
+
22
+ import {
23
+ appendFileSync,
24
+ existsSync,
25
+ mkdirSync,
26
+ readFileSync,
27
+ readdirSync,
28
+ writeFileSync,
29
+ } from 'node:fs';
30
+ import { join } from 'node:path';
31
+ import type { ServerMessage } from '@celilo/core';
32
+ import { defineEvents, openBus } from '@celilo/event-bus';
33
+ import { z } from 'zod';
34
+ import { getDataDir } from '../config/paths';
35
+
36
+ const NO_SCHEMAS = defineEvents({});
37
+
38
+ /** How long a parked session may hold its child before the reaper abandons it. */
39
+ export const DEFAULT_SESSION_TTL_MS = 30 * 60 * 1000;
40
+
41
+ /** Lines of buffered output an attaching client is replayed at most. */
42
+ const REPLAY_LIMIT = 2000;
43
+
44
+ export const SessionStateSchema = z.enum(['running', 'parked', 'finished', 'abandoned']);
45
+ export type SessionState = z.infer<typeof SessionStateSchema>;
46
+
47
+ export const SessionRecordSchema = z.object({
48
+ sessionId: z.string(),
49
+ principal: z.string(),
50
+ argv: z.array(z.string()),
51
+ startedAt: z.number(),
52
+ expiresAt: z.number(),
53
+ state: SessionStateSchema,
54
+ /** Bus event id of the question this session is parked on, when parked. */
55
+ parkedEventId: z.string().nullable(),
56
+ /** Bus event *type* of that question — what the reaper replies to. */
57
+ parkedEventType: z.string().nullable(),
58
+ question: z.string().nullable(),
59
+ /** The parked question's `<scope>.<key>`. */
60
+ questionKey: z.string().nullable(),
61
+ });
62
+ export type SessionRecord = z.infer<typeof SessionRecordSchema>;
63
+
64
+ export function getSessionsDir(): string {
65
+ return join(getDataDir(), 'api-sessions');
66
+ }
67
+
68
+ function sessionDir(sessionId: string): string {
69
+ return join(getSessionsDir(), sessionId);
70
+ }
71
+
72
+ function recordPath(sessionId: string): string {
73
+ return join(sessionDir(sessionId), 'session.json');
74
+ }
75
+
76
+ function outputPath(sessionId: string): string {
77
+ return join(sessionDir(sessionId), 'output.ndjson');
78
+ }
79
+
80
+ export function readSession(sessionId: string): SessionRecord | null {
81
+ const path = recordPath(sessionId);
82
+ if (!existsSync(path)) return null;
83
+ // Untrusted only in the sense of "written by another process" — validate it
84
+ // rather than trusting the shape (Rule 3.7).
85
+ const parsed = SessionRecordSchema.safeParse(JSON.parse(readFileSync(path, 'utf-8')));
86
+ return parsed.success ? parsed.data : null;
87
+ }
88
+
89
+ export function listSessions(): SessionRecord[] {
90
+ const dir = getSessionsDir();
91
+ if (!existsSync(dir)) return [];
92
+ return readdirSync(dir)
93
+ .map(readSession)
94
+ .filter((s): s is SessionRecord => s !== null)
95
+ .sort((a, b) => b.startedAt - a.startedAt);
96
+ }
97
+
98
+ /** The session parked on a given bus query, if one is. */
99
+ export function sessionParkedOn(eventId: string): SessionRecord | null {
100
+ return listSessions().find((s) => s.state === 'parked' && s.parkedEventId === eventId) ?? null;
101
+ }
102
+
103
+ function write(record: SessionRecord): void {
104
+ mkdirSync(sessionDir(record.sessionId), { recursive: true });
105
+ writeFileSync(recordPath(record.sessionId), `${JSON.stringify(record, null, 2)}\n`);
106
+ }
107
+
108
+ /**
109
+ * A live session's handle: the writer side of the registry. Held by the
110
+ * `api-serve` process running the command.
111
+ */
112
+ export class SessionWriter {
113
+ private record: SessionRecord;
114
+
115
+ private constructor(record: SessionRecord) {
116
+ this.record = record;
117
+ write(record);
118
+ }
119
+
120
+ static create(opts: {
121
+ principal: string;
122
+ argv: string[];
123
+ ttlMs?: number;
124
+ now?: number;
125
+ }): SessionWriter {
126
+ const now = opts.now ?? Date.now();
127
+ return new SessionWriter({
128
+ sessionId: crypto.randomUUID(),
129
+ principal: opts.principal,
130
+ argv: opts.argv,
131
+ startedAt: now,
132
+ expiresAt: now + (opts.ttlMs ?? DEFAULT_SESSION_TTL_MS),
133
+ state: 'running',
134
+ parkedEventId: null,
135
+ parkedEventType: null,
136
+ question: null,
137
+ questionKey: null,
138
+ });
139
+ }
140
+
141
+ get id(): string {
142
+ return this.record.sessionId;
143
+ }
144
+
145
+ get current(): SessionRecord {
146
+ return this.record;
147
+ }
148
+
149
+ /** Append a message to the buffer so a client attaching later sees it. */
150
+ append(msg: ServerMessage): void {
151
+ appendFileSync(outputPath(this.record.sessionId), `${JSON.stringify(msg)}\n`);
152
+ }
153
+
154
+ park(opts: { eventId: string; eventType: string; question: string; questionKey?: string }): void {
155
+ this.record = {
156
+ ...this.record,
157
+ state: 'parked',
158
+ parkedEventId: opts.eventId,
159
+ parkedEventType: opts.eventType,
160
+ question: opts.question,
161
+ questionKey: opts.questionKey ?? null,
162
+ };
163
+ write(this.record);
164
+ }
165
+
166
+ /** The question was answered (by anyone) — the command is running again. */
167
+ unpark(): void {
168
+ if (this.record.state !== 'parked') return;
169
+ this.record = {
170
+ ...this.record,
171
+ state: 'running',
172
+ parkedEventId: null,
173
+ parkedEventType: null,
174
+ question: null,
175
+ questionKey: null,
176
+ };
177
+ write(this.record);
178
+ }
179
+
180
+ /**
181
+ * `abandoned` outranks `finished`: the command exits *because* it was
182
+ * abandoned, and that exit must not overwrite why. Retained either way.
183
+ */
184
+ finish(state: 'finished' | 'abandoned'): void {
185
+ if (this.record.state === 'abandoned') return;
186
+ this.record = { ...this.record, state };
187
+ write(this.record);
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Answer a parked session's outstanding query as `abandoned` and retain the
193
+ * record as history.
194
+ *
195
+ * The reply is what releases everything the parked command holds: the child
196
+ * resumes, throws `InterviewAbandonedError`, and unwinds — releasing its own
197
+ * module-operation lock on the way out, exactly as any other failure does. That
198
+ * is the whole point of answering rather than killing.
199
+ *
200
+ * ponytail: no forced kill if the child ignores its own unwinding. Add one only
201
+ * once a real command is seen to survive an abandoned answer.
202
+ */
203
+ export function abandonSession(
204
+ record: SessionRecord,
205
+ opts: { busDbPath: string; reason: string; emittedBy?: string },
206
+ ): boolean {
207
+ if (record.parkedEventId && record.parkedEventType) {
208
+ const bus = openBus({ dbPath: opts.busDbPath, events: NO_SCHEMAS });
209
+ try {
210
+ // Someone decided it while we were on our way to reap. Abandoning now
211
+ // would overwrite a real answer with "nobody decided" — the exact
212
+ // fabrication this whole change exists to remove, only in reverse.
213
+ if (bus.repliesFor(Number(record.parkedEventId)).length > 0) return false;
214
+ bus.emitRaw(
215
+ `${record.parkedEventType}.reply`,
216
+ { abandoned: { reason: opts.reason } },
217
+ {
218
+ replyFor: Number(record.parkedEventId),
219
+ emittedBy: opts.emittedBy ?? 'api-session-reaper',
220
+ },
221
+ );
222
+ } finally {
223
+ bus.close();
224
+ }
225
+ }
226
+ write({ ...record, state: 'abandoned' });
227
+ return true;
228
+ }
229
+
230
+ /**
231
+ * Is this session still waiting on a decision *according to the bus*?
232
+ *
233
+ * The record's `state` is the owning process's view and can lag: the answer
234
+ * arrives on the bus from a third party, which the owner never sees. The bus is
235
+ * the source of truth for whether a question stands — ask it, don't infer.
236
+ */
237
+ export function stillParked(record: SessionRecord, busDbPath: string): boolean {
238
+ if (record.state !== 'parked' || !record.parkedEventId) return false;
239
+ const bus = openBus({ dbPath: busDbPath, events: NO_SCHEMAS });
240
+ try {
241
+ return bus.repliesFor(Number(record.parkedEventId)).length === 0;
242
+ } finally {
243
+ bus.close();
244
+ }
245
+ }
246
+
247
+ /** Why a reaped session's question was never decided. */
248
+ export function expiryReason(record: SessionRecord): string {
249
+ const what = record.question ?? 'the question';
250
+ return `Session ${record.sessionId} expired with "${what}" still unanswered — nobody decided it.`;
251
+ }
252
+
253
+ /**
254
+ * Abandon every parked session past its TTL. Run by the process that owns a
255
+ * parked child on its own timer, and again at `api-serve` startup so a session
256
+ * whose owner died (reboot, OOM) doesn't sit parked forever — the failure mode
257
+ * that left a `module deploy` holding every backup lock on the fleet for 20 days.
258
+ */
259
+ export function reapExpiredSessions(opts: { busDbPath: string; now?: number }): SessionRecord[] {
260
+ const now = opts.now ?? Date.now();
261
+ const expired = listSessions().filter((s) => s.state === 'parked' && s.expiresAt <= now);
262
+ for (const record of expired) {
263
+ abandonSession(record, { busDbPath: opts.busDbPath, reason: expiryReason(record) });
264
+ }
265
+ return expired;
266
+ }
267
+
268
+ /** Buffered messages an attaching client should be replayed. */
269
+ export function replayOutput(
270
+ sessionId: string,
271
+ fromLine = 0,
272
+ ): { messages: string[]; next: number } {
273
+ const path = outputPath(sessionId);
274
+ if (!existsSync(path)) return { messages: [], next: fromLine };
275
+ const lines = readFileSync(path, 'utf-8').split('\n').filter(Boolean);
276
+ const start = Math.max(fromLine, lines.length - REPLAY_LIMIT);
277
+ return { messages: lines.slice(start), next: lines.length };
278
+ }
@@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test';
2
2
  import { handleAptUpgrade } from './apt-upgrade';
3
3
 
4
4
  describe('handleAptUpgrade', () => {
5
- test('runs the three steps in order when each succeeds', async () => {
5
+ test('runs every step in order when each succeeds, ending with the dispatcher restart', async () => {
6
6
  const seen: string[][] = [];
7
7
  const result = await handleAptUpgrade([], {}, (argv) => {
8
8
  seen.push(argv);
@@ -14,9 +14,28 @@ describe('handleAptUpgrade', () => {
14
14
  ['sudo', 'apt-get', 'update'],
15
15
  ['sudo', 'apt-get', '-y', '--only-upgrade', 'install', 'celilo', 'celilo-bootstrap'],
16
16
  ['/usr/local/bin/celilo', 'system', 'migrate'],
17
+ // celilo#604: without this, apt installs new code and the running
18
+ // dispatcher keeps serving the old — celilo-mgr did so for 9 days.
19
+ ['/usr/local/bin/celilo', 'events', 'restart-daemon'],
17
20
  ]);
18
21
  });
19
22
 
23
+ test('fails, and names what DID complete, when the dispatcher restart fails', async () => {
24
+ const result = await handleAptUpgrade([], {}, (argv) => ({
25
+ status: argv.includes('restart-daemon') ? 1 : 0,
26
+ }));
27
+
28
+ // The packages ARE upgraded and the dispatcher is NOT on the new code.
29
+ // Saying so in the failure is the acceptance condition — silence here is
30
+ // what let a stale dispatcher pass for a successful upgrade.
31
+ expect(result.success).toBe(false);
32
+ if (!result.success) {
33
+ expect(result.error).toContain('restart the event dispatcher');
34
+ expect(result.error).toContain('apt-get upgrade');
35
+ expect(result.error).toContain('apply DB migrations');
36
+ }
37
+ });
38
+
20
39
  test('stops at the first failing step and does not run later ones', async () => {
21
40
  const seen: string[][] = [];
22
41
  const result = await handleAptUpgrade([], {}, (argv) => {
@@ -34,6 +34,11 @@ const STEPS: Step[] = [
34
34
  argv: ['sudo', 'apt-get', '-y', '--only-upgrade', 'install', 'celilo', 'celilo-bootstrap'],
35
35
  },
36
36
  { label: 'apply DB migrations', argv: [CELILO_BIN, 'system', 'migrate'] },
37
+ // The dispatcher runs the code it LOADED, not the code on disk. Without this
38
+ // step celilo-mgr sat 9 days on event-bus v0.1.8 after apt installed v0.2.0,
39
+ // faithfully running the bug the upgrade shipped to fix (celilo#604). The
40
+ // upgraded binary does the restart so the verification is the new code's.
41
+ { label: 'restart the event dispatcher', argv: [CELILO_BIN, 'events', 'restart-daemon'] },
37
42
  ];
38
43
 
39
44
  /** Run one argv, inheriting stdio so its output streams through api-serve. */
@@ -46,18 +51,23 @@ export async function handleAptUpgrade(
46
51
  _flags: Record<string, string | boolean>,
47
52
  runStep: StepRunner = defaultRunner,
48
53
  ): Promise<CommandResult> {
54
+ const done: string[] = [];
49
55
  for (const step of STEPS) {
50
56
  process.stdout.write(`\n▸ ${step.label}\n`);
51
57
  const { status } = runStep(step.argv);
52
58
  if (status !== 0) {
59
+ // Name what DID happen. A failure on the last step means new code is
60
+ // installed and the dispatcher is still serving the old — the operator
61
+ // has to be told that in the failure itself, not left to infer it.
53
62
  return {
54
63
  success: false,
55
- error: `apt-upgrade failed at "${step.label}" (exit ${status ?? 'signal'}). Nothing further was run.`,
64
+ error: `apt-upgrade failed at "${step.label}" (exit ${status ?? 'signal'}). Completed: ${done.length > 0 ? done.join(', ') : 'nothing'}. Nothing further was run.`,
56
65
  };
57
66
  }
67
+ done.push(step.label);
58
68
  }
59
69
  return {
60
70
  success: true,
61
- message: 'celilo apt packages upgraded and migrations applied.',
71
+ message: 'celilo apt packages upgraded, migrations applied, dispatcher restarted on new code.',
62
72
  };
63
73
  }
@@ -17,13 +17,12 @@ import {
17
17
  findBackupEligibleModules,
18
18
  isBackupDue,
19
19
  } from '../../services/backup-create';
20
- import { failBackup, getBackup } from '../../services/backup-metadata';
20
+ import { failBackup, getBackup, listInProgressBackups } from '../../services/backup-metadata';
21
21
  import { pruneBackupsForModule } from '../../services/backup-retention';
22
22
  import {
23
- ABANDONED_BACKUP_MESSAGE,
24
23
  STAGING_PREFIX,
25
- impliesAbandonedRecord,
26
24
  reapOrphanedStaging,
25
+ resolveAbandonedBackups,
27
26
  } from '../../services/backup-staging';
28
27
  import { type BackupSweepReport, runBackupSweep } from '../../services/backup-sweep';
29
28
  import { isPidRunnable } from '../../services/module-operations';
@@ -59,18 +58,30 @@ function reapStaging() {
59
58
  now: () => Date.now(),
60
59
  });
61
60
 
62
- for (const reclaimed of report.reclaimed) {
63
- if (impliesAbandonedRecord(reclaimed.reason)) {
64
- failBackup(reclaimed.recordId, ABANDONED_BACKUP_MESSAGE);
65
- }
66
- }
67
-
68
61
  return report;
69
62
  }
70
63
 
64
+ /**
65
+ * Correct every record that still claims to be running, staging or no staging.
66
+ *
67
+ * This replaces deriving the fix-up from what the reaper reclaimed. That
68
+ * version only ever saw records whose directory still existed, so anything
69
+ * cleared by a reboot or by hand stayed `in_progress` forever (#616).
70
+ */
71
+ function resolveAbandonedRecords() {
72
+ return resolveAbandonedBackups({
73
+ listInProgress: () =>
74
+ listInProgressBackups().map((b) => ({ id: b.id, pid: b.pid, startedAt: b.startedAt })),
75
+ isPidRunnable,
76
+ fail: (id, message) => failBackup(id, message),
77
+ now: () => Date.now(),
78
+ });
79
+ }
80
+
71
81
  export async function handleBackupSweep(): Promise<CommandResult> {
72
82
  const report = await runBackupSweep({
73
83
  reapStaging,
84
+ resolveAbandonedRecords,
74
85
  listEligible: () =>
75
86
  findBackupEligibleModules().map(({ module, manifest }) => ({ id: module.id, manifest })),
76
87
  isDue: (moduleId, schedule) => isBackupDue(moduleId, schedule),
@@ -117,6 +128,11 @@ function formatReport(report: BackupSweepReport): string {
117
128
  lines.push(` ${reclaimed.recordId} (${reclaimed.reason})`);
118
129
  }
119
130
  }
131
+ if (report.records.resolved.length > 0) {
132
+ lines.push(
133
+ ` corrected ${report.records.resolved.length} record(s) that still claimed to be running`,
134
+ );
135
+ }
120
136
  for (const moduleId of report.skippedLocked) {
121
137
  lines.push(` skipped ${moduleId}: another operation is in flight — retrying next tick`);
122
138
  }