dorfl 0.11.0 → 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.
Files changed (72) 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/cli.d.ts.map +1 -1
  6. package/dist/cli.js +32 -18
  7. package/dist/cli.js.map +1 -1
  8. package/dist/config.d.ts +73 -12
  9. package/dist/config.d.ts.map +1 -1
  10. package/dist/config.js +28 -0
  11. package/dist/config.js.map +1 -1
  12. package/dist/do-config.d.ts +21 -1
  13. package/dist/do-config.d.ts.map +1 -1
  14. package/dist/do-config.js +19 -0
  15. package/dist/do-config.js.map +1 -1
  16. package/dist/do.d.ts +14 -2
  17. package/dist/do.d.ts.map +1 -1
  18. package/dist/do.js +108 -14
  19. package/dist/do.js.map +1 -1
  20. package/dist/env-config.d.ts.map +1 -1
  21. package/dist/env-config.js +3 -0
  22. package/dist/env-config.js.map +1 -1
  23. package/dist/harness.d.ts +29 -0
  24. package/dist/harness.d.ts.map +1 -1
  25. package/dist/harness.js.map +1 -1
  26. package/dist/ledger-write.d.ts.map +1 -1
  27. package/dist/ledger-write.js +38 -3
  28. package/dist/ledger-write.js.map +1 -1
  29. package/dist/needs-attention.d.ts +43 -0
  30. package/dist/needs-attention.d.ts.map +1 -1
  31. package/dist/needs-attention.js +211 -50
  32. package/dist/needs-attention.js.map +1 -1
  33. package/dist/pi-harness.d.ts.map +1 -1
  34. package/dist/pi-harness.js +109 -13
  35. package/dist/pi-harness.js.map +1 -1
  36. package/dist/protocol/WORK-CONTRACT.md +11 -2
  37. package/dist/protocol/spec-template.md +1 -1
  38. package/dist/protocol/task-template.md +1 -1
  39. package/dist/reap-agent-tree.d.ts +108 -0
  40. package/dist/reap-agent-tree.d.ts.map +1 -0
  41. package/dist/reap-agent-tree.js +173 -0
  42. package/dist/reap-agent-tree.js.map +1 -0
  43. package/dist/repo-config.d.ts +1 -1
  44. package/dist/repo-config.d.ts.map +1 -1
  45. package/dist/repo-config.js +19 -2
  46. package/dist/repo-config.js.map +1 -1
  47. package/dist/run.d.ts.map +1 -1
  48. package/dist/run.js +4 -3
  49. package/dist/run.js.map +1 -1
  50. package/dist/skills/drive-tasks/SKILL.md +20 -2
  51. package/dist/skills/setup/protocol/WORK-CONTRACT.md +11 -2
  52. package/dist/skills/setup/protocol/spec-template.md +1 -1
  53. package/dist/skills/setup/protocol/task-template.md +1 -1
  54. package/dist/worktree-writer-lock.d.ts +99 -0
  55. package/dist/worktree-writer-lock.d.ts.map +1 -0
  56. package/dist/worktree-writer-lock.js +158 -0
  57. package/dist/worktree-writer-lock.js.map +1 -0
  58. package/package.json +1 -1
  59. package/src/arbiter-refs.ts +222 -0
  60. package/src/cli.ts +57 -17
  61. package/src/config.ts +90 -12
  62. package/src/do-config.ts +38 -0
  63. package/src/do.ts +158 -19
  64. package/src/env-config.ts +3 -0
  65. package/src/harness.ts +30 -0
  66. package/src/ledger-write.ts +41 -5
  67. package/src/needs-attention.ts +282 -59
  68. package/src/pi-harness.ts +109 -15
  69. package/src/reap-agent-tree.ts +221 -0
  70. package/src/repo-config.ts +19 -1
  71. package/src/run.ts +4 -3
  72. package/src/worktree-writer-lock.ts +217 -0
