@tablation/crew 0.15.0 → 0.16.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/dist/cli.js
CHANGED
|
@@ -5657,6 +5657,128 @@ function spawnAgent(plan, emit) {
|
|
|
5657
5657
|
});
|
|
5658
5658
|
}
|
|
5659
5659
|
|
|
5660
|
+
// src/handoff-guard.ts
|
|
5661
|
+
var HANDOFF_BOUNCED_MARKER = "<!-- crew:handoff-bounced -->";
|
|
5662
|
+
var pushedEventBody = (sha, remote, branch) => `pushed ${sha} to ${remote}/${branch}`;
|
|
5663
|
+
function remoteTip(dir, remote, branch) {
|
|
5664
|
+
const out = gitOk(dir, ["ls-remote", "--heads", remote, `refs/heads/${branch}`]);
|
|
5665
|
+
if (out === null) return null;
|
|
5666
|
+
return out.split("\n").find((l) => l.endsWith(` refs/heads/${branch}`))?.split(" ")[0] ?? "";
|
|
5667
|
+
}
|
|
5668
|
+
function inspectHandoff(dir, cfg, ticket, role) {
|
|
5669
|
+
const remote = cfg.branch.remote;
|
|
5670
|
+
if (!remoteConfigured(dir, remote)) return { kind: "unverified", why: `no remote \`${remote}\` configured` };
|
|
5671
|
+
const branch = existingBranchForTicket(dir, cfg, ticket, role);
|
|
5672
|
+
if (!branch) {
|
|
5673
|
+
return {
|
|
5674
|
+
kind: "bounce",
|
|
5675
|
+
branch: null,
|
|
5676
|
+
remote,
|
|
5677
|
+
local: null,
|
|
5678
|
+
origin: null,
|
|
5679
|
+
reason: `no branch for this ticket exists in this checkout, so there is nothing on \`${remote}\` to hand to QA`
|
|
5680
|
+
};
|
|
5681
|
+
}
|
|
5682
|
+
const wt = worktrees(dir).find((w) => w.branch === branch);
|
|
5683
|
+
const local = wt ? headSha(wt.path) : gitOk(dir, ["rev-parse", `refs/heads/${branch}`]);
|
|
5684
|
+
const origin = remoteTip(dir, remote, branch);
|
|
5685
|
+
if (origin === null) return { kind: "unverified", why: `could not reach \`${remote}\` to check \`${branch}\`` };
|
|
5686
|
+
if (!origin) {
|
|
5687
|
+
return {
|
|
5688
|
+
kind: "bounce",
|
|
5689
|
+
branch,
|
|
5690
|
+
remote,
|
|
5691
|
+
local,
|
|
5692
|
+
origin: null,
|
|
5693
|
+
reason: `\`${branch}\` does not exist on \`${remote}\` \u2014 it was never pushed (or the push failed)`
|
|
5694
|
+
};
|
|
5695
|
+
}
|
|
5696
|
+
if (origin !== local) {
|
|
5697
|
+
return {
|
|
5698
|
+
kind: "bounce",
|
|
5699
|
+
branch,
|
|
5700
|
+
remote,
|
|
5701
|
+
local,
|
|
5702
|
+
origin,
|
|
5703
|
+
reason: `\`${remote}/${branch}\` is at \`${origin}\`, not at this worktree's HEAD \u2014 the latest commits were not pushed, or the remote moved`
|
|
5704
|
+
};
|
|
5705
|
+
}
|
|
5706
|
+
return { kind: "pushed", branch, sha: origin, remote };
|
|
5707
|
+
}
|
|
5708
|
+
function bounceComment(v, shipName2) {
|
|
5709
|
+
return `${HANDOFF_BOUNCED_MARKER}
|
|
5710
|
+
**Hand-off bounced: the branch is not on \`${v.remote}\` at the tip this ticket was built to.**
|
|
5711
|
+
|
|
5712
|
+
${shipName2 ? `Ship: ${shipName2}
|
|
5713
|
+
` : ""}Local HEAD: ${v.local ? `\`${v.local}\`` : "(no local branch)"}
|
|
5714
|
+
\`${v.remote}\`: ${v.origin ? `\`${v.origin}\`` : v.branch ? "(branch missing)" : "(no branch to look for)"}
|
|
5715
|
+
|
|
5716
|
+
${v.reason[0].toUpperCase()}${v.reason.slice(1)}. Set back to \`in_progress\`, still held by this ship; push with a plain \`git push --set-upstream ${v.remote} <branch>\` (never \`--force\`) and set \`fixed\` again.`;
|
|
5717
|
+
}
|
|
5718
|
+
async function enforceHandoff(i) {
|
|
5719
|
+
try {
|
|
5720
|
+
const t = await i.writer.ticket(i.ticketId);
|
|
5721
|
+
if (t.status !== i.contract.statuses.handoff) return { kind: "not-handed-off" };
|
|
5722
|
+
const log = { ticket: t.issue_id, step: "handoff" };
|
|
5723
|
+
const v = inspectHandoff(i.dir, i.cfg, t, i.role);
|
|
5724
|
+
if (v.kind === "unverified") {
|
|
5725
|
+
i.log.warn(`${t.issue_id} is at ${t.status} but the push was not checked: ${v.why}`, log);
|
|
5726
|
+
return { kind: "unverified", why: v.why };
|
|
5727
|
+
}
|
|
5728
|
+
if (v.kind === "pushed") {
|
|
5729
|
+
await i.writer.postEvent(t.id, pushedEventBody(v.sha, v.remote, v.branch), i.seatId);
|
|
5730
|
+
i.log.emit(`${t.issue_id} pushed at ${v.sha.slice(0, 7)} \u2014 handed on to QA`, log);
|
|
5731
|
+
return { kind: "pushed", sha: v.sha };
|
|
5732
|
+
}
|
|
5733
|
+
const heldBy = i.hold ? t[i.hold.column] : void 0;
|
|
5734
|
+
await i.writer.updateTicket(t.id, {
|
|
5735
|
+
status: i.contract.statuses.building,
|
|
5736
|
+
...t.assignee_id ? {} : { assignee_id: i.seatId },
|
|
5737
|
+
...i.hold && !heldBy ? { [i.hold.column]: i.hold.shipId } : {}
|
|
5738
|
+
});
|
|
5739
|
+
await i.writer.postEvent(t.id, bounceComment(v, i.shipName), i.seatId).catch(() => {
|
|
5740
|
+
i.log.warn("bounced, but the note failed to post", log);
|
|
5741
|
+
});
|
|
5742
|
+
i.log.warn(`${t.issue_id} bounced back to ${i.contract.statuses.building}: ${v.reason}`, log);
|
|
5743
|
+
return { kind: "bounced" };
|
|
5744
|
+
} catch (e) {
|
|
5745
|
+
i.log.warn(`could not check the hand-off: ${e.message}`, { step: "handoff" });
|
|
5746
|
+
return { kind: "failed", why: e.message };
|
|
5747
|
+
}
|
|
5748
|
+
}
|
|
5749
|
+
async function guardRunHandoff(route, ship, tracker, role, workingId, log) {
|
|
5750
|
+
if (role !== "dev" && role !== "design" || !workingId) return null;
|
|
5751
|
+
const seatId = route.resolved?.seats[role];
|
|
5752
|
+
if (!seatId) return null;
|
|
5753
|
+
try {
|
|
5754
|
+
const t = await tracker.ticket(workingId);
|
|
5755
|
+
const target = repoTargetFor(route, t.repo_id);
|
|
5756
|
+
if (!target) return null;
|
|
5757
|
+
const o = route.repoOverrides[target.name];
|
|
5758
|
+
const cfg = resolveRepoConfig(loadRepoConfig(target.dir), {
|
|
5759
|
+
hooks: { ...route.hooks, ...o?.hooks },
|
|
5760
|
+
labels: { ...route.labels, ...o?.labels },
|
|
5761
|
+
branch: { ...route.branch, ...o?.branch }
|
|
5762
|
+
}, target.dir);
|
|
5763
|
+
const affinity = await tracker.claimAffinity();
|
|
5764
|
+
return await enforceHandoff({
|
|
5765
|
+
writer: tracker,
|
|
5766
|
+
contract: tracker.contract,
|
|
5767
|
+
ticketId: workingId,
|
|
5768
|
+
role,
|
|
5769
|
+
dir: target.dir,
|
|
5770
|
+
cfg,
|
|
5771
|
+
seatId,
|
|
5772
|
+
shipName: ship.name,
|
|
5773
|
+
hold: affinity ? { column: affinity.column, shipId: affinity.shipId } : void 0,
|
|
5774
|
+
log
|
|
5775
|
+
});
|
|
5776
|
+
} catch (e) {
|
|
5777
|
+
log.warn(`could not check the hand-off: ${e.message}`, { step: "handoff" });
|
|
5778
|
+
return null;
|
|
5779
|
+
}
|
|
5780
|
+
}
|
|
5781
|
+
|
|
5660
5782
|
// src/connect-wizard.ts
|
|
5661
5783
|
import { delimiter, dirname as dirname6, join as join8 } from "path";
|
|
5662
5784
|
import { accessSync as accessSync2, constants as constants2, existsSync as existsSync8, mkdirSync as mkdirSync6, writeFileSync as writeFileSync5 } from "fs";
|
|
@@ -9499,6 +9621,7 @@ async function runRoleAgent(role, decision, ctx) {
|
|
|
9499
9621
|
}
|
|
9500
9622
|
try {
|
|
9501
9623
|
await spawnAgent(plan, emit);
|
|
9624
|
+
await guardRunHandoff(route, ship, tracker, role, workingId, emit);
|
|
9502
9625
|
} finally {
|
|
9503
9626
|
if (memberId) {
|
|
9504
9627
|
try {
|
|
@@ -10594,6 +10717,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
10594
10717
|
}
|
|
10595
10718
|
try {
|
|
10596
10719
|
await spawnAgent(fleetPlan, wemit);
|
|
10720
|
+
await guardRunHandoff(w.route, cfg.ship, fleetTracker, w.role, fleetWorkingId, wemit);
|
|
10597
10721
|
} finally {
|
|
10598
10722
|
dropLock();
|
|
10599
10723
|
if (fleetMemberId) {
|
|
@@ -10804,6 +10928,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
10804
10928
|
}
|
|
10805
10929
|
try {
|
|
10806
10930
|
await spawnAgent(plan, emit);
|
|
10931
|
+
await guardRunHandoff(route, cfg.ship, tracker2, current, workingId, emit);
|
|
10807
10932
|
} finally {
|
|
10808
10933
|
dropLock();
|
|
10809
10934
|
if (memberId) {
|
package/package.json
CHANGED
|
@@ -556,6 +556,12 @@ if `report_type` is either of those.
|
|
|
556
556
|
`in_progress` and the next `fixed` push again — a plain fast-forward, never
|
|
557
557
|
`--force`; if the push is rejected, someone else moved the branch, so stop
|
|
558
558
|
and say so on the ticket instead of forcing it.
|
|
559
|
+
The runner checks this when your session ends: if the ticket is at `fixed`
|
|
560
|
+
and `<remote>/<branch>` is missing or not at your worktree's HEAD, it sets
|
|
561
|
+
the ticket back to `in_progress` (still yours) with an event saying what it
|
|
562
|
+
found, and QA never sees it. When it matches, the runner records
|
|
563
|
+
`pushed <sha> to <remote>/<branch>` on the ticket itself; you still state the
|
|
564
|
+
sha in your last progress comment.
|
|
559
565
|
Leave the worktree and branch exactly where they are, unmerged: QA boots
|
|
560
566
|
*your worktree* on its derived ports to test the fix, so removing it
|
|
561
567
|
would leave QA nothing to test. Your last progress comment is what QA
|
|
@@ -62,6 +62,13 @@ human review, so "probably fine" is not a pass.
|
|
|
62
62
|
`git worktree add <the digest's worktree> <name>` from the ticket's repo
|
|
63
63
|
checkout (git creates the local tracking branch) — and carry on. Only
|
|
64
64
|
**MISSING** (gone locally and on the remote) means there is nothing to test.
|
|
65
|
+
**Name the sha you test.** Your starting comment states the
|
|
66
|
+
`<remote>/<branch>` tip you fetched. The runner records the pushed tip as an
|
|
67
|
+
event comment on the ticket, `pushed <sha> to <remote>/<branch>`; if the tip
|
|
68
|
+
you fetched differs from the latest such event, say so in that same comment
|
|
69
|
+
and verify the remote tip anyway — the remote is the only git truth, and the
|
|
70
|
+
difference is information, not a reason to refuse. If the branch is not on
|
|
71
|
+
the remote at all, bounce the ticket to `in_progress` with that reason.
|
|
65
72
|
5. **Run it.** The builder left its worktree in place for you: `cd` into it,
|
|
66
73
|
`nvm use` if the repository pins a version, and `eval` the **`ports`** hook
|
|
67
74
|
for this worktree's own ports (anything already listening on them is a dead
|
|
@@ -538,6 +538,12 @@ if `report_type` is either of those.
|
|
|
538
538
|
`in_progress` and the next `fixed` push again — a plain fast-forward, never
|
|
539
539
|
`--force`; if the push is rejected, someone else moved the branch, so stop
|
|
540
540
|
and say so on the ticket instead of forcing it.
|
|
541
|
+
The runner checks this when your session ends: if the ticket is at `fixed`
|
|
542
|
+
and `<remote>/<branch>` is missing or not at your worktree's HEAD, it sets
|
|
543
|
+
the ticket back to `in_progress` (still yours) with an event saying what it
|
|
544
|
+
found, and QA never sees it. When it matches, the runner records
|
|
545
|
+
`pushed <sha> to <remote>/<branch>` on the ticket itself; you still state the
|
|
546
|
+
sha in your last progress comment.
|
|
541
547
|
Leave the worktree and branch exactly where they are, unmerged: QA boots
|
|
542
548
|
*your worktree* on its derived ports to test the fix, so removing it
|
|
543
549
|
would leave QA nothing to test. Your last progress comment is what QA
|
|
@@ -54,6 +54,13 @@ human review, so "probably fine" is not a pass.
|
|
|
54
54
|
Does the change actually do what the comment claims? Does it handle the
|
|
55
55
|
empty/error/permission path, or only the happy one? Does it touch
|
|
56
56
|
anything the ticket never mentioned?
|
|
57
|
+
**Name the sha you test.** Your starting comment states the
|
|
58
|
+
`<remote>/<branch>` tip you fetched. The runner records the pushed tip as an
|
|
59
|
+
event comment on the ticket, `pushed <sha> to <remote>/<branch>`; if the tip
|
|
60
|
+
you fetched differs from the latest such event, say so in that same comment
|
|
61
|
+
and verify the remote tip anyway — the remote is the only git truth, and the
|
|
62
|
+
difference is information, not a reason to refuse. If the branch is not on
|
|
63
|
+
the remote at all, bounce the ticket to `in_progress` with that reason.
|
|
57
64
|
5. **Run it.** The builder left its worktree in place for you: `cd` into it,
|
|
58
65
|
`nvm use` if the repository pins a version, and `eval` the **`ports`** hook
|
|
59
66
|
for this worktree's own ports (anything already listening on them is a dead
|