@a5c-ai/babysitter-omp 0.1.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.
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Iteration guards and runaway-loop detection.
3
+ *
4
+ * Before every orchestration iteration the guard module is consulted.
5
+ * If any limit is breached the run is halted gracefully rather than
6
+ * allowed to spiral into the void -- which, admittedly, is where
7
+ * everything ends up eventually.
8
+ *
9
+ * @module guards
10
+ */
11
+
12
+ import type { RunState } from './session-binder.js';
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Guard configuration constants
16
+ // ---------------------------------------------------------------------------
17
+
18
+ /** Default maximum orchestration iterations per run. */
19
+ export const MAX_ITERATIONS_DEFAULT = 256;
20
+
21
+ /** Maximum wall-clock time (ms) for a single run -- 2 hours. */
22
+ export const MAX_RUN_DURATION_MS = 7_200_000;
23
+
24
+ /** Consecutive errors before the guard trips. */
25
+ export const MAX_CONSECUTIVE_ERRORS = 3;
26
+
27
+ /** Number of suspiciously fast iterations that signal a doom loop. */
28
+ export const DOOM_LOOP_THRESHOLD = 3;
29
+
30
+ /** Minimum duration (ms) for an iteration to be considered "real work". */
31
+ export const DOOM_LOOP_MIN_DURATION_MS = 2_000;
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Guard result type
35
+ // ---------------------------------------------------------------------------
36
+
37
+ /** Result returned by {@link checkGuards}. */
38
+ export interface GuardResult {
39
+ /** Whether all guards passed. */
40
+ passed: boolean;
41
+ /** Human-readable reason when `passed` is false. */
42
+ reason?: string;
43
+ /** Suggested action when `passed` is false. */
44
+ action?: 'stop' | 'warn';
45
+ }
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Internal mutable state
49
+ // ---------------------------------------------------------------------------
50
+
51
+ /** Rolling count of consecutive iteration errors. */
52
+ let consecutiveErrorCount = 0;
53
+
54
+ /**
55
+ * History of recent pending-effect counts, used for doom-loop detection.
56
+ * Each entry records the number of pending effects at the end of an iteration.
57
+ */
58
+ const pendingCountHistory: number[] = [];
59
+
60
+ /**
61
+ * Recent iteration digests used for doom-loop detection (legacy compat).
62
+ * @internal
63
+ */
64
+ const iterationDigests: string[] = [];
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Public API
68
+ // ---------------------------------------------------------------------------
69
+
70
+ /**
71
+ * Check all guards against the current run state.
72
+ *
73
+ * Evaluates, in order:
74
+ * 1. Maximum iteration count
75
+ * 2. Wall-clock time budget
76
+ * 3. Consecutive error threshold
77
+ * 4. Doom-loop detection
78
+ *
79
+ * Returns `{ passed: true }` when the next iteration may proceed, or
80
+ * `{ passed: false, reason, action }` when a limit has been breached.
81
+ *
82
+ * @param runState - The current {@link RunState} snapshot.
83
+ * @returns Whether the next iteration is allowed.
84
+ */
85
+ export function checkGuards(runState: RunState): GuardResult {
86
+ // 1. Max iterations
87
+ const maxIter = runState.maxIterations ?? MAX_ITERATIONS_DEFAULT;
88
+ if (runState.iteration >= maxIter) {
89
+ return {
90
+ passed: false,
91
+ reason: `Maximum iterations reached (${runState.iteration} >= ${maxIter}). The run must stop.`,
92
+ action: 'stop',
93
+ };
94
+ }
95
+
96
+ // 2. Wall-clock time limit
97
+ const elapsedMs = Date.now() - new Date(runState.startedAt).getTime();
98
+ if (elapsedMs >= MAX_RUN_DURATION_MS) {
99
+ return {
100
+ passed: false,
101
+ reason: `Maximum duration exceeded (${Math.round(elapsedMs / 1000)}s >= ${Math.round(MAX_RUN_DURATION_MS / 1000)}s).`,
102
+ action: 'stop',
103
+ };
104
+ }
105
+
106
+ // 3. Consecutive error threshold
107
+ if (consecutiveErrorCount > MAX_CONSECUTIVE_ERRORS) {
108
+ return {
109
+ passed: false,
110
+ reason: `Too many consecutive errors (${consecutiveErrorCount} > ${MAX_CONSECUTIVE_ERRORS}).`,
111
+ action: 'stop',
112
+ };
113
+ }
114
+
115
+ // 4. Doom-loop detection
116
+ if (isDoomLoop(runState)) {
117
+ return {
118
+ passed: false,
119
+ reason: `Doom loop detected: the last ${DOOM_LOOP_THRESHOLD} iterations were suspiciously fast with no progress.`,
120
+ action: 'stop',
121
+ };
122
+ }
123
+
124
+ // 4b. Legacy digest-based doom-loop detection (for callers using recordIterationDigest)
125
+ if (isDigestDoomLoop(DOOM_LOOP_THRESHOLD)) {
126
+ return {
127
+ passed: false,
128
+ reason: `Doom loop detected: the last ${DOOM_LOOP_THRESHOLD} iterations produced identical output.`,
129
+ action: 'warn',
130
+ };
131
+ }
132
+
133
+ return { passed: true };
134
+ }
135
+
136
+ /**
137
+ * Reset all internal guard state (counters, histories).
138
+ *
139
+ * Called on session cleanup or when starting a fresh run.
140
+ */
141
+ export function resetGuardState(): void {
142
+ consecutiveErrorCount = 0;
143
+ pendingCountHistory.length = 0;
144
+ iterationDigests.length = 0;
145
+ }
146
+
147
+ /**
148
+ * Record the outcome of an iteration for consecutive-error tracking.
149
+ *
150
+ * @param success - `true` if the iteration succeeded, `false` if it errored.
151
+ */
152
+ export function recordIterationOutcome(success: boolean): void {
153
+ if (success) {
154
+ consecutiveErrorCount = 0;
155
+ } else {
156
+ consecutiveErrorCount += 1;
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Record the number of pending effects after an iteration, for doom-loop
162
+ * detection.
163
+ *
164
+ * @param pendingCount - The number of effects still pending.
165
+ */
166
+ export function recordPendingCount(pendingCount: number): void {
167
+ pendingCountHistory.push(pendingCount);
168
+ // Keep a bounded history
169
+ if (pendingCountHistory.length > 32) {
170
+ pendingCountHistory.shift();
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Check whether the run is stuck in a doom loop.
176
+ *
177
+ * A doom loop is detected when the last {@link DOOM_LOOP_THRESHOLD}
178
+ * iterations all completed in under {@link DOOM_LOOP_MIN_DURATION_MS}
179
+ * each AND the pending effect count has not changed across those
180
+ * iterations.
181
+ *
182
+ * @param runState - The current {@link RunState} snapshot.
183
+ * @returns `true` if the run appears to be looping without making progress.
184
+ */
185
+ export function isDoomLoop(runState: RunState): boolean {
186
+ const times = runState.iterationTimes;
187
+
188
+ // Need at least DOOM_LOOP_THRESHOLD iterations to check
189
+ if (!times || times.length < DOOM_LOOP_THRESHOLD) {
190
+ return false;
191
+ }
192
+
193
+ // Check if the last N iteration times are all suspiciously fast
194
+ const recentTimes = times.slice(-DOOM_LOOP_THRESHOLD);
195
+ const allFast = recentTimes.every((t) => t < DOOM_LOOP_MIN_DURATION_MS);
196
+ if (!allFast) {
197
+ return false;
198
+ }
199
+
200
+ // Check if pending effect count hasn't changed over the same window
201
+ if (pendingCountHistory.length < DOOM_LOOP_THRESHOLD) {
202
+ // Not enough pending-count data -- fall through to time-only check
203
+ return true;
204
+ }
205
+
206
+ const recentPending = pendingCountHistory.slice(-DOOM_LOOP_THRESHOLD);
207
+ const firstPending = recentPending[0];
208
+ const pendingUnchanged = recentPending.every((c) => c === firstPending);
209
+
210
+ return pendingUnchanged;
211
+ }
212
+
213
+ // ---------------------------------------------------------------------------
214
+ // Legacy compatibility exports
215
+ // ---------------------------------------------------------------------------
216
+
217
+ /**
218
+ * Record the digest of an iteration's output for doom-loop tracking.
219
+ *
220
+ * @deprecated Prefer {@link recordPendingCount} for doom-loop detection.
221
+ * @param digest - A string digest (e.g. JSON.stringify of pending effects).
222
+ */
223
+ export function recordIterationDigest(digest: string): void {
224
+ iterationDigests.push(digest);
225
+ if (iterationDigests.length > 32) {
226
+ iterationDigests.shift();
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Reset digest history.
232
+ *
233
+ * @deprecated Use {@link resetGuardState} instead.
234
+ */
235
+ export function resetDigests(): void {
236
+ resetGuardState();
237
+ }
238
+
239
+ // ---------------------------------------------------------------------------
240
+ // Internal helpers
241
+ // ---------------------------------------------------------------------------
242
+
243
+ /**
244
+ * Legacy digest-based doom-loop check.
245
+ *
246
+ * @param windowSize - Number of consecutive identical digests to trigger.
247
+ * @returns `true` if the last `windowSize` digests are all equal.
248
+ */
249
+ function isDigestDoomLoop(windowSize: number): boolean {
250
+ if (iterationDigests.length < windowSize) {
251
+ return false;
252
+ }
253
+
254
+ const tail = iterationDigests.slice(-windowSize);
255
+ const first = tail[0];
256
+ return tail.every((d) => d === first);
257
+ }