dorfl 0.11.1 → 0.11.3
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/dist/arbiter-refs.d.ts +139 -0
- package/dist/arbiter-refs.d.ts.map +1 -0
- package/dist/arbiter-refs.js +114 -0
- package/dist/arbiter-refs.js.map +1 -0
- package/dist/do.d.ts +11 -1
- package/dist/do.d.ts.map +1 -1
- package/dist/do.js +108 -14
- package/dist/do.js.map +1 -1
- package/dist/harness.d.ts +44 -0
- package/dist/harness.d.ts.map +1 -1
- package/dist/harness.js.map +1 -1
- package/dist/item-lock.d.ts.map +1 -1
- package/dist/item-lock.js +89 -15
- package/dist/item-lock.js.map +1 -1
- package/dist/ledger-write.d.ts.map +1 -1
- package/dist/ledger-write.js +38 -3
- package/dist/ledger-write.js.map +1 -1
- package/dist/needs-attention.d.ts +43 -0
- package/dist/needs-attention.d.ts.map +1 -1
- package/dist/needs-attention.js +211 -50
- package/dist/needs-attention.js.map +1 -1
- package/dist/pi-harness.d.ts.map +1 -1
- package/dist/pi-harness.js +133 -26
- package/dist/pi-harness.js.map +1 -1
- package/dist/protocol/REVIEW-PROTOCOL.md +1 -1
- package/dist/reap-agent-tree.d.ts +108 -0
- package/dist/reap-agent-tree.d.ts.map +1 -0
- package/dist/reap-agent-tree.js +173 -0
- package/dist/reap-agent-tree.js.map +1 -0
- package/dist/repo-mirror.d.ts.map +1 -1
- package/dist/repo-mirror.js +17 -0
- package/dist/repo-mirror.js.map +1 -1
- package/dist/review-verdict.d.ts +46 -2
- package/dist/review-verdict.d.ts.map +1 -1
- package/dist/review-verdict.js +49 -3
- package/dist/review-verdict.js.map +1 -1
- package/dist/skills/setup/protocol/REVIEW-PROTOCOL.md +1 -1
- package/dist/tasker-review-loop.d.ts +13 -0
- package/dist/tasker-review-loop.d.ts.map +1 -1
- package/dist/tasker-review-loop.js +120 -8
- package/dist/tasker-review-loop.js.map +1 -1
- package/dist/tasking.d.ts.map +1 -1
- package/dist/tasking.js +161 -17
- package/dist/tasking.js.map +1 -1
- package/dist/watch-session.d.ts +35 -4
- package/dist/watch-session.d.ts.map +1 -1
- package/dist/watch-session.js +54 -7
- package/dist/watch-session.js.map +1 -1
- package/dist/worktree-writer-lock.d.ts +99 -0
- package/dist/worktree-writer-lock.d.ts.map +1 -0
- package/dist/worktree-writer-lock.js +158 -0
- package/dist/worktree-writer-lock.js.map +1 -0
- package/package.json +1 -1
- package/src/arbiter-refs.ts +222 -0
- package/src/do.ts +155 -18
- package/src/harness.ts +45 -0
- package/src/item-lock.ts +93 -19
- package/src/ledger-write.ts +41 -5
- package/src/needs-attention.ts +282 -59
- package/src/pi-harness.ts +136 -28
- package/src/reap-agent-tree.ts +221 -0
- package/src/repo-mirror.ts +22 -0
- package/src/review-verdict.ts +73 -5
- package/src/tasker-review-loop.ts +129 -7
- package/src/tasking.ts +198 -16
- package/src/watch-session.ts +83 -7
- package/src/worktree-writer-lock.ts +217 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **Reap a stopped agent's whole PROCESS TREE, and VERIFY it is gone** (spec
|
|
3
|
+
* `graceful-pre-timeout-wip-checkpoint`, observation
|
|
4
|
+
* `checkpoint-releases-lock-while-predecessor-agent-still-writes`).
|
|
5
|
+
*
|
|
6
|
+
* ## The gap this closes
|
|
7
|
+
*
|
|
8
|
+
* The deadline checkpoint used to `child.kill('SIGTERM')` the agent process and
|
|
9
|
+
* treat the signal as if it were the outcome: the moment pi's own `exit` fired,
|
|
10
|
+
* the runner saved the WIP, RELEASED the item lock, and let the next tick
|
|
11
|
+
* dispatch a CONTINUATION agent into the SAME worktree. But `child.kill` signals
|
|
12
|
+
* exactly ONE pid, and a modern agent is a TREE (subagent processes, MCP servers,
|
|
13
|
+
* model proxies, tool subshells). Those descendants survive the parent's SIGTERM,
|
|
14
|
+
* and once pi exits they are re-parented to init — so they can no longer even be
|
|
15
|
+
* FOUND by walking `ppid`, while they keep writing into the worktree.
|
|
16
|
+
*
|
|
17
|
+
* Observed on a real run: the checkpoint saved WIP at 02:13, a continuation agent
|
|
18
|
+
* onboarded into the same worktree at ~02:15, and the predecessor's session log
|
|
19
|
+
* kept being written until 02:19:29 — four minutes INTO the successor's run,
|
|
20
|
+
* whose opening `git status` had already read the tree as clean. Nothing was lost
|
|
21
|
+
* only because the two happened to touch different files. The shape is the
|
|
22
|
+
* defect: one lock, one working tree, two live writers. A write landing after the
|
|
23
|
+
* successor's `git status` is invisible to it; a write landing during its edits
|
|
24
|
+
* can be clobbered either way; and the successor can commit the predecessor's
|
|
25
|
+
* half-finished edits as its own, under a message describing something else.
|
|
26
|
+
*
|
|
27
|
+
* ## Why the PROCESS GROUP is the handle
|
|
28
|
+
*
|
|
29
|
+
* A pid-tree walk cannot work here: the descendants we must reap are precisely
|
|
30
|
+
* the ones that OUTLIVE the parent, and an orphan's `ppid` is gone. A process
|
|
31
|
+
* GROUP id, by contrast, is inherited by every descendant and is NOT changed by
|
|
32
|
+
* re-parenting. So if the agent is spawned as a group LEADER (`detached: true`,
|
|
33
|
+
* making its pgid equal its pid), `kill(-pgid, …)` reaches the entire tree,
|
|
34
|
+
* orphans included — which is why {@link reapProcessGroup} takes a pgid and why
|
|
35
|
+
* `pi-harness.ts` spawns the deadline-capable async launch detached.
|
|
36
|
+
*
|
|
37
|
+
* ## Signal, then VERIFY — never assume
|
|
38
|
+
*
|
|
39
|
+
* The point of the whole module is that sending a signal is not evidence that
|
|
40
|
+
* anything died. So: SIGTERM the group, POLL until it is actually gone, escalate
|
|
41
|
+
* to SIGKILL after a grace, keep polling, and if it STILL will not die, say so
|
|
42
|
+
* LOUDLY and let the caller refuse to release the lock. A checkpoint that cannot
|
|
43
|
+
* prove the predecessor is dead must not hand the worktree to a successor.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/** Poll interval while waiting for a signalled group to actually exit. */
|
|
47
|
+
const REAP_POLL_MS = 50;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* How long to wait after SIGTERM before escalating to SIGKILL. Matches
|
|
51
|
+
* `pi-harness.ts`'s `DEADLINE_SIGKILL_GRACE_MS` intent: enough for an agent to
|
|
52
|
+
* flush its session log and exit cleanly, short enough not to stall a CI leg.
|
|
53
|
+
*/
|
|
54
|
+
export const REAP_SIGTERM_GRACE_MS = 10_000;
|
|
55
|
+
|
|
56
|
+
/** How long to keep waiting after SIGKILL before declaring the reap FAILED. */
|
|
57
|
+
export const REAP_SIGKILL_TIMEOUT_MS = 5_000;
|
|
58
|
+
|
|
59
|
+
/** The outcome of a {@link reapProcessGroup} attempt. */
|
|
60
|
+
export interface ReapResult {
|
|
61
|
+
/**
|
|
62
|
+
* True iff the group is VERIFIED gone (observed non-existent, not merely
|
|
63
|
+
* signalled). Only a `true` here licenses releasing the item lock and
|
|
64
|
+
* dispatching a successor into the same worktree.
|
|
65
|
+
*/
|
|
66
|
+
reaped: boolean;
|
|
67
|
+
/** True iff SIGKILL was needed (the tree ignored SIGTERM) — worth reporting. */
|
|
68
|
+
escalatedToSigkill: boolean;
|
|
69
|
+
/** Total wall-clock ms spent waiting for the tree to die. */
|
|
70
|
+
waitedMs: number;
|
|
71
|
+
/** A human-readable account, always populated (the LOUD failure text). */
|
|
72
|
+
detail: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Sleep helper (injectable clock is not needed: callers inject `wait` in tests). */
|
|
76
|
+
function sleep(ms: number): Promise<void> {
|
|
77
|
+
return new Promise((resolve) => {
|
|
78
|
+
const timer = setTimeout(resolve, ms);
|
|
79
|
+
timer.unref?.();
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Is any process still alive in process group `pgid`?
|
|
85
|
+
*
|
|
86
|
+
* `process.kill(-pgid, 0)` is the liveness probe: signal 0 performs the
|
|
87
|
+
* permission/existence check WITHOUT delivering a signal (the same technique
|
|
88
|
+
* `harness.ts`'s {@link pidAlive} uses for a single pid, widened to the group).
|
|
89
|
+
*
|
|
90
|
+
* - It THROWS `ESRCH` when no process in the group exists ⇒ the group is gone.
|
|
91
|
+
* - It THROWS `EPERM` when the group exists but we may not signal it. That is
|
|
92
|
+
* still "alive", and reporting it as dead would be the very assumption this
|
|
93
|
+
* module exists to remove — so `EPERM` reads as ALIVE.
|
|
94
|
+
* - It succeeds ⇒ alive.
|
|
95
|
+
*/
|
|
96
|
+
export function processGroupAlive(pgid: number): boolean {
|
|
97
|
+
if (!Number.isInteger(pgid) || pgid <= 1) {
|
|
98
|
+
// pgid 0/1 (or a bogus value) would mean "our own group" / init — signalling
|
|
99
|
+
// those would be catastrophic, so never claim they are ours to reap.
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
process.kill(-pgid, 0);
|
|
104
|
+
return true;
|
|
105
|
+
} catch (err) {
|
|
106
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
107
|
+
if (code === 'EPERM') {
|
|
108
|
+
return true; // exists, just not signallable by us.
|
|
109
|
+
}
|
|
110
|
+
return false; // ESRCH (or anything else): treat as gone.
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Signal a whole process group, tolerating an already-dead group. */
|
|
115
|
+
function signalGroup(pgid: number, signal: NodeJS.Signals): void {
|
|
116
|
+
try {
|
|
117
|
+
process.kill(-pgid, signal);
|
|
118
|
+
} catch {
|
|
119
|
+
// ESRCH: already gone — the wait loop below observes that and succeeds.
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* SIGTERM process group `pgid`, wait for it to ACTUALLY exit, escalate to
|
|
125
|
+
* SIGKILL after {@link REAP_SIGTERM_GRACE_MS}, and report whether the tree is
|
|
126
|
+
* VERIFIED gone.
|
|
127
|
+
*
|
|
128
|
+
* Bounded by construction: at worst `sigtermGraceMs + sigkillTimeoutMs` before it
|
|
129
|
+
* returns `reaped: false` with a loud `detail`. It never waits indefinitely, so
|
|
130
|
+
* it cannot reintroduce the runner-hang the async launch's resolve-on-`exit`
|
|
131
|
+
* discipline exists to avoid.
|
|
132
|
+
*/
|
|
133
|
+
export async function reapProcessGroup(params: {
|
|
134
|
+
/** The process GROUP id to reap (the group leader's pid). */
|
|
135
|
+
pgid: number;
|
|
136
|
+
sigtermGraceMs?: number;
|
|
137
|
+
sigkillTimeoutMs?: number;
|
|
138
|
+
/** Injectable sleep so tests need not burn real seconds. */
|
|
139
|
+
wait?: (ms: number) => Promise<void>;
|
|
140
|
+
/**
|
|
141
|
+
* Injectable liveness probe (default {@link processGroupAlive}). Exists so the
|
|
142
|
+
* REFUSAL path — a tree that survives SIGTERM *and* SIGKILL — can be tested
|
|
143
|
+
* deterministically. There is no portable way to create a genuinely unkillable
|
|
144
|
+
* process, and the alternative (a group we truly cannot signal) would risk the
|
|
145
|
+
* test process itself, so the probe is the seam.
|
|
146
|
+
*/
|
|
147
|
+
alive?: (pgid: number) => boolean;
|
|
148
|
+
}): Promise<ReapResult> {
|
|
149
|
+
const {
|
|
150
|
+
pgid,
|
|
151
|
+
sigtermGraceMs = REAP_SIGTERM_GRACE_MS,
|
|
152
|
+
sigkillTimeoutMs = REAP_SIGKILL_TIMEOUT_MS,
|
|
153
|
+
wait = sleep,
|
|
154
|
+
alive = processGroupAlive,
|
|
155
|
+
} = params;
|
|
156
|
+
const started = Date.now();
|
|
157
|
+
|
|
158
|
+
if (!alive(pgid)) {
|
|
159
|
+
return {
|
|
160
|
+
reaped: true,
|
|
161
|
+
escalatedToSigkill: false,
|
|
162
|
+
waitedMs: 0,
|
|
163
|
+
detail: `agent process group ${pgid} was already gone (nothing to reap).`,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 1. SOFT: ask the whole tree to stop, then WAIT for it to be observably gone.
|
|
168
|
+
signalGroup(pgid, 'SIGTERM');
|
|
169
|
+
while (Date.now() - started < sigtermGraceMs) {
|
|
170
|
+
if (!alive(pgid)) {
|
|
171
|
+
const waitedMs = Date.now() - started;
|
|
172
|
+
return {
|
|
173
|
+
reaped: true,
|
|
174
|
+
escalatedToSigkill: false,
|
|
175
|
+
waitedMs,
|
|
176
|
+
detail:
|
|
177
|
+
`agent process group ${pgid} exited on SIGTERM after ${waitedMs}ms ` +
|
|
178
|
+
'(verified gone).',
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
await wait(REAP_POLL_MS);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// 2. HARD: the tree ignored SIGTERM through the grace. SIGKILL it and keep
|
|
185
|
+
// verifying — a wedged agent still holding the worktree must not survive
|
|
186
|
+
// into the successor's run.
|
|
187
|
+
signalGroup(pgid, 'SIGKILL');
|
|
188
|
+
const killDeadline = Date.now() + sigkillTimeoutMs;
|
|
189
|
+
while (Date.now() < killDeadline) {
|
|
190
|
+
if (!alive(pgid)) {
|
|
191
|
+
const waitedMs = Date.now() - started;
|
|
192
|
+
return {
|
|
193
|
+
reaped: true,
|
|
194
|
+
escalatedToSigkill: true,
|
|
195
|
+
waitedMs,
|
|
196
|
+
detail:
|
|
197
|
+
`agent process group ${pgid} ignored SIGTERM and was SIGKILLed; ` +
|
|
198
|
+
`exited after ${waitedMs}ms (verified gone).`,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
await wait(REAP_POLL_MS);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// 3. LOUD FAILURE: we cannot prove the predecessor is dead. Say exactly that,
|
|
205
|
+
// and exactly what the caller must not do as a result.
|
|
206
|
+
const waitedMs = Date.now() - started;
|
|
207
|
+
return {
|
|
208
|
+
reaped: false,
|
|
209
|
+
escalatedToSigkill: true,
|
|
210
|
+
waitedMs,
|
|
211
|
+
detail:
|
|
212
|
+
`REFUSING TO PROCEED: agent process group ${pgid} is STILL ALIVE ${waitedMs}ms ` +
|
|
213
|
+
'after SIGTERM and SIGKILL (an unkillable/uninterruptible descendant — e.g. a ' +
|
|
214
|
+
'process wedged in a kernel call, or one we lack permission to signal). It may ' +
|
|
215
|
+
'still be WRITING to the worktree, so the item lock must NOT be released and no ' +
|
|
216
|
+
'successor agent may onboard here: two live writers in one working tree can ' +
|
|
217
|
+
'silently clobber each other and let a successor commit the predecessor’s ' +
|
|
218
|
+
`half-finished edits. Inspect and kill it by hand (\`ps -g ${pgid}\`, ` +
|
|
219
|
+
`\`kill -9 -${pgid}\`) before re-running this item.`,
|
|
220
|
+
};
|
|
221
|
+
}
|
package/src/repo-mirror.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type LoadedRepoConfig,
|
|
12
12
|
} from './repo-config.js';
|
|
13
13
|
import {encodeRepoKey} from './repo-key.js';
|
|
14
|
+
import {LOCK_REF_PREFIX} from './item-lock.js';
|
|
14
15
|
|
|
15
16
|
export {encodeRepoKey} from './repo-key.js';
|
|
16
17
|
|
|
@@ -132,6 +133,27 @@ export function ensureMirror(options: EnsureMirrorOptions): EnsureMirrorResult {
|
|
|
132
133
|
// materialisation has its base; the per-branch onboard reads still fail loudly
|
|
133
134
|
// downstream if a needed ref is genuinely absent.
|
|
134
135
|
run('git', ['fetch', 'origin', '+refs/heads/*:refs/heads/*'], path, {env});
|
|
136
|
+
// PRUNE the per-item LOCK namespace against the arbiter (observation
|
|
137
|
+
// `gc-ledger-reports-mirror-stale-lock-refs-as-arbiter-state`): the all-heads
|
|
138
|
+
// fetch above covers only `refs/heads/*`, so `refs/dorfl/lock/*` was never
|
|
139
|
+
// pruned here and released locks accumulated on the mirror indefinitely — a
|
|
140
|
+
// `gc --ledger` (or any `status`/`scan` read) that reads the mirror then
|
|
141
|
+
// reported locks that no longer exist on the arbiter. Best-effort (soft):
|
|
142
|
+
// an unreachable arbiter leaves the existing lock refs in place (the
|
|
143
|
+
// read-side `listItemLocks`/`listItemLockEntries` read the arbiter DIRECTLY
|
|
144
|
+
// via `ls-remote`, so this is the proactive keep-the-mirror-clean pass, not
|
|
145
|
+
// the load-bearing one).
|
|
146
|
+
run(
|
|
147
|
+
'git',
|
|
148
|
+
[
|
|
149
|
+
'fetch',
|
|
150
|
+
'--prune',
|
|
151
|
+
'origin',
|
|
152
|
+
`+${LOCK_REF_PREFIX}/*:${LOCK_REF_PREFIX}/*`,
|
|
153
|
+
],
|
|
154
|
+
path,
|
|
155
|
+
{env},
|
|
156
|
+
);
|
|
135
157
|
fetched = true;
|
|
136
158
|
} else {
|
|
137
159
|
// First time: a bare mirror clone (shared object store, cheap).
|
package/src/review-verdict.ts
CHANGED
|
@@ -40,8 +40,27 @@ export interface ReviewFinding {
|
|
|
40
40
|
export interface TaskEdit {
|
|
41
41
|
/** Repo-relative path of the candidate task file to write. */
|
|
42
42
|
path: string;
|
|
43
|
-
/**
|
|
44
|
-
|
|
43
|
+
/**
|
|
44
|
+
* The full replacement content for that file, emitted INLINE. LEGACY / small-edit
|
|
45
|
+
* form. UNBOUNDED: a large decomposition's worth of full-file bodies in ONE JSON
|
|
46
|
+
* object shares the model's capped response with the verdict, so a rich spec can
|
|
47
|
+
* cap-truncate the response before the verdict closes (observation
|
|
48
|
+
* `tasker-review-edits-payload-caps-the-verdict-response`). The tasker improver loop
|
|
49
|
+
* now asks the agent to WRITE each edited body to a scratch file and reference it by
|
|
50
|
+
* `src` instead (see {@link src}) so the verdict is obtainable independent of edit
|
|
51
|
+
* size. Kept (optional) so a small inline edit + the existing tests still work.
|
|
52
|
+
*/
|
|
53
|
+
content?: string;
|
|
54
|
+
/**
|
|
55
|
+
* Repo-relative path to a SCRATCH file the agent WROTE the full replacement body
|
|
56
|
+
* to (the runner reads it, applies it through the SAME scope fence, then deletes
|
|
57
|
+
* it). The DECOUPLED form: the verdict JSON carries only PATHS, so its size is
|
|
58
|
+
* bounded by the NUMBER of edits, not the total body size — a large decomposition
|
|
59
|
+
* can no longer cap-truncate the verdict. The agent writes the scratch file itself
|
|
60
|
+
* (it runs in the job worktree and has the `write` tool); the runner applies /
|
|
61
|
+
* commits. Exactly one of {@link content} / {@link src} carries the body.
|
|
62
|
+
*/
|
|
63
|
+
src?: string;
|
|
45
64
|
}
|
|
46
65
|
|
|
47
66
|
/**
|
|
@@ -91,6 +110,39 @@ export interface ReviewVerdict {
|
|
|
91
110
|
/** Raised when the review agent ran but produced no parseable verdict. */
|
|
92
111
|
export class ReviewParseError extends Error {}
|
|
93
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Raised when the review agent's output was TRUNCATED at the model's output cap
|
|
115
|
+
* before it could emit a complete verdict — a DISTINCT, named failure class so it is
|
|
116
|
+
* never mis-reported as a generic "produced no parseable result" (which reads as a
|
|
117
|
+
* flake and invites a blind retry, burning a second full tasking run).
|
|
118
|
+
*
|
|
119
|
+
* It is a SUBCLASS of {@link ReviewParseError} so every existing
|
|
120
|
+
* `catch (ReviewParseError)` site (the tasker-review loop's bounce routing) still
|
|
121
|
+
* catches it UNIFORMLY and routes to needs-attention — NEVER a silent approve. The
|
|
122
|
+
* only thing that changes is the MESSAGE: it names the cap and the token count so
|
|
123
|
+
* an operator knows the structural cause (the edits payload is unbounded and
|
|
124
|
+
* shared the capped response with the verdict) rather than chasing a model flake.
|
|
125
|
+
*
|
|
126
|
+
* Detected at the harness seam: the adapter surfaces the last assistant turn's
|
|
127
|
+
* `stop_reason` (null / `None` / `max_tokens` — the turn did not end naturally) and
|
|
128
|
+
* its `usage.output` token count; the gate throws this when a parse fails AND that
|
|
129
|
+
* cap signal is present. When the adapter CANNOT see the signal (the null/shell
|
|
130
|
+
* adapter, or a future adapter without usage telemetry), the parse still fails as a
|
|
131
|
+
* generic {@link ReviewParseError} — still needs-attention, never a silent approve.
|
|
132
|
+
*/
|
|
133
|
+
export class ReviewOutputCappedError extends ReviewParseError {
|
|
134
|
+
/** The output token count the agent reached when the cap hit (the observed `usage.output`). */
|
|
135
|
+
readonly outputTokens: number;
|
|
136
|
+
constructor(outputTokens: number) {
|
|
137
|
+
super(
|
|
138
|
+
`review agent output hit the model output cap (${outputTokens} tokens) ` +
|
|
139
|
+
'and was truncated before emitting its verdict',
|
|
140
|
+
);
|
|
141
|
+
this.name = 'ReviewOutputCappedError';
|
|
142
|
+
this.outputTokens = outputTokens;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
94
146
|
/**
|
|
95
147
|
* Parse the unified review verdict out of the review agent's textual output.
|
|
96
148
|
* The agent may wrap the JSON object in prose / a fenced block, so the first
|
|
@@ -246,9 +298,22 @@ function parseEdits(raw: unknown): TaskEdit[] {
|
|
|
246
298
|
continue;
|
|
247
299
|
}
|
|
248
300
|
const item = e as Record<string, unknown>;
|
|
249
|
-
if (typeof item.path
|
|
250
|
-
|
|
301
|
+
if (typeof item.path !== 'string') {
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
// The body is either inline `content` (legacy / small edits) OR a `src` scratch
|
|
305
|
+
// path the agent wrote (the decoupled form — see TaskEdit). Carry whichever
|
|
306
|
+
// is present; the runner resolves it. An edit with NEITHER is dropped (no body
|
|
307
|
+
// to apply) — but a path-only edit is also tolerated as a no-op marker so a
|
|
308
|
+
// verdict that names a path it did not actually rewrite does not crash the loop.
|
|
309
|
+
const edit: TaskEdit = {path: item.path};
|
|
310
|
+
if (typeof item.content === 'string') {
|
|
311
|
+
edit.content = item.content;
|
|
312
|
+
}
|
|
313
|
+
if (typeof item.src === 'string') {
|
|
314
|
+
edit.src = item.src;
|
|
251
315
|
}
|
|
316
|
+
out.push(edit);
|
|
252
317
|
}
|
|
253
318
|
return out;
|
|
254
319
|
}
|
|
@@ -314,7 +379,10 @@ export function verdictContractPrompt(): string {
|
|
|
314
379
|
' {"severity": "blocking" | "non-blocking", "question": "\u2026", "context": "\u2026"}',
|
|
315
380
|
' ],',
|
|
316
381
|
' "review": "<the human-readable PR-comment prose, when the caller asks for it>",',
|
|
317
|
-
' "edits": [ {"path": "work/tasks/backlog/<slug>.md", "
|
|
382
|
+
' "edits": [ {"path": "work/tasks/backlog/<slug>.md", "src": "<scratch path you wrote the full body to>"} ],',
|
|
383
|
+
' // (inline "content": "<full replacement>" is the legacy small-edit form —',
|
|
384
|
+
' // the tasker loop writes the body to a scratch file + references it by',
|
|
385
|
+
' // "src" so the edits payload cannot cap-truncate this verdict)',
|
|
318
386
|
' "edit": "<single in-memory replacement body, for the lone-task review>",',
|
|
319
387
|
' "questions": ["<open question for the human>"],',
|
|
320
388
|
' "uncertainTasks": [ {"path": "work/tasks/backlog/<slug>.md", "questions": ["\u2026"]} ],',
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {readFileSync, readdirSync, writeFileSync} from 'node:fs';
|
|
1
|
+
import {readFileSync, readdirSync, writeFileSync, rmSync} from 'node:fs';
|
|
2
2
|
import {join} from 'node:path';
|
|
3
3
|
import {
|
|
4
4
|
workFolderPrefix,
|
|
@@ -12,6 +12,7 @@ import {launchWithOptionalWatch} from './agent-launch.js';
|
|
|
12
12
|
import {
|
|
13
13
|
parseReviewVerdict,
|
|
14
14
|
ReviewParseError,
|
|
15
|
+
ReviewOutputCappedError,
|
|
15
16
|
reviewDisciplinePrompt,
|
|
16
17
|
verdictContractPrompt,
|
|
17
18
|
type ReviewFinding,
|
|
@@ -26,6 +27,20 @@ import {
|
|
|
26
27
|
// `review-protocol-doc-and-shared-machinery`).
|
|
27
28
|
export type {TaskEdit, UncertainTask} from './review-verdict.js';
|
|
28
29
|
export {parseReviewVerdict as parseTaskReviewVerdict} from './review-verdict.js';
|
|
30
|
+
export {ReviewOutputCappedError as TaskReviewOutputCappedError} from './review-verdict.js';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The SCRATCH directory the review agent writes full-replacement task bodies to
|
|
34
|
+
* (the DECOUPLED edits channel — see {@link TaskEdit.src}). Sibling of `backlog/`
|
|
35
|
+
* under `work/tasks/`; NOT a {@link WorkFolderKey}, so it is never scanned by
|
|
36
|
+
* {@link newOrChangedBacklog} (which reads `work/tasks/backlog/` only) and never
|
|
37
|
+
* appears as a candidate task. The runner reads each `src` file, applies it to the
|
|
38
|
+
* target `work/tasks/backlog/<name>.md` through the SAME scope fence, then DELETES
|
|
39
|
+
* it — so the integrate's `git add -A` never sweeps the scratch into the commit. A
|
|
40
|
+
* start-of-loop clean (see {@link cleanReviewEditsScratch}) reaps any stragglers a
|
|
41
|
+
* crashed prior run left behind.
|
|
42
|
+
*/
|
|
43
|
+
export const REVIEW_EDITS_SCRATCH_DIR = 'work/tasks/.review-edits';
|
|
29
44
|
|
|
30
45
|
/**
|
|
31
46
|
* **The tasker review→edit→re-review→converge LOOP** (`slicer-review-edit-loop`,
|
|
@@ -247,6 +262,10 @@ export async function runTaskReviewLoop(
|
|
|
247
262
|
// tasks, never pre-existing landed ones (the requeue fix). No snapshot ⇒ an
|
|
248
263
|
// empty one (the legacy whole-directory behaviour for the empty-backlog case).
|
|
249
264
|
const before = options.before ?? new Map<string, string>();
|
|
265
|
+
// Reap any review-edits SCRATCH a crashed prior run left behind, so the
|
|
266
|
+
// integrate's `git add -A` never sweeps stragglers into this run's commit. The
|
|
267
|
+
// loop deletes each `src` it applies too; this is the crash-recovery backstop.
|
|
268
|
+
cleanReviewEditsScratch(options.cwd);
|
|
250
269
|
|
|
251
270
|
let totalPasses = 0;
|
|
252
271
|
let last: SingleExecutionResult | undefined;
|
|
@@ -456,6 +475,15 @@ function applyEdits(
|
|
|
456
475
|
);
|
|
457
476
|
continue;
|
|
458
477
|
}
|
|
478
|
+
// Resolve the edit BODY: inline `content` (legacy / small edits) OR a `src`
|
|
479
|
+
// scratch file the agent wrote (the decoupled form — see TaskEdit.src). The
|
|
480
|
+
// `src` form keeps the verdict JSON bounded by the NUMBER of edits, not the
|
|
481
|
+
// total body size, so a large decomposition can no longer cap-truncate the
|
|
482
|
+
// verdict. A path-only edit with no resolvable body is a no-op marker.
|
|
483
|
+
const body = resolveEditBody(cwd, edit, note);
|
|
484
|
+
if (body === undefined) {
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
459
487
|
// A pre-existing task this run did NOT touch must not be overwritten: it is
|
|
460
488
|
// in `before` and the current on-disk content still equals the snapshot.
|
|
461
489
|
if (before.has(filename)) {
|
|
@@ -475,7 +503,70 @@ function applyEdits(
|
|
|
475
503
|
}
|
|
476
504
|
}
|
|
477
505
|
const abs = join(cwd, normalized);
|
|
478
|
-
writeFileSync(abs,
|
|
506
|
+
writeFileSync(abs, body);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Resolve an edit's full-replacement BODY: inline `content` (legacy) takes
|
|
512
|
+
* precedence, else read the `src` scratch file the agent wrote. Returns
|
|
513
|
+
* `undefined` when there is no body to apply (neither channel present, or `src`
|
|
514
|
+
* missing / outside the scratch fence / unreadable). The `src` is fenced to
|
|
515
|
+
* {@link REVIEW_EDITS_SCRATCH_DIR} (no `..`) so the agent cannot pull an arbitrary
|
|
516
|
+
* repo file in as the edit body. A read `src` is DELETED after reading so the
|
|
517
|
+
* integrate's `git add -A` never sweeps the scratch into the commit.
|
|
518
|
+
*/
|
|
519
|
+
function resolveEditBody(
|
|
520
|
+
cwd: string,
|
|
521
|
+
edit: TaskEdit,
|
|
522
|
+
note: (message: string) => void,
|
|
523
|
+
): string | undefined {
|
|
524
|
+
if (edit.content !== undefined) {
|
|
525
|
+
return edit.content;
|
|
526
|
+
}
|
|
527
|
+
if (edit.src === undefined) {
|
|
528
|
+
return undefined;
|
|
529
|
+
}
|
|
530
|
+
const src = edit.src.replace(/\\/g, '/');
|
|
531
|
+
if (src.includes('..') || !src.startsWith(REVIEW_EDITS_SCRATCH_DIR + '/')) {
|
|
532
|
+
note(
|
|
533
|
+
`Skipped a review edit whose src is outside ${REVIEW_EDITS_SCRATCH_DIR}/ (${edit.src}) — ` +
|
|
534
|
+
'the scratch body must live under the review-edits scratch dir.',
|
|
535
|
+
);
|
|
536
|
+
return undefined;
|
|
537
|
+
}
|
|
538
|
+
const srcAbs = join(cwd, src);
|
|
539
|
+
let body: string | undefined;
|
|
540
|
+
try {
|
|
541
|
+
body = readFileSync(srcAbs, 'utf8');
|
|
542
|
+
} catch {
|
|
543
|
+
note(
|
|
544
|
+
`Skipped a review edit whose src scratch file is missing/unreadable (${edit.src}) — ` +
|
|
545
|
+
'the agent did not write it before emitting the verdict.',
|
|
546
|
+
);
|
|
547
|
+
return undefined;
|
|
548
|
+
}
|
|
549
|
+
// Reap the scratch file now the body is in hand — never let it survive to the
|
|
550
|
+
// integrate's `git add -A` (best-effort; a missing file is already gone).
|
|
551
|
+
try {
|
|
552
|
+
rmSync(srcAbs, {force: true});
|
|
553
|
+
} catch {
|
|
554
|
+
// Best-effort: a failure to delete does not block the apply.
|
|
555
|
+
}
|
|
556
|
+
return body;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Best-effort reap of the {@link REVIEW_EDITS_SCRATCH_DIR} (a crashed prior run may
|
|
561
|
+
* have left scratch bodies behind; the integrate's `git add -A` would otherwise
|
|
562
|
+
* sweep them into the next tasking commit). Called at the START of a loop run.
|
|
563
|
+
* Never throws — a missing/unreadable dir is the normal steady state.
|
|
564
|
+
*/
|
|
565
|
+
function cleanReviewEditsScratch(cwd: string): void {
|
|
566
|
+
try {
|
|
567
|
+
rmSync(join(cwd, REVIEW_EDITS_SCRATCH_DIR), {recursive: true, force: true});
|
|
568
|
+
} catch {
|
|
569
|
+
// Best-effort.
|
|
479
570
|
}
|
|
480
571
|
}
|
|
481
572
|
|
|
@@ -600,7 +691,28 @@ export function harnessTaskReviewGate(
|
|
|
600
691
|
}`,
|
|
601
692
|
);
|
|
602
693
|
}
|
|
603
|
-
|
|
694
|
+
// CAP-TRUNCATION NAMING (observation
|
|
695
|
+
// `tasker-review-edits-payload-caps-the-verdict-response`): attempt the parse
|
|
696
|
+
// FIRST so a verdict that DID complete (even on a capped turn — e.g. the cap
|
|
697
|
+
// hit trailing prose after the JSON closed) is HONORED, not discarded. Only
|
|
698
|
+
// when the parse FAILS AND the adapter surfaced an output-cap signal do we
|
|
699
|
+
// re-throw the NAMED {@link ReviewOutputCappedError} (a subclass of
|
|
700
|
+
// ReviewParseError, so the bounce routing still catches it — NEVER a silent
|
|
701
|
+
// approve). A parse failure with no cap signal stays the generic
|
|
702
|
+
// ReviewParseError (the adapter could not see the signal — still
|
|
703
|
+
// needs-attention, still never a silent approve).
|
|
704
|
+
try {
|
|
705
|
+
return parseReviewVerdict(readOutput(launched.output));
|
|
706
|
+
} catch (err) {
|
|
707
|
+
if (
|
|
708
|
+
launched.outputCapped !== undefined &&
|
|
709
|
+
(err instanceof ReviewParseError ||
|
|
710
|
+
err instanceof ReviewOutputCappedError)
|
|
711
|
+
) {
|
|
712
|
+
throw new ReviewOutputCappedError(launched.outputCapped);
|
|
713
|
+
}
|
|
714
|
+
throw err;
|
|
715
|
+
}
|
|
604
716
|
};
|
|
605
717
|
}
|
|
606
718
|
|
|
@@ -634,16 +746,26 @@ export function buildTaskReviewPrompt(input: TaskReviewGateInput): string {
|
|
|
634
746
|
`loop, not a one-shot gate.`,
|
|
635
747
|
``,
|
|
636
748
|
`This is review pass ${input.pass} (fresh context ${input.execution}). You`,
|
|
637
|
-
`do NOT
|
|
638
|
-
`
|
|
749
|
+
`do NOT run git. For the EDITS, WRITE each full-replacement task body to a`,
|
|
750
|
+
`SCRATCH file under ${REVIEW_EDITS_SCRATCH_DIR}/ and reference it by path in the`,
|
|
751
|
+
`"edits" channel — do NOT inline the body in the JSON, and do NOT edit the`,
|
|
752
|
+
`candidate task files directly (the runner applies your scratch body to the`,
|
|
753
|
+
`candidate task through its scope fence + deletes the scratch; on a review`,
|
|
754
|
+
`failure the tasker's original candidates stay pristine for recovery). This`,
|
|
755
|
+
`keeps the verdict JSON bounded by the NUMBER of edits, not the total body size,`,
|
|
756
|
+
`so a large decomposition cannot cap-truncate the verdict. Tasks`,
|
|
639
757
|
`measurably keep improving when reviewed, so propose edits that fix the`,
|
|
640
758
|
`findings; converge when a pass finds NO NEW blocking issue.`,
|
|
641
759
|
``,
|
|
642
760
|
verdictContractPrompt(),
|
|
643
761
|
``,
|
|
644
762
|
`Fill the channels appropriate to THIS caller (the tasker improver loop):`,
|
|
645
|
-
` - "edits" —
|
|
646
|
-
`
|
|
763
|
+
` - "edits" — for each candidate task file you want to rewrite, WRITE the full`,
|
|
764
|
+
` replacement body to ${REVIEW_EDITS_SCRATCH_DIR}/<name>.md (the runner reads`,
|
|
765
|
+
` it, applies it to work/tasks/backlog/<name>.md, then deletes the scratch),`,
|
|
766
|
+
` and emit {"path": "work/tasks/backlog/<name>.md", "src": "${REVIEW_EDITS_SCRATCH_DIR}/<name>.md"}.`,
|
|
767
|
+
` Do NOT put the body in "content" — that shares the capped response with`,
|
|
768
|
+
` the verdict and cap-truncates it on a large set.`,
|
|
647
769
|
` - "uncertainTasks" — specific tasks you cannot make buildable (each gets`,
|
|
648
770
|
` \`needsAnswers: true\` with the questions in its body).`,
|
|
649
771
|
` - "decompositionUnclear" — the WHOLE decomposition is unsound (the spec is`,
|