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/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
  }