@tablation/crew 0.49.6 → 0.50.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 +523 -180
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
-
import { resolve as resolve4, dirname as dirname12, join as
|
|
4
|
+
import { resolve as resolve4, dirname as dirname12, join as join25, basename as basename9 } from "path";
|
|
5
5
|
import { fileURLToPath } from "url";
|
|
6
6
|
import { hostname as hostname2, homedir as homedir8 } from "os";
|
|
7
|
-
import { spawnSync as
|
|
7
|
+
import { spawnSync as spawnSync6 } from "child_process";
|
|
8
8
|
|
|
9
9
|
// src/argv.ts
|
|
10
10
|
var VALUE_FLAGS = /* @__PURE__ */ new Set([
|
|
@@ -3586,6 +3586,8 @@ function planLaunchd(ship, crewHome, job) {
|
|
|
3586
3586
|
const scheduleKeys = persistent ? ` <key>RunAtLoad</key>
|
|
3587
3587
|
<true/>
|
|
3588
3588
|
<key>KeepAlive</key>
|
|
3589
|
+
<true/>
|
|
3590
|
+
<key>AbandonProcessGroup</key>
|
|
3589
3591
|
<true/>` : ` <key>StartInterval</key>
|
|
3590
3592
|
<integer>${INTERVAL_SECONDS}</integer>
|
|
3591
3593
|
<key>RunAtLoad</key>
|
|
@@ -3650,6 +3652,9 @@ StandardOutput=append:${schedulerLog}
|
|
|
3650
3652
|
StandardError=append:${schedulerLog}
|
|
3651
3653
|
Restart=on-failure
|
|
3652
3654
|
RestartSec=5
|
|
3655
|
+
# CREW-1775: a restart stops only the daemon; agent runs it started live in
|
|
3656
|
+
# their own process groups and the next daemon reattaches to them.
|
|
3657
|
+
KillMode=process
|
|
3653
3658
|
|
|
3654
3659
|
[Install]
|
|
3655
3660
|
WantedBy=default.target
|
|
@@ -3727,6 +3732,13 @@ function planCron(ship, crewHome, job) {
|
|
|
3727
3732
|
schedulerLog
|
|
3728
3733
|
};
|
|
3729
3734
|
}
|
|
3735
|
+
function unitAbandonsAgents(plan, read) {
|
|
3736
|
+
const path = plan.unitPaths[0];
|
|
3737
|
+
if (!path || plan.mechanism !== "launchd" && plan.mechanism !== "systemd") return null;
|
|
3738
|
+
const text = read(path);
|
|
3739
|
+
if (text === null) return null;
|
|
3740
|
+
return plan.mechanism === "launchd" ? /<key>AbandonProcessGroup<\/key>\s*<true\/>/.test(text) : /^KillMode=process\s*$/m.test(text);
|
|
3741
|
+
}
|
|
3730
3742
|
function planInstall(ship, crewHome, hostShip, hasSystemd, job = "run") {
|
|
3731
3743
|
if (hostShip === "macos") return planLaunchd(ship, crewHome, job);
|
|
3732
3744
|
if (hostShip === "linux" && hasSystemd) return planSystemd(ship, crewHome, job);
|
|
@@ -6120,7 +6132,7 @@ function operatorTodo(tickets, contract, operatorId) {
|
|
|
6120
6132
|
}
|
|
6121
6133
|
|
|
6122
6134
|
// src/digest.ts
|
|
6123
|
-
var DIRECTION_EVENT_TYPES = ["conflict_handed_back", "requeued", "bounced", "conflict_escalated"];
|
|
6135
|
+
var DIRECTION_EVENT_TYPES = ["conflict_handed_back", "requeued", "bounced", "conflict_escalated", "interrupted"];
|
|
6124
6136
|
function displayKey2(t) {
|
|
6125
6137
|
return t.issue_tag ?? t.issue_id ?? "?";
|
|
6126
6138
|
}
|
|
@@ -6249,7 +6261,7 @@ ${sorted(held).map((t) => `| ${displayKey2(t)} | ${stopOf(t)} |`).join("\n")}
|
|
|
6249
6261
|
"## Current queue \u2014 built for you by the poll\n",
|
|
6250
6262
|
"\nAlready filtered to your lane, and already ordered by the Step 2 rule.\nTicket bodies are deliberately omitted: fetch the full record of only the\nticket you actually pick up. **Do not re-fetch the whole tracker.** This\ndigest comes from the same API call the poll just made, moments ago.\n",
|
|
6251
6263
|
'\n"new since last poll" counts comments from someone other than you since\nthe poll watermark \u2014 the same signal that woke this run.\n',
|
|
6252
|
-
'\n"runner direction" is the newest instruction the crew itself posted to the\nevent log (not Comments) that no push has answered yet: `conflict_handed_back`\n(merge the base into the branch and resolve), `requeued`, `bounced`,\n`conflict_escalated
|
|
6264
|
+
'\n"runner direction" is the newest instruction the crew itself posted to the\nevent log (not Comments) that no push has answered yet: `conflict_handed_back`\n(merge the base into the branch and resolve), `requeued`, `bounced`,\n`conflict_escalated`, `interrupted` (an earlier run on this seat was cut short; its\nuncommitted worktree is yours, resume it). It is direction, same as a comment from a hold: act on it.\n',
|
|
6253
6265
|
"\n**`repo` is the checkout a ticket's work happens in** \u2014 an area spans\nseveral repositories, so cut the worktree beside THAT directory, not beside\nwhichever one you started in. A ticket marked **NO CHECKOUT** is not yours:\nthis ship has no clone of its repository, and another ship may serve it.\n\n**Use the `branch` and `worktree` columns verbatim.** They are that\nrepository's own naming convention, rendered for THIS ticket \u2014 cut the\nworktree at `<repo>/../<worktree>`, on branch `<branch>`. Do not derive\neither yourself, and prefer these over the Environment section's own worked\nexample if the two ever disagree \u2014 that example is illustrative only,\nrendered before any ticket is picked.\n",
|
|
6254
6266
|
...i.workModes && [...i.workModes.modes.values()].some((m) => m !== "automatic") ? ["\n**Manual / hybrid projects.** Some projects on this board are not fully automatic. In a `manual` project no lane touches any ticket, and a ticket with no project is treated the same way, so none appear below. In a `hybrid` project a ticket appears below only when it is assigned to an agent. An `accepted` ticket that is absent for either reason is not yours to claim \u2014 do not go looking for it.\n"] : [],
|
|
6255
6267
|
"\n### Step 1 \u2014 open tickets that may be yours to act on\n",
|
|
@@ -7113,8 +7125,9 @@ ${g.tail}
|
|
|
7113
7125
|
var alreadyReported = (ticket, g, events) => events.some((e) => e.record_id === ticket.id && e.event_type === "gate_red" && (e.message ?? "").includes(`${GATE_RED_MARKER} ${g.sha.slice(0, 8)}`));
|
|
7114
7126
|
|
|
7115
7127
|
// src/agent.ts
|
|
7116
|
-
import { spawn as spawn2 } from "child_process";
|
|
7117
|
-
import { readFileSync as readFileSync12, existsSync as existsSync12, statSync as statSync5, mkdirSync as mkdirSync6, writeFileSync as writeFileSync8, createWriteStream } from "fs";
|
|
7128
|
+
import { spawn as spawn2, spawnSync as spawnSync5 } from "child_process";
|
|
7129
|
+
import { readFileSync as readFileSync12, existsSync as existsSync12, statSync as statSync5, mkdirSync as mkdirSync6, writeFileSync as writeFileSync8, createWriteStream, openSync as openSync3, closeSync as closeSync3, readSync } from "fs";
|
|
7130
|
+
import { StringDecoder } from "string_decoder";
|
|
7118
7131
|
import { finished } from "stream/promises";
|
|
7119
7132
|
import { join as join14, dirname as dirname6 } from "path";
|
|
7120
7133
|
import { createHash as createHash2 } from "crypto";
|
|
@@ -8291,6 +8304,7 @@ function planAgentRun(o) {
|
|
|
8291
8304
|
streamPath: join14(streamsDir, `${base}.jsonl`),
|
|
8292
8305
|
eventsPath: join14(streamsDir, `${base}.events.jsonl`),
|
|
8293
8306
|
ticket: o.ticket,
|
|
8307
|
+
ticketRecordId: o.ticketRecordId ?? null,
|
|
8294
8308
|
agentLog,
|
|
8295
8309
|
promptVersion: o.route.resolved?.agentPersonas?.[o.role]?.historyId,
|
|
8296
8310
|
promptSha
|
|
@@ -8314,6 +8328,43 @@ function describePlan(p) {
|
|
|
8314
8328
|
`stream: ${p.streamPath}`
|
|
8315
8329
|
].join("\n");
|
|
8316
8330
|
}
|
|
8331
|
+
var pidPathFor = (streamPath) => `${streamPath}.pid`;
|
|
8332
|
+
function readRunSidecar(streamPath) {
|
|
8333
|
+
try {
|
|
8334
|
+
const sc = JSON.parse(readFileSync12(pidPathFor(streamPath), "utf8"));
|
|
8335
|
+
return typeof sc.pid === "number" && typeof sc.spawnedAt === "string" && typeof sc.role === "string" ? sc : null;
|
|
8336
|
+
} catch {
|
|
8337
|
+
return null;
|
|
8338
|
+
}
|
|
8339
|
+
}
|
|
8340
|
+
function runIsAlive(sc) {
|
|
8341
|
+
try {
|
|
8342
|
+
process.kill(sc.pid, 0);
|
|
8343
|
+
} catch (e) {
|
|
8344
|
+
if (e.code !== "EPERM") return false;
|
|
8345
|
+
}
|
|
8346
|
+
const ps = spawnSync5("ps", ["-o", "lstart=", "-p", String(sc.pid)], { encoding: "utf8" });
|
|
8347
|
+
const started = Date.parse(ps.stdout.trim());
|
|
8348
|
+
if (ps.status !== 0 || Number.isNaN(started)) return ps.status === 0;
|
|
8349
|
+
return Math.abs(started - Date.parse(sc.spawnedAt)) < 15e3;
|
|
8350
|
+
}
|
|
8351
|
+
function identityOf(plan, adapterHarness) {
|
|
8352
|
+
return {
|
|
8353
|
+
role: plan.role,
|
|
8354
|
+
route: plan.route,
|
|
8355
|
+
ticket: plan.ticket ?? null,
|
|
8356
|
+
model: plan.model,
|
|
8357
|
+
toolchain: plan.toolchain ?? LEGACY_TOOLCHAIN_HARNESS,
|
|
8358
|
+
harness: adapterHarness,
|
|
8359
|
+
provider: plan.provider ?? LEGACY_TOOLCHAIN_PROVIDER,
|
|
8360
|
+
eventsPath: plan.eventsPath,
|
|
8361
|
+
streamPath: plan.streamPath,
|
|
8362
|
+
agentLog: plan.agentLog ? withoutKey(plan.agentLog) : void 0,
|
|
8363
|
+
promptVersion: plan.promptVersion,
|
|
8364
|
+
promptSha: plan.promptSha
|
|
8365
|
+
};
|
|
8366
|
+
}
|
|
8367
|
+
var withoutKey = ({ apiKey: _apiKey, ...rest }) => rest;
|
|
8317
8368
|
function openSink(path) {
|
|
8318
8369
|
try {
|
|
8319
8370
|
mkdirSync6(dirname6(path), { recursive: true });
|
|
@@ -8327,172 +8378,300 @@ function endSink(sink) {
|
|
|
8327
8378
|
sink.end();
|
|
8328
8379
|
return finished(sink).catch(() => void 0);
|
|
8329
8380
|
}
|
|
8381
|
+
var FileTail = class {
|
|
8382
|
+
offset = 0;
|
|
8383
|
+
decoder = new StringDecoder("utf8");
|
|
8384
|
+
path;
|
|
8385
|
+
constructor(path) {
|
|
8386
|
+
this.path = path;
|
|
8387
|
+
}
|
|
8388
|
+
poll() {
|
|
8389
|
+
let fd;
|
|
8390
|
+
try {
|
|
8391
|
+
fd = openSync3(this.path, "r");
|
|
8392
|
+
} catch {
|
|
8393
|
+
return "";
|
|
8394
|
+
}
|
|
8395
|
+
try {
|
|
8396
|
+
let text = "";
|
|
8397
|
+
const chunk = Buffer.alloc(64 * 1024);
|
|
8398
|
+
for (; ; ) {
|
|
8399
|
+
const n = readSync(fd, chunk, 0, chunk.length, this.offset);
|
|
8400
|
+
if (n <= 0) break;
|
|
8401
|
+
this.offset += n;
|
|
8402
|
+
text += this.decoder.write(chunk.subarray(0, n));
|
|
8403
|
+
}
|
|
8404
|
+
return text;
|
|
8405
|
+
} finally {
|
|
8406
|
+
closeSync3(fd);
|
|
8407
|
+
}
|
|
8408
|
+
}
|
|
8409
|
+
};
|
|
8410
|
+
var TAIL_POLL_MS = 200;
|
|
8411
|
+
var RunObserver = class {
|
|
8412
|
+
cycles = [];
|
|
8413
|
+
attribution = new TicketAttributionTracker();
|
|
8414
|
+
result;
|
|
8415
|
+
/** Set once the stream carries the harness's final result (turn count or cost). */
|
|
8416
|
+
sawResult = false;
|
|
8417
|
+
muted = false;
|
|
8418
|
+
buf = "";
|
|
8419
|
+
mapper;
|
|
8420
|
+
id;
|
|
8421
|
+
adapter;
|
|
8422
|
+
eventsSink;
|
|
8423
|
+
emitCycle;
|
|
8424
|
+
constructor(id, adapter, eventsSink, emitCycle) {
|
|
8425
|
+
this.id = id;
|
|
8426
|
+
this.adapter = adapter;
|
|
8427
|
+
this.eventsSink = eventsSink;
|
|
8428
|
+
this.emitCycle = emitCycle;
|
|
8429
|
+
this.mapper = adapter.createStreamMapper?.() ?? { map: (p) => adapter.mapStreamLine(p), flush: () => [] };
|
|
8430
|
+
}
|
|
8431
|
+
feed(text) {
|
|
8432
|
+
this.buf += text;
|
|
8433
|
+
const lines = this.buf.split("\n");
|
|
8434
|
+
this.buf = lines.pop() ?? "";
|
|
8435
|
+
for (const line of lines) this.handleLine(line);
|
|
8436
|
+
}
|
|
8437
|
+
end() {
|
|
8438
|
+
if (this.buf) this.handleLine(this.buf);
|
|
8439
|
+
this.buf = "";
|
|
8440
|
+
try {
|
|
8441
|
+
this.consume(void 0, this.mapper.flush());
|
|
8442
|
+
} catch {
|
|
8443
|
+
}
|
|
8444
|
+
}
|
|
8445
|
+
handleLine(line) {
|
|
8446
|
+
if (!line) return;
|
|
8447
|
+
let parsed;
|
|
8448
|
+
try {
|
|
8449
|
+
parsed = JSON.parse(line);
|
|
8450
|
+
} catch {
|
|
8451
|
+
return;
|
|
8452
|
+
}
|
|
8453
|
+
try {
|
|
8454
|
+
const got = this.adapter.extractResult(parsed);
|
|
8455
|
+
if (got) {
|
|
8456
|
+
this.result = { ...this.result, ...Object.fromEntries(Object.entries(got).filter(([, v]) => v !== void 0)) };
|
|
8457
|
+
if (got.numTurns !== void 0 || got.costUsd !== void 0) this.sawResult = true;
|
|
8458
|
+
}
|
|
8459
|
+
this.consume(parsed, this.mapper.map(parsed));
|
|
8460
|
+
} catch {
|
|
8461
|
+
}
|
|
8462
|
+
}
|
|
8463
|
+
// Turns mapped events (and, when there is a source line, its token usage)
|
|
8464
|
+
// into sink writes and buffered cycles. `parsed` is undefined for events
|
|
8465
|
+
// the mapper flushes at end of stream, which have no line of their own.
|
|
8466
|
+
consume(parsed, events) {
|
|
8467
|
+
const plan = this.id;
|
|
8468
|
+
let turnText = "";
|
|
8469
|
+
for (const ev of events) {
|
|
8470
|
+
const e = {
|
|
8471
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
8472
|
+
cycle: this.emitCycle,
|
|
8473
|
+
route: plan.route,
|
|
8474
|
+
step: "agent",
|
|
8475
|
+
level: "info",
|
|
8476
|
+
message: ev.kind === "tool" ? `${ev.tool ?? "tool"}${ev.target ? ` ${ev.target}` : ""}` : ev.kind,
|
|
8477
|
+
role: plan.role,
|
|
8478
|
+
...plan.ticket ? { ticket: plan.ticket } : {},
|
|
8479
|
+
data: { kind: ev.kind, ...ev.text ? { text: ev.text } : {}, ...ev.tool ? { tool: ev.tool } : {}, ...ev.target ? { target: ev.target } : {} }
|
|
8480
|
+
};
|
|
8481
|
+
if (!this.muted) this.eventsSink?.write(`${JSON.stringify(e)}
|
|
8482
|
+
`);
|
|
8483
|
+
if ((ev.kind === "thought" || ev.kind === "text") && ev.text) {
|
|
8484
|
+
turnText += `${ev.text}
|
|
8485
|
+
`;
|
|
8486
|
+
const cycleIndex = this.cycles.length;
|
|
8487
|
+
this.cycles.push({
|
|
8488
|
+
cycleIndex,
|
|
8489
|
+
occurredAt: e.at,
|
|
8490
|
+
thinking: ev.text,
|
|
8491
|
+
// A conversational `text` turn is tagged 'assistant' so it's
|
|
8492
|
+
// distinguishable from a thinking block once both share this
|
|
8493
|
+
// buffer (ISSUE-594) — matching the precedent
|
|
8494
|
+
// `agent-chat.service.ts`'s logChatRun (ISSUE-504) already set
|
|
8495
|
+
// for the same column. A `thought` cycle stays untagged, same
|
|
8496
|
+
// as before this change.
|
|
8497
|
+
...ev.kind === "text" ? { role: "assistant" } : {}
|
|
8498
|
+
});
|
|
8499
|
+
if (!this.muted) {
|
|
8500
|
+
this.eventsSink?.write(`${JSON.stringify({
|
|
8501
|
+
...e,
|
|
8502
|
+
message: "cycle",
|
|
8503
|
+
data: { kind: "cycle", cycleIndex, text: ev.text }
|
|
8504
|
+
})}
|
|
8505
|
+
`);
|
|
8506
|
+
}
|
|
8507
|
+
}
|
|
8508
|
+
}
|
|
8509
|
+
const turnTokens = parsed === void 0 ? void 0 : this.adapter.extractTurnTokens(parsed);
|
|
8510
|
+
if (turnTokens !== void 0) this.attribution.add(turnText, turnTokens);
|
|
8511
|
+
}
|
|
8512
|
+
};
|
|
8513
|
+
async function finishRun(id, obs, emit, startedAt, end, agentLogKey) {
|
|
8514
|
+
const attributedTicket = obs.attribution.winner(id.ticket ?? void 0);
|
|
8515
|
+
const identity = { toolchain: id.toolchain, harness: id.harness, provider: id.provider };
|
|
8516
|
+
const { result } = obs;
|
|
8517
|
+
try {
|
|
8518
|
+
writeFileSync8(`${id.streamPath}.meta.json`, JSON.stringify({
|
|
8519
|
+
route: id.route,
|
|
8520
|
+
role: id.role,
|
|
8521
|
+
ticket: id.ticket,
|
|
8522
|
+
attributedTicket: attributedTicket ?? null,
|
|
8523
|
+
startedAt,
|
|
8524
|
+
exitCode: end.code,
|
|
8525
|
+
model: id.model,
|
|
8526
|
+
...end.interrupted ? { interrupted: end.interrupted } : {},
|
|
8527
|
+
...identity,
|
|
8528
|
+
sessionId: result?.sessionId ?? null,
|
|
8529
|
+
numTurns: result?.numTurns ?? null,
|
|
8530
|
+
totalCostUsd: result?.costUsd ?? null
|
|
8531
|
+
}));
|
|
8532
|
+
} catch {
|
|
8533
|
+
}
|
|
8534
|
+
const ms2 = Date.now() - Date.parse(startedAt);
|
|
8535
|
+
emit.emit(end.interrupted ? "agent run interrupted" : `agent run finished (exit ${end.code ?? "unknown"})`, {
|
|
8536
|
+
data: {
|
|
8537
|
+
ms: ms2,
|
|
8538
|
+
code: end.code,
|
|
8539
|
+
...end.interrupted ? { interrupted: end.interrupted } : {},
|
|
8540
|
+
...identity,
|
|
8541
|
+
...result?.sessionId ? { sessionId: result.sessionId } : {},
|
|
8542
|
+
...result?.numTurns !== void 0 ? { numTurns: result.numTurns } : {},
|
|
8543
|
+
...result?.costUsd !== void 0 ? { totalCostUsd: result.costUsd } : {}
|
|
8544
|
+
}
|
|
8545
|
+
});
|
|
8546
|
+
if (id.agentLog && agentLogKey) {
|
|
8547
|
+
try {
|
|
8548
|
+
await reportAgentRun({ ...id.agentLog, apiKey: agentLogKey }, {
|
|
8549
|
+
ticketReference: attributedTicket,
|
|
8550
|
+
outcome: end.interrupted ? "interrupted" : end.code === 0 || end.code === null && obs.sawResult ? "success" : "error",
|
|
8551
|
+
startedAt,
|
|
8552
|
+
finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
8553
|
+
cycles: obs.cycles,
|
|
8554
|
+
model: id.model,
|
|
8555
|
+
tokensIn: result?.inputTokens,
|
|
8556
|
+
tokensOut: result?.outputTokens,
|
|
8557
|
+
cacheReadTokens: result?.cacheReadTokens,
|
|
8558
|
+
cacheWriteTokens: result?.cacheWriteTokens,
|
|
8559
|
+
costUsd: result?.costUsd,
|
|
8560
|
+
harness: identity.harness,
|
|
8561
|
+
provider: identity.provider,
|
|
8562
|
+
promptVersion: id.promptVersion,
|
|
8563
|
+
promptSha: id.promptSha
|
|
8564
|
+
});
|
|
8565
|
+
} catch (e) {
|
|
8566
|
+
emit.warn(`could not report agent log: ${e.message}`, { step: "agent" });
|
|
8567
|
+
}
|
|
8568
|
+
}
|
|
8569
|
+
return { ms: ms2, ticket: attributedTicket };
|
|
8570
|
+
}
|
|
8330
8571
|
function spawnAgent(plan, emit) {
|
|
8331
8572
|
const adapter = adapterFor(plan.harness ?? LEGACY_TOOLCHAIN_HARNESS);
|
|
8332
8573
|
const started = Date.now();
|
|
8333
8574
|
const startedAt = new Date(started).toISOString();
|
|
8334
8575
|
for (const w of plan.warnings ?? []) emit.warn(w, { step: "agent", role: plan.role });
|
|
8335
|
-
const rawSink = openSink(plan.streamPath);
|
|
8336
8576
|
const eventsSink = openSink(plan.eventsPath);
|
|
8577
|
+
const id = identityOf(plan, adapter.harness);
|
|
8337
8578
|
return new Promise((resolve5, reject) => {
|
|
8579
|
+
let streamFd;
|
|
8580
|
+
try {
|
|
8581
|
+
mkdirSync6(dirname6(plan.streamPath), { recursive: true });
|
|
8582
|
+
streamFd = openSync3(plan.streamPath, "a");
|
|
8583
|
+
} catch (e) {
|
|
8584
|
+
void endSink(eventsSink);
|
|
8585
|
+
reject(new AgentSpawnError(`cannot open the stream file ${plan.streamPath}: ${e.message}`));
|
|
8586
|
+
return;
|
|
8587
|
+
}
|
|
8338
8588
|
const child = spawn2(plan.bin, plan.args, {
|
|
8339
8589
|
cwd: plan.cwd,
|
|
8340
8590
|
env: { ...scrubbedEnv(process.env, adapter.harness), ...plan.setEnv },
|
|
8341
|
-
stdio: ["pipe",
|
|
8591
|
+
stdio: ["pipe", streamFd, "inherit"],
|
|
8592
|
+
detached: true
|
|
8342
8593
|
});
|
|
8343
|
-
|
|
8344
|
-
|
|
8345
|
-
|
|
8346
|
-
|
|
8347
|
-
|
|
8348
|
-
|
|
8349
|
-
|
|
8350
|
-
|
|
8351
|
-
|
|
8352
|
-
|
|
8353
|
-
|
|
8354
|
-
|
|
8355
|
-
|
|
8356
|
-
|
|
8357
|
-
|
|
8358
|
-
|
|
8359
|
-
|
|
8360
|
-
|
|
8594
|
+
closeSync3(streamFd);
|
|
8595
|
+
if (child.pid !== void 0) {
|
|
8596
|
+
const sidecar = {
|
|
8597
|
+
pid: child.pid,
|
|
8598
|
+
ownerPid: process.pid,
|
|
8599
|
+
spawnedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
8600
|
+
role: plan.role,
|
|
8601
|
+
route: plan.route,
|
|
8602
|
+
ticket: plan.ticket ?? null,
|
|
8603
|
+
ticketRecordId: plan.ticketRecordId ?? null,
|
|
8604
|
+
model: plan.model,
|
|
8605
|
+
toolchain: id.toolchain,
|
|
8606
|
+
harness: id.harness,
|
|
8607
|
+
provider: id.provider,
|
|
8608
|
+
eventsPath: plan.eventsPath,
|
|
8609
|
+
agentLog: id.agentLog,
|
|
8610
|
+
promptVersion: plan.promptVersion,
|
|
8611
|
+
promptSha: plan.promptSha
|
|
8612
|
+
};
|
|
8361
8613
|
try {
|
|
8362
|
-
|
|
8363
|
-
if (got) result = { ...result, ...Object.fromEntries(Object.entries(got).filter(([, v]) => v !== void 0)) };
|
|
8364
|
-
consume(parsed, mapper.map(parsed));
|
|
8614
|
+
writeFileSync8(pidPathFor(plan.streamPath), JSON.stringify(sidecar));
|
|
8365
8615
|
} catch {
|
|
8366
8616
|
}
|
|
8367
|
-
}
|
|
8368
|
-
const
|
|
8369
|
-
|
|
8370
|
-
|
|
8371
|
-
for (const ev of events) {
|
|
8372
|
-
const e = {
|
|
8373
|
-
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
8374
|
-
cycle: emit.cycle,
|
|
8375
|
-
route: plan.route,
|
|
8376
|
-
step: "agent",
|
|
8377
|
-
level: "info",
|
|
8378
|
-
message: ev.kind === "tool" ? `${ev.tool ?? "tool"}${ev.target ? ` ${ev.target}` : ""}` : ev.kind,
|
|
8379
|
-
role: plan.role,
|
|
8380
|
-
...plan.ticket ? { ticket: plan.ticket } : {},
|
|
8381
|
-
data: { kind: ev.kind, ...ev.text ? { text: ev.text } : {}, ...ev.tool ? { tool: ev.tool } : {}, ...ev.target ? { target: ev.target } : {} }
|
|
8382
|
-
};
|
|
8383
|
-
eventsSink?.write(`${JSON.stringify(e)}
|
|
8384
|
-
`);
|
|
8385
|
-
if ((ev.kind === "thought" || ev.kind === "text") && ev.text) {
|
|
8386
|
-
turnText += `${ev.text}
|
|
8387
|
-
`;
|
|
8388
|
-
const cycleIndex = cycles.length;
|
|
8389
|
-
cycles.push({
|
|
8390
|
-
cycleIndex,
|
|
8391
|
-
occurredAt: e.at,
|
|
8392
|
-
thinking: ev.text,
|
|
8393
|
-
// A conversational `text` turn is tagged 'assistant' so it's
|
|
8394
|
-
// distinguishable from a thinking block once both share this
|
|
8395
|
-
// buffer (ISSUE-594) — matching the precedent
|
|
8396
|
-
// `agent-chat.service.ts`'s logChatRun (ISSUE-504) already set
|
|
8397
|
-
// for the same column. A `thought` cycle stays untagged, same
|
|
8398
|
-
// as before this change.
|
|
8399
|
-
...ev.kind === "text" ? { role: "assistant" } : {}
|
|
8400
|
-
});
|
|
8401
|
-
eventsSink?.write(`${JSON.stringify({
|
|
8402
|
-
...e,
|
|
8403
|
-
message: "cycle",
|
|
8404
|
-
data: { kind: "cycle", cycleIndex, text: ev.text }
|
|
8405
|
-
})}
|
|
8406
|
-
`);
|
|
8407
|
-
}
|
|
8408
|
-
}
|
|
8409
|
-
const turnTokens = parsed === void 0 ? void 0 : adapter.extractTurnTokens(parsed);
|
|
8410
|
-
if (turnTokens !== void 0) attribution.add(turnText, turnTokens);
|
|
8411
|
-
}
|
|
8412
|
-
};
|
|
8413
|
-
child.stdout.on("data", (chunk) => {
|
|
8414
|
-
buf += chunk.toString("utf8");
|
|
8415
|
-
const lines = buf.split("\n");
|
|
8416
|
-
buf = lines.pop() ?? "";
|
|
8417
|
-
for (const line of lines) handleLine(line);
|
|
8418
|
-
});
|
|
8617
|
+
}
|
|
8618
|
+
const obs = new RunObserver(id, adapter, eventsSink, emit.cycle);
|
|
8619
|
+
const tail = new FileTail(plan.streamPath);
|
|
8620
|
+
const timer = setInterval(() => obs.feed(tail.poll()), TAIL_POLL_MS);
|
|
8419
8621
|
let spawnFailed = false;
|
|
8420
8622
|
child.on("error", (err) => {
|
|
8421
8623
|
spawnFailed = true;
|
|
8624
|
+
clearInterval(timer);
|
|
8422
8625
|
reject(new AgentSpawnError(`cannot run ${plan.bin}: ${err.message}`));
|
|
8423
8626
|
});
|
|
8424
8627
|
child.on("close", async (code) => {
|
|
8425
|
-
|
|
8426
|
-
|
|
8427
|
-
|
|
8428
|
-
|
|
8429
|
-
}
|
|
8430
|
-
await Promise.all([endSink(rawSink), endSink(eventsSink)]);
|
|
8628
|
+
clearInterval(timer);
|
|
8629
|
+
obs.feed(tail.poll());
|
|
8630
|
+
obs.end();
|
|
8631
|
+
await endSink(eventsSink);
|
|
8431
8632
|
if (spawnFailed) return;
|
|
8432
|
-
const
|
|
8433
|
-
const identity = {
|
|
8434
|
-
toolchain: plan.toolchain ?? LEGACY_TOOLCHAIN_HARNESS,
|
|
8435
|
-
harness: adapter.harness,
|
|
8436
|
-
provider: plan.provider ?? LEGACY_TOOLCHAIN_PROVIDER
|
|
8437
|
-
};
|
|
8438
|
-
try {
|
|
8439
|
-
writeFileSync8(`${plan.streamPath}.meta.json`, JSON.stringify({
|
|
8440
|
-
route: plan.route,
|
|
8441
|
-
role: plan.role,
|
|
8442
|
-
ticket: plan.ticket ?? null,
|
|
8443
|
-
attributedTicket: attributedTicket ?? null,
|
|
8444
|
-
startedAt,
|
|
8445
|
-
exitCode: code ?? 1,
|
|
8446
|
-
model: plan.model,
|
|
8447
|
-
...identity,
|
|
8448
|
-
sessionId: result?.sessionId ?? null,
|
|
8449
|
-
numTurns: result?.numTurns ?? null,
|
|
8450
|
-
totalCostUsd: result?.costUsd ?? null
|
|
8451
|
-
}));
|
|
8452
|
-
} catch {
|
|
8453
|
-
}
|
|
8454
|
-
const ms2 = Date.now() - started;
|
|
8455
|
-
emit.emit(`agent run finished (exit ${code ?? 1})`, {
|
|
8456
|
-
data: {
|
|
8457
|
-
ms: ms2,
|
|
8458
|
-
code: code ?? 1,
|
|
8459
|
-
...identity,
|
|
8460
|
-
...result?.sessionId ? { sessionId: result.sessionId } : {},
|
|
8461
|
-
...result?.numTurns !== void 0 ? { numTurns: result.numTurns } : {},
|
|
8462
|
-
...result?.costUsd !== void 0 ? { totalCostUsd: result.costUsd } : {}
|
|
8463
|
-
}
|
|
8464
|
-
});
|
|
8465
|
-
if (plan.agentLog) {
|
|
8466
|
-
try {
|
|
8467
|
-
await reportAgentRun(plan.agentLog, {
|
|
8468
|
-
ticketReference: attributedTicket,
|
|
8469
|
-
outcome: (code ?? 1) === 0 ? "success" : "error",
|
|
8470
|
-
startedAt,
|
|
8471
|
-
finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
8472
|
-
cycles,
|
|
8473
|
-
model: plan.model,
|
|
8474
|
-
tokensIn: result?.inputTokens,
|
|
8475
|
-
tokensOut: result?.outputTokens,
|
|
8476
|
-
cacheReadTokens: result?.cacheReadTokens,
|
|
8477
|
-
cacheWriteTokens: result?.cacheWriteTokens,
|
|
8478
|
-
costUsd: result?.costUsd,
|
|
8479
|
-
harness: identity.harness,
|
|
8480
|
-
provider: identity.provider,
|
|
8481
|
-
promptVersion: plan.promptVersion,
|
|
8482
|
-
promptSha: plan.promptSha
|
|
8483
|
-
});
|
|
8484
|
-
} catch (e) {
|
|
8485
|
-
emit.warn(`could not report agent log: ${e.message}`, { step: "agent" });
|
|
8486
|
-
}
|
|
8487
|
-
}
|
|
8633
|
+
const { ms: ms2 } = await finishRun(id, obs, emit, startedAt, { code: code ?? 1 }, plan.agentLog?.apiKey);
|
|
8488
8634
|
resolve5({ code: code ?? 1, ms: ms2 });
|
|
8489
8635
|
});
|
|
8490
|
-
child.stdin
|
|
8636
|
+
child.stdin?.on("error", (err) => {
|
|
8491
8637
|
emit.warn(`agent closed stdin before the prompt was delivered: ${err.message}`, { step: "agent" });
|
|
8492
8638
|
});
|
|
8493
|
-
child.stdin
|
|
8639
|
+
child.stdin?.end(plan.prompt);
|
|
8494
8640
|
});
|
|
8495
8641
|
}
|
|
8642
|
+
async function reattachRun(streamPath, sc, emit, o = {}) {
|
|
8643
|
+
const adapter = adapterFor(sc.harness);
|
|
8644
|
+
const isAlive = o.isAlive ?? runIsAlive;
|
|
8645
|
+
const id = { ...sc, streamPath };
|
|
8646
|
+
const eventsSink = openSink(sc.eventsPath);
|
|
8647
|
+
const obs = new RunObserver(id, adapter, eventsSink, emit.cycle);
|
|
8648
|
+
const tail = new FileTail(streamPath);
|
|
8649
|
+
obs.muted = true;
|
|
8650
|
+
obs.feed(tail.poll());
|
|
8651
|
+
obs.muted = false;
|
|
8652
|
+
const adopted = isAlive(sc);
|
|
8653
|
+
if (adopted) {
|
|
8654
|
+
o.onAdopted?.();
|
|
8655
|
+
const pollMs = o.pollMs ?? TAIL_POLL_MS;
|
|
8656
|
+
while (isAlive(sc)) {
|
|
8657
|
+
obs.feed(tail.poll());
|
|
8658
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
8659
|
+
}
|
|
8660
|
+
}
|
|
8661
|
+
obs.feed(tail.poll());
|
|
8662
|
+
obs.end();
|
|
8663
|
+
await endSink(eventsSink);
|
|
8664
|
+
const interrupted = obs.sawResult ? void 0 : adopted ? "no-result" : "daemon-exit";
|
|
8665
|
+
const { ms: ms2, ticket } = await finishRun(
|
|
8666
|
+
id,
|
|
8667
|
+
obs,
|
|
8668
|
+
emit,
|
|
8669
|
+
sc.spawnedAt,
|
|
8670
|
+
{ code: null, ...interrupted ? { interrupted } : {} },
|
|
8671
|
+
o.agentLogKey
|
|
8672
|
+
);
|
|
8673
|
+
return { adopted, ...interrupted ? { interrupted } : {}, ms: ms2, ticket };
|
|
8674
|
+
}
|
|
8496
8675
|
|
|
8497
8676
|
// src/run-worktree.ts
|
|
8498
8677
|
import { existsSync as existsSync14 } from "fs";
|
|
@@ -8601,6 +8780,8 @@ var describeRunWorktreePlan = (p) => {
|
|
|
8601
8780
|
return `reusing ${p.path} (${p.branch} is already level with the remote)`;
|
|
8602
8781
|
case "recut":
|
|
8603
8782
|
return `${p.branch} is gone from the remote and ${p.path} held no work of its own \u2014 re-cut it fresh from ${p.base}`;
|
|
8783
|
+
case "resume-dirty":
|
|
8784
|
+
return `${p.path} has uncommitted changes from this seat's previous run (interrupted) \u2014 resuming`;
|
|
8604
8785
|
case "leave-dirty":
|
|
8605
8786
|
return `left ${p.path} alone: it has uncommitted changes (someone may be editing it by hand)`;
|
|
8606
8787
|
case "leave-unpushed":
|
|
@@ -8612,7 +8793,7 @@ function newBranchName(cfg, t, role) {
|
|
|
8612
8793
|
const { prefix } = ticketBranchContext(t);
|
|
8613
8794
|
return branchRenderer(t, role)(effectiveBranchTemplate(cfg, prefix));
|
|
8614
8795
|
}
|
|
8615
|
-
function planRunWorktree(dir, cfg, t, role) {
|
|
8796
|
+
function planRunWorktree(dir, cfg, t, role, opts = {}) {
|
|
8616
8797
|
const { remote, base } = cfg.branch;
|
|
8617
8798
|
const hasRemote = remoteConfigured(dir, remote);
|
|
8618
8799
|
if (hasRemote) fetchRemote(dir, remote);
|
|
@@ -8622,7 +8803,9 @@ function planRunWorktree(dir, cfg, t, role) {
|
|
|
8622
8803
|
const baseRef = hasRemote ? `${remote}/${base}` : base;
|
|
8623
8804
|
if (existing && !existing.prunable && existsSync14(existing.path)) {
|
|
8624
8805
|
const branch = existing.branch;
|
|
8625
|
-
if (status(existing.path).length > 0)
|
|
8806
|
+
if (status(existing.path).length > 0) {
|
|
8807
|
+
return { action: opts.resumeInterrupted ? "resume-dirty" : "leave-dirty", path: existing.path, branch };
|
|
8808
|
+
}
|
|
8626
8809
|
if (!branch) return { action: "leave-unpushed", path: existing.path, branch: "(detached HEAD)" };
|
|
8627
8810
|
if (!hasRemote) return { action: "reuse", path: existing.path, branch };
|
|
8628
8811
|
if (remoteBranchExists(dir, remote, branch)) {
|
|
@@ -8649,6 +8832,8 @@ function applyRunWorktree(dir, cfg, plan) {
|
|
|
8649
8832
|
case "leave-dirty":
|
|
8650
8833
|
case "leave-unpushed":
|
|
8651
8834
|
return null;
|
|
8835
|
+
case "resume-dirty":
|
|
8836
|
+
return plan.path;
|
|
8652
8837
|
case "reuse":
|
|
8653
8838
|
if (!worktrees(dir).some((w) => w.path === plan.path)) git(dir, ["worktree", "add", plan.path, plan.branch]);
|
|
8654
8839
|
return plan.path;
|
|
@@ -10464,7 +10649,7 @@ function missingReleaserWarning(repo, release, ships, now = Date.now()) {
|
|
|
10464
10649
|
}
|
|
10465
10650
|
|
|
10466
10651
|
// src/watch.ts
|
|
10467
|
-
import { watch as watchFile, existsSync as existsSync17, statSync as statSync6, openSync as
|
|
10652
|
+
import { watch as watchFile, existsSync as existsSync17, statSync as statSync6, openSync as openSync4, readSync as readSync2, closeSync as closeSync4 } from "fs";
|
|
10468
10653
|
var E = "\x1B[";
|
|
10469
10654
|
var dim = (s) => `${E}2m${s}${E}0m`;
|
|
10470
10655
|
var bold = (s) => `${E}1m${s}${E}0m`;
|
|
@@ -10569,10 +10754,10 @@ function readFrom(file, from) {
|
|
|
10569
10754
|
const size = statSync6(file).size;
|
|
10570
10755
|
if (size < from) return readFrom(file, 0);
|
|
10571
10756
|
if (size === from) return { events: [], offset: from };
|
|
10572
|
-
const fd =
|
|
10757
|
+
const fd = openSync4(file, "r");
|
|
10573
10758
|
const buf = Buffer.alloc(size - from);
|
|
10574
|
-
|
|
10575
|
-
|
|
10759
|
+
readSync2(fd, buf, 0, buf.length, from);
|
|
10760
|
+
closeSync4(fd);
|
|
10576
10761
|
const text = buf.toString("utf8");
|
|
10577
10762
|
const lastNewline = text.lastIndexOf("\n");
|
|
10578
10763
|
if (lastNewline === -1) return { events: [], offset: from };
|
|
@@ -11811,7 +11996,7 @@ function planEventsSweep(stateDir, retentionDays, now = Date.now()) {
|
|
|
11811
11996
|
}
|
|
11812
11997
|
|
|
11813
11998
|
// src/cli.ts
|
|
11814
|
-
import { readFileSync as readFileSync18, existsSync as
|
|
11999
|
+
import { readFileSync as readFileSync18, existsSync as existsSync25, mkdirSync as mkdirSync13, writeFileSync as writeFileSync14, unlinkSync as unlinkSync5, copyFileSync as copyFileSync2, renameSync as renameSync2, statSync as statSync9 } from "fs";
|
|
11815
12000
|
import { createInterface } from "readline/promises";
|
|
11816
12001
|
import { dirname as dirOf, resolve as resolvePath } from "path";
|
|
11817
12002
|
|
|
@@ -12551,8 +12736,85 @@ function syncPassengerSyncDaemons(plans, cfg, stateDir, deps = {}) {
|
|
|
12551
12736
|
}
|
|
12552
12737
|
|
|
12553
12738
|
// src/daemon.ts
|
|
12554
|
-
import { existsSync as
|
|
12739
|
+
import { existsSync as existsSync24, mkdirSync as mkdirSync12, readFileSync as readFileSync17, readdirSync as readdirSync9, rmSync as rmSync9, statSync as statSync8, writeFileSync as writeFileSync13 } from "fs";
|
|
12740
|
+
import { join as join24 } from "path";
|
|
12741
|
+
|
|
12742
|
+
// src/interrupted.ts
|
|
12743
|
+
import { existsSync as existsSync23, readdirSync as readdirSync8 } from "fs";
|
|
12555
12744
|
import { join as join23 } from "path";
|
|
12745
|
+
var INTERRUPTED_MARKER = "<!-- crew:interrupted -->";
|
|
12746
|
+
function interruptedComment(b) {
|
|
12747
|
+
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.";
|
|
12748
|
+
return `${INTERRUPTED_MARKER}
|
|
12749
|
+
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.`;
|
|
12750
|
+
}
|
|
12751
|
+
var handled = /* @__PURE__ */ new Set();
|
|
12752
|
+
var pidAlive = (pid) => {
|
|
12753
|
+
try {
|
|
12754
|
+
process.kill(pid, 0);
|
|
12755
|
+
return true;
|
|
12756
|
+
} catch (e) {
|
|
12757
|
+
return e.code === "EPERM";
|
|
12758
|
+
}
|
|
12759
|
+
};
|
|
12760
|
+
function orphanedStreams(o) {
|
|
12761
|
+
const dir = join23(o.stateDir, "streams");
|
|
12762
|
+
if (!existsSync23(dir)) return [];
|
|
12763
|
+
const ownerAlive = o.isOwnerAlive ?? pidAlive;
|
|
12764
|
+
const out = [];
|
|
12765
|
+
for (const name of readdirSync8(dir)) {
|
|
12766
|
+
if (!name.endsWith(".jsonl") || name.endsWith(".events.jsonl")) continue;
|
|
12767
|
+
const streamPath = join23(dir, name);
|
|
12768
|
+
if (existsSync23(`${streamPath}.meta.json`)) continue;
|
|
12769
|
+
const sidecar = readRunSidecar(streamPath);
|
|
12770
|
+
if (!sidecar || sidecar.route !== o.routeLabel) continue;
|
|
12771
|
+
if (ownerAlive(sidecar.ownerPid)) continue;
|
|
12772
|
+
out.push({ streamPath, sidecar });
|
|
12773
|
+
}
|
|
12774
|
+
return out;
|
|
12775
|
+
}
|
|
12776
|
+
async function reconcileRuns(o) {
|
|
12777
|
+
let adopted = 0;
|
|
12778
|
+
let interrupted = 0;
|
|
12779
|
+
for (const { streamPath, sidecar } of orphanedStreams(o)) {
|
|
12780
|
+
if (handled.has(streamPath)) continue;
|
|
12781
|
+
handled.add(streamPath);
|
|
12782
|
+
const isAlive = o.isAlive ?? runIsAlive;
|
|
12783
|
+
const lock = isAlive(sidecar) ? o.state.acquireRun(sidecar.role, o.maxConcurrentAgents) : null;
|
|
12784
|
+
const work = (async () => {
|
|
12785
|
+
try {
|
|
12786
|
+
const result = await reattachRun(streamPath, sidecar, o.emit, {
|
|
12787
|
+
agentLogKey: await o.agentLogKey?.(sidecar.role).catch(() => void 0),
|
|
12788
|
+
isAlive: o.isAlive,
|
|
12789
|
+
pollMs: o.pollMs,
|
|
12790
|
+
onAdopted: () => o.emit.emit(`adopted ${sidecar.role} run (pid ${sidecar.pid}) left by a previous crew process`, { step: "agent", role: sidecar.role })
|
|
12791
|
+
});
|
|
12792
|
+
if (result.adopted) adopted++;
|
|
12793
|
+
else interrupted++;
|
|
12794
|
+
try {
|
|
12795
|
+
await o.afterRun?.(sidecar, result);
|
|
12796
|
+
} catch (e) {
|
|
12797
|
+
o.emit.warn(`could not follow up on ${sidecar.role} run ${streamPath}: ${e.message}`, { step: "agent" });
|
|
12798
|
+
}
|
|
12799
|
+
} catch (e) {
|
|
12800
|
+
o.emit.warn(`could not reattach ${sidecar.role} run ${streamPath}: ${e.message}`, { step: "agent" });
|
|
12801
|
+
} finally {
|
|
12802
|
+
if (lock?.ok) lock.release();
|
|
12803
|
+
}
|
|
12804
|
+
})();
|
|
12805
|
+
if (isAlive(sidecar)) {
|
|
12806
|
+
if (o.inFlight) {
|
|
12807
|
+
o.inFlight.add(work);
|
|
12808
|
+
void work.finally(() => o.inFlight.delete(work));
|
|
12809
|
+
}
|
|
12810
|
+
} else {
|
|
12811
|
+
await work;
|
|
12812
|
+
}
|
|
12813
|
+
}
|
|
12814
|
+
return { adopted, interrupted };
|
|
12815
|
+
}
|
|
12816
|
+
|
|
12817
|
+
// src/daemon.ts
|
|
12556
12818
|
var DEFAULT_FLOOR_MS = 5e3;
|
|
12557
12819
|
var DEFAULT_CEILING_MS = INTERVAL_SECONDS * 1e3;
|
|
12558
12820
|
var defaultSleep = (ms2) => new Promise((resolve5) => setTimeout(resolve5, ms2));
|
|
@@ -12601,8 +12863,8 @@ function latestMtime(path) {
|
|
|
12601
12863
|
if (stat.isFile()) return stat.mtimeMs;
|
|
12602
12864
|
if (!stat.isDirectory()) return 0;
|
|
12603
12865
|
let latest = stat.mtimeMs;
|
|
12604
|
-
for (const entry of
|
|
12605
|
-
latest = Math.max(latest, latestMtime(
|
|
12866
|
+
for (const entry of readdirSync9(path, { withFileTypes: true })) {
|
|
12867
|
+
latest = Math.max(latest, latestMtime(join24(path, entry.name)));
|
|
12606
12868
|
}
|
|
12607
12869
|
return latest;
|
|
12608
12870
|
}
|
|
@@ -12611,13 +12873,13 @@ function computeTreeSignature(paths) {
|
|
|
12611
12873
|
}
|
|
12612
12874
|
function makeStaleChecker(paths, opts = {}) {
|
|
12613
12875
|
const initial = computeTreeSignature(paths);
|
|
12614
|
-
return () => computeTreeSignature(paths) !== initial || opts.reloadSentinel !== void 0 &&
|
|
12876
|
+
return () => computeTreeSignature(paths) !== initial || opts.reloadSentinel !== void 0 && existsSync24(opts.reloadSentinel);
|
|
12615
12877
|
}
|
|
12616
12878
|
function reloadSentinelFor(stateDir) {
|
|
12617
|
-
return
|
|
12879
|
+
return join24(stateDir, "reload-requested");
|
|
12618
12880
|
}
|
|
12619
12881
|
function daemonStampFor(stateDir) {
|
|
12620
|
-
return
|
|
12882
|
+
return join24(stateDir, "daemon.started");
|
|
12621
12883
|
}
|
|
12622
12884
|
function noteDaemonStarted(stateDir, now = Date.now()) {
|
|
12623
12885
|
mkdirSync12(stateDir, { recursive: true });
|
|
@@ -12680,6 +12942,11 @@ async function runOnePass(o) {
|
|
|
12680
12942
|
const spawn5 = o.spawnRoleAgent ?? runRoleAgent;
|
|
12681
12943
|
const breaker = o.spawnBreaker ?? new SpawnBreaker();
|
|
12682
12944
|
const ctx = { route: o.route, ship: o.ship, state: o.state, emit, getSessionStore: o.getSessionStore };
|
|
12945
|
+
try {
|
|
12946
|
+
await (o.reconcile ?? reconcileForRoute)(ctx, o);
|
|
12947
|
+
} catch (e) {
|
|
12948
|
+
emit.warn(`could not reconcile earlier agent runs: ${e.message}`, { step: "agent" });
|
|
12949
|
+
}
|
|
12683
12950
|
const decision = await decide(ctx);
|
|
12684
12951
|
o.state.attention(o.route.route).persist(decision.attention);
|
|
12685
12952
|
if (decision.sweep.length) {
|
|
@@ -12777,6 +13044,54 @@ async function publishShipAttention(o) {
|
|
|
12777
13044
|
} catch {
|
|
12778
13045
|
}
|
|
12779
13046
|
}
|
|
13047
|
+
async function reconcileForRoute(ctx, o) {
|
|
13048
|
+
const { route, ship, emit } = ctx;
|
|
13049
|
+
await reconcileRuns({
|
|
13050
|
+
stateDir: ship.stateDir,
|
|
13051
|
+
routeLabel: routeSlug(route.route),
|
|
13052
|
+
emit,
|
|
13053
|
+
state: o.state,
|
|
13054
|
+
maxConcurrentAgents: o.maxConcurrentAgents,
|
|
13055
|
+
inFlight: o.inFlight,
|
|
13056
|
+
agentLogKey: async (role) => (await resolveSeatKey(route, role, ctx.getSessionStore)).apiKey,
|
|
13057
|
+
afterRun: async (sc, result) => {
|
|
13058
|
+
const identity = await resolveSeatKey(route, sc.role, ctx.getSessionStore);
|
|
13059
|
+
const tracker = new Tracker(route, ship, { apiKey: identity.apiKey });
|
|
13060
|
+
const memberId = route.resolved?.seats[sc.role];
|
|
13061
|
+
try {
|
|
13062
|
+
if (!sc.ticketRecordId) return;
|
|
13063
|
+
if (result.interrupted) {
|
|
13064
|
+
if (!memberId) return;
|
|
13065
|
+
const t = await tracker.ticket(sc.ticketRecordId);
|
|
13066
|
+
const target = repoTargetFor(route, t.repo_id);
|
|
13067
|
+
const num = String(t[tracker.contract.columns.key] ?? "").replace(/^\D+/, "");
|
|
13068
|
+
const wt = target && num ? worktreeForNumber(target.dir, num) : null;
|
|
13069
|
+
let dirty = null;
|
|
13070
|
+
try {
|
|
13071
|
+
if (wt) dirty = status(wt.path).length > 0;
|
|
13072
|
+
} catch {
|
|
13073
|
+
}
|
|
13074
|
+
await tracker.postEvent(sc.ticketRecordId, interruptedComment({
|
|
13075
|
+
branch: wt?.branch ?? null,
|
|
13076
|
+
dirty,
|
|
13077
|
+
reason: result.interrupted === "daemon-exit" ? "the crew daemon exited" : "it exited without a result"
|
|
13078
|
+
}), memberId, "interrupted");
|
|
13079
|
+
} else {
|
|
13080
|
+
await guardRunHandoff(route, ship, tracker, sc.role, sc.ticketRecordId, emit, sc.spawnedAt);
|
|
13081
|
+
}
|
|
13082
|
+
} finally {
|
|
13083
|
+
if (memberId) {
|
|
13084
|
+
await tracker.setCrewStatus(memberId, "idle").catch((e) => {
|
|
13085
|
+
emit.warn(`could not clear crew status: ${e.message}`, { step: "agent" });
|
|
13086
|
+
});
|
|
13087
|
+
}
|
|
13088
|
+
await tracker.beatIdle(ship.name).catch((e) => {
|
|
13089
|
+
emit.warn(`could not beat ship idle: ${e.message}`, { step: "agent" });
|
|
13090
|
+
});
|
|
13091
|
+
}
|
|
13092
|
+
}
|
|
13093
|
+
});
|
|
13094
|
+
}
|
|
12780
13095
|
async function runRoleAgent(role, decision, ctx) {
|
|
12781
13096
|
const { route, ship, emit } = ctx;
|
|
12782
13097
|
const identity = await resolveSeatKey(route, role, ctx.getSessionStore);
|
|
@@ -12866,6 +13181,7 @@ async function runRoleAgent(role, decision, ctx) {
|
|
|
12866
13181
|
cycle: emit.cycle,
|
|
12867
13182
|
ticket: ticketHint,
|
|
12868
13183
|
repo: ticketRepo,
|
|
13184
|
+
ticketRecordId: workingId,
|
|
12869
13185
|
...personaPromptOptions(await fetchPersonaPrompt(route, role, { userAgent: ship.userAgent })),
|
|
12870
13186
|
skillOverrides: await fetchSkillOverrides(route, { userAgent: ship.userAgent }),
|
|
12871
13187
|
agentPersona: await fetchSeatPersona(route, role, { userAgent: ship.userAgent }),
|
|
@@ -13442,14 +13758,14 @@ if (command === "passenger-sync-daemon") {
|
|
|
13442
13758
|
}
|
|
13443
13759
|
if (host === "linux" && hasSystemd) {
|
|
13444
13760
|
const label = labelFor(CREW_HOME, "run");
|
|
13445
|
-
const staleTimer =
|
|
13446
|
-
if (
|
|
13761
|
+
const staleTimer = join25(homedir8(), ".config", "systemd", "user", `${label}.timer`);
|
|
13762
|
+
if (existsSync25(staleTimer)) {
|
|
13447
13763
|
process.stdout.write(
|
|
13448
13764
|
`removing stale run timer from before the persistent-service cutover: ${staleTimer}${dryRun ? " (dry run)" : ""}
|
|
13449
13765
|
`
|
|
13450
13766
|
);
|
|
13451
13767
|
if (!dryRun) {
|
|
13452
|
-
|
|
13768
|
+
spawnSync6("systemctl", ["--user", "disable", "--now", `${label}.timer`], { cwd: CREW_HOME });
|
|
13453
13769
|
try {
|
|
13454
13770
|
unlinkSync5(staleTimer);
|
|
13455
13771
|
} catch {
|
|
@@ -13469,7 +13785,7 @@ if (command === "passenger-sync-daemon") {
|
|
|
13469
13785
|
const tracker = new Tracker(c, cfg.ship);
|
|
13470
13786
|
problems.push(...validateContract(tracker.contract).map((p) => `contract: ${p}`));
|
|
13471
13787
|
for (const r of await resolvedRepos(c)) {
|
|
13472
|
-
if (!
|
|
13788
|
+
if (!existsSync25(r.dir)) problems.push(`repo ${r.name}: ${r.dir} is missing on disk`);
|
|
13473
13789
|
if (!satisfies(host, r.config.platform)) problems.push(`repo ${r.name}: requires ${r.config.platform}, this host is ${host}`);
|
|
13474
13790
|
problems.push(...validateEffective(r.config).map((p) => `repo ${r.name}: ${p}`));
|
|
13475
13791
|
}
|
|
@@ -13487,8 +13803,8 @@ if (command === "passenger-sync-daemon") {
|
|
|
13487
13803
|
}
|
|
13488
13804
|
async function doctorFix(o) {
|
|
13489
13805
|
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))));
|
|
13490
|
-
const unitExists = (p) =>
|
|
13491
|
-
const crontab = o.host === "linux" ?
|
|
13806
|
+
const unitExists = (p) => existsSync25(p);
|
|
13807
|
+
const crontab = o.host === "linux" ? spawnSync6("crontab", ["-l"], { encoding: "utf8" }).stdout ?? "" : "";
|
|
13492
13808
|
const schedulerAbsent = o.host !== "windows" ? !schedulerInstalled(planInstall(cfg.ship, CREW_HOME, o.host, detectSystemd(), "run"), unitExists, crontab) : true;
|
|
13493
13809
|
const plan = planFix({ routes, schedulerAbsent, host: o.host });
|
|
13494
13810
|
for (const n of plan.notes) process.stdout.write(`fix: ${n}
|
|
@@ -14439,7 +14755,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
14439
14755
|
}
|
|
14440
14756
|
let failed = false;
|
|
14441
14757
|
for (const cmd2 of commands) {
|
|
14442
|
-
const res2 =
|
|
14758
|
+
const res2 = spawnSync6(cmd2[0], cmd2.slice(1), { cwd: CREW_HOME, encoding: "utf8" });
|
|
14443
14759
|
if (res2.stdout) process.stdout.write(res2.stdout);
|
|
14444
14760
|
if (res2.stderr) process.stderr.write(res2.stderr);
|
|
14445
14761
|
if (cmd2 === control.startCommand && (res2.error || res2.status !== 0)) {
|
|
@@ -14457,7 +14773,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
14457
14773
|
`);
|
|
14458
14774
|
break;
|
|
14459
14775
|
}
|
|
14460
|
-
const res =
|
|
14776
|
+
const res = spawnSync6(cmd[0], cmd.slice(1), { cwd: CREW_HOME, encoding: "utf8" });
|
|
14461
14777
|
if (res.stdout) process.stdout.write(res.stdout);
|
|
14462
14778
|
if (res.stderr) process.stderr.write(res.stderr);
|
|
14463
14779
|
if (daemonSub !== "status" && (res.error || res.status !== 0)) {
|
|
@@ -14480,7 +14796,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
14480
14796
|
process.exit(1);
|
|
14481
14797
|
}
|
|
14482
14798
|
requireArmed("run the daemon loop");
|
|
14483
|
-
const watchPaths = isCompiledBinary(import.meta.url) ? [process.execPath] : [
|
|
14799
|
+
const watchPaths = isCompiledBinary(import.meta.url) ? [process.execPath] : [join25(CREW_HOME, "src"), join25(CREW_HOME, "dist"), join25(CREW_HOME, "bin")];
|
|
14484
14800
|
const configPaths = daemonConfigPaths(cfg);
|
|
14485
14801
|
watchPaths.push(...configPaths);
|
|
14486
14802
|
noteDaemonStarted(cfg.ship.stateDir);
|
|
@@ -14642,7 +14958,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
14642
14958
|
`);
|
|
14643
14959
|
if (!dryRun) {
|
|
14644
14960
|
const resolvedPath = resolvedPathFor(cfg.ship.stateDir, target.route);
|
|
14645
|
-
const raw =
|
|
14961
|
+
const raw = existsSync25(resolvedPath) ? JSON.parse(readFileSync18(resolvedPath, "utf8")) : {};
|
|
14646
14962
|
raw.agentPersonas = personaResult.agentPersonas;
|
|
14647
14963
|
raw.agentSkills = skillResult.agentSkills;
|
|
14648
14964
|
if (personaResult.releaseAgentId) raw.releaseAgentId = personaResult.releaseAgentId;
|
|
@@ -14702,7 +15018,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
14702
15018
|
`);
|
|
14703
15019
|
if (!dryRun) {
|
|
14704
15020
|
const resolvedPath = resolvedPathFor(cfg.ship.stateDir, target.route);
|
|
14705
|
-
const raw =
|
|
15021
|
+
const raw = existsSync25(resolvedPath) ? JSON.parse(readFileSync18(resolvedPath, "utf8")) : {};
|
|
14706
15022
|
raw.agentSkills = result.agentSkills;
|
|
14707
15023
|
mkdirSync13(dirOf(resolvedPath), { recursive: true });
|
|
14708
15024
|
writeFileSync14(resolvedPath, `${JSON.stringify(raw, null, 2)}
|
|
@@ -14725,7 +15041,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
14725
15041
|
if (sub === "list" && !routeName) usage2();
|
|
14726
15042
|
if (sub === "add") {
|
|
14727
15043
|
try {
|
|
14728
|
-
const a = parseAddArgs(positional.slice(2), routeNamesOf(configText), (p) =>
|
|
15044
|
+
const a = parseAddArgs(positional.slice(2), routeNamesOf(configText), (p) => existsSync25(resolvePath(p.replace(/^~(?=\/|$)/, homedir8()))));
|
|
14729
15045
|
routeName = a.route;
|
|
14730
15046
|
addPath = a.path;
|
|
14731
15047
|
addName = a.name;
|
|
@@ -14740,7 +15056,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
14740
15056
|
}
|
|
14741
15057
|
const resolvedFile = resolvedPathFor(cfg.ship.stateDir, routeName);
|
|
14742
15058
|
let resolvedRemotes;
|
|
14743
|
-
if (
|
|
15059
|
+
if (existsSync25(resolvedFile)) {
|
|
14744
15060
|
try {
|
|
14745
15061
|
resolvedRemotes = JSON.parse(readFileSync18(resolvedFile, "utf8"));
|
|
14746
15062
|
} catch {
|
|
@@ -14757,14 +15073,14 @@ ${changed2.map((c) => ` - ${c}
|
|
|
14757
15073
|
for (const r of repos) {
|
|
14758
15074
|
const origin2 = originOf(r.dir);
|
|
14759
15075
|
const row = matchRepoName(origin2, resolvedRemotes);
|
|
14760
|
-
const note = !
|
|
15076
|
+
const note = !existsSync25(r.dir) ? "missing on disk" : row ? `Repos row: ${row}` : `no Repos row matches ${normalizeRemote(origin2) ?? "this checkout (no origin remote)"}`;
|
|
14761
15077
|
process.stdout.write(`${r.name} ${r.dir} ${note}
|
|
14762
15078
|
`);
|
|
14763
15079
|
}
|
|
14764
15080
|
break;
|
|
14765
15081
|
}
|
|
14766
15082
|
const target = resolvePath(addPath.replace(/^~(?=\/|$)/, homedir8()));
|
|
14767
|
-
const top =
|
|
15083
|
+
const top = existsSync25(target) ? gitOk(target, ["rev-parse", "--show-toplevel"]) : null;
|
|
14768
15084
|
if (!top) throw new ReposError(`${target} is not a git checkout`);
|
|
14769
15085
|
const origin = originOf(top);
|
|
14770
15086
|
const plan = planRepoAdd({
|
|
@@ -14813,7 +15129,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
14813
15129
|
const created = await rowTracker.createRepoRow(rowPayload);
|
|
14814
15130
|
process.stdout.write(`created Repos row ${String(rowPayload.name)} (${String(rowPayload.remote)}) ${created.id}
|
|
14815
15131
|
`);
|
|
14816
|
-
if (
|
|
15132
|
+
if (existsSync25(resolvedFile)) {
|
|
14817
15133
|
const raw = JSON.parse(readFileSync18(resolvedFile, "utf8"));
|
|
14818
15134
|
raw.repoNames = { ...raw.repoNames, [created.id]: String(rowPayload.name) };
|
|
14819
15135
|
raw.repoRemotes = { ...raw.repoRemotes, [created.id]: String(rowPayload.remote) };
|
|
@@ -15060,7 +15376,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
15060
15376
|
operator = await pickOperator(found.holds);
|
|
15061
15377
|
}
|
|
15062
15378
|
let previous;
|
|
15063
|
-
const hadPreviousFile =
|
|
15379
|
+
const hadPreviousFile = existsSync25(resolvedPath);
|
|
15064
15380
|
if (hadPreviousFile) {
|
|
15065
15381
|
try {
|
|
15066
15382
|
previous = JSON.parse(readFileSync18(resolvedPath, "utf8"));
|
|
@@ -15163,7 +15479,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
15163
15479
|
const keyPersisted = keychainBacked || apiKeyFile !== void 0;
|
|
15164
15480
|
const wizard = firstRun;
|
|
15165
15481
|
const writeFirstRun = wizard !== void 0 && !dryRun && keyPersisted;
|
|
15166
|
-
const routeDir = value("dir") ?? (writeFirstRun ?
|
|
15482
|
+
const routeDir = value("dir") ?? (writeFirstRun ? join25(DEFAULT_REPOS_BASE_PATH, found.workspaceSlug) : void 0);
|
|
15167
15483
|
const routeBlock = renderConnection(
|
|
15168
15484
|
found,
|
|
15169
15485
|
route2,
|
|
@@ -15539,7 +15855,19 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
15539
15855
|
process.exit(2);
|
|
15540
15856
|
}
|
|
15541
15857
|
const roleWt = positional.find((p) => p === "dev" || p === "design" || p === "qa");
|
|
15542
|
-
|
|
15858
|
+
let resumeWt = false;
|
|
15859
|
+
try {
|
|
15860
|
+
const trWt = new Tracker(routeWt, cfg.ship);
|
|
15861
|
+
const heldWt = Object.values(routeWt.resolved?.seats ?? {}).includes(ticketWt[trWt.contract.columns.assignee]);
|
|
15862
|
+
if (heldWt) {
|
|
15863
|
+
const cut = await trWt.latestEvent(ticketWt.id, "interrupted");
|
|
15864
|
+
const pushed = await trWt.latestEvent(ticketWt.id, "pushed");
|
|
15865
|
+
const at = (e) => e?.occurred_at ?? e?.created_at ?? "";
|
|
15866
|
+
resumeWt = cut !== void 0 && at(cut) > at(pushed);
|
|
15867
|
+
}
|
|
15868
|
+
} catch {
|
|
15869
|
+
}
|
|
15870
|
+
const planWt = planRunWorktree(repoWt.dir, repoWt.config, ticketWt, roleWt, { resumeInterrupted: resumeWt });
|
|
15543
15871
|
if (dryRun) {
|
|
15544
15872
|
emit.emit(`would ${describeRunWorktreePlan(planWt)}`);
|
|
15545
15873
|
break;
|
|
@@ -15685,7 +16013,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
15685
16013
|
}
|
|
15686
16014
|
const host = hostPlatform();
|
|
15687
16015
|
const control = host === "windows" ? null : planDaemonControl(CREW_HOME, host, detectSystemd());
|
|
15688
|
-
const unitStatus = control?.statusCommand ?
|
|
16016
|
+
const unitStatus = control?.statusCommand ? spawnSync6(control.statusCommand[0], control.statusCommand.slice(1), { cwd: CREW_HOME, encoding: "utf8" }) : null;
|
|
15689
16017
|
const unitRunning = unitStatus !== null && !unitStatus.error && unitStatus.status === 0;
|
|
15690
16018
|
if (!unitRunning) {
|
|
15691
16019
|
process.stdout.write("crew reload: no daemon unit is running \u2014 nothing to reload (the next run reads crew.yaml fresh)\n");
|
|
@@ -15697,7 +16025,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
15697
16025
|
process.stdout.write("crew reload: requested \u2014 the daemon finishes its in-flight pass, then relaunches with the new config\n");
|
|
15698
16026
|
if (flag("now") && control?.stopCommand && control.startCommand) {
|
|
15699
16027
|
for (const cmd of [control.stopCommand, control.startCommand]) {
|
|
15700
|
-
const res =
|
|
16028
|
+
const res = spawnSync6(cmd[0], cmd.slice(1), { cwd: CREW_HOME, encoding: "utf8" });
|
|
15701
16029
|
if (cmd === control.startCommand && (res.error || res.status !== 0)) {
|
|
15702
16030
|
process.stderr.write(`crew reload: ${cmd.join(" ")} exited ${res.status ?? "error"}
|
|
15703
16031
|
`);
|
|
@@ -15953,13 +16281,28 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
|
|
|
15953
16281
|
);
|
|
15954
16282
|
try {
|
|
15955
16283
|
const evFile = eventFileFor(cfg.ship.stateDir);
|
|
15956
|
-
const evSize =
|
|
16284
|
+
const evSize = existsSync25(evFile) ? statSync9(evFile).size : 0;
|
|
15957
16285
|
process.stdout.write(
|
|
15958
16286
|
evSize > cfg.ship.eventsRotateBytes * 4 ? `events log: WARN ${evFile} is ${Math.round(evSize / 1048576)} MB (> 4x eventsRotateBytes); run \`crew reap\` to rotate it
|
|
15959
16287
|
` : "events log: ok\n"
|
|
15960
16288
|
);
|
|
15961
16289
|
} catch {
|
|
15962
16290
|
}
|
|
16291
|
+
if (host !== "windows") {
|
|
16292
|
+
const unitPlan = planInstall(cfg.ship, CREW_HOME, host, detectSystemd(), "run");
|
|
16293
|
+
const abandons = unitAbandonsAgents(unitPlan, (p) => {
|
|
16294
|
+
try {
|
|
16295
|
+
return readFileSync18(p, "utf8");
|
|
16296
|
+
} catch {
|
|
16297
|
+
return null;
|
|
16298
|
+
}
|
|
16299
|
+
});
|
|
16300
|
+
if (abandons === false) {
|
|
16301
|
+
process.stdout.write(
|
|
16302
|
+
"scheduler unit: WARN the installed unit does not leave agent runs alone when the daemon restarts (no AbandonProcessGroup / KillMode=process) \u2014 a daemon crash or relaunch kills the run in flight; run `crew install`\n"
|
|
16303
|
+
);
|
|
16304
|
+
}
|
|
16305
|
+
}
|
|
15963
16306
|
const daemonStartedAt = readDaemonStarted(cfg.ship.stateDir);
|
|
15964
16307
|
if (daemonStartedAt !== null) {
|
|
15965
16308
|
const changed2 = configChangedAfter(daemonConfigPaths(cfg), daemonStartedAt);
|
package/package.json
CHANGED