dorfl 0.11.1 → 0.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,158 @@
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { run } from './git.js';
4
+ import { pidAlive } from './harness.js';
5
+ import { processGroupAlive } from './reap-agent-tree.js';
6
+ /**
7
+ * **The per-WORKING-TREE writer sentinel** (observation
8
+ * `checkpoint-releases-lock-while-predecessor-agent-still-writes`).
9
+ *
10
+ * The per-item lock (`item-lock.ts`) guards the ITEM: it answers "who owns this
11
+ * task?" and it is what claim / requeue / bounce move around. Nothing guarded the
12
+ * WORKING TREE. Those are different resources, and the deadline checkpoint is
13
+ * exactly where they come apart: the checkpoint releases the item lock so the
14
+ * next tick can continue the task, but the tree the previous agent was editing is
15
+ * reused by the successor. If the predecessor is still alive, two agents write to
16
+ * one tree.
17
+ *
18
+ * The primary fix is to reap the predecessor and VERIFY it is gone before the
19
+ * lock moves (`reap-agent-tree.ts`). This sentinel is the INDEPENDENT backstop:
20
+ * even if a live writer survives by some route the reap did not cover (a
21
+ * deliberately `setsid`-ed grandchild, a stale run from a crashed runner, an
22
+ * operator manually re-driving an item), a second agent physically cannot onboard
23
+ * into a tree that already has a LIVE holder. It is deliberately keyed on the
24
+ * TREE, not on the item: two different items sharing one worktree is just as
25
+ * unsafe as two attempts at the same item.
26
+ *
27
+ * ## Where the sentinel lives, and why not in the tree
28
+ *
29
+ * It is written to the worktree's PRIVATE git directory (`git rev-parse
30
+ * --absolute-git-dir`, which for a linked worktree is
31
+ * `.../.git/worktrees/<name>/`), NOT to a file inside the working tree. That
32
+ * placement is load-bearing:
33
+ *
34
+ * - it is per-worktree (linked worktrees each get their own git dir), which is
35
+ * precisely the granularity we are guarding;
36
+ * - it can never appear in `git status`, so it cannot be mistaken for agent work,
37
+ * cannot be swept into a commit by a `git add -A`, and needs no new exclusion
38
+ * in the empty-diff backstop / `gc`'s cleanliness predicate (unlike
39
+ * `.dorfl-job.json`, which each of those has to filter out by name);
40
+ * - it is removed with the worktree, so it cannot outlive what it guards.
41
+ *
42
+ * ## Liveness, not presence
43
+ *
44
+ * A pid file that only records presence becomes a permanent blocker the first
45
+ * time a runner is `kill -9`ed. So the holder is checked for LIVENESS (its
46
+ * process group first, falling back to its pid) and a dead holder's sentinel is
47
+ * treated as stale and taken over. Only a genuinely live foreign writer refuses.
48
+ */
49
+ /** The sentinel filename inside the worktree's private git directory. */
50
+ export const WRITER_SENTINEL_FILENAME = 'dorfl-writer.json';
51
+ /**
52
+ * The worktree's PRIVATE git directory, or `undefined` when `dir` is not a git
53
+ * worktree (in which case there is no sentinel location and the caller proceeds
54
+ * unguarded rather than failing — this is a backstop, not a gate).
55
+ */
56
+ function worktreeGitDir(dir, env) {
57
+ const result = run('git', ['rev-parse', '--absolute-git-dir'], dir, { env });
58
+ if (result.status !== 0) {
59
+ return undefined;
60
+ }
61
+ const path = result.stdout.trim();
62
+ return path === '' ? undefined : path;
63
+ }
64
+ /** The sentinel path for `dir`, or `undefined` when `dir` is not a worktree. */
65
+ export function writerSentinelPath(dir, env) {
66
+ const gitDir = worktreeGitDir(dir, env);
67
+ return gitDir === undefined
68
+ ? undefined
69
+ : join(gitDir, WRITER_SENTINEL_FILENAME);
70
+ }
71
+ /** Read + parse the sentinel, or `undefined` when absent/corrupt. */
72
+ export function readWorktreeWriter(dir, env) {
73
+ const path = writerSentinelPath(dir, env);
74
+ if (path === undefined || !existsSync(path)) {
75
+ return undefined;
76
+ }
77
+ try {
78
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
79
+ return typeof parsed?.pid === 'number' ? parsed : undefined;
80
+ }
81
+ catch {
82
+ // A corrupt sentinel records nothing we can trust; treat it as absent so it
83
+ // self-heals on the next acquire rather than wedging the worktree forever.
84
+ return undefined;
85
+ }
86
+ }
87
+ /**
88
+ * Is the recorded holder still running? Prefers the agent's process GROUP (which
89
+ * survives the group leader's death and so catches exactly the orphaned-writer
90
+ * case this exists for), and falls back to the runner pid.
91
+ */
92
+ export function writerAlive(holder) {
93
+ if (holder.pgid !== undefined && processGroupAlive(holder.pgid)) {
94
+ return true;
95
+ }
96
+ return pidAlive(holder.pid);
97
+ }
98
+ /**
99
+ * Claim `dir` as the SOLE agent-writable working tree for `slug`.
100
+ *
101
+ * Refuses when a DIFFERENT, still-LIVE writer holds it — the second-agent case
102
+ * the observation describes. A dead holder's sentinel is stale and is taken over
103
+ * silently (a `kill -9`ed runner must not poison the worktree forever), and our
104
+ * OWN pid re-acquiring is a no-op re-entry rather than a refusal.
105
+ *
106
+ * When `dir` is not a git worktree there is nowhere private to record the
107
+ * sentinel; that is reported as acquired with a no-op release, because this is a
108
+ * defence-in-depth backstop and must never become a new way for a legitimate run
109
+ * to fail.
110
+ */
111
+ export function acquireWorktreeWriterLock(params) {
112
+ const { dir, slug, pgid, env } = params;
113
+ const path = writerSentinelPath(dir, env);
114
+ if (path === undefined) {
115
+ return { acquired: true, release: () => { } };
116
+ }
117
+ const existing = readWorktreeWriter(dir, env);
118
+ if (existing !== undefined &&
119
+ existing.pid !== process.pid &&
120
+ writerAlive(existing)) {
121
+ return {
122
+ acquired: false,
123
+ holder: existing,
124
+ reason: `worktree ${dir} already has a LIVE agent writer: pid ${existing.pid}` +
125
+ (existing.pgid !== undefined ? ` (group ${existing.pgid})` : '') +
126
+ ` building '${existing.slug}' since ${existing.startedAt}. Refusing to ` +
127
+ `onboard '${slug}' into the same working tree: two live agents in one ` +
128
+ 'tree can clobber each other’s edits, and the second can commit the ' +
129
+ 'first’s half-finished work under a message describing something else. ' +
130
+ 'Wait for it to exit, or kill it, then retry.',
131
+ };
132
+ }
133
+ const record = {
134
+ pid: process.pid,
135
+ ...(pgid !== undefined ? { pgid } : {}),
136
+ slug,
137
+ startedAt: new Date().toISOString(),
138
+ };
139
+ mkdirSync(dirname(path), { recursive: true });
140
+ writeFileSync(path, `${JSON.stringify(record, null, 2)}\n`, 'utf8');
141
+ let released = false;
142
+ return {
143
+ acquired: true,
144
+ release: () => {
145
+ if (released) {
146
+ return;
147
+ }
148
+ released = true;
149
+ // Only remove a sentinel that is still OURS: if it was stolen as stale by
150
+ // another runner, deleting it would silently un-guard that runner's tree.
151
+ const current = readWorktreeWriter(dir, env);
152
+ if (current === undefined || current.pid === process.pid) {
153
+ rmSync(path, { force: true });
154
+ }
155
+ },
156
+ };
157
+ }
158
+ //# sourceMappingURL=worktree-writer-lock.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"worktree-writer-lock.js","sourceRoot":"","sources":["../src/worktree-writer-lock.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,UAAU,EACV,SAAS,EACT,YAAY,EACZ,MAAM,EACN,aAAa,GACb,MAAM,SAAS,CAAC;AACjB,OAAO,EAAC,OAAO,EAAE,IAAI,EAAC,MAAM,WAAW,CAAC;AACxC,OAAO,EAAC,GAAG,EAAC,MAAM,UAAU,CAAC;AAC7B,OAAO,EAAC,QAAQ,EAAC,MAAM,cAAc,CAAC;AACtC,OAAO,EAAC,iBAAiB,EAAC,MAAM,sBAAsB,CAAC;AAEvD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AAEH,yEAAyE;AACzE,MAAM,CAAC,MAAM,wBAAwB,GAAG,mBAAmB,CAAC;AA6B5D;;;;GAIG;AACH,SAAS,cAAc,CACtB,GAAW,EACX,GAAkC;IAElC,MAAM,MAAM,GAAG,GAAG,CAAC,KAAK,EAAE,CAAC,WAAW,EAAE,oBAAoB,CAAC,EAAE,GAAG,EAAE,EAAC,GAAG,EAAC,CAAC,CAAC;IAC3E,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACzB,OAAO,SAAS,CAAC;IAClB,CAAC;IACD,MAAM,IAAI,GAAG,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC;IAClC,OAAO,IAAI,KAAK,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC;AACvC,CAAC;AAED,gFAAgF;AAChF,MAAM,UAAU,kBAAkB,CACjC,GAAW,EACX,GAAuB;IAEvB,MAAM,MAAM,GAAG,cAAc,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;IACxC,OAAO,MAAM,KAAK,SAAS;QAC1B,CAAC,CAAC,SAAS;QACX,CAAC,CAAC,IAAI,CAAC,MAAM,EAAE,wBAAwB,CAAC,CAAC;AAC3C,CAAC;AAED,qEAAqE;AACrE,MAAM,UAAU,kBAAkB,CACjC,GAAW,EACX,GAAuB;IAEvB,MAAM,IAAI,GAAG,kBAAkB,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;IAC1C,IAAI,IAAI,KAAK,SAAS,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,EAAE,CAAC;QAC7C,OAAO,SAAS,CAAC;IAClB,CAAC;IACD,IAAI,CAAC;QACJ,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,CAAmB,CAAC;QACxE,OAAO,OAAO,MAAM,EAAE,GAAG,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,SAAS,CAAC;IAC7D,CAAC;IAAC,MAAM,CAAC;QACR,4EAA4E;QAC5E,2EAA2E;QAC3E,OAAO,SAAS,CAAC;IAClB,CAAC;AACF,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,WAAW,CAAC,MAAsB;IACjD,IAAI,MAAM,CAAC,IAAI,KAAK,SAAS,IAAI,iBAAiB,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC;QACjE,OAAO,IAAI,CAAC;IACb,CAAC;IACD,OAAO,QAAQ,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;AAC7B,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,yBAAyB,CAAC,MAMzC;IACA,MAAM,EAAC,GAAG,EAAE,IAAI,EAAE,IAAI,EAAE,GAAG,EAAC,GAAG,MAAM,CAAC;IACtC,MAAM,IAAI,GAAG,kBAAkB,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;IAC1C,IAAI,IAAI,KAAK,SAAS,EAAE,CAAC;QACxB,OAAO,EAAC,QAAQ,EAAE,IAAI,EAAE,OAAO,EAAE,GAAG,EAAE,GAAE,CAAC,EAAC,CAAC;IAC5C,CAAC;IAED,MAAM,QAAQ,GAAG,kBAAkB,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;IAC9C,IACC,QAAQ,KAAK,SAAS;QACtB,QAAQ,CAAC,GAAG,KAAK,OAAO,CAAC,GAAG;QAC5B,WAAW,CAAC,QAAQ,CAAC,EACpB,CAAC;QACF,OAAO;YACN,QAAQ,EAAE,KAAK;YACf,MAAM,EAAE,QAAQ;YAChB,MAAM,EACL,YAAY,GAAG,yCAAyC,QAAQ,CAAC,GAAG,EAAE;gBACtE,CAAC,QAAQ,CAAC,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,WAAW,QAAQ,CAAC,IAAI,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC;gBAChE,cAAc,QAAQ,CAAC,IAAI,WAAW,QAAQ,CAAC,SAAS,gBAAgB;gBACxE,YAAY,IAAI,uDAAuD;gBACvE,qEAAqE;gBACrE,wEAAwE;gBACxE,8CAA8C;SAC/C,CAAC;IACH,CAAC;IAED,MAAM,MAAM,GAAmB;QAC9B,GAAG,EAAE,OAAO,CAAC,GAAG;QAChB,GAAG,CAAC,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,EAAC,IAAI,EAAC,CAAC,CAAC,CAAC,EAAE,CAAC;QACrC,IAAI;QACJ,SAAS,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE;KACnC,CAAC;IACF,SAAS,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,EAAC,SAAS,EAAE,IAAI,EAAC,CAAC,CAAC;IAC5C,aAAa,CAAC,IAAI,EAAE,GAAG,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;IAEpE,IAAI,QAAQ,GAAG,KAAK,CAAC;IACrB,OAAO;QACN,QAAQ,EAAE,IAAI;QACd,OAAO,EAAE,GAAS,EAAE;YACnB,IAAI,QAAQ,EAAE,CAAC;gBACd,OAAO;YACR,CAAC;YACD,QAAQ,GAAG,IAAI,CAAC;YAChB,0EAA0E;YAC1E,0EAA0E;YAC1E,MAAM,OAAO,GAAG,kBAAkB,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;YAC7C,IAAI,OAAO,KAAK,SAAS,IAAI,OAAO,CAAC,GAAG,KAAK,OAAO,CAAC,GAAG,EAAE,CAAC;gBAC1D,MAAM,CAAC,IAAI,EAAE,EAAC,KAAK,EAAE,IAAI,EAAC,CAAC,CAAC;YAC7B,CAAC;QACF,CAAC;KACD,CAAC;AACH,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dorfl",
3
- "version": "0.11.1",
3
+ "version": "0.11.2",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/wighawag/dorfl.git",
@@ -0,0 +1,222 @@
1
+ import {runAsync} from './git.js';
2
+
3
+ /**
4
+ * **The ONE arbiter-ref refresh + authoritative-read seam** (observation
5
+ * `checkpoint-path-reports-its-own-write-as-absent`).
6
+ *
7
+ * Every "did my own write land?" / "is the branch on the arbiter?" question in
8
+ * the checkpoint + surface paths used to be answered the same wrong way: run a
9
+ * PLAIN `git fetch <arbiter>`, then `git rev-parse <arbiter>/<branch>` — i.e.
10
+ * read a REMOTE-TRACKING ref (`refs/remotes/<arbiter>/…`) and trust it. That is
11
+ * unsound in the configuration dorfl itself creates for `--isolated` runs, and
12
+ * it produced two field defects where dorfl reported its OWN successful write as
13
+ * absent:
14
+ *
15
+ * 1. A job worktree is `git worktree add`ed from the BARE HUB MIRROR
16
+ * (`workspace.ts` `createJob` → `repo-mirror.ts` `ensureMirror`), whose
17
+ * `origin` carries the MIRROR-style refspec `+refs/heads/*:refs/heads/*`.
18
+ * So a plain fetch there writes `refs/heads/main`, and **never populates**
19
+ * `refs/remotes/origin/main` at all. `rev-parse origin/main` then returns
20
+ * whatever a PRIOR explicit-refspec fetch happened to leave behind — a value
21
+ * that PREDATES the write being verified. A push that genuinely landed reads
22
+ * back as "not our commit ⇒ rejected".
23
+ * 2. Worse, in that same worktree a plain `git fetch origin` **fails outright**
24
+ * (`fatal: refusing to fetch into branch 'refs/heads/work/<slug>' checked out
25
+ * at …`), because the mirror refspec's destination IS the branch the worktree
26
+ * has checked out. So it refreshes NOTHING, and a follow-up
27
+ * `rev-parse <arbiter>/work/<slug>` fails against a ref that never existed —
28
+ * reported as "no work branch on <arbiter>" while the branch (and an hour of
29
+ * agent work) sits on the arbiter.
30
+ *
31
+ * Both call sites now route through this module, which fixes the class rather
32
+ * than the two instances:
33
+ *
34
+ * - {@link refreshArbiterRefs} prune-fetches with an EXPLICIT, per-branch
35
+ * refspec into the `refs/remotes/<arbiter>/…` namespace the readers actually
36
+ * read, tolerating the checked-out-branch refusal instead of being silently
37
+ * defeated by it.
38
+ * - {@link resolveArbiterBranch} answers the sha question from the ARBITER
39
+ * ITSELF (`git ls-remote`), so no local ref-namespace/refspec accident can
40
+ * make a landed write look absent. The local tracking ref is only a FALLBACK,
41
+ * used when the arbiter cannot be reached at all.
42
+ *
43
+ * The `ls-remote`-is-authoritative stance is not new — it is the same one
44
+ * `continue-branch.ts` (`branchAheadOfArbiter`), `workspace.ts`, `integrator.ts`
45
+ * and `reap-branches.ts` already take for continue-detection and branch reaping.
46
+ * This module makes it the SHARED default for the post-write verification too,
47
+ * instead of each site re-deciding.
48
+ */
49
+
50
+ /** How a {@link ResolvedArbiterBranch} sha was obtained — the read's PROVENANCE. */
51
+ export type ArbiterRefAuthority =
52
+ /** Read from the arbiter itself (`git ls-remote`): AUTHORITATIVE. */
53
+ | 'arbiter'
54
+ /**
55
+ * The arbiter could not be reached (offline / broken remote), so the local
56
+ * remote-tracking ref was used. Best-effort: it may be stale, so a caller
57
+ * deciding "did MY write land?" must NOT treat a mismatch here as proof of
58
+ * a loss (see {@link ResolvedArbiterBranch.trustworthy}).
59
+ */
60
+ | 'local-fallback'
61
+ /** Neither the arbiter nor any local ref has this branch. */
62
+ | 'absent';
63
+
64
+ /** The resolved state of ONE branch on the arbiter (a single, coherent read). */
65
+ export interface ResolvedArbiterBranch {
66
+ /** The unqualified branch name that was resolved (e.g. `main`, `work/task-x`). */
67
+ branch: string;
68
+ /** Its sha, or `undefined` when the branch exists nowhere we could look. */
69
+ sha?: string;
70
+ /** Where {@link sha} came from. */
71
+ authority: ArbiterRefAuthority;
72
+ /**
73
+ * True iff the arbiter answered (`authority` is `arbiter` or `absent` off a
74
+ * REACHABLE arbiter). When false the read is a stale-capable local fallback,
75
+ * so a mismatch proves nothing and callers must not report a loss from it.
76
+ */
77
+ trustworthy: boolean;
78
+ /** The `ls-remote` stderr when the arbiter could not be reached (diagnostics). */
79
+ unreachableDetail?: string;
80
+ }
81
+
82
+ /** The explicit refspec that maps an arbiter branch into the namespace we READ. */
83
+ function trackingRefspec(arbiter: string, branch: string): string {
84
+ return `+refs/heads/${branch}:refs/remotes/${arbiter}/${branch}`;
85
+ }
86
+
87
+ /**
88
+ * PRUNE-FETCH the named arbiter branches into `refs/remotes/<arbiter>/<branch>`
89
+ * — the namespace every reader in this codebase actually reads — using an
90
+ * EXPLICIT per-branch refspec.
91
+ *
92
+ * Three properties matter, and all three are the reason this is not just
93
+ * `git fetch <arbiter>`:
94
+ *
95
+ * - **Explicit refspec.** A bare-hub-mirror worktree's `origin` maps
96
+ * `+refs/heads/*:refs/heads/*`, so a plain fetch never writes
97
+ * `refs/remotes/<arbiter>/*`. Naming the destination makes the refresh work
98
+ * identically in a normal clone AND in a mirror worktree.
99
+ * - **`--prune`.** A branch DELETED on the arbiter (a `requeue --reset`, a
100
+ * merge-reap, a cross-machine `gc`) must disappear from our view too;
101
+ * otherwise a stale tracking ref answers a liveness question with a ghost.
102
+ * - **Per-branch and SOFT.** Fetching branch-at-a-time means the one refspec
103
+ * git refuses (the destination that is checked out in THIS worktree — see the
104
+ * module doc) cannot abort the refresh of the others, which is exactly how the
105
+ * single combined fetch silently refreshed nothing. Every failure is
106
+ * tolerated and reported rather than thrown: this is a REFRESH, and the
107
+ * authoritative answer comes from {@link resolveArbiterBranch} anyway.
108
+ *
109
+ * Returns the branches that could not be refreshed (for diagnostics only — a
110
+ * caller should not gate on it, because the authoritative read does not depend
111
+ * on the refresh succeeding).
112
+ */
113
+ export async function refreshArbiterRefs(params: {
114
+ cwd: string;
115
+ arbiter: string;
116
+ /** Unqualified branch names to refresh (e.g. `['main', 'work/task-x']`). */
117
+ branches: readonly string[];
118
+ env?: NodeJS.ProcessEnv;
119
+ }): Promise<{failed: string[]}> {
120
+ const {cwd, arbiter, branches, env} = params;
121
+ const failed: string[] = [];
122
+ for (const branch of branches) {
123
+ const fetched = await runAsync(
124
+ 'git',
125
+ [
126
+ 'fetch',
127
+ '--quiet',
128
+ '--prune',
129
+ arbiter,
130
+ trackingRefspec(arbiter, branch),
131
+ ],
132
+ cwd,
133
+ {env},
134
+ );
135
+ if (fetched.status !== 0) {
136
+ failed.push(branch);
137
+ }
138
+ }
139
+ return {failed};
140
+ }
141
+
142
+ /**
143
+ * Resolve ONE branch's sha on the arbiter, ARBITER-AUTHORITATIVELY.
144
+ *
145
+ * `git ls-remote --heads <arbiter> <branch>` asks the arbiter directly, so the
146
+ * answer cannot be defeated by a local refspec/namespace accident — which is the
147
+ * whole point: this is the read a post-write verification uses to decide whether
148
+ * its OWN push landed, and that decision must never be made from a view that
149
+ * predates the push.
150
+ *
151
+ * Decision order:
152
+ * - `ls-remote` exits 0 with a sha ⇒ `{sha, authority: 'arbiter'}` (trustworthy).
153
+ * - `ls-remote` exits 0 with EMPTY output ⇒ the arbiter genuinely does not have
154
+ * the branch ⇒ `{authority: 'absent'}` (trustworthy: a definite "no").
155
+ * - `ls-remote` exits non-zero (unreachable / no such remote) ⇒ fall back to the
156
+ * local `refs/remotes/<arbiter>/<branch>`, flagged `trustworthy: false` so a
157
+ * caller cannot mistake a stale local read for proof of anything.
158
+ *
159
+ * Always call {@link refreshArbiterRefs} first when the caller ALSO needs the
160
+ * objects locally (a CAS base, a `merge-base` / `rev-list` comparison): a sha
161
+ * from `ls-remote` names a commit this repo may not have yet.
162
+ */
163
+ export async function resolveArbiterBranch(params: {
164
+ cwd: string;
165
+ arbiter: string;
166
+ branch: string;
167
+ env?: NodeJS.ProcessEnv;
168
+ }): Promise<ResolvedArbiterBranch> {
169
+ const {cwd, arbiter, branch, env} = params;
170
+ const ls = await runAsync(
171
+ 'git',
172
+ ['ls-remote', '--heads', arbiter, branch],
173
+ cwd,
174
+ {env},
175
+ );
176
+ if (ls.status === 0) {
177
+ // `<sha>\t<ref>` lines. `--heads <branch>` can match several refs when the
178
+ // name is a glob-ish prefix, so take the line whose ref is EXACTLY ours.
179
+ const sha = ls.stdout
180
+ .split('\n')
181
+ .map((line) => line.trim())
182
+ .filter((line) => line !== '')
183
+ .map((line) => line.split(/\s+/))
184
+ .find(([, ref]) => ref === `refs/heads/${branch}`)?.[0];
185
+ if (sha !== undefined && sha !== '') {
186
+ return {branch, sha, authority: 'arbiter', trustworthy: true};
187
+ }
188
+ // Reachable arbiter that does NOT have the branch: a definite, trustworthy
189
+ // "absent" (a stale local ref must not be able to resurrect it).
190
+ return {branch, authority: 'absent', trustworthy: true};
191
+ }
192
+ // Unreachable arbiter: best-effort local read, explicitly NOT trustworthy.
193
+ const local = await runAsync(
194
+ 'git',
195
+ [
196
+ 'rev-parse',
197
+ '--verify',
198
+ '--quiet',
199
+ `refs/remotes/${arbiter}/${branch}^{commit}`,
200
+ ],
201
+ cwd,
202
+ {env},
203
+ );
204
+ const localSha = local.status === 0 ? local.stdout.trim() : '';
205
+ const unreachableDetail =
206
+ ls.stderr.trim() || `git ls-remote exit ${ls.status}`;
207
+ if (localSha !== '') {
208
+ return {
209
+ branch,
210
+ sha: localSha,
211
+ authority: 'local-fallback',
212
+ trustworthy: false,
213
+ unreachableDetail,
214
+ };
215
+ }
216
+ return {
217
+ branch,
218
+ authority: 'absent',
219
+ trustworthy: false,
220
+ unreachableDetail,
221
+ };
222
+ }
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} = params;
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 (madeProgressThisSession && checkpointCount <= maxAutoCheckpoints) {
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
- `lock released so the next tick continues from ${branch}. ${reason}`;
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 = madeProgressThisSession
2084
- ? deadlineSurfaceReason({
2085
- slug,
2086
- kind: 'ceiling',
2087
- count: checkpointCount,
2088
- max: maxAutoCheckpoints,
2089
- })
2090
- : deadlineSurfaceReason({slug, kind: 'no-progress'});
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
- madeProgressThisSession
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
  });