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,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **The per-WORKING-TREE writer sentinel** (observation
|
|
3
|
+
* `checkpoint-releases-lock-while-predecessor-agent-still-writes`).
|
|
4
|
+
*
|
|
5
|
+
* The per-item lock (`item-lock.ts`) guards the ITEM: it answers "who owns this
|
|
6
|
+
* task?" and it is what claim / requeue / bounce move around. Nothing guarded the
|
|
7
|
+
* WORKING TREE. Those are different resources, and the deadline checkpoint is
|
|
8
|
+
* exactly where they come apart: the checkpoint releases the item lock so the
|
|
9
|
+
* next tick can continue the task, but the tree the previous agent was editing is
|
|
10
|
+
* reused by the successor. If the predecessor is still alive, two agents write to
|
|
11
|
+
* one tree.
|
|
12
|
+
*
|
|
13
|
+
* The primary fix is to reap the predecessor and VERIFY it is gone before the
|
|
14
|
+
* lock moves (`reap-agent-tree.ts`). This sentinel is the INDEPENDENT backstop:
|
|
15
|
+
* even if a live writer survives by some route the reap did not cover (a
|
|
16
|
+
* deliberately `setsid`-ed grandchild, a stale run from a crashed runner, an
|
|
17
|
+
* operator manually re-driving an item), a second agent physically cannot onboard
|
|
18
|
+
* into a tree that already has a LIVE holder. It is deliberately keyed on the
|
|
19
|
+
* TREE, not on the item: two different items sharing one worktree is just as
|
|
20
|
+
* unsafe as two attempts at the same item.
|
|
21
|
+
*
|
|
22
|
+
* ## Where the sentinel lives, and why not in the tree
|
|
23
|
+
*
|
|
24
|
+
* It is written to the worktree's PRIVATE git directory (`git rev-parse
|
|
25
|
+
* --absolute-git-dir`, which for a linked worktree is
|
|
26
|
+
* `.../.git/worktrees/<name>/`), NOT to a file inside the working tree. That
|
|
27
|
+
* placement is load-bearing:
|
|
28
|
+
*
|
|
29
|
+
* - it is per-worktree (linked worktrees each get their own git dir), which is
|
|
30
|
+
* precisely the granularity we are guarding;
|
|
31
|
+
* - it can never appear in `git status`, so it cannot be mistaken for agent work,
|
|
32
|
+
* cannot be swept into a commit by a `git add -A`, and needs no new exclusion
|
|
33
|
+
* in the empty-diff backstop / `gc`'s cleanliness predicate (unlike
|
|
34
|
+
* `.dorfl-job.json`, which each of those has to filter out by name);
|
|
35
|
+
* - it is removed with the worktree, so it cannot outlive what it guards.
|
|
36
|
+
*
|
|
37
|
+
* ## Liveness, not presence
|
|
38
|
+
*
|
|
39
|
+
* A pid file that only records presence becomes a permanent blocker the first
|
|
40
|
+
* time a runner is `kill -9`ed. So the holder is checked for LIVENESS (its
|
|
41
|
+
* process group first, falling back to its pid) and a dead holder's sentinel is
|
|
42
|
+
* treated as stale and taken over. Only a genuinely live foreign writer refuses.
|
|
43
|
+
*/
|
|
44
|
+
/** The sentinel filename inside the worktree's private git directory. */
|
|
45
|
+
export declare const WRITER_SENTINEL_FILENAME = "dorfl-writer.json";
|
|
46
|
+
/** The recorded holder of a worktree's writer sentinel. */
|
|
47
|
+
export interface WorktreeWriter {
|
|
48
|
+
/** The runner process that owns the agent writing in this tree. */
|
|
49
|
+
pid: number;
|
|
50
|
+
/** The agent's process GROUP, when the harness spawned a killable one. */
|
|
51
|
+
pgid?: number;
|
|
52
|
+
/** The item being built in this tree (diagnostics: names the other writer). */
|
|
53
|
+
slug: string;
|
|
54
|
+
/** ISO timestamp of acquisition (diagnostics: how long it has been held). */
|
|
55
|
+
startedAt: string;
|
|
56
|
+
}
|
|
57
|
+
/** The outcome of trying to become a worktree's sole writer. */
|
|
58
|
+
export type WorktreeWriterLock = {
|
|
59
|
+
acquired: true;
|
|
60
|
+
/** Release the sentinel. Idempotent, and safe if it was already stolen. */
|
|
61
|
+
release(): void;
|
|
62
|
+
} | {
|
|
63
|
+
acquired: false;
|
|
64
|
+
/** The LIVE holder that refused us (when it could be parsed). */
|
|
65
|
+
holder?: WorktreeWriter;
|
|
66
|
+
/** Human-readable refusal, naming the other writer. */
|
|
67
|
+
reason: string;
|
|
68
|
+
};
|
|
69
|
+
/** The sentinel path for `dir`, or `undefined` when `dir` is not a worktree. */
|
|
70
|
+
export declare function writerSentinelPath(dir: string, env?: NodeJS.ProcessEnv): string | undefined;
|
|
71
|
+
/** Read + parse the sentinel, or `undefined` when absent/corrupt. */
|
|
72
|
+
export declare function readWorktreeWriter(dir: string, env?: NodeJS.ProcessEnv): WorktreeWriter | undefined;
|
|
73
|
+
/**
|
|
74
|
+
* Is the recorded holder still running? Prefers the agent's process GROUP (which
|
|
75
|
+
* survives the group leader's death and so catches exactly the orphaned-writer
|
|
76
|
+
* case this exists for), and falls back to the runner pid.
|
|
77
|
+
*/
|
|
78
|
+
export declare function writerAlive(holder: WorktreeWriter): boolean;
|
|
79
|
+
/**
|
|
80
|
+
* Claim `dir` as the SOLE agent-writable working tree for `slug`.
|
|
81
|
+
*
|
|
82
|
+
* Refuses when a DIFFERENT, still-LIVE writer holds it — the second-agent case
|
|
83
|
+
* the observation describes. A dead holder's sentinel is stale and is taken over
|
|
84
|
+
* silently (a `kill -9`ed runner must not poison the worktree forever), and our
|
|
85
|
+
* OWN pid re-acquiring is a no-op re-entry rather than a refusal.
|
|
86
|
+
*
|
|
87
|
+
* When `dir` is not a git worktree there is nowhere private to record the
|
|
88
|
+
* sentinel; that is reported as acquired with a no-op release, because this is a
|
|
89
|
+
* defence-in-depth backstop and must never become a new way for a legitimate run
|
|
90
|
+
* to fail.
|
|
91
|
+
*/
|
|
92
|
+
export declare function acquireWorktreeWriterLock(params: {
|
|
93
|
+
dir: string;
|
|
94
|
+
slug: string;
|
|
95
|
+
/** The agent's process group, when known (the strongest liveness anchor). */
|
|
96
|
+
pgid?: number;
|
|
97
|
+
env?: NodeJS.ProcessEnv;
|
|
98
|
+
}): WorktreeWriterLock;
|
|
99
|
+
//# sourceMappingURL=worktree-writer-lock.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"worktree-writer-lock.d.ts","sourceRoot":"","sources":["../src/worktree-writer-lock.ts"],"names":[],"mappings":"AAYA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AAEH,yEAAyE;AACzE,eAAO,MAAM,wBAAwB,sBAAsB,CAAC;AAE5D,2DAA2D;AAC3D,MAAM,WAAW,cAAc;IAC9B,mEAAmE;IACnE,GAAG,EAAE,MAAM,CAAC;IACZ,0EAA0E;IAC1E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,+EAA+E;IAC/E,IAAI,EAAE,MAAM,CAAC;IACb,6EAA6E;IAC7E,SAAS,EAAE,MAAM,CAAC;CAClB;AAED,gEAAgE;AAChE,MAAM,MAAM,kBAAkB,GAC3B;IACA,QAAQ,EAAE,IAAI,CAAC;IACf,2EAA2E;IAC3E,OAAO,IAAI,IAAI,CAAC;CACf,GACD;IACA,QAAQ,EAAE,KAAK,CAAC;IAChB,iEAAiE;IACjE,MAAM,CAAC,EAAE,cAAc,CAAC;IACxB,uDAAuD;IACvD,MAAM,EAAE,MAAM,CAAC;CACd,CAAC;AAmBL,gFAAgF;AAChF,wBAAgB,kBAAkB,CACjC,GAAG,EAAE,MAAM,EACX,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,GACrB,MAAM,GAAG,SAAS,CAKpB;AAED,qEAAqE;AACrE,wBAAgB,kBAAkB,CACjC,GAAG,EAAE,MAAM,EACX,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,GACrB,cAAc,GAAG,SAAS,CAa5B;AAED;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE,cAAc,GAAG,OAAO,CAK3D;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,yBAAyB,CAAC,MAAM,EAAE;IACjD,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,6EAA6E;IAC7E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;CACxB,GAAG,kBAAkB,CAoDrB"}
|
|
@@ -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":"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"}
|
package/package.json
CHANGED
|
@@ -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
|
+
}
|