@tablation/crew 0.69.0 → 0.69.1

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.
Files changed (2) hide show
  1. package/dist/cli.js +37 -8
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -6099,13 +6099,13 @@ var Tracker = class {
6099
6099
  * whatever wording the caller already used. Attributed to the seat's Crew
6100
6100
  * row, with the posting ship in `metadata`.
6101
6101
  */
6102
- async postEvent(ticketId, body, memberId, type = "note") {
6102
+ async postEvent(ticketId, body, memberId, type = "note", extra) {
6103
6103
  const auth = await this.authorshipProbe();
6104
6104
  const res = await this.eventsFetch("POST", `/records/${ticketId}/events`, {
6105
6105
  type,
6106
6106
  message: body,
6107
6107
  actorRecordId: memberId,
6108
- ...auth.shipId ? { metadata: { ship_id: auth.shipId } } : {}
6108
+ ...auth.shipId || extra ? { metadata: { ...auth.shipId ? { ship_id: auth.shipId } : {}, ...extra } } : {}
6109
6109
  });
6110
6110
  if (res.status === 404) throw new ConfigError(EVENT_LOG_MISSING);
6111
6111
  if (!res.ok) throw new Error(`posting a ${type} event failed: HTTP ${res.status}`);
@@ -10095,6 +10095,17 @@ var priorBounces = (ticket, events) => {
10095
10095
  (e) => e.event_type === "conflict_handed_back" && pushes.some((p) => p > stampOf(e))
10096
10096
  ).length;
10097
10097
  };
10098
+ var baseShaOf = (e) => {
10099
+ const m = e.metadata?.base_sha;
10100
+ if (typeof m === "string" && m) return m;
10101
+ return /Base tip: `([0-9a-f]{7,40})`/.exec(e.message ?? "")?.[1] ?? null;
10102
+ };
10103
+ var lastActedHandBack = (ticket, events) => {
10104
+ const mine = events.filter((e) => e.record_id === ticket.id);
10105
+ const pushes = mine.filter((e) => e.event_type === "pushed").map(stampOf);
10106
+ return mine.filter((e) => e.event_type === "conflict_handed_back" && pushes.some((p) => p > stampOf(e))).sort((a, b) => stampOf(b).localeCompare(stampOf(a)))[0];
10107
+ };
10108
+ var MAX_ABSORBED_HANDBACKS = 2;
10098
10109
  function planConflictBounce(cwd, ticket, branch, base, paths, events) {
10099
10110
  return {
10100
10111
  ticket,
@@ -10102,7 +10113,10 @@ function planConflictBounce(cwd, ticket, branch, base, paths, events) {
10102
10113
  base,
10103
10114
  paths,
10104
10115
  worktree: worktreeForBranch(cwd, branch),
10105
- priorBounces: priorBounces(ticket, events)
10116
+ priorBounces: priorBounces(ticket, events),
10117
+ cwd,
10118
+ baseSha: gitOk(cwd, ["rev-parse", "--verify", "-q", base]) || null,
10119
+ priorBaseSha: baseShaOf(lastActedHandBack(ticket, events) ?? { record_id: "", event_type: "" })
10106
10120
  };
10107
10121
  }
10108
10122
  function refreshBranch(worktree, base) {
@@ -10135,20 +10149,29 @@ function handBackComment(b, merged) {
10135
10149
 
10136
10150
  ${merged ? `\`${b.base}\` has been merged into \`${b.branch}\` in its worktree and the conflict is left in place, so \`git status\` there shows exactly what disagrees. Resolve it, commit, push as a plain fast-forward (the branch is pushed, so rebasing it is not an option) and hand back to QA as usual \u2014 \`git merge --abort\` backs the merge out if you would rather start differently.` : `This ship has no worktree for \`${b.branch}\`, so nothing has been merged for you. Merge \`${b.base}\` into the branch wherever it lives, resolve the conflict, and commit.`}
10137
10151
 
10138
- Nothing was written to \`${b.base}\`: the failed merge was rewound, and the rest of the release shipped without this ticket. Re-verifying is what puts it back in the merge queue.`;
10152
+ ${b.baseSha ? `Base tip: \`${b.baseSha}\`
10153
+
10154
+ ` : ""}Nothing was written to \`${b.base}\`: the failed merge was rewound, and the rest of the release shipped without this ticket. Re-verifying is what puts it back in the merge queue.`;
10139
10155
  }
10140
10156
  function escalateComment(b) {
10157
+ const where = b.paths.length ? ` (${b.paths.map((p) => `\`${p}\``).join(", ")})` : "";
10158
+ const absorbed = b.priorBounces >= MAX_ABSORBED_HANDBACKS;
10141
10159
  return `${BOUNCE_MARKER}
10142
10160
 
10143
10161
  **Conflicting again after a hand-back \u2014 stopping rather than looping.**
10144
10162
 
10145
- \`${b.branch}\` was handed back once for a merge conflict, verified again, and conflicts against \`${b.base}\` a second time${b.paths.length ? ` (${b.paths.map((p) => `\`${p}\``).join(", ")})` : ""}.
10163
+ \`${b.branch}\` was handed back ${b.priorBounces === 1 ? "once" : `${b.priorBounces} times`} for a merge conflict, verified again, and conflicts against \`${b.base}\` ${b.priorBounces === 1 ? "a second time" : `again (conflict ${b.priorBounces + 1})`}${where}.
10146
10164
 
10147
- Either \`${b.base}\` is moving faster than this branch can absorb it, or this ticket overlaps work that has already shipped and needs rethinking rather than merging. That is a call for a person, not another lap through the lanes.`;
10165
+ ${absorbed ? `Each earlier hand-back was answered by merging \`${b.base}\` in, and \`${b.base}\` moved underneath the branch every time. This file is changing faster than the lanes can absorb it, so it needs a person to sequence this ticket against the others touching it.` : `The branch ${b.priorBaseSha ? `does not contain \`${b.priorBaseSha}\`, the \`${b.base}\` tip it was last handed back for` : "was pushed"} \u2014 the hand-back was answered without merging \`${b.base}\` in, or this ticket overlaps work that has already shipped and needs rethinking rather than merging.`}
10166
+ That is a call for a person, not another lap through the lanes.`;
10148
10167
  }
10149
10168
  async function applyConflictBounce(writer, b, contract, memberId, log, dryRun) {
10150
10169
  const t = { ticket: b.ticket.issue_id, step: "merge" };
10151
- if (b.priorBounces > 0) {
10170
+ const absorbed = b.priorBounces > 0 && b.priorBounces < MAX_ABSORBED_HANDBACKS && !!b.priorBaseSha && isAncestor(b.worktree ?? b.cwd, b.priorBaseSha, b.branch);
10171
+ if (absorbed) {
10172
+ log.emit(`${b.ticket.issue_id} conflicts again, but ${b.base} moved since the hand-back was answered \u2014 handing back again`, t);
10173
+ }
10174
+ if (b.priorBounces > 0 && !absorbed) {
10152
10175
  if (dryRun) {
10153
10176
  log.emit(`would escalate to ${contract.statuses.needsHuman} \u2014 handed back before`, t);
10154
10177
  return { kind: "escalated" };
@@ -10196,7 +10219,13 @@ async function applyConflictBounce(writer, b, contract, memberId, log, dryRun) {
10196
10219
  { status: contract.statuses.building, assignee_id: null },
10197
10220
  b.ticket.updated_at
10198
10221
  );
10199
- await writer.postEvent(b.ticket.id, handBackComment(b, merged), memberId, "conflict_handed_back").catch(() => {
10222
+ await writer.postEvent(
10223
+ b.ticket.id,
10224
+ handBackComment(b, merged),
10225
+ memberId,
10226
+ "conflict_handed_back",
10227
+ b.baseSha ? { base_sha: b.baseSha } : void 0
10228
+ ).catch(() => {
10200
10229
  log.warn("handed back, but the note failed to post", t);
10201
10230
  });
10202
10231
  log.emit(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.69.0",
3
+ "version": "0.69.1",
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",