@hank-warren/pi-loop 0.4.0 → 0.5.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,183 @@
1
+ /**
2
+ * Persistence for `run` tasks (`~/.pi/agent/loop/schedules.json`) and the
3
+ * single-writer lease that decides which live Pi session is allowed to fire
4
+ * them.
5
+ *
6
+ * The lease exists because headless tasks are shared state: every open
7
+ * session reads the same file, and without arbitration a task scheduled for
8
+ * 09:00 fires once per session that happens to be running. A lockfile holding
9
+ * a pid and a heartbeat is enough — a holder that dies stops renewing, and
10
+ * the next session takes over after the stale window. Deliberately not a real
11
+ * distributed lock: the failure it must prevent is *duplicate work*, and the
12
+ * worst case it can produce is one skipped tick.
13
+ *
14
+ * Everything here is best-effort. A read-only home directory means "no
15
+ * headless scheduling", never a crash.
16
+ */
17
+
18
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
19
+ import { randomUUID } from "node:crypto";
20
+ import { dirname, join } from "node:path";
21
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
22
+ import { normalizeTask, type ScheduledTask } from "./model.js";
23
+
24
+ export const SCHEDULES_FILE = "schedules.json";
25
+ export const LEASE_FILE = "schedules.lease";
26
+ export const RUNS_DIR = "runs";
27
+
28
+ /** A lease older than this is assumed abandoned. */
29
+ export const LEASE_STALE_MS = 90_000;
30
+ /** How often the holder refreshes it. */
31
+ export const LEASE_HEARTBEAT_MS = 30_000;
32
+
33
+ export interface SchedulePaths {
34
+ dir: string;
35
+ schedules: string;
36
+ lease: string;
37
+ runs: string;
38
+ }
39
+
40
+ export function schedulePaths(agentDir = getAgentDir()): SchedulePaths {
41
+ const dir = join(agentDir, "loop");
42
+ return {
43
+ dir,
44
+ schedules: join(dir, SCHEDULES_FILE),
45
+ lease: join(dir, LEASE_FILE),
46
+ runs: join(dir, RUNS_DIR),
47
+ };
48
+ }
49
+
50
+ /** Read persisted tasks, fail-open: an unreadable file means no tasks. */
51
+ export function readTasks(paths: SchedulePaths): ScheduledTask[] {
52
+ let contents: string;
53
+ try {
54
+ contents = readFileSync(paths.schedules, "utf8");
55
+ } catch {
56
+ return [];
57
+ }
58
+ try {
59
+ const parsed: unknown = JSON.parse(contents);
60
+ if (!Array.isArray(parsed)) return [];
61
+ // One malformed entry drops itself, not the whole schedule.
62
+ return parsed
63
+ .map((value) => normalizeTask(value))
64
+ .filter((task): task is ScheduledTask => task !== undefined);
65
+ } catch {
66
+ return [];
67
+ }
68
+ }
69
+
70
+ /** Atomically replace the persisted tasks. Returns a failure reason, if any. */
71
+ export function writeTasks(paths: SchedulePaths, tasks: ScheduledTask[]): string | undefined {
72
+ const document = `${JSON.stringify(tasks, null, 2)}\n`;
73
+ const temporary = join(paths.dir, `.${SCHEDULES_FILE}.${randomUUID()}.tmp`);
74
+ try {
75
+ mkdirSync(paths.dir, { recursive: true });
76
+ writeFileSync(temporary, document, { encoding: "utf8", flag: "wx" });
77
+ renameSync(temporary, paths.schedules);
78
+ return undefined;
79
+ } catch (error) {
80
+ return error instanceof Error ? error.message : String(error);
81
+ } finally {
82
+ try {
83
+ rmSync(temporary, { force: true });
84
+ } catch {
85
+ // Best-effort cleanup must not replace the write result.
86
+ }
87
+ }
88
+ }
89
+
90
+ export interface LeaseRecord {
91
+ pid: number;
92
+ heartbeat: number;
93
+ owner: string;
94
+ }
95
+
96
+ /**
97
+ * Take or renew the lease. Returns true when this process may fire headless
98
+ * tasks. `owner` identifies this runtime so a restarted process with a reused
99
+ * pid cannot mistake someone else's lease for its own.
100
+ */
101
+ export function acquireLease(
102
+ paths: SchedulePaths,
103
+ owner: string,
104
+ now: number,
105
+ pid: number = process.pid,
106
+ ): boolean {
107
+ const current = readLease(paths);
108
+ if (current && current.owner !== owner) {
109
+ const fresh = now - current.heartbeat < LEASE_STALE_MS;
110
+ if (fresh && isProcessAlive(current.pid)) return false;
111
+ }
112
+ return writeLease(paths, { pid, heartbeat: now, owner });
113
+ }
114
+
115
+ /** Release the lease if this owner holds it. */
116
+ export function releaseLease(paths: SchedulePaths, owner: string): void {
117
+ const current = readLease(paths);
118
+ if (!current || current.owner !== owner) return;
119
+ try {
120
+ rmSync(paths.lease, { force: true });
121
+ } catch {
122
+ // A lease we cannot delete simply goes stale.
123
+ }
124
+ }
125
+
126
+ export function readLease(paths: SchedulePaths): LeaseRecord | undefined {
127
+ try {
128
+ const parsed: unknown = JSON.parse(readFileSync(paths.lease, "utf8"));
129
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
130
+ const record = parsed as Record<string, unknown>;
131
+ if (
132
+ typeof record.pid !== "number" ||
133
+ typeof record.heartbeat !== "number" ||
134
+ typeof record.owner !== "string" ||
135
+ !record.owner
136
+ ) {
137
+ return undefined;
138
+ }
139
+ return { pid: record.pid, heartbeat: record.heartbeat, owner: record.owner };
140
+ } catch {
141
+ return undefined;
142
+ }
143
+ }
144
+
145
+ function writeLease(paths: SchedulePaths, lease: LeaseRecord): boolean {
146
+ const temporary = join(dirname(paths.lease), `.${LEASE_FILE}.${randomUUID()}.tmp`);
147
+ try {
148
+ mkdirSync(paths.dir, { recursive: true });
149
+ writeFileSync(temporary, `${JSON.stringify(lease)}\n`, { encoding: "utf8", flag: "wx" });
150
+ renameSync(temporary, paths.lease);
151
+ return true;
152
+ } catch {
153
+ return false;
154
+ } finally {
155
+ try {
156
+ rmSync(temporary, { force: true });
157
+ } catch {
158
+ // Best-effort.
159
+ }
160
+ }
161
+ }
162
+
163
+ function isProcessAlive(pid: number): boolean {
164
+ if (!Number.isSafeInteger(pid) || pid <= 0) return false;
165
+ try {
166
+ // Signal 0 tests for existence without delivering anything.
167
+ process.kill(pid, 0);
168
+ return true;
169
+ } catch (error) {
170
+ // EPERM means it exists and belongs to someone else.
171
+ return isNodeError(error) && error.code === "EPERM";
172
+ }
173
+ }
174
+
175
+ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
176
+ return error instanceof Error && "code" in error;
177
+ }
178
+
179
+ /** Where a headless run's output is logged. */
180
+ export function runLogPath(paths: SchedulePaths, taskId: string, at: number): string {
181
+ const stamp = new Date(at).toISOString().replace(/[:.]/g, "-");
182
+ return join(paths.runs, taskId, `${stamp}.log`);
183
+ }
package/src/settings.ts CHANGED
@@ -22,8 +22,19 @@ export interface LoopCompactionSettings {
22
22
  }
