@tablation/crew 0.80.1 → 0.82.0

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
@@ -2055,6 +2055,7 @@ var SHIP_KEYS = /* @__PURE__ */ new Set([
2055
2055
  "baseUrl",
2056
2056
  "apiKey",
2057
2057
  "maxConcurrentAgents",
2058
+ "roleConcurrency",
2058
2059
  "streamRetentionDays",
2059
2060
  "eventsRotateBytes",
2060
2061
  "reposBasePath",
@@ -2364,10 +2365,34 @@ Create the first of those:
2364
2365
  if (declaredPlatform && !isShipPlatform(declaredPlatform)) {
2365
2366
  missing.add(`ship.platform must be one of macos|linux|windows (got "${declaredPlatform}")`);
2366
2367
  }
2367
- const maxConcurrentAgents = shipRaw.maxConcurrentAgents === void 0 ? 2 : Number(shipRaw.maxConcurrentAgents);
2368
+ const maxConcurrentAgentsSet = shipRaw.maxConcurrentAgents !== void 0;
2369
+ let maxConcurrentAgents = maxConcurrentAgentsSet ? Number(shipRaw.maxConcurrentAgents) : 2;
2368
2370
  if (!Number.isInteger(maxConcurrentAgents) || maxConcurrentAgents < 1) {
2369
2371
  missing.add(`ship.maxConcurrentAgents must be a positive integer (got "${shipRaw.maxConcurrentAgents}")`);
2370
2372
  }
2373
+ const roleConcurrency = {};
2374
+ const roleConcurrencyRaw = shipRaw.roleConcurrency;
2375
+ if (roleConcurrencyRaw !== void 0 && roleConcurrencyRaw !== null) {
2376
+ if (typeof roleConcurrencyRaw !== "object" || Array.isArray(roleConcurrencyRaw)) {
2377
+ missing.add("ship.roleConcurrency must be a map of role to a positive integer");
2378
+ } else {
2379
+ for (const [role, v] of Object.entries(roleConcurrencyRaw)) {
2380
+ if (!ROLE_NAMES.includes(role)) {
2381
+ missing.add(`ship.roleConcurrency.${role} is not a role (expected one of ${ROLE_NAMES.join(", ")})`);
2382
+ continue;
2383
+ }
2384
+ const n = Number(v);
2385
+ if (!Number.isInteger(n) || n < 1) {
2386
+ missing.add(`ship.roleConcurrency.${role} must be a positive integer (got "${String(v)}")`);
2387
+ continue;
2388
+ }
2389
+ roleConcurrency[role] = n;
2390
+ }
2391
+ }
2392
+ }
2393
+ if (!maxConcurrentAgentsSet && Object.keys(roleConcurrency).length > 0) {
2394
+ maxConcurrentAgents = ROLE_NAMES.filter((r) => r !== "pair").reduce((n, r) => n + (roleConcurrency[r] ?? 1), 0);
2395
+ }
2371
2396
  const streamRetentionDays = shipRaw.streamRetentionDays === void 0 ? 7 : Number(shipRaw.streamRetentionDays);
2372
2397
  if (!Number.isInteger(streamRetentionDays) || streamRetentionDays < 1) {
2373
2398
  missing.add(`ship.streamRetentionDays must be a positive integer (got "${shipRaw.streamRetentionDays}")`);
@@ -2431,6 +2456,8 @@ Create the first of those:
2431
2456
  logFile: expand(shipRaw.logFile ?? join9(tmpdir(), "crew.log"), base),
2432
2457
  userAgent: shipRaw.userAgent ?? `Mozilla/5.0 CrewAgent/${crewVersion(crewHome)}`,
2433
2458
  maxConcurrentAgents,
2459
+ maxConcurrentAgentsSet,
2460
+ roleConcurrency,
2434
2461
  streamRetentionDays,
2435
2462
  eventsRotateBytes,
2436
2463
  relayHost: shipRaw.relayHost ? String(shipRaw.relayHost) : void 0,
@@ -4541,6 +4568,10 @@ function formatStaleOwnLock(scope, holder) {
4541
4568
  return `WARN lock ${scope} is held by ${holder}, but that process is gone \u2014 the next release cycle reclaims it
4542
4569
  `;
4543
4570
  }
4571
+ function roleCapWarnings(ship) {
4572
+ if (!ship.maxConcurrentAgentsSet) return [];
4573
+ return Object.entries(ship.roleConcurrency).filter(([, n]) => (n ?? 0) > ship.maxConcurrentAgents).map(([role, n]) => `ship.roleConcurrency.${role} is ${n}, above ship.maxConcurrentAgents (${ship.maxConcurrentAgents}); the ship-wide cap wins`);
4574
+ }
4544
4575
 
4545
4576
  // src/selftest.ts
4546
4577
  import { spawn as spawn3 } from "child_process";
@@ -5654,22 +5685,36 @@ var State = class _State {
5654
5685
  return { ok: false, heldBy };
5655
5686
  }
5656
5687
  /**
5657
- * The run lock (ISSUE-381): role-distinct AND capacity-limited, checked
5658
- * together.
5688
+ * The run lock (ISSUE-381, CREW-2821): role-capped AND capacity-limited,
5689
+ * checked together.
5659
5690
  *
5660
- * Role queues are disjoint by construction (`sliceFor`), so two sessions
5661
- * of the SAME role would collide on the same ticket if ever let run at
5662
- * once — that half is a plain exclusive lock, one per role name, and
5663
- * never relaxed. `max` is this machine's separate, configurable limit on
5664
- * how many DIFFERENT roles may run together; a role that clears its own
5665
- * lock but finds every slot taken must give the slot back its lock too,
5666
- * or a locked-but-not-running role would wedge itself out for the rest
5667
- * of the machine's uptime.
5691
+ * Two sessions of one role must never take the same ticket. Before
5692
+ * CREW-2821 that was guaranteed by an exclusive per-role lock; now the
5693
+ * guarantee is the per-ticket lock (`acquireTicket`), so the role lock is a
5694
+ * counted one: `roleMax` interchangeable slots, default 1. Slot 1 keeps the
5695
+ * historical name `crew-role-<role>` (crew-macos reads it for the live pid),
5696
+ * further slots are `crew-role-<role>-<n>`. `max` is this machine's separate
5697
+ * limit on how many runs of ANY role may overlap; a role that clears its own
5698
+ * lock but finds every slot taken must give its role lock back, or a
5699
+ * locked-but-not-running role would wedge itself out for the rest of the
5700
+ * machine's uptime.
5668
5701
  */
5669
- acquireRun(role, max) {
5670
- const roleLock = this.acquire(`crew-role-${role}`);
5671
- if (!roleLock.ok) {
5672
- return { ok: false, reason: `${role} is already running (pid ${roleLock.heldBy})` };
5702
+ acquireRun(role, max, roleMax = 1) {
5703
+ let roleLock;
5704
+ const roleHolders = [];
5705
+ for (let i = 1; i <= roleMax; i++) {
5706
+ const got = this.acquire(i === 1 ? `crew-role-${role}` : `crew-role-${role}-${i}`);
5707
+ if (got.ok) {
5708
+ roleLock = got;
5709
+ break;
5710
+ }
5711
+ roleHolders.push(got.heldBy);
5712
+ }
5713
+ if (!roleLock) {
5714
+ return {
5715
+ ok: false,
5716
+ reason: roleMax === 1 ? `${role} is already running (pid ${roleHolders[0]})` : `${role} is already running ${roleMax} agents (its roleConcurrency cap; pid${roleHolders.length > 1 ? "s" : ""} ${[...new Set(roleHolders)].join(", ")})`
5717
+ };
5673
5718
  }
5674
5719
  const slot = this.acquireN("crew-slot", max);
5675
5720
  if (!slot.ok) {
@@ -5684,6 +5729,27 @@ var State = class _State {
5684
5729
  roleLock.release();
5685
5730
  } };
5686
5731
  }
5732
+ /**
5733
+ * The per-ticket run lock (CREW-2821): held for the life of a run that works
5734
+ * the ticket (`crew-ticket-<record id>`), so a second run of the same role
5735
+ * on this ship walks past it instead of being handed the first run's
5736
+ * ticket. Same stale-pid rule as every lock here.
5737
+ */
5738
+ acquireTicket(recordId) {
5739
+ return this.acquire(`crew-ticket-${recordId}`);
5740
+ }
5741
+ /** Whether a live run on this ship holds `recordId`'s ticket lock. */
5742
+ ticketInFlight(recordId) {
5743
+ const file = this.path(`.${_State.safe(`crew-ticket-${recordId}`)}.lock`);
5744
+ try {
5745
+ const pid = Number.parseInt(readFileSync12(file, "utf8").trim(), 10);
5746
+ if (!Number.isFinite(pid) || pid <= 0) return false;
5747
+ process.kill(pid, 0);
5748
+ return true;
5749
+ } catch {
5750
+ return false;
5751
+ }
5752
+ }
5687
5753
  /**
5688
5754
  * How many consecutive cycles the release phase has refused to deploy.
5689
5755
  *
@@ -6413,7 +6479,7 @@ var Tracker = class {
6413
6479
  * `beatIdle` in a `finally` — the same shape as `setCrewStatus`'s
6414
6480
  * working/idle pair, one level up (ship, not seat).
6415
6481
  */
6416
- async beatEngaged(shipName2, connectionName, ticketRecordId) {
6482
+ async beatEngaged(shipName2, connectionName, ticketRecordId, since2) {
6417
6483
  const row = await this.myShipRow(shipName2);
6418
6484
  if (!row) return;
6419
6485
  await this.client.records.update(this.route.resolved.shipsModelId, row.id, {
@@ -6421,7 +6487,7 @@ var Tracker = class {
6421
6487
  host: hostname(),
6422
6488
  pid: process.pid,
6423
6489
  engaged: true,
6424
- engaged_since: (/* @__PURE__ */ new Date()).toISOString(),
6490
+ engaged_since: since2 ?? (/* @__PURE__ */ new Date()).toISOString(),
6425
6491
  engaged_connection: connectionName,
6426
6492
  engaged_ticket_id: ticketRecordId,
6427
6493
  ...this.attentionPatch(row)
@@ -7063,7 +7129,14 @@ function newBranchName(cfg, t, role) {
7063
7129
  function planRunWorktree(dir, cfg, t, role, opts = {}) {
7064
7130
  const { remote, base } = cfg.branch;
7065
7131
  const hasRemote = remoteConfigured(dir, remote);
7066
- if (hasRemote) fetchRemote(dir, remote);
7132
+ if (hasRemote) {
7133
+ const unlock = acquireCheckoutLock(dir);
7134
+ try {
7135
+ fetchRemote(dir, remote);
7136
+ } finally {
7137
+ unlock();
7138
+ }
7139
+ }
7067
7140
  const num = t.issue_id.replace(/^\D+/, "");
7068
7141
  const existing = worktreeForNumber(dir, num);
7069
7142
  const located = locateBranchForTicket(dir, cfg, t, role);
@@ -8373,6 +8446,7 @@ var AgentError = class extends Error {
8373
8446
  };
8374
8447
  var AgentSpawnError = class extends AgentError {
8375
8448
  };
8449
+ var digestPathFor = (stateDir, routeLabel, role, runId) => join19(stateDir, `digest-${routeLabel}-${role}${runId ? `-${runId}` : ""}.md`);
8376
8450
  function planAgentRun(o) {
8377
8451
  const briefPath = join19(o.route.promptsDir, "personas", `lane-${o.role}.md`);
8378
8452
  if (!existsSync18(briefPath)) {
@@ -8382,7 +8456,8 @@ function planAgentRun(o) {
8382
8456
  const usesCommon = !SELF_CONTAINED_ROLES.has(o.role) && existsSync18(commonPath);
8383
8457
  const now = o.now ?? (() => Date.now());
8384
8458
  const routeLabel = routeSlug(o.route.route);
8385
- const digestPath2 = join19(o.stateDir, `digest-${routeLabel}-${o.role}.md`);
8459
+ const ownDigest = o.runId ? digestPathFor(o.stateDir, routeLabel, o.role, o.runId) : void 0;
8460
+ const digestPath2 = ownDigest && existsSync18(ownDigest) ? ownDigest : digestPathFor(o.stateDir, routeLabel, o.role);
8386
8461
  let digest;
8387
8462
  let ageSeconds;
8388
8463
  if (existsSync18(digestPath2)) {
@@ -8413,7 +8488,7 @@ function planAgentRun(o) {
8413
8488
  });
8414
8489
  const promptSha = createHash3("sha256").update(expanded.text).digest("hex");
8415
8490
  const streamsDir = join19(o.stateDir, "streams");
8416
- const base = `${routeLabel}-${o.role}-${o.cycle}`;
8491
+ const base = `${routeLabel}-${o.role}-${o.cycle}${o.runId ? `-${o.runId}` : ""}`;
8417
8492
  const seat = resolveSeat(o.ship, o.route, o.role, o.agentPersona ?? (o.agentModel ? { model: o.agentModel } : {}), o.repo);
8418
8493
  const { toolchain, model } = seat;
8419
8494
  const adapter = adapterFor(toolchain.harness);
@@ -8486,6 +8561,8 @@ function planAgentRun(o) {
8486
8561
  eventsPath: join19(streamsDir, `${base}.events.jsonl`),
8487
8562
  ticket: o.ticket,
8488
8563
  ticketRecordId: o.ticketRecordId ?? null,
8564
+ repo: o.repo ?? null,
8565
+ routeName: o.route.route,
8489
8566
  agentLog,
8490
8567
  promptVersion: o.route.resolved?.agentPersonas?.[o.role]?.historyId,
8491
8568
  promptSha
@@ -8900,6 +8977,8 @@ function spawnAgent(plan, emit, hintMoved) {
8900
8977
  route: plan.route,
8901
8978
  ticket: plan.ticket ?? null,
8902
8979
  ticketRecordId: plan.ticketRecordId ?? null,
8980
+ repo: plan.repo ?? null,
8981
+ routeName: plan.routeName,
8903
8982
  model: plan.model,
8904
8983
  toolchain: id.toolchain,
8905
8984
  harness: id.harness,
@@ -9471,7 +9550,7 @@ function doctorSelftestLines(stored, toolchains) {
9471
9550
  }
9472
9551
 
9473
9552
  // src/cli.ts
9474
- import { resolve as resolve5, dirname as dirname20, join as join37, basename as basename12 } from "path";
9553
+ import { resolve as resolve5, dirname as dirname20, join as join38, basename as basename12 } from "path";
9475
9554
  import { fileURLToPath } from "url";
9476
9555
  import { hostname as hostname2, homedir as homedir10 } from "os";
9477
9556
  import { spawnSync as spawnSync7 } from "child_process";
@@ -10955,8 +11034,15 @@ async function decideCycle(o) {
10955
11034
  }
10956
11035
  return decision;
10957
11036
  }
10958
- var digestPath = (stateDir, route, role) => join23(stateDir, `digest-${route}-${role}.md`);
10959
- function writeDigest(o, d, role, stateDir) {
11037
+ var digestPath = (stateDir, route, role, runId) => join23(stateDir, `digest-${route}-${role}${runId ? `-${runId}` : ""}.md`);
11038
+ function otherRunsRemoved(tickets, own) {
11039
+ if (!own) return tickets;
11040
+ return tickets.filter((t) => t.id === own.ticket.id || !own.inFlight(t.id));
11041
+ }
11042
+ function ownRunBanner(own) {
11043
+ return `**Your ticket for this run: ${own.ticket.issue_id}.** Work this one. Any other ticket a live run on this ship is already working has been left out of this digest: it belongs to that run.`;
11044
+ }
11045
+ function writeDigest(o, d, role, stateDir, runId, own) {
10960
11046
  const me = o.route.resolved?.seats[role];
10961
11047
  if (!me) return false;
10962
11048
  try {
@@ -11002,7 +11088,7 @@ function writeDigest(o, d, role, stateDir) {
11002
11088
  }
11003
11089
  return existingBranchForTicket(dir, repoFor(dir), t, role);
11004
11090
  },
11005
- tickets: role === "triage" ? triageSlice(d.tickets, o.route.resolved?.seats.triage, d.selectionInput.workModes) : withinWorkMode(sliceFor(d.tickets, role), d.selectionInput.workModes),
11091
+ tickets: role === "triage" ? triageSlice(d.tickets, o.route.resolved?.seats.triage, d.selectionInput.workModes) : withinWorkMode(sliceFor(otherRunsRemoved(d.tickets, own), role), d.selectionInput.workModes),
11006
11092
  workModes: d.selectionInput.workModes,
11007
11093
  comments: d.comments,
11008
11094
  commentColumns: d.selectionInput.contract?.comments,
@@ -11021,8 +11107,11 @@ function writeDigest(o, d, role, stateDir) {
11021
11107
  blocked: d.blocked,
11022
11108
  blockerInfo: d.info
11023
11109
  };
11024
- const text = role === "qa" ? qaDigest(input) : buildingDigest(input);
11025
- const path = digestPath(stateDir, routeSlug(o.route.route), role);
11110
+ const body = role === "qa" ? qaDigest(input) : buildingDigest(input);
11111
+ const text = own ? `${ownRunBanner(own)}
11112
+
11113
+ ${body}` : body;
11114
+ const path = digestPath(stateDir, routeSlug(o.route.route), role, runId);
11026
11115
  writeFileSync14(path, text);
11027
11116
  o.emit.emit(`queue digest written for ${role} (${Buffer.byteLength(text)} bytes)`, {
11028
11117
  data: { role, bytes: Buffer.byteLength(text) }
@@ -11063,7 +11152,7 @@ async function stampResumption(tracker, t, affinity) {
11063
11152
  }
11064
11153
  return { outcome: "unknown" };
11065
11154
  }
11066
- async function resolveTopCandidate(tracker, candidates, seat, approvedStatus, buildingStatus, isServable, affinity) {
11155
+ async function resolveTopCandidate(tracker, candidates, seat, approvedStatus, buildingStatus, isServable, affinity, ticketLock) {
11067
11156
  const contended = [];
11068
11157
  const unservable = [];
11069
11158
  const held = [];
@@ -11076,41 +11165,58 @@ async function resolveTopCandidate(tracker, candidates, seat, approvedStatus, bu
11076
11165
  unservable.push(t.issue_id);
11077
11166
  continue;
11078
11167
  }
11079
- if (t.status !== approvedStatus) {
11080
- if (affinity?.isLiveHoldElsewhere?.(t)) {
11081
- return { ticket: t, claimed: false, contended, unservable, held, reaccepted: false, tookOverFrom: null };
11082
- }
11083
- if (affinity && t[affinity.column] !== affinity.shipId) {
11084
- const stamp = await stampResumption(tracker, t, affinity);
11085
- if (stamp.outcome === "stamped") {
11086
- return { ticket: stamp.ticket, claimed: false, contended, unservable, held, reaccepted: false, tookOverFrom: displacedHolder(t, affinity) };
11087
- }
11088
- (stamp.outcome === "held" ? held : contended).push(t.issue_id);
11168
+ let releaseTicket;
11169
+ if (ticketLock) {
11170
+ const got = ticketLock(t);
11171
+ if (!got) {
11172
+ contended.push(t.issue_id);
11089
11173
  continue;
11090
11174
  }
11175
+ releaseTicket = got;
11176
+ }
11177
+ const result = await resolveOne(tracker, t, seat, approvedStatus, buildingStatus, affinity, contended, unservable, held);
11178
+ if (result === "walk") {
11179
+ releaseTicket?.();
11180
+ continue;
11181
+ }
11182
+ return releaseTicket ? { ...result, releaseTicket } : result;
11183
+ }
11184
+ return { ticket: null, claimed: false, contended, unservable, held, reaccepted: false, tookOverFrom: null };
11185
+ }
11186
+ async function resolveOne(tracker, t, seat, approvedStatus, buildingStatus, affinity, contended, unservable, held) {
11187
+ if (t.status !== approvedStatus) {
11188
+ if (affinity?.isLiveHoldElsewhere?.(t)) {
11091
11189
  return { ticket: t, claimed: false, contended, unservable, held, reaccepted: false, tookOverFrom: null };
11092
11190
  }
11093
- try {
11094
- const claimed = await tracker.updateTicket(
11095
- t.id,
11096
- {
11097
- status: buildingStatus,
11098
- assignee_id: seat,
11099
- ...affinity ? { [affinity.column]: affinity.shipId } : {}
11100
- },
11101
- t.updated_at
11102
- );
11103
- const reaccepted = !!affinity && t[affinity.column] === affinity.shipId;
11104
- return { ticket: { ...t, ...claimed }, claimed: true, contended, unservable, held, reaccepted, tookOverFrom: displacedHolder(t, affinity) };
11105
- } catch (e) {
11106
- if (e instanceof StaleWriteError6) {
11107
- contended.push(t.issue_id);
11108
- continue;
11191
+ if (affinity && t[affinity.column] !== affinity.shipId) {
11192
+ const stamp = await stampResumption(tracker, t, affinity);
11193
+ if (stamp.outcome === "stamped") {
11194
+ return { ticket: stamp.ticket, claimed: false, contended, unservable, held, reaccepted: false, tookOverFrom: displacedHolder(t, affinity) };
11109
11195
  }
11110
- throw e;
11196
+ (stamp.outcome === "held" ? held : contended).push(t.issue_id);
11197
+ return "walk";
11111
11198
  }
11199
+ return { ticket: t, claimed: false, contended, unservable, held, reaccepted: false, tookOverFrom: null };
11200
+ }
11201
+ try {
11202
+ const claimed = await tracker.updateTicket(
11203
+ t.id,
11204
+ {
11205
+ status: buildingStatus,
11206
+ assignee_id: seat,
11207
+ ...affinity ? { [affinity.column]: affinity.shipId } : {}
11208
+ },
11209
+ t.updated_at
11210
+ );
11211
+ const reaccepted = !!affinity && t[affinity.column] === affinity.shipId;
11212
+ return { ticket: { ...t, ...claimed }, claimed: true, contended, unservable, held, reaccepted, tookOverFrom: displacedHolder(t, affinity) };
11213
+ } catch (e) {
11214
+ if (e instanceof StaleWriteError6) {
11215
+ contended.push(t.issue_id);
11216
+ return "walk";
11217
+ }
11218
+ throw e;
11112
11219
  }
11113
- return { ticket: null, claimed: false, contended, unservable, held, reaccepted: false, tookOverFrom: null };
11114
11220
  }
11115
11221
 
11116
11222
  // src/conflict.ts
@@ -13277,6 +13383,164 @@ Passed: ${passed}.${willClose ? " Closing this alert." : ""}`,
13277
13383
  return out;
13278
13384
  }
13279
13385
 
13386
+ // src/live-runs.ts
13387
+ import { existsSync as existsSync24, readdirSync as readdirSync10 } from "fs";
13388
+ import { join as join28 } from "path";
13389
+
13390
+ // src/interrupted.ts
13391
+ import { existsSync as existsSync23, readdirSync as readdirSync9, readFileSync as readFileSync21 } from "fs";
13392
+ import { join as join27 } from "path";
13393
+ var INTERRUPTED_MARKER = calloutHeader("NOTE", "Interrupted");
13394
+ function interruptedComment(b) {
13395
+ const tree = b.dirty === null ? "The worktree could not be checked." : b.dirty ? "The worktree has uncommitted changes \u2014 they are this seat's own, from that run." : "The worktree is clean.";
13396
+ return `${INTERRUPTED_MARKER}
13397
+
13398
+ The agent run on this ticket was cut short (${b.reason}) before it handed on${b.branch ? `; its branch is \`${b.branch}\`` : ""}. ${tree} The next run on this seat resumes from the worktree rather than starting over.`;
13399
+ }
13400
+ var handled = /* @__PURE__ */ new Set();
13401
+ var pidAlive = (pid) => {
13402
+ try {
13403
+ process.kill(pid, 0);
13404
+ return true;
13405
+ } catch (e) {
13406
+ return e.code === "EPERM";
13407
+ }
13408
+ };
13409
+ function runFinished(streamPath) {
13410
+ const path = `${streamPath}.meta.json`;
13411
+ if (!existsSync23(path)) return false;
13412
+ try {
13413
+ return JSON.parse(readFileSync21(path, "utf8")).running !== true;
13414
+ } catch {
13415
+ return true;
13416
+ }
13417
+ }
13418
+ function orphanedStreams(o) {
13419
+ const dir = join27(o.stateDir, "streams");
13420
+ if (!existsSync23(dir)) return [];
13421
+ const ownerAlive = o.isOwnerAlive ?? pidAlive;
13422
+ const out = [];
13423
+ for (const name of readdirSync9(dir)) {
13424
+ if (!name.endsWith(".jsonl") || name.endsWith(".events.jsonl")) continue;
13425
+ const streamPath = join27(dir, name);
13426
+ if (runFinished(streamPath)) continue;
13427
+ const sidecar = readRunSidecar(streamPath);
13428
+ if (!sidecar || sidecar.route !== o.routeLabel) continue;
13429
+ if (ownerAlive(sidecar.ownerPid)) continue;
13430
+ out.push({ streamPath, sidecar });
13431
+ }
13432
+ return out;
13433
+ }
13434
+ async function reconcileRuns(o) {
13435
+ let adopted = 0;
13436
+ let interrupted = 0;
13437
+ for (const { streamPath, sidecar } of orphanedStreams(o)) {
13438
+ if (handled.has(streamPath)) continue;
13439
+ handled.add(streamPath);
13440
+ const isAlive = o.isAlive ?? runIsAlive;
13441
+ const lock = isAlive(sidecar) ? o.state.acquireRun(sidecar.role, o.maxConcurrentAgents, o.roleConcurrency?.[sidecar.role] ?? 1) : null;
13442
+ const ticketLock = isAlive(sidecar) && sidecar.ticketRecordId ? o.state.acquireTicket(sidecar.ticketRecordId) : null;
13443
+ const work = (async () => {
13444
+ try {
13445
+ const result = await reattachRun(streamPath, sidecar, o.emit, {
13446
+ agentLogKey: await o.agentLogKey?.(sidecar.role).catch(() => void 0),
13447
+ isAlive: o.isAlive,
13448
+ pollMs: o.pollMs,
13449
+ onAdopted: () => o.emit.emit(`adopted ${sidecar.role} run (pid ${sidecar.pid}) left by a previous crew process`, { step: "agent", role: sidecar.role })
13450
+ });
13451
+ if (result.adopted) adopted++;
13452
+ else interrupted++;
13453
+ try {
13454
+ await o.afterRun?.(sidecar, result);
13455
+ } catch (e) {
13456
+ o.emit.warn(`could not follow up on ${sidecar.role} run ${streamPath}: ${e.message}`, { step: "agent" });
13457
+ }
13458
+ } catch (e) {
13459
+ o.emit.warn(`could not reattach ${sidecar.role} run ${streamPath}: ${e.message}`, { step: "agent" });
13460
+ } finally {
13461
+ if (lock?.ok) lock.release();
13462
+ if (ticketLock?.ok) ticketLock.release();
13463
+ }
13464
+ })();
13465
+ if (isAlive(sidecar)) {
13466
+ if (o.inFlight) {
13467
+ o.inFlight.add(work);
13468
+ void work.finally(() => o.inFlight.delete(work));
13469
+ }
13470
+ } else {
13471
+ await work;
13472
+ }
13473
+ }
13474
+ return { adopted, interrupted };
13475
+ }
13476
+
13477
+ // src/live-runs.ts
13478
+ function liveRuns(stateDir, isAlive = runIsAlive) {
13479
+ const dir = join28(stateDir, "streams");
13480
+ if (!existsSync24(dir)) return [];
13481
+ const out = [];
13482
+ for (const name of readdirSync10(dir)) {
13483
+ if (!name.endsWith(".jsonl") || name.endsWith(".events.jsonl")) continue;
13484
+ const streamPath = join28(dir, name);
13485
+ if (runFinished(streamPath)) continue;
13486
+ const sidecar = readRunSidecar(streamPath);
13487
+ if (!sidecar || !isAlive(sidecar)) continue;
13488
+ out.push({ ...sidecar, streamPath });
13489
+ }
13490
+ return out.sort((a, b) => a.spawnedAt.localeCompare(b.spawnedAt));
13491
+ }
13492
+ function siblingRuns(runs, ownTicketRecordId) {
13493
+ return runs.filter((r) => !ownTicketRecordId || r.ticketRecordId !== ownTicketRecordId);
13494
+ }
13495
+ async function settleRunEnd(o) {
13496
+ const { tracker, emit } = o;
13497
+ const all = o.runs ?? liveRuns(o.stateDir);
13498
+ const slug = routeSlug(o.route.route);
13499
+ const seatSiblings = siblingRuns(all.filter((r) => r.role === o.role && r.route === slug), o.ownTicketRecordId);
13500
+ const shipSiblings = siblingRuns(all, o.ownTicketRecordId);
13501
+ const newest = (rs) => rs[rs.length - 1];
13502
+ if (o.memberId) {
13503
+ try {
13504
+ if (seatSiblings.length) await tracker.setCrewStatus(o.memberId, "working", newest(seatSiblings).ticketRecordId);
13505
+ else await tracker.setCrewStatus(o.memberId, "idle");
13506
+ } catch (e) {
13507
+ emit.warn(`could not clear crew status: ${e.message}`, { step: "agent" });
13508
+ }
13509
+ }
13510
+ try {
13511
+ if (shipSiblings.length) {
13512
+ const n = newest(shipSiblings);
13513
+ await tracker.beatEngaged(o.shipName, n.routeName ?? n.route, n.ticketRecordId, n.spawnedAt);
13514
+ } else {
13515
+ await tracker.beatIdle(o.shipName);
13516
+ }
13517
+ } catch (e) {
13518
+ emit.warn(`could not beat ship idle: ${e.message}`, { step: "agent" });
13519
+ }
13520
+ }
13521
+ function describeRun(run2, route, seatIds, worktreeOf = (d, n) => {
13522
+ try {
13523
+ return worktreeForNumber(d, n)?.path ?? null;
13524
+ } catch {
13525
+ return null;
13526
+ }
13527
+ }) {
13528
+ const ownRoute = run2.route === routeSlug(route.route);
13529
+ const repoDir = run2.repo ? reposOf(route).find((t) => t.name === run2.repo)?.dir : void 0;
13530
+ const num = run2.ticket?.replace(/^\D+/, "") ?? "";
13531
+ return {
13532
+ role: run2.role,
13533
+ seat: ownRoute ? seatIds[run2.role] ?? null : null,
13534
+ ticket: run2.ticket,
13535
+ ticketRecordId: run2.ticketRecordId,
13536
+ repo: run2.repo ?? null,
13537
+ worktree: repoDir && num ? worktreeOf(repoDir, num) : null,
13538
+ stream: run2.streamPath,
13539
+ pid: run2.pid,
13540
+ startedAt: run2.spawnedAt
13541
+ };
13542
+ }
13543
+
13280
13544
  // src/projects-cmd.ts
13281
13545
  async function buildProjectsReport(routes, open) {
13282
13546
  return Promise.all(routes.map(async (r) => {
@@ -13486,7 +13750,7 @@ function missingReleaserWarning(repo, release, ships, now = Date.now()) {
13486
13750
  }
13487
13751
 
13488
13752
  // src/watch.ts
13489
- import { watch as watchFile, existsSync as existsSync23, statSync as statSync7, openSync as openSync4, readSync as readSync2, closeSync as closeSync4 } from "fs";
13753
+ import { watch as watchFile, existsSync as existsSync25, statSync as statSync7, openSync as openSync4, readSync as readSync2, closeSync as closeSync4 } from "fs";
13490
13754
  var E = "\x1B[";
13491
13755
  var dim = (s) => `${E}2m${s}${E}0m`;
13492
13756
  var bold = (s) => `${E}1m${s}${E}0m`;
@@ -13587,7 +13851,7 @@ function renderHeaders(states, now = Date.now()) {
13587
13851
  return states.map((s) => renderHeader(s, now)).join("");
13588
13852
  }
13589
13853
  function readFrom(file, from) {
13590
- if (!existsSync23(file)) return { events: [], offset: from };
13854
+ if (!existsSync25(file)) return { events: [], offset: from };
13591
13855
  const size = statSync7(file).size;
13592
13856
  if (size < from) return readFrom(file, 0);
13593
13857
  if (size === from) return { events: [], offset: from };
@@ -13830,17 +14094,17 @@ function nextRoles(decision, ran) {
13830
14094
  }
13831
14095
 
13832
14096
  // src/ship-name.ts
13833
- import { existsSync as existsSync25, mkdirSync as mkdirSync16, readFileSync as readFileSync22, renameSync as renameSync2, writeFileSync as writeFileSync18 } from "fs";
14097
+ import { existsSync as existsSync27, mkdirSync as mkdirSync16, readFileSync as readFileSync23, renameSync as renameSync2, writeFileSync as writeFileSync18 } from "fs";
13834
14098
  import { dirname as dirname14 } from "path";
13835
14099
  import { parseDocument } from "yaml";
13836
14100
 
13837
14101
  // src/machine-id.ts
13838
14102
  import { execFileSync as execFileSync4 } from "child_process";
13839
14103
  import { createHash as createHash4 } from "crypto";
13840
- import { existsSync as existsSync24, mkdirSync as mkdirSync15, readFileSync as readFileSync21, writeFileSync as writeFileSync17 } from "fs";
13841
- import { dirname as dirname13, join as join27 } from "path";
14104
+ import { existsSync as existsSync26, mkdirSync as mkdirSync15, readFileSync as readFileSync22, writeFileSync as writeFileSync17 } from "fs";
14105
+ import { dirname as dirname13, join as join29 } from "path";
13842
14106
  function machineIdPathFor(stateDir) {
13843
- return join27(stateDir, "machine-id");
14107
+ return join29(stateDir, "machine-id");
13844
14108
  }
13845
14109
  var sha256 = (s) => createHash4("sha256").update(s).digest("hex");
13846
14110
  function readRawMachineId(platform2 = process.platform) {
@@ -13853,8 +14117,8 @@ function readRawMachineId(platform2 = process.platform) {
13853
14117
  return /"IOPlatformUUID"\s*=\s*"([^"]+)"/.exec(out)?.[1];
13854
14118
  }
13855
14119
  for (const p of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
13856
- if (existsSync24(p)) {
13857
- const id = readFileSync21(p, "utf8").trim();
14120
+ if (existsSync26(p)) {
14121
+ const id = readFileSync22(p, "utf8").trim();
13858
14122
  if (id) return id;
13859
14123
  }
13860
14124
  }
@@ -13865,8 +14129,8 @@ function readRawMachineId(platform2 = process.platform) {
13865
14129
  function ensureMachineId(stateDir, opts = {}) {
13866
14130
  if (opts.override?.trim()) return sha256(opts.override.trim());
13867
14131
  const path = machineIdPathFor(stateDir);
13868
- if (existsSync24(path)) {
13869
- const cached = readFileSync21(path, "utf8").trim();
14132
+ if (existsSync26(path)) {
14133
+ const cached = readFileSync22(path, "utf8").trim();
13870
14134
  if (/^[0-9a-f]{64}$/.test(cached)) return cached;
13871
14135
  }
13872
14136
  const raw = (opts.read ?? readRawMachineId)();
@@ -13944,19 +14208,19 @@ async function syncRouteName(i) {
13944
14208
  return { status: "ok", ...base, action, name: i.local };
13945
14209
  }
13946
14210
  function readSyncedName(resolvedFile) {
13947
- if (!existsSync25(resolvedFile)) return void 0;
14211
+ if (!existsSync27(resolvedFile)) return void 0;
13948
14212
  try {
13949
- const v = JSON.parse(readFileSync22(resolvedFile, "utf8")).shipName;
14213
+ const v = JSON.parse(readFileSync23(resolvedFile, "utf8")).shipName;
13950
14214
  return typeof v === "string" ? v : void 0;
13951
14215
  } catch {
13952
14216
  return void 0;
13953
14217
  }
13954
14218
  }
13955
14219
  function writeSyncedName(resolvedFile, name) {
13956
- if (!existsSync25(resolvedFile)) return;
14220
+ if (!existsSync27(resolvedFile)) return;
13957
14221
  let raw;
13958
14222
  try {
13959
- raw = JSON.parse(readFileSync22(resolvedFile, "utf8"));
14223
+ raw = JSON.parse(readFileSync23(resolvedFile, "utf8"));
13960
14224
  } catch {
13961
14225
  return;
13962
14226
  }
@@ -13971,7 +14235,7 @@ function writeSyncedName(resolvedFile, name) {
13971
14235
  async function reconcileShipName(i) {
13972
14236
  let local = i.ship.name;
13973
14237
  try {
13974
- local = shipNameOfYaml(readFileSync22(i.configFile, "utf8")) ?? local;
14238
+ local = shipNameOfYaml(readFileSync23(i.configFile, "utf8")) ?? local;
13975
14239
  } catch {
13976
14240
  }
13977
14241
  const resolvedFile = resolvedPathFor(i.stateDir, i.route);
@@ -13995,7 +14259,7 @@ async function reconcileShipName(i) {
13995
14259
  }
13996
14260
  if (result.status !== "ok") return result;
13997
14261
  if (result.action === "adopt" || result.action === "row-wins") {
13998
- const text = readFileSync22(i.configFile, "utf8");
14262
+ const text = readFileSync23(i.configFile, "utf8");
13999
14263
  writeFileSync18(i.configFile, setShipNameInYaml(text, result.name));
14000
14264
  i.ship.name = result.name;
14001
14265
  if (result.action === "row-wins") {
@@ -14641,7 +14905,7 @@ function describeCapability(i) {
14641
14905
  }
14642
14906
 
14643
14907
  // src/agents-sync-run.ts
14644
- import { existsSync as existsSync26, mkdirSync as mkdirSync17, readFileSync as readFileSync23, writeFileSync as writeFileSync19 } from "fs";
14908
+ import { existsSync as existsSync28, mkdirSync as mkdirSync17, readFileSync as readFileSync24, writeFileSync as writeFileSync19 } from "fs";
14645
14909
  import { dirname as dirname15 } from "path";
14646
14910
  async function runAgentsSync(target, o) {
14647
14911
  const dryRun = o.dryRun ?? false;
@@ -14675,7 +14939,7 @@ async function runAgentsSync(target, o) {
14675
14939
  for (const s of skillResult?.outcomes ?? []) o.say(describeSkillSyncOutcome(s));
14676
14940
  if (!dryRun) {
14677
14941
  const resolvedPath = resolvedPathFor(o.stateDir, target.route);
14678
- const raw = existsSync26(resolvedPath) ? JSON.parse(readFileSync23(resolvedPath, "utf8")) : {};
14942
+ const raw = existsSync28(resolvedPath) ? JSON.parse(readFileSync24(resolvedPath, "utf8")) : {};
14679
14943
  raw.agentPersonas = personaResult.agentPersonas;
14680
14944
  if (skillResult) raw.agentSkills = skillResult.agentSkills;
14681
14945
  if (personaResult.releaseAgentId) raw.releaseAgentId = personaResult.releaseAgentId;
@@ -14784,7 +15048,7 @@ async function showLogEntry(route, entryId, opts = {}) {
14784
15048
 
14785
15049
  // src/repos-check.ts
14786
15050
  import { spawnSync as spawnSync6 } from "child_process";
14787
- import { readFileSync as readFileSync24 } from "fs";
15051
+ import { readFileSync as readFileSync25 } from "fs";
14788
15052
  import { basename as basename8 } from "path";
14789
15053
  var POSIX_SHELL = /^(?:ba|z|da|k|a|mk)?sh$|^fish$/;
14790
15054
  function checkHookSyntax(script, shell) {
@@ -14825,7 +15089,7 @@ function checkRepoSpec(dir, opts = {}) {
14825
15089
  }
14826
15090
  let parsed;
14827
15091
  try {
14828
- parsed = parseRepoConfig(readFileSync24(file, "utf8"), file);
15092
+ parsed = parseRepoConfig(readFileSync25(file, "utf8"), file);
14829
15093
  } catch (e) {
14830
15094
  if (!(e instanceof RepoConfigError)) throw e;
14831
15095
  return { ...empty, file, error: e.message, exitCode: 1 };
@@ -14882,7 +15146,7 @@ import { isMap as isMap2, parseDocument as parseDocument3, YAMLMap } from "yaml"
14882
15146
 
14883
15147
  // src/repos-cmd.ts
14884
15148
  import { homedir as homedir9 } from "os";
14885
- import { basename as basename9, join as join28, resolve as resolve3 } from "path";
15149
+ import { basename as basename9, join as join30, resolve as resolve3 } from "path";
14886
15150
  import { isMap, isSeq, parseDocument as parseDocument2, stringify as stringify2 } from "yaml";
14887
15151
  var ReposError = class extends Error {
14888
15152
  };
@@ -15160,7 +15424,7 @@ function planRepoClone(i) {
15160
15424
  }
15161
15425
  const ship = doc.get("ship", true);
15162
15426
  const basePath = String(m.get("reposBasePath") ?? (isMap(ship) ? ship.get("reposBasePath") : void 0) ?? "~/Crew");
15163
- const target = i.dir ? expandDir(i.dir, i.base) : join28(expandDir(basePath, i.base), i.route.split("/")[0], name);
15427
+ const target = i.dir ? expandDir(i.dir, i.base) : join30(expandDir(basePath, i.base), i.route.split("/")[0], name);
15164
15428
  const add = planRepoAdd({ text: i.text, base: i.base, route: i.route, path: target, origin: remote, resolved: i.resolved, name, isCheckout: i.isCheckout });
15165
15429
  return { ...add, remote, target };
15166
15430
  }
@@ -15559,7 +15823,7 @@ ${run2.err}` });
15559
15823
  }
15560
15824
 
15561
15825
  // src/sample-ticket.ts
15562
- import { basename as basename10, dirname as dirname17, join as join29 } from "path";
15826
+ import { basename as basename10, dirname as dirname17, join as join31 } from "path";
15563
15827
  var SAMPLE_REPO = "crew-sample";
15564
15828
  var SAMPLE_REMOTE = `local/${SAMPLE_REPO}`;
15565
15829
  var SAMPLE_MARKER = "sample: true";
@@ -15582,8 +15846,8 @@ has something safe to work on: its test and build hooks always pass, it has no
15582
15846
  remote, and \`crew sample-ticket --remove\` deletes it.
15583
15847
  `;
15584
15848
  function sampleDir(existing, reposBase, workspace) {
15585
- if (existing.length > 0) return join29(dirname17(existing[0]), SAMPLE_REPO);
15586
- return join29(reposBase, workspace, SAMPLE_REPO);
15849
+ if (existing.length > 0) return join31(dirname17(existing[0]), SAMPLE_REPO);
15850
+ return join31(reposBase, workspace, SAMPLE_REPO);
15587
15851
  }
15588
15852
  var SAMPLE_TITLE = "Sample: add a documented `greet` function with a test";
15589
15853
  function sampleDescription() {
@@ -15675,8 +15939,8 @@ function schedulerInstalled(plan, exists, crontab) {
15675
15939
  }
15676
15940
 
15677
15941
  // src/worktree-sweep.ts
15678
- import { existsSync as existsSync27, readFileSync as readFileSync25, readdirSync as readdirSync9 } from "fs";
15679
- import { basename as basename11, dirname as dirname18, join as join30, resolve as resolve4 } from "path";
15942
+ import { existsSync as existsSync29, readFileSync as readFileSync26, readdirSync as readdirSync11 } from "fs";
15943
+ import { basename as basename11, dirname as dirname18, join as join32, resolve as resolve4 } from "path";
15680
15944
  function planWorktreeSweep(target, tickets, contract) {
15681
15945
  const terminal = new Set(contract.statuses.resolved.filter((s) => s !== contract.statuses.verified));
15682
15946
  const verified = contract.statuses.verified;
@@ -15735,7 +15999,7 @@ function siblingWorktreeDirs(target) {
15735
15999
  const registered = new Set(worktrees(target.dir).map((w) => resolve4(w.path)));
15736
16000
  let names;
15737
16001
  try {
15738
- names = readdirSync9(parent);
16002
+ names = readdirSync11(parent);
15739
16003
  } catch {
15740
16004
  return [];
15741
16005
  }
@@ -15743,7 +16007,7 @@ function siblingWorktreeDirs(target) {
15743
16007
  for (const name of names) {
15744
16008
  if (!name.startsWith(stem)) continue;
15745
16009
  const m = DIR_NUMBER_SUFFIXED.exec(name);
15746
- const path = join30(parent, name);
16010
+ const path = join32(parent, name);
15747
16011
  if (m && !registered.has(resolve4(path))) out.push({ path, number: m[1] });
15748
16012
  }
15749
16013
  return out;
@@ -15778,7 +16042,7 @@ async function applyWorktreeSweep(cwd, actions, dryRun, log, remote = "origin",
15778
16042
  let removed = 0;
15779
16043
  let keptBranches = 0;
15780
16044
  for (const a of actions) {
15781
- if (!dryRun && base && !a.keepBranch && existsSync27(a.path) && isHandBackMerge(a.path, base)) {
16045
+ if (!dryRun && base && !a.keepBranch && existsSync29(a.path) && isHandBackMerge(a.path, base)) {
15782
16046
  if (gitOk(a.path, ["merge", "--abort"]) !== null) {
15783
16047
  log.emit(
15784
16048
  `abandoned the hand-back merge of ${base} in ${a.path}: ${a.ticket.issue_id} deployed without it`,
@@ -15786,7 +16050,7 @@ async function applyWorktreeSweep(cwd, actions, dryRun, log, remote = "origin",
15786
16050
  );
15787
16051
  }
15788
16052
  }
15789
- if (!dryRun && existsSync27(a.path)) {
16053
+ if (!dryRun && existsSync29(a.path)) {
15790
16054
  let dirty = false;
15791
16055
  try {
15792
16056
  dirty = status(a.path).length > 0;
@@ -15797,7 +16061,7 @@ async function applyWorktreeSweep(cwd, actions, dryRun, log, remote = "origin",
15797
16061
  continue;
15798
16062
  }
15799
16063
  }
15800
- if (!dryRun && a.requirePushed && a.branch && existsSync27(a.path) && remoteConfigured(cwd, remote) && worktreeHoldsUnpushed(a.path, remote)) {
16064
+ if (!dryRun && a.requirePushed && a.branch && existsSync29(a.path) && remoteConfigured(cwd, remote) && worktreeHoldsUnpushed(a.path, remote)) {
15801
16065
  refuse(a, "unpushed commits", `${a.branch} has commits that are not on ${remote}`);
15802
16066
  continue;
15803
16067
  }
@@ -15846,7 +16110,7 @@ function isHandBackMerge(path, base) {
15846
16110
  if (!msgPath) return false;
15847
16111
  let first;
15848
16112
  try {
15849
- first = readFileSync25(resolve4(path, msgPath), "utf8").split("\n")[0] ?? "";
16113
+ first = readFileSync26(resolve4(path, msgPath), "utf8").split("\n")[0] ?? "";
15850
16114
  } catch {
15851
16115
  return false;
15852
16116
  }
@@ -15951,14 +16215,14 @@ function applyRemoteBranchCleanup(cwd, remote, branches2, dryRun, log) {
15951
16215
  }
15952
16216
 
15953
16217
  // src/stream-sweep.ts
15954
- import { existsSync as existsSync28, readdirSync as readdirSync10, rmSync as rmSync13, statSync as statSync8 } from "fs";
15955
- import { join as join31 } from "path";
16218
+ import { existsSync as existsSync30, readdirSync as readdirSync12, rmSync as rmSync13, statSync as statSync8 } from "fs";
16219
+ import { join as join33 } from "path";
15956
16220
  var DAY_MS2 = 24 * 60 * 60 * 1e3;
15957
16221
  function planStreamSweep(stateDir, retentionDays, now = Date.now()) {
15958
- const dir = join31(stateDir, "streams");
15959
- if (!existsSync28(dir)) return [];
16222
+ const dir = join33(stateDir, "streams");
16223
+ if (!existsSync30(dir)) return [];
15960
16224
  const cutoff = now - retentionDays * DAY_MS2;
15961
- return readdirSync10(dir).map((name) => join31(dir, name)).filter((path) => {
16225
+ return readdirSync12(dir).map((name) => join33(dir, name)).filter((path) => {
15962
16226
  try {
15963
16227
  return statSync8(path).mtimeMs < cutoff;
15964
16228
  } catch {
@@ -15984,9 +16248,9 @@ function applyStreamSweep(paths, dryRun, log) {
15984
16248
  return removed;
15985
16249
  }
15986
16250
  function planEventsSweep(stateDir, retentionDays, now = Date.now()) {
15987
- if (!existsSync28(stateDir)) return [];
16251
+ if (!existsSync30(stateDir)) return [];
15988
16252
  const cutoff = now - retentionDays * DAY_MS2;
15989
- return readdirSync10(stateDir).filter((name) => ROTATED_EVENTS_RE.test(name)).map((name) => join31(stateDir, name)).filter((path) => {
16253
+ return readdirSync12(stateDir).filter((name) => ROTATED_EVENTS_RE.test(name)).map((name) => join33(stateDir, name)).filter((path) => {
15990
16254
  try {
15991
16255
  return statSync8(path).mtimeMs < cutoff;
15992
16256
  } catch {
@@ -15996,23 +16260,23 @@ function planEventsSweep(stateDir, retentionDays, now = Date.now()) {
15996
16260
  }
15997
16261
 
15998
16262
  // src/cli.ts
15999
- import { realpathSync as realpathSync3, readFileSync as readFileSync31, existsSync as existsSync34, mkdirSync as mkdirSync22, writeFileSync as writeFileSync23, rmSync as rmSync15, unlinkSync as unlinkSync5, copyFileSync as copyFileSync2, renameSync as renameSync3, statSync as statSync10, readdirSync as readdirSync15 } from "fs";
16263
+ import { realpathSync as realpathSync3, readFileSync as readFileSync31, existsSync as existsSync35, mkdirSync as mkdirSync22, writeFileSync as writeFileSync23, rmSync as rmSync15, unlinkSync as unlinkSync5, copyFileSync as copyFileSync2, renameSync as renameSync3, statSync as statSync10, readdirSync as readdirSync16 } from "fs";
16000
16264
  import { createInterface } from "readline/promises";
16001
16265
  import { dirname as dirOf, resolve as resolvePath } from "path";
16002
16266
 
16003
16267
  // src/ssh-keys.ts
16004
16268
  import { execFileSync as execFileSync6 } from "child_process";
16005
- import { existsSync as existsSync29, mkdirSync as mkdirSync18, readFileSync as readFileSync26, chmodSync } from "fs";
16006
- import { dirname as dirname19, join as join32 } from "path";
16269
+ import { existsSync as existsSync31, mkdirSync as mkdirSync18, readFileSync as readFileSync27, chmodSync } from "fs";
16270
+ import { dirname as dirname19, join as join34 } from "path";
16007
16271
  var SshKeyError = class extends Error {
16008
16272
  };
16009
16273
  function shipSshKeyPathFor(stateDir) {
16010
- return join32(stateDir, "ssh", "id_ed25519");
16274
+ return join34(stateDir, "ssh", "id_ed25519");
16011
16275
  }
16012
16276
  function ensureShipSshKeypair(stateDir) {
16013
16277
  const privateKeyPath = shipSshKeyPathFor(stateDir);
16014
16278
  const publicKeyPath = `${privateKeyPath}.pub`;
16015
- if (!existsSync29(privateKeyPath)) {
16279
+ if (!existsSync31(privateKeyPath)) {
16016
16280
  mkdirSync18(dirname19(privateKeyPath), { recursive: true });
16017
16281
  try {
16018
16282
  execFileSync6("ssh-keygen", [
@@ -16033,10 +16297,10 @@ function ensureShipSshKeypair(stateDir) {
16033
16297
  }
16034
16298
  chmodSync(privateKeyPath, 384);
16035
16299
  }
16036
- if (!existsSync29(publicKeyPath)) {
16300
+ if (!existsSync31(publicKeyPath)) {
16037
16301
  throw new SshKeyError(`${privateKeyPath} exists but ${publicKeyPath} does not \u2014 regenerate or restore it`);
16038
16302
  }
16039
- const publicKey = readFileSync26(publicKeyPath, "utf8").trim();
16303
+ const publicKey = readFileSync27(publicKeyPath, "utf8").trim();
16040
16304
  return { privateKeyPath, publicKeyPath, publicKey };
16041
16305
  }
16042
16306
  function sshKeygenAvailable() {
@@ -16253,9 +16517,9 @@ function syncAllPassengerCheckouts(cfg) {
16253
16517
 
16254
16518
  // src/tunnel.ts
16255
16519
  import { spawn as spawn5 } from "child_process";
16256
- import { existsSync as existsSync30, mkdirSync as mkdirSync19, readFileSync as readFileSync27, writeFileSync as writeFileSync20, unlinkSync as unlinkSync3, readdirSync as readdirSync11 } from "fs";
16520
+ import { existsSync as existsSync32, mkdirSync as mkdirSync19, readFileSync as readFileSync28, writeFileSync as writeFileSync20, unlinkSync as unlinkSync3, readdirSync as readdirSync13 } from "fs";
16257
16521
  import { randomInt } from "crypto";
16258
- import { join as join33 } from "path";
16522
+ import { join as join35 } from "path";
16259
16523
  var DEFAULT_SPAWN_OPTIONS = { stdio: "ignore", detached: true };
16260
16524
  var defaultSpawn = (cmd, args) => spawn5(cmd, args, DEFAULT_SPAWN_OPTIONS);
16261
16525
  var SLUG_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
@@ -16322,20 +16586,20 @@ function startTunnel(opts) {
16322
16586
  };
16323
16587
  }
16324
16588
  function tunnelStatePath(stateDir, workspaceId) {
16325
- return join33(stateDir, "passengers", `${workspaceId}.json`);
16589
+ return join35(stateDir, "passengers", `${workspaceId}.json`);
16326
16590
  }
16327
16591
  function readPersistedTunnel(stateDir, workspaceId) {
16328
16592
  const path = tunnelStatePath(stateDir, workspaceId);
16329
- if (!existsSync30(path)) return void 0;
16593
+ if (!existsSync32(path)) return void 0;
16330
16594
  try {
16331
- return JSON.parse(readFileSync27(path, "utf8"));
16595
+ return JSON.parse(readFileSync28(path, "utf8"));
16332
16596
  } catch {
16333
16597
  return void 0;
16334
16598
  }
16335
16599
  }
16336
16600
  function writePersistedTunnel(stateDir, state) {
16337
16601
  const path = tunnelStatePath(stateDir, state.workspaceId);
16338
- mkdirSync19(join33(stateDir, "passengers"), { recursive: true });
16602
+ mkdirSync19(join35(stateDir, "passengers"), { recursive: true });
16339
16603
  writeFileSync20(path, `${JSON.stringify(state, null, 2)}
16340
16604
  `);
16341
16605
  }
@@ -16433,9 +16697,9 @@ function syncPassengerTunnels(plans, relayHost, relayPort, privateKeyPath, state
16433
16697
  }
16434
16698
  }
16435
16699
  function readAllPersistedWorkspaceIds(stateDir) {
16436
- const dir = join33(stateDir, "passengers");
16437
- if (!existsSync30(dir)) return [];
16438
- return readdirSync11(dir).filter((f) => f.endsWith(".json") && !f.endsWith("-sync.json")).map((f) => f.slice(0, -".json".length));
16700
+ const dir = join35(stateDir, "passengers");
16701
+ if (!existsSync32(dir)) return [];
16702
+ return readdirSync13(dir).filter((f) => f.endsWith(".json") && !f.endsWith("-sync.json")).map((f) => f.slice(0, -".json".length));
16439
16703
  }
16440
16704
 
16441
16705
  // src/mcp-endpoint-registry.ts
@@ -16555,8 +16819,8 @@ async function unregisterEndpoint(workspaceId, info, d) {
16555
16819
  import { createServer as createHttpServer } from "http";
16556
16820
  import { spawn as spawn6 } from "child_process";
16557
16821
  import { randomBytes } from "crypto";
16558
- import { existsSync as existsSync31, mkdirSync as mkdirSync20, readFileSync as readFileSync28, writeFileSync as writeFileSync21, unlinkSync as unlinkSync4, readdirSync as readdirSync12 } from "fs";
16559
- import { join as join34 } from "path";
16822
+ import { existsSync as existsSync33, mkdirSync as mkdirSync20, readFileSync as readFileSync29, writeFileSync as writeFileSync21, unlinkSync as unlinkSync4, readdirSync as readdirSync14 } from "fs";
16823
+ import { join as join36 } from "path";
16560
16824
 
16561
16825
  // src/secret-compare.ts
16562
16826
  import { timingSafeEqual } from "crypto";
@@ -16645,20 +16909,20 @@ var defaultSpawn2 = (cmd, args, env) => {
16645
16909
  return child;
16646
16910
  };
16647
16911
  function syncDaemonStatePath(stateDir, workspaceId) {
16648
- return join34(stateDir, "passengers", `${workspaceId}-sync.json`);
16912
+ return join36(stateDir, "passengers", `${workspaceId}-sync.json`);
16649
16913
  }
16650
16914
  function readPersistedSyncDaemon(stateDir, workspaceId) {
16651
16915
  const path = syncDaemonStatePath(stateDir, workspaceId);
16652
- if (!existsSync31(path)) return void 0;
16916
+ if (!existsSync33(path)) return void 0;
16653
16917
  try {
16654
- return JSON.parse(readFileSync28(path, "utf8"));
16918
+ return JSON.parse(readFileSync29(path, "utf8"));
16655
16919
  } catch {
16656
16920
  return void 0;
16657
16921
  }
16658
16922
  }
16659
16923
  function writePersistedSyncDaemon(stateDir, state) {
16660
16924
  const path = syncDaemonStatePath(stateDir, state.workspaceId);
16661
- mkdirSync20(join34(stateDir, "passengers"), { recursive: true });
16925
+ mkdirSync20(join36(stateDir, "passengers"), { recursive: true });
16662
16926
  writeFileSync21(path, `${JSON.stringify(state, null, 2)}
16663
16927
  `);
16664
16928
  }
@@ -16679,10 +16943,10 @@ function waitForExit(pid, alive, timeoutMs = 5e3) {
16679
16943
  }
16680
16944
  }
16681
16945
  function readAllPersistedSyncWorkspaceIds(stateDir) {
16682
- const dir = join34(stateDir, "passengers");
16683
- if (!existsSync31(dir)) return [];
16946
+ const dir = join36(stateDir, "passengers");
16947
+ if (!existsSync33(dir)) return [];
16684
16948
  const suffix = "-sync.json";
16685
- return readdirSync12(dir).filter((f) => f.endsWith(suffix)).map((f) => f.slice(0, -suffix.length));
16949
+ return readdirSync14(dir).filter((f) => f.endsWith(suffix)).map((f) => f.slice(0, -suffix.length));
16686
16950
  }
16687
16951
  function syncPassengerSyncDaemons(plans, cfg, stateDir, deps = {}) {
16688
16952
  const alive = deps.isPidAlive ?? isPidAlive;
@@ -16768,95 +17032,9 @@ function describeCause(err) {
16768
17032
  }
16769
17033
 
16770
17034
  // src/daemon.ts
16771
- import { existsSync as existsSync33, mkdirSync as mkdirSync21, readFileSync as readFileSync30, readdirSync as readdirSync14, rmSync as rmSync14, statSync as statSync9, writeFileSync as writeFileSync22 } from "fs";
16772
- import { join as join36 } from "path";
16773
-
16774
- // src/interrupted.ts
16775
- import { existsSync as existsSync32, readdirSync as readdirSync13, readFileSync as readFileSync29 } from "fs";
16776
- import { join as join35 } from "path";
16777
- var INTERRUPTED_MARKER = calloutHeader("NOTE", "Interrupted");
16778
- function interruptedComment(b) {
16779
- const tree = b.dirty === null ? "The worktree could not be checked." : b.dirty ? "The worktree has uncommitted changes \u2014 they are this seat's own, from that run." : "The worktree is clean.";
16780
- return `${INTERRUPTED_MARKER}
16781
-
16782
- The agent run on this ticket was cut short (${b.reason}) before it handed on${b.branch ? `; its branch is \`${b.branch}\`` : ""}. ${tree} The next run on this seat resumes from the worktree rather than starting over.`;
16783
- }
16784
- var handled = /* @__PURE__ */ new Set();
16785
- var pidAlive = (pid) => {
16786
- try {
16787
- process.kill(pid, 0);
16788
- return true;
16789
- } catch (e) {
16790
- return e.code === "EPERM";
16791
- }
16792
- };
16793
- function runFinished(streamPath) {
16794
- const path = `${streamPath}.meta.json`;
16795
- if (!existsSync32(path)) return false;
16796
- try {
16797
- return JSON.parse(readFileSync29(path, "utf8")).running !== true;
16798
- } catch {
16799
- return true;
16800
- }
16801
- }
16802
- function orphanedStreams(o) {
16803
- const dir = join35(o.stateDir, "streams");
16804
- if (!existsSync32(dir)) return [];
16805
- const ownerAlive = o.isOwnerAlive ?? pidAlive;
16806
- const out = [];
16807
- for (const name of readdirSync13(dir)) {
16808
- if (!name.endsWith(".jsonl") || name.endsWith(".events.jsonl")) continue;
16809
- const streamPath = join35(dir, name);
16810
- if (runFinished(streamPath)) continue;
16811
- const sidecar = readRunSidecar(streamPath);
16812
- if (!sidecar || sidecar.route !== o.routeLabel) continue;
16813
- if (ownerAlive(sidecar.ownerPid)) continue;
16814
- out.push({ streamPath, sidecar });
16815
- }
16816
- return out;
16817
- }
16818
- async function reconcileRuns(o) {
16819
- let adopted = 0;
16820
- let interrupted = 0;
16821
- for (const { streamPath, sidecar } of orphanedStreams(o)) {
16822
- if (handled.has(streamPath)) continue;
16823
- handled.add(streamPath);
16824
- const isAlive = o.isAlive ?? runIsAlive;
16825
- const lock = isAlive(sidecar) ? o.state.acquireRun(sidecar.role, o.maxConcurrentAgents) : null;
16826
- const work = (async () => {
16827
- try {
16828
- const result = await reattachRun(streamPath, sidecar, o.emit, {
16829
- agentLogKey: await o.agentLogKey?.(sidecar.role).catch(() => void 0),
16830
- isAlive: o.isAlive,
16831
- pollMs: o.pollMs,
16832
- onAdopted: () => o.emit.emit(`adopted ${sidecar.role} run (pid ${sidecar.pid}) left by a previous crew process`, { step: "agent", role: sidecar.role })
16833
- });
16834
- if (result.adopted) adopted++;
16835
- else interrupted++;
16836
- try {
16837
- await o.afterRun?.(sidecar, result);
16838
- } catch (e) {
16839
- o.emit.warn(`could not follow up on ${sidecar.role} run ${streamPath}: ${e.message}`, { step: "agent" });
16840
- }
16841
- } catch (e) {
16842
- o.emit.warn(`could not reattach ${sidecar.role} run ${streamPath}: ${e.message}`, { step: "agent" });
16843
- } finally {
16844
- if (lock?.ok) lock.release();
16845
- }
16846
- })();
16847
- if (isAlive(sidecar)) {
16848
- if (o.inFlight) {
16849
- o.inFlight.add(work);
16850
- void work.finally(() => o.inFlight.delete(work));
16851
- }
16852
- } else {
16853
- await work;
16854
- }
16855
- }
16856
- return { adopted, interrupted };
16857
- }
16858
-
16859
- // src/daemon.ts
17035
+ import { existsSync as existsSync34, mkdirSync as mkdirSync21, readFileSync as readFileSync30, readdirSync as readdirSync15, rmSync as rmSync14, statSync as statSync9, writeFileSync as writeFileSync22 } from "fs";
17036
+ import { join as join37 } from "path";
17037
+ import { randomBytes as randomBytes2 } from "crypto";
16860
17038
  var DEFAULT_FLOOR_MS = 5e3;
16861
17039
  var DEFAULT_CEILING_MS = INTERVAL_SECONDS * 1e3;
16862
17040
  var defaultSleep = (ms2) => new Promise((resolve6) => setTimeout(resolve6, ms2));
@@ -16925,8 +17103,8 @@ function latestMtime(path) {
16925
17103
  if (stat.isFile()) return stat.mtimeMs;
16926
17104
  if (!stat.isDirectory()) return 0;
16927
17105
  let latest = stat.mtimeMs;
16928
- for (const entry of readdirSync14(path, { withFileTypes: true })) {
16929
- latest = Math.max(latest, latestMtime(join36(path, entry.name)));
17106
+ for (const entry of readdirSync15(path, { withFileTypes: true })) {
17107
+ latest = Math.max(latest, latestMtime(join37(path, entry.name)));
16930
17108
  }
16931
17109
  return latest;
16932
17110
  }
@@ -16935,13 +17113,13 @@ function computeTreeSignature(paths) {
16935
17113
  }
16936
17114
  function makeStaleChecker(paths, opts = {}) {
16937
17115
  const initial = computeTreeSignature(paths);
16938
- return () => computeTreeSignature(paths) !== initial || opts.reloadSentinel !== void 0 && existsSync33(opts.reloadSentinel);
17116
+ return () => computeTreeSignature(paths) !== initial || opts.reloadSentinel !== void 0 && existsSync34(opts.reloadSentinel);
16939
17117
  }
16940
17118
  function reloadSentinelFor(stateDir) {
16941
- return join36(stateDir, "reload-requested");
17119
+ return join37(stateDir, "reload-requested");
16942
17120
  }
16943
17121
  function daemonStampFor(stateDir) {
16944
- return join36(stateDir, "daemon.started");
17122
+ return join37(stateDir, "daemon.started");
16945
17123
  }
16946
17124
  function noteDaemonStarted(stateDir, now = Date.now()) {
16947
17125
  mkdirSync21(stateDir, { recursive: true });
@@ -16966,6 +17144,7 @@ function configChangedAfter(files, startedAt) {
16966
17144
  }
16967
17145
  return null;
16968
17146
  }
17147
+ var TICKET_ROLES = /* @__PURE__ */ new Set(["dev", "design", "qa"]);
16969
17148
  var SPAWN_PARK_AFTER = 3;
16970
17149
  var SpawnBreaker = class {
16971
17150
  states = /* @__PURE__ */ new Map();
@@ -17063,46 +17242,69 @@ async function runOnePassUnguarded(o) {
17063
17242
  );
17064
17243
  const started = [];
17065
17244
  const skipped = [];
17066
- for (const role of order) {
17067
- if (!breaker.allows(role)) {
17068
- skipped.push({ role, reason: "agent recently failed to start \u2014 waiting before retry" });
17069
- continue;
17070
- }
17071
- const lock = o.state.acquireRun(role, o.maxConcurrentAgents);
17072
- if (!lock.ok) {
17073
- skipped.push({ role, reason: lock.reason });
17074
- continue;
17075
- }
17076
- started.push(role);
17077
- const run2 = spawn7(role, decision, ctx).then(() => {
17078
- breaker.recordSuccess(role);
17079
- clearShipAttention(o.state, `role_parked:${role}`);
17080
- }).catch(async (e) => {
17081
- const message = e.message;
17082
- if (!(e instanceof AgentSpawnError)) {
17083
- emit.warn(`${role} agent run failed: ${message}`, { step: "agent", role });
17084
- return;
17245
+ const roleCap = (role) => TICKET_ROLES.has(role) ? o.roleConcurrency?.[role] ?? 1 : 1;
17246
+ const live = new Map(order.map((r) => [r, 0]));
17247
+ while (live.size) {
17248
+ for (const role of order) {
17249
+ const count = live.get(role);
17250
+ if (count === void 0) continue;
17251
+ if (!breaker.allows(role)) {
17252
+ if (count === 0) skipped.push({ role, reason: "agent recently failed to start \u2014 waiting before retry" });
17253
+ live.delete(role);
17254
+ continue;
17085
17255
  }
17086
- const report = breaker.recordFailure(role, message);
17087
- if (report.firstOfMessage || report.parked) {
17088
- emit.warn(`${role} agent could not start: ${message}`, { step: "agent", role });
17256
+ const lock = o.state.acquireRun(role, o.maxConcurrentAgents, roleCap(role));
17257
+ if (!lock.ok) {
17258
+ if (count === 0) skipped.push({ role, reason: lock.reason });
17259
+ live.delete(role);
17260
+ continue;
17089
17261
  }
17090
- if (report.parked) {
17091
- emit.error(
17092
- `${role} parked for ${Math.round(report.retryInMs / 1e3)}s after ${report.failures} consecutive failed starts \u2014 check that ship.agent.bin is installed and on PATH`
17093
- );
17094
- await raiseShipAttention({ state: o.state, emit, route: o.route, ship: o.ship }, {
17095
- kind: "role_parked",
17096
- key: `role_parked:${role}`,
17097
- message: `${role} agent is parked after ${report.failures} failed starts: ${message}`,
17098
- since: (/* @__PURE__ */ new Date()).toISOString()
17099
- });
17262
+ started.push(role);
17263
+ let ready = () => {
17264
+ };
17265
+ const settled = new Promise((resolve6) => {
17266
+ ready = resolve6;
17267
+ });
17268
+ const run2 = spawn7(role, decision, ctx, { ready }).then(() => {
17269
+ breaker.recordSuccess(role);
17270
+ clearShipAttention(o.state, `role_parked:${role}`);
17271
+ }).catch(async (e) => {
17272
+ const message = e.message;
17273
+ if (!(e instanceof AgentSpawnError)) {
17274
+ emit.warn(`${role} agent run failed: ${message}`, { step: "agent", role });
17275
+ return;
17276
+ }
17277
+ const report = breaker.recordFailure(role, message);
17278
+ if (report.firstOfMessage || report.parked) {
17279
+ emit.warn(`${role} agent could not start: ${message}`, { step: "agent", role });
17280
+ }
17281
+ if (report.parked) {
17282
+ emit.error(
17283
+ `${role} parked for ${Math.round(report.retryInMs / 1e3)}s after ${report.failures} consecutive failed starts \u2014 check that ship.agent.bin is installed and on PATH`
17284
+ );
17285
+ await raiseShipAttention({ state: o.state, emit, route: o.route, ship: o.ship }, {
17286
+ kind: "role_parked",
17287
+ key: `role_parked:${role}`,
17288
+ message: `${role} agent is parked after ${report.failures} failed starts: ${message}`,
17289
+ since: (/* @__PURE__ */ new Date()).toISOString()
17290
+ });
17291
+ }
17292
+ }).finally(() => {
17293
+ lock.release();
17294
+ ready(false);
17295
+ });
17296
+ if (o.inFlight) {
17297
+ const set = o.inFlight;
17298
+ set.add(run2);
17299
+ void run2.finally(() => set.delete(run2));
17300
+ }
17301
+ if (roleCap(role) <= 1) {
17302
+ live.delete(role);
17303
+ continue;
17100
17304
  }
17101
- }).finally(() => lock.release());
17102
- if (o.inFlight) {
17103
- const set = o.inFlight;
17104
- set.add(run2);
17105
- void run2.finally(() => set.delete(run2));
17305
+ const tookTicket = await settled;
17306
+ if (tookTicket && count + 1 < roleCap(role)) live.set(role, count + 1);
17307
+ else live.delete(role);
17106
17308
  }
17107
17309
  }
17108
17310
  await publishShipAttention(o);
@@ -17151,6 +17353,7 @@ async function reconcileForRoute(ctx, o) {
17151
17353
  emit,
17152
17354
  state: o.state,
17153
17355
  maxConcurrentAgents: o.maxConcurrentAgents,
17356
+ roleConcurrency: o.roleConcurrency,
17154
17357
  inFlight: o.inFlight,
17155
17358
  agentLogKey: async (role) => (await resolveSeatKey(route, role, ctx.getSessionStore)).apiKey,
17156
17359
  afterRun: async (sc, result) => {
@@ -17179,20 +17382,32 @@ async function reconcileForRoute(ctx, o) {
17179
17382
  await guardRunHandoff(route, ship, tracker, sc.role, sc.ticketRecordId, emit, sc.spawnedAt);
17180
17383
  }
17181
17384
  } finally {
17182
- if (memberId) {
17183
- await tracker.setCrewStatus(memberId, "idle").catch((e) => {
17184
- emit.warn(`could not clear crew status: ${e.message}`, { step: "agent" });
17185
- });
17186
- }
17187
- await tracker.beatIdle(ship.name).catch((e) => {
17188
- emit.warn(`could not beat ship idle: ${e.message}`, { step: "agent" });
17385
+ await settleRunEnd({
17386
+ tracker,
17387
+ shipName: ship.name,
17388
+ route,
17389
+ role: sc.role,
17390
+ memberId,
17391
+ ownTicketRecordId: sc.ticketRecordId,
17392
+ stateDir: ship.stateDir,
17393
+ emit
17189
17394
  });
17190
17395
  }
17191
17396
  }
17192
17397
  });
17193
17398
  }
17194
- async function runRoleAgent(role, decision, ctx) {
17399
+ async function runRoleAgent(role, decision, ctx, hooks) {
17400
+ const held = {};
17401
+ try {
17402
+ await runRoleAgentLocked(role, decision, ctx, hooks, held);
17403
+ } finally {
17404
+ held.release?.();
17405
+ hooks?.ready(false);
17406
+ }
17407
+ }
17408
+ async function runRoleAgentLocked(role, decision, ctx, hooks, held) {
17195
17409
  const { route, ship, emit } = ctx;
17410
+ const runId = randomBytes2(3).toString("hex");
17196
17411
  const identity = await resolveSeatKey(route, role, ctx.getSessionStore);
17197
17412
  if (identity.kind === "ship" && SEAT_KEY_ROLES.includes(role)) {
17198
17413
  emit.warn(`${role} writes as the ship, not its own agent: ${identity.reason}`, { step: "agent", role });
@@ -17215,8 +17430,13 @@ async function runRoleAgent(role, decision, ctx) {
17215
17430
  claimStatuses(role, contract).approved,
17216
17431
  claimStatuses(role, contract).building,
17217
17432
  (t) => dirForRepo(route, t.repo_id) !== null,
17218
- withHoldCheck(await tracker.claimAffinity(), decision.selectionInput)
17433
+ withHoldCheck(await tracker.claimAffinity(), decision.selectionInput),
17434
+ (t) => {
17435
+ const got = ctx.state.acquireTicket(t.id);
17436
+ return got.ok ? got.release : null;
17437
+ }
17219
17438
  );
17439
+ held.release = result.releaseTicket;
17220
17440
  if (result.contended.length) {
17221
17441
  emit.emit(`claim contended for ${result.contended.join(", ")} \u2014 moved to the next candidate`, {
17222
17442
  step: "select",
@@ -17253,6 +17473,11 @@ async function runRoleAgent(role, decision, ctx) {
17253
17473
  if (idx >= 0) decision.tickets[idx] = result.ticket;
17254
17474
  writeDigest({ route, ship, state: ctx.state, emit }, decision, role, ship.stateDir);
17255
17475
  }
17476
+ writeDigest({ route, ship, state: ctx.state, emit }, decision, role, ship.stateDir, runId, {
17477
+ ticket: result.ticket,
17478
+ inFlight: (id) => ctx.state.ticketInFlight(id)
17479
+ });
17480
+ hooks?.ready(true);
17256
17481
  ticketHint = result.ticket.issue_id;
17257
17482
  workingId = result.ticket.id;
17258
17483
  const target = repoTargetFor(route, result.ticket.repo_id);
@@ -17278,6 +17503,7 @@ async function runRoleAgent(role, decision, ctx) {
17278
17503
  environment: await buildEnvironment(route, ship, ticketHint),
17279
17504
  apiKey: identity.apiKey,
17280
17505
  cycle: emit.cycle,
17506
+ runId,
17281
17507
  ticket: ticketHint,
17282
17508
  repo: ticketRepo,
17283
17509
  ticketRecordId: workingId,
@@ -17318,18 +17544,16 @@ async function runRoleAgent(role, decision, ctx) {
17318
17544
  await spawnAgent(plan, emit, hintMoved);
17319
17545
  await guardRunHandoff(route, ship, tracker, role, workingId, emit, runStart);
17320
17546
  } finally {
17321
- if (memberId) {
17322
- try {
17323
- await tracker.setCrewStatus(memberId, "idle");
17324
- } catch (e) {
17325
- emit.warn(`could not clear crew status: ${e.message}`, { step: "agent" });
17326
- }
17327
- }
17328
- try {
17329
- await tracker.beatIdle(ship.name);
17330
- } catch (e) {
17331
- emit.warn(`could not beat ship idle: ${e.message}`, { step: "agent" });
17332
- }
17547
+ await settleRunEnd({
17548
+ tracker,
17549
+ shipName: ship.name,
17550
+ route,
17551
+ role,
17552
+ memberId,
17553
+ ownTicketRecordId: workingId,
17554
+ stateDir: ship.stateDir,
17555
+ emit
17556
+ });
17333
17557
  }
17334
17558
  }
17335
17559
  async function buildEnvironment(route, ship, ticket) {
@@ -17771,7 +17995,7 @@ if (command === "passenger-sync-daemon") {
17771
17995
  } else {
17772
17996
  const toleratesNoRoutes = command === "repos" || command === "ship" || command === "disconnect" && flag("reset") || command === "connect" && flag("sign-out");
17773
17997
  const configless = command === "connect" && flag("sign-out") || command === "disconnect" && flag("reset");
17774
- if (configless && !existsSync34(findConfigFile(CREW_HOME))) {
17998
+ if (configless && !existsSync35(findConfigFile(CREW_HOME))) {
17775
17999
  cfg = loadConfig(CREW_HOME, firstRunConfigPath(), { text: "ship: {}\n", allowNoRoutes: true });
17776
18000
  } else {
17777
18001
  cfg = loadConfig(CREW_HOME, void 0, toleratesNoRoutes ? { allowNoRoutes: true } : {});
@@ -18009,8 +18233,8 @@ if (command === "passenger-sync-daemon") {
18009
18233
  }
18010
18234
  if (host === "linux" && hasSystemd) {
18011
18235
  const label = labelFor(CREW_HOME, "run");
18012
- const staleTimer = join37(homedir10(), ".config", "systemd", "user", `${label}.timer`);
18013
- if (existsSync34(staleTimer)) {
18236
+ const staleTimer = join38(homedir10(), ".config", "systemd", "user", `${label}.timer`);
18237
+ if (existsSync35(staleTimer)) {
18014
18238
  process.stdout.write(
18015
18239
  `removing stale run timer from before the persistent-service cutover: ${staleTimer}${dryRun ? " (dry run)" : ""}
18016
18240
  `
@@ -18037,7 +18261,7 @@ if (command === "passenger-sync-daemon") {
18037
18261
  const tracker = new Tracker(c, cfg.ship);
18038
18262
  problems.push(...validateContract(tracker.contract).map((p) => `contract: ${p}`));
18039
18263
  for (const r of await resolvedRepos(c)) {
18040
- if (!existsSync34(r.dir)) problems.push(`repo ${r.name}: ${r.dir} is missing on disk`);
18264
+ if (!existsSync35(r.dir)) problems.push(`repo ${r.name}: ${r.dir} is missing on disk`);
18041
18265
  if (!satisfies(host, r.config.platform)) problems.push(`repo ${r.name}: requires ${r.config.platform}, this host is ${host}`);
18042
18266
  readiness.push(...validateEffective(r.config).map((p) => `repo ${r.name}: ${p}`));
18043
18267
  }
@@ -18055,7 +18279,7 @@ if (command === "passenger-sync-daemon") {
18055
18279
  }
18056
18280
  async function doctorFix(o) {
18057
18281
  const routes = await Promise.all(routesInScope(cfg.routes, named).map((c) => gatherHealth({ route: c.route, enabled: c.enabled, hasRepos: reposOf(c).length > 0 }, () => routeProblems(c))));
18058
- const unitExists = (p) => existsSync34(p);
18282
+ const unitExists = (p) => existsSync35(p);
18059
18283
  const crontab = o.host === "linux" ? spawnSync7("crontab", ["-l"], { encoding: "utf8" }).stdout ?? "" : "";
18060
18284
  const schedulerAbsent = o.host !== "windows" ? !schedulerInstalled(planInstall(cfg.ship, CREW_HOME, o.host, detectSystemd(), "run"), unitExists, crontab) : true;
18061
18285
  const plan = planFix({ routes, schedulerAbsent, host: o.host });
@@ -18718,7 +18942,7 @@ ${cu.tail.trimEnd()}
18718
18942
  `);
18719
18943
  break;
18720
18944
  }
18721
- const fleetLock = state.acquireRun(w.role, cfg.ship.maxConcurrentAgents);
18945
+ const fleetLock = state.acquireRun(w.role, cfg.ship.maxConcurrentAgents, cfg.ship.roleConcurrency[w.role] ?? 1);
18722
18946
  if (!fleetLock.ok) {
18723
18947
  wemit.emit(`${w.role} skipped this cycle \u2014 ${fleetLock.reason}`);
18724
18948
  if (!skipInlineRelease) await releaseFleet();
@@ -18741,8 +18965,20 @@ ${cu.tail.trimEnd()}
18741
18965
  claimStatuses(w.role, contract).approved,
18742
18966
  claimStatuses(w.role, contract).building,
18743
18967
  (t) => dirForRepo(w.route, t.repo_id) !== null,
18744
- withHoldCheck(await fleetTracker.claimAffinity(), w.decision.selectionInput)
18968
+ withHoldCheck(await fleetTracker.claimAffinity(), w.decision.selectionInput),
18969
+ (t) => {
18970
+ const got = state.acquireTicket(t.id);
18971
+ return got.ok ? got.release : null;
18972
+ }
18745
18973
  );
18974
+ if (result.releaseTicket) {
18975
+ const roleLock = lock;
18976
+ const releaseTicket = result.releaseTicket;
18977
+ lock = { release: () => {
18978
+ roleLock?.release();
18979
+ releaseTicket();
18980
+ } };
18981
+ }
18746
18982
  if (result.contended.length) {
18747
18983
  wemit.emit(`claim contended for ${result.contended.join(", ")} \u2014 moved to the next candidate`, {
18748
18984
  step: "select",
@@ -18832,18 +19068,16 @@ ${cu.tail.trimEnd()}
18832
19068
  await guardRunHandoff(w.route, cfg.ship, fleetTracker, w.role, fleetWorkingId, wemit, runStart);
18833
19069
  } finally {
18834
19070
  dropLock();
18835
- if (fleetMemberId) {
18836
- try {
18837
- await fleetTracker.setCrewStatus(fleetMemberId, "idle");
18838
- } catch (e) {
18839
- wemit.warn(`could not clear crew status: ${e.message}`, { step: "agent" });
18840
- }
18841
- }
18842
- try {
18843
- await fleetTracker.beatIdle(cfg.ship.name);
18844
- } catch (e) {
18845
- wemit.warn(`could not beat ship idle: ${e.message}`, { step: "agent" });
18846
- }
19071
+ await settleRunEnd({
19072
+ tracker: fleetTracker,
19073
+ shipName: cfg.ship.name,
19074
+ route: w.route,
19075
+ role: w.role,
19076
+ memberId: fleetMemberId,
19077
+ ownTicketRecordId: fleetWorkingId,
19078
+ stateDir: cfg.ship.stateDir,
19079
+ emit: wemit
19080
+ });
18847
19081
  }
18848
19082
  if (!skipInlineRelease) await releaseFleet();
18849
19083
  break;
@@ -18914,7 +19148,7 @@ ${cu.tail.trimEnd()}
18914
19148
  const tracker2 = new Tracker(route, cfg.ship);
18915
19149
  while (current) {
18916
19150
  if (!dryRun) {
18917
- const got = state.acquireRun(current, cfg.ship.maxConcurrentAgents);
19151
+ const got = state.acquireRun(current, cfg.ship.maxConcurrentAgents, cfg.ship.roleConcurrency[current] ?? 1);
18918
19152
  if (!got.ok) {
18919
19153
  emit.emit(`${current} skipped this cycle \u2014 ${got.reason}`);
18920
19154
  if (explicitRole) {
@@ -18942,8 +19176,20 @@ ${cu.tail.trimEnd()}
18942
19176
  claimStatuses(current, contract).approved,
18943
19177
  claimStatuses(current, contract).building,
18944
19178
  (t) => dirForRepo(route, t.repo_id) !== null,
18945
- withHoldCheck(await tracker2.claimAffinity(), decision.selectionInput)
19179
+ withHoldCheck(await tracker2.claimAffinity(), decision.selectionInput),
19180
+ (t) => {
19181
+ const got = state.acquireTicket(t.id);
19182
+ return got.ok ? got.release : null;
19183
+ }
18946
19184
  );
19185
+ if (result.releaseTicket) {
19186
+ const roleLock = lock;
19187
+ const releaseTicket = result.releaseTicket;
19188
+ lock = { release: () => {
19189
+ roleLock?.release();
19190
+ releaseTicket();
19191
+ } };
19192
+ }
18947
19193
  if (result.contended.length) {
18948
19194
  emit.emit(`claim contended for ${result.contended.join(", ")} \u2014 moved to the next candidate`, {
18949
19195
  step: "select",
@@ -19061,18 +19307,16 @@ ${cu.tail.trimEnd()}
19061
19307
  await guardRunHandoff(route, cfg.ship, tracker2, current, workingId, emit, runStart);
19062
19308
  } finally {
19063
19309
  dropLock();
19064
- if (memberId) {
19065
- try {
19066
- await tracker2.setCrewStatus(memberId, "idle");
19067
- } catch (e) {
19068
- emit.warn(`could not clear crew status: ${e.message}`, { step: "agent" });
19069
- }
19070
- }
19071
- try {
19072
- await tracker2.beatIdle(cfg.ship.name);
19073
- } catch (e) {
19074
- emit.warn(`could not beat ship idle: ${e.message}`, { step: "agent" });
19075
- }
19310
+ await settleRunEnd({
19311
+ tracker: tracker2,
19312
+ shipName: cfg.ship.name,
19313
+ route,
19314
+ role: current,
19315
+ memberId,
19316
+ ownTicketRecordId: workingId,
19317
+ stateDir: cfg.ship.stateDir,
19318
+ emit
19319
+ });
19076
19320
  }
19077
19321
  ran.push(current);
19078
19322
  if (ran.length >= maxRoles || explicitRole) break;
@@ -19156,7 +19400,7 @@ ${cu.tail.trimEnd()}
19156
19400
  process.exit(1);
19157
19401
  }
19158
19402
  requireArmed("run the daemon loop");
19159
- const watchPaths = isCompiledBinary(import.meta.url) ? [process.execPath] : [join37(CREW_HOME, "src"), join37(CREW_HOME, "dist"), join37(CREW_HOME, "bin")];
19403
+ const watchPaths = isCompiledBinary(import.meta.url) ? [process.execPath] : [join38(CREW_HOME, "src"), join38(CREW_HOME, "dist"), join38(CREW_HOME, "bin")];
19160
19404
  const configPaths = daemonConfigPaths(cfg);
19161
19405
  watchPaths.push(...configPaths);
19162
19406
  noteDaemonStarted(cfg.ship.stateDir);
@@ -19167,6 +19411,7 @@ ${cu.tail.trimEnd()}
19167
19411
  ship: cfg.ship,
19168
19412
  state,
19169
19413
  maxConcurrentAgents: cfg.ship.maxConcurrentAgents,
19414
+ roleConcurrency: cfg.ship.roleConcurrency,
19170
19415
  spawnBreaker,
19171
19416
  configFile: cfg.configFile,
19172
19417
  newEmitter: () => new Emitter({
@@ -19384,7 +19629,7 @@ ${cu.tail.trimEnd()}
19384
19629
  `);
19385
19630
  if (!dryRun) {
19386
19631
  const resolvedPath = resolvedPathFor(cfg.ship.stateDir, target.route);
19387
- const raw = existsSync34(resolvedPath) ? JSON.parse(readFileSync31(resolvedPath, "utf8")) : {};
19632
+ const raw = existsSync35(resolvedPath) ? JSON.parse(readFileSync31(resolvedPath, "utf8")) : {};
19388
19633
  raw.agentSkills = result.agentSkills;
19389
19634
  mkdirSync22(dirOf(resolvedPath), { recursive: true });
19390
19635
  writeFileSync23(resolvedPath, `${JSON.stringify(raw, null, 2)}
@@ -19488,10 +19733,10 @@ ${cu.tail.trimEnd()}
19488
19733
  try {
19489
19734
  const a = parseHooksArgs(positional.slice(2), routeNamesOf(configText));
19490
19735
  const dir = repoDirOf(configText, dirname20(cfg.configFile), a.route, a.name);
19491
- if (!existsSync34(dir)) throw new ReposError(`${dir} does not exist on disk`);
19492
- const files = { exists: (r) => existsSync34(resolvePath(dir, r)), list: (r) => {
19736
+ if (!existsSync35(dir)) throw new ReposError(`${dir} does not exist on disk`);
19737
+ const files = { exists: (r) => existsSync35(resolvePath(dir, r)), list: (r) => {
19493
19738
  try {
19494
- return readdirSync15(resolvePath(dir, r));
19739
+ return readdirSync16(resolvePath(dir, r));
19495
19740
  } catch {
19496
19741
  return [];
19497
19742
  }
@@ -19607,7 +19852,7 @@ ${cu.tail.trimEnd()}
19607
19852
  if (sub === "list" && !routeName) usage2();
19608
19853
  if (sub === "add") {
19609
19854
  try {
19610
- const a = parseAddArgs(positional.slice(2), routeNamesOf(configText), (p) => existsSync34(resolvePath(p.replace(/^~(?=\/|$)/, homedir10()))));
19855
+ const a = parseAddArgs(positional.slice(2), routeNamesOf(configText), (p) => existsSync35(resolvePath(p.replace(/^~(?=\/|$)/, homedir10()))));
19611
19856
  routeName = a.route;
19612
19857
  addPath = a.path;
19613
19858
  addName = a.name;
@@ -19636,7 +19881,7 @@ ${cu.tail.trimEnd()}
19636
19881
  }
19637
19882
  const resolvedFile = resolvedPathFor(cfg.ship.stateDir, routeName);
19638
19883
  let resolvedRemotes;
19639
- if (existsSync34(resolvedFile)) {
19884
+ if (existsSync35(resolvedFile)) {
19640
19885
  try {
19641
19886
  resolvedRemotes = JSON.parse(readFileSync31(resolvedFile, "utf8"));
19642
19887
  } catch {
@@ -19645,7 +19890,7 @@ ${cu.tail.trimEnd()}
19645
19890
  const originOf = (dir) => gitOk(dir, ["remote", "get-url", "origin"]) ?? void 0;
19646
19891
  const text = configText;
19647
19892
  const base = dirname20(cfg.configFile);
19648
- const isCheckout = (d) => existsSync34(`${d}/.git`);
19893
+ const isCheckout = (d) => existsSync35(`${d}/.git`);
19649
19894
  try {
19650
19895
  if (sub === "list") {
19651
19896
  if (flag("json")) {
@@ -19655,7 +19900,7 @@ ${cu.tail.trimEnd()}
19655
19900
  base,
19656
19901
  resolved: resolvedRemotes,
19657
19902
  originOf,
19658
- exists: (d) => existsSync34(d),
19903
+ exists: (d) => existsSync35(d),
19659
19904
  isCheckout,
19660
19905
  repoHasHooks: (d) => REPO_CONFIG_FILENAMES.some((f) => {
19661
19906
  try {
@@ -19676,7 +19921,7 @@ ${cu.tail.trimEnd()}
19676
19921
  for (const r of repos) {
19677
19922
  const origin2 = originOf(r.dir);
19678
19923
  const row = matchRepoName(origin2, resolvedRemotes);
19679
- const note = !existsSync34(r.dir) ? "missing on disk" : row ? `Repos row: ${row}` : `no Repos row matches ${normalizeRemote(origin2) ?? "this checkout (no origin remote)"}`;
19924
+ const note = !existsSync35(r.dir) ? "missing on disk" : row ? `Repos row: ${row}` : `no Repos row matches ${normalizeRemote(origin2) ?? "this checkout (no origin remote)"}`;
19680
19925
  process.stdout.write(`${r.name} ${r.dir} ${note}
19681
19926
  `);
19682
19927
  }
@@ -19688,9 +19933,9 @@ ${cu.tail.trimEnd()}
19688
19933
  ` : `${human}
19689
19934
  `);
19690
19935
  const plan2 = planRepoClone({ text, base, route: routeName, name: addName, resolved: resolvedRemotes, dir: value("dir"), isCheckout });
19691
- if (existsSync34(plan2.target) && (existsSync34(`${plan2.target}/.git`) || readdirSync15(plan2.target).length > 0)) {
19936
+ if (existsSync35(plan2.target) && (existsSync35(`${plan2.target}/.git`) || readdirSync16(plan2.target).length > 0)) {
19692
19937
  throw new ReposError(
19693
- existsSync34(`${plan2.target}/.git`) ? `${plan2.target} is already a checkout \u2014 attach it with \`crew repos add ${routeName} ${plan2.name} ${plan2.target}\`` : `${plan2.target} exists and is not empty \u2014 pass --dir to clone somewhere else`
19938
+ existsSync35(`${plan2.target}/.git`) ? `${plan2.target} is already a checkout \u2014 attach it with \`crew repos add ${routeName} ${plan2.name} ${plan2.target}\`` : `${plan2.target} exists and is not empty \u2014 pass --dir to clone somewhere else`
19694
19939
  );
19695
19940
  }
19696
19941
  if (dryRun) {
@@ -19715,7 +19960,7 @@ ${cu.tail.trimEnd()}
19715
19960
  break;
19716
19961
  }
19717
19962
  const target = resolvePath(addPath.replace(/^~(?=\/|$)/, homedir10()));
19718
- const top = existsSync34(target) ? gitOk(target, ["rev-parse", "--show-toplevel"]) : null;
19963
+ const top = existsSync35(target) ? gitOk(target, ["rev-parse", "--show-toplevel"]) : null;
19719
19964
  if (!top) throw new ReposError(`${target} is not a git checkout`);
19720
19965
  const origin = originOf(top);
19721
19966
  const plan = planRepoAdd({
@@ -19767,7 +20012,7 @@ ${cu.tail.trimEnd()}
19767
20012
  const created = await rowTracker.createRepoRow(rowPayload);
19768
20013
  process.stdout.write(`created Repos row ${String(rowPayload.name)} (${String(rowPayload.remote)}) ${created.id}
19769
20014
  `);
19770
- if (existsSync34(resolvedFile)) {
20015
+ if (existsSync35(resolvedFile)) {
19771
20016
  const raw = JSON.parse(readFileSync31(resolvedFile, "utf8"));
19772
20017
  raw.repoNames = { ...raw.repoNames, [created.id]: String(rowPayload.name) };
19773
20018
  raw.repoRemotes = { ...raw.repoRemotes, [created.id]: String(rowPayload.remote) };
@@ -20184,7 +20429,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
20184
20429
  }
20185
20430
  }
20186
20431
  let previous;
20187
- const hadPreviousFile = existsSync34(resolvedPath);
20432
+ const hadPreviousFile = existsSync35(resolvedPath);
20188
20433
  if (hadPreviousFile) {
20189
20434
  try {
20190
20435
  previous = JSON.parse(readFileSync31(resolvedPath, "utf8"));
@@ -21226,7 +21471,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
21226
21471
  }
21227
21472
  if (row) {
21228
21473
  await tracker.deleteRepoRow(row.id);
21229
- if (existsSync34(resolvedFile)) {
21474
+ if (existsSync35(resolvedFile)) {
21230
21475
  const raw = JSON.parse(readFileSync31(resolvedFile, "utf8"));
21231
21476
  if (raw.repoNames) delete raw.repoNames[row.id];
21232
21477
  if (raw.repoRemotes) delete raw.repoRemotes[row.id];
@@ -21234,21 +21479,21 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
21234
21479
  `);
21235
21480
  }
21236
21481
  }
21237
- if (existsSync34(join37(dir, ".git")) && basename12(dir) === SAMPLE_REPO) rmSync15(dir, { recursive: true, force: true });
21482
+ if (existsSync35(join38(dir, ".git")) && basename12(dir) === SAMPLE_REPO) rmSync15(dir, { recursive: true, force: true });
21238
21483
  say("removed", { dir, closed }, `removed the sample repo${closed.length ? `, closed ${closed.join(", ")}` : ""}`);
21239
21484
  break;
21240
21485
  }
21241
21486
  const projectId = route.resolved.areaId ?? resolveRepoProject(await tracker.projectRows(), value("project"));
21242
- const fresh = !existsSync34(join37(dir, ".git"));
21243
- if (existsSync34(dir) && fresh && readdirSync15(dir).length > 0) fail(`${dir} exists and is not a git checkout \u2014 move it aside`);
21487
+ const fresh = !existsSync35(join38(dir, ".git"));
21488
+ if (existsSync35(dir) && fresh && readdirSync16(dir).length > 0) fail(`${dir} exists and is not a git checkout \u2014 move it aside`);
21244
21489
  if (dryRun) {
21245
21490
  say("dry-run", { dir, newCheckout: fresh, repoRow: row?.id ?? null }, `would ${fresh ? "create" : "reuse"} ${dir}${row ? "" : ", create the Repos row"}, and file one ${tracker.contract.statuses.approved} ticket`);
21246
21491
  break;
21247
21492
  }
21248
21493
  if (fresh) {
21249
21494
  mkdirSync22(dir, { recursive: true });
21250
- writeFileSync23(join37(dir, "README.md"), SAMPLE_README);
21251
- writeFileSync23(join37(dir, ".crew.yaml"), SAMPLE_CREW_YAML);
21495
+ writeFileSync23(join38(dir, "README.md"), SAMPLE_README);
21496
+ writeFileSync23(join38(dir, ".crew.yaml"), SAMPLE_CREW_YAML);
21252
21497
  const g = (...a) => {
21253
21498
  const r = spawnSync7("git", a, { cwd: dir, encoding: "utf8" });
21254
21499
  if (r.status !== 0) fail(`git ${a[0]} failed in ${dir}: ${(r.stderr || r.stdout).trim()}`);
@@ -21266,7 +21511,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
21266
21511
  spec: { platform: "unix", releaseMode: "integrate", ciProvider: "none" }
21267
21512
  });
21268
21513
  repoId = (await tracker.createRepoRow(payload)).id;
21269
- if (existsSync34(resolvedFile)) {
21514
+ if (existsSync35(resolvedFile)) {
21270
21515
  const raw = JSON.parse(readFileSync31(resolvedFile, "utf8"));
21271
21516
  raw.repoNames = { ...raw.repoNames, [repoId]: SAMPLE_REPO };
21272
21517
  raw.repoRemotes = { ...raw.repoRemotes, [repoId]: SAMPLE_REMOTE };
@@ -21397,6 +21642,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
21397
21642
  const todo = await statusTracker.openTickets().then((open) => operatorTodo(open, statusTracker.contract, route.resolved?.operator ?? "")).catch(() => []);
21398
21643
  const projects = projectModeLabels(await statusTracker.projectRowsWithModes().catch(() => []));
21399
21644
  const crewMinVersion = await readCrewFloor(route, cfg.ship) ?? null;
21645
+ const runs = liveRuns(cfg.ship.stateDir);
21400
21646
  if (flag("json")) {
21401
21647
  const nameSync = await reconcileRouteShipName({ route, ship: cfg.ship, configFile: cfg.configFile, readOnly: true });
21402
21648
  const repos = await resolvedRepos(route);
@@ -21414,7 +21660,9 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
21414
21660
  label: ROLE_LABEL[r],
21415
21661
  crewId: seatId ?? null,
21416
21662
  name: crewRow?.name ?? null,
21417
- personaName
21663
+ personaName,
21664
+ // Additive (CREW-2822): how many runs this seat has live right now.
21665
+ running: runs.filter((x) => x.role === r && x.route === routeSlug(route.route)).length
21418
21666
  };
21419
21667
  });
21420
21668
  process.stdout.write(`${JSON.stringify({
@@ -21457,6 +21705,8 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
21457
21705
  roles: ROLE_NAMES.map((r) => ({ name: r, label: ROLE_LABEL[r], paused: state.isRolePaused(r) })),
21458
21706
  seats: seats2
21459
21707
  },
21708
+ // Additive (CREW-2822): every live run on this ship. `version` stays 1.
21709
+ runs: runs.map((x) => describeRun(x, route, seatIds)),
21460
21710
  watermark: state.watermark(),
21461
21711
  route: {
21462
21712
  name: route.route,
@@ -21569,6 +21819,10 @@ watermark: ${state.watermark()}
21569
21819
  `
21570
21820
  );
21571
21821
  }
21822
+ for (const x of runs) {
21823
+ process.stdout.write(`running: ${x.role} \u2014 ${x.ticket ?? "(no ticket)"}${x.repo ? ` in ${x.repo}` : ""} (pid ${x.pid}, since ${x.spawnedAt})
21824
+ `);
21825
+ }
21572
21826
  if (shipRows.length) {
21573
21827
  process.stdout.write("ships:\n");
21574
21828
  for (const s of shipRows) {
@@ -21597,7 +21851,7 @@ watermark: ${state.watermark()}
21597
21851
  }
21598
21852
  for (const r of repos) {
21599
21853
  const remote = r.config.branch.remote;
21600
- if (existsSync34(r.dir)) {
21854
+ if (existsSync35(r.dir)) {
21601
21855
  pushAuth.push({
21602
21856
  route: c.route,
21603
21857
  repo: r.name,
@@ -21642,6 +21896,7 @@ watermark: ${state.watermark()}
21642
21896
  } else if (p.state === "ok") passed++;
21643
21897
  }
21644
21898
  }
21899
+ for (const message of roleCapWarnings(cfg.ship)) problems.push({ severity: "note", kind: "role_cap_above_ship_cap", message });
21645
21900
  problems.push(...attentionProblems(openShipAttention(state), problems));
21646
21901
  if (problems.some((p) => p.severity === "blocker")) process.exitCode = 1;
21647
21902
  process.stdout.write(`${JSON.stringify({ offline: flag("offline"), pushAuth, tools: toolRows, problems, passed }, null, 2)}
@@ -21653,9 +21908,11 @@ watermark: ${state.watermark()}
21653
21908
  routes: ${cfg.routes.map((c) => c.route).join(", ")}
21654
21909
  `
21655
21910
  );
21911
+ for (const w of roleCapWarnings(cfg.ship)) process.stdout.write(`role concurrency: WARN ${w}
21912
+ `);
21656
21913
  try {
21657
21914
  const evFile = eventFileFor(cfg.ship.stateDir);
21658
- const evSize = existsSync34(evFile) ? statSync10(evFile).size : 0;
21915
+ const evSize = existsSync35(evFile) ? statSync10(evFile).size : 0;
21659
21916
  process.stdout.write(
21660
21917
  evSize > cfg.ship.eventsRotateBytes * 4 ? `events log: WARN ${evFile} is ${Math.round(evSize / 1048576)} MB (> 4x eventsRotateBytes); run \`crew reap\` to rotate it
21661
21918
  ` : "events log: ok\n"