dsh-autorestart 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/LICENSE +0 -0
- package/README.md +204 -295
- package/README.zh.md +504 -0
- package/assets/icon-180.png +0 -0
- package/assets/screenshot-1.png +0 -0
- package/assets/screenshot-2.png +0 -0
- package/assets/screenshot-3.png +0 -0
- package/client.js +267 -8
- package/cordis.patch.yml +0 -0
- package/icon.svg +0 -0
- package/index.js +1284 -43
- package/lib/checkpoint.js +516 -2
- package/lib/lifecycle.js +387 -0
- package/lib/restart.js +0 -0
- package/locale/en.json +0 -0
- package/locale/zh.json +0 -0
- package/package.json +6 -3
- package/screenshots.json +5 -0
package/lib/checkpoint.js
CHANGED
|
@@ -13,6 +13,16 @@
|
|
|
13
13
|
* - every mutating helper below mutates `state` in place **and** returns it,
|
|
14
14
|
* so callers may treat the helpers as expressions.
|
|
15
15
|
*
|
|
16
|
+
* Goal records come in TWO shapes on purpose:
|
|
17
|
+
* - `state.disarmedGoals[sessionId] = { goalId, disarmedAt, resumedAt }` is the
|
|
18
|
+
* current shape: this plugin called `ctx.goals.disarm(agent)` before a
|
|
19
|
+
* restart, which keeps the durable goal `active` and can be re-armed with
|
|
20
|
+
* `ctx.goals.resume(...)` after the boot.
|
|
21
|
+
* - `state.parkedGoals[sessionId] = { goalId, parkedAt, resumedAt }` is the
|
|
22
|
+
* LEGACY shape written by the previous pause-based build. It is still read
|
|
23
|
+
* (and can still be cleared) so a goal that build paused is resumed once
|
|
24
|
+
* instead of being stranded in `paused` where only a human could undo it.
|
|
25
|
+
*
|
|
16
26
|
* ESM only, Node builtins only. No DSH / npm imports (profile-installed
|
|
17
27
|
* bundles may not resolve them).
|
|
18
28
|
*/
|
|
@@ -30,6 +40,12 @@ export const ENTRY_REASONS_LIMIT = 16;
|
|
|
30
40
|
/** Hard cap on `restart.requesters` (one entry per requesting session). */
|
|
31
41
|
export const REQUESTERS_LIMIT = 16;
|
|
32
42
|
|
|
43
|
+
/** Hard cap on the disarmed-goal records kept in the checkpoint. */
|
|
44
|
+
export const DISARMED_GOALS_LIMIT = 64;
|
|
45
|
+
|
|
46
|
+
/** Hard cap on the legacy (pause-based) parked-goal records. */
|
|
47
|
+
export const PARKED_GOALS_LIMIT = 64;
|
|
48
|
+
|
|
33
49
|
const RESTART_KEYS = new Set([
|
|
34
50
|
'generation',
|
|
35
51
|
'requestedAt',
|
|
@@ -41,6 +57,7 @@ const RESTART_KEYS = new Set([
|
|
|
41
57
|
'failedAttempts',
|
|
42
58
|
'notBefore',
|
|
43
59
|
'requesters',
|
|
60
|
+
'parkGoals',
|
|
44
61
|
]);
|
|
45
62
|
|
|
46
63
|
const ENTRY_KEYS = new Set([
|
|
@@ -57,10 +74,26 @@ const ENTRY_KEYS = new Set([
|
|
|
57
74
|
'resumeAttempts',
|
|
58
75
|
'lastResumeAt',
|
|
59
76
|
'lastResumeError',
|
|
77
|
+
'lastRecoveryAction',
|
|
78
|
+
'lastRecoveryDetail',
|
|
79
|
+
'handoff',
|
|
80
|
+
'contextOverflow',
|
|
60
81
|
]);
|
|
61
82
|
|
|
62
83
|
const GOAL_KEYS = new Set(['goalId', 'goalRevision', 'phase', 'updatedAt']);
|
|
63
84
|
|
|
85
|
+
/** Record written when a session was handed off to a fresh session. */
|
|
86
|
+
const HANDOFF_KEYS = new Set(['handedOffTo', 'handoffAt', 'carriedChars']);
|
|
87
|
+
|
|
88
|
+
/** Record written when the context-overflow guard decided a session is over the limit. */
|
|
89
|
+
const CONTEXT_OVERFLOW_KEYS = new Set(['over', 'inputTokens', 'contextWindow', 'detail', 'detectedAt']);
|
|
90
|
+
|
|
91
|
+
/** LEGACY record shape written by the pause-based build. */
|
|
92
|
+
const PARKED_GOAL_KEYS = new Set(['goalId', 'parkedAt', 'resumedAt']);
|
|
93
|
+
|
|
94
|
+
/** Current record shape: written after a successful `ctx.goals.disarm()`. */
|
|
95
|
+
const DISARMED_GOAL_KEYS = new Set(['goalId', 'disarmedAt', 'resumedAt']);
|
|
96
|
+
|
|
64
97
|
const TOP_LEVEL_KEYS = new Set([
|
|
65
98
|
'version',
|
|
66
99
|
'bootId',
|
|
@@ -70,6 +103,8 @@ const TOP_LEVEL_KEYS = new Set([
|
|
|
70
103
|
'phase',
|
|
71
104
|
'restart',
|
|
72
105
|
'sessions',
|
|
106
|
+
'parkedGoals',
|
|
107
|
+
'disarmedGoals',
|
|
73
108
|
'updatedAt',
|
|
74
109
|
]);
|
|
75
110
|
|
|
@@ -118,6 +153,8 @@ function defaultEntry(sessionId, now = Date.now()) {
|
|
|
118
153
|
resumeAttempts: 0,
|
|
119
154
|
lastResumeAt: null,
|
|
120
155
|
lastResumeError: null,
|
|
156
|
+
lastRecoveryAction: null,
|
|
157
|
+
lastRecoveryDetail: null,
|
|
121
158
|
};
|
|
122
159
|
}
|
|
123
160
|
|
|
@@ -167,6 +204,123 @@ function normalizeGoal(raw) {
|
|
|
167
204
|
return { goal, changed };
|
|
168
205
|
}
|
|
169
206
|
|
|
207
|
+
/**
|
|
208
|
+
* Normalize the LEGACY parked-goal records written by the pause-based build:
|
|
209
|
+
* one entry per session whose goal that build paused. A corrupt record is
|
|
210
|
+
* dropped, never thrown on.
|
|
211
|
+
*
|
|
212
|
+
* @param {unknown} raw
|
|
213
|
+
* @returns {{ parkedGoals: object|null, changed: boolean }}
|
|
214
|
+
*/
|
|
215
|
+
function normalizeParkedGoals(raw) {
|
|
216
|
+
if (!isObject(raw)) {
|
|
217
|
+
return { parkedGoals: null, changed: raw !== null && raw !== undefined };
|
|
218
|
+
}
|
|
219
|
+
const parkedGoals = {};
|
|
220
|
+
let changed = false;
|
|
221
|
+
for (const [sessionId, value] of Object.entries(raw)) {
|
|
222
|
+
if (!isObject(value)) {
|
|
223
|
+
changed = true;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
for (const key of Object.keys(value)) if (!PARKED_GOAL_KEYS.has(key)) changed = true;
|
|
227
|
+
const record = {
|
|
228
|
+
goalId: nullableString(value.goalId),
|
|
229
|
+
parkedAt: nullableNumber(value.parkedAt),
|
|
230
|
+
resumedAt: nullableNumber(value.resumedAt),
|
|
231
|
+
};
|
|
232
|
+
if (typeof value.goalId !== 'string' && value.goalId != null) changed = true;
|
|
233
|
+
if (value.parkedAt != null && !isFiniteNumber(value.parkedAt)) changed = true;
|
|
234
|
+
if (value.resumedAt != null && !isFiniteNumber(value.resumedAt)) changed = true;
|
|
235
|
+
if (record.goalId === null && record.parkedAt === null && record.resumedAt === null) {
|
|
236
|
+
changed = true;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
parkedGoals[sessionId] = record;
|
|
240
|
+
}
|
|
241
|
+
return { parkedGoals, changed };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Normalize the disarmed-goal records: one entry per session whose goal this
|
|
246
|
+
* plugin disarmed (and has not resumed yet). A corrupt record is dropped.
|
|
247
|
+
*
|
|
248
|
+
* @param {unknown} raw
|
|
249
|
+
* @returns {{ disarmedGoals: object|null, changed: boolean }}
|
|
250
|
+
*/
|
|
251
|
+
function normalizeDisarmedGoals(raw) {
|
|
252
|
+
if (!isObject(raw)) {
|
|
253
|
+
return { disarmedGoals: null, changed: raw !== null && raw !== undefined };
|
|
254
|
+
}
|
|
255
|
+
const disarmedGoals = {};
|
|
256
|
+
let changed = false;
|
|
257
|
+
for (const [sessionId, value] of Object.entries(raw)) {
|
|
258
|
+
if (!isObject(value)) {
|
|
259
|
+
changed = true;
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
for (const key of Object.keys(value)) if (!DISARMED_GOAL_KEYS.has(key)) changed = true;
|
|
263
|
+
const record = {
|
|
264
|
+
goalId: nullableString(value.goalId),
|
|
265
|
+
disarmedAt: nullableNumber(value.disarmedAt),
|
|
266
|
+
resumedAt: nullableNumber(value.resumedAt),
|
|
267
|
+
};
|
|
268
|
+
if (typeof value.goalId !== 'string' && value.goalId != null) changed = true;
|
|
269
|
+
if (value.disarmedAt != null && !isFiniteNumber(value.disarmedAt)) changed = true;
|
|
270
|
+
if (value.resumedAt != null && !isFiniteNumber(value.resumedAt)) changed = true;
|
|
271
|
+
if (record.goalId === null && record.disarmedAt === null && record.resumedAt === null) {
|
|
272
|
+
changed = true;
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
disarmedGoals[sessionId] = record;
|
|
276
|
+
}
|
|
277
|
+
return { disarmedGoals, changed };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Normalize the handoff record of a session: `{ handedOffTo, handoffAt, carriedChars }`.
|
|
282
|
+
* A missing record stays `null`; a corrupt one is dropped instead of being thrown on.
|
|
283
|
+
*
|
|
284
|
+
* @param {unknown} raw
|
|
285
|
+
* @returns {{ handoff: object|null, changed: boolean }}
|
|
286
|
+
*/
|
|
287
|
+
function normalizeHandoff(raw) {
|
|
288
|
+
if (raw == null) return { handoff: null, changed: false };
|
|
289
|
+
if (!isObject(raw)) return { handoff: null, changed: true };
|
|
290
|
+
let changed = false;
|
|
291
|
+
for (const key of Object.keys(raw)) if (!HANDOFF_KEYS.has(key)) changed = true;
|
|
292
|
+
const handoff = {
|
|
293
|
+
handedOffTo: nullableString(raw.handedOffTo),
|
|
294
|
+
handoffAt: nullableNumber(raw.handoffAt),
|
|
295
|
+
carriedChars: isFiniteNumber(raw.carriedChars) ? Math.trunc(raw.carriedChars) : null,
|
|
296
|
+
};
|
|
297
|
+
if (raw.carriedChars != null && !isFiniteNumber(raw.carriedChars)) changed = true;
|
|
298
|
+
if (handoff.handedOffTo === null && handoff.handoffAt === null) return { handoff: null, changed: true };
|
|
299
|
+
return { handoff, changed };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Normalize the recorded context-overflow verdict of a session.
|
|
304
|
+
*
|
|
305
|
+
* @param {unknown} raw
|
|
306
|
+
* @returns {{ contextOverflow: object|null, changed: boolean }}
|
|
307
|
+
*/
|
|
308
|
+
function normalizeContextOverflow(raw) {
|
|
309
|
+
if (raw == null) return { contextOverflow: null, changed: false };
|
|
310
|
+
if (!isObject(raw)) return { contextOverflow: null, changed: true };
|
|
311
|
+
let changed = false;
|
|
312
|
+
for (const key of Object.keys(raw)) if (!CONTEXT_OVERFLOW_KEYS.has(key)) changed = true;
|
|
313
|
+
if (raw.over !== true) return { contextOverflow: null, changed: true };
|
|
314
|
+
const contextOverflow = {
|
|
315
|
+
over: true,
|
|
316
|
+
inputTokens: nullableNumber(raw.inputTokens),
|
|
317
|
+
contextWindow: nullableNumber(raw.contextWindow),
|
|
318
|
+
detail: nullableString(raw.detail),
|
|
319
|
+
detectedAt: nullableNumber(raw.detectedAt),
|
|
320
|
+
};
|
|
321
|
+
return { contextOverflow, changed };
|
|
322
|
+
}
|
|
323
|
+
|
|
170
324
|
/**
|
|
171
325
|
* Normalize one persisted session entry.
|
|
172
326
|
* @param {unknown} raw
|
|
@@ -209,6 +363,11 @@ function normalizeEntry(raw, sessionId, now) {
|
|
|
209
363
|
changed = true;
|
|
210
364
|
}
|
|
211
365
|
|
|
366
|
+
for (const key of ['lastRecoveryAction', 'lastRecoveryDetail']) {
|
|
367
|
+
if (typeof raw[key] === 'string' && raw[key].length > 0) entry[key] = raw[key];
|
|
368
|
+
else if (raw[key] != null) changed = true;
|
|
369
|
+
}
|
|
370
|
+
|
|
212
371
|
if (Array.isArray(raw.reasons)) {
|
|
213
372
|
const reasons = raw.reasons.filter((r) => typeof r === 'string' && r.length > 0);
|
|
214
373
|
if (reasons.length !== raw.reasons.length) changed = true;
|
|
@@ -221,6 +380,16 @@ function normalizeEntry(raw, sessionId, now) {
|
|
|
221
380
|
entry.goal = goal.goal;
|
|
222
381
|
if (goal.changed) changed = true;
|
|
223
382
|
|
|
383
|
+
// Handoff / overflow records are only present when they were written: the
|
|
384
|
+
// default entry shape stays as it was, so an older checkpoint reads unchanged.
|
|
385
|
+
const handoff = normalizeHandoff(raw.handoff);
|
|
386
|
+
if (handoff.handoff !== null) entry.handoff = handoff.handoff;
|
|
387
|
+
if (handoff.changed) changed = true;
|
|
388
|
+
|
|
389
|
+
const contextOverflow = normalizeContextOverflow(raw.contextOverflow);
|
|
390
|
+
if (contextOverflow.contextOverflow !== null) entry.contextOverflow = contextOverflow.contextOverflow;
|
|
391
|
+
if (contextOverflow.changed) changed = true;
|
|
392
|
+
|
|
224
393
|
return { entry, changed };
|
|
225
394
|
}
|
|
226
395
|
|
|
@@ -285,12 +454,16 @@ function normalizeRestart(raw) {
|
|
|
285
454
|
failedAttempts: isFiniteNumber(raw.failedAttempts) && raw.failedAttempts >= 0 ? Math.trunc(raw.failedAttempts) : 0,
|
|
286
455
|
notBefore: isFiniteNumber(raw.notBefore) && raw.notBefore >= 0 ? raw.notBefore : 0,
|
|
287
456
|
requesters,
|
|
457
|
+
// Disarming the goals of goal-driven sessions is the default: a restart must
|
|
458
|
+
// never deadlock behind a goal that keeps its own agent busy.
|
|
459
|
+
parkGoals: raw.parkGoals !== false,
|
|
288
460
|
};
|
|
289
461
|
if (!isFiniteNumber(raw.generation) || raw.generation < 0) changed = true;
|
|
290
462
|
if (!isFiniteNumber(raw.requestedAt)) changed = true;
|
|
291
463
|
if (!isFiniteNumber(raw.attempt) || raw.attempt < 0) changed = true;
|
|
292
464
|
if (raw.failedAttempts != null && (!isFiniteNumber(raw.failedAttempts) || raw.failedAttempts < 0)) changed = true;
|
|
293
465
|
if (raw.notBefore != null && !isFiniteNumber(raw.notBefore)) changed = true;
|
|
466
|
+
if (typeof raw.parkGoals !== 'boolean' && raw.parkGoals != null) changed = true;
|
|
294
467
|
if (typeof raw.resume !== 'boolean' && raw.resume != null) changed = true;
|
|
295
468
|
if (raw.requesters != null && !Array.isArray(raw.requesters)) changed = true;
|
|
296
469
|
if (Array.isArray(raw.requesters) && requesters.length !== raw.requesters.length) changed = true;
|
|
@@ -349,6 +522,20 @@ export function normalizeState(raw, now = Date.now()) {
|
|
|
349
522
|
repaired = true;
|
|
350
523
|
}
|
|
351
524
|
|
|
525
|
+
// LEGACY: records written by the pause-based build. Kept verbatim so the
|
|
526
|
+
// goals that build paused can still be resumed after the next boot.
|
|
527
|
+
if (raw.parkedGoals != null) {
|
|
528
|
+
const parked = normalizeParkedGoals(raw.parkedGoals);
|
|
529
|
+
state.parkedGoals = parked.parkedGoals;
|
|
530
|
+
if (parked.changed) repaired = true;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (raw.disarmedGoals != null) {
|
|
534
|
+
const disarmed = normalizeDisarmedGoals(raw.disarmedGoals);
|
|
535
|
+
state.disarmedGoals = disarmed.disarmedGoals;
|
|
536
|
+
if (disarmed.changed) repaired = true;
|
|
537
|
+
}
|
|
538
|
+
|
|
352
539
|
return { state, repaired };
|
|
353
540
|
}
|
|
354
541
|
|
|
@@ -566,6 +753,9 @@ export function markIdle(state, sessionId, { now = Date.now() } = {}) {
|
|
|
566
753
|
entry.runningSince = null;
|
|
567
754
|
entry.updatedAt = now;
|
|
568
755
|
state.updatedAt = now;
|
|
756
|
+
// A handoff record must survive an idle transition: it is what keeps an
|
|
757
|
+
// over-limit session out of the automatic resume path forever.
|
|
758
|
+
if (isObject(entry.handoff)) return state;
|
|
569
759
|
if (!entry.pendingResume && !entry.goal) delete state.sessions[sessionId];
|
|
570
760
|
return state;
|
|
571
761
|
}
|
|
@@ -652,6 +842,278 @@ export function setGoal(state, sessionId, { goalId, goalRevision = null, phase =
|
|
|
652
842
|
return state;
|
|
653
843
|
}
|
|
654
844
|
|
|
845
|
+
/**
|
|
846
|
+
* Record what the last recovery attempt did for a session. This is the
|
|
847
|
+
* self-reporting half of the boot recovery: every attempt (including a plain
|
|
848
|
+
* early return) leaves an action plus a short detail behind, so
|
|
849
|
+
* `continuum_status` can explain why a session did not continue.
|
|
850
|
+
*
|
|
851
|
+
* Mutates `state` in place and returns it.
|
|
852
|
+
*
|
|
853
|
+
* @param {object} state
|
|
854
|
+
* @param {string} sessionId
|
|
855
|
+
* @param {{ action?: string|null, detail?: string|null, now?: number }} options
|
|
856
|
+
* @returns {object} state
|
|
857
|
+
*/
|
|
858
|
+
export function setRecoveryOutcome(state, sessionId, { action = null, detail = null, now = Date.now() } = {}) {
|
|
859
|
+
if (!sessionId) return state;
|
|
860
|
+
const entry = entryFor(state, sessionId);
|
|
861
|
+
entry.lastRecoveryAction = typeof action === 'string' && action.length > 0 ? action : null;
|
|
862
|
+
entry.lastRecoveryDetail = typeof detail === 'string' && detail.length > 0 ? detail : null;
|
|
863
|
+
entry.updatedAt = now;
|
|
864
|
+
state.updatedAt = now;
|
|
865
|
+
return state;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Record that this plugin DISARMED the goal of a session before a restart.
|
|
870
|
+
*
|
|
871
|
+
* A disarmed goal stays durably `active`; the record only exists so the next
|
|
872
|
+
* boot knows that re-arming it is safe and so `continuum_status` can report
|
|
873
|
+
* what happened. It is REPORTING ONLY: it never decides whether a goal may be
|
|
874
|
+
* disarmed (that is decided on the live goal state).
|
|
875
|
+
*
|
|
876
|
+
* The record of a session is OVERWRITTEN: a new disarm replaces the previous
|
|
877
|
+
* one (kind `disarmed`, the new disarm timestamp, `resumedAt: null`), and the
|
|
878
|
+
* LEGACY parked record of that session is dropped, so a record left over from
|
|
879
|
+
* the pause-based build can never block a later disarm.
|
|
880
|
+
*
|
|
881
|
+
* Mutates `state` in place and returns it.
|
|
882
|
+
*
|
|
883
|
+
* @param {object} state
|
|
884
|
+
* @param {string} sessionId
|
|
885
|
+
* @param {{ goalId?: string|null, disarmedAt?: number }} [options]
|
|
886
|
+
* @returns {object} state
|
|
887
|
+
*/
|
|
888
|
+
export function setDisarmedGoal(state, sessionId, { goalId = null, disarmedAt = Date.now() } = {}) {
|
|
889
|
+
if (!sessionId) return state;
|
|
890
|
+
if (!isObject(state.disarmedGoals)) state.disarmedGoals = {};
|
|
891
|
+
state.disarmedGoals[String(sessionId)] = {
|
|
892
|
+
goalId: goalId == null ? null : String(goalId),
|
|
893
|
+
disarmedAt: isFiniteNumber(disarmedAt) ? disarmedAt : Date.now(),
|
|
894
|
+
resumedAt: null,
|
|
895
|
+
};
|
|
896
|
+
// The current shape supersedes the LEGACY one for this session: one session
|
|
897
|
+
// has ONE goal record, so a stale legacy record can never linger next to it.
|
|
898
|
+
if (isObject(state.parkedGoals)) delete state.parkedGoals[String(sessionId)];
|
|
899
|
+
state.updatedAt = Date.now();
|
|
900
|
+
return state;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* Stamp the goal record(s) of a session with the resume timestamp: the goal was
|
|
905
|
+
* re-armed by this plugin, so the status must show it as resumed.
|
|
906
|
+
*
|
|
907
|
+
* Both record shapes are stamped (the current disarmed record and the LEGACY
|
|
908
|
+
* parked record), because either one can describe the goal that was re-armed.
|
|
909
|
+
* A session without a record simply has nothing to stamp.
|
|
910
|
+
*
|
|
911
|
+
* Mutates `state` in place and returns it.
|
|
912
|
+
*
|
|
913
|
+
* @param {object} state
|
|
914
|
+
* @param {string} sessionId
|
|
915
|
+
* @param {{ resumedAt?: number }} [options]
|
|
916
|
+
* @returns {object} state
|
|
917
|
+
*/
|
|
918
|
+
export function stampResumedGoal(state, sessionId, { resumedAt = Date.now() } = {}) {
|
|
919
|
+
if (!sessionId) return state;
|
|
920
|
+
const at = isFiniteNumber(resumedAt) ? resumedAt : Date.now();
|
|
921
|
+
let changed = false;
|
|
922
|
+
for (const records of [state.disarmedGoals, state.parkedGoals]) {
|
|
923
|
+
if (!isObject(records)) continue;
|
|
924
|
+
const record = records[String(sessionId)];
|
|
925
|
+
if (!isObject(record)) continue;
|
|
926
|
+
record.resumedAt = at;
|
|
927
|
+
changed = true;
|
|
928
|
+
}
|
|
929
|
+
if (changed) state.updatedAt = at;
|
|
930
|
+
return state;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* Clear the disarmed record of a session: the goal was re-armed. The record is
|
|
935
|
+
* kept (with `resumedAt` set) so `continuum_status` can still report whether the
|
|
936
|
+
* goal came back; a later disarm of the same session overwrites it.
|
|
937
|
+
*
|
|
938
|
+
* Mutates `state` in place and returns it.
|
|
939
|
+
*
|
|
940
|
+
* @param {object} state
|
|
941
|
+
* @param {string} sessionId
|
|
942
|
+
* @param {{ resumedAt?: number }} [options]
|
|
943
|
+
* @returns {object} state
|
|
944
|
+
*/
|
|
945
|
+
export function clearDisarmedGoal(state, sessionId, { resumedAt = Date.now() } = {}) {
|
|
946
|
+
if (!sessionId || !isObject(state.disarmedGoals)) return state;
|
|
947
|
+
const record = state.disarmedGoals[String(sessionId)];
|
|
948
|
+
if (!isObject(record)) return state;
|
|
949
|
+
record.resumedAt = isFiniteNumber(resumedAt) ? resumedAt : Date.now();
|
|
950
|
+
state.updatedAt = Date.now();
|
|
951
|
+
return state;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* The disarmed-goal record of a session that was not resumed yet, or null.
|
|
956
|
+
*
|
|
957
|
+
* @param {object} state
|
|
958
|
+
* @param {string} sessionId
|
|
959
|
+
* @returns {{ goalId: string|null, disarmedAt: number|null, resumedAt: number|null }|null}
|
|
960
|
+
*/
|
|
961
|
+
export function disarmedRecord(state, sessionId) {
|
|
962
|
+
try {
|
|
963
|
+
if (!isObject(state) || !isObject(state.disarmedGoals)) return null;
|
|
964
|
+
const record = state.disarmedGoals[String(sessionId)];
|
|
965
|
+
if (!isObject(record)) return null;
|
|
966
|
+
if (record.resumedAt !== null && record.resumedAt !== undefined) return null;
|
|
967
|
+
return record;
|
|
968
|
+
} catch {
|
|
969
|
+
return null;
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* Record that the PREVIOUS (pause-based) build parked the goal of a session.
|
|
975
|
+
* Kept for one purpose: the two goals that build paused must be resumable
|
|
976
|
+
* after the next boot instead of being stranded in `paused`.
|
|
977
|
+
*
|
|
978
|
+
* Mutates `state` in place and returns it.
|
|
979
|
+
*
|
|
980
|
+
* @param {object} state
|
|
981
|
+
* @param {string} sessionId
|
|
982
|
+
* @param {{ goalId?: string|null, parkedAt?: number }} [options]
|
|
983
|
+
* @returns {object} state
|
|
984
|
+
*/
|
|
985
|
+
export function setParkedGoal(state, sessionId, { goalId = null, parkedAt = Date.now() } = {}) {
|
|
986
|
+
if (!sessionId) return state;
|
|
987
|
+
if (!isObject(state.parkedGoals)) state.parkedGoals = {};
|
|
988
|
+
state.parkedGoals[String(sessionId)] = {
|
|
989
|
+
goalId: goalId == null ? null : String(goalId),
|
|
990
|
+
parkedAt: isFiniteNumber(parkedAt) ? parkedAt : Date.now(),
|
|
991
|
+
resumedAt: null,
|
|
992
|
+
};
|
|
993
|
+
state.updatedAt = Date.now();
|
|
994
|
+
return state;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/**
|
|
998
|
+
* Clear the LEGACY parked state of a session: the paused goal was resumed.
|
|
999
|
+
* Mutates `state` in place and returns it.
|
|
1000
|
+
*
|
|
1001
|
+
* @param {object} state
|
|
1002
|
+
* @param {string} sessionId
|
|
1003
|
+
* @param {{ resumedAt?: number }} [options]
|
|
1004
|
+
* @returns {object} state
|
|
1005
|
+
*/
|
|
1006
|
+
export function clearParkedGoal(state, sessionId, { resumedAt = Date.now() } = {}) {
|
|
1007
|
+
if (!sessionId || !isObject(state.parkedGoals)) return state;
|
|
1008
|
+
const record = state.parkedGoals[String(sessionId)];
|
|
1009
|
+
if (!isObject(record)) return state;
|
|
1010
|
+
record.resumedAt = isFiniteNumber(resumedAt) ? resumedAt : Date.now();
|
|
1011
|
+
state.updatedAt = Date.now();
|
|
1012
|
+
return state;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/**
|
|
1016
|
+
* The LEGACY parked-goal record of a session that was not resumed yet, or null.
|
|
1017
|
+
*
|
|
1018
|
+
* @param {object} state
|
|
1019
|
+
* @param {string} sessionId
|
|
1020
|
+
* @returns {{ goalId: string|null, parkedAt: number|null, resumedAt: number|null }|null}
|
|
1021
|
+
*/
|
|
1022
|
+
export function parkedRecord(state, sessionId) {
|
|
1023
|
+
try {
|
|
1024
|
+
if (!isObject(state) || !isObject(state.parkedGoals)) return null;
|
|
1025
|
+
const record = state.parkedGoals[String(sessionId)];
|
|
1026
|
+
if (!isObject(record)) return null;
|
|
1027
|
+
if (record.resumedAt !== null && record.resumedAt !== undefined) return null;
|
|
1028
|
+
return record;
|
|
1029
|
+
} catch {
|
|
1030
|
+
return null;
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/**
|
|
1035
|
+
* Record that a session was HANDED OFF to a fresh session.
|
|
1036
|
+
*
|
|
1037
|
+
* The record is the durable marker that this session must never be resumed
|
|
1038
|
+
* automatically again: its conversation is what made every request fail with
|
|
1039
|
+
* "context window exceeds limit", so replaying it can only fail again. Writing
|
|
1040
|
+
* the record therefore also clears the resume flags (the session stops blocking
|
|
1041
|
+
* a restart and stops being picked up by `recoverableSessions`).
|
|
1042
|
+
*
|
|
1043
|
+
* Mutates `state` in place and returns it.
|
|
1044
|
+
*
|
|
1045
|
+
* @param {object} state
|
|
1046
|
+
* @param {string} sessionId the OLD (handed-off) session
|
|
1047
|
+
* @param {{ handedOffTo?: string|null, handoffAt?: number, carriedChars?: number|null }} [options]
|
|
1048
|
+
* @returns {object} state
|
|
1049
|
+
*/
|
|
1050
|
+
export function setHandoff(state, sessionId, { handedOffTo = null, handoffAt = Date.now(), carriedChars = null } = {}) {
|
|
1051
|
+
if (!sessionId) return state;
|
|
1052
|
+
const at = isFiniteNumber(handoffAt) ? handoffAt : Date.now();
|
|
1053
|
+
const entry = entryFor(state, sessionId);
|
|
1054
|
+
entry.handoff = {
|
|
1055
|
+
handedOffTo: handedOffTo == null ? null : String(handedOffTo),
|
|
1056
|
+
handoffAt: at,
|
|
1057
|
+
carriedChars: isFiniteNumber(carriedChars) ? Math.trunc(carriedChars) : null,
|
|
1058
|
+
};
|
|
1059
|
+
entry.pendingResume = false;
|
|
1060
|
+
entry.pendingSince = null;
|
|
1061
|
+
entry.running = false;
|
|
1062
|
+
entry.runningSince = null;
|
|
1063
|
+
entry.interrupted = false;
|
|
1064
|
+
entry.updatedAt = at;
|
|
1065
|
+
state.updatedAt = at;
|
|
1066
|
+
return state;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
/**
|
|
1070
|
+
* The handoff record of a session, or null.
|
|
1071
|
+
*
|
|
1072
|
+
* @param {object} state
|
|
1073
|
+
* @param {string} sessionId
|
|
1074
|
+
* @returns {{ handedOffTo: string|null, handoffAt: number|null, carriedChars: number|null }|null}
|
|
1075
|
+
*/
|
|
1076
|
+
export function handoffRecord(state, sessionId) {
|
|
1077
|
+
try {
|
|
1078
|
+
if (!isObject(state) || !isObject(state.sessions)) return null;
|
|
1079
|
+
const entry = state.sessions[String(sessionId)];
|
|
1080
|
+
if (!isObject(entry) || !isObject(entry.handoff)) return null;
|
|
1081
|
+
return entry.handoff;
|
|
1082
|
+
} catch {
|
|
1083
|
+
return null;
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/**
|
|
1088
|
+
* Record (or clear, with `over: false`) the context-overflow verdict of a
|
|
1089
|
+
* session. Kept on the entry so `continuum_status` can report an over-limit
|
|
1090
|
+
* session even after it is no longer live.
|
|
1091
|
+
*
|
|
1092
|
+
* Mutates `state` in place and returns it.
|
|
1093
|
+
*
|
|
1094
|
+
* @param {object} state
|
|
1095
|
+
* @param {string} sessionId
|
|
1096
|
+
* @param {{ over?: boolean, inputTokens?: number|null, contextWindow?: number|null, detail?: string|null, detectedAt?: number }} [options]
|
|
1097
|
+
* @returns {object} state
|
|
1098
|
+
*/
|
|
1099
|
+
export function setContextOverflow(state, sessionId, { over = false, inputTokens = null, contextWindow = null, detail = null, detectedAt = Date.now() } = {}) {
|
|
1100
|
+
if (!sessionId) return state;
|
|
1101
|
+
const entry = entryFor(state, sessionId);
|
|
1102
|
+
if (over !== true) {
|
|
1103
|
+
entry.contextOverflow = null;
|
|
1104
|
+
} else {
|
|
1105
|
+
entry.contextOverflow = {
|
|
1106
|
+
over: true,
|
|
1107
|
+
inputTokens: nullableNumber(inputTokens),
|
|
1108
|
+
contextWindow: nullableNumber(contextWindow),
|
|
1109
|
+
detail: nullableString(detail),
|
|
1110
|
+
detectedAt: isFiniteNumber(detectedAt) ? detectedAt : Date.now(),
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
1113
|
+
state.updatedAt = Date.now();
|
|
1114
|
+
return state;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
655
1117
|
/**
|
|
656
1118
|
* Open a deferred restart cycle (bumps `generation` exactly once per request).
|
|
657
1119
|
*
|
|
@@ -666,10 +1128,10 @@ export function setGoal(state, sessionId, { goalId, goalRevision = null, phase =
|
|
|
666
1128
|
* Mutates `state` in place and returns it.
|
|
667
1129
|
*
|
|
668
1130
|
* @param {object} state
|
|
669
|
-
* @param {{ reason?: string, bySessionId?: string|null, resume?: boolean, now?: number }} options
|
|
1131
|
+
* @param {{ reason?: string, bySessionId?: string|null, resume?: boolean, parkGoals?: boolean, now?: number }} options
|
|
670
1132
|
* @returns {object} state
|
|
671
1133
|
*/
|
|
672
|
-
export function requestRestart(state, { reason, bySessionId = null, resume = true, now = Date.now() } = {}) {
|
|
1134
|
+
export function requestRestart(state, { reason, bySessionId = null, resume = true, parkGoals = true, now = Date.now() } = {}) {
|
|
673
1135
|
const generation = (isFiniteNumber(state.generation) && state.generation >= 0 ? state.generation : 0) + 1;
|
|
674
1136
|
state.generation = generation;
|
|
675
1137
|
state.phase = 'deferred';
|
|
@@ -704,6 +1166,9 @@ export function requestRestart(state, { reason, bySessionId = null, resume = tru
|
|
|
704
1166
|
failedAttempts: 0,
|
|
705
1167
|
notBefore: 0,
|
|
706
1168
|
requesters,
|
|
1169
|
+
// A goal-driven session must not deadlock the restart: disarming its goal is
|
|
1170
|
+
// on by default and can be turned off per restart request.
|
|
1171
|
+
parkGoals: parkGoals !== false,
|
|
707
1172
|
};
|
|
708
1173
|
for (const requester of requesters) {
|
|
709
1174
|
if (requester.resume === false) continue;
|
|
@@ -761,6 +1226,51 @@ export function recoverableSessions(state, { limit } = {}) {
|
|
|
761
1226
|
return selectRecoverableSessions(state, { limit });
|
|
762
1227
|
}
|
|
763
1228
|
|
|
1229
|
+
/**
|
|
1230
|
+
* Prune one goal-record map: a resumed record is diagnostics only and is
|
|
1231
|
+
* dropped once it is older than `maxAgeMs`; a record that was never resumed is
|
|
1232
|
+
* NEVER dropped, because a later boot still has to resume that goal.
|
|
1233
|
+
*
|
|
1234
|
+
* @param {object} map
|
|
1235
|
+
* @param {{ now: number, maxAgeMs: number, atKey: string, limit: number }} options
|
|
1236
|
+
*/
|
|
1237
|
+
function pruneGoalRecords(map, { now, maxAgeMs, atKey, limit }) {
|
|
1238
|
+
for (const id of Object.keys(map)) {
|
|
1239
|
+
const record = map[id];
|
|
1240
|
+
if (!isObject(record)) {
|
|
1241
|
+
delete map[id];
|
|
1242
|
+
continue;
|
|
1243
|
+
}
|
|
1244
|
+
const resumedAt = isFiniteNumber(record.resumedAt) ? record.resumedAt : null;
|
|
1245
|
+
if (resumedAt !== null && resumedAt < now - maxAgeMs) delete map[id];
|
|
1246
|
+
}
|
|
1247
|
+
const ids = Object.keys(map);
|
|
1248
|
+
if (ids.length > limit) {
|
|
1249
|
+
const at = (id) => {
|
|
1250
|
+
const record = map[id];
|
|
1251
|
+
return isObject(record) && isFiniteNumber(record[atKey]) ? record[atKey] : 0;
|
|
1252
|
+
};
|
|
1253
|
+
ids.sort((a, b) => at(b) - at(a));
|
|
1254
|
+
for (const id of ids.slice(limit)) delete map[id];
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
/**
|
|
1259
|
+
* Keep the goal records small (both shapes). Mutates `state` in place.
|
|
1260
|
+
*
|
|
1261
|
+
* @param {object} state
|
|
1262
|
+
* @param {{ now?: number, maxAgeMs?: number }} [options]
|
|
1263
|
+
*/
|
|
1264
|
+
function pruneGoalRecordMaps(state, { now = Date.now(), maxAgeMs } = {}) {
|
|
1265
|
+
const age = isFiniteNumber(maxAgeMs) && maxAgeMs > 0 ? maxAgeMs : 7 * 24 * 60 * 60 * 1000;
|
|
1266
|
+
if (isObject(state.disarmedGoals)) {
|
|
1267
|
+
pruneGoalRecords(state.disarmedGoals, { now, maxAgeMs: age, atKey: 'disarmedAt', limit: DISARMED_GOALS_LIMIT });
|
|
1268
|
+
}
|
|
1269
|
+
if (isObject(state.parkedGoals)) {
|
|
1270
|
+
pruneGoalRecords(state.parkedGoals, { now, maxAgeMs: age, atKey: 'parkedAt', limit: PARKED_GOALS_LIMIT });
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
|
|
764
1274
|
/**
|
|
765
1275
|
* Keep the checkpoint small: cap the session map by `updatedAt` (newest wins)
|
|
766
1276
|
* and optionally drop finished entries older than `maxAgeMs`. Running and
|
|
@@ -772,6 +1282,7 @@ export function recoverableSessions(state, { limit } = {}) {
|
|
|
772
1282
|
* @returns {object} state
|
|
773
1283
|
*/
|
|
774
1284
|
export function pruneSessions(state, { maxSessions, now = Date.now(), maxAgeMs } = {}) {
|
|
1285
|
+
pruneGoalRecordMaps(state, { now, maxAgeMs });
|
|
775
1286
|
if (!isObject(state.sessions)) return state;
|
|
776
1287
|
const sessions = state.sessions;
|
|
777
1288
|
const ids = Object.keys(sessions);
|
|
@@ -793,6 +1304,9 @@ export function pruneSessions(state, { maxSessions, now = Date.now(), maxAgeMs }
|
|
|
793
1304
|
if (!isObject(entry)) continue;
|
|
794
1305
|
if (entry.running === true || entry.pendingResume === true) continue;
|
|
795
1306
|
if (entry.goal) continue;
|
|
1307
|
+
// A handoff / overflow record is the durable "never auto-resume this
|
|
1308
|
+
// session again" marker: it is never dropped by age.
|
|
1309
|
+
if (isObject(entry.handoff) || isObject(entry.contextOverflow)) continue;
|
|
796
1310
|
if (isFiniteNumber(entry.updatedAt) && entry.updatedAt < cutoff) remove.add(id);
|
|
797
1311
|
}
|
|
798
1312
|
}
|