@tablation/crew 0.49.4 → 0.49.6

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
@@ -4931,6 +4931,8 @@ var Tracker = class {
4931
4931
  * writes pass its seat's agent key so the event log and rule context see an
4932
4932
  * agent, not the ship's human principal.
4933
4933
  */
4934
+ /** Told once per failed best-effort read (`events`); poll.ts points it at the pass's emitter. */
4935
+ onReadFailure;
4934
4936
  constructor(route, ship, opts = {}) {
4935
4937
  this.route = route;
4936
4938
  if (!route.resolved) {
@@ -5372,10 +5374,16 @@ var Tracker = class {
5372
5374
  */
5373
5375
  async events(type, limit = 500) {
5374
5376
  const filters = encodeURIComponent(JSON.stringify([{ columnName: "event_type", operator: "EQ", value: type }]));
5375
- const res = await this.eventsFetch("GET", `/events?limit=${limit}&filters=${filters}`);
5376
- if (res.status === 404) return [];
5377
- if (!res.ok) throw new Error(`reading ${type} events failed: HTTP ${res.status}`);
5378
- const rows = await res.json();
5377
+ let rows;
5378
+ try {
5379
+ const res = await this.eventsFetch("GET", `/events?limit=${limit}&filters=${filters}`);
5380
+ if (res.status === 404) return [];
5381
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
5382
+ rows = await res.json();
5383
+ } catch (err) {
5384
+ this.onReadFailure?.(`reading ${type} events failed: ${err instanceof Error ? err.message : String(err)}`);
5385
+ return [];
5386
+ }
5379
5387
  return rows.filter((e) => e.event_type === type).sort((a, b) => Date.parse(b.occurred_at ?? b.created_at ?? "") - Date.parse(a.occurred_at ?? a.created_at ?? ""));
5380
5388
  }
5381
5389
  /** The newest event of `type` on a ticket, or undefined — e.g. the `pushed` tip QA verifies against. */
@@ -6112,6 +6120,7 @@ function operatorTodo(tickets, contract, operatorId) {
6112
6120
  }
6113
6121
 
6114
6122
  // src/digest.ts
6123
+ var DIRECTION_EVENT_TYPES = ["conflict_handed_back", "requeued", "bounced", "conflict_escalated"];
6115
6124
  function displayKey2(t) {
6116
6125
  return t.issue_tag ?? t.issue_id ?? "?";
6117
6126
  }
@@ -6146,10 +6155,24 @@ function lastComment(i, ticketId) {
6146
6155
  if (!last) return "\u2014";
6147
6156
  return `${last.created_at.slice(0, 16)}Z ${author(last, i)}`;
6148
6157
  }
6158
+ var eventAt = (e) => e.occurred_at ?? e.created_at ?? "";
6159
+ function pendingDirection(i, ticketId) {
6160
+ const mine = (i.events ?? []).filter((e) => e.record_id === ticketId);
6161
+ const pushed = mine.filter((e) => e.event_type === "pushed").map(eventAt).sort().pop() ?? "";
6162
+ return mine.filter((e) => DIRECTION_EVENT_TYPES.includes(e.event_type) && eventAt(e) > pushed).sort((a, b) => eventAt(a) < eventAt(b) ? 1 : -1)[0];
6163
+ }
6164
+ function directionCell(i, ticketId) {
6165
+ const d = pendingDirection(i, ticketId);
6166
+ if (!d) return "\u2014";
6167
+ const msg = (d.message ?? "").replace(/\s+/g, " ").trim().slice(0, 160);
6168
+ return `${d.event_type} ${eventAt(d).slice(0, 16)}Z${msg ? ` \u2014 ${msg}` : ""}`.replace(/\|/g, "/");
6169
+ }
6149
6170
  function newFromOthers(i, ticketId) {
6150
- return i.comments.filter(
6171
+ const comments = i.comments.filter(
6151
6172
  (c) => c.ticket_id === ticketId && c.team_member_id !== i.me && c.created_at > i.watermark
6152
6173
  ).length;
6174
+ const d = pendingDirection(i, ticketId);
6175
+ return comments + (d && eventAt(d) > i.watermark ? 1 : 0);
6153
6176
  }
6154
6177
  function who(t, i) {
6155
6178
  return whoAssigned(t, i) + heldBySuffix(t, i);
@@ -6188,14 +6211,14 @@ ${sorted(ts).map(row).join("\n")}
6188
6211
  `;
6189
6212
  }
6190
6213
  function buildingDigest(i) {
6191
- const header = "| ticket | repo | branch | worktree | mode | status | assignee | filed by | sev | pri | eff | updated | last comment | new since last poll |\n|---|---|---|---|---|---|---|---|---|---|---|---|---|---|";
6214
+ const header = "| ticket | repo | branch | worktree | mode | status | assignee | filed by | sev | pri | eff | updated | last comment | runner direction | new since last poll |\n|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|";
6192
6215
  const row = (t) => {
6193
6216
  const n = newFromOthers(i, t.id);
6194
6217
  const branch = i.branchFor ? i.branchFor(t) : "";
6195
6218
  const worktree = i.worktreeFor ? i.worktreeFor(t) : "";
6196
6219
  const dir = i.dirFor ? i.dirFor(t) : null;
6197
6220
  const repo = i.dirFor ? dir ?? "**NO CHECKOUT**" : "";
6198
- return `| ${displayKey2(t)} | ${repo} | ${branch} | ${worktree} | ${modeCell(t, i)} | ${t.status} | ${who(t, i)} | ${filedBy(t, i)} | ${t.severity ?? "\u2014"} | ${t.priority ?? "\u2014"} | ${effCell(t)} | ${t.updated_at.slice(0, 16)}Z | ${lastComment(i, t.id)} | ${n > 0 ? `**${n} new**` : "\u2014"} |`;
6221
+ return `| ${displayKey2(t)} | ${repo} | ${branch} | ${worktree} | ${modeCell(t, i)} | ${t.status} | ${who(t, i)} | ${filedBy(t, i)} | ${t.severity ?? "\u2014"} | ${t.priority ?? "\u2014"} | ${effCell(t)} | ${t.updated_at.slice(0, 16)}Z | ${lastComment(i, t.id)} | ${directionCell(i, t.id)} | ${n > 0 ? `**${n} new**` : "\u2014"} |`;
6199
6222
  };
6200
6223
  const blockedRow = (t) => `| ${displayKey2(t)} | ${t.status} | ${who(t, i)} | p${effectivePriority(t)} | ${blockers(t, i)} |`;
6201
6224
  const blockedTable = (ts) => ts.length === 0 ? "\n_None._\n" : `
@@ -6226,6 +6249,7 @@ ${sorted(held).map((t) => `| ${displayKey2(t)} | ${stopOf(t)} |`).join("\n")}
6226
6249
  "## Current queue \u2014 built for you by the poll\n",
6227
6250
  "\nAlready filtered to your lane, and already ordered by the Step 2 rule.\nTicket bodies are deliberately omitted: fetch the full record of only the\nticket you actually pick up. **Do not re-fetch the whole tracker.** This\ndigest comes from the same API call the poll just made, moments ago.\n",
6228
6251
  '\n"new since last poll" counts comments from someone other than you since\nthe poll watermark \u2014 the same signal that woke this run.\n',
6252
+ '\n"runner direction" is the newest instruction the crew itself posted to the\nevent log (not Comments) that no push has answered yet: `conflict_handed_back`\n(merge the base into the branch and resolve), `requeued`, `bounced`,\n`conflict_escalated`. It is direction, same as a comment from a hold: act on it.\n',
6229
6253
  "\n**`repo` is the checkout a ticket's work happens in** \u2014 an area spans\nseveral repositories, so cut the worktree beside THAT directory, not beside\nwhichever one you started in. A ticket marked **NO CHECKOUT** is not yours:\nthis ship has no clone of its repository, and another ship may serve it.\n\n**Use the `branch` and `worktree` columns verbatim.** They are that\nrepository's own naming convention, rendered for THIS ticket \u2014 cut the\nworktree at `<repo>/../<worktree>`, on branch `<branch>`. Do not derive\neither yourself, and prefer these over the Environment section's own worked\nexample if the two ever disagree \u2014 that example is illustrative only,\nrendered before any ticket is picked.\n",
6230
6254
  ...i.workModes && [...i.workModes.modes.values()].some((m) => m !== "automatic") ? ["\n**Manual / hybrid projects.** Some projects on this board are not fully automatic. In a `manual` project no lane touches any ticket, and a ticket with no project is treated the same way, so none appear below. In a `hybrid` project a ticket appears below only when it is assigned to an agent. An `accepted` ticket that is absent for either reason is not yours to claim \u2014 do not go looking for it.\n"] : [],
6231
6255
  "\n### Step 1 \u2014 open tickets that may be yours to act on\n",
@@ -6439,16 +6463,26 @@ async function runStalledSweep(o, stalled) {
6439
6463
  async function decideCycle(o) {
6440
6464
  const { route, ship, state, emit } = o;
6441
6465
  const tracker = new Tracker(route, ship);
6466
+ tracker.onReadFailure = (m) => emit.warn(m);
6442
6467
  emit.enter("poll");
6468
+ const optional = async (what, read, fallback) => {
6469
+ try {
6470
+ return await read;
6471
+ } catch (err) {
6472
+ emit.warn(`reading ${what} failed: ${err instanceof Error ? err.message : String(err)}`);
6473
+ return fallback;
6474
+ }
6475
+ };
6443
6476
  const [tickets, comments, crewRows, epicRows, ships, projectModes, stalledEvents] = await Promise.all([
6444
6477
  tracker.openTickets(),
6445
6478
  tracker.comments(200),
6446
6479
  tracker.crewRows(),
6447
- tracker.epicRows(),
6448
- tracker.shipRows(),
6449
- tracker.projectWorkModes(),
6480
+ optional("epics", tracker.epicRows(), []),
6481
+ optional("ships", tracker.shipRows(), []),
6482
+ optional("project work modes", tracker.projectWorkModes(), /* @__PURE__ */ new Map()),
6450
6483
  tracker.events("stalled")
6451
6484
  ]);
6485
+ const directionEvents = (await Promise.all([...DIRECTION_EVENT_TYPES, "pushed"].map((t) => tracker.events(t)))).flat();
6452
6486
  emit.emit(`${tickets.length} open ticket(s), ${comments.length} comment(s)`, {
6453
6487
  data: { tickets: tickets.length, comments: comments.length }
6454
6488
  });
@@ -6594,6 +6628,7 @@ async function decideCycle(o) {
6594
6628
  selection,
6595
6629
  actionable,
6596
6630
  watermark,
6631
+ directionEvents,
6597
6632
  selectionInput,
6598
6633
  attention: attentionCurrent
6599
6634
  };
@@ -6673,6 +6708,7 @@ function writeDigest(o, d, role, stateDir) {
6673
6708
  ships: d.ships,
6674
6709
  myShipId: d.ships.find((s) => (s.name ?? "").trim() === o.ship.name.trim())?.id ?? null,
6675
6710
  watermark: d.watermark,
6711
+ events: d.directionEvents,
6676
6712
  blocked: d.blocked,
6677
6713
  blockerInfo: d.info
6678
6714
  };
@@ -6777,7 +6813,14 @@ function worktreeForBranch(cwd, branch) {
6777
6813
  }
6778
6814
  return null;
6779
6815
  }
6780
- var priorBounces = (ticket, events) => events.filter((e) => e.record_id === ticket.id && e.event_type === "conflict_handed_back").length;
6816
+ var stampOf = (e) => e.occurred_at ?? e.created_at ?? "";
6817
+ var priorBounces = (ticket, events) => {
6818
+ const mine = events.filter((e) => e.record_id === ticket.id);
6819
+ const pushes = mine.filter((e) => e.event_type === "pushed").map(stampOf);
6820
+ return mine.filter(
6821
+ (e) => e.event_type === "conflict_handed_back" && pushes.some((p) => p > stampOf(e))
6822
+ ).length;
6823
+ };
6781
6824
  function planConflictBounce(cwd, ticket, branch, base, paths, events) {
6782
6825
  return {
6783
6826
  ticket,
@@ -12521,7 +12564,18 @@ async function runDaemonLoop(o) {
12521
12564
  let passes = 0;
12522
12565
  for (; ; ) {
12523
12566
  if (o.signal?.aborted) return "aborted";
12524
- const result = await o.runPass();
12567
+ let result;
12568
+ try {
12569
+ result = await o.runPass();
12570
+ } catch (err) {
12571
+ passes++;
12572
+ o.onPassError?.(err);
12573
+ if (o.maxPasses !== void 0 && passes >= o.maxPasses) return "maxPasses";
12574
+ if (o.signal?.aborted) return "aborted";
12575
+ await sleep(backoff);
12576
+ backoff = Math.min(backoff * 2, ceiling);
12577
+ continue;
12578
+ }
12525
12579
  passes++;
12526
12580
  if (result.started.length > 0) {
12527
12581
  backoff = floor;
@@ -13822,7 +13876,7 @@ ${changed2.map((c) => ` - ${c}
13822
13876
  }
13823
13877
  }
13824
13878
  if (outcome.conflicts?.length || outcome.unbuildable?.length) {
13825
- const handedBack = await tracker.events("conflict_handed_back");
13879
+ const handedBack = [...await tracker.events("conflict_handed_back"), ...await tracker.events("pushed")];
13826
13880
  const flagged = await tracker.events("stranded_flagged");
13827
13881
  const seat = releaseSeat(c);
13828
13882
  for (const f of outcome.conflicts ?? []) {
@@ -14447,6 +14501,9 @@ ${changed2.map((c) => ` - ${c}
14447
14501
  `)
14448
14502
  })
14449
14503
  }),
14504
+ onPassError: (err) => emit.error(
14505
+ `daemon pass failed, continuing: ${err instanceof Error ? err.stack ?? err.message : String(err)}`
14506
+ ),
14450
14507
  checkStale: makeStaleChecker(watchPaths, { reloadSentinel: reloadSentinelFor(cfg.ship.stateDir) }),
14451
14508
  onPass: (result) => {
14452
14509
  if (result.started.length) emit.emit(`daemon pass started: ${result.started.join(", ")}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.49.4",
3
+ "version": "0.49.6",
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",
@@ -141,6 +141,12 @@ Then:
141
141
  the ticket again because the status was left alone.
142
142
  - A hold gave new direction on an `in_progress` ticket → adjust the branch
143
143
  and comment on what changed.
144
+ - A ticket whose `runner direction` cell is not `—` has been handed back by
145
+ the crew itself (a conflict with the base, a requeue, a bounce). That is
146
+ direction exactly as a comment from a hold is, and "no new comment" does not
147
+ cancel it: for `conflict_handed_back`, merge `<remote>/<base>` into the
148
+ branch, resolve, run the suite, push as a fast-forward and set `fixed`
149
+ (Step 4.4). Never answer it with "nothing to build".
144
150
  - An `in_progress` ticket with no assignee and no new comment → still "up
145
151
  for grabs, more work to do": a hold clearing the assignee *is* the signal.
146
152
  - **An `in_progress` ticket assigned to you with no new comment is your own
@@ -397,8 +403,10 @@ worktree, runs the test gate and deploys. Three consequences:
397
403
  lines from Step 3.5 are the only input the release phase has; nobody
398
404
  reads the diff later to fill them in.
399
405
  3. **A branch that no longer squash-merges cleanly comes back to you** at
400
- `in_progress`, reassigned to your lane, with a comment naming the
401
- conflict. Fix it in the same worktree and take it to `fixed` again. "Never
406
+ `in_progress`, reassigned to your lane, with the hand-back
407
+ posted to the event log, which the digest's `runner direction` column
408
+ shows (a ticket there reads as new direction even with no new comment).
409
+ Fix it in the same worktree and take it to `fixed` again. "Never
402
410
  merge" above means never merge INTO the base; merging the base INTO your
403
411
  own ticket branch is the expected fix (rule 4), and it is not "by hand to
404
412
  help".
@@ -142,6 +142,12 @@ Then:
142
142
  the ticket again because the status was left alone.
143
143
  - A hold gave new direction on an `in_progress` ticket → adjust the branch
144
144
  and comment on what changed.
145
+ - A ticket whose `runner direction` cell is not `—` has been handed back by
146
+ the crew itself (a conflict with the base, a requeue, a bounce). That is
147
+ direction exactly as a comment from a hold is, and "no new comment" does not
148
+ cancel it: for `conflict_handed_back`, merge `<remote>/<base>` into the
149
+ branch, resolve, run the suite, push as a fast-forward and set `fixed`
150
+ (Step 4.4). Never answer it with "nothing to build".
145
151
  - An `in_progress` ticket with no assignee and no new comment → still "up
146
152
  for grabs, more work to do": a hold clearing the assignee *is* the signal.
147
153
  - **An `in_progress` ticket assigned to you with no new comment is your own
@@ -397,8 +403,10 @@ worktree, runs the test gate and deploys. Three consequences:
397
403
  lines from Step 3.5 are the only input the release phase has; nobody
398
404
  reads the diff later to fill them in.
399
405
  3. **A branch that no longer squash-merges cleanly comes back to you** at
400
- `in_progress`, reassigned to your lane, with a comment naming the
401
- conflict. Fix it in the same worktree and take it to `fixed` again. "Never
406
+ `in_progress`, reassigned to your lane, with the hand-back
407
+ posted to the event log, which the digest's `runner direction` column
408
+ shows (a ticket there reads as new direction even with no new comment).
409
+ Fix it in the same worktree and take it to `fixed` again. "Never
402
410
  merge" above means never merge INTO the base; merging the base INTO your
403
411
  own ticket branch is the expected fix (rule 4), and it is not "by hand to
404
412
  help".