@tablation/crew 0.80.0 → 0.81.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/crew.example.yaml CHANGED
@@ -27,6 +27,21 @@ ship:
27
27
  # to disable (less latency/cost, no cycles reported).
28
28
  # maxThinkingTokens: 4096
29
29
 
30
+ # Optional (CREW-2821): how many agents this ship runs at once. `roleConcurrency`
31
+ # is the cap on runs of ONE role (default 1 per role, so unset behaves as it
32
+ # always has: dev beside qa, never two devs). `maxConcurrentAgents` is the
33
+ # ship-wide cap across every role. Default 2 when `roleConcurrency` is unset;
34
+ # when `roleConcurrency` is set and `maxConcurrentAgents` is not, it defaults
35
+ # to the SUM of the role caps (plus 1 for each of dev/design/qa/triage the map
36
+ # leaves out), so `roleConcurrency: { dev: 2, qa: 2 }` alone allows 6 runs.
37
+ # Set it explicitly to hold a machine-wide budget below that sum;
38
+ # `crew doctor` warns when it is lower than any single role's cap. Each run
39
+ # takes its own ticket (a per-ticket lock keeps two runs off the same one).
40
+ # `dev`, `design` and `qa` can run several; `triage` is always one at a time.
41
+ # The release route is unaffected: one release per repository at a time.
42
+ # maxConcurrentAgents: 4
43
+ # roleConcurrency: { dev: 2, qa: 2 }
44
+
30
45
  # Optional (CREW-1511): named toolchains — a harness adapter plus the binary
31
46
  # and model to run it with. The `agent:` block above keeps working and
32
47
  # defines an implicit toolchain named `claude`. Harnesses: claude, codex, gemini. See docs/toolchains.md.
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
  *
@@ -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);
@@ -10955,8 +11030,15 @@ async function decideCycle(o) {
10955
11030
  }
10956
11031
  return decision;
10957
11032
  }
