dsh-autorestart 0.8.1 → 0.8.3

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/locale/en.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "label": "Pending restart",
3
3
  "busyPrefix": " · waiting for ",
4
- "busySuffix": " task(s) to finish",
4
+ "busySuffix": " session(s)",
5
5
  "idle": " · restarts once idle",
6
6
  "goalActive": " · held by a self-continuing goal",
7
7
  "goalExhausted": " · a goal ran out of rounds",
@@ -12,7 +12,7 @@
12
12
  "parkBusy": "Parking…",
13
13
  "parkError": "Park failed: {message}",
14
14
  "tooltip": "Waits for the other sessions to become idle, then restarts automatically; this session resumes its progress after the restart.",
15
- "tooltipBusy": "Waits for the other tasks to finish (the count includes work that is queued but not started yet), then restarts automatically; this session resumes its progress after the restart.",
15
+ "tooltipBusy": "Waits for the other sessions to finish (the count includes work that is queued but not started yet), then restarts automatically; this session resumes its progress after the restart.",
16
16
  "tooltipWithError": "Waits for the other sessions to become idle, then restarts automatically; this session resumes its progress after the restart. {message}",
17
17
  "dotAria": "Pending restart",
18
18
  "panelHeading": "Pending restart",
@@ -48,12 +48,13 @@
48
48
  "durationMinutesShort": "{n} min",
49
49
  "durationHoursShort": "{n}h",
50
50
  "durationHoursMinutesShort": "{n}h {m}m",
51
- "cancelPanelTasks": "This restart is still waiting for {n} task(s) to finish",
51
+ "cancelPanelTasks": "Still waiting for {n} session(s)",
52
52
  "cancelPanelOthers": "{n} other session(s) are waiting for this restart too",
53
53
  "cancelPanelOthersAfter": "{n} session(s) are still waiting",
54
54
  "cancelPanelAria": "Pending restart details",
55
55
  "cancelButton": "Cancel my restart",
56
56
  "cancelButtonAria": "Cancel my restart",
57
+ "cancelLocked": "Restarting — this can no longer be cancelled",
57
58
  "cancelConfirmTitle": "Cancel your restart?",
58
59
  "cancelConfirmOnlyMe": "After this, the restart will not happen.",
59
60
  "cancelConfirmOthers": "{n} other session(s) are waiting for this restart too — cancelling only affects you; they will still restart.",
@@ -200,8 +201,10 @@
200
201
  "copyPath": "Copy the path",
201
202
  "copied": "The path is on the clipboard",
202
203
  "copyFailed": "The copy failed — please select the path above by hand",
203
- "withPlugins": "includes the installed plugins",
204
- "withoutPlugins": "configuration only"
204
+ "time": "Backup time",
205
+ "size": "Backup size",
206
+ "withPlugins": "(plugins included)",
207
+ "withoutPlugins": "(plugins excluded)"
205
208
  },
206
209
  "backupPlugins": {
207
210
  "label": "Include the installed plugins in the backup",
@@ -298,6 +301,11 @@
298
301
  "tierNone": "(this machine has no way to run it)"
299
302
  },
300
303
  "error": "Could not save the settings: {message}",
304
+ "saveNotice": {
305
+ "saved": "Saved",
306
+ "failed": "Not saved",
307
+ "dismiss": "Dismiss"
308
+ },
301
309
  "autostartKind": {
302
310
  "other": "something else",
303
311
  "windows-task": "Windows scheduled task",
@@ -320,6 +328,20 @@
320
328
  "self-supervised": "the supervisor of this plugin started it"
321
329
  }
322
330
  },
