@tablation/crew 0.80.1 → 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 +15 -0
- package/dist/cli.js +275 -96
- package/package.json +1 -1
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
|
|
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-
|
|
5658
|
-
* together.
|
|
5688
|
+
* The run lock (ISSUE-381, CREW-2821): role-capped AND capacity-limited,
|
|
5689
|
+
* checked together.
|
|
5659
5690
|
*
|
|
5660
|
-
*
|
|
5661
|
-
*
|
|
5662
|
-
*
|
|
5663
|
-
*
|
|
5664
|
-
*
|
|
5665
|
-
*
|
|
5666
|
-
*
|
|
5667
|
-
*
|
|
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
|
-
|
|
5671
|
-
|
|
5672
|
-
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
11025
|
-
const
|
|
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
|
-
|
|
11080
|
-
|
|
11081
|
-
|
|
11082
|
-
|
|
11083
|
-
|
|
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
|
-
|
|
11094
|
-
const
|
|
11095
|
-
|
|
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
|
-
|
|
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
|
|
@@ -16770,6 +16872,7 @@ function describeCause(err) {
|
|
|
16770
16872
|
// src/daemon.ts
|
|
16771
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";
|
|
16772
16874
|
import { join as join36 } from "path";
|
|
16875
|
+
import { randomBytes as randomBytes2 } from "crypto";
|
|
16773
16876
|
|
|
16774
16877
|
// src/interrupted.ts
|
|
16775
16878
|
import { existsSync as existsSync32, readdirSync as readdirSync13, readFileSync as readFileSync29 } from "fs";
|
|
@@ -16822,7 +16925,8 @@ async function reconcileRuns(o) {
|
|
|
16822
16925
|
if (handled.has(streamPath)) continue;
|
|
16823
16926
|
handled.add(streamPath);
|
|
16824
16927
|
const isAlive = o.isAlive ?? runIsAlive;
|
|
16825
|
-
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;
|
|
16826
16930
|
const work = (async () => {
|
|
16827
16931
|
try {
|
|
16828
16932
|
const result = await reattachRun(streamPath, sidecar, o.emit, {
|
|
@@ -16842,6 +16946,7 @@ async function reconcileRuns(o) {
|
|
|
16842
16946
|
o.emit.warn(`could not reattach ${sidecar.role} run ${streamPath}: ${e.message}`, { step: "agent" });
|
|
16843
16947
|
} finally {
|
|
16844
16948
|
if (lock?.ok) lock.release();
|
|
16949
|
+
if (ticketLock?.ok) ticketLock.release();
|
|
16845
16950
|
}
|
|
16846
16951
|
})();
|
|
16847
16952
|
if (isAlive(sidecar)) {
|
|
@@ -16966,6 +17071,7 @@ function configChangedAfter(files, startedAt) {
|
|
|
16966
17071
|
}
|
|
16967
17072
|
return null;
|
|
16968
17073
|
}
|
|
17074
|
+
var TICKET_ROLES = /* @__PURE__ */ new Set(["dev", "design", "qa"]);
|
|
16969
17075
|
var SPAWN_PARK_AFTER = 3;
|
|
16970
17076
|
var SpawnBreaker = class {
|
|
16971
17077
|
states = /* @__PURE__ */ new Map();
|
|
@@ -17063,46 +17169,69 @@ async function runOnePassUnguarded(o) {
|
|
|
17063
17169
|
);
|
|
17064
17170
|
const started = [];
|
|
17065
17171
|
const skipped = [];
|
|
17066
|
-
|
|
17067
|
-
|
|
17068
|
-
|
|
17069
|
-
|
|
17070
|
-
|
|
17071
|
-
|
|
17072
|
-
|
|
17073
|
-
|
|
17074
|
-
|
|
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;
|
|
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;
|
|
17085
17182
|
}
|
|
17086
|
-
const
|
|
17087
|
-
if (
|
|
17088
|
-
|
|
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;
|
|
17089
17188
|
}
|
|
17090
|
-
|
|
17091
|
-
|
|
17092
|
-
|
|
17093
|
-
|
|
17094
|
-
|
|
17095
|
-
|
|
17096
|
-
|
|
17097
|
-
|
|
17098
|
-
|
|
17099
|
-
|
|
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));
|
|
17227
|
+
}
|
|
17228
|
+
if (roleCap(role) <= 1) {
|
|
17229
|
+
live.delete(role);
|
|
17230
|
+
continue;
|
|
17100
17231
|
}
|
|
17101
|
-
|
|
17102
|
-
|
|
17103
|
-
|
|
17104
|
-
set.add(run2);
|
|
17105
|
-
void run2.finally(() => set.delete(run2));
|
|
17232
|
+
const tookTicket = await settled;
|
|
17233
|
+
if (tookTicket && count + 1 < roleCap(role)) live.set(role, count + 1);
|
|
17234
|
+
else live.delete(role);
|
|
17106
17235
|
}
|
|
17107
17236
|
}
|
|
17108
17237
|
await publishShipAttention(o);
|
|
@@ -17151,6 +17280,7 @@ async function reconcileForRoute(ctx, o) {
|
|
|
17151
17280
|
emit,
|
|
17152
17281
|
state: o.state,
|
|
17153
17282
|
maxConcurrentAgents: o.maxConcurrentAgents,
|
|
17283
|
+
roleConcurrency: o.roleConcurrency,
|
|
17154
17284
|
inFlight: o.inFlight,
|
|
17155
17285
|
agentLogKey: async (role) => (await resolveSeatKey(route, role, ctx.getSessionStore)).apiKey,
|
|
17156
17286
|
afterRun: async (sc, result) => {
|
|
@@ -17191,8 +17321,18 @@ async function reconcileForRoute(ctx, o) {
|
|
|
17191
17321
|
}
|
|
17192
17322
|
});
|
|
17193
17323
|
}
|
|
17194
|
-
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) {
|
|
17195
17334
|
const { route, ship, emit } = ctx;
|
|
17335
|
+
const runId = randomBytes2(3).toString("hex");
|
|
17196
17336
|
const identity = await resolveSeatKey(route, role, ctx.getSessionStore);
|
|
17197
17337
|
if (identity.kind === "ship" && SEAT_KEY_ROLES.includes(role)) {
|
|
17198
17338
|
emit.warn(`${role} writes as the ship, not its own agent: ${identity.reason}`, { step: "agent", role });
|
|
@@ -17215,8 +17355,13 @@ async function runRoleAgent(role, decision, ctx) {
|
|
|
17215
17355
|
claimStatuses(role, contract).approved,
|
|
17216
17356
|
claimStatuses(role, contract).building,
|
|
17217
17357
|
(t) => dirForRepo(route, t.repo_id) !== null,
|
|
17218
|
-
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
|
+
}
|
|
17219
17363
|
);
|
|
17364
|
+
held.release = result.releaseTicket;
|
|
17220
17365
|
if (result.contended.length) {
|
|
17221
17366
|
emit.emit(`claim contended for ${result.contended.join(", ")} \u2014 moved to the next candidate`, {
|
|
17222
17367
|
step: "select",
|
|
@@ -17253,6 +17398,11 @@ async function runRoleAgent(role, decision, ctx) {
|
|
|
17253
17398
|
if (idx >= 0) decision.tickets[idx] = result.ticket;
|
|
17254
17399
|
writeDigest({ route, ship, state: ctx.state, emit }, decision, role, ship.stateDir);
|
|
17255
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);
|
|
17256
17406
|
ticketHint = result.ticket.issue_id;
|
|
17257
17407
|
workingId = result.ticket.id;
|
|
17258
17408
|
const target = repoTargetFor(route, result.ticket.repo_id);
|
|
@@ -17278,6 +17428,7 @@ async function runRoleAgent(role, decision, ctx) {
|
|
|
17278
17428
|
environment: await buildEnvironment(route, ship, ticketHint),
|
|
17279
17429
|
apiKey: identity.apiKey,
|
|
17280
17430
|
cycle: emit.cycle,
|
|
17431
|
+
runId,
|
|
17281
17432
|
ticket: ticketHint,
|
|
17282
17433
|
repo: ticketRepo,
|
|
17283
17434
|
ticketRecordId: workingId,
|
|
@@ -18718,7 +18869,7 @@ ${cu.tail.trimEnd()}
|
|
|
18718
18869
|
`);
|
|
18719
18870
|
break;
|
|
18720
18871
|
}
|
|
18721
|
-
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);
|
|
18722
18873
|
if (!fleetLock.ok) {
|
|
18723
18874
|
wemit.emit(`${w.role} skipped this cycle \u2014 ${fleetLock.reason}`);
|
|
18724
18875
|
if (!skipInlineRelease) await releaseFleet();
|
|
@@ -18741,8 +18892,20 @@ ${cu.tail.trimEnd()}
|
|
|
18741
18892
|
claimStatuses(w.role, contract).approved,
|
|
18742
18893
|
claimStatuses(w.role, contract).building,
|
|
18743
18894
|
(t) => dirForRepo(w.route, t.repo_id) !== null,
|
|
18744
|
-
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
|
+
}
|
|
18745
18900
|
);
|
|
18901
|
+
if (result.releaseTicket) {
|
|
18902
|
+
const roleLock = lock;
|
|
18903
|
+
const releaseTicket = result.releaseTicket;
|
|
18904
|
+
lock = { release: () => {
|
|
18905
|
+
roleLock?.release();
|
|
18906
|
+
releaseTicket();
|
|
18907
|
+
} };
|
|
18908
|
+
}
|
|
18746
18909
|
if (result.contended.length) {
|
|
18747
18910
|
wemit.emit(`claim contended for ${result.contended.join(", ")} \u2014 moved to the next candidate`, {
|
|
18748
18911
|
step: "select",
|
|
@@ -18914,7 +19077,7 @@ ${cu.tail.trimEnd()}
|
|
|
18914
19077
|
const tracker2 = new Tracker(route, cfg.ship);
|
|
18915
19078
|
while (current) {
|
|
18916
19079
|
if (!dryRun) {
|
|
18917
|
-
const got = state.acquireRun(current, cfg.ship.maxConcurrentAgents);
|
|
19080
|
+
const got = state.acquireRun(current, cfg.ship.maxConcurrentAgents, cfg.ship.roleConcurrency[current] ?? 1);
|
|
18918
19081
|
if (!got.ok) {
|
|
18919
19082
|
emit.emit(`${current} skipped this cycle \u2014 ${got.reason}`);
|
|
18920
19083
|
if (explicitRole) {
|
|
@@ -18942,8 +19105,20 @@ ${cu.tail.trimEnd()}
|
|
|
18942
19105
|
claimStatuses(current, contract).approved,
|
|
18943
19106
|
claimStatuses(current, contract).building,
|
|
18944
19107
|
(t) => dirForRepo(route, t.repo_id) !== null,
|
|
18945
|
-
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
|
+
}
|
|
18946
19113
|
);
|
|
19114
|
+
if (result.releaseTicket) {
|
|
19115
|
+
const roleLock = lock;
|
|
19116
|
+
const releaseTicket = result.releaseTicket;
|
|
19117
|
+
lock = { release: () => {
|
|
19118
|
+
roleLock?.release();
|
|
19119
|
+
releaseTicket();
|
|
19120
|
+
} };
|
|
19121
|
+
}
|
|
18947
19122
|
if (result.contended.length) {
|
|
18948
19123
|
emit.emit(`claim contended for ${result.contended.join(", ")} \u2014 moved to the next candidate`, {
|
|
18949
19124
|
step: "select",
|
|
@@ -19167,6 +19342,7 @@ ${cu.tail.trimEnd()}
|
|
|
19167
19342
|
ship: cfg.ship,
|
|
19168
19343
|
state,
|
|
19169
19344
|
maxConcurrentAgents: cfg.ship.maxConcurrentAgents,
|
|
19345
|
+
roleConcurrency: cfg.ship.roleConcurrency,
|
|
19170
19346
|
spawnBreaker,
|
|
19171
19347
|
configFile: cfg.configFile,
|
|
19172
19348
|
newEmitter: () => new Emitter({
|
|
@@ -21642,6 +21818,7 @@ watermark: ${state.watermark()}
|
|
|
21642
21818
|
} else if (p.state === "ok") passed++;
|
|
21643
21819
|
}
|
|
21644
21820
|
}
|
|
21821
|
+
for (const message of roleCapWarnings(cfg.ship)) problems.push({ severity: "note", kind: "role_cap_above_ship_cap", message });
|
|
21645
21822
|
problems.push(...attentionProblems(openShipAttention(state), problems));
|
|
21646
21823
|
if (problems.some((p) => p.severity === "blocker")) process.exitCode = 1;
|
|
21647
21824
|
process.stdout.write(`${JSON.stringify({ offline: flag("offline"), pushAuth, tools: toolRows, problems, passed }, null, 2)}
|
|
@@ -21653,6 +21830,8 @@ watermark: ${state.watermark()}
|
|
|
21653
21830
|
routes: ${cfg.routes.map((c) => c.route).join(", ")}
|
|
21654
21831
|
`
|
|
21655
21832
|
);
|
|
21833
|
+
for (const w of roleCapWarnings(cfg.ship)) process.stdout.write(`role concurrency: WARN ${w}
|
|
21834
|
+
`);
|
|
21656
21835
|
try {
|
|
21657
21836
|
const evFile = eventFileFor(cfg.ship.stateDir);
|
|
21658
21837
|
const evSize = existsSync34(evFile) ? statSync10(evFile).size : 0;
|
package/package.json
CHANGED