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/README.md +457 -41
- package/assets/screenshot-1.png +0 -0
- package/assets/screenshot-2.png +0 -0
- package/assets/screenshot-3.png +0 -0
- package/assets/screenshot-4.png +0 -0
- package/assets/screenshot-5.png +0 -0
- package/client.js +5973 -3222
- package/cordis.patch.yml +55 -9
- package/docs/README.zh.md +305 -27
- package/index.js +2522 -127
- package/lib/access.js +212 -0
- package/lib/activity.js +124 -0
- package/lib/autostart-advice.js +198 -0
- package/lib/backup.js +131 -4
- package/lib/blockers.js +228 -0
- package/lib/checkpoint.js +147 -0
- package/lib/diagnostics.js +180 -0
- package/lib/health.js +264 -0
- package/lib/host-kind.js +145 -0
- package/lib/lifecycle.js +17 -4
- package/lib/node-runtime.js +144 -0
- package/lib/queued.js +146 -0
- package/lib/restart-verdict.js +327 -0
- package/lib/restart.js +823 -56
- package/lib/sleep.js +46 -49
- package/lib/startup-mode.js +491 -0
- package/lib/supervisor-config.js +735 -0
- package/lib/supervisor-install.js +557 -0
- package/lib/supervisor.js +1276 -0
- package/locale/en.json +247 -60
- package/locale/zh.json +247 -60
- package/package.json +7 -2
- package/screenshots.json +6 -4
- package/scripts/emergency-disable.cmd +27 -0
- package/scripts/emergency-toggle.ps1 +302 -0
- package/scripts/run-tests.ps1 +46 -0
- package/supervisor/supervisor.js +884 -0
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
|
|
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 {
|
|
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
|
/**
|
package/lib/blockers.js
ADDED
|
@@ -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
|
*
|