dsh-pause-resume 0.1.0 → 0.2.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 +67 -35
- package/client.js +101 -28
- package/index.js +102 -31
- package/lib/engine.js +235 -72
- package/lib/heartbeat.js +180 -49
- package/lib/probe.js +234 -113
- package/lib/snapshot.js +143 -78
- package/package.json +2 -2
package/lib/engine.js
CHANGED
|
@@ -1,113 +1,184 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The only place the button's shape comes from.
|
|
3
3
|
*
|
|
4
|
-
* One phase value, written before any work starts and after it ends, plus a durable
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* One phase value, written before any work starts and after it ends, plus a durable plan. The two
|
|
5
|
+
* transient phases are exclusive by construction: `pause()` and `resume()` refuse while either is
|
|
6
|
+
* set, so two windows, a double press and a tool call can never overlap. The client is not allowed
|
|
7
|
+
* to infer a phase: it asks, and renders the answer.
|
|
8
|
+
*
|
|
9
|
+
* A plan is kept until every item in it has a terminal outcome. A restore that could not put one
|
|
10
|
+
* session back keeps the plan and the journal, so the next press retries exactly the missing item
|
|
11
|
+
* instead of replaying the whole pause.
|
|
8
12
|
*
|
|
9
13
|
* The engine performs no I/O of its own. `store` persists, `probe` reads and acts on the running
|
|
10
14
|
* Harness, so every transition here is testable without a Harness.
|
|
11
15
|
*/
|
|
12
16
|
|
|
13
17
|
const TRANSIENT = new Set(['pausing', 'resuming'])
|
|
18
|
+
const TERMINAL = new Set(['restored', 'skipped', 'attention'])
|
|
14
19
|
|
|
15
|
-
export function createEngine({ store, probe, now = () => Date.now(), graceMs = 2000,
|
|
20
|
+
export function createEngine({ store, probe, heartbeat = null, now = () => Date.now(), graceMs = 2000, log = () => {} }) {
|
|
16
21
|
let phase = 'idle'
|
|
17
22
|
let lastError = null
|
|
18
23
|
let result = null
|
|
19
24
|
/** Set while a press runs, so a second caller joins the answer instead of starting work again. */
|
|
20
25
|
let inflight = null
|
|
26
|
+
/** The crash plan is derived once per boot: its id must not move between two polls. */
|
|
27
|
+
let crash = null
|
|
28
|
+
let crashRead = false
|
|
29
|
+
let counter = 0
|
|
21
30
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const snapshot = store.read()
|
|
26
|
-
// `pausing` is included on purpose: it means the last press stopped halfway, so the work in it
|
|
27
|
-
// still has to be restored. Reporting it as nothing to restore would hide a half-done pause.
|
|
28
|
-
return snapshot
|
|
31
|
+
function newPlanId(kind) {
|
|
32
|
+
counter += 1
|
|
33
|
+
return `${kind}-${now().toString(36)}-${counter.toString(36)}`
|
|
29
34
|
}
|
|
30
35
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const value = await probe.live()
|
|
34
|
-
return { ...emptyLive, ...(value ?? {}) }
|
|
35
|
-
} catch (error) {
|
|
36
|
-
log(`cannot read live work: ${error?.message ?? error}`)
|
|
37
|
-
return emptyLive
|
|
38
|
-
}
|
|
36
|
+
function pendingPlan() {
|
|
37
|
+
return store.read()
|
|
39
38
|
}
|
|
40
39
|
|
|
41
|
-
|
|
40
|
+
function readCrash() {
|
|
41
|
+
if (crashRead) return crash
|
|
42
|
+
crashRead = true
|
|
42
43
|
try {
|
|
43
|
-
const
|
|
44
|
-
|
|
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}` })
|
|
45
50
|
} catch (error) {
|
|
46
51
|
log(`cannot read crash residue: ${error?.message ?? error}`)
|
|
47
|
-
|
|
52
|
+
crash = null
|
|
48
53
|
}
|
|
54
|
+
return crash
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function currentPlan() {
|
|
58
|
+
return pendingPlan() ?? readCrash()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function itemKeys(plan) {
|
|
62
|
+
if (plan === null || plan === undefined) return []
|
|
63
|
+
const keys = []
|
|
64
|
+
for (const session of plan.sessions ?? []) if (typeof session?.sessionId === 'string') keys.push(`s:${session.sessionId}`)
|
|
65
|
+
for (const goal of plan.goals ?? []) keys.push(`g:${goal.sessionId}:${goal.goalId ?? ''}`)
|
|
66
|
+
return keys
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function outstandingOf(plan) {
|
|
70
|
+
if (plan === null || plan === undefined) return 0
|
|
71
|
+
const done = store.readJournal(plan.planId)
|
|
72
|
+
return itemKeys(plan).filter((key) => !done.has(key)).length
|
|
49
73
|
}
|
|
50
74
|
|
|
51
75
|
function countsOf(plan) {
|
|
52
|
-
if (plan === null || plan === undefined) return { sessions: 0, goals: 0,
|
|
76
|
+
if (plan === null || plan === undefined) return { sessions: 0, goals: 0, jobs: 0 }
|
|
53
77
|
return {
|
|
54
78
|
sessions: Array.isArray(plan.sessions) ? plan.sessions.length : 0,
|
|
55
79
|
goals: Array.isArray(plan.goals) ? plan.goals.length : 0,
|
|
56
|
-
schedules: Array.isArray(plan.schedules) ? plan.schedules.length : 0,
|
|
57
80
|
jobs: Array.isArray(plan.jobs) ? plan.jobs.length : 0,
|
|
58
81
|
}
|
|
59
82
|
}
|
|
60
83
|
|
|
84
|
+
function liveNow() {
|
|
85
|
+
try {
|
|
86
|
+
const value = probe.live()
|
|
87
|
+
return value ?? { sessions: [], goals: [], jobs: [], readable: false, source: 'none' }
|
|
88
|
+
} catch (error) {
|
|
89
|
+
log(`cannot read live work: ${error?.message ?? error}`)
|
|
90
|
+
return { sessions: [], goals: [], jobs: [], readable: false, source: 'none' }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
61
94
|
/**
|
|
62
95
|
* What the button shows. `action` is the one decision the client may not make itself:
|
|
63
96
|
* `none` (nothing to do), `pause`, `resume`, or `busy` (an operation is running).
|
|
64
97
|
*/
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
const current =
|
|
68
|
-
const plan = snapshot ?? (await crashPlan())
|
|
98
|
+
function describe() {
|
|
99
|
+
const plan = currentPlan()
|
|
100
|
+
const current = liveNow()
|
|
69
101
|
const busy = TRANSIENT.has(phase)
|
|
70
|
-
const
|
|
102
|
+
const work = workOf(current)
|
|
103
|
+
const canPause = current.readable === true && work.sessions.length + work.goals.length + work.jobs.length > 0
|
|
71
104
|
const action = busy ? 'busy' : plan !== null ? 'resume' : canPause ? 'pause' : 'none'
|
|
105
|
+
const outstanding = outstandingOf(plan)
|
|
72
106
|
return {
|
|
73
107
|
phase,
|
|
74
108
|
action,
|
|
75
|
-
source: plan === null ? null : plan.
|
|
76
|
-
complete
|
|
109
|
+
source: plan === null ? null : plan.source ?? 'pause',
|
|
110
|
+
// A finished pause is complete even though every item still has to be restored later.
|
|
111
|
+
complete: plan === null ? null : plan.status === 'paused' && lastError === null,
|
|
112
|
+
outstanding: outstanding, // ui-copy-ignore
|
|
77
113
|
counts: countsOf(plan), // ui-copy-ignore
|
|
78
114
|
live: { // ui-copy-ignore
|
|
79
|
-
sessions:
|
|
115
|
+
sessions: work.sessions.filter((session) => session.running === true).length,
|
|
80
116
|
goals: current.goals.length,
|
|
81
117
|
jobs: current.jobs.length,
|
|
82
118
|
},
|
|
83
|
-
|
|
119
|
+
attention: Array.isArray(plan?.attention) ? plan.attention.length : 0, // ui-copy-ignore
|
|
120
|
+
error: lastError ?? (plan?.status === 'pausing' ? 'pause-interrupted' : null), // ui-copy-ignore
|
|
84
121
|
result,
|
|
85
122
|
}
|
|
86
123
|
}
|
|
87
124
|
|
|
88
|
-
|
|
125
|
+
/**
|
|
126
|
+
* Only work that was actually stopped goes into the plan: a session whose turn was open, or a
|
|
127
|
+
* session that carries an armed goal. A session that ended normally is left alone, however it
|
|
128
|
+
* happens to look in the session list.
|
|
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)
|
|
134
|
+
}
|
|
135
|
+
for (const goal of current.goals ?? []) {
|
|
136
|
+
if (!sessions.has(goal.sessionId)) sessions.set(goal.sessionId, { sessionId: goal.sessionId, running: false, lastTurn: null })
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
sessions: [...sessions.values()],
|
|
140
|
+
goals: current.goals ?? [],
|
|
141
|
+
jobs: current.jobs ?? [], // ui-copy-ignore
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function planOf(current, { planId, status }) {
|
|
89
146
|
return {
|
|
90
|
-
version:
|
|
91
|
-
|
|
147
|
+
version: 2, // ui-copy-ignore
|
|
148
|
+
planId,
|
|
149
|
+
status,
|
|
150
|
+
source: 'pause', // ui-copy-ignore
|
|
92
151
|
createdAt: new Date(now()).toISOString(),
|
|
93
|
-
sessions: current.sessions
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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 })),
|
|
98
163
|
}
|
|
99
164
|
}
|
|
100
165
|
|
|
101
166
|
async function runPause() {
|
|
102
|
-
const current =
|
|
103
|
-
if (current.
|
|
167
|
+
const current = liveNow()
|
|
168
|
+
if (current.readable !== true) {
|
|
169
|
+
phase = 'idle'
|
|
170
|
+
lastError = 'registry'
|
|
171
|
+
return { status: 'failed', reason: 'registry' }
|
|
172
|
+
}
|
|
173
|
+
const work = workOf(current)
|
|
174
|
+
if (work.sessions.length + work.goals.length + work.jobs.length === 0) {
|
|
104
175
|
phase = 'idle'
|
|
105
176
|
return { status: 'idle', action: 'none' }
|
|
106
177
|
}
|
|
107
178
|
lastError = null
|
|
108
|
-
// The
|
|
179
|
+
// The plan exists before anything is stopped: a crash between the two is then visible as
|
|
109
180
|
// `pausing` on the next boot instead of being reported as a clean pause.
|
|
110
|
-
const draft =
|
|
181
|
+
const draft = planOf(current, { planId: newPlanId('pause'), status: 'pausing' })
|
|
111
182
|
if (store.write(draft) === null) {
|
|
112
183
|
phase = 'idle'
|
|
113
184
|
lastError = 'store'
|
|
@@ -118,52 +189,133 @@ export function createEngine({ store, probe, now = () => Date.now(), graceMs = 2
|
|
|
118
189
|
stopped = await probe.stop(current, { graceMs })
|
|
119
190
|
} catch (error) {
|
|
120
191
|
log(`stopping work failed: ${error?.message ?? error}`)
|
|
121
|
-
stopped = { canceled: [], unknown: [] }
|
|
192
|
+
stopped = { canceled: [], unknown: [], pausedGoals: [], goalErrors: [], killedJobs: [], jobErrors: [], stillRunning: [] }
|
|
122
193
|
}
|
|
194
|
+
|
|
195
|
+
// Only goals that really stopped are part of the plan. Resuming a goal nobody paused would bump
|
|
196
|
+
// its revision for no reason and could override the person's own pause; a goal that failed to
|
|
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.
|
|
201
|
+
const baseline = new Map((stopped.sessions ?? []).map((session) => [session.sessionId, session.lastTurn]))
|
|
202
|
+
draft.sessions = draft.sessions.map((session) => baseline.has(session.sessionId)
|
|
203
|
+
? { ...session, lastTurn: Number.isSafeInteger(baseline.get(session.sessionId)) ? baseline.get(session.sessionId) : null }
|
|
204
|
+
: session)
|
|
205
|
+
draft.goals = (stopped.pausedGoals ?? []).map((goal) => ({
|
|
206
|
+
sessionId: goal.sessionId,
|
|
207
|
+
goalId: goal.goalId,
|
|
208
|
+
revision: goal.pausedRevision,
|
|
209
|
+
}))
|
|
123
210
|
draft.status = 'paused'
|
|
124
211
|
draft.stoppedAt = new Date(now()).toISOString()
|
|
125
|
-
draft.stopped =
|
|
212
|
+
draft.stopped = {
|
|
213
|
+
canceled: stopped.canceled ?? [],
|
|
214
|
+
unknown: stopped.unknown ?? [],
|
|
215
|
+
stillRunning: stopped.stillRunning ?? [],
|
|
216
|
+
goalErrors: stopped.goalErrors ?? [],
|
|
217
|
+
killedJobs: stopped.killedJobs ?? [],
|
|
218
|
+
jobErrors: stopped.jobErrors ?? [],
|
|
219
|
+
}
|
|
220
|
+
draft.attention = [
|
|
221
|
+
...(stopped.stillRunning ?? []).map((id) => ({ kind: 'session', id, detail: 'still running' })),
|
|
222
|
+
...(stopped.goalErrors ?? []).map((detail) => ({ kind: 'goal', id: '', detail })),
|
|
223
|
+
...(stopped.jobErrors ?? []).map((detail) => ({ kind: 'job', id: '', detail })),
|
|
224
|
+
]
|
|
126
225
|
if (store.write(draft) === null) {
|
|
127
226
|
// The durable copy still says `pausing`, which is the truth: the next boot reports it.
|
|
128
227
|
lastError = 'store'
|
|
129
228
|
}
|
|
130
229
|
phase = 'paused'
|
|
131
|
-
|
|
132
|
-
return { status: 'paused', counts }
|
|
230
|
+
return { status: 'paused', counts: countsOf(draft), outstanding: outstandingOf(draft) }
|
|
133
231
|
}
|
|
134
232
|
|
|
233
|
+
function summaryOf() {
|
|
234
|
+
return { restored: [], skipped: [], failed: [], attention: [], jobs: 0, at: new Date(now()).toISOString() }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* One restore pass. Every item that ends in a terminal outcome is journaled, so a later press
|
|
239
|
+
* only walks what is left. A retryable failure keeps the plan; a durable write failure keeps it
|
|
240
|
+
* too, because the archive is the record that the work came back.
|
|
241
|
+
*/
|
|
135
242
|
async function runResume(plan) {
|
|
136
243
|
lastError = null
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
244
|
+
// A crash in the middle of a pause left a plan that never finished stopping. Its sessions were
|
|
245
|
+
// still turning when the process died, so mounting them is safe but continuing them is not: the
|
|
246
|
+
// person asked for silence, not another round.
|
|
247
|
+
if (plan.status === 'pausing') {
|
|
248
|
+
plan.source = 'interrupted'
|
|
249
|
+
plan.attention = [...(plan.attention ?? []), { kind: 'pause', id: '', detail: 'pause interrupted' }]
|
|
250
|
+
plan.sessions = (plan.sessions ?? []).map((session) => ({ ...session, running: false, lastTurn: null }))
|
|
251
|
+
plan.status = 'paused'
|
|
252
|
+
}
|
|
253
|
+
const summary = summaryOf()
|
|
254
|
+
summary.jobs = countsOf(plan).jobs
|
|
255
|
+
const done = store.readJournal(plan.planId)
|
|
256
|
+
const record = (key) => {
|
|
141
257
|
done.add(key)
|
|
258
|
+
store.appendJournal(plan.planId, key)
|
|
142
259
|
}
|
|
143
260
|
const step = async (key, label, run) => {
|
|
144
261
|
if (done.has(key)) return
|
|
262
|
+
let outcome
|
|
145
263
|
try {
|
|
146
|
-
|
|
147
|
-
if (outcome === 'restored') summary.restored.push(label)
|
|
148
|
-
else summary.skipped.push(label)
|
|
264
|
+
outcome = await run()
|
|
149
265
|
} catch (error) {
|
|
150
|
-
|
|
266
|
+
outcome = { status: 'failed', detail: String(error?.message ?? error).slice(0, 120) }
|
|
267
|
+
}
|
|
268
|
+
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 {
|
|
278
|
+
summary.failed.push(`${label}: ${outcome?.detail ?? 'unknown'}`)
|
|
151
279
|
}
|
|
152
|
-
mark(key)
|
|
153
280
|
}
|
|
154
281
|
|
|
155
282
|
for (const session of plan.sessions ?? []) {
|
|
156
283
|
await step(`s:${session.sessionId}`, `session ${session.sessionId}`, () => probe.restoreSession(session, { plan }))
|
|
157
284
|
}
|
|
158
285
|
for (const goal of plan.goals ?? []) {
|
|
159
|
-
|
|
286
|
+
const key = `g:${goal.sessionId}:${goal.goalId ?? ''}`
|
|
287
|
+
await step(key, `goal ${goal.goalId ?? goal.sessionId}`, () => probe.restoreGoal(goal, { plan }))
|
|
160
288
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
store.clearJournal()
|
|
289
|
+
// 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 is not a retryable item and never blocks the plan.
|
|
291
|
+
for (const job of plan.jobs ?? []) summary.attention.push(`job ${job.id}: stopped, not restarted`)
|
|
292
|
+
|
|
166
293
|
result = summary
|
|
294
|
+
const failed = summary.failed.length > 0
|
|
295
|
+
const pending = itemKeys(plan).filter((key) => !done.has(key))
|
|
296
|
+
if (failed || pending.length > 0) {
|
|
297
|
+
plan.restore = { at: summary.at, attempts: (plan.restore?.attempts ?? 0) + 1, failed: summary.failed } // ui-copy-ignore
|
|
298
|
+
if (store.write(plan) === null) lastError = 'store'
|
|
299
|
+
else lastError = 'restore-partial'
|
|
300
|
+
phase = 'paused'
|
|
301
|
+
return { status: 'partial', summary, outstanding: pending.length }
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const consumed = store.consume(plan, summary)
|
|
305
|
+
if (!consumed?.ok) {
|
|
306
|
+
lastError = 'store'
|
|
307
|
+
phase = 'paused'
|
|
308
|
+
return { status: 'partial', summary, outstanding: 0 }
|
|
309
|
+
}
|
|
310
|
+
store.clearJournal(plan.planId)
|
|
311
|
+
if (plan.source === 'crash') {
|
|
312
|
+
crash = null
|
|
313
|
+
try {
|
|
314
|
+
heartbeat?.release?.()
|
|
315
|
+
} catch (error) {
|
|
316
|
+
log(`cannot release crash record: ${error?.message ?? error}`)
|
|
317
|
+
}
|
|
318
|
+
}
|
|
167
319
|
phase = 'idle'
|
|
168
320
|
return { status: 'resumed', summary }
|
|
169
321
|
}
|
|
@@ -177,7 +329,7 @@ export function createEngine({ store, probe, now = () => Date.now(), graceMs = 2
|
|
|
177
329
|
} catch (error) {
|
|
178
330
|
log(`${kind} failed: ${error?.message ?? error}`)
|
|
179
331
|
lastError = 'internal'
|
|
180
|
-
phase = 'idle'
|
|
332
|
+
phase = pendingPlan() === null ? 'idle' : 'paused'
|
|
181
333
|
return { status: 'failed', reason: 'internal' }
|
|
182
334
|
} finally {
|
|
183
335
|
inflight = null
|
|
@@ -189,7 +341,7 @@ export function createEngine({ store, probe, now = () => Date.now(), graceMs = 2
|
|
|
189
341
|
|
|
190
342
|
async function pause() {
|
|
191
343
|
if (TRANSIENT.has(phase) || inflight !== null) return { status: 'busy', phase }
|
|
192
|
-
if (
|
|
344
|
+
if (pendingPlan() !== null) return { status: 'already', phase }
|
|
193
345
|
// Set synchronously, before the first await: two windows pressing at once are then exclusive
|
|
194
346
|
// in the same tick they were dispatched, not one microtask later.
|
|
195
347
|
phase = 'pausing'
|
|
@@ -200,7 +352,7 @@ export function createEngine({ store, probe, now = () => Date.now(), graceMs = 2
|
|
|
200
352
|
if (TRANSIENT.has(phase) || inflight !== null) return { status: 'busy', phase }
|
|
201
353
|
phase = 'resuming'
|
|
202
354
|
return once('resume', async () => {
|
|
203
|
-
const plan =
|
|
355
|
+
const plan = currentPlan()
|
|
204
356
|
if (plan === null) {
|
|
205
357
|
phase = 'idle'
|
|
206
358
|
return { status: 'empty' }
|
|
@@ -211,9 +363,20 @@ export function createEngine({ store, probe, now = () => Date.now(), graceMs = 2
|
|
|
211
363
|
|
|
212
364
|
/** A reload of this plugin must not invent a phase: the disk and the running Harness decide it. */
|
|
213
365
|
async function hydrate() {
|
|
214
|
-
|
|
215
|
-
|
|
366
|
+
crashRead = false
|
|
367
|
+
crash = null
|
|
368
|
+
readCrash()
|
|
369
|
+
phase = pendingPlan() !== null ? 'paused' : 'idle'
|
|
370
|
+
return phase
|
|
216
371
|
}
|
|
217
372
|
|
|
218
|
-
return {
|
|
373
|
+
return {
|
|
374
|
+
describe,
|
|
375
|
+
pause,
|
|
376
|
+
resume,
|
|
377
|
+
hydrate,
|
|
378
|
+
get phase() { return phase },
|
|
379
|
+
get pendingPlan() { return pendingPlan },
|
|
380
|
+
get crashPlan() { return readCrash },
|
|
381
|
+
}
|
|
219
382
|
}
|