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/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/do.d.ts +11 -1
- package/dist/do.d.ts.map +1 -1
- package/dist/do.js +108 -14
- package/dist/do.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/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/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/do.ts +155 -18
- 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/worktree-writer-lock.ts +217 -0
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
|
/**
|
package/src/ledger-write.ts
CHANGED
|
@@ -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
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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
|
);
|
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,
|