@mjasnikovs/pi-task 0.38.11 → 0.38.13
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/README.md +8 -5
- package/dist/config/config.d.ts +0 -1
- package/dist/config/config.js +0 -1
- package/dist/config/register.js +0 -2
- package/dist/index.js +0 -2
- package/dist/shared/child-process.d.ts +8 -0
- package/dist/shared/command-watchdog.d.ts +1 -1
- package/dist/shared/command-watchdog.js +1 -1
- package/dist/task/accept-debt.d.ts +47 -0
- package/dist/task/accept-debt.js +127 -28
- package/dist/task/auto-orchestrator.js +91 -114
- package/dist/task/child-runner.d.ts +39 -25
- package/dist/task/child-runner.js +59 -31
- package/dist/task/child-status.d.ts +95 -0
- package/dist/task/child-status.js +99 -0
- package/dist/task/command-run.d.ts +36 -0
- package/dist/task/command-run.js +48 -1
- package/dist/task/command-watchdog.js +1 -1
- package/dist/task/context-usage.d.ts +4 -3
- package/dist/task/context-usage.js +4 -3
- package/dist/task/contracts.js +18 -35
- package/dist/task/deep-render-check.d.ts +47 -0
- package/dist/task/deep-render-check.js +110 -65
- package/dist/task/env-notes.d.ts +3 -3
- package/dist/task/env-notes.js +24 -35
- package/dist/task/final-gate-fix.d.ts +1 -1
- package/dist/task/final-gate-fix.js +1 -1
- package/dist/task/final-gate.d.ts +5 -151
- package/dist/task/final-gate.js +81 -379
- package/dist/task/gate-child.d.ts +8 -10
- package/dist/task/gate-child.js +15 -19
- package/dist/task/gate-deps.d.ts +29 -0
- package/dist/task/gate-deps.js +192 -206
- package/dist/task/gate-tally.d.ts +189 -0
- package/dist/task/gate-tally.js +249 -0
- package/dist/task/implementation-turn.d.ts +201 -0
- package/dist/task/implementation-turn.js +263 -0
- package/dist/task/launch-contract.js +27 -43
- package/dist/task/ledger.d.ts +38 -0
- package/dist/task/ledger.js +83 -0
- package/dist/task/loop-detector.d.ts +14 -8
- package/dist/task/loop-detector.js +36 -12
- package/dist/task/orchestrator.d.ts +61 -126
- package/dist/task/orchestrator.js +67 -294
- package/dist/task/plan-orchestrator.js +34 -33
- package/dist/task/requirements.d.ts +1 -1
- package/dist/task/requirements.js +50 -66
- package/dist/task/root-cause-repair.js +20 -32
- package/dist/task/run-bracket.d.ts +75 -0
- package/dist/task/run-bracket.js +41 -0
- package/dist/task/stall-detector.d.ts +110 -0
- package/dist/task/stall-detector.js +159 -0
- package/dist/task/verify-work.d.ts +53 -67
- package/dist/task/verify-work.js +15 -11
- package/dist/workers/single-read-extension.d.ts +1 -1
- package/dist/workers/single-read-extension.js +5 -4
- package/dist/workers/single-read-guard.d.ts +32 -10
- package/dist/workers/single-read-guard.js +67 -16
- package/package.json +1 -1
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Implementation-turn supervision — what happens between "the spec was delivered
|
|
3
|
+
* to the main session" and "we know how the implementation turn REALLY ended".
|
|
4
|
+
*
|
|
5
|
+
* A single `waitForIdle` resolves for four different reasons, and three of them
|
|
6
|
+
* are not "the model finished":
|
|
7
|
+
* • `aborted` — a user ESC (or the command watchdog) cut the turn short;
|
|
8
|
+
* • `compaction` — a threshold auto-compaction parked the turn at idle without
|
|
9
|
+
* auto-continuing (the runtime expects a manual continue);
|
|
10
|
+
* • `error` — the model/provider died mid-turn after pi's own retries;
|
|
11
|
+
* • `stop` — genuine completion.
|
|
12
|
+
* `classifyTurnEnd` reads the session entries and names ONE of those, in the
|
|
13
|
+
* precedence the supervision sequence needs; `superviseImplementation` then
|
|
14
|
+
* resumes across compactions, lets the user steer after an interrupt, and reports
|
|
15
|
+
* the terminal outcome. The orchestrator calls it once.
|
|
16
|
+
*/
|
|
17
|
+
import { SessionUI } from '../remote/bridge.js';
|
|
18
|
+
import { consumeWatchdogAbort, WATCHDOG_CANCEL_MARKER } from './command-watchdog.js';
|
|
19
|
+
const isAssistant = (e) => e.message !== undefined && e.message.role === 'assistant';
|
|
20
|
+
/** Index of the last assistant message and of the last compaction boundary. */
|
|
21
|
+
function tailPositions(entries) {
|
|
22
|
+
let lastAssistant = -1;
|
|
23
|
+
let lastCompaction = -1;
|
|
24
|
+
for (let i = 0; i < entries.length; i++) {
|
|
25
|
+
const e = entries[i];
|
|
26
|
+
if (isAssistant(e))
|
|
27
|
+
lastAssistant = i;
|
|
28
|
+
else if (e.type === 'compaction')
|
|
29
|
+
lastCompaction = i;
|
|
30
|
+
}
|
|
31
|
+
return { lastAssistant, lastCompaction };
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Classify how the most recent turn ended, from the session entries alone.
|
|
35
|
+
*
|
|
36
|
+
* Precedence, when several signals are present at once (this is the order the
|
|
37
|
+
* supervision sequence has always applied, now stated in one place):
|
|
38
|
+
* 1. `aborted` — the last assistant message has stopReason "aborted". A user
|
|
39
|
+
* ESC (or watchdog abort) wins over everything: it is not a
|
|
40
|
+
* compaction pause, and the steer loop owns it.
|
|
41
|
+
* 2. `compaction` — a `compaction` entry sits AFTER the last assistant message.
|
|
42
|
+
* Position-based, not timestamp-based: the runtime appends the
|
|
43
|
+
* boundary to the tail of the branch after the message that
|
|
44
|
+
* triggered it (`appendCompaction` → `_appendEntry` push), so a
|
|
45
|
+
* trailing compaction means we are parked with no continuation.
|
|
46
|
+
* A finished turn ends on an assistant message; an *overflow*
|
|
47
|
+
* compaction self-retries and never leaves us idle here.
|
|
48
|
+
* 3. `error` — the last assistant message has stopReason "error": the
|
|
49
|
+
* model/provider died (context-overflow 400, disconnect, 5xx)
|
|
50
|
+
* after pi exhausted its own retries.
|
|
51
|
+
* 4. `stop` — anything else, including a session with no assistant turn.
|
|
52
|
+
*/
|
|
53
|
+
export function classifyTurnEnd(entries) {
|
|
54
|
+
const { lastAssistant, lastCompaction } = tailPositions(entries);
|
|
55
|
+
const last = lastAssistant >= 0 ? entries[lastAssistant].message : undefined;
|
|
56
|
+
if (last?.stopReason === 'aborted')
|
|
57
|
+
return 'aborted';
|
|
58
|
+
if (lastCompaction > lastAssistant)
|
|
59
|
+
return 'compaction';
|
|
60
|
+
if (last?.stopReason === 'error')
|
|
61
|
+
return 'error';
|
|
62
|
+
return 'stop';
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The provider's error cause for an `error` turn end — the message the run's
|
|
66
|
+
* "stopped at …" line quotes so the real cause is not lost. Undefined unless the
|
|
67
|
+
* last assistant message ended with stopReason "error".
|
|
68
|
+
*/
|
|
69
|
+
export function turnErrorMessage(entries) {
|
|
70
|
+
const { lastAssistant } = tailPositions(entries);
|
|
71
|
+
const last = lastAssistant >= 0 ? entries[lastAssistant].message : undefined;
|
|
72
|
+
if (last?.stopReason !== 'error')
|
|
73
|
+
return undefined;
|
|
74
|
+
return last.errorMessage ?? 'model error';
|
|
75
|
+
}
|
|
76
|
+
/** True when the last assistant turn was aborted (ESC / watchdog). */
|
|
77
|
+
const wasInterrupted = (entries) => classifyTurnEnd(entries) === 'aborted';
|
|
78
|
+
/**
|
|
79
|
+
* True when the watchdog's reminder follow-up has been DELIVERED into the session
|
|
80
|
+
* after the aborted assistant turn but its own turn has not finished yet — the
|
|
81
|
+
* artifact that confirms a pending watchdog recovery. Scoped after the LAST
|
|
82
|
+
* assistant entry so an earlier fire's reminder (already answered by its own
|
|
83
|
+
* turn) never matches.
|
|
84
|
+
*/
|
|
85
|
+
export function watchdogReminderDelivered(entries) {
|
|
86
|
+
const { lastAssistant } = tailPositions(entries);
|
|
87
|
+
for (let i = lastAssistant + 1; i < entries.length; i++) {
|
|
88
|
+
const m = entries[i].message;
|
|
89
|
+
if (m === undefined || m.role !== 'user')
|
|
90
|
+
continue;
|
|
91
|
+
const content = m.content;
|
|
92
|
+
const text = typeof content === 'string' ? content
|
|
93
|
+
: Array.isArray(content) ?
|
|
94
|
+
content
|
|
95
|
+
.map(b => b !== null && typeof b === 'object' && 'text' in b ?
|
|
96
|
+
String(b.text)
|
|
97
|
+
: '')
|
|
98
|
+
.join(' ')
|
|
99
|
+
: '';
|
|
100
|
+
if (text.includes(WATCHDOG_CANCEL_MARKER))
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
const STEER_WATCHDOG_DEFAULTS = {
|
|
106
|
+
consume: consumeWatchdogAbort,
|
|
107
|
+
graceMs: 10_000,
|
|
108
|
+
pollMs: 100
|
|
109
|
+
};
|
|
110
|
+
/** Dialog copy for the post-interrupt steering prompt. */
|
|
111
|
+
const STEER_TITLE = 'Paused — steer the model';
|
|
112
|
+
const STEER_PLACEHOLDER = 'Type guidance to continue this task, or leave empty to pause';
|
|
113
|
+
/** Remote-card copy for the same prompt. The browser has no placeholder ghost
|
|
114
|
+
* text, so the pause affordance must be spelled out in the question itself
|
|
115
|
+
* (Skip = empty answer = pause, same as an empty local submit). */
|
|
116
|
+
const STEER_QUESTION = 'Paused — the implementation was interrupted.\n'
|
|
117
|
+
+ 'Type guidance to continue this task, or Skip to pause the run.';
|
|
118
|
+
/** Bind the supervision deps to a live session context. */
|
|
119
|
+
export function turnDepsFor(ctx, opts = {}) {
|
|
120
|
+
// Fan the prompt out through the bridge (local TUI input + remote browser
|
|
121
|
+
// card, first answer wins) instead of a raw ctx.ui.input: an interrupt can
|
|
122
|
+
// come from the remote Stop button just as well as a terminal ESC, and a
|
|
123
|
+
// terminal-only dialog leaves the remote viewer staring at a silently
|
|
124
|
+
// paused run. Remote Skip returns '' → same pause path as an empty local
|
|
125
|
+
// submit.
|
|
126
|
+
const ask = opts.promptSteer
|
|
127
|
+
?? (c => new SessionUI(c).ask({
|
|
128
|
+
localTitle: STEER_TITLE,
|
|
129
|
+
localPlaceholder: STEER_PLACEHOLDER,
|
|
130
|
+
question: STEER_QUESTION,
|
|
131
|
+
allowSkip: true
|
|
132
|
+
}));
|
|
133
|
+
return {
|
|
134
|
+
entries: () => ctx.sessionManager.getEntries(),
|
|
135
|
+
send: text => ctx.sendUserMessage(text, { deliverAs: 'followUp' }),
|
|
136
|
+
waitForIdle: () => ctx.waitForIdle(),
|
|
137
|
+
ask: () => ask(ctx),
|
|
138
|
+
watchdog: { ...STEER_WATCHDOG_DEFAULTS, ...opts.watchdog },
|
|
139
|
+
log: opts.log
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
// ─── Resume across compactions ───────────────────────────────────────────────
|
|
143
|
+
/**
|
|
144
|
+
* Nudge that resumes an implementation turn the runtime parked at a compaction
|
|
145
|
+
* boundary. It must let a turn that was genuinely finished (then tipped over the
|
|
146
|
+
* threshold by its own final message) confirm completion without inventing busywork
|
|
147
|
+
* — we cannot tell "paused mid-task by compaction" from "finished, then compacted"
|
|
148
|
+
* from the boundary alone, so the wording lets a done turn end in one line.
|
|
149
|
+
*/
|
|
150
|
+
export const CONTINUE_AFTER_COMPACTION = 'Your context was automatically compacted. Continue implementing this task from '
|
|
151
|
+
+ 'exactly where you left off, and keep going until it is fully done. If the '
|
|
152
|
+
+ 'implementation is already complete, say so in one line and stop — do not invent '
|
|
153
|
+
+ 'extra work or restart the task.';
|
|
154
|
+
/**
|
|
155
|
+
* Safety cap on compaction-driven resumes for a single implementation turn. Each
|
|
156
|
+
* resume follows a real compaction (which only fires after the model produced a
|
|
157
|
+
* turn large enough to cross the threshold), so a legitimately large task may
|
|
158
|
+
* resume a handful of times; the cap exists only to stop a pathological loop from
|
|
159
|
+
* auto-sending forever with no user in the loop. Hitting it stops resuming and lets
|
|
160
|
+
* the verify gate / `/task-auto-resume` catch any leftover incompleteness.
|
|
161
|
+
*/
|
|
162
|
+
export const MAX_COMPACTION_RESUMES = 20;
|
|
163
|
+
/**
|
|
164
|
+
* Resume an implementation turn that went idle at a threshold-compaction boundary.
|
|
165
|
+
* The runtime compacts and parks at idle without auto-continuing; we send a
|
|
166
|
+
* continue and wait again, repeating across successive compactions until the turn
|
|
167
|
+
* ends on a real assistant message (genuine completion). A user ESC takes priority
|
|
168
|
+
* (`classifyTurnEnd` ranks `aborted` above `compaction`, so the steer loop handles
|
|
169
|
+
* it), and the safety cap bounds a runaway. Returns the number of resumes
|
|
170
|
+
* performed (0 when the turn did not end on a compaction).
|
|
171
|
+
*/
|
|
172
|
+
export async function resumeAcrossCompactions(deps) {
|
|
173
|
+
let resumes = 0;
|
|
174
|
+
while (resumes < MAX_COMPACTION_RESUMES && classifyTurnEnd(deps.entries()) === 'compaction') {
|
|
175
|
+
deps.log?.(`implementation: parked at a compaction boundary — resume ${resumes + 1}`);
|
|
176
|
+
await deps.send(CONTINUE_AFTER_COMPACTION);
|
|
177
|
+
await deps.waitForIdle();
|
|
178
|
+
resumes++;
|
|
179
|
+
}
|
|
180
|
+
return resumes;
|
|
181
|
+
}
|
|
182
|
+
// ─── Steer until done ────────────────────────────────────────────────────────
|
|
183
|
+
/**
|
|
184
|
+
* Wait for a watchdog abort's queued follow-up turn instead of prompting. The
|
|
185
|
+
* abort and the reminder follow-up are two separate steps in the watchdog's
|
|
186
|
+
* onFire, so the steer loop can observe the aborted turn before the reminder is
|
|
187
|
+
* delivered — poll (bounded) until it lands or the follow-up turn has already
|
|
188
|
+
* completed. True = recovery observed, re-check the loop; false = grace expired
|
|
189
|
+
* with no reminder (stale flag) — fall back to the human prompt.
|
|
190
|
+
*/
|
|
191
|
+
async function awaitWatchdogFollowUp(deps) {
|
|
192
|
+
const wd = deps.watchdog;
|
|
193
|
+
const deadline = Date.now() + wd.graceMs;
|
|
194
|
+
for (;;) {
|
|
195
|
+
if (!wasInterrupted(deps.entries()))
|
|
196
|
+
return true; // follow-up turn already completed
|
|
197
|
+
if (watchdogReminderDelivered(deps.entries())) {
|
|
198
|
+
await deps.waitForIdle(); // let the follow-up turn run to completion
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
if (Date.now() >= deadline)
|
|
202
|
+
return false;
|
|
203
|
+
await new Promise(r => setTimeout(r, wd.pollMs));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* After the implementation turn settles, honour a user ESC by letting them steer.
|
|
208
|
+
*
|
|
209
|
+
* `waitForIdle` resolves both on natural completion AND on an ESC (which aborts
|
|
210
|
+
* the turn → idle). When the last turn was aborted, the host's main input loop is
|
|
211
|
+
* blocked inside our command handler, so a message typed in the editor would only
|
|
212
|
+
* queue, never run (interactive-mode routes idle input through onInputCallback,
|
|
213
|
+
* which is unset while we hold the loop). We therefore solicit the steering text
|
|
214
|
+
* ourselves and feed it back as another turn via sendUserMessage — which runs to
|
|
215
|
+
* completion when the session is idle. Repeat until a turn finishes uninterrupted.
|
|
216
|
+
*
|
|
217
|
+
* A WATCHDOG abort also ends the turn with stopReason 'aborted' — indistinguishable
|
|
218
|
+
* from a human ESC by the session entries alone at that instant. The watchdog
|
|
219
|
+
* queues its own recovery follow-up, so prompting there would show a steering
|
|
220
|
+
* dialog to an empty room and wedge an unattended run on the race. The one-shot
|
|
221
|
+
* flag (set synchronously before the abort) routes that case to
|
|
222
|
+
* {@link awaitWatchdogFollowUp} instead; a stale flag degrades to a bounded wait
|
|
223
|
+
* followed by the ordinary prompt, never to a suppressed one.
|
|
224
|
+
*
|
|
225
|
+
* Returns true when the user declined to steer (empty/cancelled) and the run
|
|
226
|
+
* should pause; false when the implementation completed (steered or not).
|
|
227
|
+
*/
|
|
228
|
+
export async function steerUntilDone(deps) {
|
|
229
|
+
while (wasInterrupted(deps.entries())) {
|
|
230
|
+
if (deps.watchdog.consume() && (await awaitWatchdogFollowUp(deps))) {
|
|
231
|
+
deps.log?.('implementation: watchdog abort recovered by its own follow-up');
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
const steer = await deps.ask();
|
|
235
|
+
if (steer === undefined || steer.trim().length === 0) {
|
|
236
|
+
deps.log?.('implementation: interrupted, user declined to steer — pausing');
|
|
237
|
+
return true; // pause
|
|
238
|
+
}
|
|
239
|
+
deps.log?.('implementation: interrupted, steering with user text');
|
|
240
|
+
await deps.send(steer);
|
|
241
|
+
await deps.waitForIdle();
|
|
242
|
+
}
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Supervise an implementation turn from the first idle after spec delivery until
|
|
247
|
+
* its REAL end: resume across any compaction boundaries first (so steering and
|
|
248
|
+
* error inspection see the turn's actual end, not a compaction pause), then let
|
|
249
|
+
* the user steer across interrupts, then read how the final turn ended. The
|
|
250
|
+
* caller has already awaited the first idle.
|
|
251
|
+
*/
|
|
252
|
+
export async function superviseImplementation(ctx, opts = {}) {
|
|
253
|
+
return superviseWith(turnDepsFor(ctx, opts));
|
|
254
|
+
}
|
|
255
|
+
/** {@link superviseImplementation} over an already-bound deps object (tests). */
|
|
256
|
+
export async function superviseWith(deps) {
|
|
257
|
+
const resumes = await resumeAcrossCompactions(deps);
|
|
258
|
+
const interrupted = await steerUntilDone(deps);
|
|
259
|
+
// A user-declined steer (interrupted) is its own paused path; otherwise
|
|
260
|
+
// inspect how the turn actually ended.
|
|
261
|
+
const error = interrupted ? undefined : turnErrorMessage(deps.entries());
|
|
262
|
+
return { interrupted, error, resumes };
|
|
263
|
+
}
|
|
@@ -23,16 +23,36 @@
|
|
|
23
23
|
* naming any declared script the manifest is missing. FP-safe by construction: an
|
|
24
24
|
* empty/ungrounded list (a design that never backticks a script name) yields no check.
|
|
25
25
|
*/
|
|
26
|
-
import
|
|
27
|
-
import * as path from 'node:path';
|
|
28
|
-
import { tasksDir } from './task-io.js';
|
|
26
|
+
import { makeLedger } from './ledger.js';
|
|
29
27
|
const LAUNCH_CONTRACT_FILE = 'launch-contract.md';
|
|
30
28
|
/** Cap kept entries so a noisy extraction cannot grow the artifact unboundedly. */
|
|
31
29
|
const MAX_SCRIPTS = 40;
|
|
32
30
|
/** npm/package script names are short kebab/colon tokens; reject anything unscript-like. */
|
|
33
31
|
const SCRIPT_NAME_RE = /^[a-z0-9][a-z0-9:_-]{0,39}$/i;
|
|
32
|
+
/** Stored one name per line; a line that is not a script name is skipped on read. */
|
|
33
|
+
const ledger = makeLedger({
|
|
34
|
+
file: LAUNCH_CONTRACT_FILE,
|
|
35
|
+
max: MAX_SCRIPTS,
|
|
36
|
+
key: n => n.toLowerCase(),
|
|
37
|
+
serialize: n => n,
|
|
38
|
+
parse: raw => {
|
|
39
|
+
const seen = new Set();
|
|
40
|
+
const out = [];
|
|
41
|
+
for (const line of raw.split('\n')) {
|
|
42
|
+
const n = line.trim();
|
|
43
|
+
if (n.length === 0 || !SCRIPT_NAME_RE.test(n))
|
|
44
|
+
continue;
|
|
45
|
+
const key = n.toLowerCase();
|
|
46
|
+
if (seen.has(key))
|
|
47
|
+
continue;
|
|
48
|
+
seen.add(key);
|
|
49
|
+
out.push(n);
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
});
|
|
34
54
|
export function launchContractFile(cwd) {
|
|
35
|
-
return path
|
|
55
|
+
return ledger.path(cwd);
|
|
36
56
|
}
|
|
37
57
|
/**
|
|
38
58
|
* Parse `SCRIPT: <name>` lines out of a child's answer into bare script names. A line
|
|
@@ -115,51 +135,15 @@ export function enumerateScriptCandidates(sourceDoc) {
|
|
|
115
135
|
}
|
|
116
136
|
/** The stored declared-script list ('' when none recorded). */
|
|
117
137
|
export async function readLaunchContractRaw(cwd) {
|
|
118
|
-
|
|
119
|
-
return (await fsp.readFile(launchContractFile(cwd), 'utf8')).trim();
|
|
120
|
-
}
|
|
121
|
-
catch {
|
|
122
|
-
return '';
|
|
123
|
-
}
|
|
138
|
+
return ledger.readRaw(cwd);
|
|
124
139
|
}
|
|
125
140
|
/** The declared script names recorded for this run (deduped, order preserved). */
|
|
126
141
|
export async function readDeclaredScripts(cwd) {
|
|
127
|
-
|
|
128
|
-
const seen = new Set();
|
|
129
|
-
const out = [];
|
|
130
|
-
for (const line of raw.split('\n')) {
|
|
131
|
-
const n = line.trim();
|
|
132
|
-
if (n.length === 0 || !SCRIPT_NAME_RE.test(n))
|
|
133
|
-
continue;
|
|
134
|
-
const key = n.toLowerCase();
|
|
135
|
-
if (seen.has(key))
|
|
136
|
-
continue;
|
|
137
|
-
seen.add(key);
|
|
138
|
-
out.push(n);
|
|
139
|
-
}
|
|
140
|
-
return out;
|
|
142
|
+
return ledger.read(cwd);
|
|
141
143
|
}
|
|
142
144
|
/** Append grounded script names, deduped against what is stored, keeping newest MAX. */
|
|
143
145
|
export async function appendDeclaredScripts(cwd, names) {
|
|
144
|
-
|
|
145
|
-
return;
|
|
146
|
-
try {
|
|
147
|
-
const existing = await readDeclaredScripts(cwd);
|
|
148
|
-
const seen = new Set(existing.map(n => n.toLowerCase()));
|
|
149
|
-
const merged = [...existing];
|
|
150
|
-
for (const n of names) {
|
|
151
|
-
if (seen.has(n.toLowerCase()))
|
|
152
|
-
continue;
|
|
153
|
-
seen.add(n.toLowerCase());
|
|
154
|
-
merged.push(n);
|
|
155
|
-
}
|
|
156
|
-
const kept = merged.slice(-MAX_SCRIPTS);
|
|
157
|
-
await fsp.mkdir(tasksDir(cwd), { recursive: true });
|
|
158
|
-
await fsp.writeFile(launchContractFile(cwd), kept.join('\n') + '\n', 'utf8');
|
|
159
|
-
}
|
|
160
|
-
catch {
|
|
161
|
-
// best-effort artifact
|
|
162
|
-
}
|
|
146
|
+
await ledger.append(cwd, names);
|
|
163
147
|
}
|
|
164
148
|
/**
|
|
165
149
|
* Boot-class script names: long-running serve/watch shapes the gate's BOOT check
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export interface LedgerSpec<T> {
|
|
2
|
+
/** File name under `.pi-tasks/` (e.g. `contracts.md`). */
|
|
3
|
+
file: string;
|
|
4
|
+
/**
|
|
5
|
+
* Keep the newest `max` records on append (oldest dropped). Absent = uncapped
|
|
6
|
+
* (a ledger that is only ever overwritten whole, like requirements-owned).
|
|
7
|
+
*/
|
|
8
|
+
max?: number;
|
|
9
|
+
/** Dedupe key — two records with equal keys are one record. */
|
|
10
|
+
key: (item: T) => string;
|
|
11
|
+
/** One record → one stored line (must not contain '\n'). */
|
|
12
|
+
serialize: (item: T) => string;
|
|
13
|
+
/** Stored text → records; skips what it cannot read rather than throwing. */
|
|
14
|
+
parse: (raw: string) => T[];
|
|
15
|
+
/**
|
|
16
|
+
* What `append` does when every incoming item was already stored:
|
|
17
|
+
* 'rewrite' (default) — write the merged list back anyway, which re-caps an
|
|
18
|
+
* over-long file and canonicalises lines the parser normalised or dropped;
|
|
19
|
+
* 'skip' — leave the file untouched (a single-record "no double-record"
|
|
20
|
+
* ledger: a duplicate is a return, not a write).
|
|
21
|
+
* The two are observably different when the file has drifted from what the
|
|
22
|
+
* writer produces, so it is an option, not a unification.
|
|
23
|
+
*/
|
|
24
|
+
onNoop?: 'rewrite' | 'skip';
|
|
25
|
+
}
|
|
26
|
+
export interface Ledger<T> {
|
|
27
|
+
/** Absolute path of the ledger file for this cwd. */
|
|
28
|
+
path(cwd: string): string;
|
|
29
|
+
/** Trimmed stored text; '' when absent or on any read error. */
|
|
30
|
+
readRaw(cwd: string): Promise<string>;
|
|
31
|
+
/** Parsed records; [] when absent or on any read error. */
|
|
32
|
+
read(cwd: string): Promise<T[]>;
|
|
33
|
+
/** Merge new records in (deduped, capped) and write back. Never throws. */
|
|
34
|
+
append(cwd: string, items: T[]): Promise<void>;
|
|
35
|
+
/** Overwrite with exactly these records. Never throws. */
|
|
36
|
+
write(cwd: string, items: T[]): Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
export declare function makeLedger<T>(spec: LedgerSpec<T>): Ledger<T>;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ledger — the ONE read-modify-write ritual behind every run-level line file under
|
|
3
|
+
* `.pi-tasks/` (contracts, launch-contract, env-notes, accept-debt, repair-queue,
|
|
4
|
+
* requirements, requirements-owned).
|
|
5
|
+
*
|
|
6
|
+
* Six modules each kept their own copy of the same seven steps: read the file
|
|
7
|
+
* (ANY error → ''), parse it into records, key the records, drop an incoming item
|
|
8
|
+
* whose key is already present, cap to the newest MAX (oldest dropped), mkdir the
|
|
9
|
+
* tasks dir, write the whole file back (`lines.join('\n') + '\n'`, plain
|
|
10
|
+
* `writeFile`, NOT atomic), and swallow every fault — a ledger is a sharpener or
|
|
11
|
+
* an auditing aid, never a blocker of the phase or gate that calls it. What
|
|
12
|
+
* genuinely varied per site is the DATA SHAPE (file name, cap, key, line format,
|
|
13
|
+
* parser) and exactly one RULE — what an append does when it adds nothing new
|
|
14
|
+
* (see `onNoop`). Everything else here is the ritual, so a module that keeps a
|
|
15
|
+
* ledger is an ADAPTER: it declares its ledger and calls read/append/write.
|
|
16
|
+
*
|
|
17
|
+
* Contract details a caller can rely on:
|
|
18
|
+
* • `readRaw` is the trimmed file text ('' when absent or unreadable). Prompt-block
|
|
19
|
+
* builders take this string.
|
|
20
|
+
* • `read` is `parse(readRaw)`; every parser skips blank lines and lines it cannot
|
|
21
|
+
* read, so a corrupt line is dropped, never thrown on.
|
|
22
|
+
* • `append` with an empty batch is a no-op (no read, no write). Within a batch
|
|
23
|
+
* the first item with a key wins; a key already stored wins over the batch.
|
|
24
|
+
* • `write` overwrites with exactly these records; an empty list writes an empty
|
|
25
|
+
* file (this is how a drained queue and a fully-resolved debt ledger look).
|
|
26
|
+
* • Neither `append` nor `write` ever throws.
|
|
27
|
+
*/
|
|
28
|
+
import * as fsp from 'node:fs/promises';
|
|
29
|
+
import * as path from 'node:path';
|
|
30
|
+
import { tasksDir } from './task-io.js';
|
|
31
|
+
export function makeLedger(spec) {
|
|
32
|
+
const { file, max, key, serialize, parse } = spec;
|
|
33
|
+
const onNoop = spec.onNoop ?? 'rewrite';
|
|
34
|
+
const filePath = (cwd) => path.join(tasksDir(cwd), file);
|
|
35
|
+
async function readRaw(cwd) {
|
|
36
|
+
try {
|
|
37
|
+
return (await fsp.readFile(filePath(cwd), 'utf8')).trim();
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return '';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
async function read(cwd) {
|
|
44
|
+
return parse(await readRaw(cwd));
|
|
45
|
+
}
|
|
46
|
+
async function persist(cwd, items) {
|
|
47
|
+
await fsp.mkdir(tasksDir(cwd), { recursive: true });
|
|
48
|
+
const content = items.length === 0 ? '' : items.map(serialize).join('\n') + '\n';
|
|
49
|
+
await fsp.writeFile(filePath(cwd), content, 'utf8');
|
|
50
|
+
}
|
|
51
|
+
async function append(cwd, items) {
|
|
52
|
+
if (items.length === 0)
|
|
53
|
+
return;
|
|
54
|
+
try {
|
|
55
|
+
const existing = await read(cwd);
|
|
56
|
+
const seen = new Set(existing.map(key));
|
|
57
|
+
const merged = [...existing];
|
|
58
|
+
for (const item of items) {
|
|
59
|
+
const k = key(item);
|
|
60
|
+
if (seen.has(k))
|
|
61
|
+
continue;
|
|
62
|
+
seen.add(k);
|
|
63
|
+
merged.push(item);
|
|
64
|
+
}
|
|
65
|
+
if (merged.length === existing.length && onNoop === 'skip')
|
|
66
|
+
return;
|
|
67
|
+
const kept = max === undefined ? merged : merged.slice(-max);
|
|
68
|
+
await persist(cwd, kept);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// best-effort ledger
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
async function write(cwd, items) {
|
|
75
|
+
try {
|
|
76
|
+
await persist(cwd, items);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// best-effort ledger
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return { path: filePath, readRaw, read, append, write };
|
|
83
|
+
}
|
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
* 1. EXACT repeats — the same `(toolName, stable-stringified args)` key appears
|
|
7
7
|
* `threshold` times within the last `window` events.
|
|
8
8
|
* 2. PATH revisits — the same primary file path is re-targeted `pathThreshold`
|
|
9
|
-
* times within the window
|
|
10
|
-
* key misses when `offset/limit` vary call-to-call.
|
|
11
|
-
*
|
|
9
|
+
* times within the window over lines it has ALREADY covered, which the exact
|
|
10
|
+
* key misses when `offset/limit` vary call-to-call. Forward paging never
|
|
11
|
+
* trips (see countRevisits).
|
|
12
12
|
*
|
|
13
13
|
* Either pattern returns a LoopHit so the caller can kill the child and re-spawn
|
|
14
14
|
* with a hint. No I/O. No imports from index.ts. Trivially unit-testable.
|
|
@@ -45,11 +45,17 @@ export declare class LoopDetector {
|
|
|
45
45
|
/** Record a tool call. Returns LoopHit if either threshold is breached, else null. */
|
|
46
46
|
record(call: ToolCall): LoopHit | null;
|
|
47
47
|
/**
|
|
48
|
-
* Count same-path calls in the window that are "revisits" — accesses
|
|
49
|
-
*
|
|
50
|
-
* path.
|
|
51
|
-
*
|
|
52
|
-
*
|
|
48
|
+
* Count same-path calls in the window that are "revisits" — accesses that end
|
|
49
|
+
* no further into the file than the furthest line already covered for that
|
|
50
|
+
* path. Paging yields zero revisits and never trips; repeated whole-file
|
|
51
|
+
* re-reads, backward jumps and path-targeting greps accumulate.
|
|
52
|
+
*
|
|
53
|
+
* The comparison is on the RANGE, not the offset. Offset alone scored a page
|
|
54
|
+
* that re-asks from the same start with a bigger limit — `{offset:80,
|
|
55
|
+
* limit:400}` after `{offset:80, limit:300}`, real reads from a measured
|
|
56
|
+
* decompose run — as a revisit, even though it covers 100 lines the child had
|
|
57
|
+
* never seen. That is the same mistake SingleReadGuard made by keying on the
|
|
58
|
+
* path alone, and it is corrected the same way.
|
|
53
59
|
*/
|
|
54
60
|
private countRevisits;
|
|
55
61
|
}
|
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
* 1. EXACT repeats — the same `(toolName, stable-stringified args)` key appears
|
|
7
7
|
* `threshold` times within the last `window` events.
|
|
8
8
|
* 2. PATH revisits — the same primary file path is re-targeted `pathThreshold`
|
|
9
|
-
* times within the window
|
|
10
|
-
* key misses when `offset/limit` vary call-to-call.
|
|
11
|
-
*
|
|
9
|
+
* times within the window over lines it has ALREADY covered, which the exact
|
|
10
|
+
* key misses when `offset/limit` vary call-to-call. Forward paging never
|
|
11
|
+
* trips (see countRevisits).
|
|
12
12
|
*
|
|
13
13
|
* Either pattern returns a LoopHit so the caller can kill the child and re-spawn
|
|
14
14
|
* with a hint. No I/O. No imports from index.ts. Trivially unit-testable.
|
|
@@ -52,6 +52,23 @@ function readOffset(args) {
|
|
|
52
52
|
const o = args.offset;
|
|
53
53
|
return typeof o === 'number' && Number.isFinite(o) ? o : 0;
|
|
54
54
|
}
|
|
55
|
+
/** Default `limit` pi's read tool applies when the call names none. */
|
|
56
|
+
const DEFAULT_READ_LIMIT = 2000;
|
|
57
|
+
/**
|
|
58
|
+
* The last line a call reaches, so a page can be told from a re-read. Absent,
|
|
59
|
+
* junk, or at-the-tool-default `limit` all mean "to the end of the file": at this
|
|
60
|
+
* layer they are indistinguishable, and the generous reading is the safe one —
|
|
61
|
+
* scoring an honest page as a revisit is the failure this rule had.
|
|
62
|
+
*/
|
|
63
|
+
function readEnd(args, offset) {
|
|
64
|
+
if (!args || typeof args !== 'object')
|
|
65
|
+
return Infinity;
|
|
66
|
+
const l = args.limit;
|
|
67
|
+
if (typeof l !== 'number' || !Number.isFinite(l) || l <= 0 || l >= DEFAULT_READ_LIMIT) {
|
|
68
|
+
return Infinity;
|
|
69
|
+
}
|
|
70
|
+
return offset + Math.floor(l) - 1;
|
|
71
|
+
}
|
|
55
72
|
export class LoopDetector {
|
|
56
73
|
window;
|
|
57
74
|
threshold;
|
|
@@ -67,7 +84,8 @@ export class LoopDetector {
|
|
|
67
84
|
/** Record a tool call. Returns LoopHit if either threshold is breached, else null. */
|
|
68
85
|
record(call) {
|
|
69
86
|
const key = `${call.name}\x00${stableStringify(call.args)}`;
|
|
70
|
-
|
|
87
|
+
const offset = readOffset(call.args);
|
|
88
|
+
this.buf.push({ key, path: primaryPath(call.args), offset, end: readEnd(call.args, offset) });
|
|
71
89
|
if (this.buf.length > this.window)
|
|
72
90
|
this.buf.shift();
|
|
73
91
|
// 1. Exact-match loop: identical (name, args) repeated past threshold.
|
|
@@ -91,20 +109,26 @@ export class LoopDetector {
|
|
|
91
109
|
return null;
|
|
92
110
|
}
|
|
93
111
|
/**
|
|
94
|
-
* Count same-path calls in the window that are "revisits" — accesses
|
|
95
|
-
*
|
|
96
|
-
* path.
|
|
97
|
-
*
|
|
98
|
-
*
|
|
112
|
+
* Count same-path calls in the window that are "revisits" — accesses that end
|
|
113
|
+
* no further into the file than the furthest line already covered for that
|
|
114
|
+
* path. Paging yields zero revisits and never trips; repeated whole-file
|
|
115
|
+
* re-reads, backward jumps and path-targeting greps accumulate.
|
|
116
|
+
*
|
|
117
|
+
* The comparison is on the RANGE, not the offset. Offset alone scored a page
|
|
118
|
+
* that re-asks from the same start with a bigger limit — `{offset:80,
|
|
119
|
+
* limit:400}` after `{offset:80, limit:300}`, real reads from a measured
|
|
120
|
+
* decompose run — as a revisit, even though it covers 100 lines the child had
|
|
121
|
+
* never seen. That is the same mistake SingleReadGuard made by keying on the
|
|
122
|
+
* path alone, and it is corrected the same way.
|
|
99
123
|
*/
|
|
100
124
|
countRevisits(path) {
|
|
101
|
-
let
|
|
125
|
+
let maxEnd = -1;
|
|
102
126
|
let revisits = 0;
|
|
103
127
|
for (const e of this.buf) {
|
|
104
128
|
if (e.path !== path)
|
|
105
129
|
continue;
|
|
106
|
-
if (e.
|
|
107
|
-
|
|
130
|
+
if (e.end > maxEnd)
|
|
131
|
+
maxEnd = e.end; // progress: new ground
|
|
108
132
|
else
|
|
109
133
|
revisits++; // revisit: already-covered ground
|
|
110
134
|
}
|