dsh-pause-resume 0.2.0 → 0.4.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/CHANGELOG.md +39 -0
- package/README.md +15 -7
- package/client.js +26 -11
- package/index.js +38 -11
- package/lib/engine.js +394 -157
- package/lib/epoch.js +27 -0
- package/lib/heartbeat.js +34 -4
- package/lib/probe.js +113 -36
- package/lib/snapshot.js +169 -29
- package/package.json +2 -1
package/lib/epoch.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One epoch per plugin instance, shared across HMR module evaluations.
|
|
3
|
+
*
|
|
4
|
+
* A reload can leave the previous engine's press mid-await. Without a generation marker that old
|
|
5
|
+
* press must not overwrite the plan the new instance wrote. The marker lives on `globalThis`, so a
|
|
6
|
+
* re-evaluated module sees the same counter, and every write checks it.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const KEY = Symbol.for('dsh-pause-resume.epoch')
|
|
10
|
+
|
|
11
|
+
export function nextEpoch() {
|
|
12
|
+
const holder = globalThis
|
|
13
|
+
const value = Number.isSafeInteger(holder[KEY]) ? holder[KEY] : 0
|
|
14
|
+
holder[KEY] = value + 1
|
|
15
|
+
return holder[KEY]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function currentEpoch() {
|
|
19
|
+
const value = globalThis[KEY]
|
|
20
|
+
return Number.isSafeInteger(value) ? value : 0
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A disposed instance invalidates its own epoch, so its in-flight writes become no-ops. */
|
|
24
|
+
export function dropEpoch(epoch) {
|
|
25
|
+
if (currentEpoch() !== epoch) return
|
|
26
|
+
nextEpoch()
|
|
27
|
+
}
|
package/lib/heartbeat.js
CHANGED
|
@@ -34,6 +34,8 @@ export function createHeartbeat({ dir, bootId, intervalMs = 5000, listRunning, n
|
|
|
34
34
|
let stopped = false
|
|
35
35
|
let pending = []
|
|
36
36
|
let lastWritten = ''
|
|
37
|
+
/** A boot whose crash residue could not be recorded must not overwrite the previous heartbeat. */
|
|
38
|
+
let writable = true
|
|
37
39
|
|
|
38
40
|
function noteError(message) {
|
|
39
41
|
try {
|
|
@@ -66,10 +68,18 @@ export function createHeartbeat({ dir, bootId, intervalMs = 5000, listRunning, n
|
|
|
66
68
|
|
|
67
69
|
/** Flush a directory entry, so the rename that just happened survives a power loss too. */ // ui-copy-ignore
|
|
68
70
|
function syncDir(path) {
|
|
71
|
+
if (process.platform === 'win32') return // ui-copy-ignore
|
|
69
72
|
let fd = null
|
|
70
73
|
try {
|
|
71
74
|
fd = fsImpl.openSync(path, 'r')
|
|
72
75
|
fsImpl.fsyncSync(fd)
|
|
76
|
+
} catch (error) {
|
|
77
|
+
// Some filesystems refuse a directory fsync after a rename that already landed. The record is
|
|
78
|
+
// published either way; only the power-loss guarantee is weaker, so this is not a failure.
|
|
79
|
+
const code = error?.code
|
|
80
|
+
if (code !== 'EPERM' && code !== 'EACCES' && code !== 'ENOTSUP' && code !== 'EINVAL' && code !== 'ENOSYS' && code !== 'EBADF' && code !== 'EISDIR') {
|
|
81
|
+
noteError(`cannot flush ${path}: ${error?.message ?? error}`)
|
|
82
|
+
}
|
|
73
83
|
} finally {
|
|
74
84
|
if (fd !== null) closeQuietly(fsImpl, fd)
|
|
75
85
|
}
|
|
@@ -112,10 +122,13 @@ export function createHeartbeat({ dir, bootId, intervalMs = 5000, listRunning, n
|
|
|
112
122
|
}
|
|
113
123
|
}
|
|
114
124
|
|
|
115
|
-
/**
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
125
|
+
/**
|
|
126
|
+
* Read the previous process's record without claiming it. The caller has to read this before
|
|
127
|
+
* `start()` overwrites the file, and turning it into a durable plan may take a moment: a read that
|
|
128
|
+
* also claimed would leave the very next read empty if that write failed.
|
|
129
|
+
*/
|
|
130
|
+
function peek() {
|
|
131
|
+
if (residue !== null) return residue
|
|
119
132
|
let parsed = null
|
|
120
133
|
try {
|
|
121
134
|
parsed = JSON.parse(fsImpl.readFileSync(file, 'utf8'))
|
|
@@ -130,7 +143,15 @@ export function createHeartbeat({ dir, bootId, intervalMs = 5000, listRunning, n
|
|
|
130
143
|
return residue
|
|
131
144
|
}
|
|
132
145
|
|
|
146
|
+
/** Claims the previous process's heartbeat exactly once, before this process overwrites it. */
|
|
147
|
+
function capture() {
|
|
148
|
+
if (captured) return residue
|
|
149
|
+
captured = true
|
|
150
|
+
return peek()
|
|
151
|
+
}
|
|
152
|
+
|
|
133
153
|
function writePending() {
|
|
154
|
+
if (writable === false) return false
|
|
134
155
|
return dirty() ? persist(pending) : true
|
|
135
156
|
}
|
|
136
157
|
|
|
@@ -207,16 +228,25 @@ export function createHeartbeat({ dir, bootId, intervalMs = 5000, listRunning, n
|
|
|
207
228
|
}
|
|
208
229
|
}
|
|
209
230
|
|
|
231
|
+
/** Freeze the file after a failed crash-plan write: it is the only remaining copy of that work. */
|
|
232
|
+
function hold() {
|
|
233
|
+
writable = false
|
|
234
|
+
stop()
|
|
235
|
+
}
|
|
236
|
+
|
|
210
237
|
/** The residue was restored: from here on the heartbeat describes this process again. */
|
|
211
238
|
function release() {
|
|
239
|
+
writable = true
|
|
212
240
|
residue = null
|
|
213
241
|
void refresh(true)
|
|
214
242
|
}
|
|
215
243
|
|
|
216
244
|
return {
|
|
217
245
|
note,
|
|
246
|
+
peek,
|
|
218
247
|
start,
|
|
219
248
|
stop,
|
|
249
|
+
hold,
|
|
220
250
|
release,
|
|
221
251
|
flush,
|
|
222
252
|
get residue() {
|
package/lib/probe.js
CHANGED
|
@@ -10,9 +10,13 @@
|
|
|
10
10
|
* - `sessionController.cancel({ sessionId })` stops one turn and keeps the pending inbox.
|
|
11
11
|
* - `sessionController.resolveAgent(sessionId)` is the public resume path: it mounts the recorded
|
|
12
12
|
* preset and model selection, unlike a raw `agents.resume`.
|
|
13
|
-
* - `sessionController.prompt(
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* - `sessionController.prompt(request, signal)` admits one real user message after resuming the
|
|
14
|
+
* session itself. The shipped service validates the AbortSignal before it does anything, so the
|
|
15
|
+
* second argument is that signal, never the source metadata (the service builds the source).
|
|
16
|
+
* That is the only supported way to make a resumed session continue work, so a restore that must
|
|
17
|
+
* continue says so with a visible message instead of pretending the turn came back by itself.
|
|
18
|
+
* - `sessionController.resolveAgent(sessionId)` answers an already live Agent directly and wraps a
|
|
19
|
+
* cold one as `{ agent }`, or `{ error }`; both shapes are handled here.
|
|
16
20
|
* - `goals.pause` / `goals.resume` require the exact current revision, and every transition bumps
|
|
17
21
|
* it. The revision captured after pausing is the one a later resume must present.
|
|
18
22
|
*/
|
|
@@ -145,7 +149,11 @@ export function createProbe(services, options = {}) {
|
|
|
145
149
|
const goal = goalView(services, agent)
|
|
146
150
|
const owned = listJobs(services, [agent.id])
|
|
147
151
|
sessions.push({ sessionId: agent.id, running: agent.status === 'running', lastTurn: lastTurnOf(agent) })
|
|
148
|
-
|
|
152
|
+
// A disarmed goal is not work: it will not start a round, and pausing it would throw. Only an
|
|
153
|
+
// active, armed goal is something this press can stop and this restore can re-arm.
|
|
154
|
+
if (goal !== undefined && goal.phase === 'active' && goal.activation === 'armed') {
|
|
155
|
+
goals.push({ sessionId: agent.id, goalId: goal.id, revision: goal.revision })
|
|
156
|
+
}
|
|
149
157
|
for (const job of owned) if (RUNNING_JOB.has(job.status)) jobs.push({ id: job.id, owner: agent.id, status: job.status })
|
|
150
158
|
}
|
|
151
159
|
const unique = new Map()
|
|
@@ -224,12 +232,17 @@ export function createProbe(services, options = {}) {
|
|
|
224
232
|
}
|
|
225
233
|
|
|
226
234
|
/** The continuation message: a real user message, so the session history shows why it moved. */
|
|
227
|
-
function continuation(plan, sessionId) {
|
|
235
|
+
function continuation(plan, sessionId, epoch) {
|
|
236
|
+
// The epoch is the pause cycle. A retry of the same pause reuses the id (official dedupe keeps
|
|
237
|
+
// admission idempotent), while a new pause after a partial restore gets a fresh one, so the
|
|
238
|
+
// earlier admitted message cannot swallow this cycle's continuation.
|
|
239
|
+
const cycle = Number.isSafeInteger(epoch) && epoch > 0 ? epoch : 1
|
|
228
240
|
return {
|
|
229
241
|
sessionId,
|
|
230
|
-
requestId: `${plan.planId}:continue:${sessionId}`,
|
|
242
|
+
requestId: `${plan.planId}:continue:${sessionId}:${cycle}`,
|
|
231
243
|
content: [{ type: 'text', text: continueText }],
|
|
232
|
-
mode:
|
|
244
|
+
// `queue` is the shipped public mode: deliver after the current turn, start one when idle.
|
|
245
|
+
mode: 'queue',
|
|
233
246
|
}
|
|
234
247
|
}
|
|
235
248
|
|
|
@@ -237,7 +250,11 @@ export function createProbe(services, options = {}) {
|
|
|
237
250
|
async function mount(sessionId) {
|
|
238
251
|
const controller = service(services, 'sessionController')
|
|
239
252
|
if (controller !== undefined && controller !== null && typeof controller.resolveAgent === 'function') {
|
|
240
|
-
const
|
|
253
|
+
const resolved = await controller.resolveAgent(sessionId)
|
|
254
|
+
// The shipped service returns the Agent itself when it is already live, `{ agent }` after a
|
|
255
|
+
// cold resume, and `{ error }` when the resume failed. All three are official shapes.
|
|
256
|
+
if (resolved !== null && typeof resolved === 'object' && 'error' in resolved) throw resolved.error
|
|
257
|
+
const agent = resolved !== null && typeof resolved === 'object' && 'agent' in resolved ? resolved.agent : resolved
|
|
241
258
|
if (agent === undefined || agent === null || typeof agent.id !== 'string') throw new Error('resume returned no agent')
|
|
242
259
|
return agent
|
|
243
260
|
}
|
|
@@ -262,29 +279,70 @@ export function createProbe(services, options = {}) {
|
|
|
262
279
|
try {
|
|
263
280
|
agent = await mount(sessionId)
|
|
264
281
|
} catch (error) {
|
|
265
|
-
|
|
282
|
+
const detail = String(error?.message ?? error).slice(0, 120)
|
|
283
|
+
// A subagent row belongs to the parent chat that owns its routing; this process may not
|
|
284
|
+
// claim it directly, and a retry would fail the same way forever. The parent restores it.
|
|
285
|
+
if (/owned by subagent routing/i.test(detail)) {
|
|
286
|
+
return { status: 'attention', detail: 'subagent session; its parent restores it' }
|
|
287
|
+
}
|
|
288
|
+
return { status: 'failed', detail }
|
|
266
289
|
}
|
|
267
290
|
}
|
|
268
291
|
|
|
269
|
-
|
|
292
|
+
// Version 2 plans carry only `running`; a normalized version 3 plan carries `intent`. Reading
|
|
293
|
+
// the old field here keeps a plan written by 0.3.0 working across the upgrade.
|
|
294
|
+
const intent = session.intent === undefined && session.running === true ? 'continue' : session.intent
|
|
295
|
+
if (intent !== 'continue') {
|
|
296
|
+
return { status: 'restored', detail: mounted ? 'mounted' : 'attached', continued: false }
|
|
297
|
+
}
|
|
270
298
|
|
|
271
|
-
//
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
299
|
+
// A turn that is already open is not evidence the continuation arrived, but it is work in
|
|
300
|
+
// progress: starting a second one would be a duplicate. A turn that advanced and then ended is
|
|
301
|
+
// different: it may have been the continuation, or it may have been interrupted after the
|
|
302
|
+
// request was admitted. Neither case allows resending, and neither may be reported as a clean
|
|
303
|
+
// `skipped` with no attention.
|
|
304
|
+
const baseline = Number.isSafeInteger(session.lastTurn) ? session.lastTurn : null
|
|
305
|
+
const turnBefore = lastTurnOf(agent)
|
|
306
|
+
if (agent.status === 'running') return { status: 'skipped', detail: 'already running', continued: false }
|
|
307
|
+
if (baseline !== null && turnBefore !== null && turnBefore > baseline) {
|
|
308
|
+
return { status: 'attention', detail: 'turn advanced after the pause; not sent again', continued: false }
|
|
275
309
|
}
|
|
276
310
|
|
|
277
311
|
const controller = service(services, 'sessionController')
|
|
278
312
|
if (controller === undefined || controller === null || typeof controller.prompt !== 'function') {
|
|
279
313
|
return { status: 'failed', detail: 'prompt unavailable', continued: false }
|
|
280
314
|
}
|
|
281
|
-
const request = continuation(plan, sessionId)
|
|
315
|
+
const request = continuation(plan, sessionId, session.epoch)
|
|
282
316
|
try {
|
|
283
|
-
|
|
317
|
+
// Two arguments: the shipped service takes the caller's AbortSignal. Passing source metadata
|
|
318
|
+
// here made every restore fail on a live Harness while the fake in the tests accepted it.
|
|
319
|
+
await controller.prompt(request, new AbortController().signal)
|
|
284
320
|
} catch (error) {
|
|
285
321
|
return { status: 'failed', detail: String(error?.message ?? error).slice(0, 120), continued: false }
|
|
286
322
|
}
|
|
287
|
-
|
|
323
|
+
|
|
324
|
+
// Admission is not execution. The shipped prompt answers `{ accepted: true }` without waking a
|
|
325
|
+
// queue when the same request id was already admitted, so the press waits for the visible sign
|
|
326
|
+
// of a turn (running, or a newer lastTurn) before it calls this item restored.
|
|
327
|
+
const started = await waitForTurn(sessionId, baseline, options.settleMs)
|
|
328
|
+
if (started === true) return { status: 'restored', detail: mounted ? 'continued' : 'resumed and continued', continued: true }
|
|
329
|
+
return { status: 'attention', detail: 'continuation admitted, no turn started', continued: false }
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Bounded poll for the visible sign that an admitted continuation actually opened a turn. */
|
|
333
|
+
async function waitForTurn(sessionId, baseline, settle) {
|
|
334
|
+
if (settle === 0) return false
|
|
335
|
+
const deadline = Date.now() + (Number.isFinite(settle) ? settle : 1200)
|
|
336
|
+
for (;;) {
|
|
337
|
+
const agent = agentOf(services, sessionId)
|
|
338
|
+
if (agent !== undefined) {
|
|
339
|
+
if (agent.status === 'running') return true
|
|
340
|
+
const turn = lastTurnOf(agent)
|
|
341
|
+
if (baseline !== null && turn !== null && turn > baseline) return true
|
|
342
|
+
}
|
|
343
|
+
if (Date.now() >= deadline) return false
|
|
344
|
+
await new Promise((resolve) => setTimeout(resolve, 60))
|
|
345
|
+
}
|
|
288
346
|
}
|
|
289
347
|
|
|
290
348
|
/** Re-arm one paused goal. The saved revision is the person's veto: a changed goal is not ours. */
|
|
@@ -298,29 +356,47 @@ export function createProbe(services, options = {}) {
|
|
|
298
356
|
return { status: 'failed', detail: String(error?.message ?? error).slice(0, 120) }
|
|
299
357
|
}
|
|
300
358
|
}
|
|
301
|
-
const
|
|
302
|
-
if (
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
return { status: '
|
|
359
|
+
const services_goals = service(services, 'goals')
|
|
360
|
+
if (services_goals === undefined || services_goals === null || typeof services_goals.resume !== 'function') {
|
|
361
|
+
return { status: 'failed', detail: 'goals unavailable' }
|
|
362
|
+
}
|
|
363
|
+
const view = goalView(services, agent)
|
|
364
|
+
if (view === undefined || (typeof goal.goalId === 'string' && view.id !== goal.goalId)) {
|
|
365
|
+
return { status: 'skipped', detail: 'goal is gone' }
|
|
366
|
+
}
|
|
367
|
+
// An active goal is normally not ours to touch. The one exception is a goal this same plan
|
|
368
|
+
// armed in an earlier process: activation is per-process, so a restart leaves it `disarmed` at
|
|
369
|
+
// exactly the revision the plan recorded. Another person's goal has no such record.
|
|
370
|
+
if (view.phase === 'active') {
|
|
371
|
+
if (view.activation === 'armed') return { status: 'skipped', detail: 'already active' }
|
|
372
|
+
if (Number.isSafeInteger(goal.armedRevision) && goal.armedRevision === view.revision) {
|
|
373
|
+
try {
|
|
374
|
+
services_goals.resume(agent, { id: view.id, revision: view.revision })
|
|
375
|
+
} catch (error) {
|
|
376
|
+
return { status: 'failed', detail: String(error?.message ?? error).slice(0, 120) }
|
|
377
|
+
}
|
|
378
|
+
const after = goalView(services, agent)
|
|
379
|
+
return { status: 'restored', detail: 're-armed after restart', revision: after?.revision ?? null }
|
|
380
|
+
}
|
|
381
|
+
return { status: 'skipped', detail: 'goal disarmed' }
|
|
308
382
|
}
|
|
309
|
-
if (view
|
|
310
|
-
|
|
311
|
-
if (
|
|
312
|
-
|
|
313
|
-
// The plan stores the revision observed after `pause`; older plans called it `pausedRevision`.
|
|
314
|
-
const expected = Number.isSafeInteger(goal.pausedRevision) ? goal.pausedRevision : goal.revision
|
|
315
|
-
if (view.phase === 'paused' && Number.isSafeInteger(expected) && view.revision !== expected) {
|
|
316
|
-
return { status: 'skipped', detail: 'goal changed by hand' }
|
|
383
|
+
if (view.phase !== 'paused') return { status: 'skipped', detail: `goal is ${view.phase}` }
|
|
384
|
+
// The saved revision is the person's veto: a goal edited after the pause is not ours to resume.
|
|
385
|
+
if (Number.isSafeInteger(goal.revision) && view.revision !== goal.revision) {
|
|
386
|
+
return { status: 'skipped', detail: 'goal changed' }
|
|
317
387
|
}
|
|
388
|
+
// The plan stores the revision observed after `pause`; 0.3 plans keep it in `revision`, 0.2
|
|
389
|
+
// plans called it `pausedRevision`.
|
|
390
|
+
const expected = Number.isSafeInteger(goal.revision) ? goal.revision : goal.pausedRevision
|
|
391
|
+
if (Number.isSafeInteger(expected) === false) return { status: 'attention', detail: 'goal has no saved revision' }
|
|
392
|
+
if (view.revision !== expected) return { status: 'skipped', detail: 'goal changed by hand' }
|
|
318
393
|
try {
|
|
319
|
-
|
|
394
|
+
services_goals.resume(agent, { id: view.id, revision: view.revision })
|
|
320
395
|
} catch (error) {
|
|
321
396
|
return { status: 'failed', detail: String(error?.message ?? error).slice(0, 120) }
|
|
322
397
|
}
|
|
323
|
-
|
|
398
|
+
const after = goalView(services, agent)
|
|
399
|
+
return { status: 'restored', detail: 'armed', revision: after?.revision ?? null }
|
|
324
400
|
}
|
|
325
401
|
|
|
326
402
|
/**
|
|
@@ -333,10 +409,10 @@ export function createProbe(services, options = {}) {
|
|
|
333
409
|
const rows = Array.isArray(residue.sessions) ? residue.sessions : []
|
|
334
410
|
const sessions = rows
|
|
335
411
|
.filter((row) => typeof row?.sessionId === 'string')
|
|
336
|
-
.map((row) => ({ sessionId: row.sessionId, running: false, lastTurn: null, crash: true }))
|
|
412
|
+
.map((row) => ({ sessionId: row.sessionId, intent: 'mount', epoch: 0, running: false, lastTurn: null, crash: true }))
|
|
337
413
|
if (sessions.length === 0) return null
|
|
338
414
|
return {
|
|
339
|
-
version:
|
|
415
|
+
version: 3, // ui-copy-ignore
|
|
340
416
|
planId: planId ?? `crash-${Date.now().toString(36)}`,
|
|
341
417
|
status: 'paused', // ui-copy-ignore
|
|
342
418
|
source: 'crash', // ui-copy-ignore
|
|
@@ -344,6 +420,7 @@ export function createProbe(services, options = {}) {
|
|
|
344
420
|
sessions,
|
|
345
421
|
goals: [], // ui-copy-ignore
|
|
346
422
|
jobs: [], // ui-copy-ignore
|
|
423
|
+
attention: [], // ui-copy-ignore
|
|
347
424
|
crashes: sessions.length, // ui-copy-ignore
|
|
348
425
|
}
|
|
349
426
|
}
|
package/lib/snapshot.js
CHANGED
|
@@ -2,16 +2,24 @@ import * as fs from 'node:fs'
|
|
|
2
2
|
import { dirname, join } from 'node:path'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* The durable half of the pause button:
|
|
6
|
-
* consumed plan, and the last restore record.
|
|
5
|
+
* The durable half of the pause button: the active plan, the crash plan a restart inherited, one
|
|
6
|
+
* restore journal, one archive per consumed plan, and the last restore record.
|
|
7
7
|
*
|
|
8
|
-
* Every write is temp file + fsync + rename + directory fsync, so a reader never sees
|
|
9
|
-
* and a crash leaves the previous complete file in place.
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* Every write is temp file + fsync + rename + best-effort directory fsync, so a reader never sees
|
|
9
|
+
* half a plan and a crash leaves the previous complete file in place. A directory fsync that the
|
|
10
|
+
* platform refuses (EPERM/ENOTSUP on some network and sandboxed filesystems) does not undo a
|
|
11
|
+
* rename that already happened: the record is published, only its power-loss guarantee is weaker.
|
|
12
|
+
* Nothing here throws: a disk that cannot be written answers `false`/`null`, and the caller
|
|
13
|
+
* decides whether to stay in its current phase instead of claiming a state it could not record.
|
|
14
|
+
*
|
|
15
|
+
* Formats read: version 3 (this release), version 2 (0.2.x), version 1 (0.1.x). A version 1 plan is
|
|
16
|
+
* migrated conservatively: it has no turn baseline, so its sessions are mounted and never sent a
|
|
17
|
+
* continuation message, and its goal rows carry no revision, so they are reported, not resumed.
|
|
12
18
|
*/
|
|
13
19
|
|
|
14
20
|
const UNSAFE = /[^A-Za-z0-9._-]/gu
|
|
21
|
+
const CURRENT_VERSION = 3
|
|
22
|
+
const DIR_SYNC_CODES = new Set(['EPERM', 'EACCES', 'ENOTSUP', 'EINVAL', 'ENOSYS', 'EBADF', 'EISDIR'])
|
|
15
23
|
|
|
16
24
|
let counter = 0
|
|
17
25
|
|
|
@@ -32,6 +40,7 @@ export function createStore(dir, options = {}) {
|
|
|
32
40
|
const log = typeof options.log === 'function' ? options.log : () => {}
|
|
33
41
|
|
|
34
42
|
const snapshotPath = join(dir, 'snapshot.json')
|
|
43
|
+
const pendingPath = join(dir, 'pending.json')
|
|
35
44
|
const journalDir = join(dir, 'journal')
|
|
36
45
|
const restoresDir = join(dir, 'restores')
|
|
37
46
|
const lastRestorePath = join(dir, 'last-restore.json')
|
|
@@ -40,15 +49,20 @@ export function createStore(dir, options = {}) {
|
|
|
40
49
|
|
|
41
50
|
/** Flush a directory entry, so the rename that just happened survives a power loss too. */ // ui-copy-ignore
|
|
42
51
|
function syncDir(path) {
|
|
52
|
+
if (process.platform === 'win32') return // ui-copy-ignore
|
|
43
53
|
let fd = null
|
|
44
54
|
try {
|
|
45
55
|
fd = fsv.openSync(path, 'r')
|
|
46
56
|
fsv.fsyncSync(fd)
|
|
57
|
+
} catch (error) {
|
|
58
|
+
const code = error?.code
|
|
59
|
+
if (DIR_SYNC_CODES.has(code) === false) log(`cannot flush ${path}: ${error?.message ?? error}`)
|
|
47
60
|
} finally {
|
|
48
61
|
if (fd !== null) closeQuietly(fsv, fd)
|
|
49
62
|
}
|
|
50
63
|
}
|
|
51
64
|
|
|
65
|
+
/** `published` is the truth: the rename landed, so readers see the new record. */
|
|
52
66
|
function atomicWrite(target, value) {
|
|
53
67
|
let body = null
|
|
54
68
|
try {
|
|
@@ -85,29 +99,124 @@ export function createStore(dir, options = {}) {
|
|
|
85
99
|
}
|
|
86
100
|
}
|
|
87
101
|
|
|
88
|
-
function
|
|
102
|
+
function parse(path) {
|
|
89
103
|
let parsed = null
|
|
90
104
|
try {
|
|
91
|
-
parsed = JSON.parse(fsv.readFileSync(
|
|
105
|
+
parsed = JSON.parse(fsv.readFileSync(path, 'utf8'))
|
|
92
106
|
} catch {
|
|
93
107
|
return null
|
|
94
108
|
}
|
|
95
109
|
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null
|
|
96
|
-
if (parsed.version !== 2) return null
|
|
97
|
-
if (parsed.status !== 'pausing' && parsed.status !== 'paused') return null
|
|
98
110
|
return parsed
|
|
99
111
|
}
|
|
100
112
|
|
|
113
|
+
/**
|
|
114
|
+
* A version 1 plan predates the turn baseline and the goal revision. Mounting is safe; claiming
|
|
115
|
+
* that a turn was interrupted is not, and resuming a goal without its revision is not either.
|
|
116
|
+
*/
|
|
117
|
+
function migrateLegacy(legacy, stamp) {
|
|
118
|
+
const sessions = (Array.isArray(legacy.sessions) ? legacy.sessions : [])
|
|
119
|
+
.filter((row) => typeof row?.sessionId === 'string')
|
|
120
|
+
.map((row) => ({ sessionId: row.sessionId, running: false, lastTurn: null }))
|
|
121
|
+
const goals = (Array.isArray(legacy.goals) ? legacy.goals : [])
|
|
122
|
+
.filter((row) => typeof row?.sessionId === 'string')
|
|
123
|
+
.map((row) => ({ sessionId: row.sessionId, goalId: typeof row.goalId === 'string' ? row.goalId : null, revision: null }))
|
|
124
|
+
const jobs = (Array.isArray(legacy.jobs) ? legacy.jobs : []).map((row) => ({ id: String(row?.id ?? ''), owner: row?.owner ?? null }))
|
|
125
|
+
return {
|
|
126
|
+
planId: `legacy-${stamp}`, // ui-copy-ignore
|
|
127
|
+
status: 'paused', // ui-copy-ignore
|
|
128
|
+
source: 'legacy', // ui-copy-ignore
|
|
129
|
+
legacy: true, // ui-copy-ignore
|
|
130
|
+
createdAt: legacy.createdAt ?? stamp,
|
|
131
|
+
sessions,
|
|
132
|
+
goals,
|
|
133
|
+
jobs,
|
|
134
|
+
attention: ['legacy plan: sessions are mounted only; goals need a new pause'],
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function normalize(parsed, stamp = now()) {
|
|
139
|
+
if (parsed === null) return null
|
|
140
|
+
if (parsed.version === 1) return migrateLegacy(parsed, typeof parsed.createdAt === 'number' ? parsed.createdAt : stamp)
|
|
141
|
+
if (parsed.version !== 2 && parsed.version !== CURRENT_VERSION) return null
|
|
142
|
+
if (parsed.status !== 'pausing' && parsed.status !== 'paused' && parsed.status !== 'partial') return null
|
|
143
|
+
const plan = { ...parsed, version: CURRENT_VERSION }
|
|
144
|
+
if (typeof plan.planId !== 'string' || plan.planId === '') plan.planId = `plan-${plan.createdAt ?? stamp}`
|
|
145
|
+
if (Array.isArray(plan.attention) === false) plan.attention = []
|
|
146
|
+
if (plan.status === 'pausing') plan.interrupted = true
|
|
147
|
+
// `running` used to mean two different things: "this press stopped a turn here" (continue on
|
|
148
|
+
// resume) and "this session is merely in the plan" (mount only). Version 3 records the intent
|
|
149
|
+
// explicitly so a crash-only row can never turn into a continuation by accident.
|
|
150
|
+
plan.sessions = (Array.isArray(plan.sessions) ? plan.sessions : []).map((row) => {
|
|
151
|
+
if (typeof row?.sessionId !== 'string') return row
|
|
152
|
+
const intent = row.intent === 'continue' || row.intent === 'mount'
|
|
153
|
+
? row.intent
|
|
154
|
+
: (row.running === true ? 'continue' : 'mount')
|
|
155
|
+
const epoch = Number.isSafeInteger(row.epoch) && row.epoch > 0
|
|
156
|
+
? row.epoch
|
|
157
|
+
: (intent === 'continue' ? 1 : 0)
|
|
158
|
+
return { ...row, intent, epoch, running: intent === 'continue' }
|
|
159
|
+
})
|
|
160
|
+
plan.goals = (Array.isArray(plan.goals) ? plan.goals : []).map((row) => (
|
|
161
|
+
row !== null && typeof row === 'object' && Number.isSafeInteger(row.armedRevision) === false
|
|
162
|
+
? { ...row, armedRevision: null }
|
|
163
|
+
: row
|
|
164
|
+
))
|
|
165
|
+
return plan
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function readFrom(path) {
|
|
169
|
+
const parsed = parse(path)
|
|
170
|
+
if (parsed === null) return null
|
|
171
|
+
// A version 1 plan has no plan id; the file stamp keeps one journal per legacy press.
|
|
172
|
+
let stamp = now()
|
|
173
|
+
try {
|
|
174
|
+
stamp = Math.trunc(fsv.statSync(path).mtimeMs)
|
|
175
|
+
} catch {}
|
|
176
|
+
return normalize(parsed, stamp)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function read() {
|
|
180
|
+
return readFrom(snapshotPath)
|
|
181
|
+
}
|
|
182
|
+
|
|
101
183
|
function write(plan) {
|
|
102
184
|
if (plan === null || typeof plan !== 'object' || Array.isArray(plan)) return null
|
|
103
|
-
const record = { ...plan, version:
|
|
185
|
+
const record = { ...plan, version: CURRENT_VERSION }
|
|
104
186
|
return atomicWrite(snapshotPath, record) ? record : null
|
|
105
187
|
}
|
|
106
188
|
|
|
107
|
-
|
|
189
|
+
/** The crash plan survives this process: it is written before the heartbeat overwrites its file. */
|
|
190
|
+
function readPending() {
|
|
191
|
+
return readFrom(pendingPath)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function writePending(plan) {
|
|
195
|
+
if (plan === null || typeof plan !== 'object' || Array.isArray(plan)) return null
|
|
196
|
+
const record = { ...plan, version: CURRENT_VERSION }
|
|
197
|
+
return atomicWrite(pendingPath, record) ? record : null
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function clearPending() {
|
|
201
|
+
try {
|
|
202
|
+
fsv.rmSync(pendingPath, { force: true })
|
|
203
|
+
syncDir(dir)
|
|
204
|
+
return true
|
|
205
|
+
} catch (error) {
|
|
206
|
+
log(`cannot clear pending plan: ${error?.message ?? error}`)
|
|
207
|
+
return false
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Journals carry the boot that wrote each row. A row from an earlier process is evidence for the
|
|
213
|
+
* summary, never permission to skip work: the only things a new process can trust are the live
|
|
214
|
+
* Harness and the plan itself.
|
|
215
|
+
*/
|
|
216
|
+
function appendJournal(planId, key, status, bootId) {
|
|
108
217
|
let line = null
|
|
109
218
|
try {
|
|
110
|
-
line = JSON.stringify({ k: key })
|
|
219
|
+
line = JSON.stringify({ k: key, s: status ?? 'ok', b: bootId ?? null, at: now() })
|
|
111
220
|
} catch {
|
|
112
221
|
return false
|
|
113
222
|
}
|
|
@@ -127,23 +236,23 @@ export function createStore(dir, options = {}) {
|
|
|
127
236
|
}
|
|
128
237
|
|
|
129
238
|
function readJournal(planId) {
|
|
130
|
-
const
|
|
239
|
+
const rows = new Map()
|
|
131
240
|
let raw = ''
|
|
132
241
|
try {
|
|
133
242
|
raw = fsv.readFileSync(journalPath(planId), 'utf8')
|
|
134
243
|
} catch {
|
|
135
|
-
return
|
|
244
|
+
return rows
|
|
136
245
|
}
|
|
137
246
|
for (const line of raw.split('\n')) {
|
|
138
247
|
if (line === '') continue
|
|
139
248
|
try {
|
|
140
249
|
const row = JSON.parse(line)
|
|
141
|
-
if (typeof row?.k === 'string')
|
|
250
|
+
if (typeof row?.k === 'string') rows.set(row.k, { status: typeof row.s === 'string' ? row.s : 'ok', boot: typeof row.b === 'string' ? row.b : null })
|
|
142
251
|
} catch {
|
|
143
252
|
// A torn last line is a write that never finished, not a key that was restored.
|
|
144
253
|
}
|
|
145
254
|
}
|
|
146
|
-
return
|
|
255
|
+
return rows
|
|
147
256
|
}
|
|
148
257
|
|
|
149
258
|
function clearJournal(planId) {
|
|
@@ -152,47 +261,78 @@ export function createStore(dir, options = {}) {
|
|
|
152
261
|
} catch {}
|
|
153
262
|
}
|
|
154
263
|
|
|
264
|
+
function writeResult(record) {
|
|
265
|
+
return atomicWrite(lastRestorePath, record)
|
|
266
|
+
}
|
|
267
|
+
|
|
155
268
|
/**
|
|
156
269
|
* The plan is spent: archive it, publish the record the UI reads, and only then remove it. A plan
|
|
157
|
-
* that could not be archived stays on disk, so the same work is never claimed twice.
|
|
270
|
+
* that could not be archived stays on disk, so the same work is never claimed twice. A snapshot
|
|
271
|
+
* that could not be removed is reported as a failure even though the archive landed: the next
|
|
272
|
+
* reader would otherwise see a plan that is already restored and try to restore it again.
|
|
158
273
|
*/
|
|
159
274
|
function consume(plan, summary) {
|
|
160
275
|
const source = plan !== null && typeof plan === 'object' && !Array.isArray(plan) ? plan : {}
|
|
161
276
|
const archivePath = join(restoresDir, `${sanitize(source.planId)}.json`)
|
|
162
277
|
const record = { ...source, status: 'restored', restoredAt: new Date(now()).toISOString(), summary }
|
|
163
278
|
const archived = atomicWrite(archivePath, record)
|
|
164
|
-
const published = archived &&
|
|
279
|
+
const published = archived && writeResult(record)
|
|
165
280
|
if (published === false) {
|
|
166
281
|
log('cannot archive the restored plan: the snapshot stays')
|
|
167
|
-
return { ok: false, archivePath: null }
|
|
282
|
+
return { ok: false, archivePath: null, published: false }
|
|
168
283
|
}
|
|
169
284
|
const current = read()
|
|
170
285
|
// Another press may have written a newer plan while this one restored; that one is not ours.
|
|
171
|
-
if (current
|
|
286
|
+
if (current === null || current.planId === source.planId) {
|
|
172
287
|
try {
|
|
173
288
|
fsv.rmSync(snapshotPath, { force: true })
|
|
174
|
-
|
|
289
|
+
syncDir(dir)
|
|
290
|
+
} catch (error) {
|
|
291
|
+
log(`cannot clear the restored plan: ${error?.message ?? error}`)
|
|
292
|
+
return { ok: false, archivePath, published: true }
|
|
293
|
+
}
|
|
175
294
|
}
|
|
176
295
|
clearJournal(source.planId)
|
|
177
|
-
return { ok: true, archivePath }
|
|
296
|
+
return { ok: true, archivePath, published: true }
|
|
178
297
|
}
|
|
179
298
|
|
|
180
299
|
function lastRestore() {
|
|
300
|
+
const parsed = parse(lastRestorePath)
|
|
301
|
+
return parsed
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function hasSnapshot() {
|
|
181
305
|
try {
|
|
182
|
-
|
|
183
|
-
return parsed !== null && typeof parsed === 'object' ? parsed : null
|
|
306
|
+
return fsv.existsSync(snapshotPath) === true
|
|
184
307
|
} catch {
|
|
185
|
-
return
|
|
308
|
+
return false
|
|
186
309
|
}
|
|
187
310
|
}
|
|
188
311
|
|
|
189
|
-
function
|
|
312
|
+
function hasPending() {
|
|
190
313
|
try {
|
|
191
|
-
return fsv.existsSync(
|
|
314
|
+
return fsv.existsSync(pendingPath) === true
|
|
192
315
|
} catch {
|
|
193
316
|
return false
|
|
194
317
|
}
|
|
195
318
|
}
|
|
196
319
|
|
|
197
|
-
return {
|
|
320
|
+
return {
|
|
321
|
+
dir,
|
|
322
|
+
snapshotPath,
|
|
323
|
+
pendingPath,
|
|
324
|
+
read,
|
|
325
|
+
write,
|
|
326
|
+
readPending,
|
|
327
|
+
writePending,
|
|
328
|
+
clearPending,
|
|
329
|
+
appendJournal,
|
|
330
|
+
readJournal,
|
|
331
|
+
clearJournal,
|
|
332
|
+
writeResult,
|
|
333
|
+
consume,
|
|
334
|
+
lastRestore,
|
|
335
|
+
hasSnapshot,
|
|
336
|
+
hasPending,
|
|
337
|
+
}
|
|
198
338
|
}
|