331
+ "restart": {
332
+ "stage": {
333
+ "snapshotting": "Backing up…",
334
+ "restarting": "Restarting…",
335
+ "back": "Back"
336
+ },
337
+ "overlay": {
338
+ "snapshotting": "Backing up before DSH restart",
339
+ "size": "{done} / {total}",
340
+ "sizeDone": "{done}",
341
+ "restarting": "Restarting…",
342
+ "back": "Back again"
343
+ }
344
+ },
323
345
  "meta": {
324
346
  "title": "dsh-autorestart — restart continuation",
325
347
  "description": "Keeps agent work alive across a DeepSeek Harness restart: a durable checkpoint, goal re-arm or a continuation message on boot, and an idle-aware graceful restart the agent can request itself."
package/locale/zh.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "label": "待重启",
3
3
  "busyPrefix": " · 等待 ",
4
- "busySuffix": " 个任务结束",
4
+ "busySuffix": " 个会话",
5
5
  "idle": " · 空闲后自动重启",
6
6
  "goalActive": " · 被某个自动续跑的 goal 占着",
7
7
  "goalExhausted": " · 某个任务的 goal 轮次额度已用完,无法续跑",
@@ -12,7 +12,7 @@
12
12
  "parkBusy": "停泊中…",
13
13
  "parkError": "停泊失败:{message}",
14
14
  "tooltip": "等待其他会话空闲后自动重启;重启后会自动继续本会话进度",
15
- "tooltipBusy": "等待其他任务结束(计数含“排队但还没开始”的任务)后自动重启;重启后本会话会自动继续进度",
15
+ "tooltipBusy": "等待其他会话结束(计数含“排队但还没开始”的会话)后自动重启;重启后本会话会自动继续进度",
16
16
  "tooltipWithError": "等待其他会话空闲后自动重启;重启后本会话会自动继续进度。{message}",
17
17
  "dotAria": "待重启",
18
18
  "panelHeading": "待重启",
@@ -48,12 +48,13 @@
48
48
  "durationMinutesShort": "{n} 分钟",
49
49
  "durationHoursShort": "{n} 小时",
50
50
  "durationHoursMinutesShort": "{n} 小时 {m} 分",
51
- "cancelPanelTasks": "这次重启还在等 {n} 个任务结束",
51
+ "cancelPanelTasks": "还在等 {n} 个会话",
52
52
  "cancelPanelOthers": "还有 {n} 个会话也在等这次重启",
53
53
  "cancelPanelOthersAfter": "还剩 {n} 个会话在等",
54
54
  "cancelPanelAria": "待重启详情",
55
55
  "cancelButton": "取消我的这次重启",
56
56
  "cancelButtonAria": "取消我的这次重启",
57
+ "cancelLocked": "正在重启,已经不能取消了",
57
58
  "cancelConfirmTitle": "取消你的这次重启?",
58
59
  "cancelConfirmOnlyMe": "取消后这次重启就不会发生了。",
59
60
  "cancelConfirmOthers": "还有 {n} 个会话也在等这次重启——取消只影响你自己,他们仍会重启。",
@@ -200,8 +201,10 @@
200
201
  "copyPath": "复制路径",
201
202
  "copied": "路径已复制",
202
203
  "copyFailed": "复制失败,请手动选中上面的路径",
203
- "withPlugins": "含已装插件",
204
- "withoutPlugins": "只有配置"
204
+ "time": "备份时间",
205
+ "size": "备份大小",
206
+ "withPlugins": "(含已装插件)",
207
+ "withoutPlugins": "(不含已装插件)"
205
208
  },
206
209
  "backupPlugins": {
207
210
  "label": "备份包含已安装插件",
@@ -298,6 +301,11 @@
298
301
  "tierNone": "(这台机器没有可用机制)"
299
302
  },
300
303
  "error": "没能保存设置:{message}",
304
+ "saveNotice": {
305
+ "saved": "已生效",
306
+ "failed": "没保存成功",
307
+ "dismiss": "关闭"
308
+ },
301
309
  "autostartKind": {
302
310
  "other": "其他方式",
303
311
  "windows-task": "Windows 计划任务",
@@ -320,6 +328,20 @@
320
328
  "self-supervised": "它由本插件自己的守护在管"
321
329
  }
322
330
  },
