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
@@ -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,
package/src/pi-harness.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  } from './harness.js';
14
14
  import {generateSessionPath} from './session-path.js';
15
15
  import {lastAssistantText} from './watch-session.js';
16
+ import {reapProcessGroup} from './reap-agent-tree.js';
16
17
  import type {HarnessAdapter} from './config.js';
17
18
 
18
19
  /**
@@ -237,8 +238,46 @@ export class PiHarness implements Harness {
237
238
  cwd: input.dir,
238
239
  env: input.env ?? process.env,
239
240
  stdio: ['pipe', 'pipe', 'pipe'],
241
+ // PROCESS-GROUP LEADER (observation
242
+ // `checkpoint-releases-lock-while-predecessor-agent-still-writes`): pi's
243
+ // pgid becomes its own pid, so the deadline stop can signal the WHOLE
244
+ // agent tree with `kill(-pgid)` and VERIFY it is gone. Without this,
245
+ // `child.kill()` reached exactly one pid: subagents / MCP servers / tool
246
+ // subshells survived, were re-parented to init (so no ppid walk could even
247
+ // find them), and kept writing into the worktree while a SUCCESSOR agent
248
+ // was already editing it. A pgid is inherited by every descendant and is
249
+ // unaffected by re-parenting, which is why it is the only usable handle.
250
+ // We deliberately do NOT `unref()` here: the parent keeps supervising the
251
+ // child (and forwards its own termination to the group, below).
252
+ detached: true,
240
253
  });
241
254
  record.pid = child.pid; // the liveness anchor, recorded like spawnSync.
255
+ // The group id equals the leader's pid because we spawned `detached`.
256
+ const pgid = child.pid;
257
+ // `detached: true` takes pi OUT of our terminal's foreground process group,
258
+ // so a Ctrl-C / `kill` aimed at the runner would no longer reach it — which
259
+ // would WIDEN the very "aborting `do` does not kill the spawned agent tree"
260
+ // gap this change is closing. Forward our own termination to the group for
261
+ // as long as the child is live, so detaching strictly improves reachability
262
+ // instead of trading one orphan class for another.
263
+ const forwardSignal = (signal: NodeJS.Signals) => (): void => {
264
+ if (pgid === undefined) {
265
+ return;
266
+ }
267
+ try {
268
+ process.kill(-pgid, signal);
269
+ } catch {
270
+ // Already gone; nothing to forward to.
271
+ }
272
+ };
273
+ const onSigint = forwardSignal('SIGINT');
274
+ const onSigterm = forwardSignal('SIGTERM');
275
+ process.on('SIGINT', onSigint);
276
+ process.on('SIGTERM', onSigterm);
277
+ const stopForwarding = (): void => {
278
+ process.off('SIGINT', onSigint);
279
+ process.off('SIGTERM', onSigterm);
280
+ };
242
281
  let stderr = '';
243
282
  child.stderr?.on('data', (chunk: Buffer) => {
244
283
  stderr += chunk.toString('utf8');
@@ -276,18 +315,29 @@ export class PiHarness implements Harness {
276
315
  return;
277
316
  }
278
317
  timedOut = true;
318
+ // Signal the whole GROUP, not just pi: the descendants are exactly the
319
+ // processes that outlive it and keep writing to the worktree.
279
320
  try {
280
- child.kill('SIGTERM');
321
+ if (pgid !== undefined) {
322
+ process.kill(-pgid, 'SIGTERM');
323
+ } else {
324
+ child.kill('SIGTERM');
325
+ }
281
326
  } catch {
282
- // Best-effort: a already-exited child throws ESRCH; the `exit`
283
- // handler will still settle the promise.
327
+ // Best-effort: an already-exited group throws ESRCH; the `exit`
328
+ // handler will still settle the promise, and the post-exit reap below
329
+ // is what actually VERIFIES the tree is gone.
284
330
  }
285
331
  hardTimer = setTimeout(() => {
286
332
  if (settled) {
287
333
  return;
288
334
  }
289
335
  try {
290
- child.kill('SIGKILL');
336
+ if (pgid !== undefined) {
337
+ process.kill(-pgid, 'SIGKILL');
338
+ } else {
339
+ child.kill('SIGKILL');
340
+ }
291
341
  } catch {
292
342
  // Best-effort: see above.
293
343
  }
@@ -302,6 +352,7 @@ export class PiHarness implements Harness {
302
352
  }
303
353
  settled = true;
304
354
  clearDeadlineTimers();
355
+ stopForwarding();
305
356
  reject(new Error(`failed to spawn pi (${this.piBin}): ${err.message}`));
306
357
  });
307
358
  // Resolve on `exit` (pi itself terminated), NOT `close`: `close` waits for
@@ -314,6 +365,7 @@ export class PiHarness implements Harness {
314
365
  }
315
366
  settled = true;
316
367
  clearDeadlineTimers();
368
+ stopForwarding();
317
369
  // Release our end of the stdio pipes so a leaked grandchild's inherited
318
370
  // FDs stop keeping our streams referenced; `unref` the child handle too.
319
371
  child.stdout?.destroy();
@@ -321,17 +373,59 @@ export class PiHarness implements Harness {
321
373
  child.stdin?.destroy();
322
374
  child.unref?.();
323
375
  const status = code ?? -1;
324
- resolve({
325
- ok: status === 0 && !timedOut,
326
- record,
327
- detail:
328
- status === 0 && !timedOut ? undefined : stderr.trim() || undefined,
329
- timedOut: timedOut ? true : undefined,
330
- // Read the agent's ANSWER from the `.jsonl` at `exit` — the same
331
- // last-assistant-text read `launch` does at return (task
332
- // `harness-agent-output`); the process has exited so the log is final.
333
- output: readLastAssistantText(sessionFile),
334
- });
376
+ const settleWith = (reap?: LaunchResult['reap']): void => {
377
+ resolve({
378
+ ok: status === 0 && !timedOut,
379
+ record,
380
+ detail:
381
+ status === 0 && !timedOut
382
+ ? undefined
383
+ : stderr.trim() || undefined,
384
+ timedOut: timedOut ? true : undefined,
385
+ ...(reap ? {reap} : {}),
386
+ // Read the agent's ANSWER from the `.jsonl` at `exit` — the same
387
+ // last-assistant-text read `launch` does at return (task
388
+ // `harness-agent-output`); the process has exited so the log is final.
389
+ output: readLastAssistantText(sessionFile),
390
+ });
391
+ };
392
+ if (!timedOut || pgid === undefined) {
393
+ // Normal exit: we signalled nothing, so there is nothing to prove and
394
+ // nothing to kill. Byte-for-byte the pre-existing behaviour — in
395
+ // particular we do NOT reap a group the agent may have deliberately left
396
+ // running behind a successful run.
397
+ settleWith();
398
+ return;
399
+ }
400
+ // DEADLINE STOP: pi's own exit says NOTHING about its descendants — that
401
+ // assumption is the defect. Before this promise resolves (which is the
402
+ // runner's cue to save WIP, release the lock and dispatch a SUCCESSOR into
403
+ // this same worktree), reap the group and VERIFY it is gone. Bounded by
404
+ // construction, so this cannot reintroduce the resolve-on-`exit` hang the
405
+ // doc-comment above guards against: a tree that will not die resolves with
406
+ // `reaped: false` and the caller refuses to release the lock.
407
+ void reapProcessGroup({pgid})
408
+ .then((result) => {
409
+ settleWith({
410
+ reaped: result.reaped,
411
+ pgid,
412
+ escalatedToSigkill: result.escalatedToSigkill,
413
+ detail: result.detail,
414
+ });
415
+ })
416
+ .catch((err: unknown) => {
417
+ // A throw here means we could not even RUN the verification, which is
418
+ // indistinguishable from "might still be alive" — report it as an
419
+ // unproven reap rather than silently claiming success.
420
+ settleWith({
421
+ reaped: false,
422
+ pgid,
423
+ detail:
424
+ `could not verify that the agent process group ${pgid} exited ` +
425
+ `(${err instanceof Error ? err.message : String(err)}); treating ` +
426
+ 'the predecessor as possibly still writing to the worktree.',
427
+ });
428
+ });
335
429
  });
336
430
  // Feed the same prepared prompt on stdin, then close it (pi reads to EOF).
337
431
  if (input.prompt !== undefined) {