package/src/do-config.ts CHANGED
@@ -17,6 +17,8 @@ import type {
17
17
  export interface HarnessFlags {
18
18
  agentCmd?: string;
19
19
  model?: string;
20
+ /** `--build-model <id>` — the builder-specific model override (falls back to `--model`). */
21
+ buildModel?: string;
20
22
  harness?: string;
21
23
  piBin?: string;
22
24
  /**
@@ -43,6 +45,9 @@ export function harnessFlagOverrides(flags: HarnessFlags): PartialConfig {
43
45
  if (flags.model !== undefined) {
44
46
  overrides.model = flags.model;
45
47
  }
48
+ if (flags.buildModel !== undefined) {
49
+ overrides.buildModel = flags.buildModel;
50
+ }
46
51
  if (flags.harness === 'null' || flags.harness === 'pi') {
47
52
  overrides.harness = flags.harness;
48
53
  }
@@ -69,6 +74,7 @@ export function doFlagOverrides(
69
74
  flags: HarnessFlags &
70
75
  ReviewFlags &
71
76
  TaskerLoopFlags &
77
+ TriageModelFlags &
72
78
  FreshWorktreeGateFlags &
73
79
  SelectionOrderFlags &
74
80
  ObservationTriageFlags &
@@ -115,6 +121,10 @@ export function doFlagOverrides(
115
121
  // family from the gate's `--review*`, never sharing a flag/key/field name.
116
122
  // The flag fields bridge onto the `taskerLoop*` config keys.
117
123
  ...taskerLoopFlagOverrides(flags),
124
+ // `--triage-model <id>` (the advance lifecycle gates' model — surface, apply,
125
+ // triage) rides the SAME chain. DISTINCT from `--review-model`/
126
+ // `--tasker-loop-model`; falls back to `model` when unset at all levels.
127
+ ...triageModelFlagOverrides(flags),
118
128
  // `--fresh-worktree-gate`/`--no-fresh-worktree-gate` (run the acceptance gate
119
129
  // against the REBASED tip in a clean throwaway worktree, ON by default) rides
120
130
  // the SAME chain — a DISTINCT family from `--review*`/`--tasker-loop*`.
@@ -232,6 +242,34 @@ export function taskerLoopFlagOverrides(flags: TaskerLoopFlags): PartialConfig {
232
242
  return overrides;
233
243
  }
234
244
 
245
+ /**
246
+ * The `--triage-model` flag — the model the advance lifecycle gates (surface,
247
+ * apply, triage) run on. A single string flag, distinct from the harness
248
+ * `--model`/`--build-model` family and from `--review-model`/
249
+ * `--tasker-loop-model`. Resolved through the SAME `flag > env > per-repo >
250
+ * global > default` chain; falls back to `model` when unset at all levels (via
251
+ * `applyModelFallbacks`).
252
+ */
253
+ export interface TriageModelFlags {
254
+ /** `--triage-model <id>` — the advance lifecycle gates' model (routing intent). */
255
+ triageModel?: string;
256
+ }
257
+
258
+ /**
259
+ * Map the `--triage-model` flag into a {@link PartialConfig} override. Only
260
+ * contributes when the flag is actually present (absent flag => absent key), so
261
+ * the override layer never clobbers a lower-precedence source with `undefined`.
262
+ */
263
+ export function triageModelFlagOverrides(
264
+ flags: TriageModelFlags,
265
+ ): PartialConfig {
266
+ const overrides: PartialConfig = {};
267
+ if (flags.triageModel !== undefined) {
268
+ overrides.triageModel = flags.triageModel;
269
+ }
270
+ return overrides;
271
+ }
272
+
235
273
  /**
236
274
  * The fresh-worktree acceptance-gate CLI flag (`do`/`complete`/`run`):
237
275
  * `--fresh-worktree-gate` / `--no-fresh-worktree-gate`, the POSITIVE boolean
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 {
@@ -549,7 +577,9 @@ export interface DoOptions {
549
577
  watch?: boolean;
550
578
  /** The configured agent command the harness shells out to (null adapter). */
551
579
  agentCmd?: string;
552
- /** The model routing intent forwarded to the harness (ADR §13). */
580
+ /** The model routing intent forwarded to the harness (ADR §13). Sourced
581
+ * from `config.buildModel` (which inherits the general `config.model` when
582
+ * unset via `applyModelFallbacks`). */
553
583
  model?: string;
554
584
  /**
555
585
  * The HOST-ONLY root folder under which this run's pi session FILE is generated
@@ -1304,6 +1334,7 @@ export async function performDo(options: DoOptions): Promise<DoResult> {
1304
1334
  detail?: string;
1305
1335
  output?: string;
1306
1336
  timedOut?: boolean;
1337
+ reap?: AgentTreeReap;
1307
1338
  };
1308
1339
  try {
1309
1340
  agent = await runDoAgent(options, tree.dir, prompt, slug);
@@ -1332,6 +1363,7 @@ export async function performDo(options: DoOptions): Promise<DoResult> {
1332
1363
  cwd: tree.dir,
1333
1364
  arbiter: tree.arbiterRemote,
1334
1365
  maxAutoCheckpoints: options.maxAutoCheckpoints ?? 5,
1366
+ reap: agent.reap,
1335
1367
  env,
1336
1368
  note,
1337
1369
  });
@@ -1950,11 +1982,58 @@ async function runDoAgent(
1950
1982
  detail?: string;
1951
1983
  output?: string;
1952
1984
  timedOut?: boolean;
1985
+ reap?: AgentTreeReap;
1953
1986
  }> {
1954
1987
  if (options.dorfl) {
1955
1988
  return options.dorfl({cwd, prompt, slug, env: options.env});
1956
1989
  }
1957
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;
1958
2037
  // Convert the dorfl-internal deadline (minutes) into a wall-clock epoch-ms so
1959
2038
  // the harness (`launchAsync`) can race the child against it (spec
1960
2039
  // `graceful-pre-timeout-wip-checkpoint`). Absent ⇒ no deadline; a run that
@@ -1989,6 +2068,10 @@ async function runDoAgent(
1989
2068
  detail: launched.detail,
1990
2069
  output: launched.output,
1991
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,
1992
2075
  };
1993
2076
  }
1994
2077
 
@@ -2006,10 +2089,39 @@ async function routeDeadlineCheckpoint(params: {
2006
2089
  cwd: string;
2007
2090
  arbiter: string;
2008
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;
2009
2102
  env: NodeJS.ProcessEnv | undefined;
2010
2103
  note: (message: string) => void;
2011
2104
  }): Promise<DoResult> {
2012
- 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
+ }
2013
2125
 
2014
2126
  // 1. ALWAYS save the WIP first: commit any residue + push the work branch.
2015
2127
  const savedReason = `deadline checkpoint save for '${slug}'`;
@@ -2038,7 +2150,11 @@ async function routeDeadlineCheckpoint(params: {
2038
2150
  env,
2039
2151
  });
2040
2152
 
2041
- if (madeProgressThisSession && checkpointCount <= maxAutoCheckpoints) {
2153
+ if (
2154
+ predecessorGone &&
2155
+ madeProgressThisSession &&
2156
+ checkpointCount <= maxAutoCheckpoints
2157
+ ) {
2042
2158
  // AUTO-CONTINUE: release the lock via the SAME default keep+continue path
2043
2159
  // `requeue` uses (no --reset, no --reconcile, no sidecar). The branch is
2044
2160
  // KEPT on the arbiter so the next claim continues from its tip.
@@ -2055,10 +2171,27 @@ async function routeDeadlineCheckpoint(params: {
2055
2171
  note,
2056
2172
  });
2057
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';
2058
2191
  const message =
2059
2192
  `Auto-continued '${slug}' at the dorfl-internal deadline (checkpoint ` +
2060
2193
  `${checkpointCount}/${maxAutoCheckpoints}): WIP saved + branch pushed, ` +
2061
- `lock released so the next tick continues from ${branch}. ${reason}`;
2194
+ `${continuation}. ${reason}`;
2062
2195
  note(message);
2063
2196
  return {
2064
2197
  exitCode: 0,
@@ -2078,14 +2211,16 @@ async function routeDeadlineCheckpoint(params: {
2078
2211
  // SURFACE: mark the lock stuck via the whole applyNeedsAttentionTransition
2079
2212
  // (save + push + stuck). The WIP was already saved above; a second save is
2080
2213
  // idempotent (empty commit is skipped inside routeToNeedsAttention).
2081
- const surfaceReason = madeProgressThisSession
2082
- ? deadlineSurfaceReason({
2083
- slug,
2084
- kind: 'ceiling',
2085
- count: checkpointCount,
2086
- max: maxAutoCheckpoints,
2087
- })
2088
- : 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'});
2089
2224
  const routed = await ledgerWrite.applyNeedsAttentionTransition({
2090
2225
  cwd,
2091
2226
  slug,
@@ -2095,12 +2230,14 @@ async function routeDeadlineCheckpoint(params: {
2095
2230
  note,
2096
2231
  });
2097
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';
2098
2238
  const message = routed.moved
2099
- ? `Surfaced '${slug}' at the deadline checkpoint (${
2100
- madeProgressThisSession
2101
- ? `ceiling ${checkpointCount}/${maxAutoCheckpoints}`
2102
- : 'no progress this session'
2103
- }); ${report!.fragment}. ${surfaceReason}`
2239
+ ? `Surfaced '${slug}' at the deadline checkpoint (${why}); ` +
2240
+ `${report!.fragment}. ${surfaceReason}`
2104
2241
  : `Could not surface '${slug}' at the deadline checkpoint ` +
2105
2242
  `(${routed.reasonNotMoved ?? 'unknown'}). ${surfaceReason}`;
2106
2243
  note(message);
@@ -2744,6 +2881,7 @@ async function runRemotePipeline(
2744
2881
  detail?: string;
2745
2882
  output?: string;
2746
2883
  timedOut?: boolean;
2884
+ reap?: AgentTreeReap;
2747
2885
  };
2748
2886
  try {
2749
2887
  agent = await runDoAgent(options, cwd, prompt, slug);
@@ -2768,6 +2906,7 @@ async function runRemotePipeline(
2768
2906
  cwd,
2769
2907
  arbiter: arbiterRemote,
2770
2908
  maxAutoCheckpoints: options.maxAutoCheckpoints ?? 5,
2909
+ reap: agent.reap,
2771
2910
  env,
2772
2911
  note,
2773
2912
  });
package/src/env-config.ts CHANGED
@@ -152,6 +152,9 @@ const KEY_COERCIONS: {[K in keyof Config]?: Coercion} = {
152
152
  // A non-string / empty value is normalised at load (`validateDorflCmdConfig`).
153
153
  dorflCmd: 'string',
154
154
  model: 'string',
155
+ buildModel: 'string',
156
+ triageModel: 'string',
157
+ intakeModel: 'string',
155
158
  harness: {enum: ['null', 'pi']},
156
159
  piBin: 'string',
157
160
  sessionsDir: 'string',
package/src/harness.ts CHANGED
@@ -210,6 +210,36 @@ export interface LaunchResult {
210
210
  * part from its `--format json` stream — the SAME `output` field.
211
211
  */
212
212
  output?: string;
213
+ /**
214
+ * **The verified outcome of reaping the agent's process TREE** after a deadline
215
+ * stop (observation
216
+ * `checkpoint-releases-lock-while-predecessor-agent-still-writes`).
217
+ *
218
+ * Only present when {@link timedOut} is set — i.e. when the harness itself
219
+ * signalled the agent and therefore owes the caller PROOF that it is gone
220
+ * rather than merely signalled. `reaped: false` means a descendant may still be
221
+ * writing to the worktree, so the caller MUST NOT release the item lock or let
222
+ * a successor agent onboard there (see `do.ts`'s deadline routing).
223
+ *
224
+ * Absent on a normal exit (nothing was signalled, so there is nothing to prove)
225
+ * and on adapters that do not spawn a killable process group.
226
+ */
227
+ reap?: AgentTreeReap;
228
+ }
229
+
230
+ /**
231
+ * The harness-reported result of reaping a stopped agent's process tree — the
232
+ * adapter-agnostic projection of `reap-agent-tree.ts`'s `ReapResult`.
233
+ */
234
+ export interface AgentTreeReap {
235
+ /** True iff the tree is VERIFIED gone (observed, not merely signalled). */
236
+ reaped: boolean;
237
+ /** The process group that was reaped (the agent's group-leader pid). */
238
+ pgid?: number;
239
+ /** True iff SIGKILL was needed because the tree ignored SIGTERM. */
240
+ escalatedToSigkill?: boolean;
241
+ /** Human-readable account — the LOUD text when `reaped` is false. */
242
+ detail: string;
213
243
  }
214
244
 
215
245
  /**
@@ -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
  );