@tablation/crew 0.49.5 → 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.
Files changed (2) hide show
  1. package/dist/cli.js +562 -188
  2. 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 join24, basename as basename9 } from "path";
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 spawnSync5 } from "child_process";
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);
@@ -4931,6 +4943,8 @@ var Tracker = class {
4931
4943
  * writes pass its seat's agent key so the event log and rule context see an
4932
4944
  * agent, not the ship's human principal.
4933
4945
  */
4946
+ /** Told once per failed best-effort read (`events`); poll.ts points it at the pass's emitter. */
4947
+ onReadFailure;
4934
4948
  constructor(route, ship, opts = {}) {
4935
4949
  this.route = route;
4936
4950
  if (!route.resolved) {
@@ -5372,10 +5386,16 @@ var Tracker = class {
5372
5386
  */
5373
5387
  async events(type, limit = 500) {
5374
5388
  const filters = encodeURIComponent(JSON.stringify([{ columnName: "event_type", operator: "EQ", value: type }]));
5375
- const res = await this.eventsFetch("GET", `/events?limit=${limit}&filters=${filters}`);
5376
- if (res.status === 404) return [];
5377
- if (!res.ok) throw new Error(`reading ${type} events failed: HTTP ${res.status}`);
5378
- const rows = await res.json();
5389
+ let rows;
5390
+ try {
5391
+ const res = await this.eventsFetch("GET", `/events?limit=${limit}&filters=${filters}`);
5392
+ if (res.status === 404) return [];
5393
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
5394
+ rows = await res.json();
5395
+ } catch (err) {
5396
+ this.onReadFailure?.(`reading ${type} events failed: ${err instanceof Error ? err.message : String(err)}`);
5397
+ return [];
5398
+ }
5379
5399
  return rows.filter((e) => e.event_type === type).sort((a, b) => Date.parse(b.occurred_at ?? b.created_at ?? "") - Date.parse(a.occurred_at ?? a.created_at ?? ""));
5380
5400
  }
5381
5401
  /** The newest event of `type` on a ticket, or undefined — e.g. the `pushed` tip QA verifies against. */
@@ -6112,7 +6132,7 @@ function operatorTodo(tickets, contract, operatorId) {
6112
6132
  }
6113
6133
 
6114
6134
  // src/digest.ts
6115
- var DIRECTION_EVENT_TYPES = ["conflict_handed_back", "requeued", "bounced", "conflict_escalated"];
6135
+ var DIRECTION_EVENT_TYPES = ["conflict_handed_back", "requeued", "bounced", "conflict_escalated", "interrupted"];
6116
6136
  function displayKey2(t) {
6117
6137
  return t.issue_tag ?? t.issue_id ?? "?";
6118
6138
  }
@@ -6241,7 +6261,7 @@ ${sorted(held).map((t) => `| ${displayKey2(t)} | ${stopOf(t)} |`).join("\n")}
6241
6261
  "## Current queue \u2014 built for you by the poll\n",
6242
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",
6243
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',
6244
- '\n"runner direction" is the newest instruction the crew itself posted to the\nevent log (not Comments) that no push has answered yet: `conflict_handed_back`\n(merge the base into the branch and resolve), `requeued`, `bounced`,\n`conflict_escalated`. It is direction, same as a comment from a hold: act on it.\n',
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',
6245
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",
6246
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"] : [],
6247
6267
  "\n### Step 1 \u2014 open tickets that may be yours to act on\n",
@@ -6455,14 +6475,23 @@ async function runStalledSweep(o, stalled) {
6455
6475
  async function decideCycle(o) {
6456
6476
  const { route, ship, state, emit } = o;
6457
6477
  const tracker = new Tracker(route, ship);
6478
+ tracker.onReadFailure = (m) => emit.warn(m);
6458
6479
  emit.enter("poll");
6480
+ const optional = async (what, read, fallback) => {
6481
+ try {
6482
+ return await read;
6483
+ } catch (err) {
6484
+ emit.warn(`reading ${what} failed: ${err instanceof Error ? err.message : String(err)}`);
6485
+ return fallback;
6486
+ }
6487
+ };
6459
6488
  const [tickets, comments, crewRows, epicRows, ships, projectModes, stalledEvents] = await Promise.all([
6460
6489
  tracker.openTickets(),
6461
6490
  tracker.comments(200),
6462
6491
  tracker.crewRows(),
6463
- tracker.epicRows(),
6464
- tracker.shipRows(),
6465
- tracker.projectWorkModes(),
6492
+ optional("epics", tracker.epicRows(), []),
6493
+ optional("ships", tracker.shipRows(), []),
6494
+ optional("project work modes", tracker.projectWorkModes(), /* @__PURE__ */ new Map()),
6466
6495
  tracker.events("stalled")
6467
6496
  ]);
6468
6497
  const directionEvents = (await Promise.all([...DIRECTION_EVENT_TYPES, "pushed"].map((t) => tracker.events(t)))).flat();
@@ -7096,8 +7125,9 @@ ${g.tail}
7096
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)}`));
7097
7126
 
7098
7127
  // src/agent.ts
7099
- import { spawn as spawn2 } from "child_process";
7100
- 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";
7101
7131
  import { finished } from "stream/promises";
7102
7132
  import { join as join14, dirname as dirname6 } from "path";
7103
7133
  import { createHash as createHash2 } from "crypto";
@@ -8274,6 +8304,7 @@ function planAgentRun(o) {
8274
8304
  streamPath: join14(streamsDir, `${base}.jsonl`),
8275
8305
  eventsPath: join14(streamsDir, `${base}.events.jsonl`),
8276
8306
  ticket: o.ticket,
8307
+ ticketRecordId: o.ticketRecordId ?? null,
8277
8308
  agentLog,
8278
8309
  promptVersion: o.route.resolved?.agentPersonas?.[o.role]?.historyId,
8279
8310
  promptSha
@@ -8297,6 +8328,43 @@ function describePlan(p) {
8297
8328
  `stream: ${p.streamPath}`
8298
8329
  ].join("\n");
8299
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;
8300
8368
  function openSink(path) {
8301
8369
  try {
8302
8370
  mkdirSync6(dirname6(path), { recursive: true });
@@ -8310,172 +8378,300 @@ function endSink(sink) {
8310
8378
  sink.end();
8311
8379
  return finished(sink).catch(() => void 0);
8312
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
+ }
8313
8571
  function spawnAgent(plan, emit) {
8314
8572
  const adapter = adapterFor(plan.harness ?? LEGACY_TOOLCHAIN_HARNESS);
8315
8573
  const started = Date.now();
8316
8574
  const startedAt = new Date(started).toISOString();
8317
8575
  for (const w of plan.warnings ?? []) emit.warn(w, { step: "agent", role: plan.role });
8318
- const rawSink = openSink(plan.streamPath);
8319
8576
  const eventsSink = openSink(plan.eventsPath);
8577
+ const id = identityOf(plan, adapter.harness);
8320
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
+ }
8321
8588
  const child = spawn2(plan.bin, plan.args, {
8322
8589
  cwd: plan.cwd,
8323
8590
  env: { ...scrubbedEnv(process.env, adapter.harness), ...plan.setEnv },
8324
- stdio: ["pipe", "pipe", "inherit"]
8591
+ stdio: ["pipe", streamFd, "inherit"],
8592
+ detached: true
8325
8593
  });
8326
- let buf = "";
8327
- let result;
8328
- const cycles = [];
8329
- const attribution = new TicketAttributionTracker();
8330
- const mapper = adapter.createStreamMapper?.() ?? { map: (p) => adapter.mapStreamLine(p), flush: () => [] };
8331
- const handleLine = (line) => {
8332
- if (!line) return;
8333
- try {
8334
- rawSink?.write(`${line}
8335
- `);
8336
- } catch {
8337
- }
8338
- let parsed;
8339
- try {
8340
- parsed = JSON.parse(line);
8341
- } catch {
8342
- return;
8343
- }
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
+ };
8344
8613
  try {
8345
- const got = adapter.extractResult(parsed);
8346
- if (got) result = { ...result, ...Object.fromEntries(Object.entries(got).filter(([, v]) => v !== void 0)) };
8347
- consume(parsed, mapper.map(parsed));
8614
+ writeFileSync8(pidPathFor(plan.streamPath), JSON.stringify(sidecar));
8348
8615
  } catch {
8349
8616
  }
8350
- };
8351
- const consume = (parsed, events) => {
8352
- {
8353
- let turnText = "";
8354
- for (const ev of events) {
8355
- const e = {
8356
- at: (/* @__PURE__ */ new Date()).toISOString(),
8357
- cycle: emit.cycle,
8358
- route: plan.route,
8359
- step: "agent",
8360
- level: "info",
8361
- message: ev.kind === "tool" ? `${ev.tool ?? "tool"}${ev.target ? ` ${ev.target}` : ""}` : ev.kind,
8362
- role: plan.role,
8363
- ...plan.ticket ? { ticket: plan.ticket } : {},
8364
- data: { kind: ev.kind, ...ev.text ? { text: ev.text } : {}, ...ev.tool ? { tool: ev.tool } : {}, ...ev.target ? { target: ev.target } : {} }
8365
- };
8366
- eventsSink?.write(`${JSON.stringify(e)}
8367
- `);
8368
- if ((ev.kind === "thought" || ev.kind === "text") && ev.text) {
8369
- turnText += `${ev.text}
8370
- `;
8371
- const cycleIndex = cycles.length;
8372
- cycles.push({
8373
- cycleIndex,
8374
- occurredAt: e.at,
8375
- thinking: ev.text,
8376
- // A conversational `text` turn is tagged 'assistant' so it's
8377
- // distinguishable from a thinking block once both share this
8378
- // buffer (ISSUE-594) — matching the precedent
8379
- // `agent-chat.service.ts`'s logChatRun (ISSUE-504) already set
8380
- // for the same column. A `thought` cycle stays untagged, same
8381
- // as before this change.
8382
- ...ev.kind === "text" ? { role: "assistant" } : {}
8383
- });
8384
- eventsSink?.write(`${JSON.stringify({
8385
- ...e,
8386
- message: "cycle",
8387
- data: { kind: "cycle", cycleIndex, text: ev.text }
8388
- })}
8389
- `);
8390
- }
8391
- }
8392
- const turnTokens = parsed === void 0 ? void 0 : adapter.extractTurnTokens(parsed);
8393
- if (turnTokens !== void 0) attribution.add(turnText, turnTokens);
8394
- }
8395
- };
8396
- child.stdout.on("data", (chunk) => {
8397
- buf += chunk.toString("utf8");
8398
- const lines = buf.split("\n");
8399
- buf = lines.pop() ?? "";
8400
- for (const line of lines) handleLine(line);
8401
- });
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);
8402
8621
  let spawnFailed = false;
8403
8622
  child.on("error", (err) => {
8404
8623
  spawnFailed = true;
8624
+ clearInterval(timer);
8405
8625
  reject(new AgentSpawnError(`cannot run ${plan.bin}: ${err.message}`));
8406
8626
  });
8407
8627
  child.on("close", async (code) => {
8408
- if (buf) handleLine(buf);
8409
- try {
8410
- consume(void 0, mapper.flush());
8411
- } catch {
8412
- }
8413
- await Promise.all([endSink(rawSink), endSink(eventsSink)]);
8628
+ clearInterval(timer);
8629
+ obs.feed(tail.poll());
8630
+ obs.end();
8631
+ await endSink(eventsSink);
8414
8632
  if (spawnFailed) return;
8415
- const attributedTicket = attribution.winner(plan.ticket);
8416
- const identity = {
8417
- toolchain: plan.toolchain ?? LEGACY_TOOLCHAIN_HARNESS,
8418
- harness: adapter.harness,
8419
- provider: plan.provider ?? LEGACY_TOOLCHAIN_PROVIDER
8420
- };
8421
- try {
8422
- writeFileSync8(`${plan.streamPath}.meta.json`, JSON.stringify({
8423
- route: plan.route,
8424
- role: plan.role,
8425
- ticket: plan.ticket ?? null,
8426
- attributedTicket: attributedTicket ?? null,
8427
- startedAt,
8428
- exitCode: code ?? 1,
8429
- model: plan.model,
8430
- ...identity,
8431
- sessionId: result?.sessionId ?? null,
8432
- numTurns: result?.numTurns ?? null,
8433
- totalCostUsd: result?.costUsd ?? null
8434
- }));
8435
- } catch {
8436
- }
8437
- const ms2 = Date.now() - started;
8438
- emit.emit(`agent run finished (exit ${code ?? 1})`, {
8439
- data: {
8440
- ms: ms2,
8441
- code: code ?? 1,
8442
- ...identity,
8443
- ...result?.sessionId ? { sessionId: result.sessionId } : {},
8444
- ...result?.numTurns !== void 0 ? { numTurns: result.numTurns } : {},
8445
- ...result?.costUsd !== void 0 ? { totalCostUsd: result.costUsd } : {}
8446
- }
8447
- });
8448
- if (plan.agentLog) {
8449
- try {
8450
- await reportAgentRun(plan.agentLog, {
8451
- ticketReference: attributedTicket,
8452
- outcome: (code ?? 1) === 0 ? "success" : "error",
8453
- startedAt,
8454
- finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
8455
- cycles,
8456
- model: plan.model,
8457
- tokensIn: result?.inputTokens,
8458
- tokensOut: result?.outputTokens,
8459
- cacheReadTokens: result?.cacheReadTokens,
8460
- cacheWriteTokens: result?.cacheWriteTokens,
8461
- costUsd: result?.costUsd,
8462
- harness: identity.harness,
8463
- provider: identity.provider,
8464
- promptVersion: plan.promptVersion,
8465
- promptSha: plan.promptSha
8466
- });
8467
- } catch (e) {
8468
- emit.warn(`could not report agent log: ${e.message}`, { step: "agent" });
8469
- }
8470
- }
8633
+ const { ms: ms2 } = await finishRun(id, obs, emit, startedAt, { code: code ?? 1 }, plan.agentLog?.apiKey);
8471
8634
  resolve5({ code: code ?? 1, ms: ms2 });
8472
8635
  });
8473
- child.stdin.on("error", (err) => {
8636
+ child.stdin?.on("error", (err) => {
8474
8637
  emit.warn(`agent closed stdin before the prompt was delivered: ${err.message}`, { step: "agent" });
8475
8638
  });
8476
- child.stdin.end(plan.prompt);
8639
+ child.stdin?.end(plan.prompt);
8477
8640
  });
8478
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
+ }
8479
8675
 
8480
8676
  // src/run-worktree.ts
8481
8677
  import { existsSync as existsSync14 } from "fs";
@@ -8584,6 +8780,8 @@ var describeRunWorktreePlan = (p) => {
8584
8780
  return `reusing ${p.path} (${p.branch} is already level with the remote)`;
8585
8781
  case "recut":
8586
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`;
8587
8785
  case "leave-dirty":
8588
8786
  return `left ${p.path} alone: it has uncommitted changes (someone may be editing it by hand)`;
8589
8787
  case "leave-unpushed":
@@ -8595,7 +8793,7 @@ function newBranchName(cfg, t, role) {
8595
8793
  const { prefix } = ticketBranchContext(t);
8596
8794
  return branchRenderer(t, role)(effectiveBranchTemplate(cfg, prefix));
8597
8795
  }
8598
- function planRunWorktree(dir, cfg, t, role) {
8796
+ function planRunWorktree(dir, cfg, t, role, opts = {}) {
8599
8797
  const { remote, base } = cfg.branch;
8600
8798
  const hasRemote = remoteConfigured(dir, remote);
8601
8799
  if (hasRemote) fetchRemote(dir, remote);
@@ -8605,7 +8803,9 @@ function planRunWorktree(dir, cfg, t, role) {
8605
8803
  const baseRef = hasRemote ? `${remote}/${base}` : base;
8606
8804
  if (existing && !existing.prunable && existsSync14(existing.path)) {
8607
8805
  const branch = existing.branch;
8608
- if (status(existing.path).length > 0) return { action: "leave-dirty", path: existing.path, branch };
8806
+ if (status(existing.path).length > 0) {
8807
+ return { action: opts.resumeInterrupted ? "resume-dirty" : "leave-dirty", path: existing.path, branch };
8808
+ }
8609
8809
  if (!branch) return { action: "leave-unpushed", path: existing.path, branch: "(detached HEAD)" };
8610
8810
  if (!hasRemote) return { action: "reuse", path: existing.path, branch };
8611
8811
  if (remoteBranchExists(dir, remote, branch)) {
@@ -8632,6 +8832,8 @@ function applyRunWorktree(dir, cfg, plan) {
8632
8832
  case "leave-dirty":
8633
8833
  case "leave-unpushed":
8634
8834
  return null;
8835
+ case "resume-dirty":
8836
+ return plan.path;
8635
8837
  case "reuse":
8636
8838
  if (!worktrees(dir).some((w) => w.path === plan.path)) git(dir, ["worktree", "add", plan.path, plan.branch]);
8637
8839
  return plan.path;
@@ -10447,7 +10649,7 @@ function missingReleaserWarning(repo, release, ships, now = Date.now()) {
10447
10649
  }
10448
10650
 
10449
10651
  // src/watch.ts
10450
- import { watch as watchFile, existsSync as existsSync17, statSync as statSync6, openSync as openSync3, readSync, closeSync as closeSync3 } from "fs";
10652
+ import { watch as watchFile, existsSync as existsSync17, statSync as statSync6, openSync as openSync4, readSync as readSync2, closeSync as closeSync4 } from "fs";
10451
10653
  var E = "\x1B[";
10452
10654
  var dim = (s) => `${E}2m${s}${E}0m`;
10453
10655
  var bold = (s) => `${E}1m${s}${E}0m`;
@@ -10552,10 +10754,10 @@ function readFrom(file, from) {
10552
10754
  const size = statSync6(file).size;
10553
10755
  if (size < from) return readFrom(file, 0);
10554
10756
  if (size === from) return { events: [], offset: from };
10555
- const fd = openSync3(file, "r");
10757
+ const fd = openSync4(file, "r");
10556
10758
  const buf = Buffer.alloc(size - from);
10557
- readSync(fd, buf, 0, buf.length, from);
10558
- closeSync3(fd);
10759
+ readSync2(fd, buf, 0, buf.length, from);
10760
+ closeSync4(fd);
10559
10761
  const text = buf.toString("utf8");
10560
10762
  const lastNewline = text.lastIndexOf("\n");
10561
10763
  if (lastNewline === -1) return { events: [], offset: from };
@@ -11794,7 +11996,7 @@ function planEventsSweep(stateDir, retentionDays, now = Date.now()) {
11794
11996
  }
11795
11997
 
11796
11998
  // src/cli.ts
11797
- import { readFileSync as readFileSync18, existsSync as existsSync24, mkdirSync as mkdirSync13, writeFileSync as writeFileSync14, unlinkSync as unlinkSync5, copyFileSync as copyFileSync2, renameSync as renameSync2, statSync as statSync9 } from "fs";
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";
11798
12000
  import { createInterface } from "readline/promises";
11799
12001
  import { dirname as dirOf, resolve as resolvePath } from "path";
11800
12002
 
@@ -12534,8 +12736,85 @@ function syncPassengerSyncDaemons(plans, cfg, stateDir, deps = {}) {
12534
12736
  }
12535
12737
 
12536
12738
  // src/daemon.ts
12537
- import { existsSync as existsSync23, mkdirSync as mkdirSync12, readFileSync as readFileSync17, readdirSync as readdirSync8, rmSync as rmSync9, statSync as statSync8, writeFileSync as writeFileSync13 } from "fs";
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";
12538
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
12539
12818
  var DEFAULT_FLOOR_MS = 5e3;
12540
12819
  var DEFAULT_CEILING_MS = INTERVAL_SECONDS * 1e3;
12541
12820
  var defaultSleep = (ms2) => new Promise((resolve5) => setTimeout(resolve5, ms2));
@@ -12547,7 +12826,18 @@ async function runDaemonLoop(o) {
12547
12826
  let passes = 0;
12548
12827
  for (; ; ) {
12549
12828
  if (o.signal?.aborted) return "aborted";
12550
- const result = await o.runPass();
12829
+ let result;
12830
+ try {
12831
+ result = await o.runPass();
12832
+ } catch (err) {
12833
+ passes++;
12834
+ o.onPassError?.(err);
12835
+ if (o.maxPasses !== void 0 && passes >= o.maxPasses) return "maxPasses";
12836
+ if (o.signal?.aborted) return "aborted";
12837
+ await sleep(backoff);
12838
+ backoff = Math.min(backoff * 2, ceiling);
12839
+ continue;
12840
+ }
12551
12841
  passes++;
12552
12842
  if (result.started.length > 0) {
12553
12843
  backoff = floor;
@@ -12573,8 +12863,8 @@ function latestMtime(path) {
12573
12863
  if (stat.isFile()) return stat.mtimeMs;
12574
12864
  if (!stat.isDirectory()) return 0;
12575
12865
  let latest = stat.mtimeMs;
12576
- for (const entry of readdirSync8(path, { withFileTypes: true })) {
12577
- latest = Math.max(latest, latestMtime(join23(path, entry.name)));
12866
+ for (const entry of readdirSync9(path, { withFileTypes: true })) {
12867
+ latest = Math.max(latest, latestMtime(join24(path, entry.name)));
12578
12868
  }
12579
12869
  return latest;
12580
12870
  }
@@ -12583,13 +12873,13 @@ function computeTreeSignature(paths) {
12583
12873
  }
12584
12874
  function makeStaleChecker(paths, opts = {}) {
12585
12875
  const initial = computeTreeSignature(paths);
12586
- return () => computeTreeSignature(paths) !== initial || opts.reloadSentinel !== void 0 && existsSync23(opts.reloadSentinel);
12876
+ return () => computeTreeSignature(paths) !== initial || opts.reloadSentinel !== void 0 && existsSync24(opts.reloadSentinel);
12587
12877
  }
12588
12878
  function reloadSentinelFor(stateDir) {
12589
- return join23(stateDir, "reload-requested");
12879
+ return join24(stateDir, "reload-requested");
12590
12880
  }
12591
12881
  function daemonStampFor(stateDir) {
12592
- return join23(stateDir, "daemon.started");
12882
+ return join24(stateDir, "daemon.started");
12593
12883
  }
12594
12884
  function noteDaemonStarted(stateDir, now = Date.now()) {
12595
12885
  mkdirSync12(stateDir, { recursive: true });
@@ -12652,6 +12942,11 @@ async function runOnePass(o) {
12652
12942
  const spawn5 = o.spawnRoleAgent ?? runRoleAgent;
12653
12943
  const breaker = o.spawnBreaker ?? new SpawnBreaker();
12654
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
+ }
12655
12950
  const decision = await decide(ctx);
12656
12951
  o.state.attention(o.route.route).persist(decision.attention);
12657
12952
  if (decision.sweep.length) {
@@ -12749,6 +13044,54 @@ async function publishShipAttention(o) {
12749
13044
  } catch {
12750
13045
  }
12751
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
+ }
12752
13095
  async function runRoleAgent(role, decision, ctx) {
12753
13096
  const { route, ship, emit } = ctx;
12754
13097
  const identity = await resolveSeatKey(route, role, ctx.getSessionStore);
@@ -12838,6 +13181,7 @@ async function runRoleAgent(role, decision, ctx) {
12838
13181
  cycle: emit.cycle,
12839
13182
  ticket: ticketHint,
12840
13183
  repo: ticketRepo,
13184
+ ticketRecordId: workingId,
12841
13185
  ...personaPromptOptions(await fetchPersonaPrompt(route, role, { userAgent: ship.userAgent })),
12842
13186
  skillOverrides: await fetchSkillOverrides(route, { userAgent: ship.userAgent }),
12843
13187
  agentPersona: await fetchSeatPersona(route, role, { userAgent: ship.userAgent }),
@@ -13414,14 +13758,14 @@ if (command === "passenger-sync-daemon") {
13414
13758
  }
13415
13759
  if (host === "linux" && hasSystemd) {
13416
13760
  const label = labelFor(CREW_HOME, "run");
13417
- const staleTimer = join24(homedir8(), ".config", "systemd", "user", `${label}.timer`);
13418
- if (existsSync24(staleTimer)) {
13761
+ const staleTimer = join25(homedir8(), ".config", "systemd", "user", `${label}.timer`);
13762
+ if (existsSync25(staleTimer)) {
13419
13763
  process.stdout.write(
13420
13764
  `removing stale run timer from before the persistent-service cutover: ${staleTimer}${dryRun ? " (dry run)" : ""}
13421
13765
  `
13422
13766
  );
13423
13767
  if (!dryRun) {
13424
- spawnSync5("systemctl", ["--user", "disable", "--now", `${label}.timer`], { cwd: CREW_HOME });
13768
+ spawnSync6("systemctl", ["--user", "disable", "--now", `${label}.timer`], { cwd: CREW_HOME });
13425
13769
  try {
13426
13770
  unlinkSync5(staleTimer);
13427
13771
  } catch {
@@ -13441,7 +13785,7 @@ if (command === "passenger-sync-daemon") {
13441
13785
  const tracker = new Tracker(c, cfg.ship);
13442
13786
  problems.push(...validateContract(tracker.contract).map((p) => `contract: ${p}`));
13443
13787
  for (const r of await resolvedRepos(c)) {
13444
- if (!existsSync24(r.dir)) problems.push(`repo ${r.name}: ${r.dir} is missing on disk`);
13788
+ if (!existsSync25(r.dir)) problems.push(`repo ${r.name}: ${r.dir} is missing on disk`);
13445
13789
  if (!satisfies(host, r.config.platform)) problems.push(`repo ${r.name}: requires ${r.config.platform}, this host is ${host}`);
13446
13790
  problems.push(...validateEffective(r.config).map((p) => `repo ${r.name}: ${p}`));
13447
13791
  }
@@ -13459,8 +13803,8 @@ if (command === "passenger-sync-daemon") {
13459
13803
  }
13460
13804
  async function doctorFix(o) {
13461
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))));
13462
- const unitExists = (p) => existsSync24(p);
13463
- const crontab = o.host === "linux" ? spawnSync5("crontab", ["-l"], { encoding: "utf8" }).stdout ?? "" : "";
13806
+ const unitExists = (p) => existsSync25(p);
13807
+ const crontab = o.host === "linux" ? spawnSync6("crontab", ["-l"], { encoding: "utf8" }).stdout ?? "" : "";
13464
13808
  const schedulerAbsent = o.host !== "windows" ? !schedulerInstalled(planInstall(cfg.ship, CREW_HOME, o.host, detectSystemd(), "run"), unitExists, crontab) : true;
13465
13809
  const plan = planFix({ routes, schedulerAbsent, host: o.host });
13466
13810
  for (const n of plan.notes) process.stdout.write(`fix: ${n}
@@ -14411,7 +14755,7 @@ ${changed2.map((c) => ` - ${c}
14411
14755
  }
14412
14756
  let failed = false;
14413
14757
  for (const cmd2 of commands) {
14414
- const res2 = spawnSync5(cmd2[0], cmd2.slice(1), { cwd: CREW_HOME, encoding: "utf8" });
14758
+ const res2 = spawnSync6(cmd2[0], cmd2.slice(1), { cwd: CREW_HOME, encoding: "utf8" });
14415
14759
  if (res2.stdout) process.stdout.write(res2.stdout);
14416
14760
  if (res2.stderr) process.stderr.write(res2.stderr);
14417
14761
  if (cmd2 === control.startCommand && (res2.error || res2.status !== 0)) {
@@ -14429,7 +14773,7 @@ ${changed2.map((c) => ` - ${c}
14429
14773
  `);
14430
14774
  break;
14431
14775
  }
14432
- const res = spawnSync5(cmd[0], cmd.slice(1), { cwd: CREW_HOME, encoding: "utf8" });
14776
+ const res = spawnSync6(cmd[0], cmd.slice(1), { cwd: CREW_HOME, encoding: "utf8" });
14433
14777
  if (res.stdout) process.stdout.write(res.stdout);
14434
14778
  if (res.stderr) process.stderr.write(res.stderr);
14435
14779
  if (daemonSub !== "status" && (res.error || res.status !== 0)) {
@@ -14452,7 +14796,7 @@ ${changed2.map((c) => ` - ${c}
14452
14796
  process.exit(1);
14453
14797
  }
14454
14798
  requireArmed("run the daemon loop");
14455
- const watchPaths = isCompiledBinary(import.meta.url) ? [process.execPath] : [join24(CREW_HOME, "src"), join24(CREW_HOME, "dist"), join24(CREW_HOME, "bin")];
14799
+ const watchPaths = isCompiledBinary(import.meta.url) ? [process.execPath] : [join25(CREW_HOME, "src"), join25(CREW_HOME, "dist"), join25(CREW_HOME, "bin")];
14456
14800
  const configPaths = daemonConfigPaths(cfg);
14457
14801
  watchPaths.push(...configPaths);
14458
14802
  noteDaemonStarted(cfg.ship.stateDir);
@@ -14473,6 +14817,9 @@ ${changed2.map((c) => ` - ${c}
14473
14817
  `)
14474
14818
  })
14475
14819
  }),
14820
+ onPassError: (err) => emit.error(
14821
+ `daemon pass failed, continuing: ${err instanceof Error ? err.stack ?? err.message : String(err)}`
14822
+ ),
14476
14823
  checkStale: makeStaleChecker(watchPaths, { reloadSentinel: reloadSentinelFor(cfg.ship.stateDir) }),
14477
14824
  onPass: (result) => {
14478
14825
  if (result.started.length) emit.emit(`daemon pass started: ${result.started.join(", ")}`);
@@ -14611,7 +14958,7 @@ ${changed2.map((c) => ` - ${c}
14611
14958
  `);
14612
14959
  if (!dryRun) {
14613
14960
  const resolvedPath = resolvedPathFor(cfg.ship.stateDir, target.route);
14614
- const raw = existsSync24(resolvedPath) ? JSON.parse(readFileSync18(resolvedPath, "utf8")) : {};
14961
+ const raw = existsSync25(resolvedPath) ? JSON.parse(readFileSync18(resolvedPath, "utf8")) : {};
14615
14962
  raw.agentPersonas = personaResult.agentPersonas;
14616
14963
  raw.agentSkills = skillResult.agentSkills;
14617
14964
  if (personaResult.releaseAgentId) raw.releaseAgentId = personaResult.releaseAgentId;
@@ -14671,7 +15018,7 @@ ${changed2.map((c) => ` - ${c}
14671
15018
  `);
14672
15019
  if (!dryRun) {
14673
15020
  const resolvedPath = resolvedPathFor(cfg.ship.stateDir, target.route);
14674
- const raw = existsSync24(resolvedPath) ? JSON.parse(readFileSync18(resolvedPath, "utf8")) : {};
15021
+ const raw = existsSync25(resolvedPath) ? JSON.parse(readFileSync18(resolvedPath, "utf8")) : {};
14675
15022
  raw.agentSkills = result.agentSkills;
14676
15023
  mkdirSync13(dirOf(resolvedPath), { recursive: true });
14677
15024
  writeFileSync14(resolvedPath, `${JSON.stringify(raw, null, 2)}
@@ -14694,7 +15041,7 @@ ${changed2.map((c) => ` - ${c}
14694
15041
  if (sub === "list" && !routeName) usage2();
14695
15042
  if (sub === "add") {
14696
15043
  try {
14697
- const a = parseAddArgs(positional.slice(2), routeNamesOf(configText), (p) => existsSync24(resolvePath(p.replace(/^~(?=\/|$)/, homedir8()))));
15044
+ const a = parseAddArgs(positional.slice(2), routeNamesOf(configText), (p) => existsSync25(resolvePath(p.replace(/^~(?=\/|$)/, homedir8()))));
14698
15045
  routeName = a.route;
14699
15046
  addPath = a.path;
14700
15047
  addName = a.name;
@@ -14709,7 +15056,7 @@ ${changed2.map((c) => ` - ${c}
14709
15056
  }
14710
15057
  const resolvedFile = resolvedPathFor(cfg.ship.stateDir, routeName);
14711
15058
  let resolvedRemotes;
14712
- if (existsSync24(resolvedFile)) {
15059
+ if (existsSync25(resolvedFile)) {
14713
15060
  try {
14714
15061
  resolvedRemotes = JSON.parse(readFileSync18(resolvedFile, "utf8"));
14715
15062
  } catch {
@@ -14726,14 +15073,14 @@ ${changed2.map((c) => ` - ${c}
14726
15073
  for (const r of repos) {
14727
15074
  const origin2 = originOf(r.dir);
14728
15075
  const row = matchRepoName(origin2, resolvedRemotes);
14729
- const note = !existsSync24(r.dir) ? "missing on disk" : row ? `Repos row: ${row}` : `no Repos row matches ${normalizeRemote(origin2) ?? "this checkout (no origin remote)"}`;
15076
+ const note = !existsSync25(r.dir) ? "missing on disk" : row ? `Repos row: ${row}` : `no Repos row matches ${normalizeRemote(origin2) ?? "this checkout (no origin remote)"}`;
14730
15077
  process.stdout.write(`${r.name} ${r.dir} ${note}
14731
15078
  `);
14732
15079
  }
14733
15080
  break;
14734
15081
  }
14735
15082
  const target = resolvePath(addPath.replace(/^~(?=\/|$)/, homedir8()));
14736
- const top = existsSync24(target) ? gitOk(target, ["rev-parse", "--show-toplevel"]) : null;
15083
+ const top = existsSync25(target) ? gitOk(target, ["rev-parse", "--show-toplevel"]) : null;
14737
15084
  if (!top) throw new ReposError(`${target} is not a git checkout`);
14738
15085
  const origin = originOf(top);
14739
15086
  const plan = planRepoAdd({
@@ -14782,7 +15129,7 @@ ${changed2.map((c) => ` - ${c}
14782
15129
  const created = await rowTracker.createRepoRow(rowPayload);
14783
15130
  process.stdout.write(`created Repos row ${String(rowPayload.name)} (${String(rowPayload.remote)}) ${created.id}
14784
15131
  `);
14785
- if (existsSync24(resolvedFile)) {
15132
+ if (existsSync25(resolvedFile)) {
14786
15133
  const raw = JSON.parse(readFileSync18(resolvedFile, "utf8"));
14787
15134
  raw.repoNames = { ...raw.repoNames, [created.id]: String(rowPayload.name) };
14788
15135
  raw.repoRemotes = { ...raw.repoRemotes, [created.id]: String(rowPayload.remote) };
@@ -15029,7 +15376,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
15029
15376
  operator = await pickOperator(found.holds);
15030
15377
  }
15031
15378
  let previous;
15032
- const hadPreviousFile = existsSync24(resolvedPath);
15379
+ const hadPreviousFile = existsSync25(resolvedPath);
15033
15380
  if (hadPreviousFile) {
15034
15381
  try {
15035
15382
  previous = JSON.parse(readFileSync18(resolvedPath, "utf8"));
@@ -15132,7 +15479,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
15132
15479
  const keyPersisted = keychainBacked || apiKeyFile !== void 0;
15133
15480
  const wizard = firstRun;
15134
15481
  const writeFirstRun = wizard !== void 0 && !dryRun && keyPersisted;
15135
- const routeDir = value("dir") ?? (writeFirstRun ? join24(DEFAULT_REPOS_BASE_PATH, found.workspaceSlug) : void 0);
15482
+ const routeDir = value("dir") ?? (writeFirstRun ? join25(DEFAULT_REPOS_BASE_PATH, found.workspaceSlug) : void 0);
15136
15483
  const routeBlock = renderConnection(
15137
15484
  found,
15138
15485
  route2,
@@ -15508,7 +15855,19 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
15508
15855
  process.exit(2);
15509
15856
  }
15510
15857
  const roleWt = positional.find((p) => p === "dev" || p === "design" || p === "qa");
15511
- const planWt = planRunWorktree(repoWt.dir, repoWt.config, ticketWt, roleWt);
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 });
15512
15871
  if (dryRun) {
15513
15872
  emit.emit(`would ${describeRunWorktreePlan(planWt)}`);
15514
15873
  break;
@@ -15654,7 +16013,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
15654
16013
  }
15655
16014
  const host = hostPlatform();
15656
16015
  const control = host === "windows" ? null : planDaemonControl(CREW_HOME, host, detectSystemd());
15657
- const unitStatus = control?.statusCommand ? spawnSync5(control.statusCommand[0], control.statusCommand.slice(1), { cwd: CREW_HOME, encoding: "utf8" }) : null;
16016
+ const unitStatus = control?.statusCommand ? spawnSync6(control.statusCommand[0], control.statusCommand.slice(1), { cwd: CREW_HOME, encoding: "utf8" }) : null;
15658
16017
  const unitRunning = unitStatus !== null && !unitStatus.error && unitStatus.status === 0;
15659
16018
  if (!unitRunning) {
15660
16019
  process.stdout.write("crew reload: no daemon unit is running \u2014 nothing to reload (the next run reads crew.yaml fresh)\n");
@@ -15666,7 +16025,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
15666
16025
  process.stdout.write("crew reload: requested \u2014 the daemon finishes its in-flight pass, then relaunches with the new config\n");
15667
16026
  if (flag("now") && control?.stopCommand && control.startCommand) {
15668
16027
  for (const cmd of [control.stopCommand, control.startCommand]) {
15669
- const res = spawnSync5(cmd[0], cmd.slice(1), { cwd: CREW_HOME, encoding: "utf8" });
16028
+ const res = spawnSync6(cmd[0], cmd.slice(1), { cwd: CREW_HOME, encoding: "utf8" });
15670
16029
  if (cmd === control.startCommand && (res.error || res.status !== 0)) {
15671
16030
  process.stderr.write(`crew reload: ${cmd.join(" ")} exited ${res.status ?? "error"}
15672
16031
  `);
@@ -15922,13 +16281,28 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
15922
16281
  );
15923
16282
  try {
15924
16283
  const evFile = eventFileFor(cfg.ship.stateDir);
15925
- const evSize = existsSync24(evFile) ? statSync9(evFile).size : 0;
16284
+ const evSize = existsSync25(evFile) ? statSync9(evFile).size : 0;
15926
16285
  process.stdout.write(
15927
16286
  evSize > cfg.ship.eventsRotateBytes * 4 ? `events log: WARN ${evFile} is ${Math.round(evSize / 1048576)} MB (> 4x eventsRotateBytes); run \`crew reap\` to rotate it
15928
16287
  ` : "events log: ok\n"
15929
16288
  );
15930
16289
  } catch {
15931
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
+ }
15932
16306
  const daemonStartedAt = readDaemonStarted(cfg.ship.stateDir);
15933
16307
  if (daemonStartedAt !== null) {
15934
16308
  const changed2 = configChangedAfter(daemonConfigPaths(cfg), daemonStartedAt);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.49.5",
3
+ "version": "0.50.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",