@tablation/crew 0.49.2 → 0.49.4
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
|
@@ -4125,7 +4125,17 @@ function levelRemoteBranch(cwd, remote, branch) {
|
|
|
4125
4125
|
}
|
|
4126
4126
|
const remoteTip2 = gitOk(cwd, ["rev-parse", "-q", "--verify", `refs/remotes/${remote}/${branch}`]);
|
|
4127
4127
|
if (!remoteTip2 || remoteTip2 === local) return { exists: true };
|
|
4128
|
-
if (!isAncestor(cwd, local, remoteTip2))
|
|
4128
|
+
if (!isAncestor(cwd, local, remoteTip2)) {
|
|
4129
|
+
const diverged = !isAncestor(cwd, remoteTip2, local);
|
|
4130
|
+
const holder2 = diverged ? worktrees(cwd).find((w) => w.branch === branch && !w.prunable) : void 0;
|
|
4131
|
+
if (holder2) {
|
|
4132
|
+
return {
|
|
4133
|
+
exists: true,
|
|
4134
|
+
problem: `local ${branch} at ${local.slice(0, 8)} has commits not on ${remote}/${branch} at ${remoteTip2.slice(0, 8)}; worktree ${holder2.path}`
|
|
4135
|
+
};
|
|
4136
|
+
}
|
|
4137
|
+
return { exists: true };
|
|
4138
|
+
}
|
|
4129
4139
|
const holder = worktrees(cwd).find((w) => w.branch === branch && !w.prunable);
|
|
4130
4140
|
if (holder) {
|
|
4131
4141
|
if (gitOk(holder.path, ["status", "--porcelain"])) {
|
|
@@ -4961,6 +4971,19 @@ var Tracker = class {
|
|
|
4961
4971
|
limit: 500
|
|
4962
4972
|
});
|
|
4963
4973
|
}
|
|
4974
|
+
/**
|
|
4975
|
+
* Tickets at the hand-off status whose row changed at or after `since`,
|
|
4976
|
+
* other than `exceptId` (CREW-1759: a run's stray hand-offs).
|
|
4977
|
+
*/
|
|
4978
|
+
async handedOffSince(since2, exceptId) {
|
|
4979
|
+
const c = this.contract;
|
|
4980
|
+
const rows = await this.client.records.list(this.models.issues, {
|
|
4981
|
+
filters: encodeFilters([{ columnName: c.columns.status, operator: "EQ", value: c.statuses.handoff }]),
|
|
4982
|
+
limit: 100
|
|
4983
|
+
});
|
|
4984
|
+
const from = Date.parse(since2);
|
|
4985
|
+
return rows.filter((t) => t.id !== exceptId && Date.parse(t.updated_at) >= from);
|
|
4986
|
+
}
|
|
4964
4987
|
/**
|
|
4965
4988
|
* The tickets behind a set of ticket numbers — the cleanup sweeps' input
|
|
4966
4989
|
* (CREW-1593).
|
|
@@ -8728,21 +8751,15 @@ async function enforceHandoff(i) {
|
|
|
8728
8751
|
return { kind: "failed", why: e.message };
|
|
8729
8752
|
}
|
|
8730
8753
|
}
|
|
8731
|
-
async function guardRunHandoff(route, ship, tracker, role, workingId, log) {
|
|
8754
|
+
async function guardRunHandoff(route, ship, tracker, role, workingId, log, since2) {
|
|
8732
8755
|
if (!workingId || role !== "dev" && role !== "design" && role !== "qa") return null;
|
|
8733
8756
|
const seatId = route.resolved?.seats[role];
|
|
8734
8757
|
if (!seatId) return null;
|
|
8735
8758
|
try {
|
|
8736
8759
|
const t = await tracker.ticket(workingId);
|
|
8737
|
-
const
|
|
8738
|
-
if (!
|
|
8739
|
-
const
|
|
8740
|
-
const cfg = resolveRepoConfig(loadRepoConfig(target.dir), {
|
|
8741
|
-
hooks: { ...route.hooks, ...o?.hooks },
|
|
8742
|
-
labels: { ...route.labels, ...o?.labels },
|
|
8743
|
-
branch: { ...route.branch, ...o?.branch },
|
|
8744
|
-
worktrees: { nameOverride: o?.worktreeName }
|
|
8745
|
-
}, target.dir);
|
|
8760
|
+
const resolved = repoConfigFor(route, t);
|
|
8761
|
+
if (!resolved) return null;
|
|
8762
|
+
const { target, cfg } = resolved;
|
|
8746
8763
|
if (role === "qa") {
|
|
8747
8764
|
await retireRunWorktree(tracker, target.dir, cfg, t, role, seatId, ship.name, log);
|
|
8748
8765
|
return null;
|
|
@@ -8761,6 +8778,7 @@ async function guardRunHandoff(route, ship, tracker, role, workingId, log) {
|
|
|
8761
8778
|
log
|
|
8762
8779
|
});
|
|
8763
8780
|
if (outcome.kind === "pushed") await retireRunWorktree(tracker, target.dir, cfg, t, role, seatId, ship.name, log);
|
|
8781
|
+
if (since2) await guardRunStrays(route, ship, tracker, role, workingId, since2, log);
|
|
8764
8782
|
return outcome;
|
|
8765
8783
|
} catch (e) {
|
|
8766
8784
|
log.warn(`could not check the hand-off: ${e.message}`, { step: "handoff" });
|
|
@@ -8783,6 +8801,88 @@ async function retireRunWorktree(tracker, dir, cfg, t, role, seatId, shipName2,
|
|
|
8783
8801
|
log.warn(`could not retire the run's worktree: ${e.message}`, { step: "handoff" });
|
|
8784
8802
|
}
|
|
8785
8803
|
}
|
|
8804
|
+
function repoConfigFor(route, t) {
|
|
8805
|
+
const target = repoTargetFor(route, t.repo_id);
|
|
8806
|
+
if (!target) return null;
|
|
8807
|
+
const o = route.repoOverrides[target.name];
|
|
8808
|
+
const cfg = resolveRepoConfig(loadRepoConfig(target.dir), {
|
|
8809
|
+
hooks: { ...route.hooks, ...o?.hooks },
|
|
8810
|
+
labels: { ...route.labels, ...o?.labels },
|
|
8811
|
+
branch: { ...route.branch, ...o?.branch },
|
|
8812
|
+
worktrees: { nameOverride: o?.worktreeName }
|
|
8813
|
+
}, target.dir);
|
|
8814
|
+
return { target, cfg };
|
|
8815
|
+
}
|
|
8816
|
+
var STRAY_HANDOFF_MARKER = "<!-- crew:stray-handoff -->";
|
|
8817
|
+
async function guardStrayHandoffs(i) {
|
|
8818
|
+
const st = i.contract.statuses;
|
|
8819
|
+
if (i.role === "qa") return;
|
|
8820
|
+
for (const t of i.candidates) {
|
|
8821
|
+
const at = { ticket: t.issue_id, step: "handoff" };
|
|
8822
|
+
try {
|
|
8823
|
+
const holder = i.myShipId ? ticketHolderShipId(t, i.contract) : null;
|
|
8824
|
+
if (holder && holder !== i.myShipId) {
|
|
8825
|
+
await i.writer.updateTicket(t.id, { status: st.building });
|
|
8826
|
+
await i.writer.postEvent(
|
|
8827
|
+
t.id,
|
|
8828
|
+
`${STRAY_HANDOFF_MARKER}
|
|
8829
|
+
**Hand-off bounced: this ticket is held by another ship.**
|
|
8830
|
+
|
|
8831
|
+
${i.shipName ? `${i.shipName}'s ` : "A "}${i.role} run set it to \`${st.handoff}\` without having claimed it. Set back to \`${st.building}\`; the holding ship's own hand-off is the one that counts.`,
|
|
8832
|
+
i.seatId,
|
|
8833
|
+
"bounced"
|
|
8834
|
+
).catch(() => i.log.warn("bounced, but the note failed to post", at));
|
|
8835
|
+
i.log.warn(`${t.issue_id} was handed off by a run that did not claim it, while held by another ship \u2014 bounced to ${st.building}`, at);
|
|
8836
|
+
continue;
|
|
8837
|
+
}
|
|
8838
|
+
const co = i.checkout(t);
|
|
8839
|
+
if (!co || !existingBranchForTicket(co.dir, co.cfg, t, i.role)) {
|
|
8840
|
+
i.log.warn(`${t.issue_id} moved to ${st.handoff} during a run that did not claim it; no local branch to verify, left alone`, at);
|
|
8841
|
+
continue;
|
|
8842
|
+
}
|
|
8843
|
+
const v = inspectHandoff(co.dir, co.cfg, t, i.role);
|
|
8844
|
+
i.log.warn(`${t.issue_id} was handed off by a run that did not claim it`, at);
|
|
8845
|
+
if (v.kind === "pushed") {
|
|
8846
|
+
await i.writer.postEvent(t.id, pushedEventBody(v.sha, v.remote, v.branch), i.seatId, "pushed");
|
|
8847
|
+
} else if (v.kind === "bounce") {
|
|
8848
|
+
if (v.origin && v.local && !isAncestor(co.dir, v.origin, v.local)) {
|
|
8849
|
+
i.log.warn(`${t.issue_id}: \`${v.remote}/${v.branch}\` is at ${v.origin.slice(0, 7)}, not a tip this checkout built \u2014 left alone`, at);
|
|
8850
|
+
continue;
|
|
8851
|
+
}
|
|
8852
|
+
await i.writer.updateTicket(t.id, { status: st.building, ...t.assignee_id ? {} : { assignee_id: i.seatId } });
|
|
8853
|
+
await i.writer.postEvent(t.id, bounceComment(v, i.shipName), i.seatId, "bounced").catch(() => {
|
|
8854
|
+
});
|
|
8855
|
+
}
|
|
8856
|
+
} catch (e) {
|
|
8857
|
+
i.log.warn(`could not check the stray hand-off: ${e.message}`, at);
|
|
8858
|
+
}
|
|
8859
|
+
}
|
|
8860
|
+
}
|
|
8861
|
+
async function guardRunStrays(route, ship, tracker, role, workingId, since2, log) {
|
|
8862
|
+
const seatId = route.resolved?.seats[role];
|
|
8863
|
+
if (!seatId) return;
|
|
8864
|
+
try {
|
|
8865
|
+
const affinity = await tracker.claimAffinity();
|
|
8866
|
+
const candidates = await tracker.handedOffSince(since2, workingId);
|
|
8867
|
+
if (candidates.length === 0) return;
|
|
8868
|
+
await guardStrayHandoffs({
|
|
8869
|
+
writer: tracker,
|
|
8870
|
+
contract: tracker.contract,
|
|
8871
|
+
candidates,
|
|
8872
|
+
seatId,
|
|
8873
|
+
role,
|
|
8874
|
+
myShipId: affinity?.shipId ?? null,
|
|
8875
|
+
shipName: ship.name,
|
|
8876
|
+
checkout: (t) => {
|
|
8877
|
+
const r = repoConfigFor(route, t);
|
|
8878
|
+
return r ? { dir: r.target.dir, cfg: r.cfg } : null;
|
|
8879
|
+
},
|
|
8880
|
+
log
|
|
8881
|
+
});
|
|
8882
|
+
} catch (e) {
|
|
8883
|
+
log.warn(`could not check for stray hand-offs: ${e.message}`, { step: "handoff" });
|
|
8884
|
+
}
|
|
8885
|
+
}
|
|
8786
8886
|
|
|
8787
8887
|
// src/connect-wizard.ts
|
|
8788
8888
|
import { delimiter, dirname as dirname8, join as join17 } from "path";
|
|
@@ -9784,6 +9884,14 @@ async function runReleaseCycle(o, movedPast2) {
|
|
|
9784
9884
|
const moved = branch ? movedPastVerified(o.cwd, branch, verified) : null;
|
|
9785
9885
|
if (!moved) continue;
|
|
9786
9886
|
drop.add(t.id);
|
|
9887
|
+
const remoteTip2 = branch ? gitOk(o.cwd, ["rev-parse", "-q", "--verify", `refs/remotes/${o.repo.branch.remote}/${branch}^{commit}`]) : null;
|
|
9888
|
+
if (remoteTip2 && remoteTip2 !== moved.tip) {
|
|
9889
|
+
o.emit.error(
|
|
9890
|
+
`not releasing ${t.issue_id}: local ${branch} at ${moved.tip.slice(0, 8)} is not on ${o.repo.branch.remote}/${branch} (${remoteTip2.slice(0, 8)}); not requeuing it`,
|
|
9891
|
+
{ ticket: t.issue_id }
|
|
9892
|
+
);
|
|
9893
|
+
continue;
|
|
9894
|
+
}
|
|
9787
9895
|
movedPast2.push({ ticket: t, verified: moved.verified, head: moved.tip, branch: pushedBranchForTicket(o.repo, t) });
|
|
9788
9896
|
o.emit.error(
|
|
9789
9897
|
`not releasing ${t.issue_id}: ${branch} is at ${moved.tip.slice(0, 8)}, past the ${moved.verified.slice(0, 8)} QA verified`,
|
|
@@ -12725,8 +12833,9 @@ async function runRoleAgent(role, decision, ctx) {
|
|
|
12725
12833
|
emit.warn(`could not beat ship engaged: ${e.message}`, { step: "agent" });
|
|
12726
12834
|
}
|
|
12727
12835
|
try {
|
|
12836
|
+
const runStart = (/* @__PURE__ */ new Date()).toISOString();
|
|
12728
12837
|
await spawnAgent(plan, emit);
|
|
12729
|
-
await guardRunHandoff(route, ship, tracker, role, workingId, emit);
|
|
12838
|
+
await guardRunHandoff(route, ship, tracker, role, workingId, emit, runStart);
|
|
12730
12839
|
} finally {
|
|
12731
12840
|
if (memberId) {
|
|
12732
12841
|
try {
|
|
@@ -14001,8 +14110,9 @@ ${changed2.map((c) => ` - ${c}
|
|
|
14001
14110
|
wemit.warn(`could not beat ship engaged: ${e.message}`, { step: "agent" });
|
|
14002
14111
|
}
|
|
14003
14112
|
try {
|
|
14113
|
+
const runStart = (/* @__PURE__ */ new Date()).toISOString();
|
|
14004
14114
|
await spawnAgent(fleetPlan, wemit);
|
|
14005
|
-
await guardRunHandoff(w.route, cfg.ship, fleetTracker, w.role, fleetWorkingId, wemit);
|
|
14115
|
+
await guardRunHandoff(w.route, cfg.ship, fleetTracker, w.role, fleetWorkingId, wemit, runStart);
|
|
14006
14116
|
} finally {
|
|
14007
14117
|
dropLock();
|
|
14008
14118
|
if (fleetMemberId) {
|
|
@@ -14216,8 +14326,9 @@ ${changed2.map((c) => ` - ${c}
|
|
|
14216
14326
|
emit.warn(`could not beat ship engaged: ${e.message}`, { step: "agent" });
|
|
14217
14327
|
}
|
|
14218
14328
|
try {
|
|
14329
|
+
const runStart = (/* @__PURE__ */ new Date()).toISOString();
|
|
14219
14330
|
await spawnAgent(plan, emit);
|
|
14220
|
-
await guardRunHandoff(route, cfg.ship, tracker2, current, workingId, emit);
|
|
14331
|
+
await guardRunHandoff(route, cfg.ship, tracker2, current, workingId, emit, runStart);
|
|
14221
14332
|
} finally {
|
|
14222
14333
|
dropLock();
|
|
14223
14334
|
if (memberId) {
|
package/package.json
CHANGED
|
@@ -478,6 +478,12 @@ existing worktree and note that the status change was not needed.
|
|
|
478
478
|
|
|
479
479
|
- One ticket at a time. Never mix two tickets' changes in one branch,
|
|
480
480
|
worktree or commit.
|
|
481
|
+
- **A ticket that appears mid-run is not yours this run.** A bounce, a
|
|
482
|
+
re-accept or a hand-back that lands while you work another ticket is left for
|
|
483
|
+
a later run, which claims it with `assignee_id` and the ship hold like any
|
|
484
|
+
other. Finish the ticket you claimed. Before touching any worktree, re-fetch
|
|
485
|
+
its record and stop if the ship hold names another ship. Setting `fixed` on a
|
|
486
|
+
ticket you did not claim is bounced by the runner.
|
|
481
487
|
- **Stay in your lane.** Status decides QA's slice; `needs_design` decides
|
|
482
488
|
dev versus design. Both are checked before assignee and before whether a
|
|
483
489
|
worktree exists.
|
|
@@ -478,6 +478,12 @@ existing worktree and note that the status change was not needed.
|
|
|
478
478
|
|
|
479
479
|
- One ticket at a time. Never mix two tickets' changes in one branch,
|
|
480
480
|
worktree or commit.
|
|
481
|
+
- **A ticket that appears mid-run is not yours this run.** A bounce, a
|
|
482
|
+
re-accept or a hand-back that lands while you work another ticket is left for
|
|
483
|
+
a later run, which claims it with `assignee_id` and the ship hold like any
|
|
484
|
+
other. Finish the ticket you claimed. Before touching any worktree, re-fetch
|
|
485
|
+
its record and stop if the ship hold names another ship. Setting `fixed` on a
|
|
486
|
+
ticket you did not claim is bounced by the runner.
|
|
481
487
|
- **Stay in your lane.** Status decides QA's slice (`fixed` and `qa`);
|
|
482
488
|
everything else is dev's. Checked before assignee and before whether a
|
|
483
489
|
worktree exists.
|