10958
- var digestPath = (stateDir, route, role) => join23(stateDir, `digest-${route}-${role}.md`);
10959
- function writeDigest(o, d, role, stateDir) {
11033
+ var digestPath = (stateDir, route, role, runId) => join23(stateDir, `digest-${route}-${role}${runId ? `-${runId}` : ""}.md`);
11034
+ function otherRunsRemoved(tickets, own) {
11035
+ if (!own) return tickets;
11036
+ return tickets.filter((t) => t.id === own.ticket.id || !own.inFlight(t.id));
11037
+ }
11038
+ function ownRunBanner(own) {
11039
+ 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.`;
11040
+ }
11041
+ function writeDigest(o, d, role, stateDir, runId, own) {
10960
11042
  const me = o.route.resolved?.seats[role];
10961
11043
  if (!me) return false;
10962
11044
  try {
@@ -11002,7 +11084,7 @@ function writeDigest(o, d, role, stateDir) {
11002
11084
  }
11003
11085
  return existingBranchForTicket(dir, repoFor(dir), t, role);
11004
11086
  },
11005
- tickets: role === "triage" ? triageSlice(d.tickets, o.route.resolved?.seats.triage, d.selectionInput.workModes) : withinWorkMode(sliceFor(d.tickets, role), d.selectionInput.workModes),
11087
+ 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
11088
  workModes: d.selectionInput.workModes,
11007
11089
  comments: d.comments,
11008
11090
  commentColumns: d.selectionInput.contract?.comments,
@@ -11021,8 +11103,11 @@ function writeDigest(o, d, role, stateDir) {
11021
11103
  blocked: d.blocked,
11022
11104
  blockerInfo: d.info
11023
11105
  };
11024
- const text = role === "qa" ? qaDigest(input) : buildingDigest(input);
11025
- const path = digestPath(stateDir, routeSlug(o.route.route), role);
11106
+ const body = role === "qa" ? qaDigest(input) : buildingDigest(input);
11107
+ const text = own ? `${ownRunBanner(own)}
11108
+
11109
+ ${body}` : body;
11110
+ const path = digestPath(stateDir, routeSlug(o.route.route), role, runId);
11026
11111
  writeFileSync14(path, text);
11027
11112
  o.emit.emit(`queue digest written for ${role} (${Buffer.byteLength(text)} bytes)`, {
11028
11113
  data: { role, bytes: Buffer.byteLength(text) }
@@ -11063,7 +11148,7 @@ async function stampResumption(tracker, t, affinity) {
11063
11148
  }
11064
11149
  return { outcome: "unknown" };
11065
11150
  }
11066
- async function resolveTopCandidate(tracker, candidates, seat, approvedStatus, buildingStatus, isServable, affinity) {
11151
+ async function resolveTopCandidate(tracker, candidates, seat, approvedStatus, buildingStatus, isServable, affinity, ticketLock) {
11067
11152
  const contended = [];
11068
11153
  const unservable = [];
11069
11154
  const held = [];
@@ -11076,41 +11161,58 @@ async function resolveTopCandidate(tracker, candidates, seat, approvedStatus, bu
11076
11161
  unservable.push(t.issue_id);
11077
11162
  continue;
11078
11163
  }
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);
11164
+ let releaseTicket;
11165
+ if (ticketLock) {
11166
+ const got = ticketLock(t);
11167
+ if (!got) {
11168
+ contended.push(t.issue_id);
11089
11169
  continue;
11090
11170
  }
11171
+ releaseTicket = got;
11172
+ }
11173
+ const result = await resolveOne(tracker, t, seat, approvedStatus, buildingStatus, affinity, contended, unservable, held);
11174
+ if (result === "walk") {
11175
+ releaseTicket?.();
11176
+ continue;
11177
+ }
11178
+ return releaseTicket ? { ...result, releaseTicket } : result;
11179
+ }
11180
+ return { ticket: null, claimed: false, contended, unservable, held, reaccepted: false, tookOverFrom: null };
11181
+ }
11182
+ async function resolveOne(tracker, t, seat, approvedStatus, buildingStatus, affinity, contended, unservable, held) {
11183
+ if (t.status !== approvedStatus) {
11184
+ if (affinity?.isLiveHoldElsewhere?.(t)) {
11091
11185
  return { ticket: t, claimed: false, contended, unservable, held, reaccepted: false, tookOverFrom: null };
11092
11186
  }
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;
11187
+ if (affinity && t[affinity.column] !== affinity.shipId) {
11188
+ const stamp = await stampResumption(tracker, t, affinity);
11189
+ if (stamp.outcome === "stamped") {
11190
+ return { ticket: stamp.ticket, claimed: false, contended, unservable, held, reaccepted: false, tookOverFrom: displacedHolder(t, affinity) };
11109
11191
  }
11110
- throw e;
11192
+ (stamp.outcome === "held" ? held : contended).push(t.issue_id);
11193
+ return "walk";
11111
11194
  }
11195
+ return { ticket: t, claimed: false, contended, unservable, held, reaccepted: false, tookOverFrom: null };
11196
+ }
11197
+ try {
11198
+ const claimed = await tracker.updateTicket(
11199
+ t.id,
11200
+ {
11201
+ status: buildingStatus,
11202
+ assignee_id: seat,
11203
+ ...affinity ? { [affinity.column]: affinity.shipId } : {}
11204
+ },
11205
+ t.updated_at
11206
+ );
11207
+ const reaccepted = !!affinity && t[affinity.column] === affinity.shipId;
11208
+ return { ticket: { ...t, ...claimed }, claimed: true, contended, unservable, held, reaccepted, tookOverFrom: displacedHolder(t, affinity) };
11209
+ } catch (e) {
11210
+ if (e instanceof StaleWriteError6) {
11211
+ contended.push(t.issue_id);
11212
+ return "walk";
11213
+ }
11214
+ throw e;
11112
11215
  }
11113
- return { ticket: null, claimed: false, contended, unservable, held, reaccepted: false, tookOverFrom: null };
11114
11216
  }
11115
11217
 
11116
11218
  // src/conflict.ts
@@ -11975,6 +12077,27 @@ function planStamp(cwd, tickets, contract, fromSha, toSha, merged = /* @__PURE__
11975
12077
  function mergedAtFor(cwd, sha, fallback) {
11976
12078
  return cwd && commitDate(cwd, sha) || fallback;
11977
12079
  }
12080
+ async function applyMergeStamp(tracker, merged, tickets, emit, dryRun, cwd) {
12081
+ let stamped = 0;
12082
+ const at = (/* @__PURE__ */ new Date()).toISOString();
12083
+ for (const ticket of tickets) {
12084
+ const sha = merged.get(ticket.issue_id);
12085
+ if (dryRun) {
12086
+ emit.emit(`would stamp commit_sha${sha ? ` (${sha.slice(0, 8)})` : ""}`, { ticket: ticket.issue_id });
12087
+ stamped++;
12088
+ continue;
12089
+ }
12090
+ if (!sha || ticket.commit_sha === sha) continue;
12091
+ try {
12092
+ await tracker.updateTicket(ticket.id, { commit_sha: sha, merged_at: mergedAtFor(cwd, sha, at) });
12093
+ emit.emit(`stamped commit_sha (${sha.slice(0, 8)})`, { ticket: ticket.issue_id });
12094
+ stamped++;
12095
+ } catch (e) {
12096
+ emit.warn(`could not stamp commit_sha: ${e.message}`, { ticket: ticket.issue_id });
12097
+ }
12098
+ }
12099
+ return stamped;
12100
+ }
11978
12101
  async function applyStamp(tracker, plan, version, contract, emit, dryRun, cwd) {
11979
12102
  let stamped = 0;
11980
12103
  const at = (/* @__PURE__ */ new Date()).toISOString();
@@ -11987,6 +12110,7 @@ async function applyStamp(tracker, plan, version, contract, emit, dryRun, cwd) {
11987
12110
  stamped++;
11988
12111
  continue;
11989
12112
  }
12113
+ const recordMerge = !!sha && !ticket.commit_sha;
11990
12114
  try {
11991
12115
  await tracker.updateTicket(ticket.id, {
11992
12116
  status: contract.statuses.deployed,
@@ -11997,7 +12121,7 @@ async function applyStamp(tracker, plan, version, contract, emit, dryRun, cwd) {
11997
12121
  // ticket's record should mean the same thing regardless of which
11998
12122
  // release mode closed it (ISSUE-218). `merged_at` is the commit's own
11999
12123
  // date, not the stamp time (which trails the deploy in ci_* modes).
12000
- ...sha ? { commit_sha: sha, merged_at: mergedAtFor(cwd, sha, at) } : {}
12124
+ ...recordMerge && sha ? { commit_sha: sha, merged_at: mergedAtFor(cwd, sha, at) } : {}
12001
12125
  });
12002
12126
  emit.emit(`stamped ${contract.statuses.deployed}${version ? ` (${version})` : ""}${shaNote}`, {
12003
12127
  ticket: ticket.issue_id
@@ -13072,6 +13196,13 @@ async function mergeAndRelease(o, decision, fresh, releaseBranch, isolated = fal
13072
13196
  }
13073
13197
  }
13074
13198
  }
13199
+ if (o.repo.release.mode === "ci_auto" || o.repo.release.mode === "ci_manual") {
13200
+ try {
13201
+ await o.onBasePushed?.(merged);
13202
+ } catch (e) {
13203
+ o.emit.warn(`could not record merged tickets: ${e.message}`);
13204
+ }
13205
+ }
13075
13206
  let deployed = false;
13076
13207
  let integrated = false;
13077
13208
  if (o.repo.release.mode === "integrate") {
@@ -16741,6 +16872,7 @@ function describeCause(err) {
16741
16872
  // src/daemon.ts
16742
16873
  import { existsSync as existsSync33, mkdirSync as mkdirSync21, readFileSync as readFileSync30, readdirSync as readdirSync14, rmSync as rmSync14, statSync as statSync9, writeFileSync as writeFileSync22 } from "fs";
16743
16874
  import { join as join36 } from "path";
16875
+ import { randomBytes as randomBytes2 } from "crypto";
16744
16876
 
16745
16877
  // src/interrupted.ts
16746
16878
  import { existsSync as existsSync32, readdirSync as readdirSync13, readFileSync as readFileSync29 } from "fs";
@@ -16793,7 +16925,8 @@ async function reconcileRuns(o) {
16793
16925
  if (handled.has(streamPath)) continue;
16794
16926
  handled.add(streamPath);
16795
16927
  const isAlive = o.isAlive ?? runIsAlive;
16796
- const lock = isAlive(sidecar) ? o.state.acquireRun(sidecar.role, o.maxConcurrentAgents) : null;
16928
+ const lock = isAlive(sidecar) ? o.state.acquireRun(sidecar.role, o.maxConcurrentAgents, o.roleConcurrency?.[sidecar.role] ?? 1) : null;
16929
+ const ticketLock = isAlive(sidecar) && sidecar.ticketRecordId ? o.state.acquireTicket(sidecar.ticketRecordId) : null;
16797
16930
  const work = (async () => {
16798
16931
  try {
16799
16932
  const result = await reattachRun(streamPath, sidecar, o.emit, {
@@ -16813,6 +16946,7 @@ async function reconcileRuns(o) {
16813
16946
  o.emit.warn(`could not reattach ${sidecar.role} run ${streamPath}: ${e.message}`, { step: "agent" });
16814
16947
  } finally {
16815
16948
  if (lock?.ok) lock.release();
16949
+ if (ticketLock?.ok) ticketLock.release();
16816
16950
  }
16817
16951
  })();
16818
16952
  if (isAlive(sidecar)) {
@@ -16937,6 +17071,7 @@ function configChangedAfter(files, startedAt) {
16937
17071
  }
16938
17072
  return null;
16939
17073
  }
17074
+ var TICKET_ROLES = /* @__PURE__ */ new Set(["dev", "design", "qa"]);
16940
17075
  var SPAWN_PARK_AFTER = 3;
16941
17076
  var SpawnBreaker = class {
16942
17077
  states = /* @__PURE__ */ new Map();
@@ -17034,46 +17169,69 @@ async function runOnePassUnguarded(o) {
17034
17169
  );
17035
17170
  const started = [];
17036
17171
  const skipped = [];
17037
- for (const role of order) {
17038
- if (!breaker.allows(role)) {
17039
- skipped.push({ role, reason: "agent recently failed to start \u2014 waiting before retry" });
17040
- continue;
17041
- }
17042
- const lock = o.state.acquireRun(role, o.maxConcurrentAgents);
17043
- if (!lock.ok) {
17044
- skipped.push({ role, reason: lock.reason });
17045
- continue;
17046
- }
17047
- started.push(role);
17048
- const run2 = spawn7(role, decision, ctx).then(() => {
17049
- breaker.recordSuccess(role);
17050
- clearShipAttention(o.state, `role_parked:${role}`);
17051
- }).catch(async (e) => {
17052
- const message = e.message;
17053
- if (!(e instanceof AgentSpawnError)) {
17054
- emit.warn(`${role} agent run failed: ${message}`, { step: "agent", role });
17055
- return;
17172
+ const roleCap = (role) => TICKET_ROLES.has(role) ? o.roleConcurrency?.[role] ?? 1 : 1;
17173
+ const live = new Map(order.map((r) => [r, 0]));
17174
+ while (live.size) {
17175
+ for (const role of order) {
17176
+ const count = live.get(role);
17177
+ if (count === void 0) continue;
17178
+ if (!breaker.allows(role)) {
17179
+ if (count === 0) skipped.push({ role, reason: "agent recently failed to start \u2014 waiting before retry" });
17180
+ live.delete(role);
17181
+ continue;
17056
17182
  }
17057
- const report = breaker.recordFailure(role, message);
17058
- if (report.firstOfMessage || report.parked) {
17059
- emit.warn(`${role} agent could not start: ${message}`, { step: "agent", role });
17183
+ const lock = o.state.acquireRun(role, o.maxConcurrentAgents, roleCap(role));
17184
+ if (!lock.ok) {
17185
+ if (count === 0) skipped.push({ role, reason: lock.reason });
17186
+ live.delete(role);
17187
+ continue;
17060
17188
  }
17061
- if (report.parked) {
17062
- emit.error(
17063
- `${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`
17064
- );
17065
- await raiseShipAttention({ state: o.state, emit, route: o.route, ship: o.ship }, {
17066
- kind: "role_parked",
17067
- key: `role_parked:${role}`,
17068
- message: `${role} agent is parked after ${report.failures} failed starts: ${message}`,
17069
- since: (/* @__PURE__ */ new Date()).toISOString()
17070
- });
17189
+ started.push(role);
17190
+ let ready = () => {
17191
+ };
17192
+ const settled = new Promise((resolve6) => {
17193
+ ready = resolve6;
17194
+ });
17195
+ const run2 = spawn7(role, decision, ctx, { ready }).then(() => {
17196
+ breaker.recordSuccess(role);
17197
+ clearShipAttention(o.state, `role_parked:${role}`);
17198
+ }).catch(async (e) => {
17199
+ const message = e.message;
17200
+ if (!(e instanceof AgentSpawnError)) {
17201
+ emit.warn(`${role} agent run failed: ${message}`, { step: "agent", role });
17202
+ return;
17203
+ }
17204
+ const report = breaker.recordFailure(role, message);
17205
+ if (report.firstOfMessage || report.parked) {
17206
+ emit.warn(`${role} agent could not start: ${message}`, { step: "agent", role });
17207
+ }
17208
+ if (report.parked) {
17209
+ emit.error(
17210
+ `${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`
17211
+ );
17212
+ await raiseShipAttention({ state: o.state, emit, route: o.route, ship: o.ship }, {
17213
+ kind: "role_parked",
17214
+ key: `role_parked:${role}`,
17215
+ message: `${role} agent is parked after ${report.failures} failed starts: ${message}`,
17216
+ since: (/* @__PURE__ */ new Date()).toISOString()
17217
+ });
17218
+ }
17219
+ }).finally(() => {
17220
+ lock.release();
17221
+ ready(false);
17222
+ });
17223
+ if (o.inFlight) {
17224
+ const set = o.inFlight;
17225
+ set.add(run2);
17226
+ void run2.finally(() => set.delete(run2));
17071
17227
  }
17072
- }).finally(() => lock.release());
17073
- if (o.inFlight) {
17074
- const set = o.inFlight;
17075
- set.add(run2);
17076
- void run2.finally(() => set.delete(run2));
17228
+ if (roleCap(role) <= 1) {
17229
+ live.delete(role);
17230
+ continue;
17231
+ }
17232
+ const tookTicket = await settled;
17233
+ if (tookTicket && count + 1 < roleCap(role)) live.set(role, count + 1);
17234
+ else live.delete(role);
17077
17235
  }
17078
17236
  }
17079
17237
  await publishShipAttention(o);
@@ -17122,6 +17280,7 @@ async function reconcileForRoute(ctx, o) {
17122
17280
  emit,
17123
17281
  state: o.state,
17124
17282
  maxConcurrentAgents: o.maxConcurrentAgents,
17283
+ roleConcurrency: o.roleConcurrency,
17125
17284
  inFlight: o.inFlight,
17126
17285
  agentLogKey: async (role) => (await resolveSeatKey(route, role, ctx.getSessionStore)).apiKey,
17127
17286
  afterRun: async (sc, result) => {
@@ -17162,8 +17321,18 @@ async function reconcileForRoute(ctx, o) {
17162
17321
  }
17163
17322
  });
17164
17323
  }
17165
- async function runRoleAgent(role, decision, ctx) {
17324
+ async function runRoleAgent(role, decision, ctx, hooks) {
17325
+ const held = {};
17326
+ try {
17327
+ await runRoleAgentLocked(role, decision, ctx, hooks, held);
17328
+ } finally {
17329
+ held.release?.();
17330
+ hooks?.ready(false);
17331
+ }
17332
+ }
17333
+ async function runRoleAgentLocked(role, decision, ctx, hooks, held) {
17166
17334
  const { route, ship, emit } = ctx;
17335
+ const runId = randomBytes2(3).toString("hex");
17167
17336
  const identity = await resolveSeatKey(route, role, ctx.getSessionStore);
17168
17337
  if (identity.kind === "ship" && SEAT_KEY_ROLES.includes(role)) {
17169
17338
  emit.warn(`${role} writes as the ship, not its own agent: ${identity.reason}`, { step: "agent", role });
@@ -17186,8 +17355,13 @@ async function runRoleAgent(role, decision, ctx) {
17186
17355
  claimStatuses(role, contract).approved,
17187
17356
  claimStatuses(role, contract).building,
17188
17357
  (t) => dirForRepo(route, t.repo_id) !== null,
17189
- withHoldCheck(await tracker.claimAffinity(), decision.selectionInput)
17358
+ withHoldCheck(await tracker.claimAffinity(), decision.selectionInput),
17359
+ (t) => {
17360
+ const got = ctx.state.acquireTicket(t.id);
17361
+ return got.ok ? got.release : null;
17362
+ }
17190
17363
  );
17364
+ held.release = result.releaseTicket;
17191
17365
  if (result.contended.length) {
17192
17366
  emit.emit(`claim contended for ${result.contended.join(", ")} \u2014 moved to the next candidate`, {
17193
17367
  step: "select",
@@ -17224,6 +17398,11 @@ async function runRoleAgent(role, decision, ctx) {
17224
17398
  if (idx >= 0) decision.tickets[idx] = result.ticket;
17225
17399
  writeDigest({ route, ship, state: ctx.state, emit }, decision, role, ship.stateDir);
17226
17400
  }
17401
+ writeDigest({ route, ship, state: ctx.state, emit }, decision, role, ship.stateDir, runId, {
17402
+ ticket: result.ticket,
17403
+ inFlight: (id) => ctx.state.ticketInFlight(id)
17404
+ });
17405
+ hooks?.ready(true);
17227
17406
  ticketHint = result.ticket.issue_id;
17228
17407
  workingId = result.ticket.id;
17229
17408
  const target = repoTargetFor(route, result.ticket.repo_id);
@@ -17249,6 +17428,7 @@ async function runRoleAgent(role, decision, ctx) {
17249
17428
  environment: await buildEnvironment(route, ship, ticketHint),
17250
17429
  apiKey: identity.apiKey,
17251
17430
  cycle: emit.cycle,
17431
+ runId,
17252
17432
  ticket: ticketHint,
17253
17433
  repo: ticketRepo,
17254
17434
  ticketRecordId: workingId,
@@ -18292,6 +18472,19 @@ ${changed2.map((c) => ` - ${c}
18292
18472
  skipTests: flag("skip-tests"),
18293
18473
  shell: cfg.ship.shell,
18294
18474
  mergeOnly: command === "merge",
18475
+ // ci_* modes: record the merge on the board when it lands on base,
18476
+ // not only once CI confirms the release live (CREW-2820).
18477
+ onBasePushed: async (merged) => {
18478
+ remit.enter("reconcile");
18479
+ await applyMergeStamp(
18480
+ tracker,
18481
+ new Map(merged.map((m) => [m.ticket.issue_id, m.sha])),
18482
+ merged.map((m) => m.ticket),
18483
+ remit,
18484
+ dryRun,
18485
+ target.dir
18486
+ );
18487
+ },
18295
18488
  // `deploy` is the "go now" button: it exists for a commit a previous
18296
18489
  // deploy failed on, where nothing new will merge but the work is
18297
18490
  // genuinely unreleased.
@@ -18676,7 +18869,7 @@ ${cu.tail.trimEnd()}
18676
18869
  `);
18677
18870
  break;
18678
18871
  }
18679
- const fleetLock = state.acquireRun(w.role, cfg.ship.maxConcurrentAgents);
18872
+ const fleetLock = state.acquireRun(w.role, cfg.ship.maxConcurrentAgents, cfg.ship.roleConcurrency[w.role] ?? 1);
18680
18873
  if (!fleetLock.ok) {
18681
18874
  wemit.emit(`${w.role} skipped this cycle \u2014 ${fleetLock.reason}`);
18682
18875
  if (!skipInlineRelease) await releaseFleet();
@@ -18699,8 +18892,20 @@ ${cu.tail.trimEnd()}
18699
18892
  claimStatuses(w.role, contract).approved,
18700
18893
  claimStatuses(w.role, contract).building,
18701
18894
  (t) => dirForRepo(w.route, t.repo_id) !== null,
18702
- withHoldCheck(await fleetTracker.claimAffinity(), w.decision.selectionInput)
18895
+ withHoldCheck(await fleetTracker.claimAffinity(), w.decision.selectionInput),
18896
+ (t) => {
18897
+ const got = state.acquireTicket(t.id);
18898
+ return got.ok ? got.release : null;
18899
+ }
18703
18900
  );
18901
+ if (result.releaseTicket) {
18902
+ const roleLock = lock;
18903
+ const releaseTicket = result.releaseTicket;
18904
+ lock = { release: () => {
18905
+ roleLock?.release();
18906
+ releaseTicket();
18907
+ } };
18908
+ }
18704
18909
  if (result.contended.length) {
18705
18910
  wemit.emit(`claim contended for ${result.contended.join(", ")} \u2014 moved to the next candidate`, {
18706
18911
  step: "select",
@@ -18872,7 +19077,7 @@ ${cu.tail.trimEnd()}
18872
19077
  const tracker2 = new Tracker(route, cfg.ship);
18873
19078
  while (current) {
18874
19079
  if (!dryRun) {
18875
- const got = state.acquireRun(current, cfg.ship.maxConcurrentAgents);
19080
+ const got = state.acquireRun(current, cfg.ship.maxConcurrentAgents, cfg.ship.roleConcurrency[current] ?? 1);
18876
19081
  if (!got.ok) {
18877
19082
  emit.emit(`${current} skipped this cycle \u2014 ${got.reason}`);
18878
19083
  if (explicitRole) {
@@ -18900,8 +19105,20 @@ ${cu.tail.trimEnd()}
18900
19105
  claimStatuses(current, contract).approved,
18901
19106
  claimStatuses(current, contract).building,
18902
19107
  (t) => dirForRepo(route, t.repo_id) !== null,
18903
- withHoldCheck(await tracker2.claimAffinity(), decision.selectionInput)
19108
+ withHoldCheck(await tracker2.claimAffinity(), decision.selectionInput),
19109
+ (t) => {
19110
+ const got = state.acquireTicket(t.id);
19111
+ return got.ok ? got.release : null;
19112
+ }
18904
19113
  );
19114
+ if (result.releaseTicket) {
19115
+ const roleLock = lock;
19116
+ const releaseTicket = result.releaseTicket;
19117
+ lock = { release: () => {
19118
+ roleLock?.release();
19119
+ releaseTicket();
19120
+ } };
19121
+ }
18905
19122
  if (result.contended.length) {
18906
19123
  emit.emit(`claim contended for ${result.contended.join(", ")} \u2014 moved to the next candidate`, {
18907
19124
  step: "select",
@@ -19125,6 +19342,7 @@ ${cu.tail.trimEnd()}
19125
19342
  ship: cfg.ship,
19126
19343
  state,
19127
19344
  maxConcurrentAgents: cfg.ship.maxConcurrentAgents,
19345
+ roleConcurrency: cfg.ship.roleConcurrency,
19128
19346
  spawnBreaker,
19129
19347
  configFile: cfg.configFile,
19130
19348
  newEmitter: () => new Emitter({
@@ -21600,6 +21818,7 @@ watermark: ${state.watermark()}
21600
21818
  } else if (p.state === "ok") passed++;
21601
21819
  }
21602
21820
  }
21821
+ for (const message of roleCapWarnings(cfg.ship)) problems.push({ severity: "note", kind: "role_cap_above_ship_cap", message });
21603
21822
  problems.push(...attentionProblems(openShipAttention(state), problems));
21604
21823
  if (problems.some((p) => p.severity === "blocker")) process.exitCode = 1;
21605
21824
  process.stdout.write(`${JSON.stringify({ offline: flag("offline"), pushAuth, tools: toolRows, problems, passed }, null, 2)}
@@ -21611,6 +21830,8 @@ watermark: ${state.watermark()}
21611
21830
  routes: ${cfg.routes.map((c) => c.route).join(", ")}
21612
21831
  `
21613
21832
  );
21833
+ for (const w of roleCapWarnings(cfg.ship)) process.stdout.write(`role concurrency: WARN ${w}
21834
+ `);
21614
21835
  try {
21615
21836
  const evFile = eventFileFor(cfg.ship.stateDir);
21616
21837
  const evSize = existsSync34(evFile) ? statSync10(evFile).size : 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.80.0",
3
+ "version": "0.81.0",
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",