23
23
 
24
24
  export interface LoopSettings {
25
- /** Delivered-poke cap; null means unlimited (explicit opt-in). */
25
+ /** Delivered-wake cap; null means unlimited (explicit opt-in). */
26
26
  maxIterations: number | null;
27
+ /**
28
+ * Cap on turns the loop itself causes (settle continuations plus fallback
29
+ * pokes); null means unlimited. A settle-paced loop can run many turns per
30
+ * wake, so this is the bound that actually holds it.
31
+ */
32
+ automaticTurns: number | null;
33
+ /**
34
+ * Consecutive tool-free loop turns with identical output that pause the
35
+ * loop; null disables the breaker.
36
+ */
37
+ noProgressTurns: number | null;
27
38
  /** Wall-clock expiry for a loop, e.g. "7d" (research: bound forgotten loops). */
28
39
  maxLoopDuration: string;
29
40
  compaction: LoopCompactionSettings;
@@ -31,6 +42,8 @@ export interface LoopSettings {
31
42
 
32
43
  export const DEFAULT_LOOP_SETTINGS: LoopSettings = {
33
44
  maxIterations: 25,
45
+ automaticTurns: 25,
46
+ noProgressTurns: 3,
34
47
  maxLoopDuration: "7d",
35
48
  compaction: {
36
49
  enabled: true,
@@ -51,6 +64,15 @@ export function normalizeLoopSettings(value: unknown): LoopSettings | undefined
51
64
  const maxIterations = normalizeCap(record.maxIterations, DEFAULT_LOOP_SETTINGS.maxIterations);
52
65
  if (maxIterations === false) return undefined;
53
66
 
67
+ const automaticTurns = normalizeCap(record.automaticTurns, DEFAULT_LOOP_SETTINGS.automaticTurns);
68
+ if (automaticTurns === false) return undefined;
69
+
70
+ const noProgressTurns = normalizeCap(
71
+ record.noProgressTurns,
72
+ DEFAULT_LOOP_SETTINGS.noProgressTurns,
73
+ );
74
+ if (noProgressTurns === false) return undefined;
75
+
54
76
  const maxLoopDuration = Object.hasOwn(record, "maxLoopDuration")
55
77
  ? record.maxLoopDuration
56
78
  : DEFAULT_LOOP_SETTINGS.maxLoopDuration;
@@ -86,6 +108,8 @@ export function normalizeLoopSettings(value: unknown): LoopSettings | undefined
86
108
 
87
109
  return {
88
110
  maxIterations,
111
+ automaticTurns,
112
+ noProgressTurns,
89
113
  maxLoopDuration,
90
114
  compaction: { enabled, threshold, instructions },
91
115
  };
@@ -172,6 +196,8 @@ export function saveLoopSettings(settings: LoopSettings, settingsPath = loopSett
172
196
  {
173
197
  ...raw,
174
198
  maxIterations: normalized.maxIterations,
199
+ automaticTurns: normalized.automaticTurns,
200
+ noProgressTurns: normalized.noProgressTurns,
175
201
  maxLoopDuration: normalized.maxLoopDuration,
176
202
  compaction: { ...compaction, ...normalized.compaction },
177
203
  },
package/src/state.ts CHANGED
@@ -11,6 +11,8 @@ export const LOOP_STATE_ENTRY_TYPE = "loop-state";
11
11
  export const GOAL_STATE_ENTRY_TYPE = "goal-state";
12
12
  export const PLAN_MODE_STATE_ENTRY_TYPE = "plan-mode-state";
13
13
 
14
+ import { type LoopWait, normalizeLoopWait } from "./wait.js";
15
+
14
16
  export const LOOP_STATUSES = ["active", "paused", "stopped"] as const;
15
17
  export type LoopStatus = (typeof LOOP_STATUSES)[number];
16
18
 
@@ -31,11 +33,37 @@ export interface LoopState {
31
33
  maxIterations: number | null;
32
34
  /** Proactive-compaction threshold fraction, or null when disabled per loop. */
33
35
  compactAt: number | null;
34
- /** Delivered pokes so far. */
36
+ /**
37
+ * Turns this loop caused; null means unlimited. Counted separately from
38
+ * `maxIterations` because one wake now yields many turns: a settle-driven
39
+ * continuation chain runs without any wake at all.
40
+ */
41
+ maxAutomaticTurns: number | null;
42
+ /** Delivered wakes so far (fallback pokes only). */
35
43
  iteration: number;
44
+ /** Loop-caused turns so far (continuations + pokes). */
45
+ automaticTurns: number;
36
46
  startedAt: number;
37
47
  expiresAt: number;
38
48
  lastWakeAt?: number;
49
+ /** Set while the model has declared an external wait through `loop_wait`. */
50
+ waiting?: LoopWait;
51
+ /**
52
+ * The reason of a wait cancelled by something other than its own deadline,
53
+ * surfaced once in the next loop message so the context is not simply lost.
54
+ */
55
+ cancelledWaitReason?: string;
56
+ /** Consecutive tool-free loop turns with identical visible output. */
57
+ toolFreeRepeatCount?: number;
58
+ lastFingerprint?: string;
59
+ /** Why a paused loop paused, for the widget and status after a restore. */
60
+ pauseCause?: string;
61
+ /**
62
+ * Set once the expiry's final wake has been delivered. The loop is still
63
+ * active for exactly that one turn, so the objective append is present
64
+ * while it writes its state down; the next settle stops it.
65
+ */
66
+ expiring?: true;
39
67
  }
40
68
 
41
69
  const MAX_PROMPT_LENGTH = 100_000;
@@ -71,12 +99,47 @@ export function normalizeLoopState(value: unknown): LoopState | undefined {
71
99
  ) {
72
100
  return undefined;
73
101
  }
102
+ const maxAutomaticTurns = Object.hasOwn(record, "maxAutomaticTurns")
103
+ ? record.maxAutomaticTurns
104
+ : null;
105
+ if (maxAutomaticTurns !== null && !isPositiveSafeInteger(maxAutomaticTurns)) return undefined;
74
106
  const iteration = record.iteration;
75
107
  if (typeof iteration !== "number" || !Number.isSafeInteger(iteration) || iteration < 0) {
76
108
  return undefined;
77
109
  }
110
+ // Loops persisted before the two-counter split carry no automaticTurns; an
111
+ // absent counter restores as zero rather than rejecting the whole state.
112
+ const automaticTurns = Object.hasOwn(record, "automaticTurns") ? record.automaticTurns : 0;
113
+ if (
114
+ typeof automaticTurns !== "number" ||
115
+ !Number.isSafeInteger(automaticTurns) ||
116
+ automaticTurns < 0
117
+ ) {
118
+ return undefined;
119
+ }
78
120
  if (!isTimestamp(record.startedAt) || !isTimestamp(record.expiresAt)) return undefined;
79
121
  if (record.lastWakeAt !== undefined && !isTimestamp(record.lastWakeAt)) return undefined;
122
+ let waiting: LoopWait | undefined;
123
+ if (record.waiting !== undefined) {
124
+ waiting = normalizeLoopWait(record.waiting);
125
+ if (!waiting) return undefined;
126
+ }
127
+ const cancelledWaitReason = optionalText(record.cancelledWaitReason);
128
+ if (cancelledWaitReason === false) return undefined;
129
+ const pauseCause = optionalText(record.pauseCause);
130
+ if (pauseCause === false) return undefined;
131
+ const toolFreeRepeatCount = record.toolFreeRepeatCount;
132
+ if (
133
+ toolFreeRepeatCount !== undefined &&
134
+ (typeof toolFreeRepeatCount !== "number" ||
135
+ !Number.isSafeInteger(toolFreeRepeatCount) ||
136
+ toolFreeRepeatCount < 0)
137
+ ) {
138
+ return undefined;
139
+ }
140
+ const lastFingerprint = optionalText(record.lastFingerprint);
141
+ if (lastFingerprint === false) return undefined;
142
+ if (record.expiring !== undefined && record.expiring !== true) return undefined;
80
143
  return {
81
144
  id,
82
145
  status: status as LoopStatus,
@@ -84,11 +147,19 @@ export function normalizeLoopState(value: unknown): LoopState | undefined {
84
147
  ...(objective === undefined ? {} : { objective }),
85
148
  intervalMs,
86
149
  maxIterations: maxIterations as number | null,
150
+ maxAutomaticTurns: maxAutomaticTurns as number | null,
87
151
  compactAt: compactAt as number | null,
88
152
  iteration,
153
+ automaticTurns,
89
154
  startedAt: record.startedAt as number,
90
155
  expiresAt: record.expiresAt as number,
91
156
  ...(record.lastWakeAt === undefined ? {} : { lastWakeAt: record.lastWakeAt as number }),
157
+ ...(waiting === undefined ? {} : { waiting }),
158
+ ...(cancelledWaitReason === undefined ? {} : { cancelledWaitReason }),
159
+ ...(toolFreeRepeatCount === undefined ? {} : { toolFreeRepeatCount }),
160
+ ...(lastFingerprint === undefined ? {} : { lastFingerprint }),
161
+ ...(pauseCause === undefined ? {} : { pauseCause }),
162
+ ...(record.expiring === true ? { expiring: true as const } : {}),
92
163
  };
93
164
  }
94
165
 
@@ -205,6 +276,14 @@ function ownRecord(value: unknown): Record<string, unknown> | undefined {
205
276
  : undefined;
206
277
  }
207
278
 
279
+ /** A present-but-optional string: the value, undefined when absent, false when invalid. */
280
+ function optionalText(value: unknown): string | undefined | false {
281
+ if (value === undefined) return undefined;
282
+ if (typeof value !== "string") return false;
283
+ const trimmed = value.trim();
284
+ return trimmed && trimmed.length <= MAX_PROMPT_LENGTH ? trimmed : false;
285
+ }
286
+
208
287
  function isPositiveSafeInteger(value: unknown): value is number {
209
288
  return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
210
289
  }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * `loop_wait`: the loop's adaptive wake.
3
+ *
4
+ * Without it a loop has exactly one answer to "progress depends on something
5
+ * outside this session": keep continuing, and burn turns re-checking. With
6
+ * it, the model says what it is waiting for and roughly how long, the loop
7
+ * stops continuing on its own, and the wait's deadline becomes the next thing
8
+ * that speaks.
9
+ *
10
+ * Registered unconditionally, like `loop_complete`, and for the same reason:
11
+ * tools are part of the cached request prefix, so adding or removing one
12
+ * mid-session invalidates the whole conversation cache. It refuses when no
13
+ * standalone loop is active.
14
+ */
15
+
16
+ import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
17
+ import { Type } from "typebox";
18
+ import type { LoopController } from "./loop.js";
19
+ import { formatDuration } from "./interval.js";
20
+ import { MAX_WAIT_DELAY_MS, MAX_WAIT_REASON_LENGTH, MIN_WAIT_DELAY_MS } from "./wait.js";
21
+
22
+ export const LOOP_WAIT_TOOL = "loop_wait";
23
+
24
+ export function registerLoopWaitTool(pi: ExtensionAPI, controller: LoopController) {
25
+ pi.registerTool(
26
+ defineTool({
27
+ name: LOOP_WAIT_TOOL,
28
+ label: "Loop Wait",
29
+ description:
30
+ "Pause the active /loop's automatic continuations while progress depends on an external event. The loop stays active; it simply stops continuing on its own until resume_after_ms elapses or something else wakes the session. Never use it for ordinary unfinished work.",
31
+ promptSnippet: "Wait for an external event without ending the active /loop",
32
+ promptGuidelines: [
33
+ "Call loop_wait only when progress genuinely depends on a later external event (a CI run, a deploy, a human reply), never for ordinary unfinished work and never as a way to end a turn early.",
34
+ "Give a one-sentence reason: it is shown in the loop widget and status, and it is the only record of what the loop was waiting for.",
35
+ "Never poll for work Pi already notifies you about — background processes, subagents, and tool completions wake the session on their own.",
36
+ // The cache-window guidance: a wake just after the provider's
37
+ // prompt-cache TTL pays a full cache miss for nothing.
38
+ "Avoid resume_after_ms near 300000 (5 minutes): that is the prompt-cache dead zone, where the cache has just expired and the next turn re-reads the whole conversation at full price.",
39
+ "Use a value at or below 270000 only when actively polling external state that nothing else will report. Otherwise commit to 1200000 or more.",
40
+ `resume_after_ms is clamped to [${MIN_WAIT_DELAY_MS}, ${MAX_WAIT_DELAY_MS}]; the clamped value is echoed back. Omitting it waits until something else wakes the session.`,
41
+ "Call loop_wait alone: sibling tool calls in the same turn can prevent the turn from ending, which is the point of the wait.",
42
+ ],
43
+ parameters: Type.Object({
44
+ reason: Type.String({
45
+ minLength: 1,
46
+ maxLength: MAX_WAIT_REASON_LENGTH,
47
+ description:
48
+ "One sentence naming the external event being waited for. Shown in the loop widget and status.",
49
+ }),
50
+ resume_after_ms: Type.Optional(
51
+ Type.Number({
52
+ description:
53
+ "Optional bounded safety wake-up in milliseconds, clamped to [60000, 3600000]. Omit to wait until something else wakes the session.",
54
+ }),
55
+ ),
56
+ }),
57
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
58
+ const loop = controller.state;
59
+ if (!loop || loop.objective === undefined) {
60
+ return {
61
+ content: toolContent(
62
+ "No standalone /loop is active. loop_wait only applies to a loop that carries its own objective; a goal-bound loop waits through pi-goal.",
63
+ ),
64
+ details: {},
65
+ isError: true,
66
+ };
67
+ }
68
+ if (loop.status !== "active") {
69
+ return {
70
+ content: toolContent(`The /loop is ${loop.status}; there is nothing to wait on.`),
71
+ details: { loopId: loop.id, status: loop.status },
72
+ isError: true,
73
+ };
74
+ }
75
+ const reason = params.reason.trim();
76
+ if (!reason) {
77
+ return {
78
+ content: toolContent("loop_wait needs a reason: name the external event in one sentence."),
79
+ details: { loopId: loop.id },
80
+ isError: true,
81
+ };
82
+ }
83
+ const wait = controller.enterWait(reason, params.resume_after_ms);
84
+ if (!wait) {
85
+ return {
86
+ content: toolContent("The /loop could not enter a wait; it is no longer active."),
87
+ details: { loopId: loop.id },
88
+ isError: true,
89
+ };
90
+ }
91
+ const deadline =
92
+ wait.effectiveMs === undefined
93
+ ? "No deadline: the loop stays quiet until something else wakes the session."
94
+ : `${wait.clamped ? `Requested ${Math.round((wait.requestedMs ?? 0) / 1000)}s, clamped to ` : "Waking in "}${formatDuration(wait.effectiveMs)}.`;
95
+ return {
96
+ content: toolContent(
97
+ `Loop waiting: ${reason}\n${deadline} Automatic continuations are held; the loop stays active.`,
98
+ ),
99
+ details: {
100
+ loopId: loop.id,
101
+ reason,
102
+ ...(wait.requestedMs === undefined ? {} : { requestedMs: wait.requestedMs }),
103
+ ...(wait.effectiveMs === undefined ? {} : { effectiveMs: wait.effectiveMs }),
104
+ clamped: wait.clamped,
105
+ },
106
+ };
107
+ },
108
+ }),
109
+ );
110
+ }
111
+
112
+ function toolContent(text: string) {
113
+ return [{ type: "text" as const, text }];
114
+ }
package/src/wait.ts ADDED
@@ -0,0 +1,95 @@
1
+ /**
2
+ * `loop_wait` state: the model's declaration that progress now depends on
3
+ * something outside the session.
4
+ *
5
+ * A wait does not stop the loop and does not cancel the pacemaker. It
6
+ * supersedes the *next* fallback wake — the loop stops continuing on its own
7
+ * and the wait's own deadline becomes the next thing that speaks. That is the
8
+ * whole difference between "waiting" and "paused": a paused loop needs the
9
+ * user, a waiting loop needs the world.
10
+ *
11
+ * The clamp is a range, not a floor. Below 60s a wait is polling, which is
12
+ * what the tool exists to replace; above an hour it stops being a wait and
13
+ * becomes an abandonment, and the fallback heartbeat covers that case better.
14
+ */
15
+
16
+ export interface LoopWait {
17
+ reason: string;
18
+ resumeAt?: number;
19
+ }
20
+
21
+ export const MAX_WAIT_REASON_LENGTH = 1_000;
22
+ export const MIN_WAIT_DELAY_MS = 60_000;
23
+ export const MAX_WAIT_DELAY_MS = 3_600_000;
24
+ const MAX_TIMESTAMP = 8_640_000_000_000_000;
25
+
26
+ export interface ResolvedWaitDelay {
27
+ requestedMs?: number;
28
+ effectiveMs?: number;
29
+ clamped: boolean;
30
+ }
31
+
32
+ export function resolveWaitDelay(resumeAfterMs: number | undefined): ResolvedWaitDelay {
33
+ if (resumeAfterMs === undefined || !Number.isFinite(resumeAfterMs)) return { clamped: false };
34
+ const effectiveMs = Math.min(MAX_WAIT_DELAY_MS, Math.max(MIN_WAIT_DELAY_MS, resumeAfterMs));
35
+ return { requestedMs: resumeAfterMs, effectiveMs, clamped: effectiveMs !== resumeAfterMs };
36
+ }
37
+
38
+ export function createLoopWait(
39
+ reason: string,
40
+ resumeAfterMs: number | undefined,
41
+ now: number,
42
+ ): LoopWait {
43
+ const { effectiveMs } = resolveWaitDelay(resumeAfterMs);
44
+ return {
45
+ reason,
46
+ ...(effectiveMs === undefined ? {} : { resumeAt: now + effectiveMs }),
47
+ };
48
+ }
49
+
50
+ export function normalizeLoopWait(value: unknown): LoopWait | undefined {
51
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
52
+ const record = value as Record<string, unknown>;
53
+ const reason = typeof record.reason === "string" ? record.reason.trim() : "";
54
+ if (!reason || reason.length > MAX_WAIT_REASON_LENGTH) return undefined;
55
+ if (!Object.hasOwn(record, "resumeAt")) return { reason };
56
+ const resumeAt = record.resumeAt;
57
+ if (
58
+ typeof resumeAt !== "number" ||
59
+ !Number.isSafeInteger(resumeAt) ||
60
+ resumeAt < 0 ||
61
+ resumeAt > MAX_TIMESTAMP
62
+ ) {
63
+ return undefined;
64
+ }
65
+ return { reason, resumeAt };
66
+ }
67
+
68
+ /**
69
+ * A single-slot timer whose callbacks are generation-guarded, so a wait that
70
+ * was cleared or replaced can never fire against the loop that replaced it.
71
+ */
72
+ export class LoopWaitTimer {
73
+ private generation = 0;
74
+ private timer: NodeJS.Timeout | undefined;
75
+
76
+ clear(): void {
77
+ this.generation += 1;
78
+ if (!this.timer) return;
79
+ clearTimeout(this.timer);
80
+ this.timer = undefined;
81
+ }
82
+
83
+ schedule(resumeAt: number, onDue: () => void, now: number = Date.now()): void {
84
+ this.clear();
85
+ const generation = this.generation;
86
+ const delay = Math.max(0, Math.min(MAX_WAIT_DELAY_MS, resumeAt - now));
87
+ this.timer = setTimeout(() => {
88
+ if (generation !== this.generation) return;
89
+ this.timer = undefined;
90
+ onDue();
91
+ }, delay);
92
+ // A pending wake must never hold the process open.
93
+ this.timer.unref?.();
94
+ }
95
+ }