@gr8ful/spf 0.9.2 → 0.10.0
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/README.md +56 -0
- package/assets/defaults/spf.config.yaml +75 -0
- package/assets/skill/references/config.md +98 -4
- package/dist/chains/index.d.ts +2 -0
- package/dist/chains/index.js +4 -0
- package/dist/cli/commands/doctor.js +339 -2
- package/dist/cli/commands/fanout.d.ts +7 -14
- package/dist/cli/commands/fanout.js +45 -39
- package/dist/cli/commands/loop.d.ts +2 -0
- package/dist/cli/commands/loop.js +198 -0
- package/dist/cli/commands/run.js +14 -4
- package/dist/cli/commands/watch.d.ts +29 -1
- package/dist/cli/commands/watch.js +219 -64
- package/dist/cli/index.js +14 -0
- package/dist/core/agent_cc.d.ts +11 -0
- package/dist/core/agent_cc.js +25 -2
- package/dist/core/agent_flue.js +14 -5
- package/dist/core/agents.d.ts +61 -1
- package/dist/core/agents.js +363 -6
- package/dist/core/data_types.d.ts +316 -0
- package/dist/core/data_types.js +143 -0
- package/dist/core/loop.d.ts +230 -0
- package/dist/core/loop.js +290 -0
- package/dist/core/quality.d.ts +1 -2
- package/dist/core/sandbox.d.ts +236 -0
- package/dist/core/sandbox.js +655 -0
- package/dist/core/sandbox_cloudflare.d.ts +137 -0
- package/dist/core/sandbox_cloudflare.js +505 -0
- package/dist/core/sandbox_opensandbox.d.ts +59 -0
- package/dist/core/sandbox_opensandbox.js +484 -0
- package/dist/core/sandbox_sdk_types.d.ts +171 -0
- package/dist/core/sandbox_sdk_types.js +20 -0
- package/dist/core/watch.d.ts +56 -0
- package/dist/core/watch.js +354 -51
- package/dist/core/worktree_data.d.ts +1 -0
- package/dist/core/worktree_data.js +37 -0
- package/package.json +1 -1
package/dist/core/watch.js
CHANGED
|
@@ -55,11 +55,17 @@
|
|
|
55
55
|
* retry loop, no auto-merge; those remain deliberately out of scope.
|
|
56
56
|
*/
|
|
57
57
|
import path from "node:path";
|
|
58
|
+
import { attemptAdwId, attemptBranch, attemptWorktreePath, runBestOf } from "./fanout.js";
|
|
58
59
|
import { PRIORITY_RANK } from "./data_types.js";
|
|
59
60
|
import { parseRefineMarker } from "./refine.js";
|
|
61
|
+
import { newId } from "./utils.js";
|
|
60
62
|
const MAX_ORPHAN_ATTEMPTS = 2;
|
|
61
63
|
/** GitHub's own documented sub-issue nesting cap (see `github_provider.ts`'s `linkChild` doc comment) — `rollUp`'s own recursion bound, so a malformed/cyclic hierarchy can't spin forever. */
|
|
62
64
|
const MAX_ROLLUP_DEPTH = 8;
|
|
65
|
+
/** Twin of `MAX_N` in `cli/commands/fanout.ts` — the fan-out lane's own attempt-count ceiling (`watch.fanout.n`'s schema bound), duplicated as a literal so the pre-sweep (§8.5) can size itself without importing a CLI command module. */
|
|
66
|
+
const MAX_FANOUT_N = 8;
|
|
67
|
+
/** §8.7's salt ceiling — and therefore §8.5's sweep range too. Bounds an otherwise-unbounded probe/sweep against an issue fanned out over and over. */
|
|
68
|
+
const MAX_FANOUT_SALT = 8;
|
|
63
69
|
export function createWatchState() {
|
|
64
70
|
return { inflight: new Set(), refining: new Set(), inflightParents: new Map() };
|
|
65
71
|
}
|
|
@@ -542,8 +548,337 @@ export async function finishReviews(deps) {
|
|
|
542
548
|
}
|
|
543
549
|
}
|
|
544
550
|
}
|
|
545
|
-
/**
|
|
546
|
-
|
|
551
|
+
/**
|
|
552
|
+
* The half of a claimed issue's path that runs once a WINNING tree exists:
|
|
553
|
+
* empty-diff check, push, PR, marker, transition, notify. Called by
|
|
554
|
+
* `runIssueSingle` with its sole attempt's tree and by `runIssueFanout` with
|
|
555
|
+
* `pickBest`'s winner — it never learns which, because nothing about opening
|
|
556
|
+
* a PR depends on how many candidates were considered.
|
|
557
|
+
*
|
|
558
|
+
* `diffBase` is a PARAMETER, not `origin/${deps.baseBranch}` recomputed here:
|
|
559
|
+
* the fan-out lane pins one base SHA for the whole fan-out (see
|
|
560
|
+
* `runIssueFanout`) and the winner must be diffed against the SAME commit its
|
|
561
|
+
* siblings were ranked against, not against wherever `origin/<base>` has
|
|
562
|
+
* moved to by now. The single lane passes `origin/${deps.baseBranch}` — the
|
|
563
|
+
* literal expression it used inline before this extraction.
|
|
564
|
+
*
|
|
565
|
+
* `extraNotifyFields`, when given, are appended to the `pr_opened` notify's
|
|
566
|
+
* `fields` — additive, never replacing any of the four fields below. The
|
|
567
|
+
* fan-out lane uses this for one `["fanout", "<n> attempts, won by <adw_id>"]`
|
|
568
|
+
* entry; the single lane passes nothing, so its notify is byte-identical to
|
|
569
|
+
* before this extraction.
|
|
570
|
+
*
|
|
571
|
+
* IT CATCHES NOTHING, deliberately. The try/catch that covers this region
|
|
572
|
+
* lives one level up, in each lane's own caller, and this extraction does not
|
|
573
|
+
* move it. So EVERY caller must still hold the winner's `{worktreePath,
|
|
574
|
+
* branch}` in whatever its own catch cleans up, for the whole duration of
|
|
575
|
+
* this call: the throws in here are ordinary (`push` rejected, `openPr` 5xx,
|
|
576
|
+
* a tracker write failing), and on any of them the caller's catch is the only
|
|
577
|
+
* thing that removes the tree and deletes the branch.
|
|
578
|
+
*/
|
|
579
|
+
async function openPrForWinner(deps, issue, won) {
|
|
580
|
+
const wtGit = deps.worktreeGit(won.worktreePath);
|
|
581
|
+
if (wtGit.diffFiles(won.diffBase).length === 0) {
|
|
582
|
+
deps.log(`watch: ${issue.id}: chain succeeded but committed nothing — blocked`);
|
|
583
|
+
deps.notify({
|
|
584
|
+
kind: "issue_blocked",
|
|
585
|
+
level: "notice",
|
|
586
|
+
title: `issue ${issue.id} blocked`,
|
|
587
|
+
detail: `Chain "${deps.chain}" (adw_id ${won.adwId}) completed but left no committed changes.`,
|
|
588
|
+
fields: [["issue", issue.id], ["title", issue.title], ["chain", deps.chain], ["adw_id", won.adwId]],
|
|
589
|
+
});
|
|
590
|
+
await deps.provider.transition(issue, "blocked", `Chain "${deps.chain}" (adw_id ${won.adwId}) completed but left no committed changes.`);
|
|
591
|
+
cleanupWorktree(deps, { worktree: won.worktreePath, branch: won.branch });
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
wtGit.push("origin", won.branch);
|
|
595
|
+
// The human merging this PR sees whatever the reviewer found — or, if
|
|
596
|
+
// nothing reviewed this change at all, is told that plainly rather than
|
|
597
|
+
// left to assume a silent approval. `reviewSummary` is already
|
|
598
|
+
// sanitized/truncated by the caller (see runChain's own doc comment).
|
|
599
|
+
const reviewLine = won.reviewSummary
|
|
600
|
+
? won.reviewSummary
|
|
601
|
+
: won.reviewRequired
|
|
602
|
+
? "Reviewer ran, but no verdict could be read back from the session data."
|
|
603
|
+
: `Nothing reviewed this change — chain \`${deps.chain}\` has no reviewer step.`;
|
|
604
|
+
// The reviewer's biggest complaint about a bare "automated by spf watch"
|
|
605
|
+
// body: it never says what was actually asked for, only chain plumbing.
|
|
606
|
+
// The issue body IS that ask (it's the same text `runIssue`/`runChain`
|
|
607
|
+
// built the prompt from) and is already sitting on `issue` — no new DB
|
|
608
|
+
// read needed to surface it. Capped defensively: a Jira description can
|
|
609
|
+
// be arbitrarily long, and this is a PR body, not the source of record.
|
|
610
|
+
const MAX_ISSUE_BODY_CHARS = 4000;
|
|
611
|
+
const issueBody = issue.body.trim();
|
|
612
|
+
const askSection = issueBody.length > MAX_ISSUE_BODY_CHARS ? `${issueBody.slice(0, MAX_ISSUE_BODY_CHARS)}\n\n_(truncated)_` : issueBody || "_(no description on the issue)_";
|
|
613
|
+
// No cross-linking magic keyword here on purpose (a code host paired
|
|
614
|
+
// with a different tracker has no "Closes #n" convention to hook into
|
|
615
|
+
// — see provider.ts) — the issue id in the title/body is plain text
|
|
616
|
+
// for humans, and, on a Jira+Bitbucket pairing, exactly what Jira's own
|
|
617
|
+
// Bitbucket integration scans for to link the PR automatically.
|
|
618
|
+
const pr = await deps.codeHost.openPr({
|
|
619
|
+
branch: won.branch,
|
|
620
|
+
title: `${issue.title} (${issue.id})`,
|
|
621
|
+
body: `${askSection}\n\n---\n\n**Review:** ${reviewLine}\n\n_Automated by \`spf watch\` — chain \`${deps.chain}\`, adw_id \`${won.adwId}\`, issue ${issue.id}._`,
|
|
622
|
+
base: deps.baseBranch,
|
|
623
|
+
});
|
|
624
|
+
await deps.provider.writeMarker(issue, { worktree: won.worktreePath, branch: won.branch, pr: pr.number, attempt: 0 });
|
|
625
|
+
await deps.provider.transition(issue, "review");
|
|
626
|
+
deps.log(`watch: ${issue.id}: opened PR #${pr.number} — review`);
|
|
627
|
+
deps.notify({
|
|
628
|
+
kind: "pr_opened",
|
|
629
|
+
// Unlike `issue_claimed`/`issue_done` (routine milestones), an opened PR
|
|
630
|
+
// is a standing ask for a human reviewer — the same "needs a human, not
|
|
631
|
+
// a failure" bucket as `issue_blocked`, so it ships to an
|
|
632
|
+
// `attention`-scoped channel, not just `all`.
|
|
633
|
+
level: "notice",
|
|
634
|
+
title: `PR #${pr.number} opened`,
|
|
635
|
+
detail: reviewLine,
|
|
636
|
+
fields: [
|
|
637
|
+
["issue", issue.id],
|
|
638
|
+
["title", issue.title],
|
|
639
|
+
["chain", deps.chain],
|
|
640
|
+
["review", won.reviewSummary ? "reviewed" : won.reviewRequired ? "reviewer ran, no verdict" : "not reviewed"],
|
|
641
|
+
...(won.extraNotifyFields ?? []),
|
|
642
|
+
],
|
|
643
|
+
url: pr.url || undefined,
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* `issue-<id>` at `r = 0` — matching `runIssueSingle`'s own adw_id scheme, so
|
|
648
|
+
* the common case (an issue's first fan-out) is the id a human would guess
|
|
649
|
+
* (`spf phases issue-42-2` works as advertised). `r > 0` salts it — see
|
|
650
|
+
* `runIssueFanout`'s salt probe for why a PROBED salt, not a counter anyone
|
|
651
|
+
* maintains, decides `r`.
|
|
652
|
+
*/
|
|
653
|
+
function baseFor(issue, r) {
|
|
654
|
+
return r === 0 ? `issue-${issue.id}` : `issue-${issue.id}-r${r}`;
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* Tear down a whole SET of worktree/branch pairs — EVERY worktree in the
|
|
658
|
+
* list first, then every branch, never pair by pair. The pairs a fan-out
|
|
659
|
+
* pre-sweep has to cover are cross-linked (the winner's renamed branch can
|
|
660
|
+
* live in a *different* entry's fan-out worktree — see the rename step in
|
|
661
|
+
* `runIssueFanout`), and `git branch -D` refuses to delete a branch that is
|
|
662
|
+
* checked out in ANY worktree still on disk while `deleteLocalBranch`
|
|
663
|
+
* swallows that refusal (a bare `spawnSync` whose status is never read —
|
|
664
|
+
* `git_helper.ts`). A name-by-name (pairwise) sweep can therefore try to
|
|
665
|
+
* delete a branch that is still checked out in an entry later in the SAME
|
|
666
|
+
* list, silently fail, and leave the issue permanently wedged the next time
|
|
667
|
+
* something tries to create that branch. Two full passes over the whole list
|
|
668
|
+
* remove that ordering dependency entirely — the same rule
|
|
669
|
+
* `removeRunWorktree` documents for its own single pair (worktree before
|
|
670
|
+
* branch), applied at the granularity a sweep across many pairs needs.
|
|
671
|
+
*/
|
|
672
|
+
function sweepPairs(deps, pairs) {
|
|
673
|
+
for (const p of pairs) {
|
|
674
|
+
if (!p.worktree)
|
|
675
|
+
continue;
|
|
676
|
+
try {
|
|
677
|
+
deps.git.worktreeRemove(p.worktree);
|
|
678
|
+
}
|
|
679
|
+
catch (error) {
|
|
680
|
+
deps.log(`watch: sweep warning: ${error.message}`);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
for (const p of pairs) {
|
|
684
|
+
if (!p.branch)
|
|
685
|
+
continue;
|
|
686
|
+
try {
|
|
687
|
+
deps.git.deleteLocalBranch(p.branch);
|
|
688
|
+
}
|
|
689
|
+
catch (error) {
|
|
690
|
+
deps.log(`watch: sweep warning: ${error.message}`);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* `watch.fanout.n > 1`: run `n` sibling attempts of this issue's prompt via
|
|
696
|
+
* `core/fanout.ts`'s `runBestOf`, let it pick a winner, rename the winner's
|
|
697
|
+
* branch onto the canonical `spf-watch/<id>-<slug>` name, then hand it to
|
|
698
|
+
* `openPrForWinner` — the same tail `runIssueSingle` uses.
|
|
699
|
+
*
|
|
700
|
+
* `won` is a single mutable local (unlike the single lane's deterministic
|
|
701
|
+
* `worktreePath`/`branch` locals) because the fan-out lane's cleanup
|
|
702
|
+
* coordinates change three times over the course of one claim: null while
|
|
703
|
+
* nothing of watch's own exists yet, the winner's fan-out tree/branch right
|
|
704
|
+
* after selection, then the winner's tree on the renamed canonical branch
|
|
705
|
+
* from the rename onward — including all the way through `openPrForWinner`,
|
|
706
|
+
* which catches nothing itself. `won` is intentionally never nulled before
|
|
707
|
+
* that call: doing so would disarm this catch for `push`/`openPr` failures,
|
|
708
|
+
* the single most failure-prone part of the whole flow, and leak a full
|
|
709
|
+
* checkout plus the canonical branch on every one of them.
|
|
710
|
+
*/
|
|
711
|
+
async function runIssueFanout(deps, issue, fanout) {
|
|
712
|
+
const n = fanout.n;
|
|
713
|
+
let won = null;
|
|
714
|
+
try {
|
|
715
|
+
// Read for its PAIR (the pre-sweep needs to know what a previous cycle
|
|
716
|
+
// recorded), never for `attempt` — see the salt probe below for why that
|
|
717
|
+
// field cannot be the fan-out lane's re-claim counter.
|
|
718
|
+
const marker0 = await deps.provider.readMarker(issue);
|
|
719
|
+
// SALT PROBE (§8.7 in the design doc): the smallest r whose n attempt
|
|
720
|
+
// ids have NO session rows yet. `WatchMarker.attempt` only advances on
|
|
721
|
+
// reconcileOrphans' retry path — an ordinary blocked -> re-labeled ready
|
|
722
|
+
// -> re-claim never touches it — so it cannot answer "is this base
|
|
723
|
+
// still clean," but the sessions a previous claim's attempts actually
|
|
724
|
+
// wrote can (adwIdsFree's own doc comment has the full argument).
|
|
725
|
+
let salt = 0;
|
|
726
|
+
for (; salt <= MAX_FANOUT_SALT; salt++) {
|
|
727
|
+
const ids = Array.from({ length: n }, (_, i) => attemptAdwId(baseFor(issue, salt), i + 1));
|
|
728
|
+
if (fanout.adwIdsFree(ids))
|
|
729
|
+
break;
|
|
730
|
+
}
|
|
731
|
+
const baseAdwId = salt <= MAX_FANOUT_SALT ? baseFor(issue, salt) : `issue-${issue.id}-x${newId(4)}`;
|
|
732
|
+
if (salt > MAX_FANOUT_SALT) {
|
|
733
|
+
deps.log(`watch: ${issue.id}: exhausted ${MAX_FANOUT_SALT + 1} deterministic fan-out base id(s) — falling back to a random one (${baseAdwId})`);
|
|
734
|
+
}
|
|
735
|
+
// PRE-SWEEP: one list, two passes (sweepPairs) — marker0's own recorded
|
|
736
|
+
// pair (the only way to reach a stale RENAMED winner from a crash after
|
|
737
|
+
// §8.3's rename), the canonical single-lane pair (the rename target
|
|
738
|
+
// below must find it free), and every deterministic attempt name under
|
|
739
|
+
// every base this claim can have used (0..salt inclusive — bases above
|
|
740
|
+
// salt were never reached, so nothing can be there).
|
|
741
|
+
const branch = branchNameFor(issue);
|
|
742
|
+
const worktreePath = worktreePathFor(deps, issue);
|
|
743
|
+
const pairs = [
|
|
744
|
+
{ worktree: marker0?.worktree, branch: marker0?.branch },
|
|
745
|
+
{ worktree: worktreePath, branch },
|
|
746
|
+
];
|
|
747
|
+
for (let r = 0; r <= Math.min(salt, MAX_FANOUT_SALT); r++) {
|
|
748
|
+
const base = baseFor(issue, r);
|
|
749
|
+
for (let i = 1; i <= MAX_FANOUT_N; i++) {
|
|
750
|
+
pairs.push({
|
|
751
|
+
worktree: attemptWorktreePath(deps.worktreesDir, base, i),
|
|
752
|
+
branch: attemptBranch(base, i),
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
sweepPairs(deps, pairs);
|
|
757
|
+
// The base is fetched and resolved to a SHA exactly ONCE, loudly, here —
|
|
758
|
+
// matching the single lane's own unguarded, fatal-on-failure fetch
|
|
759
|
+
// (`runIssueSingle`) rather than `runBestOf`'s own per-attempt,
|
|
760
|
+
// failure-swallowing fetch. `pinnedGit` suppresses the N redundant
|
|
761
|
+
// fetches `runBestOf` would otherwise issue against a base already
|
|
762
|
+
// fetched here, and pins every attempt (and the eventual `diffFiles`
|
|
763
|
+
// check in `openPrForWinner`) to the identical commit even if
|
|
764
|
+
// `origin/<base>` moves mid-fan-out.
|
|
765
|
+
deps.git.fetch("origin", deps.baseBranch);
|
|
766
|
+
const baseSha = deps.git.rev(`origin/${deps.baseBranch}`);
|
|
767
|
+
const pinnedGit = { ...deps.git, fetch: () => { } };
|
|
768
|
+
// CLAIM-TIME MARKER WRITE — mirrors the single lane's own write
|
|
769
|
+
// (`runIssueSingle`) and, like it, drops `pr`. Without this, a marker
|
|
770
|
+
// surviving from a PREVIOUS cycle (e.g. a human hand-returning a
|
|
771
|
+
// `review` issue with an open PR back to `ready`) would carry that
|
|
772
|
+
// stale `pr` through the sweep and the whole fan-out window untouched —
|
|
773
|
+
// and if the daemon dies mid-fan-out, `reconcileOrphans` would read that
|
|
774
|
+
// stale `marker.pr` on restart, find it open or merged, and resume the
|
|
775
|
+
// issue straight to `review` (its RESUME path, never reached again from
|
|
776
|
+
// `ready`/`blocked`), leaking this claim's worktrees/branches forever
|
|
777
|
+
// with nothing left to sweep them on a future claim. `attempt` is
|
|
778
|
+
// PRESERVED here (not reset to 0) so the orphan-retry counter survives a
|
|
779
|
+
// claim that has not yet produced a winner; it only becomes the literal
|
|
780
|
+
// `0` once a real winner marker is written below, matching the single
|
|
781
|
+
// lane's own reset at that point.
|
|
782
|
+
await deps.provider.writeMarker(issue, { attempt: marker0?.attempt ?? 0 });
|
|
783
|
+
const result = await runBestOf({
|
|
784
|
+
chainName: deps.chain,
|
|
785
|
+
prompt: `${issue.title}\n\n${issue.body}`.trim(),
|
|
786
|
+
n,
|
|
787
|
+
concurrency: fanout.concurrency,
|
|
788
|
+
baseAdwId,
|
|
789
|
+
baseBranch: baseSha,
|
|
790
|
+
repoRoot: fanout.repoRoot,
|
|
791
|
+
worktreesDir: deps.worktreesDir,
|
|
792
|
+
git: pinnedGit,
|
|
793
|
+
linkDataDir: deps.linkDataDir,
|
|
794
|
+
runAttempt: fanout.runAttempt,
|
|
795
|
+
readMetrics: fanout.readMetrics,
|
|
796
|
+
log: deps.log,
|
|
797
|
+
});
|
|
798
|
+
deps.log(`watch: ${issue.id}: best of ${n} — ${result.basis}`);
|
|
799
|
+
if (!result.winner) {
|
|
800
|
+
const first = attemptAdwId(baseAdwId, 1);
|
|
801
|
+
const last = attemptAdwId(baseAdwId, n);
|
|
802
|
+
const detail = `Chain "${deps.chain}" did not succeed in any of ${n} attempt(s) (adw_ids ${first}..${last}): ${result.basis}. ` +
|
|
803
|
+
`Run \`spf phases <adw_id>\` on any attempt for detail.`;
|
|
804
|
+
deps.notify({
|
|
805
|
+
kind: "issue_blocked",
|
|
806
|
+
level: "notice",
|
|
807
|
+
title: `issue ${issue.id} blocked`,
|
|
808
|
+
detail,
|
|
809
|
+
fields: [
|
|
810
|
+
["issue", issue.id],
|
|
811
|
+
["title", issue.title],
|
|
812
|
+
["chain", deps.chain],
|
|
813
|
+
["adw_id", `${baseAdwId}-1..${n}`],
|
|
814
|
+
["attempts", String(n)],
|
|
815
|
+
],
|
|
816
|
+
});
|
|
817
|
+
await deps.provider.transition(issue, "blocked", detail);
|
|
818
|
+
// NO cleanupWorktree call: `won` is still null — nothing of watch's
|
|
819
|
+
// own survived `runBestOf`, which already cleaned every attempt it
|
|
820
|
+
// owned (losers as it went, and there is no winner to keep here).
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
const winner = result.winner;
|
|
824
|
+
won = { worktree: winner.worktree, branch: winner.branch }; // still spf/fanout/*
|
|
825
|
+
await deps.provider.writeMarker(issue, { worktree: won.worktree, branch: won.branch, attempt: 0 });
|
|
826
|
+
// RENAME (§8.3): `spf/fanout/*` branches are never pushed by SPF
|
|
827
|
+
// (`core/fanout.ts`'s own stated contract) — pushing one from watch
|
|
828
|
+
// would contradict the module this composes. Renaming preserves
|
|
829
|
+
// everything downstream that reads a branch name (WatchMarker.branch,
|
|
830
|
+
// the PR head, finishReviews' cleanup, the Jira/Bitbucket branch-name
|
|
831
|
+
// auto-link). Order matters: `createBranch` (a `git checkout -b` INSIDE
|
|
832
|
+
// the winner's worktree) must run before `deleteLocalBranch` on the old
|
|
833
|
+
// name, because git refuses to delete a branch checked out in a
|
|
834
|
+
// worktree — the winner's own worktree is that checkout until this line
|
|
835
|
+
// runs. `createBranch` CAN throw (most commonly: the canonical name
|
|
836
|
+
// already exists) — the pre-sweep above is what keeps that name free by
|
|
837
|
+
// the time this runs; if it still throws, `won` has not been reassigned
|
|
838
|
+
// yet, so the catch below cleans the pre-rename pair, correctly.
|
|
839
|
+
const wtGit = deps.worktreeGit(winner.worktree);
|
|
840
|
+
wtGit.createBranch(branch);
|
|
841
|
+
deps.git.deleteLocalBranch(winner.branch);
|
|
842
|
+
won = { worktree: winner.worktree, branch };
|
|
843
|
+
await deps.provider.writeMarker(issue, { worktree: won.worktree, branch: won.branch, attempt: 0 });
|
|
844
|
+
const review = fanout.reviewFor({ cwd: winner.worktree, adwId: winner.adw_id, chainOptions: deps.chainOptions });
|
|
845
|
+
// `won` stays set from here on — openPrForWinner catches nothing, so
|
|
846
|
+
// this function's own catch (below) is the only cleanup for a throw
|
|
847
|
+
// from push/openPr/writeMarker/transition/notify inside it, exactly as
|
|
848
|
+
// runIssueSingle's catch is for the single lane.
|
|
849
|
+
await openPrForWinner(deps, issue, {
|
|
850
|
+
branch: won.branch,
|
|
851
|
+
worktreePath: won.worktree,
|
|
852
|
+
adwId: winner.adw_id,
|
|
853
|
+
diffBase: baseSha,
|
|
854
|
+
reviewRequired: review.reviewRequired,
|
|
855
|
+
reviewSummary: review.reviewSummary,
|
|
856
|
+
extraNotifyFields: [["fanout", `${n} attempts, won by ${winner.adw_id}`]],
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
catch (error) {
|
|
860
|
+
const message = error.message;
|
|
861
|
+
deps.log(`watch: ${issue.id}: error: ${message}`);
|
|
862
|
+
deps.notify({
|
|
863
|
+
kind: "watch_error",
|
|
864
|
+
level: "error",
|
|
865
|
+
title: `issue ${issue.id} errored`,
|
|
866
|
+
detail: message,
|
|
867
|
+
fields: [["issue", issue.id], ["title", issue.title]],
|
|
868
|
+
});
|
|
869
|
+
await deps.provider.transition(issue, "blocked", `spf watch error: ${message}`).catch(() => undefined);
|
|
870
|
+
cleanupWorktree(deps, won);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
/**
|
|
874
|
+
* Today's single-dispatch path — `runIssue`'s entire body before fan-out
|
|
875
|
+
* existed, moved intact with its post-win tail extracted into
|
|
876
|
+
* `openPrForWinner` above: same statements, same order, same values. This is
|
|
877
|
+
* what makes `watch.fanout.n: 1` (the default) a no-op for the running
|
|
878
|
+
* daemon: same adw_id, same worktree, same fetch semantics, same marker,
|
|
879
|
+
* same PR, same notifications as before this feature existed.
|
|
880
|
+
*/
|
|
881
|
+
async function runIssueSingle(deps, issue) {
|
|
547
882
|
const branch = branchNameFor(issue);
|
|
548
883
|
const worktreePath = worktreePathFor(deps, issue);
|
|
549
884
|
const adwId = `issue-${issue.id}`;
|
|
@@ -578,56 +913,13 @@ async function runIssue(deps, issue) {
|
|
|
578
913
|
cleanupWorktree(deps, { worktree: worktreePath, branch });
|
|
579
914
|
return;
|
|
580
915
|
}
|
|
581
|
-
|
|
582
|
-
if (wtGit.diffFiles(`origin/${deps.baseBranch}`).length === 0) {
|
|
583
|
-
deps.log(`watch: ${issue.id}: chain succeeded but committed nothing — blocked`);
|
|
584
|
-
deps.notify({
|
|
585
|
-
kind: "issue_blocked",
|
|
586
|
-
level: "notice",
|
|
587
|
-
title: `issue ${issue.id} blocked`,
|
|
588
|
-
detail: `Chain "${deps.chain}" (adw_id ${adwId}) completed but left no committed changes.`,
|
|
589
|
-
fields: [["issue", issue.id], ["title", issue.title], ["chain", deps.chain], ["adw_id", adwId]],
|
|
590
|
-
});
|
|
591
|
-
await deps.provider.transition(issue, "blocked", `Chain "${deps.chain}" (adw_id ${adwId}) completed but left no committed changes.`);
|
|
592
|
-
cleanupWorktree(deps, { worktree: worktreePath, branch });
|
|
593
|
-
return;
|
|
594
|
-
}
|
|
595
|
-
wtGit.push("origin", branch);
|
|
596
|
-
// The human merging this PR sees whatever the reviewer found — or, if
|
|
597
|
-
// nothing reviewed this change at all, is told that plainly rather than
|
|
598
|
-
// left to assume a silent approval. `reviewSummary` is already
|
|
599
|
-
// sanitized/truncated by the caller (see runChain's own doc comment).
|
|
600
|
-
const reviewLine = result.reviewSummary
|
|
601
|
-
? result.reviewSummary
|
|
602
|
-
: result.reviewRequired
|
|
603
|
-
? "Reviewer ran, but no verdict could be read back from the session data."
|
|
604
|
-
: `Nothing reviewed this change — chain \`${deps.chain}\` has no reviewer step.`;
|
|
605
|
-
// No cross-linking magic keyword here on purpose (a code host paired
|
|
606
|
-
// with a different tracker has no "Closes #n" convention to hook into
|
|
607
|
-
// — see provider.ts) — the issue id in the title/body is plain text
|
|
608
|
-
// for humans, and, on a Jira+Bitbucket pairing, exactly what Jira's own
|
|
609
|
-
// Bitbucket integration scans for to link the PR automatically.
|
|
610
|
-
const pr = await deps.codeHost.openPr({
|
|
916
|
+
await openPrForWinner(deps, issue, {
|
|
611
917
|
branch,
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
await deps.provider.transition(issue, "review");
|
|
618
|
-
deps.log(`watch: ${issue.id}: opened PR #${pr.number} — review`);
|
|
619
|
-
deps.notify({
|
|
620
|
-
kind: "pr_opened",
|
|
621
|
-
level: "info",
|
|
622
|
-
title: `PR #${pr.number} opened`,
|
|
623
|
-
detail: reviewLine,
|
|
624
|
-
fields: [
|
|
625
|
-
["issue", issue.id],
|
|
626
|
-
["title", issue.title],
|
|
627
|
-
["chain", deps.chain],
|
|
628
|
-
["review", result.reviewSummary ? "reviewed" : result.reviewRequired ? "reviewer ran, no verdict" : "not reviewed"],
|
|
629
|
-
],
|
|
630
|
-
url: pr.url || undefined,
|
|
918
|
+
worktreePath,
|
|
919
|
+
adwId,
|
|
920
|
+
diffBase: `origin/${deps.baseBranch}`,
|
|
921
|
+
reviewRequired: result.reviewRequired,
|
|
922
|
+
reviewSummary: result.reviewSummary,
|
|
631
923
|
});
|
|
632
924
|
}
|
|
633
925
|
catch (error) {
|
|
@@ -644,6 +936,17 @@ async function runIssue(deps, issue) {
|
|
|
644
936
|
cleanupWorktree(deps, { worktree: worktreePath, branch });
|
|
645
937
|
}
|
|
646
938
|
}
|
|
939
|
+
/**
|
|
940
|
+
* One issue's full claim -> chain -> PR path, run in the background —
|
|
941
|
+
* `claimNewWork` doesn't await this. `deps.fanout` unset (or `n <= 1`) takes
|
|
942
|
+
* the single-dispatch path exactly as it always has; `watch.fanout.n > 1`
|
|
943
|
+
* fans out via `runBestOf` first.
|
|
944
|
+
*/
|
|
945
|
+
async function runIssue(deps, issue) {
|
|
946
|
+
if (!deps.fanout || deps.fanout.n <= 1)
|
|
947
|
+
return runIssueSingle(deps, issue);
|
|
948
|
+
return runIssueFanout(deps, issue, deps.fanout);
|
|
949
|
+
}
|
|
647
950
|
/**
|
|
648
951
|
* The spec's own `<prefix>:priority:pN` label, or `null` if it was never
|
|
649
952
|
* set. Deliberately NOT `issuePriority`'s default-to-`p2` behavior: `null`
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function excludeSpfDataFromGit(worktreePath: string): void;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keep a worktree's `.spf/data` symlink invisible to `git status`/`git add -A`.
|
|
3
|
+
*
|
|
4
|
+
* Moved here (out of `cli/commands/fanout.ts`) so both `cli/commands/fanout.ts`
|
|
5
|
+
* and `cli/commands/watch.ts` can call it without either command importing the
|
|
6
|
+
* other — `spawnSync` + `node:fs` only, no chains, no db, no sandbox, matching
|
|
7
|
+
* `core/git_helper.ts`'s own dependency discipline.
|
|
8
|
+
*
|
|
9
|
+
* `.spf/data/` — a trailing-slash DIRECTORY pattern, the shape a tracked
|
|
10
|
+
* `.gitignore` uses — does NOT match a SYMLINK of the same name, so without
|
|
11
|
+
* this, `git status` shows `?? .spf/data` and a committing chain's
|
|
12
|
+
* `git_helper.commitAll` (`git add -A`) stages it. Following a printed
|
|
13
|
+
* `git merge <branch>` instruction then fast-forwards the symlink straight
|
|
14
|
+
* into the MAIN repo, replacing its real `.spf/data` directory with a symlink
|
|
15
|
+
* pointing back at itself — destroying the trace db (`ls .spf/data` becomes
|
|
16
|
+
* `ELOOP`; see `paths.healBrokenDataDir` for the recovery side of this same
|
|
17
|
+
* bug). `info/exclude` is per-worktree, local-only, and idempotent to append
|
|
18
|
+
* to — the opposite of adding a pattern to a tracked `.gitignore`, which would
|
|
19
|
+
* ship this workaround into every clone for a symlink only a scoped-worktree
|
|
20
|
+
* caller (fan-out attempts, `spf watch`'s own build-lane worktrees) ever
|
|
21
|
+
* creates.
|
|
22
|
+
*/
|
|
23
|
+
import { spawnSync } from "node:child_process";
|
|
24
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
export function excludeSpfDataFromGit(worktreePath) {
|
|
27
|
+
const resolved = spawnSync("git", ["rev-parse", "--git-path", "info/exclude"], { cwd: worktreePath, encoding: "utf-8" });
|
|
28
|
+
if (resolved.status !== 0)
|
|
29
|
+
return; // best-effort: worst case is the pre-existing staging risk, not a crash
|
|
30
|
+
const excludePath = path.resolve(worktreePath, resolved.stdout.trim());
|
|
31
|
+
const line = ".spf/data";
|
|
32
|
+
const existing = existsSync(excludePath) ? readFileSync(excludePath, "utf-8") : "";
|
|
33
|
+
if (existing.split("\n").some((l) => l.trim() === line))
|
|
34
|
+
return;
|
|
35
|
+
mkdirSync(path.dirname(excludePath), { recursive: true });
|
|
36
|
+
appendFileSync(excludePath, `${existing && !existing.endsWith("\n") ? "\n" : ""}${line}\n`);
|
|
37
|
+
}
|