331
+ "restart": {
332
+ "stage": {
333
+ "snapshotting": "重启前备份中",
334
+ "restarting": "正在重启…",
335
+ "back": "已回来"
336
+ },
337
+ "overlay": {
338
+ "snapshotting": "DSH 重启前备份中",
339
+ "size": "{done} / {total}",
340
+ "sizeDone": "{done}",
341
+ "restarting": "正在重启…",
342
+ "back": "已回来"
343
+ }
344
+ },
323
345
  "meta": {
324
346
  "title": "dsh-autorestart — 重启不断线",
325
347
  "description": "让 DeepSeek Harness 重启后接着干活:落盘的检查点、启动时重新武装 goal 或给一条续接提示,以及一次等人闲下来的优雅重启。"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-autorestart",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "type": "module",
5
5
  "description": "DeepSeek Harness plugin: restart without losing work. Waits until every conversation is idle, restarts the host, then automatically resumes the interrupted sessions. AI-generated, not human-reviewed: use with caution.",
6
6
  "license": "MIT",
@@ -1,5 +1,5 @@
1
1
  /**
2
- * THE WATCHDOG DRILL: three real scenarios, and the tiers on a real machine.
2
+ * THE WATCHDOG DRILL: four real scenarios, and the tiers on a real machine.
3
3
  *
4
4
  * Nothing here touches the profile of a running DSH: the drill works in its
5
5
  * own directory under the temp directory, with a FAKE host (a small script
@@ -8,6 +8,9 @@
8
8
  * 1. the host comes up -> the watchdog stands down, NO restore
9
9
  * 2. the host never comes up -> restore, start again, RECOVERED
10
10
  * 3. the host stays down -> restore ONCE, start again, FAILED
11
+ * 4. the host is UP but wrote NO ready marker (the 0.8.2 incident) -> the
12
+ * watchdog ASKS THE HEALTH ROUTE, sees the host answer, does NOT restore,
13
+ * and still deletes the pending marker AND the scheduled task.
11
14
  *
12
15
  * And then the tiers, for real:
13
16
  *
@@ -193,6 +196,133 @@ async function scenario({ name, behaviour, fixIt, expect }) {
193
196
  return report;
194
197
  }
195
198
 
199
+ /**
200
+ * A fake host that is UP and answers the health route -- and NEVER writes the
201
+ * ready marker. That is exactly the 0.8.2 incident: a healthy host with no
202
+ * marker, because the marker write failed and the failure was swallowed.
203
+ *
204
+ * @param {string} dir
205
+ */
206
+ function fakeHealthOnlyHost(dir) {
207
+ const script = path.join(dir, 'fake-health-host.mjs');
208
+ const portFile = path.join(dir, 'health-port.txt');
209
+ fs.writeFileSync(script, [
210
+ "import fs from 'node:fs';",
211
+ "import http from 'node:http';",
212
+ "import path from 'node:path';",
213
+ "const dir = process.argv[2];",
214
+ "const server = http.createServer((req, res) => {",
215
+ " res.statusCode = 200;",
216
+ " res.setHeader('content-type', 'application/json');",
217
+ " res.end(JSON.stringify({ ok: true }));",
218
+ "});",
219
+ "server.listen(0, '127.0.0.1', () => {",
220
+ " fs.writeFileSync(path.join(dir, 'health-port.txt'), String(server.address().port));",
221
+ "});",
222
+ "setTimeout(() => process.exit(0), 90000);",
223
+ '',
224
+ ].join('\n'), 'utf8');
225
+ return { script, portFile };
226
+ }
227
+
228
+ /**
229
+ * Does the Windows task still exist?
230
+ *
231
+ * @param {string} taskName
232
+ * @returns {boolean}
233
+ */
234
+ function taskStillExists(taskName) {
235
+ try {
236
+ execFileSync('schtasks', ['/Query', '/TN', taskName], { stdio: 'ignore', timeout: 15000 });
237
+ return true;
238
+ } catch {
239
+ return false;
240
+ }
241
+ }
242
+
243
+ /**
244
+ * SCENARIO 4 -- THE HOST IS UP BUT WROTE NO READY MARKER (the 0.8.2 incident).
245
+ *
246
+ * The watchdog must ask the health route itself before it restores anything.
247
+ * The host answers, so the snapshot is NOT restored -- and the pending marker
248
+ * and the real scheduled task are still cleaned up (an armed task that fires
249
+ * later would restore a healthy profile).
250
+ */
251
+ async function scenarioHostUpWithoutMarker() {
252
+ const name = 'scenario-4-no-marker';
253
+ const dir = path.join(drillRoot, name);
254
+ fs.mkdirSync(dir, { recursive: true });
255
+ const restoreLog = path.join(dir, 'restore.log');
256
+ const restore = fakeRestore(dir, false);
257
+
258
+ // (1) The host: up, answering health, NO ready marker.
259
+ const host = fakeHealthOnlyHost(dir);
260
+ const child = spawn(process.execPath, [host.script, dir], { detached: true, stdio: 'ignore' });
261
+ child.unref();
262
+ const up = await waitForFile(host.portFile, 15000);
263
+ check(name + ': the fake host came up and answers health', up);
264
+ const port = up ? Number(fs.readFileSync(host.portFile, 'utf8').trim()) : 0;
265
+ check(name + ': the health port is known', Number.isFinite(port) && port > 0, String(port));
266
+
267
+ // (2) The instance record the real host writes at boot -- WITHOUT a boot id,
268
+ // so "is an instance alive" cannot be answered from it alone: only the
269
+ // direct health question can. That is the guard this scenario tests.
270
+ const portFile = path.join(dir, 'access.json');
271
+ fs.writeFileSync(portFile, JSON.stringify({ bootId: null, port: port > 0 ? port : null, at: Date.now() }), 'utf8');
272
+
273
+ // (3) A REAL scheduling item: the watchdog has to delete it, not only the
274
+ // pending marker. (On a machine without the task scheduler the drill
275
+ // falls back to the detached-process tier and checks the markers only.)
276
+ const taskName = 'dsh-autorestart-drill-' + crypto.randomUUID().slice(0, 8);
277
+ let registered = { ok: false, error: 'the task scheduler is not used here' };
278
+ if (process.platform === 'win32') {
279
+ registered = registerScheduledTask({
280
+ taskName,
281
+ startIso: localIso(new Date(Date.now() + 15 * 60 * 1000)),
282
+ endIso: localIso(new Date(Date.now() + 45 * 60 * 1000)),
283
+ command: 'echo fired > "' + path.join(dir, 'task-fired.txt') + '"',
284
+ xmlPath: path.join(dir, 'task.xml'),
285
+ });
286
+ check(name + ': the real scheduled task was registered', registered.ok === true, registered.error || '');
287
+ }
288
+
289
+ writePending(dir, buildPending({
290
+ tier: registered.ok === true ? 'scheduled-task' : 'detached-process',
291
+ tierReason: 'the drill arms the watchdog itself',
292
+ taskName: registered.ok === true ? taskName : '',
293
+ restoreCommand: { file: process.execPath, args: [restore, dir] },
294
+ healthPath: '/__dsh_autorestart/health',
295
+ portFile,
296
+ expectedBootId: 'the-boot-before-the-restart',
297
+ timeoutMs: 3000,
298
+ recheckMs: 800,
299
+ readyTimeoutMs: 4000,
300
+ now: Date.now(),
301
+ }));
302
+
303
+ const watchdog = spawn(process.execPath, [path.join(here, 'watchdog.mjs'), '--state-dir', dir], { stdio: 'ignore' });
304
+ await new Promise((resolve) => watchdog.on('exit', resolve));
305
+
306
+ const report = readReport(dir);
307
+ const restores = fs.existsSync(restoreLog)
308
+ ? fs.readFileSync(restoreLog, 'utf8').trim().split('\n').filter(Boolean).length
309
+ : 0;
310
+ check(name + ': the report says not-needed', report !== null && report.result === 'not-needed', report ? String(report.result) : 'no report');
311
+ check(name + ': the health route was asked and answered', report !== null && report.healthCheck === 'healthy', report ? String(report.healthCheck) : 'no report');
312
+ check(name + ': nothing was restored', restores === 0, restores + ' restore(s)');
313
+ check(name + ': the pending marker is gone', !fs.existsSync(path.join(dir, 'watchdog-pending.json')));
314
+ check(name + ': no task was fired', !fs.existsSync(path.join(dir, 'task-fired.txt')));
315
+ if (registered.ok === true) {
316
+ check(name + ': the scheduled task is gone', !taskStillExists(taskName), taskName);
317
+ }
318
+ try {
319
+ child.kill();
320
+ } catch {
321
+ // It is gone anyway (it exits by itself).
322
+ }
323
+ return report;
324
+ }
325
+
196
326
  /**
197
327
  * The Windows scheduled task, for real: register it, read it back, let it
198
328
  * fire, delete it.
@@ -311,10 +441,13 @@ console.log('dsh-autorestart watchdog drill');
311
441
  console.log(' workspace: ' + drillRoot);
312
442
  console.log('');
313
443
 
314
- console.log('--- the three scenarios ---');
444
+ console.log('--- the four scenarios ---');
315
445
  await scenario({ name: 'scenario-1-normal', behaviour: 'up', fixIt: false, expect: 'not-needed' });
316
446
  await scenario({ name: 'scenario-2-recovered', behaviour: 'down', fixIt: true, expect: 'recovered' });
317
447
  await scenario({ name: 'scenario-3-failed', behaviour: 'down', fixIt: false, expect: 'failed' });
448
+ // THE 0.8.2 INCIDENT: a healthy host that never wrote the ready marker. The
449
+ // watchdog has to ask the health route instead of trusting the marker.
450
+ await scenarioHostUpWithoutMarker();
318
451
 
319
452
  console.log('');
320
453
  console.log('--- the tiers, for real ---');
@@ -80,6 +80,109 @@ function healthOk(url, timeoutMs = 5000) {
80
80
  });
81
81
  }
82
82
 
83
+ /**
84
+ * Every health URL that could answer for this host, most trustworthy first.
85
+ *
86
+ * The NEW instance of the desktop app listens on a RANDOM port, so the URL the
87
+ * old instance knew (`pending.healthUrl`) is usually worthless after a
88
+ * restart. The new instance publishes its own port in the instance record
89
+ * (`pending.portFile`), and that record is asked REGARDLESS of its boot id: any
90
+ * host that answers this route is a host that must not be restored.
91
+ *
92
+ * @param {object} pending
93
+ * @returns {string[]}
94
+ */
95
+ function healthCandidates(pending) {
96
+ const healthPath = pending.healthPath || '/__dsh_autorestart/health';
97
+ const urls = [];
98
+ const add = (url) => {
99
+ if (typeof url === 'string' && url !== '' && !urls.includes(url)) urls.push(url);
100
+ };
101
+ // (1) The port the running instance published about itself.
102
+ if (pending.portFile) {
103
+ try {
104
+ const record = JSON.parse(fs.readFileSync(pending.portFile, 'utf8'));
105
+ const port = Number(record && record.port);
106
+ if (Number.isFinite(port) && port > 0) add('http://127.0.0.1:' + port + healthPath);
107
+ } catch {
108
+ // No record yet: the host may not have written it.
109
+ }
110
+ }
111
+ // (2) A port the plugin put into the pending marker itself.
112
+ const own = Number(pending.port);
113
+ if (Number.isFinite(own) && own > 0) add('http://127.0.0.1:' + own + healthPath);
114
+ // (3) The URL of the instance that is being replaced (last resort: it is the
115
+ // OLD port, but a host that never went down still answers on it).
116
+ add(pending.healthUrl);
117
+ return urls;
118
+ }
119
+
120
+ /**
121
+ * ASK THE HOST ITSELF: is it alive?
122
+ *
123
+ * This is the last question before a restore (see step 3b of
124
+ * lib/watchdog.js). It answers:
125
+ *
126
+ * 'healthy' -- a health route answered `{"ok":true}`, or an instance of the
127
+ * NEW boot is clearly running: DO NOT RESTORE
128
+ * 'unhealthy' -- every known URL was asked and none answered, and no instance
129
+ * of the new boot is running: the restore may go ahead
130
+ * 'unknown' -- there was no URL to ask at all (no instance record, no
131
+ * health URL): the caller waits one more round
132
+ *
133
+ * The instance record is checked as well as the ports, because a port in that
134
+ * record can be the WRONG one (it may carry the configured port instead of the
135
+ * one the server really listens on). The pid of the new boot is a harder fact
136
+ * than a failed HTTP request, and "never restore a live host" is the rule that
137
+ * matters here.
138
+ *
139
+ * @param {object} pending
140
+ * @returns {Promise<{ state: 'healthy'|'unhealthy'|'unknown', detail: string|null }>}
141
+ */
142
+ async function askHealth(pending) {
143
+ const urls = healthCandidates(pending);
144
+ for (const url of urls) {
145
+ if (await healthOk(url)) return { state: 'healthy', detail: 'the health route answered: ' + url };
146
+ }
147
+ const record = readInstanceRecord(pending.portFile);
148
+ if (record && record.pid && record.bootId !== pending.expectedBootId && isAlive(record.pid)) {
149
+ return { state: 'healthy', detail: 'the instance of the new boot is running (pid ' + record.pid + ')' };
150
+ }
151
+ if (urls.length === 0) return { state: 'unknown', detail: null };
152
+ return { state: 'unhealthy', detail: 'no health route answered (' + urls.length + ' asked), no live instance' };
153
+ }
154
+
155
+ /**
156
+ * The instance record the plugin writes at boot (port, pid, boot id).
157
+ *
158
+ * @param {string} file
159
+ * @returns {object|null}
160
+ */
161
+ function readInstanceRecord(file) {
162
+ if (!file) return null;
163
+ try {
164
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
165
+ return parsed && typeof parsed === 'object' ? parsed : null;
166
+ } catch {
167
+ return null;
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Is that process still there?
173
+ *
174
+ * @param {number} pid
175
+ * @returns {boolean}
176
+ */
177
+ function isAlive(pid) {
178
+ try {
179
+ process.kill(Number(pid), 0);
180
+ return true;
181
+ } catch {
182
+ return false;
183
+ }
184
+ }
185
+
83
186
  /**
84
187
  * Is an instance of the host alive?
85
188
  *
@@ -181,6 +284,10 @@ const report = await runWatchdogFlow({
181
284
  stateDir,
182
285
  deps: {
183
286
  probeAlive: () => probeAlive(pending),
287
+ // THE LAST QUESTION BEFORE ANY RESTORE: ask the health route itself. A
288
+ // host that answers must never be rolled back, even when it forgot to
289
+ // write the ready marker.
290
+ askHealth: () => askHealth(pending),
184
291
  runRestore: () => (pending.restoreCommand
185
292
  ? runCommand(pending.restoreCommand)
186
293
  : restoreFromSnapshot({ snapshotDir: pending.snapshotDir, profileDir: pending.profileDir })),