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.
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
  );
@@ -24,8 +24,10 @@ import {
24
24
  } from './item-lock.js';
25
25
  import {ledgerWrite, type LedgerTransitionKind} from './ledger-write.js';
26
26
  import {workBranchRef} from './slug-namespace.js';
27
+ import {refreshArbiterRefs, resolveArbiterBranch} from './arbiter-refs.js';
27
28
  import {
28
29
  appendQuestions,
30
+ isEntryAnswered,
29
31
  newSidecar,
30
32
  parseSidecar,
31
33
  resolveSidecarIdentity,
@@ -247,6 +249,50 @@ export interface ReturnToBacklogResult {
247
249
  reconciled?: boolean;
248
250
  /** When NOT moved, why (e.g. the slug held no recoverable per-item lock on the arbiter, or a failed --reset delete). */
249
251
  reasonNotMoved?: string;
252
+ /**
253
+ * **The ONE resolved continue-branch state** this requeue decided from — so a
254
+ * CALLER reports the same reality the requeue acted on instead of running its
255
+ * own second probe (observation
256
+ * `checkpoint-path-reports-its-own-write-as-absent`).
257
+ *
258
+ * The deadline checkpoint used to print two lines from two independent probes
259
+ * that disagreed inside the same second: `returnToBacklog` said "'<slug>' has no
260
+ * work branch on origin — nothing to continue from", and the caller then said
261
+ * "the next tick continues from work/task-<slug>". Both cannot be true, and
262
+ * acting on the first one discards the branch's work. Publishing the resolved
263
+ * state here removes the second probe entirely: there is one answer, and every
264
+ * message is derived from it.
265
+ *
266
+ * Absent only when no continue-branch question was asked (the `--reset` path,
267
+ * which discards the branch by design, or an early refusal).
268
+ */
269
+ continueBranch?: ResolvedContinueBranch;
270
+ }
271
+
272
+ /**
273
+ * The resolved state of the kept `work/<slug>` continue-branch on the arbiter, as
274
+ * decided ONCE by {@link returnToBacklog} (see
275
+ * {@link ReturnToBacklogResult.continueBranch}).
276
+ */
277
+ export interface ResolvedContinueBranch {
278
+ /** The unqualified branch name (e.g. `work/task-<slug>`). */
279
+ branch: string;
280
+ /** True iff the arbiter HAS this branch (arbiter-authoritative `ls-remote`). */
281
+ present: boolean;
282
+ /** Its tip sha on the arbiter, when present. */
283
+ sha?: string;
284
+ /**
285
+ * True iff the branch is present AND carries commits `<arbiter>/main` lacks —
286
+ * i.e. there IS work to continue from. False when absent, or present but fully
287
+ * merged (nothing to resume).
288
+ */
289
+ aheadOfMain: boolean;
290
+ /**
291
+ * False when the arbiter could not be reached, so {@link present} is a
292
+ * stale-capable local read. A caller MUST NOT report "nothing to continue from"
293
+ * off an untrustworthy read — that is exactly the defect.
294
+ */
295
+ trustworthy: boolean;
250
296
  }
251
297
 
252
298
  export interface SurfaceToNeedsAttentionOptions {
@@ -591,7 +637,23 @@ export async function returnToBacklog(
591
637
  // Refresh the remote-tracking refs so every check below (the item's residence,
592
638
  // the continue-branch guard, the CAS base) sees the arbiter's TRUTH, not a stale
593
639
  // local copy. This is a fetch, not a checkout — the working tree is untouched.
594
- await gitSoftAsync(['fetch', '--quiet', arbiter], cwd, env);
640
+ //
641
+ // This used to be a PLAIN `git fetch <arbiter>`, which is precisely how the
642
+ // deadline checkpoint came to announce "no work branch on origin" over a branch
643
+ // holding an hour of work: in the bare-hub-mirror job worktree an `--isolated`
644
+ // run uses, that fetch does not populate `refs/remotes/<arbiter>/*` (the mirror
645
+ // refspec maps `+refs/heads/*:refs/heads/*`) and in fact FAILS outright
646
+ // (`refusing to fetch into branch 'refs/heads/work/<slug>' checked out at …`),
647
+ // so it refreshed nothing and the guard below read a ref that never existed.
648
+ // The shared helper prune-fetches per branch with the EXPLICIT destination
649
+ // refspec, tolerating that one refusal instead of being defeated by it.
650
+ const continueBranchName = workBranchRef('task', slug);
651
+ await refreshArbiterRefs({
652
+ cwd,
653
+ arbiter,
654
+ branches: ['main', continueBranchName],
655
+ env,
656
+ });
595
657
 
596
658
  // Is the item LOCK-HELD on the arbiter? (task
597
659
  // `cutover-needs-attention-becomes-lock-stuck-recovery-surface`, decision i+:
@@ -729,8 +791,34 @@ export async function returnToBacklog(
729
791
  // in `isolation.ts`. We check the ARBITER ref (already fetched above), NOT the
730
792
  // local `work/<slug>` (which SURVIVES a failed push). NOT on `--reset` (which
731
793
  // discards the branch by design).
794
+ let continueBranch: ResolvedContinueBranch | undefined;
732
795
  if (!options.reset) {
733
- const branch = workBranchRef('task', slug);
796
+ const branch = continueBranchName;
797
+ // Resolve the continue-branch state EXACTLY ONCE, ARBITER-AUTHORITATIVELY, and
798
+ // reuse that single answer for the guard decision, the note, AND the caller's
799
+ // report (`result.continueBranch`). Previously this read the local tracking ref
800
+ // `<arbiter>/work/<slug>` — a ref the bare-mirror job worktree never has — so
801
+ // it answered "absent" for a branch that was sitting on the arbiter, and the
802
+ // caller's own separate probe then contradicted it in the very next line.
803
+ const resolved = await resolveArbiterBranch({cwd, arbiter, branch, env});
804
+ const present = resolved.sha !== undefined;
805
+ // AHEAD-of-main only makes sense when the branch is present. `refreshArbiterRefs`
806
+ // above put the objects + tracking ref in place, so the comparison is local;
807
+ // fall back to the arbiter-reported sha when the tracking ref is still missing
808
+ // (e.g. the refspec git refused because the branch is checked out HERE — in
809
+ // which case the local head of the same name IS the branch).
810
+ const aheadOfMain = present
811
+ ? branchAheadOf(cwd, `${arbiter}/${branch}`, `${arbiter}/main`, env) ||
812
+ branchAheadOf(cwd, resolved.sha!, `${arbiter}/main`, env)
813
+ : false;
814
+ continueBranch = {
815
+ branch,
816
+ present,
817
+ ...(resolved.sha !== undefined ? {sha: resolved.sha} : {}),
818
+ aheadOfMain,
819
+ trustworthy: resolved.trustworthy,
820
+ };
821
+
734
822
  // Split the guard into TWO cases (task
735
823
  // `default-requeue-succeeds-when-no-work-branch-exists`):
736
824
  // (a) the arbiter branch does NOT EXIST at all (never pushed, or a prior
@@ -743,33 +831,29 @@ export async function returnToBacklog(
743
831
  // (b) the arbiter branch EXISTS but is NOT ahead of `<arbiter>/main` — a
744
832
  // real anomaly (the continue-branch would resume from a state already
745
833
  // reachable from main). Preserve today's refusal so the case surfaces.
746
- const tip = gitSoftRun(
747
- ['rev-parse', '--verify', '--quiet', `${arbiter}/${branch}^{commit}`],
748
- cwd,
749
- env,
750
- );
751
- const arbiterBranchExists = tip.status === 0 && tip.stdout.trim() !== '';
752
- if (!arbiterBranchExists) {
834
+ if (!present) {
835
+ // Say "nothing to continue from" ONLY off a read the arbiter actually
836
+ // answered. On an unreachable arbiter we cannot know, and claiming a branch
837
+ // is absent is the dangerous direction (an operator or wrapper acting on it
838
+ // re-drives the task from scratch and discards the saved work).
753
839
  note(
754
- `'${slug}' has no work branch on ${arbiter} — requeueing to backlog ` +
755
- 'for a FRESH claim (nothing to continue from; no --reset needed).',
840
+ resolved.trustworthy
841
+ ? `'${slug}' has no work branch on ${arbiter} — requeueing to backlog ` +
842
+ 'for a FRESH claim (nothing to continue from; no --reset needed).'
843
+ : `'${slug}': could not read ${arbiter} to tell whether a work branch ` +
844
+ `exists (${resolved.unreachableDetail ?? 'arbiter unreachable'}) — ` +
845
+ 'requeueing to backlog WITHOUT asserting there is nothing to ' +
846
+ 'continue from. Do NOT re-drive from scratch until the branch has ' +
847
+ 'been checked.',
756
848
  );
757
- } else {
758
- const onArbiter = branchAheadOf(
759
- cwd,
760
- `${arbiter}/${branch}`,
761
- `${arbiter}/main`,
762
- env,
763
- );
764
- if (!onArbiter) {
765
- const message =
766
- `the work branch ${branch} isn't on ${arbiter} (the continue ` +
767
- `branch a cross-machine worker would resume from) — push it first, or ` +
768
- '`requeue --reset` to discard and start fresh. Item left stuck (lock not ' +
769
- 'released).';
770
- note(message);
771
- return {moved: false, reasonNotMoved: message};
772
- }
849
+ } else if (!aheadOfMain) {
850
+ const message =
851
+ `the work branch ${branch} isn't on ${arbiter} (the continue ` +
852
+ `branch a cross-machine worker would resume from) — push it first, or ` +
853
+ '`requeue --reset` to discard and start fresh. Item left stuck (lock not ' +
854
+ 'released).';
855
+ note(message);
856
+ return {moved: false, reasonNotMoved: message, continueBranch};
773
857
  }
774
858
  }
775
859
 
@@ -856,12 +940,23 @@ export async function returnToBacklog(
856
940
  `requeue for '${slug}': could not release the per-item lock ` +
857
941
  `(${released.message}). The item is left stuck. Try again shortly.`;
858
942
  note(message);
859
- return {moved: false, reasonNotMoved: message};
943
+ return {moved: false, reasonNotMoved: message, continueBranch};
860
944
  }
945
+ // Derive the closing line from the SAME resolved state the guard used, so this
946
+ // note can never contradict the one above it.
861
947
  note(
862
- `Returned '${slug}' to backlog (released the lock; body rests in pool).`,
948
+ `Returned '${slug}' to backlog (released the lock; body rests in pool)` +
949
+ (continueBranch?.aheadOfMain === true
950
+ ? `; the next claim continues from ${continueBranch.branch}.`
951
+ : '.'),
863
952
  );
864
- return {moved: true, commitMessage, deletedRemoteBranch, reconciled};
953
+ return {
954
+ moved: true,
955
+ commitMessage,
956
+ deletedRemoteBranch,
957
+ reconciled,
958
+ continueBranch,
959
+ };
865
960
  }
866
961
 
867
962
  /**
@@ -1528,18 +1623,22 @@ async function runTreelessLedgerMove(params: {
1528
1623
  env,
1529
1624
  note,
1530
1625
  } = params;
1531
- const fetchArgs = explicitMainRefspec
1532
- ? [
1533
- 'fetch',
1534
- '--quiet',
1535
- arbiter,
1536
- `+refs/heads/main:refs/remotes/${arbiter}/main`,
1537
- ]
1538
- : ['fetch', '--quiet', arbiter];
1626
+ // `explicitMainRefspec` is now VESTIGIAL: the shared refresh below always uses
1627
+ // the explicit per-branch refspec, because the plain `git fetch <arbiter>` the
1628
+ // `false` case used is exactly what made the surface path read a view PREDATING
1629
+ // its own write (and, in a bare-mirror job worktree, fail outright) — see
1630
+ // `arbiter-refs.ts` and observation
1631
+ // `checkpoint-path-reports-its-own-write-as-absent`. It is kept in the signature
1632
+ // only so the two call sites stay explicit about which direction they are; the
1633
+ // requeue direction refreshes its OTHER refs (the continue-branch guard) itself.
1634
+ void explicitMainRefspec;
1635
+ const refreshMain = async (): Promise<void> => {
1636
+ await refreshArbiterRefs({cwd, arbiter, branches: ['main'], env});
1637
+ };
1539
1638
 
1540
1639
  for (let i = 0; i < TREELESS_CONTENTION_ATTEMPTS; i++) {
1541
1640
  if (i > 0) {
1542
- await gitSoftAsync(fetchArgs, cwd, env);
1641
+ await refreshMain();
1543
1642
  }
1544
1643
  const base = (
1545
1644
  await gitHardAsync(['rev-parse', `${arbiter}/main`], cwd, env)
@@ -1556,6 +1655,24 @@ async function runTreelessLedgerMove(params: {
1556
1655
  return false;
1557
1656
  }
1558
1657
 
1658
+ // COMMIT-LEVEL IDEMPOTENCE (observation
1659
+ // `checkpoint-path-reports-its-own-write-as-absent`): if the planned commit's
1660
+ // TREE is identical to the base's, this transition has NOTHING to write —
1661
+ // whatever it wanted to say is already on `main`. Publishing it anyway appends
1662
+ // an empty commit, which is how one bounce turned into five identical commits
1663
+ // (the retry budget, not anything real, set the commit count). An empty diff is
1664
+ // the DESIRED end state, so report landed and push nothing.
1665
+ const baseTree = (
1666
+ await gitHardAsync(['rev-parse', `${base}^{tree}`], cwd, env)
1667
+ ).stdout.trim();
1668
+ const preparedTree = (
1669
+ await gitHardAsync(['rev-parse', `${prepared.commit}^{tree}`], cwd, env)
1670
+ ).stdout.trim();
1671
+ if (preparedTree === baseTree) {
1672
+ await gitSoftAsync(['update-ref', '-d', prepared.ref], cwd, env);
1673
+ return true;
1674
+ }
1675
+
1559
1676
  // Publish THROUGH the shared seam (the same `:main` push + force-with-lease +
1560
1677
  // verify `claim` uses). The transition's WHO stays the caller's ambient env
1561
1678
  // (threaded by `commit-tree` above) — tree-less is orthogonal to attribution.
@@ -1575,7 +1692,7 @@ async function runTreelessLedgerMove(params: {
1575
1692
  if (result.kind === 'published') {
1576
1693
  // Advance the LOCAL remote-tracking `<arbiter>/main` so it INCLUDES the
1577
1694
  // move (the push only moved the arbiter's main). Best-effort.
1578
- await gitSoftAsync(fetchArgs, cwd, env);
1695
+ await refreshMain();
1579
1696
  return true;
1580
1697
  }
1581
1698
  // rejected: main moved under us — refetch + REPLAN against the new base.
@@ -1676,6 +1793,105 @@ function prepareTreelessMoveCommit(params: {
1676
1793
  }
1677
1794
  }
1678
1795
 
1796
+ /**
1797
+ * Build the ENGINE-AUTHORED envelope entry for a bounce — the one entry every
1798
+ * bounce always surfaces, so a reason-only bounce still leaves exactly one
1799
+ * human-answerable question.
1800
+ *
1801
+ * Callers may OVERRIDE it (e.g. the empty-diff path swaps in a dispose-defaulted
1802
+ * question); the override still defaults `kind` to `stuck` and `context` to the
1803
+ * bounce reason when it leaves them unset (so a caller can restate the reason in
1804
+ * the envelope prose without duplicating it in `context`).
1805
+ *
1806
+ * Extracted from {@link prepareTreelessSurfaceCommit} so the idempotence probe
1807
+ * ({@link bounceAlreadySurfaced}) compares against the EXACT entry the surface
1808
+ * would write. If the two ever derived the envelope independently they could
1809
+ * drift, and the de-duplication would silently stop de-duplicating — which is
1810
+ * the whole defect it exists to prevent.
1811
+ */
1812
+ function buildBounceEnvelope(params: {
1813
+ item: string;
1814
+ reason: string;
1815
+ envelope?: NewQuestion;
1816
+ }): NewQuestion {
1817
+ const {item, reason, envelope: override} = params;
1818
+ if (override) {
1819
+ return {
1820
+ kind: override.kind ?? 'stuck',
1821
+ question: override.question,
1822
+ context: override.context ?? reason,
1823
+ ...(override.default !== undefined ? {default: override.default} : {}),
1824
+ };
1825
+ }
1826
+ return {
1827
+ question: `'${item}' was bounced — how should we proceed?`,
1828
+ context: reason,
1829
+ kind: 'stuck',
1830
+ };
1831
+ }
1832
+
1833
+ /**
1834
+ * Is this EXACT bounce already surfaced (and still awaiting a human) on `base`?
1835
+ *
1836
+ * The surface-level half of the idempotence fix (observation
1837
+ * `checkpoint-path-reports-its-own-write-as-absent`): the generic empty-tree
1838
+ * short-circuit in {@link runTreelessLedgerMove} cannot catch a re-surface,
1839
+ * because {@link appendQuestions} always mints a NEW entry id — so re-running a
1840
+ * surface that already landed produced a genuinely different tree, and therefore
1841
+ * an additional commit. Five retries ⇒ five commits, i.e. the commit count scaled
1842
+ * with the retry budget rather than with anything real.
1843
+ *
1844
+ * "Already surfaced" is defined narrowly and precisely: the item body already
1845
+ * carries `needsAnswers: true` AND **every** entry this surface would append (the
1846
+ * engine envelope plus any agent-surfaced questions) is ALREADY present as an
1847
+ * UNANSWERED entry with the same `question` + `context`. Each clause matters:
1848
+ *
1849
+ * - Requiring `needsAnswers: true` keeps the `needsAnswers ⟺ sidecar` invariant
1850
+ * intact: if the flag is somehow missing, we still write (and repair it).
1851
+ * - Requiring EVERY addition to be present means a bounce carrying NEW
1852
+ * agent-surfaced questions is never swallowed just because its envelope
1853
+ * matches. Partial overlap writes; only a TOTAL match is a no-op.
1854
+ * - Requiring the matching entries to be UNANSWERED keeps this a
1855
+ * de-duplication rather than a swallow. A human who ANSWERED this exact
1856
+ * question and let the work resume MUST be told again when the same failure
1857
+ * recurs — that is a NEW bounce, and it surfaces normally. Only identical,
1858
+ * still-pending questions are suppressed, and a duplicate of a question
1859
+ * nobody has answered yet adds noise, never information.
1860
+ */
1861
+ function bounceAlreadySurfaced(params: {
1862
+ base: string;
1863
+ itemPath: string;
1864
+ sidecarPath: string;
1865
+ /** Every entry the surface would append (envelope first, then agent questions). */
1866
+ additions: readonly NewQuestion[];
1867
+ cwd: string;
1868
+ env: NodeJS.ProcessEnv | undefined;
1869
+ }): boolean {
1870
+ const {base, itemPath, sidecarPath, additions, cwd, env} = params;
1871
+ if (!pathInCommit(base, sidecarPath, cwd, env)) {
1872
+ return false;
1873
+ }
1874
+ try {
1875
+ const body = catBlob(`${base}:${itemPath}`, cwd, env);
1876
+ if (parseFrontmatter(body).needsAnswers !== true) {
1877
+ return false;
1878
+ }
1879
+ const model = parseSidecar(catBlob(`${base}:${sidecarPath}`, cwd, env));
1880
+ const pending = model.entries.filter((entry) => !isEntryAnswered(entry));
1881
+ return additions.every((addition) =>
1882
+ pending.some(
1883
+ (entry) =>
1884
+ entry.question === addition.question &&
1885
+ entry.context === (addition.context ?? ''),
1886
+ ),
1887
+ );
1888
+ } catch {
1889
+ // Unreadable body/sidecar ⇒ do NOT claim it is already surfaced; fall through
1890
+ // and let the normal surface path run (the safe direction: record the bounce).
1891
+ return false;
1892
+ }
1893
+ }
1894
+
1679
1895
  /** The heading that opens an appended requeue handoff note in the item body. */
1680
1896
  const REQUEUE_HEADING_PREFIX = '## Requeue';
1681
1897
 
@@ -1809,25 +2025,12 @@ export function prepareTreelessSurfaceCommit(params: {
1809
2025
 
1810
2026
  // Compose the entries: an engine-authored envelope carrying the reason, then
1811
2027
  // any agent-surfaced questions (stamped `stuck`-kind if the caller left the
1812
- // kind unset — this IS the stuck-surface path). Callers may OVERRIDE the
1813
- // envelope (e.g. the empty-diff path swaps in a dispose-defaulted question);
1814
- // the override still defaults `kind` to `stuck` and `context` to the bounce
1815
- // reason when the override leaves them unset (the caller can restate the
1816
- // reason in the envelope prose without duplicating it in `context`).
1817
- const envelope: NewQuestion = envelopeOverride
1818
- ? {
1819
- kind: envelopeOverride.kind ?? 'stuck',
1820
- question: envelopeOverride.question,
1821
- context: envelopeOverride.context ?? reason,
1822
- ...(envelopeOverride.default !== undefined
1823
- ? {default: envelopeOverride.default}
1824
- : {}),
1825
- }
1826
- : {
1827
- question: `'${item}' was bounced — how should we proceed?`,
1828
- context: reason,
1829
- kind: 'stuck',
1830
- };
2028
+ // kind unset — this IS the stuck-surface path).
2029
+ const envelope = buildBounceEnvelope({
2030
+ item,
2031
+ reason,
2032
+ envelope: envelopeOverride,
2033
+ });
1831
2034
  const surfaced: NewQuestion[] = (questions ?? []).map((q) => ({
1832
2035
  ...q,
1833
2036
  kind: q.kind ?? 'stuck',
@@ -2023,6 +2226,26 @@ export async function surfaceStuckToNeedsAttention(
2023
2226
  if (!pathInCommit(base, resolvedItemPath, cwd, env)) {
2024
2227
  return 'missing';
2025
2228
  }
2229
+ // IDEMPOTENCE: this exact bounce may ALREADY be surfaced on this base —
2230
+ // either a genuine re-bounce for an identical, still-pending reason, or a
2231
+ // retry of an attempt that landed but was mis-read as rejected. Either way
2232
+ // there is nothing to add, so land no commit (see
2233
+ // {@link bounceAlreadySurfaced}).
2234
+ if (
2235
+ bounceAlreadySurfaced({
2236
+ base,
2237
+ itemPath: resolvedItemPath,
2238
+ sidecarPath: sidecarPathFor(item),
2239
+ additions: [
2240
+ buildBounceEnvelope({item, reason, envelope}),
2241
+ ...(questions ?? []),
2242
+ ],
2243
+ cwd,
2244
+ env,
2245
+ })
2246
+ ) {
2247
+ return 'already-done';
2248
+ }
2026
2249
  return prepareTreelessSurfaceCommit({
2027
2250
  cwd,
2028
2251
  slug,