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
package/src/do.ts
CHANGED
|
@@ -17,7 +17,8 @@ import {
|
|
|
17
17
|
resolvePromptGuidanceForItem,
|
|
18
18
|
PromptError,
|
|
19
19
|
} from './prompt.js';
|
|
20
|
-
import {NullHarness, type Harness} from './harness.js';
|
|
20
|
+
import {NullHarness, type AgentTreeReap, type Harness} from './harness.js';
|
|
21
|
+
import {acquireWorktreeWriterLock} from './worktree-writer-lock.js';
|
|
21
22
|
import {PiHarness} from './pi-harness.js';
|
|
22
23
|
import {launchWithOptionalWatch} from './agent-launch.js';
|
|
23
24
|
import {ledgerRead, type LedgerReadStrategy} from './ledger-read.js';
|
|
@@ -155,10 +156,27 @@ function deadlineAutoContinueReason(params: {
|
|
|
155
156
|
}
|
|
156
157
|
function deadlineSurfaceReason(params: {
|
|
157
158
|
slug: string;
|
|
158
|
-
kind: 'no-progress' | 'ceiling';
|
|
159
|
+
kind: 'no-progress' | 'ceiling' | 'unreaped';
|
|
159
160
|
count?: number;
|
|
160
161
|
max?: number;
|
|
162
|
+
/** The harness's loud reap detail (the `unreaped` kind only). */
|
|
163
|
+
detail?: string;
|
|
161
164
|
}): string {
|
|
165
|
+
if (params.kind === 'unreaped') {
|
|
166
|
+
// The auto-continue path is BLOCKED, not merely skipped: releasing the lock
|
|
167
|
+
// would let a successor onboard into a worktree a live predecessor may still
|
|
168
|
+
// be writing to (observation
|
|
169
|
+
// `checkpoint-releases-lock-while-predecessor-agent-still-writes`). The work
|
|
170
|
+
// is saved and pushed; only the hand-off is withheld.
|
|
171
|
+
return (
|
|
172
|
+
`deadline checkpoint (agent NOT verifiably stopped): '${params.slug}' hit ` +
|
|
173
|
+
'the dorfl-internal deadline and its WIP was saved + pushed, but the agent ' +
|
|
174
|
+
'process tree could not be confirmed dead, so the lock was NOT released and ' +
|
|
175
|
+
'no successor was dispatched — a second agent in the same working tree can ' +
|
|
176
|
+
'clobber or silently absorb the first one\u2019s edits. ' +
|
|
177
|
+
`${params.detail ?? ''}`.trim()
|
|
178
|
+
);
|
|
179
|
+
}
|
|
162
180
|
if (params.kind === 'no-progress') {
|
|
163
181
|
return (
|
|
164
182
|
`deadline checkpoint (no progress / ceiling): '${params.slug}' hit ` +
|
|
@@ -329,6 +347,16 @@ export type DoDorfl = (input: {
|
|
|
329
347
|
* injected path — the real deadline race lives in `PiHarness.launchAsync`.
|
|
330
348
|
*/
|
|
331
349
|
timedOut?: boolean;
|
|
350
|
+
/**
|
|
351
|
+
* **The simulated process-tree REAP verdict** for a {@link timedOut} stop
|
|
352
|
+
* (observation `checkpoint-releases-lock-while-predecessor-agent-still-writes`).
|
|
353
|
+
* Test-only signal, the sibling of `timedOut`, so an injected agent can drive
|
|
354
|
+
* the "predecessor could not be verified dead" routing — where the checkpoint
|
|
355
|
+
* must NOT release the item lock, because the next tick would then onboard a
|
|
356
|
+
* successor into a working tree the predecessor may still be writing to.
|
|
357
|
+
* Absent ⇒ nothing was left running (the pre-existing behaviour).
|
|
358
|
+
*/
|
|
359
|
+
reap?: AgentTreeReap;
|
|
332
360
|
};
|
|
333
361
|
|
|
334
362
|
export interface DoOptions {
|
|
@@ -1306,6 +1334,7 @@ export async function performDo(options: DoOptions): Promise<DoResult> {
|
|
|
1306
1334
|
detail?: string;
|
|
1307
1335
|
output?: string;
|
|
1308
1336
|
timedOut?: boolean;
|
|
1337
|
+
reap?: AgentTreeReap;
|
|
1309
1338
|
};
|
|
1310
1339
|
try {
|
|
1311
1340
|
agent = await runDoAgent(options, tree.dir, prompt, slug);
|
|
@@ -1334,6 +1363,7 @@ export async function performDo(options: DoOptions): Promise<DoResult> {
|
|
|
1334
1363
|
cwd: tree.dir,
|
|
1335
1364
|
arbiter: tree.arbiterRemote,
|
|
1336
1365
|
maxAutoCheckpoints: options.maxAutoCheckpoints ?? 5,
|
|
1366
|
+
reap: agent.reap,
|
|
1337
1367
|
env,
|
|
1338
1368
|
note,
|
|
1339
1369
|
});
|
|
@@ -1952,11 +1982,58 @@ async function runDoAgent(
|
|
|
1952
1982
|
detail?: string;
|
|
1953
1983
|
output?: string;
|
|
1954
1984
|
timedOut?: boolean;
|
|
1985
|
+
reap?: AgentTreeReap;
|
|
1955
1986
|
}> {
|
|
1956
1987
|
if (options.dorfl) {
|
|
1957
1988
|
return options.dorfl({cwd, prompt, slug, env: options.env});
|
|
1958
1989
|
}
|
|
1959
1990
|
const harness = options.harness ?? new NullHarness();
|
|
1991
|
+
|
|
1992
|
+
// WORKING-TREE SENTINEL (observation
|
|
1993
|
+
// `checkpoint-releases-lock-while-predecessor-agent-still-writes`): the item
|
|
1994
|
+
// lock guards the ITEM; this guards the TREE. A deadline checkpoint releases the
|
|
1995
|
+
// item lock so the next tick can continue the task in the SAME worktree, so the
|
|
1996
|
+
// item lock alone cannot keep a successor out while a predecessor is still
|
|
1997
|
+
// alive. Refuse to launch a second agent into a tree that already has a LIVE
|
|
1998
|
+
// writer, independently of the reap. A dead holder's sentinel is stale and taken
|
|
1999
|
+
// over, so a crashed runner never poisons the worktree.
|
|
2000
|
+
const writer = acquireWorktreeWriterLock({dir: cwd, slug, env: options.env});
|
|
2001
|
+
if (!writer.acquired) {
|
|
2002
|
+
return {ok: false, detail: writer.reason};
|
|
2003
|
+
}
|
|
2004
|
+
try {
|
|
2005
|
+
return await launchAgentUnderWriterLock({
|
|
2006
|
+
options,
|
|
2007
|
+
cwd,
|
|
2008
|
+
prompt,
|
|
2009
|
+
slug,
|
|
2010
|
+
harness,
|
|
2011
|
+
});
|
|
2012
|
+
} finally {
|
|
2013
|
+
writer.release();
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
/**
|
|
2018
|
+
* The actual harness launch, run while this process holds the worktree writer
|
|
2019
|
+
* sentinel (see {@link runDoAgent}). Split out purely so the sentinel's
|
|
2020
|
+
* acquire/release brackets the launch in a `try/finally` without indenting the
|
|
2021
|
+
* whole body.
|
|
2022
|
+
*/
|
|
2023
|
+
async function launchAgentUnderWriterLock(params: {
|
|
2024
|
+
options: DoAgentLaunchOptions;
|
|
2025
|
+
cwd: string;
|
|
2026
|
+
prompt: string;
|
|
2027
|
+
slug: string;
|
|
2028
|
+
harness: Harness;
|
|
2029
|
+
}): Promise<{
|
|
2030
|
+
ok: boolean;
|
|
2031
|
+
detail?: string;
|
|
2032
|
+
output?: string;
|
|
2033
|
+
timedOut?: boolean;
|
|
2034
|
+
reap?: AgentTreeReap;
|
|
2035
|
+
}> {
|
|
2036
|
+
const {options, cwd, prompt, slug, harness} = params;
|
|
1960
2037
|
// Convert the dorfl-internal deadline (minutes) into a wall-clock epoch-ms so
|
|
1961
2038
|
// the harness (`launchAsync`) can race the child against it (spec
|
|
1962
2039
|
// `graceful-pre-timeout-wip-checkpoint`). Absent ⇒ no deadline; a run that
|
|
@@ -1991,6 +2068,10 @@ async function runDoAgent(
|
|
|
1991
2068
|
detail: launched.detail,
|
|
1992
2069
|
output: launched.output,
|
|
1993
2070
|
timedOut: launched.timedOut,
|
|
2071
|
+
// The harness's PROOF that a deadline-stopped agent's process tree is gone.
|
|
2072
|
+
// Threaded to {@link routeDeadlineCheckpoint}, which refuses to release the
|
|
2073
|
+
// item lock without it.
|
|
2074
|
+
reap: launched.reap,
|
|
1994
2075
|
};
|
|
1995
2076
|
}
|
|
1996
2077
|
|
|
@@ -2008,10 +2089,39 @@ async function routeDeadlineCheckpoint(params: {
|
|
|
2008
2089
|
cwd: string;
|
|
2009
2090
|
arbiter: string;
|
|
2010
2091
|
maxAutoCheckpoints: number;
|
|
2092
|
+
/**
|
|
2093
|
+
* The harness's VERIFIED reap of the checkpointed agent's process tree. The
|
|
2094
|
+
* auto-continue branch releases the lock and lets the next tick dispatch a
|
|
2095
|
+
* SUCCESSOR into this same worktree, so it may only run once the predecessor
|
|
2096
|
+
* is proven gone (observation
|
|
2097
|
+
* `checkpoint-releases-lock-while-predecessor-agent-still-writes`). Absent ⮕
|
|
2098
|
+
* the harness spawned no killable group (test doubles, the null adapter), which
|
|
2099
|
+
* is treated as "nothing was left running".
|
|
2100
|
+
*/
|
|
2101
|
+
reap?: AgentTreeReap;
|
|
2011
2102
|
env: NodeJS.ProcessEnv | undefined;
|
|
2012
2103
|
note: (message: string) => void;
|
|
2013
2104
|
}): Promise<DoResult> {
|
|
2014
|
-
const {slug, branch, cwd, arbiter, maxAutoCheckpoints, env, note} =
|
|
2105
|
+
const {slug, branch, cwd, arbiter, maxAutoCheckpoints, reap, env, note} =
|
|
2106
|
+
params;
|
|
2107
|
+
|
|
2108
|
+
// 0. THE PREDECESSOR MUST BE DEAD BEFORE THE LOCK CAN MOVE.
|
|
2109
|
+
//
|
|
2110
|
+
// The item lock guards the ITEM; nothing guards the WORKING TREE. So if we
|
|
2111
|
+
// released the lock while the checkpointed agent's tree were still alive, the
|
|
2112
|
+
// next tick would onboard a successor into the very worktree the predecessor is
|
|
2113
|
+
// still writing to — one lock, one working tree, two live writers. That was
|
|
2114
|
+
// observed in the field: a predecessor kept writing for four minutes into its
|
|
2115
|
+
// successor's run, and its last write landed on a file the successor had already
|
|
2116
|
+
// read as clean in its opening `git status`.
|
|
2117
|
+
//
|
|
2118
|
+
// A reap we could not VERIFY is therefore a hard stop for the auto-continue
|
|
2119
|
+
// path. We still SAVE the work below (never lose work) and still surface the
|
|
2120
|
+
// item, but we do not hand the tree to anybody else.
|
|
2121
|
+
const predecessorGone = reap === undefined || reap.reaped;
|
|
2122
|
+
if (reap !== undefined && reap.escalatedToSigkill && reap.reaped) {
|
|
2123
|
+
note(`Deadline checkpoint for '${slug}': ${reap.detail}`);
|
|
2124
|
+
}
|
|
2015
2125
|
|
|
2016
2126
|
// 1. ALWAYS save the WIP first: commit any residue + push the work branch.
|
|
2017
2127
|
const savedReason = `deadline checkpoint save for '${slug}'`;
|
|
@@ -2040,7 +2150,11 @@ async function routeDeadlineCheckpoint(params: {
|
|
|
2040
2150
|
env,
|
|
2041
2151
|
});
|
|
2042
2152
|
|
|
2043
|
-
if (
|
|
2153
|
+
if (
|
|
2154
|
+
predecessorGone &&
|
|
2155
|
+
madeProgressThisSession &&
|
|
2156
|
+
checkpointCount <= maxAutoCheckpoints
|
|
2157
|
+
) {
|
|
2044
2158
|
// AUTO-CONTINUE: release the lock via the SAME default keep+continue path
|
|
2045
2159
|
// `requeue` uses (no --reset, no --reconcile, no sidecar). The branch is
|
|
2046
2160
|
// KEPT on the arbiter so the next claim continues from its tip.
|
|
@@ -2057,10 +2171,27 @@ async function routeDeadlineCheckpoint(params: {
|
|
|
2057
2171
|
note,
|
|
2058
2172
|
});
|
|
2059
2173
|
if (returned.moved) {
|
|
2174
|
+
// Derive this line from the SINGLE state `returnToBacklog` resolved, never
|
|
2175
|
+
// from an independent assumption. It used to assert "the next tick continues
|
|
2176
|
+
// from <branch>" unconditionally, which contradicted the requeue's own
|
|
2177
|
+
// "'<slug>' has no work branch on <arbiter> — nothing to continue from" line
|
|
2178
|
+
// emitted two lines earlier. Both cannot be true, and acting on the wrong one
|
|
2179
|
+
// re-drives the task from scratch and discards the saved work (observation
|
|
2180
|
+
// `checkpoint-path-reports-its-own-write-as-absent`). One resolved state, one
|
|
2181
|
+
// story: mutually contradictory lines are worse than emitting nothing.
|
|
2182
|
+
const continueFrom = returned.continueBranch;
|
|
2183
|
+
const continuation =
|
|
2184
|
+
continueFrom === undefined || continueFrom.aheadOfMain
|
|
2185
|
+
? `lock released so the next tick continues from ${continueFrom?.branch ?? branch}`
|
|
2186
|
+
: continueFrom.trustworthy
|
|
2187
|
+
? 'lock released; the arbiter has no work to continue from, so the next ' +
|
|
2188
|
+
'tick starts this item fresh'
|
|
2189
|
+
: `lock released; the arbiter could not be read to confirm ${continueFrom.branch}, ` +
|
|
2190
|
+
'so do NOT assume the work is gone — check the branch before re-driving';
|
|
2060
2191
|
const message =
|
|
2061
2192
|
`Auto-continued '${slug}' at the dorfl-internal deadline (checkpoint ` +
|
|
2062
2193
|
`${checkpointCount}/${maxAutoCheckpoints}): WIP saved + branch pushed, ` +
|
|
2063
|
-
|
|
2194
|
+
`${continuation}. ${reason}`;
|
|
2064
2195
|
note(message);
|
|
2065
2196
|
return {
|
|
2066
2197
|
exitCode: 0,
|
|
@@ -2080,14 +2211,16 @@ async function routeDeadlineCheckpoint(params: {
|
|
|
2080
2211
|
// SURFACE: mark the lock stuck via the whole applyNeedsAttentionTransition
|
|
2081
2212
|
// (save + push + stuck). The WIP was already saved above; a second save is
|
|
2082
2213
|
// idempotent (empty commit is skipped inside routeToNeedsAttention).
|
|
2083
|
-
const surfaceReason =
|
|
2084
|
-
? deadlineSurfaceReason({
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2214
|
+
const surfaceReason = !predecessorGone
|
|
2215
|
+
? deadlineSurfaceReason({slug, kind: 'unreaped', detail: reap?.detail})
|
|
2216
|
+
: madeProgressThisSession
|
|
2217
|
+
? deadlineSurfaceReason({
|
|
2218
|
+
slug,
|
|
2219
|
+
kind: 'ceiling',
|
|
2220
|
+
count: checkpointCount,
|
|
2221
|
+
max: maxAutoCheckpoints,
|
|
2222
|
+
})
|
|
2223
|
+
: deadlineSurfaceReason({slug, kind: 'no-progress'});
|
|
2091
2224
|
const routed = await ledgerWrite.applyNeedsAttentionTransition({
|
|
2092
2225
|
cwd,
|
|
2093
2226
|
slug,
|
|
@@ -2097,12 +2230,14 @@ async function routeDeadlineCheckpoint(params: {
|
|
|
2097
2230
|
note,
|
|
2098
2231
|
});
|
|
2099
2232
|
const report = routed.moved ? routeReport(routed, branch) : undefined;
|
|
2233
|
+
const why = !predecessorGone
|
|
2234
|
+
? 'the checkpointed agent could not be verified dead'
|
|
2235
|
+
: madeProgressThisSession
|
|
2236
|
+
? `ceiling ${checkpointCount}/${maxAutoCheckpoints}`
|
|
2237
|
+
: 'no progress this session';
|
|
2100
2238
|
const message = routed.moved
|
|
2101
|
-
? `Surfaced '${slug}' at the deadline checkpoint (${
|
|
2102
|
-
|
|
2103
|
-
? `ceiling ${checkpointCount}/${maxAutoCheckpoints}`
|
|
2104
|
-
: 'no progress this session'
|
|
2105
|
-
}); ${report!.fragment}. ${surfaceReason}`
|
|
2239
|
+
? `Surfaced '${slug}' at the deadline checkpoint (${why}); ` +
|
|
2240
|
+
`${report!.fragment}. ${surfaceReason}`
|
|
2106
2241
|
: `Could not surface '${slug}' at the deadline checkpoint ` +
|
|
2107
2242
|
`(${routed.reasonNotMoved ?? 'unknown'}). ${surfaceReason}`;
|
|
2108
2243
|
note(message);
|
|
@@ -2746,6 +2881,7 @@ async function runRemotePipeline(
|
|
|
2746
2881
|
detail?: string;
|
|
2747
2882
|
output?: string;
|
|
2748
2883
|
timedOut?: boolean;
|
|
2884
|
+
reap?: AgentTreeReap;
|
|
2749
2885
|
};
|
|
2750
2886
|
try {
|
|
2751
2887
|
agent = await runDoAgent(options, cwd, prompt, slug);
|
|
@@ -2770,6 +2906,7 @@ async function runRemotePipeline(
|
|
|
2770
2906
|
cwd,
|
|
2771
2907
|
arbiter: arbiterRemote,
|
|
2772
2908
|
maxAutoCheckpoints: options.maxAutoCheckpoints ?? 5,
|
|
2909
|
+
reap: agent.reap,
|
|
2773
2910
|
env,
|
|
2774
2911
|
note,
|
|
2775
2912
|
});
|
package/src/harness.ts
CHANGED
|
@@ -210,6 +210,51 @@ export interface LaunchResult {
|
|
|
210
210
|
* part from its `--format json` stream — the SAME `output` field.
|
|
211
211
|
*/
|
|
212
212
|
output?: string;
|
|
213
|
+
/**
|
|
214
|
+
* **The model OUTPUT-CAP signal** (observation
|
|
215
|
+
* `tasker-review-edits-payload-caps-the-verdict-response`): present iff the
|
|
216
|
+
* adapter detected the agent's final assistant turn was TRUNCATED at the
|
|
217
|
+
* model's output-token cap before it finished — i.e. its `stop_reason` is not a
|
|
218
|
+
* natural turn-end (`null`/`None`/`max_tokens`) AND it produced a positive
|
|
219
|
+
* `usage.output` token count. The value is that token count (the observed cap).
|
|
220
|
+
* A gate that fails to parse a verdict AND sees this signal names the failure
|
|
221
|
+
* a cap-truncation (NOT a generic parse error) so an operator does not mis-read
|
|
222
|
+
* it as a model flake and retry blindly. `undefined` when the adapter has no
|
|
223
|
+
* usage/stop_reason telemetry (the null/shell adapter) or the turn ended
|
|
224
|
+
* naturally — in which case a parse failure stays the generic `ReviewParseError`
|
|
225
|
+
* (still needs-attention, NEVER a silent approve).
|
|
226
|
+
*/
|
|
227
|
+
outputCapped?: number;
|
|
228
|
+
/**
|
|
229
|
+
* **The verified outcome of reaping the agent's process TREE** after a deadline
|
|
230
|
+
* stop (observation
|
|
231
|
+
* `checkpoint-releases-lock-while-predecessor-agent-still-writes`).
|
|
232
|
+
*
|
|
233
|
+
* Only present when {@link timedOut} is set — i.e. when the harness itself
|
|
234
|
+
* signalled the agent and therefore owes the caller PROOF that it is gone
|
|
235
|
+
* rather than merely signalled. `reaped: false` means a descendant may still be
|
|
236
|
+
* writing to the worktree, so the caller MUST NOT release the item lock or let
|
|
237
|
+
* a successor agent onboard there (see `do.ts`'s deadline routing).
|
|
238
|
+
*
|
|
239
|
+
* Absent on a normal exit (nothing was signalled, so there is nothing to prove)
|
|
240
|
+
* and on adapters that do not spawn a killable process group.
|
|
241
|
+
*/
|
|
242
|
+
reap?: AgentTreeReap;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The harness-reported result of reaping a stopped agent's process tree — the
|
|
247
|
+
* adapter-agnostic projection of `reap-agent-tree.ts`'s `ReapResult`.
|
|
248
|
+
*/
|
|
249
|
+
export interface AgentTreeReap {
|
|
250
|
+
/** True iff the tree is VERIFIED gone (observed, not merely signalled). */
|
|
251
|
+
reaped: boolean;
|
|
252
|
+
/** The process group that was reaped (the agent's group-leader pid). */
|
|
253
|
+
pgid?: number;
|
|
254
|
+
/** True iff SIGKILL was needed because the tree ignored SIGTERM. */
|
|
255
|
+
escalatedToSigkill?: boolean;
|
|
256
|
+
/** Human-readable account — the LOUD text when `reaped` is false. */
|
|
257
|
+
detail: string;
|
|
213
258
|
}
|
|
214
259
|
|
|
215
260
|
/**
|
package/src/item-lock.ts
CHANGED
|
@@ -298,10 +298,14 @@ export async function acquireItemLock(
|
|
|
298
298
|
const entry = lockEntryFor(opts.item);
|
|
299
299
|
const ref = itemLockRef(entry);
|
|
300
300
|
try {
|
|
301
|
-
// Fetch the current lock refs so the lease sees the real
|
|
301
|
+
// Fetch the current lock refs (PRUNED) so the lease sees the real arbiter
|
|
302
|
+
// state — `--prune` keeps the local `refs/dorfl/lock/*` namespace from
|
|
303
|
+
// accumulating refs the arbiter has deleted (observation
|
|
304
|
+
// `gc-ledger-reports-mirror-stale-lock-refs-as-arbiter-state`).
|
|
302
305
|
await gitHard(
|
|
303
306
|
[
|
|
304
307
|
'fetch',
|
|
308
|
+
'--prune',
|
|
305
309
|
'--quiet',
|
|
306
310
|
arbiter,
|
|
307
311
|
`+${LOCK_REF_PREFIX}/*:${LOCK_REF_PREFIX}/*`,
|
|
@@ -393,9 +397,13 @@ async function releaseLockEntry(
|
|
|
393
397
|
): Promise<ReleaseResult> {
|
|
394
398
|
const ref = itemLockRef(entry);
|
|
395
399
|
try {
|
|
400
|
+
// `--prune` so a lock already released on the arbiter reads as not-held
|
|
401
|
+
// (its stale local ref is pruned) rather than as a phantom hold
|
|
402
|
+
// (observation `gc-ledger-reports-mirror-stale-lock-refs-as-arbiter-state`).
|
|
396
403
|
await gitHard(
|
|
397
404
|
[
|
|
398
405
|
'fetch',
|
|
406
|
+
'--prune',
|
|
399
407
|
'--quiet',
|
|
400
408
|
arbiter,
|
|
401
409
|
`+${LOCK_REF_PREFIX}/*:${LOCK_REF_PREFIX}/*`,
|
|
@@ -558,8 +566,18 @@ async function fetchHeldEntry(
|
|
|
558
566
|
arbiter: string,
|
|
559
567
|
env: NodeJS.ProcessEnv | undefined,
|
|
560
568
|
): Promise<{lock: LockEntry; sha: string} | undefined> {
|
|
569
|
+
// `--prune` so a lock RELEASED on the arbiter (its ref deleted there) is PRUNED
|
|
570
|
+
// locally — otherwise the stale local ref survives and `rev-parse`/`show` below
|
|
571
|
+
// read it as still held (observation
|
|
572
|
+
// `gc-ledger-reports-mirror-stale-lock-refs-as-arbiter-state`).
|
|
561
573
|
await gitHard(
|
|
562
|
-
[
|
|
574
|
+
[
|
|
575
|
+
'fetch',
|
|
576
|
+
'--prune',
|
|
577
|
+
'--quiet',
|
|
578
|
+
arbiter,
|
|
579
|
+
`+${LOCK_REF_PREFIX}/*:${LOCK_REF_PREFIX}/*`,
|
|
580
|
+
],
|
|
563
581
|
cwd,
|
|
564
582
|
env,
|
|
565
583
|
);
|
|
@@ -903,8 +921,19 @@ export async function readItemLock(
|
|
|
903
921
|
const env = opts.env;
|
|
904
922
|
const cwd = opts.cwd;
|
|
905
923
|
const ref = itemLockRef(lockEntryFor(opts.item));
|
|
924
|
+
// `--prune` so a lock RELEASED on the arbiter (its ref deleted there) is PRUNED
|
|
925
|
+
// locally — otherwise the stale local `refs/dorfl/lock/<entry>` would survive
|
|
926
|
+
// and `git show <ref>:lock.md` below would read it as still held (observation
|
|
927
|
+
// `gc-ledger-reports-mirror-stale-lock-refs-as-arbiter-state`). After the prune,
|
|
928
|
+
// a released lock's ref is gone locally ⇒ `show` fails ⇒ `undefined` (not locked).
|
|
906
929
|
await gitHard(
|
|
907
|
-
[
|
|
930
|
+
[
|
|
931
|
+
'fetch',
|
|
932
|
+
'--prune',
|
|
933
|
+
'--quiet',
|
|
934
|
+
arbiter,
|
|
935
|
+
`+${LOCK_REF_PREFIX}/*:${LOCK_REF_PREFIX}/*`,
|
|
936
|
+
],
|
|
908
937
|
cwd,
|
|
909
938
|
env,
|
|
910
939
|
);
|
|
@@ -1052,10 +1081,13 @@ export async function reconcileItemLockAgainstMain(
|
|
|
1052
1081
|
const ref = itemLockRef(entry);
|
|
1053
1082
|
try {
|
|
1054
1083
|
// One fetch refreshes BOTH the lock refs and `<arbiter>/main` so the lock and
|
|
1055
|
-
// the durable record are read from the SAME live arbiter snapshot.
|
|
1084
|
+
// the durable record are read from the SAME live arbiter snapshot. `--prune`
|
|
1085
|
+
// keeps the local lock namespace from accumulating refs the arbiter deleted
|
|
1086
|
+
// (observation `gc-ledger-reports-mirror-stale-lock-refs-as-arbiter-state`).
|
|
1056
1087
|
await gitHard(
|
|
1057
1088
|
[
|
|
1058
1089
|
'fetch',
|
|
1090
|
+
'--prune',
|
|
1059
1091
|
'--quiet',
|
|
1060
1092
|
arbiter,
|
|
1061
1093
|
`+${LOCK_REF_PREFIX}/*:${LOCK_REF_PREFIX}/*`,
|
|
@@ -1235,9 +1267,12 @@ export async function classifyItemLockAgainstMain(
|
|
|
1235
1267
|
const entry = lockEntryFor(opts.item);
|
|
1236
1268
|
const ref = itemLockRef(entry);
|
|
1237
1269
|
try {
|
|
1270
|
+
// `--prune` keeps the local lock namespace from accumulating refs the
|
|
1271
|
+
// arbiter deleted (observation `gc-ledger-reports-mirror-stale-lock-refs-as-arbiter-state`).
|
|
1238
1272
|
await gitHard(
|
|
1239
1273
|
[
|
|
1240
1274
|
'fetch',
|
|
1275
|
+
'--prune',
|
|
1241
1276
|
'--quiet',
|
|
1242
1277
|
arbiter,
|
|
1243
1278
|
`+${LOCK_REF_PREFIX}/*:${LOCK_REF_PREFIX}/*`,
|
|
@@ -1799,8 +1834,25 @@ export async function listItemLocks(
|
|
|
1799
1834
|
arbiter = 'origin',
|
|
1800
1835
|
env?: NodeJS.ProcessEnv,
|
|
1801
1836
|
): Promise<string[]> {
|
|
1837
|
+
// `--prune` so a lock RELEASED on the arbiter (its ref deleted there) is PRUNED
|
|
1838
|
+
// locally — a bare `git fetch +refs/dorfl/lock/*:refs/dorfl/lock/*` (force, NO
|
|
1839
|
+
// `--prune`) leaves local refs the arbiter has since DELETED, so `for-each-ref`
|
|
1840
|
+
// would list locks that no longer exist (observation
|
|
1841
|
+
// `gc-ledger-reports-mirror-stale-lock-refs-as-arbiter-state`). After the pruned
|
|
1842
|
+
// fetch the local `refs/dorfl/lock/*` namespace EXACTLY matches the arbiter's, so
|
|
1843
|
+
// `for-each-ref` reads the arbiter's actual lock set. The refs are materialized
|
|
1844
|
+
// LOCALLY (not just `ls-remote`) because callers like `migrateStuckLocks` read a
|
|
1845
|
+
// lock's body via `git show <ref>:lock.md` after this. A fault THROWS (fail-closed
|
|
1846
|
+
// for the SELECTION path via {@link heldTaskSlugsStrict}; the graceful
|
|
1847
|
+
// {@link heldTaskSlugs}/{@link heldSpecSlugs} twins catch it).
|
|
1802
1848
|
await gitHard(
|
|
1803
|
-
[
|
|
1849
|
+
[
|
|
1850
|
+
'fetch',
|
|
1851
|
+
'--prune',
|
|
1852
|
+
'--quiet',
|
|
1853
|
+
arbiter,
|
|
1854
|
+
`+${LOCK_REF_PREFIX}/*:${LOCK_REF_PREFIX}/*`,
|
|
1855
|
+
],
|
|
1804
1856
|
cwd,
|
|
1805
1857
|
env,
|
|
1806
1858
|
);
|
|
@@ -1837,9 +1889,44 @@ export async function listItemLockEntries(
|
|
|
1837
1889
|
env?: NodeJS.ProcessEnv,
|
|
1838
1890
|
): Promise<LockEntry[]> {
|
|
1839
1891
|
try {
|
|
1840
|
-
|
|
1892
|
+
// Read the arbiter DIRECTLY (`git ls-remote`) for the AUTHORITATIVE lock ref
|
|
1893
|
+
// set — NOT the local `refs/dorfl/lock/*` refs (observation
|
|
1894
|
+
// `gc-ledger-reports-mirror-stale-lock-refs-as-arbiter-state`): a bare
|
|
1895
|
+
// `git fetch +refs/dorfl/lock/*:refs/dorfl/lock/*` (force, NO `--prune`)
|
|
1896
|
+
// leaves local refs the arbiter has since DELETED, so `for-each-ref` would
|
|
1897
|
+
// report locks that no longer exist — and `gc --ledger`'s entire purpose is
|
|
1898
|
+
// to name locks a human should delete, so it MUST NOT name locks that do not
|
|
1899
|
+
// exist. `ls-remote` lists ONLY the refs that ACTUALLY exist on the arbiter
|
|
1900
|
+
// right now; the report is bounded by THAT set, never by accumulated local
|
|
1901
|
+
// state. A fault degrades to an EMPTY report (US #12 — recoverable), exactly
|
|
1902
|
+
// as an absent lock-ref namespace reads; an arbiter with no locks returns
|
|
1903
|
+
// exit 0 + empty output ⇒ `[]`.
|
|
1904
|
+
const ls = await gitSoft(
|
|
1905
|
+
['ls-remote', arbiter, `${LOCK_REF_PREFIX}/*`],
|
|
1906
|
+
cwd,
|
|
1907
|
+
env,
|
|
1908
|
+
);
|
|
1909
|
+
if (ls.status !== 0) {
|
|
1910
|
+
return [];
|
|
1911
|
+
}
|
|
1912
|
+
const refs = ls.stdout
|
|
1913
|
+
.split('\n')
|
|
1914
|
+
.map((l) => l.trim())
|
|
1915
|
+
.filter((l) => l !== '')
|
|
1916
|
+
.map((l) => l.split(/\s+/)[1])
|
|
1917
|
+
.filter((ref) => ref.startsWith(`${LOCK_REF_PREFIX}/`))
|
|
1918
|
+
.sort();
|
|
1919
|
+
if (refs.length === 0) {
|
|
1920
|
+
return [];
|
|
1921
|
+
}
|
|
1922
|
+
// Materialize the objects AND keep the local `refs/dorfl/lock/*` namespace
|
|
1923
|
+
// PRUNED to match the arbiter (best-effort). The list above is already
|
|
1924
|
+
// bounded by `ls-remote`, so a fetch fault here can only UNDER-report (skip
|
|
1925
|
+
// content), never name a non-existent ref — the safe direction.
|
|
1926
|
+
await gitSoft(
|
|
1841
1927
|
[
|
|
1842
1928
|
'fetch',
|
|
1929
|
+
'--prune',
|
|
1843
1930
|
'--quiet',
|
|
1844
1931
|
arbiter,
|
|
1845
1932
|
`+${LOCK_REF_PREFIX}/*:${LOCK_REF_PREFIX}/*`,
|
|
@@ -1847,19 +1934,6 @@ export async function listItemLockEntries(
|
|
|
1847
1934
|
cwd,
|
|
1848
1935
|
env,
|
|
1849
1936
|
);
|
|
1850
|
-
const out = await gitSoft(
|
|
1851
|
-
['for-each-ref', '--format=%(refname)', `${LOCK_REF_PREFIX}/*`],
|
|
1852
|
-
cwd,
|
|
1853
|
-
env,
|
|
1854
|
-
);
|
|
1855
|
-
if (out.status !== 0) {
|
|
1856
|
-
return [];
|
|
1857
|
-
}
|
|
1858
|
-
const refs = out.stdout
|
|
1859
|
-
.split('\n')
|
|
1860
|
-
.map((l) => l.trim())
|
|
1861
|
-
.filter((l) => l.startsWith(`${LOCK_REF_PREFIX}/`))
|
|
1862
|
-
.sort();
|
|
1863
1937
|
const entries: LockEntry[] = [];
|
|
1864
1938
|
for (const ref of refs) {
|
|
1865
1939
|
const show = await gitSoft(['show', `${ref}:lock.md`], cwd, env);
|
package/src/ledger-write.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {randomUUID} from 'node:crypto';
|
|
2
2
|
import {runAsync, type RunResult} from './git.js';
|
|
3
|
+
import {refreshArbiterRefs, resolveArbiterBranch} from './arbiter-refs.js';
|
|
3
4
|
import {
|
|
4
5
|
Integrator,
|
|
5
6
|
type IntegrateResult,
|
|
@@ -560,17 +561,52 @@ export const currentLedgerWrite: LedgerWriteStrategy = {
|
|
|
560
561
|
// making" no-op, which is a LOSS. The nonce makes the two naturally
|
|
561
562
|
// distinguishable: `arbiterHead === nonced` iff WE won. So an up-to-date
|
|
562
563
|
// no-op can never satisfy this and is classified REJECTED, never published.
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
564
|
+
//
|
|
565
|
+
// The read MUST be ARBITER-AUTHORITATIVE, and used to not be: it was a
|
|
566
|
+
// plain `git fetch <arbiter>` + `rev-parse <arbiter>/main`. In the
|
|
567
|
+
// bare-hub-mirror job worktree `--isolated` runs in, that fetch does not
|
|
568
|
+
// populate `refs/remotes/<arbiter>/main` at all (the mirror refspec maps
|
|
569
|
+
// `+refs/heads/*:refs/heads/*`) and can even fail outright, so the verify
|
|
570
|
+
// compared our fresh sha against a view PREDATING our own push and declared
|
|
571
|
+
// a landed transition "not our commit ⇒ rejected" — five times per bounce,
|
|
572
|
+
// landing five identical commits and then reporting "did not land"
|
|
573
|
+
// (observation `checkpoint-path-reports-its-own-write-as-absent`). We now
|
|
574
|
+
// prune-fetch with the EXPLICIT refspec (so the objects/refs are local for
|
|
575
|
+
// any follow-up comparison) and then ask the ARBITER for the sha via
|
|
576
|
+
// `ls-remote`, which no local refspec accident can defeat.
|
|
577
|
+
await refreshArbiterRefs({cwd, arbiter, branches: ['main'], env});
|
|
578
|
+
const resolved = await resolveArbiterBranch({
|
|
579
|
+
cwd,
|
|
580
|
+
arbiter,
|
|
581
|
+
branch: 'main',
|
|
582
|
+
env,
|
|
583
|
+
});
|
|
584
|
+
if (resolved.sha === nonced) {
|
|
568
585
|
return {
|
|
569
586
|
kind: 'published',
|
|
570
587
|
message: 'transition published',
|
|
571
588
|
publishedHead: nonced,
|
|
572
589
|
};
|
|
573
590
|
}
|
|
591
|
+
if (!resolved.trustworthy) {
|
|
592
|
+
// The arbiter could not be reached, so we CANNOT tell a lost CAS from an
|
|
593
|
+
// unreadable view — and our push exited 0, which is evidence FOR landing.
|
|
594
|
+
// Reporting "rejected" here is precisely the defect (a successful write
|
|
595
|
+
// described as absent), and it also drives a retry that would duplicate
|
|
596
|
+
// the commit. Trust the green push: report published, and say why.
|
|
597
|
+
emit(
|
|
598
|
+
`push to ${arbiter}/main succeeded but the arbiter could not be re-read ` +
|
|
599
|
+
`to confirm it (${resolved.unreachableDetail ?? 'arbiter unreachable'}) ` +
|
|
600
|
+
'— trusting the successful push rather than reporting a landed write as ' +
|
|
601
|
+
'absent.',
|
|
602
|
+
);
|
|
603
|
+
return {
|
|
604
|
+
kind: 'published',
|
|
605
|
+
message:
|
|
606
|
+
'transition published (push succeeded; arbiter re-read unavailable)',
|
|
607
|
+
publishedHead: nonced,
|
|
608
|
+
};
|
|
609
|
+
}
|
|
574
610
|
emit(
|
|
575
611
|
`push reported up-to-date / no change of our making — ${arbiter}/main is not our commit — treating as rejected.`,
|
|
576
612
|
);
|