@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)) return { exists: true };
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 target = repoTargetFor(route, t.repo_id);
8738
- if (!target) return null;
8739
- const o = route.repoOverrides[target.name];
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.49.2",
3
+ "version": "0.49.4",
4
4
  "description": "A standing team of headless agents that picks work off a Tablation board, does it on a machine you control, and reports back.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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.