@tablation/crew 0.49.3 → 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
@@ -4971,6 +4971,19 @@ var Tracker = class {
4971
4971
  limit: 500
4972
4972
  });
4973
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
+ }
4974
4987
  /**
4975
4988
  * The tickets behind a set of ticket numbers — the cleanup sweeps' input
4976
4989
  * (CREW-1593).
@@ -8738,21 +8751,15 @@ async function enforceHandoff(i) {
8738
8751
  return { kind: "failed", why: e.message };
8739
8752
  }
8740
8753
  }
8741
- async function guardRunHandoff(route, ship, tracker, role, workingId, log) {
8754
+ async function guardRunHandoff(route, ship, tracker, role, workingId, log, since2) {
8742
8755
  if (!workingId || role !== "dev" && role !== "design" && role !== "qa") return null;
8743
8756
  const seatId = route.resolved?.seats[role];
8744
8757
  if (!seatId) return null;
8745
8758
  try {
8746
8759
  const t = await tracker.ticket(workingId);
8747
- const target = repoTargetFor(route, t.repo_id);
8748
- if (!target) return null;
8749
- const o = route.repoOverrides[target.name];
8750
- const cfg = resolveRepoConfig(loadRepoConfig(target.dir), {
8751
- hooks: { ...route.hooks, ...o?.hooks },
8752
- labels: { ...route.labels, ...o?.labels },
8753
- branch: { ...route.branch, ...o?.branch },
8754
- worktrees: { nameOverride: o?.worktreeName }
8755
- }, target.dir);
8760
+ const resolved = repoConfigFor(route, t);
8761
+ if (!resolved) return null;
8762
+ const { target, cfg } = resolved;
8756
8763
  if (role === "qa") {
8757
8764
  await retireRunWorktree(tracker, target.dir, cfg, t, role, seatId, ship.name, log);
8758
8765
  return null;
@@ -8771,6 +8778,7 @@ async function guardRunHandoff(route, ship, tracker, role, workingId, log) {
8771
8778
  log
8772
8779
  });
8773
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);
8774
8782
  return outcome;
8775
8783
  } catch (e) {
8776
8784
  log.warn(`could not check the hand-off: ${e.message}`, { step: "handoff" });
@@ -8793,6 +8801,88 @@ async function retireRunWorktree(tracker, dir, cfg, t, role, seatId, shipName2,
8793
8801
  log.warn(`could not retire the run's worktree: ${e.message}`, { step: "handoff" });
8794
8802
  }
8795
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
+ }
8796
8886
 
8797
8887
  // src/connect-wizard.ts
8798
8888
  import { delimiter, dirname as dirname8, join as join17 } from "path";
@@ -12743,8 +12833,9 @@ async function runRoleAgent(role, decision, ctx) {
12743
12833
  emit.warn(`could not beat ship engaged: ${e.message}`, { step: "agent" });
12744
12834
  }
12745
12835
  try {
12836
+ const runStart = (/* @__PURE__ */ new Date()).toISOString();
12746
12837
  await spawnAgent(plan, emit);
12747
- await guardRunHandoff(route, ship, tracker, role, workingId, emit);
12838
+ await guardRunHandoff(route, ship, tracker, role, workingId, emit, runStart);
12748
12839
  } finally {
12749
12840
  if (memberId) {
12750
12841
  try {
@@ -14019,8 +14110,9 @@ ${changed2.map((c) => ` - ${c}
14019
14110
  wemit.warn(`could not beat ship engaged: ${e.message}`, { step: "agent" });
14020
14111
  }
14021
14112
  try {
14113
+ const runStart = (/* @__PURE__ */ new Date()).toISOString();
14022
14114
  await spawnAgent(fleetPlan, wemit);
14023
- await guardRunHandoff(w.route, cfg.ship, fleetTracker, w.role, fleetWorkingId, wemit);
14115
+ await guardRunHandoff(w.route, cfg.ship, fleetTracker, w.role, fleetWorkingId, wemit, runStart);
14024
14116
  } finally {
14025
14117
  dropLock();
14026
14118
  if (fleetMemberId) {
@@ -14234,8 +14326,9 @@ ${changed2.map((c) => ` - ${c}
14234
14326
  emit.warn(`could not beat ship engaged: ${e.message}`, { step: "agent" });
14235
14327
  }
14236
14328
  try {
14329
+ const runStart = (/* @__PURE__ */ new Date()).toISOString();
14237
14330
  await spawnAgent(plan, emit);
14238
- await guardRunHandoff(route, cfg.ship, tracker2, current, workingId, emit);
14331
+ await guardRunHandoff(route, cfg.ship, tracker2, current, workingId, emit, runStart);
14239
14332
  } finally {
14240
14333
  dropLock();
14241
14334
  if (memberId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.49.3",
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.