dsh-autorestart 0.6.1 → 0.7.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.
package/lib/backup.js CHANGED
@@ -37,9 +37,87 @@ export const DEFAULT_MAX_BACKUPS = 2;
37
37
  /** The report the detached helper writes when it rolls a backup back. */
38
38
  export const ROLLBACK_REPORT_NAME = 'last-rollback.json';
39
39
 
40
+ /** The lock that makes sure a snapshot is restored ONCE. */
41
+ export const ROLLBACK_LOCK_NAME = 'rollback.lock';
42
+
43
+ /**
44
+ * Claim the right to restore the snapshot.
45
+ *
46
+ * TWO parties can want to restore the snapshot at the same time: the plugin
47
+ * itself (it saw that the restart failed) and the detached watcher (the host
48
+ * never came back). Both copy the same files over the profile — two copies at
49
+ * once would write the same files at the same time, and the plugin tree is
50
+ * 117 MB.
51
+ *
52
+ * The claim is atomic: the file is CREATED, never opened, so exactly ONE
53
+ * caller gets it. The loser stands down.
54
+ *
55
+ * @param {string} stateDir
56
+ * @returns {{ ok: boolean, path: string, error: string|null }}
57
+ */
58
+ export function claimRollbackLock(stateDir) {
59
+ const dir = typeof stateDir === 'string' ? stateDir.trim() : '';
60
+ if (dir === '') return { ok: false, path: '', error: 'claimRollbackLock requires stateDir' };
61
+ const file = path.join(dir, ROLLBACK_LOCK_NAME);
62
+ try {
63
+ fs.mkdirSync(dir, { recursive: true });
64
+ } catch {
65
+ /* the directory may already be there */
66
+ }
67
+ // A lock that is older than 10 minutes belongs to a process that died in the
68
+ // middle of its restore: it must not block the rollback for ever.
69
+ try {
70
+ const stat = fs.statSync(file);
71
+ if (Date.now() - stat.mtimeMs > 10 * 60 * 1000) {
72
+ fs.rmSync(file, { force: true });
73
+ }
74
+ } catch {
75
+ /* no lock there: nothing to clean up */
76
+ }
77
+ try {
78
+ const handle = fs.openSync(file, 'wx');
79
+ try {
80
+ fs.writeFileSync(handle, String(process.pid ?? ''), 'utf8');
81
+ } catch {
82
+ /* the owner is what matters, not the note inside the file */
83
+ }
84
+ try {
85
+ fs.closeSync(handle);
86
+ } catch {
87
+ /* nothing else to do */
88
+ }
89
+ return { ok: true, path: file, error: null };
90
+ } catch {
91
+ return { ok: false, path: file, error: 'another rollback is already running' };
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Release the rollback lock.
97
+ *
98
+ * @param {string} stateDir
99
+ * @returns {boolean} whether a lock was removed
100
+ */
101
+ export function releaseRollbackLock(stateDir) {
102
+ const dir = typeof stateDir === 'string' ? stateDir.trim() : '';
103
+ if (dir === '') return false;
104
+ try {
105
+ fs.rmSync(path.join(dir, ROLLBACK_LOCK_NAME), { force: true });
106
+ return true;
107
+ } catch {
108
+ return false;
109
+ }
110
+ }
111
+
40
112
  /**
41
113
  * A filesystem-safe timestamp for a backup directory name.
42
114
  *
115
+ * The name is in UTC on purpose: it sorts chronologically and it does not
116
+ * depend on the timezone of the machine. The price is that it does NOT look
117
+ * like the wall clock the operator reads (`2026-10-29T00-25-20-779Z` is
118
+ * 08:25 in Shanghai) — so the manifest and the restart record carry the local
119
+ * time as well, and a fresh snapshot can never be mistaken for an old one.
120
+ *
43
121
  * @param {number} [now]
44
122
  * @returns {string}
45
123
  */
@@ -48,6 +126,26 @@ export function backupStamp(now = Date.now()) {
48
126
  return date.toISOString().replace(/[:.]/g, '-');
49
127
  }
50
128
 
129
+ /**
130
+ * The local time of an epoch, with the UTC offset (`2026-10-29T08:25:20+08:00`).
131
+ *
132
+ * @param {number|null|undefined} at
133
+ * @returns {string|null}
134
+ */
135
+ export function backupLocalTime(at) {
136
+ const value = Number(at);
137
+ if (!Number.isFinite(value)) return null;
138
+ const date = new Date(value);
139
+ if (Number.isNaN(date.getTime())) return null;
140
+ const offsetMinutes = -date.getTimezoneOffset();
141
+ const sign = offsetMinutes >= 0 ? '+' : '-';
142
+ const absolute = Math.abs(offsetMinutes);
143
+ const hours = String(Math.floor(absolute / 60)).padStart(2, '0');
144
+ const minutes = String(absolute % 60).padStart(2, '0');
145
+ const local = new Date(value + offsetMinutes * 60000).toISOString();
146
+ return `${local.slice(0, 19)}${sign}${hours}:${minutes}`;
147
+ }
148
+
51
149
  /**
52
150
  * The backups directory that belongs to a state directory.
53
151
  *
@@ -142,8 +240,12 @@ export function pruneBackups(backupsDir, maxBackups = DEFAULT_MAX_BACKUPS) {
142
240
  /**
143
241
  * List the backups, newest first.
144
242
  *
243
+ * Only the DIRECTORY is read: the name, the manifest and nothing else. The
244
+ * backup tree can be 150 MB per copy, so its contents are never walked — the
245
+ * manifest already carries the size, the file count and the local time.
246
+ *
145
247
  * @param {string} backupsDir
146
- * @returns {Array<{name: string, dir: string, at: number|null, bytes: number|null, includePlugins: boolean}>}
248
+ * @returns {Array<{name: string, dir: string, at: number|null, atLocal: string|null, bytes: number|null, files: number|null, includePlugins: boolean}>}
147
249
  */
148
250
  export function listBackups(backupsDir) {
149
251
  let names;
@@ -158,11 +260,18 @@ export function listBackups(backupsDir) {
158
260
  for (const name of names.sort().reverse()) {
159
261
  const dir = path.join(backupsDir, name);
160
262
  const manifest = readManifest(dir);
263
+ const at = manifest && Number.isFinite(manifest.at) ? manifest.at : null;
161
264
  out.push({
162
265
  name,
163
266
  dir,
164
- at: manifest && Number.isFinite(manifest.at) ? manifest.at : null,
267
+ at,
268
+ // The local time of the snapshot. The directory name is UTC on purpose,
269
+ // so a fresh snapshot must never look like an old one.
270
+ atLocal: manifest && typeof manifest.atLocal === 'string' && manifest.atLocal !== ''
271
+ ? manifest.atLocal
272
+ : (at === null ? null : backupLocalTime(at)),
165
273
  bytes: manifest && Number.isFinite(manifest.bytes) ? manifest.bytes : null,
274
+ files: manifest && Array.isArray(manifest.files) ? manifest.files.length : null,
166
275
  includePlugins: Boolean(manifest && manifest.includePlugins === true),
167
276
  });
168
277
  }
@@ -202,7 +311,7 @@ export function readManifest(backupDir) {
202
311
  * maxBackups?: number,
203
312
  * now?: number,
204
313
  * }} options
205
- * @returns {{ ok: boolean, dir: string|null, stamp: string|null, bytes: number, files: string[], includePlugins: boolean, pruned: string[], error: string|null }}
314
+ * @returns {{ ok: boolean, dir: string|null, stamp: string|null, at: number|null, atLocal: string|null, durationMs: number, bytes: number, files: string[], includePlugins: boolean, pruned: string[], error: string|null }}
206
315
  */
207
316
  export function createBackup({
208
317
  profileDir,
@@ -213,10 +322,14 @@ export function createBackup({
213
322
  } = {}) {
214
323
  const profile = typeof profileDir === 'string' ? profileDir.trim() : '';
215
324
  const root = typeof backupsDir === 'string' ? backupsDir.trim() : '';
325
+ const startedAt = Date.now();
216
326
  const base = {
217
327
  ok: false,
218
328
  dir: null,
219
329
  stamp: null,
330
+ at: null,
331
+ atLocal: null,
332
+ durationMs: 0,
220
333
  bytes: 0,
221
334
  files: [],
222
335
  includePlugins: includePlugins === true,
@@ -261,6 +374,9 @@ export function createBackup({
261
374
  const manifest = {
262
375
  stamp,
263
376
  at: Number(now),
377
+ // The wall clock of the machine: the directory name above is UTC, and
378
+ // without this line a fresh snapshot looks like an old leftover.
379
+ atLocal: backupLocalTime(Number(now)),
264
380
  profileDir: profile,
265
381
  includePlugins: includePlugins === true,
266
382
  files,
@@ -273,7 +389,18 @@ export function createBackup({
273
389
  }
274
390
 
275
391
  const pruned = pruneBackups(root, maxBackups);
276
- return { ...base, ok: true, dir, stamp, bytes, files, pruned };
392
+ return {
393
+ ...base,
394
+ ok: true,
395
+ dir,
396
+ stamp,
397
+ at: Number(now),
398
+ atLocal: manifest.atLocal,
399
+ durationMs: Math.max(0, Date.now() - startedAt),
400
+ bytes,
401
+ files,
402
+ pruned,
403
+ };
277
404
  }
278
405
 
279
406
  /**
@@ -0,0 +1,228 @@
1
+ /**
2
+ * dsh-autorestart — ONE task count for every surface.
3
+ *
4
+ * A TASK is one conversation of the left sidebar: the session itself. A
5
+ * subagent belongs to the session that spawned it, so a session and its
6
+ * subagents are ONE task. The sidebar counts them once — so the bubble has
7
+ * to count them the same way.
8
+ *
9
+ * THE DEFECT THIS FIXES
10
+ *
11
+ * The real machine showed this:
12
+ *
13
+ * blockedBy: [1] session (父会话 A)
14
+ * [2] subagent (父 = A)
15
+ * [3] subagent (父 = B)
16
+ *
17
+ * The sidebar showed TWO busy conversations, and the bubble said "waiting for
18
+ * 3 tasks to finish". It counted every BLOCKER, so a parent session and its
19
+ * own subagent were counted twice.
20
+ *
21
+ * THE RULE (one rule, four surfaces)
22
+ *
23
+ * - a session is a task of its own: its key is its own session id;
24
+ * - a subagent belongs to the session that created it: its key is the
25
+ * PARENT's session id, so a parent and its subagents are ONE task;
26
+ * - a subagent whose parent is only known by its TITLE joins the session
27
+ * with that title (the shutdown card reports the title but no id);
28
+ * - a subagent with no known parent at all is a task of its own;
29
+ * - a pending restart is not a task: it is what the shutdown waits for.
30
+ *
31
+ * THE TWO HALVES
32
+ *
33
+ * The browser half of this rule lives in `client.js`: the client is a CLASSIC
34
+ * script and cannot import an ES module. The two implementations MUST agree,
35
+ * and `test/task-count.test.mjs` runs the same cases through both — so a
36
+ * change on one side that is not made on the other fails the suite.
37
+ *
38
+ * ESM only, Node builtins only.
39
+ */
40
+
41
+ /** The prefix of the key of a family that is known by its session id. */
42
+ const SESSION_KEY = 'session:'
43
+
44
+ /** The prefix of the key of a family that is only known by its title. */
45
+ const TITLE_KEY = 'title:'
46
+
47
+ /**
48
+ * One trimmed string field of a blocker.
49
+ *
50
+ * @param {any} entry
51
+ * @param {string} key
52
+ * @returns {string}
53
+ */
54
+ function field(entry, key) {
55
+ if (!entry || typeof entry !== 'object') return ''
56
+ const value = entry[key]
57
+ return typeof value === 'string' ? value.trim() : ''
58
+ }
59
+
60
+ /**
61
+ * WHAT a blocker is: a session, a subagent or a pending restart.
62
+ *
63
+ * The kind is NOT the only evidence: an entry that names a parent session
64
+ * (`parentSessionId` / `parentTitle`) IS a subagent, whatever the Host
65
+ * called it.
66
+ *
67
+ * @param {any} entry
68
+ * @returns {'session'|'subagent'|'restart-pending'}
69
+ */
70
+ export function blockerKind(entry) {
71
+ if (!entry || typeof entry !== 'object') return 'session'
72
+ if (entry.kind === 'restart-pending') return 'restart-pending'
73
+ if (entry.kind === 'subagent') return 'subagent'
74
+ if (entry.kind === 'session') return 'session'
75
+ if (field(entry, 'parentSessionId') !== '' || field(entry, 'parentTitle') !== '') return 'subagent'
76
+ return 'session'
77
+ }
78
+
79
+ /**
80
+ * The session id of a blocker.
81
+ *
82
+ * @param {any} entry
83
+ * @returns {string}
84
+ */
85
+ function sessionIdOf(entry) {
86
+ if (!entry || typeof entry !== 'object') return ''
87
+ const value = entry.sessionId
88
+ return value == null ? '' : String(value).trim()
89
+ }
90
+
91
+ /**
92
+ * The title of the session a subagent belongs to.
93
+ *
94
+ * @param {any} entry
95
+ * @returns {string}
96
+ */
97
+ function parentTitleOf(entry) {
98
+ return field(entry, 'parentTitle') || field(entry, 'label')
99
+ }
100
+
101
+ /**
102
+ * The title of a blocker (for a session: its own title).
103
+ *
104
+ * @param {any} entry
105
+ * @returns {string}
106
+ */
107
+ function titleOf(entry) {
108
+ return field(entry, 'label') || field(entry, 'title')
109
+ }
110
+
111
+ /**
112
+ * The map "title of a session" → "session id" of the sessions in the list.
113
+ *
114
+ * It is the fallback for the surfaces that report the parent by its title
115
+ * only (the shutdown card): without it, the members of a team would become
116
+ * tasks of their own.
117
+ *
118
+ * @param {any[]} entries
119
+ * @returns {Map<string, string>}
120
+ */
121
+ function sessionTitles(entries) {
122
+ const map = new Map()
123
+ for (const entry of entries) {
124
+ if (!entry || typeof entry !== 'object') continue
125
+ if (blockerKind(entry) === 'subagent') continue
126
+ const title = titleOf(entry)
127
+ if (title === '') continue
128
+ const id = sessionIdOf(entry)
129
+ if (id === '') continue
130
+ if (!map.has(title)) map.set(title, id)
131
+ }
132
+ return map
133
+ }
134
+
135
+ /**
136
+ * The key of the family every entry belongs to.
137
+ *
138
+ * One key per entry, in the order of the list ('' for an entry that is not a
139
+ * task at all).
140
+ *
141
+ * @param {any[]} entries the blockers the Host reported
142
+ * @returns {string[]}
143
+ */
144
+ export function blockerTaskKeys(entries) {
145
+ const list = Array.isArray(entries) ? entries : []
146
+ const titles = sessionTitles(list)
147
+ const keys = []
148
+ for (let index = 0; index < list.length; index += 1) {
149
+ const entry = list[index]
150
+ if (!entry || typeof entry !== 'object') {
151
+ keys.push('')
152
+ continue
153
+ }
154
+ const kind = blockerKind(entry)
155
+ if (kind === 'restart-pending') {
156
+ // A pending restart is not a task: it is what the shutdown waits for.
157
+ keys.push('')
158
+ continue
159
+ }
160
+ if (kind === 'subagent') {
161
+ const parentId = field(entry, 'parentSessionId')
162
+ if (parentId !== '') {
163
+ keys.push(SESSION_KEY + parentId)
164
+ continue
165
+ }
166
+ const parentTitle = parentTitleOf(entry)
167
+ if (parentTitle !== '') {
168
+ const known = titles.get(parentTitle)
169
+ keys.push(known ? SESSION_KEY + known : TITLE_KEY + parentTitle)
170
+ continue
171
+ }
172
+ const own = sessionIdOf(entry)
173
+ keys.push(own !== '' ? SESSION_KEY + own : 'entry:' + index)
174
+ continue
175
+ }
176
+ const own = sessionIdOf(entry)
177
+ keys.push(own !== '' ? SESSION_KEY + own : 'entry:' + index)
178
+ }
179
+ return keys
180
+ }
181
+
182
+ /**
183
+ * How many TASKS the blockers are.
184
+ *
185
+ * A parent session and its own subagents are ONE task: the same number the
186
+ * left sidebar shows.
187
+ *
188
+ * @param {any[]} entries the blockers the Host reported
189
+ * @returns {number}
190
+ */
191
+ export function countBlockerTasks(entries) {
192
+ const keys = blockerTaskKeys(entries)
193
+ const families = new Set()
194
+ for (const key of keys) {
195
+ if (key !== '') families.add(key)
196
+ }
197
+ return families.size
198
+ }
199
+
200
+ /**
201
+ * How many of those tasks are carried by SUBAGENTS ONLY.
202
+ *
203
+ * A task whose session is idle and whose subagent works is a task of the
204
+ * subagent — that is what the card tells the user ("2 of them are
205
+ * subagents"). A task whose session works itself is not one of them, even
206
+ * when it has subagents of its own.
207
+ *
208
+ * @param {any[]} entries the blockers the Host reported
209
+ * @returns {number}
210
+ */
211
+ export function countSubagentTasks(entries) {
212
+ const list = Array.isArray(entries) ? entries : []
213
+ const keys = blockerTaskKeys(list)
214
+ const families = new Map()
215
+ list.forEach((entry, index) => {
216
+ const key = keys[index]
217
+ if (key === '') return
218
+ const family = families.get(key) || { session: false, subagent: false }
219
+ if (blockerKind(entry) === 'subagent') family.subagent = true
220
+ else family.session = true
221
+ families.set(key, family)
222
+ })
223
+ let count = 0
224
+ for (const family of families.values()) {
225
+ if (family.subagent && !family.session) count += 1
226
+ }
227
+ return count
228
+ }
package/lib/checkpoint.js CHANGED
@@ -106,6 +106,7 @@ const TOP_LEVEL_KEYS = new Set([
106
106
  'parkedGoals',
107
107
  'disarmedGoals',
108
108
  'seenFeatureVersions',
109
+ 'onboarding',
109
110
  'sleep',
110
111
  'updatedAt',
111
112
  ]);
@@ -175,11 +176,84 @@ export function createEmptyState(now = Date.now()) {
175
176
  restart: null,
176
177
  sessions: {},
177
178
  seenFeatureVersions: {},
179
+ onboarding: null,
178
180
  sleep: null,
179
181
  updatedAt: now,
180
182
  };
181
183
  }
182
184
 
185
+ /**
186
+ * Normalize the record of the one-time questions.
187
+ *
188
+ * The record is `{ feature, version, answeredAt, choice }`: WHICH question was
189
+ * answered (the id of the feature), the version of the question, when it was
190
+ * answered, and WHAT the user chose.
191
+ *
192
+ * A broken record is DROPPED: a question whose answer cannot be read is
193
+ * asked again — that is much better than a card that never comes back
194
+ * because of a half-written file.
195
+ *
196
+ * @param {unknown} raw
197
+ * @returns {{ onboarding: object|null, changed: boolean }}
198
+ */
199
+ function normalizeOnboarding(raw) {
200
+ if (raw == null) return { onboarding: null, changed: false };
201
+ if (!isObject(raw)) return { onboarding: null, changed: true };
202
+ const feature = nullableString(raw.feature);
203
+ const version = nullableString(raw.version);
204
+ const choice = nullableString(raw.choice);
205
+ const answeredAt = nullableNumber(raw.answeredAt);
206
+ if (feature === null || version === null || choice === null || answeredAt === null) {
207
+ return { onboarding: null, changed: true };
208
+ }
209
+ // The values that were in effect BEFORE the answer: they are what the undo
210
+ // puts back.
211
+ const previous = isObject(raw.previous)
212
+ ? {
213
+ restartMode: nullableString(raw.previous.restartMode),
214
+ restartCommand: nullableString(raw.previous.restartCommand),
215
+ }
216
+ : null;
217
+ return { onboarding: { feature, version, choice, answeredAt, previous }, changed: false };
218
+ }
219
+
220
+ /**
221
+ * Record the answer of a one-time question.
222
+ *
223
+ * @param {object} state the checkpoint state (mutated in place)
224
+ * @param {{
225
+ * feature?: string,
226
+ * version?: string,
227
+ * choice?: string,
228
+ * previous?: { restartMode?: string, restartCommand?: string }|null,
229
+ * now?: number,
230
+ * }} answer
231
+ * @returns {object} the recorded answer
232
+ */
233
+ export function recordOnboarding(state, {
234
+ feature = '',
235
+ version = '',
236
+ choice = '',
237
+ previous = null,
238
+ now = Date.now(),
239
+ } = {}) {
240
+ const record = {
241
+ feature: String(feature ?? ''),
242
+ version: String(version ?? ''),
243
+ choice: String(choice ?? ''),
244
+ answeredAt: Number.isFinite(Number(now)) ? Number(now) : Date.now(),
245
+ previous: previous && typeof previous === 'object'
246
+ ? {
247
+ restartMode: typeof previous.restartMode === 'string' ? previous.restartMode : null,
248
+ restartCommand: typeof previous.restartCommand === 'string' ? previous.restartCommand : null,
249
+ }
250
+ : null,
251
+ };
252
+ if (!state || typeof state !== 'object') return record;
253
+ state.onboarding = record;
254
+ return record;
255
+ }
256
+
183
257
  /**
184
258
  * Normalize one persisted goal record. Unknown or wrong-typed fields are
185
259
  * dropped; `{ goal: null }` means "no goal recorded".
@@ -610,6 +684,14 @@ export function normalizeState(raw, now = Date.now()) {
610
684
  if (sleep.changed) repaired = true;
611
685
  }
612
686
 
687
+ // The answer of the one-time questions: it has to survive a restart, so a
688
+ // question is never asked twice.
689
+ if (raw.onboarding != null) {
690
+ const onboarding = normalizeOnboarding(raw.onboarding);
691
+ state.onboarding = onboarding.onboarding;
692
+ if (onboarding.changed) repaired = true;
693
+ }
694
+
613
695
  return { state, repaired };
614
696
  }
615
697
 
@@ -1271,6 +1353,71 @@ export function cancelRestart(state, { now = Date.now() } = {}) {
1271
1353
  return state;
1272
1354
  }
1273
1355
 
1356
+ /**
1357
+ * Withdraw ONE requester from a pending restart — "cancel MY restart".
1358
+ *
1359
+ * A restart request is COALESCED: several sessions may ask for the same
1360
+ * restart cycle, and every one of them is kept in `restart.requesters`. So
1361
+ * "withdraw the restart I asked for" must NOT take the other requesters
1362
+ * with it — that is how one session could cancel everybody's restart.
1363
+ *
1364
+ * The rules:
1365
+ *
1366
+ * - the session must be a requester; if it is not, NOTHING is changed
1367
+ * (`reason: 'not-a-requester'`) — never a silent "cancel everything";
1368
+ * - the session id is required (`reason: 'no-session-id'` when it is
1369
+ * missing) — it is never guessed;
1370
+ * - the last requester leaving really cancels the restart (`cancelled:
1371
+ * true`), with the same cleanup as {@link cancelRestart};
1372
+ * - while other requesters remain, the restart stays and
1373
+ * `restart.bySessionId` is re-pointed at the first of them when it
1374
+ * pointed at the session that just left.
1375
+ *
1376
+ * Mutates `state` in place and returns the outcome.
1377
+ *
1378
+ * @param {object} state
1379
+ * @param {{ sessionId?: string|null, now?: number }} options
1380
+ * @returns {{ removed: boolean, cancelled: boolean, remaining: number, requesters: string[], reason: string }}
1381
+ */
1382
+ export function cancelOwnRestart(state, { sessionId = null, now = Date.now() } = {}) {
1383
+ const restart = isObject(state.restart) ? state.restart : null;
1384
+ if (!restart) {
1385
+ return { removed: false, cancelled: false, remaining: 0, requesters: [], reason: 'no-restart-pending' };
1386
+ }
1387
+ const requesters = normalizeRequesters(restart.requesters);
1388
+ const ids = requesters.map((entry) => entry.sessionId);
1389
+ const id = typeof sessionId === 'string' ? sessionId.trim() : '';
1390
+ if (id === '') {
1391
+ return { removed: false, cancelled: false, remaining: ids.length, requesters: ids, reason: 'no-session-id' };
1392
+ }
1393
+ // The requester list is the truth. The legacy fallback covers a state file
1394
+ // that knows only `bySessionId` (no requesters at all).
1395
+ const inList = ids.includes(id);
1396
+ const legacyOnly = !inList && requesters.length === 0 && restart.bySessionId === id;
1397
+ if (!inList && !legacyOnly) {
1398
+ return { removed: false, cancelled: false, remaining: ids.length, requesters: ids, reason: 'not-a-requester' };
1399
+ }
1400
+ const kept = requesters.filter((entry) => entry.sessionId !== id);
1401
+ if (kept.length === 0) {
1402
+ // The LAST requester left: the restart really goes away.
1403
+ cancelRestart(state, { now });
1404
+ return { removed: true, cancelled: true, remaining: 0, requesters: [], reason: 'cancelled' };
1405
+ }
1406
+ // Other sessions are still waiting for this restart: it stays.
1407
+ restart.requesters = kept;
1408
+ if (restart.bySessionId === id) {
1409
+ restart.bySessionId = kept[0].sessionId;
1410
+ }
1411
+ state.updatedAt = now;
1412
+ return {
1413
+ removed: true,
1414
+ cancelled: false,
1415
+ remaining: kept.length,
1416
+ requesters: kept.map((entry) => entry.sessionId),
1417
+ reason: 'withdrawn',
1418
+ };
1419
+ }
1420
+
1274
1421
  /**
1275
1422
  * Record the shutdown intent: "switch the machine off when the work is done".
1276
1423
  *