dsh-pause-resume 0.2.0 → 0.3.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 +12 -7
- package/client.js +22 -10
- package/index.js +8 -6
- package/lib/engine.js +278 -155
- package/lib/heartbeat.js +23 -4
- package/lib/probe.js +74 -27
- package/lib/snapshot.js +151 -29
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -40,15 +40,19 @@ A restore is not a claim that work continued. It is three honest steps per item:
|
|
|
40
40
|
3. **Re-arm a goal** with the revision it had when paused. The revision is the person's veto: if the
|
|
41
41
|
goal changed while paused, it is not ours to resume and the item is `skipped`.
|
|
42
42
|
|
|
43
|
-
Every
|
|
44
|
-
|
|
45
|
-
and the
|
|
43
|
+
Every attempt is journaled with the id of the process that made it, but a journal is reporting, not a
|
|
44
|
+
promise: the next boot asks the live Harness again instead of trusting an old row. The continuation
|
|
45
|
+
request id is derived from the plan and the session, so a retry before a turn starts is deduplicated
|
|
46
|
+
by the Host. A failure leaves the plan on disk and the next press retries every item that still needs
|
|
47
|
+
work.
|
|
46
48
|
|
|
47
49
|
## Crash, not just a press
|
|
48
50
|
|
|
49
51
|
A press writes a snapshot. A crash writes nothing, so the plugin keeps a heartbeat instead:
|
|
50
52
|
`running.json` holds the sessions whose turn is open right now and the id of the process that wrote
|
|
51
|
-
it.
|
|
53
|
+
it. The previous process's record is turned into a crash plan in `pending.json` before the heartbeat
|
|
54
|
+
file is replaced, so a second or third restart still sees the same work and offers ▶ with no press
|
|
55
|
+
needed.
|
|
52
56
|
|
|
53
57
|
A crash record is deliberately weaker than a press: it proves a turn was open, not whether the person
|
|
54
58
|
wanted it continued. So restore mounts those sessions and lists them in the outcome; it does not
|
|
@@ -74,8 +78,8 @@ machine; local-only is the default.
|
|
|
74
78
|
a double press and a tool call cannot overlap. The second caller is told `busy`.
|
|
75
79
|
* **Durable before destructive.** No turn is cancelled until the plan is on disk. A failed write means
|
|
76
80
|
nothing was stopped.
|
|
77
|
-
* **Idempotent.**
|
|
78
|
-
the
|
|
81
|
+
* **Idempotent.** The continuation request id is derived from the plan and the session, so a retry
|
|
82
|
+
cannot post the same message twice; a turn that already moved is not continued again.
|
|
79
83
|
* **Honest.** Unreadable state never reports `paused`. A session deleted by hand, a goal changed by
|
|
80
84
|
hand, a session already running: each is skipped and named in the outcome.
|
|
81
85
|
* **No hidden takeover.** There is no public API to detach an agent, so the pause guarantee is
|
|
@@ -92,7 +96,8 @@ All state lives under `$DSH_HOME/pause-resume/`:
|
|
|
92
96
|
| File | Purpose |
|
|
93
97
|
| --- | --- |
|
|
94
98
|
| `snapshot.json` | the work a press stopped, `pausing` or `paused` |
|
|
95
|
-
| `
|
|
99
|
+
| `pending.json` | a crash plan, kept until a restore finishes |
|
|
100
|
+
| `journal/<planId>.jsonl` | restore attempts, tagged with the boot that made them |
|
|
96
101
|
| `restores/<planId>.json` | the archived plan, kept as a record of the last restore |
|
|
97
102
|
| `last-restore.json` | summary of the last restore |
|
|
98
103
|
| `running.json` | sessions with an open turn, for crash residue |
|
package/client.js
CHANGED
|
@@ -172,24 +172,36 @@ window.__ModuleLoader__.load({
|
|
|
172
172
|
ensureStyle()
|
|
173
173
|
mounted.current = true
|
|
174
174
|
let timer = null
|
|
175
|
+
let stopped = false
|
|
175
176
|
let current = null
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
177
|
+
// One request at a time, and the next one starts after this one settles. A fixed interval
|
|
178
|
+
// that aborts its own predecessor made any answer slower than the interval invisible.
|
|
179
|
+
const beat = async () => {
|
|
180
|
+
timer = null
|
|
181
|
+
if (stopped || document.visibilityState !== 'visible' || inflight.current !== null) { schedule() ; return }
|
|
181
182
|
current = typeof AbortController === 'function' ? new AbortController() : null
|
|
182
|
-
|
|
183
|
+
try {
|
|
184
|
+
await pull(current?.signal)
|
|
185
|
+
} finally {
|
|
186
|
+
current = null
|
|
187
|
+
schedule()
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const schedule = () => {
|
|
191
|
+
if (stopped || timer !== null) return
|
|
192
|
+
timer = setTimeout(() => { void beat() }, POLL_MS)
|
|
183
193
|
}
|
|
184
|
-
beat()
|
|
185
|
-
timer = setInterval(beat, POLL_MS)
|
|
186
194
|
const onVisible = () => {
|
|
187
|
-
if (document.visibilityState
|
|
195
|
+
if (document.visibilityState !== 'visible') return
|
|
196
|
+
if (timer !== null) { clearTimeout(timer); timer = null }
|
|
197
|
+
void beat()
|
|
188
198
|
}
|
|
199
|
+
void beat()
|
|
189
200
|
document.addEventListener('visibilitychange', onVisible)
|
|
190
201
|
return () => {
|
|
202
|
+
stopped = true
|
|
191
203
|
mounted.current = false
|
|
192
|
-
if (timer !== null)
|
|
204
|
+
if (timer !== null) clearTimeout(timer)
|
|
193
205
|
try {
|
|
194
206
|
current?.abort()
|
|
195
207
|
} catch {}
|
package/index.js
CHANGED
|
@@ -6,7 +6,7 @@ import { createHeartbeat } from './lib/heartbeat.js'
|
|
|
6
6
|
import { createProbe, listRunning } from './lib/probe.js'
|
|
7
7
|
|
|
8
8
|
export const name = 'dsh-pause-resume'
|
|
9
|
-
export const VERSION = '0.
|
|
9
|
+
export const VERSION = '0.3.0'
|
|
10
10
|
|
|
11
11
|
const BASE_PATH = '/dsh-pause-resume'
|
|
12
12
|
const BUTTON_HEADER = 'x-dsh-pause-resume'
|
|
@@ -147,7 +147,7 @@ function serve(ctx, engine, heartbeat, options) {
|
|
|
147
147
|
route(async (req, res) => {
|
|
148
148
|
const outcome = await engine.pause()
|
|
149
149
|
log(`pause: ${outcome.status}${outcome.status === 'busy' ? ` (${outcome.phase})` : ''}`)
|
|
150
|
-
if (outcome.status === 'paused') heartbeat.release()
|
|
150
|
+
if (outcome.status === 'paused' || outcome.status === 'partial') heartbeat.release()
|
|
151
151
|
sendJson(res, 200, { ...engine.describe(), outcome })
|
|
152
152
|
}, false),
|
|
153
153
|
],
|
|
@@ -216,6 +216,7 @@ export function apply(ctx, config = {}) {
|
|
|
216
216
|
store,
|
|
217
217
|
probe,
|
|
218
218
|
heartbeat,
|
|
219
|
+
bootId: bootId(),
|
|
219
220
|
graceMs: Number.isFinite(config?.graceMs) ? config.graceMs : 2000,
|
|
220
221
|
log,
|
|
221
222
|
})
|
|
@@ -265,11 +266,12 @@ export function apply(ctx, config = {}) {
|
|
|
265
266
|
|
|
266
267
|
void (async () => {
|
|
267
268
|
try {
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
heartbeat.
|
|
269
|
+
// Read the previous process's record before `start()` overwrites it, and persist the crash
|
|
270
|
+
// plan before the heartbeat goes away: a second restart then still sees the same work.
|
|
271
|
+
const residue = heartbeat.peek()
|
|
272
|
+
await engine.hydrate({ residue })
|
|
271
273
|
if (disposed) return
|
|
272
|
-
|
|
274
|
+
heartbeat.start()
|
|
273
275
|
if (disposed) return
|
|
274
276
|
const dispose = serve(ctx, engine, heartbeat, { allowRemote })
|
|
275
277
|
if (disposed) dispose()
|
package/lib/engine.js
CHANGED
|
@@ -6,26 +6,34 @@
|
|
|
6
6
|
* set, so two windows, a double press and a tool call can never overlap. The client is not allowed
|
|
7
7
|
* to infer a phase: it asks, and renders the answer.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* `action` is state-derived, never phase-derived. Live work asks for a pause; a plan with no live
|
|
10
|
+
* work asks for a resume; neither is `none`. That single rule is what makes a second press useful
|
|
11
|
+
* after a failed pause, a partial stop, or a restart: the button does what the Harness still needs,
|
|
12
|
+
* not what the last operation intended.
|
|
12
13
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
14
|
+
* A plan is kept until every item in it has a terminal outcome or a retryable failure. Attention
|
|
15
|
+
* outcomes (a continuation that was admitted but never opened a turn, a job that cannot be
|
|
16
|
+
* replayed) are terminal: they are carried into the last-restore record and shown as a dot, but
|
|
17
|
+
* they never cause the same message to be delivered twice. The crash plan is written to disk
|
|
18
|
+
* before the heartbeat overwrites the previous process's record, so a second or third restart
|
|
19
|
+
* still sees the same pending work.
|
|
15
20
|
*/
|
|
16
21
|
|
|
17
22
|
const TRANSIENT = new Set(['pausing', 'resuming'])
|
|
18
23
|
const TERMINAL = new Set(['restored', 'skipped', 'attention'])
|
|
19
24
|
|
|
20
|
-
|
|
25
|
+
function emptyLive() {
|
|
26
|
+
return { sessions: [], goals: [], jobs: [], readable: false, source: 'none' }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createEngine({ store, probe, heartbeat = null, bootId = 'boot', now = () => Date.now(), graceMs = 2000, log = () => {} }) {
|
|
21
30
|
let phase = 'idle'
|
|
22
31
|
let lastError = null
|
|
23
32
|
let result = null
|
|
24
33
|
/** Set while a press runs, so a second caller joins the answer instead of starting work again. */
|
|
25
34
|
let inflight = null
|
|
26
|
-
/**
|
|
27
|
-
let
|
|
28
|
-
let crashRead = false
|
|
35
|
+
/** Crash plan kept in memory only when its durable write failed; a reload loses it, the file does not. */
|
|
36
|
+
let crashFallback = null
|
|
29
37
|
let counter = 0
|
|
30
38
|
|
|
31
39
|
function newPlanId(kind) {
|
|
@@ -33,29 +41,22 @@ export function createEngine({ store, probe, heartbeat = null, now = () => Date.
|
|
|
33
41
|
return `${kind}-${now().toString(36)}-${counter.toString(36)}`
|
|
34
42
|
}
|
|
35
43
|
|
|
36
|
-
function
|
|
44
|
+
function pausedPlan() {
|
|
37
45
|
return store.read()
|
|
38
46
|
}
|
|
39
47
|
|
|
40
|
-
function
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
try {
|
|
44
|
-
const residue = heartbeat?.residue ?? null
|
|
45
|
-
// The id is derived from the residue itself: two polls of the same crash share one journal,
|
|
46
|
-
// and a different crash gets its own.
|
|
47
|
-
const at = Number.isFinite(residue?.at) ? residue.at : 0
|
|
48
|
-
const count = Array.isArray(residue?.sessions) ? residue.sessions.length : 0
|
|
49
|
-
crash = probe.crashPlan(residue, { planId: `crash-${at}-${count}` })
|
|
50
|
-
} catch (error) {
|
|
51
|
-
log(`cannot read crash residue: ${error?.message ?? error}`)
|
|
52
|
-
crash = null
|
|
53
|
-
}
|
|
54
|
-
return crash
|
|
48
|
+
function crashPlan() {
|
|
49
|
+
const stored = typeof store.readPending === 'function' ? store.readPending() : null
|
|
50
|
+
return stored ?? crashFallback
|
|
55
51
|
}
|
|
56
52
|
|
|
53
|
+
/** Exactly one plan is pending, and a finished pause outranks a leftover crash record. */
|
|
57
54
|
function currentPlan() {
|
|
58
|
-
return
|
|
55
|
+
return pausedPlan() ?? crashPlan()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isCrashPlan(plan) {
|
|
59
|
+
return plan !== null && plan !== undefined && plan.source === 'crash'
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
function itemKeys(plan) {
|
|
@@ -66,10 +67,17 @@ export function createEngine({ store, probe, heartbeat = null, now = () => Date.
|
|
|
66
67
|
return keys
|
|
67
68
|
}
|
|
68
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Rows written by an earlier process are evidence, not permission to skip: a new process must
|
|
72
|
+
* check the live Harness again. Only this boot's rows reduce what is still outstanding.
|
|
73
|
+
*/
|
|
69
74
|
function outstandingOf(plan) {
|
|
70
75
|
if (plan === null || plan === undefined) return 0
|
|
71
|
-
const
|
|
72
|
-
return itemKeys(plan).filter((key) =>
|
|
76
|
+
const rows = store.readJournal(plan.planId)
|
|
77
|
+
return itemKeys(plan).filter((key) => {
|
|
78
|
+
const row = rows.get(key)
|
|
79
|
+
return row === undefined || (row.boot !== null && row.boot !== bootId)
|
|
80
|
+
}).length
|
|
73
81
|
}
|
|
74
82
|
|
|
75
83
|
function countsOf(plan) {
|
|
@@ -83,14 +91,37 @@ export function createEngine({ store, probe, heartbeat = null, now = () => Date.
|
|
|
83
91
|
|
|
84
92
|
function liveNow() {
|
|
85
93
|
try {
|
|
86
|
-
|
|
87
|
-
return value ?? { sessions: [], goals: [], jobs: [], readable: false, source: 'none' }
|
|
94
|
+
return probe.live() ?? emptyLive()
|
|
88
95
|
} catch (error) {
|
|
89
96
|
log(`cannot read live work: ${error?.message ?? error}`)
|
|
90
|
-
return
|
|
97
|
+
return emptyLive()
|
|
91
98
|
}
|
|
92
99
|
}
|
|
93
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Only work that was actually stopped goes into the plan: a session whose turn was open, or a
|
|
103
|
+
* session that carries an armed goal. A session that ended normally is left alone, however it
|
|
104
|
+
* happens to look in the session list.
|
|
105
|
+
*/
|
|
106
|
+
function workOf(current) {
|
|
107
|
+
const sessions = new Map()
|
|
108
|
+
for (const session of current.sessions ?? []) {
|
|
109
|
+
if (session.running === true) sessions.set(session.sessionId, session)
|
|
110
|
+
}
|
|
111
|
+
for (const goal of current.goals ?? []) {
|
|
112
|
+
if (!sessions.has(goal.sessionId)) sessions.set(goal.sessionId, { sessionId: goal.sessionId, running: false, lastTurn: null })
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
sessions: [...sessions.values()],
|
|
116
|
+
goals: current.goals ?? [],
|
|
117
|
+
jobs: current.jobs ?? [], // ui-copy-ignore
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function liveWorkCount(work) {
|
|
122
|
+
return work.sessions.length + work.goals.length + work.jobs.length
|
|
123
|
+
}
|
|
124
|
+
|
|
94
125
|
/**
|
|
95
126
|
* What the button shows. `action` is the one decision the client may not make itself:
|
|
96
127
|
* `none` (nothing to do), `pause`, `resume`, or `busy` (an operation is running).
|
|
@@ -98,167 +129,229 @@ export function createEngine({ store, probe, heartbeat = null, now = () => Date.
|
|
|
98
129
|
function describe() {
|
|
99
130
|
const plan = currentPlan()
|
|
100
131
|
const current = liveNow()
|
|
101
|
-
const busy = TRANSIENT.has(phase)
|
|
102
132
|
const work = workOf(current)
|
|
103
|
-
const
|
|
104
|
-
const
|
|
105
|
-
|
|
133
|
+
const busy = TRANSIENT.has(phase)
|
|
134
|
+
const workCount = liveWorkCount(work)
|
|
135
|
+
let action = 'none'
|
|
136
|
+
if (busy) action = 'busy'
|
|
137
|
+
else if (current.readable === true && workCount > 0) action = 'pause'
|
|
138
|
+
else if (plan !== null) action = 'resume'
|
|
139
|
+
const attention = plan !== null
|
|
140
|
+
? (Array.isArray(plan.attention) ? plan.attention.length : 0)
|
|
141
|
+
: (Array.isArray(result?.attention) ? result.attention.length : 0)
|
|
106
142
|
return {
|
|
107
143
|
phase,
|
|
108
144
|
action,
|
|
109
145
|
source: plan === null ? null : plan.source ?? 'pause',
|
|
110
|
-
//
|
|
111
|
-
complete: plan === null ? null : plan.status
|
|
112
|
-
outstanding:
|
|
146
|
+
// `complete` means the stop finished and nothing is still turning. A partial stop is not.
|
|
147
|
+
complete: plan === null ? null : plan.status !== 'partial' && plan.status !== 'pausing' && lastError !== 'store',
|
|
148
|
+
outstanding: outstandingOf(plan), // ui-copy-ignore
|
|
113
149
|
counts: countsOf(plan), // ui-copy-ignore
|
|
114
150
|
live: { // ui-copy-ignore
|
|
115
151
|
sessions: work.sessions.filter((session) => session.running === true).length,
|
|
116
152
|
goals: current.goals.length,
|
|
117
153
|
jobs: current.jobs.length,
|
|
118
154
|
},
|
|
119
|
-
attention
|
|
120
|
-
error: lastError ?? (plan?.status === 'pausing' ? 'pause-interrupted' : null), // ui-copy-ignore
|
|
155
|
+
attention,
|
|
156
|
+
error: lastError ?? (plan?.status === 'pausing' ? 'pause-interrupted' : plan?.status === 'partial' ? 'pause-partial' : null), // ui-copy-ignore
|
|
121
157
|
result,
|
|
122
158
|
}
|
|
123
159
|
}
|
|
124
160
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
function workOf(current) {
|
|
131
|
-
const sessions = new Map()
|
|
132
|
-
for (const session of current.sessions ?? []) {
|
|
133
|
-
if (session.running === true) sessions.set(session.sessionId, session)
|
|
161
|
+
function freshSession(session) {
|
|
162
|
+
return {
|
|
163
|
+
sessionId: session.sessionId,
|
|
164
|
+
running: session.running === true,
|
|
165
|
+
lastTurn: Number.isSafeInteger(session.lastTurn) ? session.lastTurn : null,
|
|
134
166
|
}
|
|
135
|
-
|
|
136
|
-
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function freshGoal(goal) {
|
|
170
|
+
return { sessionId: goal.sessionId, goalId: goal.goalId, revision: goal.revision }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function freshJob(job) {
|
|
174
|
+
return { id: job.id, owner: job.owner ?? null, status: job.status ?? null }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function unionSessions(base, additions) {
|
|
178
|
+
const out = []
|
|
179
|
+
const index = new Map()
|
|
180
|
+
for (const row of [...base, ...additions]) {
|
|
181
|
+
if (typeof row?.sessionId !== 'string') continue
|
|
182
|
+
const existing = index.get(row.sessionId)
|
|
183
|
+
if (existing !== undefined) {
|
|
184
|
+
// Stopping again refreshes the baseline; a plain union would keep a stale turn number.
|
|
185
|
+
if (Number.isSafeInteger(row.lastTurn)) existing.lastTurn = row.lastTurn
|
|
186
|
+
if (row.running === false) existing.running = false
|
|
187
|
+
continue
|
|
188
|
+
}
|
|
189
|
+
const copy = { ...row }
|
|
190
|
+
index.set(copy.sessionId, copy)
|
|
191
|
+
out.push(copy)
|
|
137
192
|
}
|
|
138
|
-
return
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
193
|
+
return out
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function unionById(base, additions, idOf) {
|
|
197
|
+
const out = []
|
|
198
|
+
const seen = new Set()
|
|
199
|
+
for (const row of [...base, ...additions]) {
|
|
200
|
+
const id = idOf(row)
|
|
201
|
+
if (typeof id !== 'string' || id === '' || seen.has(id)) continue
|
|
202
|
+
seen.add(id)
|
|
203
|
+
out.push(row)
|
|
142
204
|
}
|
|
205
|
+
return out
|
|
143
206
|
}
|
|
144
207
|
|
|
145
|
-
function
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
source: 'pause', // ui-copy-ignore
|
|
151
|
-
createdAt: new Date(now()).toISOString(),
|
|
152
|
-
sessions: workOf(current).sessions.map((session) => ({
|
|
153
|
-
sessionId: session.sessionId,
|
|
154
|
-
running: session.running === true,
|
|
155
|
-
lastTurn: Number.isSafeInteger(session.lastTurn) ? session.lastTurn : null,
|
|
156
|
-
})),
|
|
157
|
-
goals: workOf(current).goals.map((goal) => ({
|
|
158
|
-
sessionId: goal.sessionId,
|
|
159
|
-
goalId: goal.goalId,
|
|
160
|
-
revision: goal.revision,
|
|
161
|
-
})),
|
|
162
|
-
jobs: workOf(current).jobs.map((job) => ({ id: job.id, owner: job.owner ?? null, status: job.status ?? null })),
|
|
208
|
+
function persist(plan) {
|
|
209
|
+
if (isCrashPlan(plan) === true) {
|
|
210
|
+
const stored = store.writePending(plan)
|
|
211
|
+
if (stored !== null) crashFallback = null
|
|
212
|
+
return stored
|
|
163
213
|
}
|
|
214
|
+
return store.write(plan)
|
|
164
215
|
}
|
|
165
216
|
|
|
166
|
-
|
|
217
|
+
/**
|
|
218
|
+
* Pause, including the second attempt after a partial stop. Capture happens before anything is
|
|
219
|
+
* stopped, and the plan is durable before the first cancellation, so a crash in between is
|
|
220
|
+
* visible as an interrupted pause instead of a clean one.
|
|
221
|
+
*/
|
|
222
|
+
async function runPause(basePlan) {
|
|
167
223
|
const current = liveNow()
|
|
224
|
+
const work = workOf(current)
|
|
168
225
|
if (current.readable !== true) {
|
|
169
|
-
phase = 'idle'
|
|
226
|
+
phase = basePlan === null ? 'idle' : 'paused'
|
|
170
227
|
lastError = 'registry'
|
|
171
228
|
return { status: 'failed', reason: 'registry' }
|
|
172
229
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
return { status: 'idle', action: 'none' }
|
|
230
|
+
if (liveWorkCount(work) === 0) {
|
|
231
|
+
phase = basePlan === null ? 'idle' : 'paused'
|
|
232
|
+
return basePlan === null ? { status: 'idle', action: 'none' } : { status: 'already', phase }
|
|
177
233
|
}
|
|
178
234
|
lastError = null
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
235
|
+
const plan = basePlan === null
|
|
236
|
+
? {
|
|
237
|
+
version: 3, // ui-copy-ignore
|
|
238
|
+
planId: newPlanId('pause'),
|
|
239
|
+
status: 'pausing', // ui-copy-ignore
|
|
240
|
+
source: 'pause', // ui-copy-ignore
|
|
241
|
+
createdAt: new Date(now()).toISOString(),
|
|
242
|
+
sessions: [], // ui-copy-ignore
|
|
243
|
+
goals: [], // ui-copy-ignore
|
|
244
|
+
jobs: [], // ui-copy-ignore
|
|
245
|
+
attention: [], // ui-copy-ignore
|
|
246
|
+
}
|
|
247
|
+
: { ...basePlan, status: 'pausing', attention: Array.isArray(basePlan.attention) ? basePlan.attention : [] }
|
|
248
|
+
plan.sessions = unionSessions(plan.sessions ?? [], work.sessions.map(freshSession))
|
|
249
|
+
plan.goals = unionById(plan.goals ?? [], work.goals.map(freshGoal), (goal) => `${goal?.sessionId}:${goal?.goalId}`)
|
|
250
|
+
plan.jobs = unionById(plan.jobs ?? [], work.jobs.map(freshJob), (job) => job?.id)
|
|
251
|
+
if (persist(plan) === null) {
|
|
252
|
+
// Nothing was stopped: the state is exactly what it was before the press.
|
|
253
|
+
phase = basePlan === null ? 'idle' : 'paused'
|
|
184
254
|
lastError = 'store'
|
|
185
255
|
return { status: 'failed', reason: 'store' }
|
|
186
256
|
}
|
|
257
|
+
|
|
187
258
|
let stopped = null
|
|
188
259
|
try {
|
|
189
260
|
stopped = await probe.stop(current, { graceMs })
|
|
190
261
|
} catch (error) {
|
|
191
262
|
log(`stopping work failed: ${error?.message ?? error}`)
|
|
192
|
-
stopped = { canceled: [], unknown: [], pausedGoals: [], goalErrors: [], killedJobs: [], jobErrors: [], stillRunning: [] }
|
|
263
|
+
stopped = { canceled: [], unknown: [], pausedGoals: [], goalErrors: [], killedJobs: [], jobErrors: [], stillRunning: [], sessions: [] }
|
|
193
264
|
}
|
|
194
265
|
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
// pause is reported instead and left alone.
|
|
198
|
-
// A cancelled turn bumps the agent's turn counter, so the baseline has to be read after the
|
|
199
|
-
// stop, not before it. Without this a restore would mistake its own cancelled turn for work
|
|
200
|
-
// the person continued by hand and skip the continuation it was asked to send.
|
|
266
|
+
// A cancelled turn bumps the agent's turn counter, so the baseline is read after the stop.
|
|
267
|
+
// Without this a restore would mistake its own cancelled turn for work the person continued.
|
|
201
268
|
const baseline = new Map((stopped.sessions ?? []).map((session) => [session.sessionId, session.lastTurn]))
|
|
202
|
-
|
|
203
|
-
? { ...session, lastTurn: Number.isSafeInteger(baseline.get(session.sessionId)) ? baseline.get(session.sessionId) :
|
|
269
|
+
plan.sessions = plan.sessions.map((session) => baseline.has(session.sessionId)
|
|
270
|
+
? { ...session, lastTurn: Number.isSafeInteger(baseline.get(session.sessionId)) ? baseline.get(session.sessionId) : session.lastTurn }
|
|
204
271
|
: session)
|
|
205
|
-
|
|
272
|
+
// A goal that could not be paused is not in the plan: resuming it would bump a revision for no
|
|
273
|
+
// reason. It is reported instead, and the person can pause again after fixing whatever blocked it.
|
|
274
|
+
const pausedGoals = (stopped.pausedGoals ?? []).map((goal) => ({
|
|
206
275
|
sessionId: goal.sessionId,
|
|
207
276
|
goalId: goal.goalId,
|
|
208
277
|
revision: goal.pausedRevision,
|
|
209
278
|
}))
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
279
|
+
const keptGoals = (plan.goals ?? []).filter((goal) => !(stopped.pausedGoals ?? []).some((row) => row.sessionId === goal.sessionId && row.goalId === goal.goalId))
|
|
280
|
+
plan.goals = [...keptGoals, ...pausedGoals]
|
|
281
|
+
const stillRunning = stopped.stillRunning ?? []
|
|
282
|
+
plan.status = stillRunning.length > 0 ? 'partial' : 'paused'
|
|
283
|
+
plan.stoppedAt = new Date(now()).toISOString()
|
|
284
|
+
plan.stopped = {
|
|
213
285
|
canceled: stopped.canceled ?? [],
|
|
214
286
|
unknown: stopped.unknown ?? [],
|
|
215
|
-
stillRunning
|
|
287
|
+
stillRunning,
|
|
216
288
|
goalErrors: stopped.goalErrors ?? [],
|
|
217
289
|
killedJobs: stopped.killedJobs ?? [],
|
|
218
290
|
jobErrors: stopped.jobErrors ?? [],
|
|
219
291
|
}
|
|
220
|
-
|
|
221
|
-
...(
|
|
292
|
+
plan.attention = [
|
|
293
|
+
...(Array.isArray(basePlan?.attention) ? basePlan.attention : []),
|
|
294
|
+
...stillRunning.map((id) => ({ kind: 'session', id, detail: 'still running' })),
|
|
222
295
|
...(stopped.goalErrors ?? []).map((detail) => ({ kind: 'goal', id: '', detail })),
|
|
223
296
|
...(stopped.jobErrors ?? []).map((detail) => ({ kind: 'job', id: '', detail })),
|
|
224
297
|
]
|
|
225
|
-
if (
|
|
226
|
-
// The
|
|
298
|
+
if (isCrashPlan(basePlan) === true) {
|
|
299
|
+
// The crash record described work that was not paused. This press did pause it, so the plan
|
|
300
|
+
// becomes a normal pause plan and the crash evidence is no longer a separate obligation.
|
|
301
|
+
plan.source = 'pause' // ui-copy-ignore
|
|
302
|
+
if (store.write(plan) === null) {
|
|
303
|
+
const kept = store.writePending(plan)
|
|
304
|
+
if (kept === null) {
|
|
305
|
+
crashFallback = plan
|
|
306
|
+
lastError = 'store'
|
|
307
|
+
}
|
|
308
|
+
} else if (typeof store.clearPending === 'function') {
|
|
309
|
+
if (store.clearPending() === false) lastError = 'store'
|
|
310
|
+
}
|
|
311
|
+
crashFallback = null
|
|
312
|
+
} else if (store.write(plan) === null) {
|
|
313
|
+
// The durable copy still says `pausing` or `paused`: the next boot reports it instead of
|
|
314
|
+
// claiming a stop that was never written.
|
|
227
315
|
lastError = 'store'
|
|
228
316
|
}
|
|
229
317
|
phase = 'paused'
|
|
230
|
-
return { status: 'paused', counts: countsOf(
|
|
318
|
+
return { status: plan.status === 'partial' ? 'partial' : 'paused', counts: countsOf(plan), outstanding: outstandingOf(plan) }
|
|
231
319
|
}
|
|
232
320
|
|
|
233
|
-
function summaryOf() {
|
|
234
|
-
return { restored: [], skipped: [], failed: [], attention: [], jobs: 0, at: new Date(now()).toISOString() }
|
|
321
|
+
function summaryOf(plan) {
|
|
322
|
+
return { planId: plan.planId, source: plan.source ?? 'pause', restored: [], skipped: [], failed: [], attention: [], jobs: 0, at: new Date(now()).toISOString() }
|
|
235
323
|
}
|
|
236
324
|
|
|
237
325
|
/**
|
|
238
|
-
* One restore pass.
|
|
239
|
-
*
|
|
240
|
-
*
|
|
326
|
+
* One restore pass. Items are not skipped from the journal: the live Harness decides whether a
|
|
327
|
+
* session is already back, which is the only answer that survives a second process, a manual
|
|
328
|
+
* resume, or a reload. A retryable failure keeps the plan; attention outcomes are terminal.
|
|
241
329
|
*/
|
|
242
330
|
async function runResume(plan) {
|
|
243
331
|
lastError = null
|
|
244
|
-
// A crash in the middle of a pause left a plan that never finished stopping.
|
|
245
|
-
//
|
|
246
|
-
// person asked for silence, not another round.
|
|
332
|
+
// A crash in the middle of a pause left a plan that never finished stopping. Mounting is safe;
|
|
333
|
+
// continuing is not, because the person asked for silence and the turn may have died mid-way.
|
|
247
334
|
if (plan.status === 'pausing') {
|
|
248
|
-
plan.source = 'interrupted'
|
|
335
|
+
plan.source = plan.source === 'crash' ? 'crash' : 'interrupted'
|
|
249
336
|
plan.attention = [...(plan.attention ?? []), { kind: 'pause', id: '', detail: 'pause interrupted' }]
|
|
250
337
|
plan.sessions = (plan.sessions ?? []).map((session) => ({ ...session, running: false, lastTurn: null }))
|
|
251
338
|
plan.status = 'paused'
|
|
339
|
+
if (persist(plan) === null) {
|
|
340
|
+
phase = 'paused'
|
|
341
|
+
lastError = 'store'
|
|
342
|
+
return { status: 'partial', summary: null, outstanding: outstandingOf(plan) }
|
|
343
|
+
}
|
|
252
344
|
}
|
|
253
|
-
const summary = summaryOf()
|
|
345
|
+
const summary = summaryOf(plan)
|
|
254
346
|
summary.jobs = countsOf(plan).jobs
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
347
|
+
const record = (key, status) => {
|
|
348
|
+
if (store.appendJournal(plan.planId, key, status, bootId) === false) {
|
|
349
|
+
// Reporting only: the restore already happened, so this never blocks the plan. It does mean
|
|
350
|
+
// a later boot counts the item as outstanding and checks it again, which is safe.
|
|
351
|
+
log(`cannot journal ${key}`)
|
|
352
|
+
}
|
|
259
353
|
}
|
|
260
354
|
const step = async (key, label, run) => {
|
|
261
|
-
if (done.has(key)) return
|
|
262
355
|
let outcome
|
|
263
356
|
try {
|
|
264
357
|
outcome = await run()
|
|
@@ -266,55 +359,60 @@ export function createEngine({ store, probe, heartbeat = null, now = () => Date.
|
|
|
266
359
|
outcome = { status: 'failed', detail: String(error?.message ?? error).slice(0, 120) }
|
|
267
360
|
}
|
|
268
361
|
const status = typeof outcome?.status === 'string' ? outcome.status : 'failed'
|
|
269
|
-
if (TERMINAL.has(status)) {
|
|
270
|
-
record(key)
|
|
271
|
-
if (status === 'restored') summary.restored.push(label)
|
|
272
|
-
else if (status === 'skipped') summary.skipped.push(label)
|
|
273
|
-
else {
|
|
274
|
-
summary.attention.push(`${label}: ${outcome?.detail ?? 'needs a look'}`)
|
|
275
|
-
plan.attention = [...(plan.attention ?? []), { kind: 'session', id: label, detail: String(outcome?.detail ?? '') }]
|
|
276
|
-
}
|
|
277
|
-
} else {
|
|
362
|
+
if (TERMINAL.has(status) === false) {
|
|
278
363
|
summary.failed.push(`${label}: ${outcome?.detail ?? 'unknown'}`)
|
|
364
|
+
return
|
|
279
365
|
}
|
|
366
|
+
record(key, status)
|
|
367
|
+
if (status === 'restored') summary.restored.push(label)
|
|
368
|
+
else if (status === 'skipped') summary.skipped.push(label)
|
|
369
|
+
else summary.attention.push(`${label}: ${outcome?.detail ?? 'needs a look'}`)
|
|
280
370
|
}
|
|
281
371
|
|
|
282
372
|
for (const session of plan.sessions ?? []) {
|
|
283
373
|
await step(`s:${session.sessionId}`, `session ${session.sessionId}`, () => probe.restoreSession(session, { plan }))
|
|
284
374
|
}
|
|
285
375
|
for (const goal of plan.goals ?? []) {
|
|
286
|
-
|
|
287
|
-
await step(key, `goal ${goal.goalId ?? goal.sessionId}`, () => probe.restoreGoal(goal, { plan }))
|
|
376
|
+
await step(`g:${goal.sessionId}:${goal.goalId ?? ''}`, `goal ${goal.goalId ?? goal.sessionId}`, () => probe.restoreGoal(goal, { plan }))
|
|
288
377
|
}
|
|
289
378
|
// A killed job cannot be replayed: the same command may have had an unknown effect, so it is
|
|
290
|
-
// reported once and left to the person. It
|
|
379
|
+
// reported once and left to the person. It never blocks the plan and is never restarted.
|
|
291
380
|
for (const job of plan.jobs ?? []) summary.attention.push(`job ${job.id}: stopped, not restarted`)
|
|
292
381
|
|
|
293
382
|
result = summary
|
|
294
|
-
|
|
295
|
-
const pending = itemKeys(plan).filter((key) => !done.has(key))
|
|
296
|
-
if (failed || pending.length > 0) {
|
|
383
|
+
if (summary.failed.length > 0) {
|
|
297
384
|
plan.restore = { at: summary.at, attempts: (plan.restore?.attempts ?? 0) + 1, failed: summary.failed } // ui-copy-ignore
|
|
298
|
-
if (
|
|
385
|
+
if (persist(plan) === null) lastError = 'store'
|
|
299
386
|
else lastError = 'restore-partial'
|
|
300
387
|
phase = 'paused'
|
|
301
|
-
return { status: 'partial', summary, outstanding:
|
|
388
|
+
return { status: 'partial', summary, outstanding: outstandingOf(plan) }
|
|
302
389
|
}
|
|
303
390
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
391
|
+
if (isCrashPlan(plan) === true) {
|
|
392
|
+
if (typeof store.writeResult === 'function' && store.writeResult({ ...plan, status: 'restored', restoredAt: summary.at, summary }) === false) {
|
|
393
|
+
lastError = 'store'
|
|
394
|
+
phase = 'paused'
|
|
395
|
+
return { status: 'partial', summary, outstanding: 0 }
|
|
396
|
+
}
|
|
397
|
+
if (typeof store.clearPending !== 'function' || store.clearPending() === false) {
|
|
398
|
+
lastError = 'store'
|
|
399
|
+
phase = 'paused'
|
|
400
|
+
return { status: 'partial', summary, outstanding: 0 }
|
|
401
|
+
}
|
|
402
|
+
crashFallback = null
|
|
403
|
+
if (typeof store.clearJournal === 'function') store.clearJournal(plan.planId)
|
|
313
404
|
try {
|
|
314
405
|
heartbeat?.release?.()
|
|
315
406
|
} catch (error) {
|
|
316
407
|
log(`cannot release crash record: ${error?.message ?? error}`)
|
|
317
408
|
}
|
|
409
|
+
} else {
|
|
410
|
+
const consumed = store.consume(plan, summary)
|
|
411
|
+
if (!consumed?.ok) {
|
|
412
|
+
lastError = 'store'
|
|
413
|
+
phase = 'paused'
|
|
414
|
+
return { status: 'partial', summary, outstanding: 0 }
|
|
415
|
+
}
|
|
318
416
|
}
|
|
319
417
|
phase = 'idle'
|
|
320
418
|
return { status: 'resumed', summary }
|
|
@@ -329,7 +427,7 @@ export function createEngine({ store, probe, heartbeat = null, now = () => Date.
|
|
|
329
427
|
} catch (error) {
|
|
330
428
|
log(`${kind} failed: ${error?.message ?? error}`)
|
|
331
429
|
lastError = 'internal'
|
|
332
|
-
phase =
|
|
430
|
+
phase = currentPlan() === null ? 'idle' : 'paused'
|
|
333
431
|
return { status: 'failed', reason: 'internal' }
|
|
334
432
|
} finally {
|
|
335
433
|
inflight = null
|
|
@@ -341,11 +439,15 @@ export function createEngine({ store, probe, heartbeat = null, now = () => Date.
|
|
|
341
439
|
|
|
342
440
|
async function pause() {
|
|
343
441
|
if (TRANSIENT.has(phase) || inflight !== null) return { status: 'busy', phase }
|
|
344
|
-
|
|
442
|
+
const plan = currentPlan()
|
|
443
|
+
if (plan !== null) {
|
|
444
|
+
const work = workOf(liveNow())
|
|
445
|
+
if (liveWorkCount(work) === 0) return { status: 'already', phase }
|
|
446
|
+
}
|
|
345
447
|
// Set synchronously, before the first await: two windows pressing at once are then exclusive
|
|
346
448
|
// in the same tick they were dispatched, not one microtask later.
|
|
347
449
|
phase = 'pausing'
|
|
348
|
-
return once('pause', runPause)
|
|
450
|
+
return once('pause', () => runPause(plan))
|
|
349
451
|
}
|
|
350
452
|
|
|
351
453
|
async function resume() {
|
|
@@ -361,12 +463,33 @@ export function createEngine({ store, probe, heartbeat = null, now = () => Date.
|
|
|
361
463
|
})
|
|
362
464
|
}
|
|
363
465
|
|
|
364
|
-
/**
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
466
|
+
/**
|
|
467
|
+
* A reload or restart must not invent a phase: the disk and the running Harness decide it. The
|
|
468
|
+
* crash residue is handed in by the caller, because it has to be read before the heartbeat
|
|
469
|
+
* overwrites the previous process's file; it is persisted here so a second restart still sees it.
|
|
470
|
+
*/
|
|
471
|
+
async function hydrate(options = {}) {
|
|
472
|
+
crashFallback = null
|
|
473
|
+
const residue = options?.residue ?? heartbeat?.residue ?? null
|
|
474
|
+
if (residue !== null && residue !== undefined && pausedPlan() === null && crashPlan() === null) {
|
|
475
|
+
let plan = null
|
|
476
|
+
try {
|
|
477
|
+
plan = probe.crashPlan(residue, { planId: `crash-${Number.isFinite(residue.at) ? residue.at : 0}-${Array.isArray(residue.sessions) ? residue.sessions.length : 0}` })
|
|
478
|
+
} catch (error) {
|
|
479
|
+
log(`cannot derive crash plan: ${error?.message ?? error}`)
|
|
480
|
+
}
|
|
481
|
+
if (plan !== null) {
|
|
482
|
+
const stored = typeof store.writePending === 'function' ? store.writePending(plan) : null
|
|
483
|
+
// A failed write keeps the plan in memory so this process can still restore it; only the
|
|
484
|
+
// next restart loses it.
|
|
485
|
+
crashFallback = stored ?? plan
|
|
486
|
+
log('crash plan recorded')
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
const restored = typeof store.lastRestore === 'function' ? store.lastRestore() : null
|
|
490
|
+
result = restored !== null && typeof restored === 'object' ? (restored.summary ?? null) : null
|
|
491
|
+
phase = pausedPlan() !== null ? 'paused' : 'idle'
|
|
492
|
+
lastError = null
|
|
370
493
|
return phase
|
|
371
494
|
}
|
|
372
495
|
|
|
@@ -376,7 +499,7 @@ export function createEngine({ store, probe, heartbeat = null, now = () => Date.
|
|
|
376
499
|
resume,
|
|
377
500
|
hydrate,
|
|
378
501
|
get phase() { return phase },
|
|
379
|
-
get pendingPlan() { return
|
|
380
|
-
get crashPlan() { return
|
|
502
|
+
get pendingPlan() { return currentPlan },
|
|
503
|
+
get crashPlan() { return crashPlan },
|
|
381
504
|
}
|
|
382
505
|
}
|
package/lib/heartbeat.js
CHANGED
|
@@ -66,10 +66,18 @@ export function createHeartbeat({ dir, bootId, intervalMs = 5000, listRunning, n
|
|
|
66
66
|
|
|
67
67
|
/** Flush a directory entry, so the rename that just happened survives a power loss too. */ // ui-copy-ignore
|
|
68
68
|
function syncDir(path) {
|
|
69
|
+
if (process.platform === 'win32') return // ui-copy-ignore
|
|
69
70
|
let fd = null
|
|
70
71
|
try {
|
|
71
72
|
fd = fsImpl.openSync(path, 'r')
|
|
72
73
|
fsImpl.fsyncSync(fd)
|
|
74
|
+
} catch (error) {
|
|
75
|
+
// Some filesystems refuse a directory fsync after a rename that already landed. The record is
|
|
76
|
+
// published either way; only the power-loss guarantee is weaker, so this is not a failure.
|
|
77
|
+
const code = error?.code
|
|
78
|
+
if (code !== 'EPERM' && code !== 'EACCES' && code !== 'ENOTSUP' && code !== 'EINVAL' && code !== 'ENOSYS' && code !== 'EBADF' && code !== 'EISDIR') {
|
|
79
|
+
noteError(`cannot flush ${path}: ${error?.message ?? error}`)
|
|
80
|
+
}
|
|
73
81
|
} finally {
|
|
74
82
|
if (fd !== null) closeQuietly(fsImpl, fd)
|
|
75
83
|
}
|
|
@@ -112,10 +120,13 @@ export function createHeartbeat({ dir, bootId, intervalMs = 5000, listRunning, n
|
|
|
112
120
|
}
|
|
113
121
|
}
|
|
114
122
|
|
|
115
|
-
/**
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
123
|
+
/**
|
|
124
|
+
* Read the previous process's record without claiming it. The caller has to read this before
|
|
125
|
+
* `start()` overwrites the file, and turning it into a durable plan may take a moment: a read that
|
|
126
|
+
* also claimed would leave the very next read empty if that write failed.
|
|
127
|
+
*/
|
|
128
|
+
function peek() {
|
|
129
|
+
if (residue !== null) return residue
|
|
119
130
|
let parsed = null
|
|
120
131
|
try {
|
|
121
132
|
parsed = JSON.parse(fsImpl.readFileSync(file, 'utf8'))
|
|
@@ -130,6 +141,13 @@ export function createHeartbeat({ dir, bootId, intervalMs = 5000, listRunning, n
|
|
|
130
141
|
return residue
|
|
131
142
|
}
|
|
132
143
|
|
|
144
|
+
/** Claims the previous process's heartbeat exactly once, before this process overwrites it. */
|
|
145
|
+
function capture() {
|
|
146
|
+
if (captured) return residue
|
|
147
|
+
captured = true
|
|
148
|
+
return peek()
|
|
149
|
+
}
|
|
150
|
+
|
|
133
151
|
function writePending() {
|
|
134
152
|
return dirty() ? persist(pending) : true
|
|
135
153
|
}
|
|
@@ -215,6 +233,7 @@ export function createHeartbeat({ dir, bootId, intervalMs = 5000, listRunning, n
|
|
|
215
233
|
|
|
216
234
|
return {
|
|
217
235
|
note,
|
|
236
|
+
peek,
|
|
218
237
|
start,
|
|
219
238
|
stop,
|
|
220
239
|
release,
|
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()
|
|
@@ -229,7 +237,8 @@ export function createProbe(services, options = {}) {
|
|
|
229
237
|
sessionId,
|
|
230
238
|
requestId: `${plan.planId}:continue:${sessionId}`,
|
|
231
239
|
content: [{ type: 'text', text: continueText }],
|
|
232
|
-
mode:
|
|
240
|
+
// `queue` is the shipped public mode: deliver after the current turn, start one when idle.
|
|
241
|
+
mode: 'queue',
|
|
233
242
|
}
|
|
234
243
|
}
|
|
235
244
|
|
|
@@ -237,7 +246,11 @@ export function createProbe(services, options = {}) {
|
|
|
237
246
|
async function mount(sessionId) {
|
|
238
247
|
const controller = service(services, 'sessionController')
|
|
239
248
|
if (controller !== undefined && controller !== null && typeof controller.resolveAgent === 'function') {
|
|
240
|
-
const
|
|
249
|
+
const resolved = await controller.resolveAgent(sessionId)
|
|
250
|
+
// The shipped service returns the Agent itself when it is already live, `{ agent }` after a
|
|
251
|
+
// cold resume, and `{ error }` when the resume failed. All three are official shapes.
|
|
252
|
+
if (resolved !== null && typeof resolved === 'object' && 'error' in resolved) throw resolved.error
|
|
253
|
+
const agent = resolved !== null && typeof resolved === 'object' && 'agent' in resolved ? resolved.agent : resolved
|
|
241
254
|
if (agent === undefined || agent === null || typeof agent.id !== 'string') throw new Error('resume returned no agent')
|
|
242
255
|
return agent
|
|
243
256
|
}
|
|
@@ -269,8 +282,9 @@ export function createProbe(services, options = {}) {
|
|
|
269
282
|
if (session.running !== true) return { status: 'restored', detail: mounted ? 'mounted' : 'attached', continued: false }
|
|
270
283
|
|
|
271
284
|
// Someone continued by hand after the pause: the press must not add a second turn.
|
|
272
|
-
const
|
|
273
|
-
|
|
285
|
+
const baseline = Number.isSafeInteger(session.lastTurn) ? session.lastTurn : null
|
|
286
|
+
const turnBefore = lastTurnOf(agent)
|
|
287
|
+
if (agent.status === 'running' || (baseline !== null && turnBefore !== null && turnBefore > baseline)) {
|
|
274
288
|
return { status: 'skipped', detail: 'already continued', continued: false }
|
|
275
289
|
}
|
|
276
290
|
|
|
@@ -280,11 +294,35 @@ export function createProbe(services, options = {}) {
|
|
|
280
294
|
}
|
|
281
295
|
const request = continuation(plan, sessionId)
|
|
282
296
|
try {
|
|
283
|
-
|
|
297
|
+
// Two arguments: the shipped service takes the caller's AbortSignal. Passing source metadata
|
|
298
|
+
// here made every restore fail on a live Harness while the fake in the tests accepted it.
|
|
299
|
+
await controller.prompt(request, new AbortController().signal)
|
|
284
300
|
} catch (error) {
|
|
285
301
|
return { status: 'failed', detail: String(error?.message ?? error).slice(0, 120), continued: false }
|
|
286
302
|
}
|
|
287
|
-
|
|
303
|
+
|
|
304
|
+
// Admission is not execution. The shipped prompt answers `{ accepted: true }` without waking a
|
|
305
|
+
// queue when the same request id was already admitted, so the press waits for the visible sign
|
|
306
|
+
// of a turn (running, or a newer lastTurn) before it calls this item restored.
|
|
307
|
+
const started = await waitForTurn(sessionId, baseline, options.settleMs)
|
|
308
|
+
if (started === true) return { status: 'restored', detail: mounted ? 'continued' : 'resumed and continued', continued: true }
|
|
309
|
+
return { status: 'attention', detail: 'continuation admitted, no turn started', continued: false }
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Bounded poll for the visible sign that an admitted continuation actually opened a turn. */
|
|
313
|
+
async function waitForTurn(sessionId, baseline, settle) {
|
|
314
|
+
if (settle === 0) return false
|
|
315
|
+
const deadline = Date.now() + (Number.isFinite(settle) ? settle : 1200)
|
|
316
|
+
for (;;) {
|
|
317
|
+
const agent = agentOf(services, sessionId)
|
|
318
|
+
if (agent !== undefined) {
|
|
319
|
+
if (agent.status === 'running') return true
|
|
320
|
+
const turn = lastTurnOf(agent)
|
|
321
|
+
if (baseline !== null && turn !== null && turn > baseline) return true
|
|
322
|
+
}
|
|
323
|
+
if (Date.now() >= deadline) return false
|
|
324
|
+
await new Promise((resolve) => setTimeout(resolve, 60))
|
|
325
|
+
}
|
|
288
326
|
}
|
|
289
327
|
|
|
290
328
|
/** Re-arm one paused goal. The saved revision is the person's veto: a changed goal is not ours. */
|
|
@@ -298,25 +336,33 @@ export function createProbe(services, options = {}) {
|
|
|
298
336
|
return { status: 'failed', detail: String(error?.message ?? error).slice(0, 120) }
|
|
299
337
|
}
|
|
300
338
|
}
|
|
301
|
-
const
|
|
302
|
-
if (
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
return { status: '
|
|
339
|
+
const services_goals = service(services, 'goals')
|
|
340
|
+
if (services_goals === undefined || services_goals === null || typeof services_goals.resume !== 'function') {
|
|
341
|
+
return { status: 'failed', detail: 'goals unavailable' }
|
|
342
|
+
}
|
|
343
|
+
const view = goalView(services, agent)
|
|
344
|
+
if (view === undefined || (typeof goal.goalId === 'string' && view.id !== goal.goalId)) {
|
|
345
|
+
return { status: 'skipped', detail: 'goal is gone' }
|
|
346
|
+
}
|
|
347
|
+
// A goal that is already active and armed was restored by an earlier press, or re-armed by the
|
|
348
|
+
// person: it must not be touched again. A disarmed active goal is not ours to arm either.
|
|
349
|
+
if (view.phase === 'active') {
|
|
350
|
+
return view.activation === 'armed'
|
|
351
|
+
? { status: 'skipped', detail: 'already active' }
|
|
352
|
+
: { status: 'skipped', detail: 'goal disarmed' }
|
|
308
353
|
}
|
|
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' }
|
|
354
|
+
if (view.phase !== 'paused') return { status: 'skipped', detail: `goal is ${view.phase}` }
|
|
355
|
+
// The saved revision is the person's veto: a goal edited after the pause is not ours to resume.
|
|
356
|
+
if (Number.isSafeInteger(goal.revision) && view.revision !== goal.revision) {
|
|
357
|
+
return { status: 'skipped', detail: 'goal changed' }
|
|
317
358
|
}
|
|
359
|
+
// The plan stores the revision observed after `pause`; 0.3 plans keep it in `revision`, 0.2
|
|
360
|
+
// plans called it `pausedRevision`.
|
|
361
|
+
const expected = Number.isSafeInteger(goal.revision) ? goal.revision : goal.pausedRevision
|
|
362
|
+
if (Number.isSafeInteger(expected) === false) return { status: 'attention', detail: 'goal has no saved revision' }
|
|
363
|
+
if (view.revision !== expected) return { status: 'skipped', detail: 'goal changed by hand' }
|
|
318
364
|
try {
|
|
319
|
-
|
|
365
|
+
services_goals.resume(agent, { id: view.id, revision: view.revision })
|
|
320
366
|
} catch (error) {
|
|
321
367
|
return { status: 'failed', detail: String(error?.message ?? error).slice(0, 120) }
|
|
322
368
|
}
|
|
@@ -336,7 +382,7 @@ export function createProbe(services, options = {}) {
|
|
|
336
382
|
.map((row) => ({ sessionId: row.sessionId, running: false, lastTurn: null, crash: true }))
|
|
337
383
|
if (sessions.length === 0) return null
|
|
338
384
|
return {
|
|
339
|
-
version:
|
|
385
|
+
version: 3, // ui-copy-ignore
|
|
340
386
|
planId: planId ?? `crash-${Date.now().toString(36)}`,
|
|
341
387
|
status: 'paused', // ui-copy-ignore
|
|
342
388
|
source: 'crash', // ui-copy-ignore
|
|
@@ -344,6 +390,7 @@ export function createProbe(services, options = {}) {
|
|
|
344
390
|
sessions,
|
|
345
391
|
goals: [], // ui-copy-ignore
|
|
346
392
|
jobs: [], // ui-copy-ignore
|
|
393
|
+
attention: [], // ui-copy-ignore
|
|
347
394
|
crashes: sessions.length, // ui-copy-ignore
|
|
348
395
|
}
|
|
349
396
|
}
|
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,106 @@ 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
|
+
return plan
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function readFrom(path) {
|
|
151
|
+
const parsed = parse(path)
|
|
152
|
+
if (parsed === null) return null
|
|
153
|
+
// A version 1 plan has no plan id; the file stamp keeps one journal per legacy press.
|
|
154
|
+
let stamp = now()
|
|
155
|
+
try {
|
|
156
|
+
stamp = Math.trunc(fsv.statSync(path).mtimeMs)
|
|
157
|
+
} catch {}
|
|
158
|
+
return normalize(parsed, stamp)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function read() {
|
|
162
|
+
return readFrom(snapshotPath)
|
|
163
|
+
}
|
|
164
|
+
|
|
101
165
|
function write(plan) {
|
|
102
166
|
if (plan === null || typeof plan !== 'object' || Array.isArray(plan)) return null
|
|
103
|
-
const record = { ...plan, version:
|
|
167
|
+
const record = { ...plan, version: CURRENT_VERSION }
|
|
104
168
|
return atomicWrite(snapshotPath, record) ? record : null
|
|
105
169
|
}
|
|
106
170
|
|
|
107
|
-
|
|
171
|
+
/** The crash plan survives this process: it is written before the heartbeat overwrites its file. */
|
|
172
|
+
function readPending() {
|
|
173
|
+
return readFrom(pendingPath)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function writePending(plan) {
|
|
177
|
+
if (plan === null || typeof plan !== 'object' || Array.isArray(plan)) return null
|
|
178
|
+
const record = { ...plan, version: CURRENT_VERSION }
|
|
179
|
+
return atomicWrite(pendingPath, record) ? record : null
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function clearPending() {
|
|
183
|
+
try {
|
|
184
|
+
fsv.rmSync(pendingPath, { force: true })
|
|
185
|
+
syncDir(dir)
|
|
186
|
+
return true
|
|
187
|
+
} catch (error) {
|
|
188
|
+
log(`cannot clear pending plan: ${error?.message ?? error}`)
|
|
189
|
+
return false
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Journals carry the boot that wrote each row. A row from an earlier process is evidence for the
|
|
195
|
+
* summary, never permission to skip work: the only things a new process can trust are the live
|
|
196
|
+
* Harness and the plan itself.
|
|
197
|
+
*/
|
|
198
|
+
function appendJournal(planId, key, status, bootId) {
|
|
108
199
|
let line = null
|
|
109
200
|
try {
|
|
110
|
-
line = JSON.stringify({ k: key })
|
|
201
|
+
line = JSON.stringify({ k: key, s: status ?? 'ok', b: bootId ?? null, at: now() })
|
|
111
202
|
} catch {
|
|
112
203
|
return false
|
|
113
204
|
}
|
|
@@ -127,23 +218,23 @@ export function createStore(dir, options = {}) {
|
|
|
127
218
|
}
|
|
128
219
|
|
|
129
220
|
function readJournal(planId) {
|
|
130
|
-
const
|
|
221
|
+
const rows = new Map()
|
|
131
222
|
let raw = ''
|
|
132
223
|
try {
|
|
133
224
|
raw = fsv.readFileSync(journalPath(planId), 'utf8')
|
|
134
225
|
} catch {
|
|
135
|
-
return
|
|
226
|
+
return rows
|
|
136
227
|
}
|
|
137
228
|
for (const line of raw.split('\n')) {
|
|
138
229
|
if (line === '') continue
|
|
139
230
|
try {
|
|
140
231
|
const row = JSON.parse(line)
|
|
141
|
-
if (typeof row?.k === 'string')
|
|
232
|
+
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
233
|
} catch {
|
|
143
234
|
// A torn last line is a write that never finished, not a key that was restored.
|
|
144
235
|
}
|
|
145
236
|
}
|
|
146
|
-
return
|
|
237
|
+
return rows
|
|
147
238
|
}
|
|
148
239
|
|
|
149
240
|
function clearJournal(planId) {
|
|
@@ -152,47 +243,78 @@ export function createStore(dir, options = {}) {
|
|
|
152
243
|
} catch {}
|
|
153
244
|
}
|
|
154
245
|
|
|
246
|
+
function writeResult(record) {
|
|
247
|
+
return atomicWrite(lastRestorePath, record)
|
|
248
|
+
}
|
|
249
|
+
|
|
155
250
|
/**
|
|
156
251
|
* 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.
|
|
252
|
+
* that could not be archived stays on disk, so the same work is never claimed twice. A snapshot
|
|
253
|
+
* that could not be removed is reported as a failure even though the archive landed: the next
|
|
254
|
+
* reader would otherwise see a plan that is already restored and try to restore it again.
|
|
158
255
|
*/
|
|
159
256
|
function consume(plan, summary) {
|
|
160
257
|
const source = plan !== null && typeof plan === 'object' && !Array.isArray(plan) ? plan : {}
|
|
161
258
|
const archivePath = join(restoresDir, `${sanitize(source.planId)}.json`)
|
|
162
259
|
const record = { ...source, status: 'restored', restoredAt: new Date(now()).toISOString(), summary }
|
|
163
260
|
const archived = atomicWrite(archivePath, record)
|
|
164
|
-
const published = archived &&
|
|
261
|
+
const published = archived && writeResult(record)
|
|
165
262
|
if (published === false) {
|
|
166
263
|
log('cannot archive the restored plan: the snapshot stays')
|
|
167
|
-
return { ok: false, archivePath: null }
|
|
264
|
+
return { ok: false, archivePath: null, published: false }
|
|
168
265
|
}
|
|
169
266
|
const current = read()
|
|
170
267
|
// Another press may have written a newer plan while this one restored; that one is not ours.
|
|
171
|
-
if (current
|
|
268
|
+
if (current === null || current.planId === source.planId) {
|
|
172
269
|
try {
|
|
173
270
|
fsv.rmSync(snapshotPath, { force: true })
|
|
174
|
-
|
|
271
|
+
syncDir(dir)
|
|
272
|
+
} catch (error) {
|
|
273
|
+
log(`cannot clear the restored plan: ${error?.message ?? error}`)
|
|
274
|
+
return { ok: false, archivePath, published: true }
|
|
275
|
+
}
|
|
175
276
|
}
|
|
176
277
|
clearJournal(source.planId)
|
|
177
|
-
return { ok: true, archivePath }
|
|
278
|
+
return { ok: true, archivePath, published: true }
|
|
178
279
|
}
|
|
179
280
|
|
|
180
281
|
function lastRestore() {
|
|
282
|
+
const parsed = parse(lastRestorePath)
|
|
283
|
+
return parsed
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function hasSnapshot() {
|
|
181
287
|
try {
|
|
182
|
-
|
|
183
|
-
return parsed !== null && typeof parsed === 'object' ? parsed : null
|
|
288
|
+
return fsv.existsSync(snapshotPath) === true
|
|
184
289
|
} catch {
|
|
185
|
-
return
|
|
290
|
+
return false
|
|
186
291
|
}
|
|
187
292
|
}
|
|
188
293
|
|
|
189
|
-
function
|
|
294
|
+
function hasPending() {
|
|
190
295
|
try {
|
|
191
|
-
return fsv.existsSync(
|
|
296
|
+
return fsv.existsSync(pendingPath) === true
|
|
192
297
|
} catch {
|
|
193
298
|
return false
|
|
194
299
|
}
|
|
195
300
|
}
|
|
196
301
|
|
|
197
|
-
return {
|
|
302
|
+
return {
|
|
303
|
+
dir,
|
|
304
|
+
snapshotPath,
|
|
305
|
+
pendingPath,
|
|
306
|
+
read,
|
|
307
|
+
write,
|
|
308
|
+
readPending,
|
|
309
|
+
writePending,
|
|
310
|
+
clearPending,
|
|
311
|
+
appendJournal,
|
|
312
|
+
readJournal,
|
|
313
|
+
clearJournal,
|
|
314
|
+
writeResult,
|
|
315
|
+
consume,
|
|
316
|
+
lastRestore,
|
|
317
|
+
hasSnapshot,
|
|
318
|
+
hasPending,
|
|
319
|
+
}
|
|
198
320
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-pause-resume",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "One header button stops running turns, goals and jobs, writes a plan to disk, and restores that work after a restart or crash; the button shape is the only status.",
|
|
6
6
|
"license": "MIT",
|