@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.
- package/CHANGELOG.md +21 -0
- package/README.md +155 -19
- package/package.json +4 -2
- package/src/ack.ts +67 -0
- package/src/command.ts +92 -33
- package/src/complete-tool.ts +120 -13
- package/src/decide.ts +111 -22
- package/src/errors.ts +131 -0
- package/src/index.ts +95 -2
- package/src/ledger.ts +230 -0
- package/src/loop.ts +842 -40
- package/src/manager.ts +55 -29
- package/src/markers.ts +24 -3
- package/src/messages.ts +164 -8
- package/src/objective.ts +29 -2
- package/src/render.ts +25 -2
- package/src/safety.ts +98 -0
- package/src/schedule/command.ts +255 -0
- package/src/schedule/cron.ts +182 -0
- package/src/schedule/manager.ts +129 -0
- package/src/schedule/model.ts +237 -0
- package/src/schedule/runner.ts +351 -0
- package/src/schedule/store.ts +183 -0
- package/src/settings.ts +27 -1
- package/src/state.ts +80 -1
- package/src/wait-tool.ts +114 -0
- package/src/wait.ts +95 -0
|
@@ -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-
|
|
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
|
-
/**
|
|
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
|
}
|
package/src/wait-tool.ts
ADDED
|
@@ -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
|
+
}
|