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.
Files changed (67) hide show
  1. package/dist/arbiter-refs.d.ts +139 -0
  2. package/dist/arbiter-refs.d.ts.map +1 -0
  3. package/dist/arbiter-refs.js +114 -0
  4. package/dist/arbiter-refs.js.map +1 -0
  5. package/dist/do.d.ts +11 -1
  6. package/dist/do.d.ts.map +1 -1
  7. package/dist/do.js +108 -14
  8. package/dist/do.js.map +1 -1
  9. package/dist/harness.d.ts +44 -0
  10. package/dist/harness.d.ts.map +1 -1
  11. package/dist/harness.js.map +1 -1
  12. package/dist/item-lock.d.ts.map +1 -1
  13. package/dist/item-lock.js +89 -15
  14. package/dist/item-lock.js.map +1 -1
  15. package/dist/ledger-write.d.ts.map +1 -1
  16. package/dist/ledger-write.js +38 -3
  17. package/dist/ledger-write.js.map +1 -1
  18. package/dist/needs-attention.d.ts +43 -0
  19. package/dist/needs-attention.d.ts.map +1 -1
  20. package/dist/needs-attention.js +211 -50
  21. package/dist/needs-attention.js.map +1 -1
  22. package/dist/pi-harness.d.ts.map +1 -1
  23. package/dist/pi-harness.js +133 -26
  24. package/dist/pi-harness.js.map +1 -1
  25. package/dist/protocol/REVIEW-PROTOCOL.md +1 -1
  26. package/dist/reap-agent-tree.d.ts +108 -0
  27. package/dist/reap-agent-tree.d.ts.map +1 -0
  28. package/dist/reap-agent-tree.js +173 -0
  29. package/dist/reap-agent-tree.js.map +1 -0
  30. package/dist/repo-mirror.d.ts.map +1 -1
  31. package/dist/repo-mirror.js +17 -0
  32. package/dist/repo-mirror.js.map +1 -1
  33. package/dist/review-verdict.d.ts +46 -2
  34. package/dist/review-verdict.d.ts.map +1 -1
  35. package/dist/review-verdict.js +49 -3
  36. package/dist/review-verdict.js.map +1 -1
  37. package/dist/skills/setup/protocol/REVIEW-PROTOCOL.md +1 -1
  38. package/dist/tasker-review-loop.d.ts +13 -0
  39. package/dist/tasker-review-loop.d.ts.map +1 -1
  40. package/dist/tasker-review-loop.js +120 -8
  41. package/dist/tasker-review-loop.js.map +1 -1
  42. package/dist/tasking.d.ts.map +1 -1
  43. package/dist/tasking.js +161 -17
  44. package/dist/tasking.js.map +1 -1
  45. package/dist/watch-session.d.ts +35 -4
  46. package/dist/watch-session.d.ts.map +1 -1
  47. package/dist/watch-session.js +54 -7
  48. package/dist/watch-session.js.map +1 -1
  49. package/dist/worktree-writer-lock.d.ts +99 -0
  50. package/dist/worktree-writer-lock.d.ts.map +1 -0
  51. package/dist/worktree-writer-lock.js +158 -0
  52. package/dist/worktree-writer-lock.js.map +1 -0
  53. package/package.json +1 -1
  54. package/src/arbiter-refs.ts +222 -0
  55. package/src/do.ts +155 -18
  56. package/src/harness.ts +45 -0
  57. package/src/item-lock.ts +93 -19
  58. package/src/ledger-write.ts +41 -5
  59. package/src/needs-attention.ts +282 -59
  60. package/src/pi-harness.ts +136 -28
  61. package/src/reap-agent-tree.ts +221 -0
  62. package/src/repo-mirror.ts +22 -0
  63. package/src/review-verdict.ts +73 -5
  64. package/src/tasker-review-loop.ts +129 -7
  65. package/src/tasking.ts +198 -16
  66. package/src/watch-session.ts +83 -7
  67. 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} = 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
  });
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 state.
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
- ['fetch', '--quiet', arbiter, `+${LOCK_REF_PREFIX}/*:${LOCK_REF_PREFIX}/*`],
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
- ['fetch', '--quiet', arbiter, `+${LOCK_REF_PREFIX}/*:${LOCK_REF_PREFIX}/*`],
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
- ['fetch', '--quiet', arbiter, `+${LOCK_REF_PREFIX}/*:${LOCK_REF_PREFIX}/*`],
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
- await gitHard(
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);
@@ -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
- await gitHard(['fetch', '--quiet', arbiter], cwd, env);
564
- const arbiterHead = (
565
- await gitHard(['rev-parse', `${arbiter}/main`], cwd, env)
566
- ).stdout.trim();
567
- if (arbiterHead === nonced) {
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
  );