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.
- package/dist/arbiter-refs.d.ts +139 -0
- package/dist/arbiter-refs.d.ts.map +1 -0
- package/dist/arbiter-refs.js +114 -0
- package/dist/arbiter-refs.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +32 -18
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +73 -12
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +28 -0
- package/dist/config.js.map +1 -1
- package/dist/do-config.d.ts +21 -1
- package/dist/do-config.d.ts.map +1 -1
- package/dist/do-config.js +19 -0
- package/dist/do-config.js.map +1 -1
- package/dist/do.d.ts +14 -2
- package/dist/do.d.ts.map +1 -1
- package/dist/do.js +108 -14
- package/dist/do.js.map +1 -1
- package/dist/env-config.d.ts.map +1 -1
- package/dist/env-config.js +3 -0
- package/dist/env-config.js.map +1 -1
- package/dist/harness.d.ts +29 -0
- package/dist/harness.d.ts.map +1 -1
- package/dist/harness.js.map +1 -1
- package/dist/ledger-write.d.ts.map +1 -1
- package/dist/ledger-write.js +38 -3
- package/dist/ledger-write.js.map +1 -1
- package/dist/needs-attention.d.ts +43 -0
- package/dist/needs-attention.d.ts.map +1 -1
- package/dist/needs-attention.js +211 -50
- package/dist/needs-attention.js.map +1 -1
- package/dist/pi-harness.d.ts.map +1 -1
- package/dist/pi-harness.js +109 -13
- package/dist/pi-harness.js.map +1 -1
- package/dist/protocol/WORK-CONTRACT.md +11 -2
- package/dist/protocol/spec-template.md +1 -1
- package/dist/protocol/task-template.md +1 -1
- package/dist/reap-agent-tree.d.ts +108 -0
- package/dist/reap-agent-tree.d.ts.map +1 -0
- package/dist/reap-agent-tree.js +173 -0
- package/dist/reap-agent-tree.js.map +1 -0
- package/dist/repo-config.d.ts +1 -1
- package/dist/repo-config.d.ts.map +1 -1
- package/dist/repo-config.js +19 -2
- package/dist/repo-config.js.map +1 -1
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +4 -3
- package/dist/run.js.map +1 -1
- package/dist/skills/drive-tasks/SKILL.md +20 -2
- package/dist/skills/setup/protocol/WORK-CONTRACT.md +11 -2
- package/dist/skills/setup/protocol/spec-template.md +1 -1
- package/dist/skills/setup/protocol/task-template.md +1 -1
- package/dist/worktree-writer-lock.d.ts +99 -0
- package/dist/worktree-writer-lock.d.ts.map +1 -0
- package/dist/worktree-writer-lock.js +158 -0
- package/dist/worktree-writer-lock.js.map +1 -0
- package/package.json +1 -1
- package/src/arbiter-refs.ts +222 -0
- package/src/cli.ts +57 -17
- package/src/config.ts +90 -12
- package/src/do-config.ts +38 -0
- package/src/do.ts +158 -19
- package/src/env-config.ts +3 -0
- package/src/harness.ts +30 -0
- package/src/ledger-write.ts +41 -5
- package/src/needs-attention.ts +282 -59
- package/src/pi-harness.ts +109 -15
- package/src/reap-agent-tree.ts +221 -0
- package/src/repo-config.ts +19 -1
- package/src/run.ts +4 -3
- package/src/worktree-writer-lock.ts +217 -0
package/src/needs-attention.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
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
|
-
|
|
755
|
-
'
|
|
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
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
);
|
|
764
|
-
|
|
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 {
|
|
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
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
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
|
|
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
|
|
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).
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
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
|
-
|
|
321
|
+
if (pgid !== undefined) {
|
|
322
|
+
process.kill(-pgid, 'SIGTERM');
|
|
323
|
+
} else {
|
|
324
|
+
child.kill('SIGTERM');
|
|
325
|
+
}
|
|
281
326
|
} catch {
|
|
282
|
-
// Best-effort:
|
|
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
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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) {
|