@tablation/crew 0.49.6 → 0.50.1

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 +601 -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([
@@ -240,7 +240,7 @@ function mapStreamLine(line) {
240
240
  if (block.type === "thinking") out.push({ kind: "thought", text: block.thinking });
241
241
  else if (block.type === "text") out.push({ kind: "text", text: block.text });
242
242
  else if (block.type === "tool_use") {
243
- out.push({ kind: "tool", tool: block.name, target: toolTarget(block.name, block.input) });
243
+ out.push({ kind: "tool", tool: block.name, target: toolTarget(block.name, block.input), input: block.input });
244
244
  }
245
245
  }
246
246
  return out;
@@ -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`. 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',
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";
@@ -8148,6 +8161,15 @@ function ticketMentions(text) {
8148
8161
  var TicketAttributionTracker = class {
8149
8162
  #tokensByTicket = /* @__PURE__ */ new Map();
8150
8163
  #current;
8164
+ /** The ticket key most recently named in the transcript, or undefined before any. */
8165
+ get current() {
8166
+ return this.#current;
8167
+ }
8168
+ /** Record the mentions in `text` without crediting tokens (a claim needs "which ticket" before the turn's usage line arrives). */
8169
+ note(text) {
8170
+ const mentions = ticketMentions(text);
8171
+ if (mentions.length > 0) this.#current = mentions[mentions.length - 1];
8172
+ }
8151
8173
  /** One assistant turn: its combined visible text (thinking + text blocks) and its own token cost. */
8152
8174
  add(text, tokens) {
8153
8175
  const mentions = ticketMentions(text);
@@ -8173,6 +8195,11 @@ var TicketAttributionTracker = class {
8173
8195
  return best;
8174
8196
  }
8175
8197
  };
8198
+ function isClaimWrite(tool, input) {
8199
+ if (!tool || !/records_controller_update$/.test(tool)) return false;
8200
+ const body = input?.body;
8201
+ return typeof body === "object" && body !== null && body.status === "in_progress";
8202
+ }
8176
8203
 
8177
8204
  // src/agent.ts
8178
8205
  var DIGEST_MAX_AGE_SECONDS = 600;
@@ -8291,6 +8318,7 @@ function planAgentRun(o) {
8291
8318
  streamPath: join14(streamsDir, `${base}.jsonl`),
8292
8319
  eventsPath: join14(streamsDir, `${base}.events.jsonl`),
8293
8320
  ticket: o.ticket,
8321
+ ticketRecordId: o.ticketRecordId ?? null,
8294
8322
  agentLog,
8295
8323
  promptVersion: o.route.resolved?.agentPersonas?.[o.role]?.historyId,
8296
8324
  promptSha
@@ -8314,6 +8342,43 @@ function describePlan(p) {
8314
8342
  `stream: ${p.streamPath}`
8315
8343
  ].join("\n");
8316
8344
  }
8345
+ var pidPathFor = (streamPath) => `${streamPath}.pid`;
8346
+ function readRunSidecar(streamPath) {
8347
+ try {
8348
+ const sc = JSON.parse(readFileSync12(pidPathFor(streamPath), "utf8"));
8349
+ return typeof sc.pid === "number" && typeof sc.spawnedAt === "string" && typeof sc.role === "string" ? sc : null;
8350
+ } catch {
8351
+ return null;
8352
+ }
8353
+ }
8354
+ function runIsAlive(sc) {
8355
+ try {
8356
+ process.kill(sc.pid, 0);
8357
+ } catch (e) {
8358
+ if (e.code !== "EPERM") return false;
8359
+ }
8360
+ const ps = spawnSync5("ps", ["-o", "lstart=", "-p", String(sc.pid)], { encoding: "utf8" });
8361
+ const started = Date.parse(ps.stdout.trim());
8362
+ if (ps.status !== 0 || Number.isNaN(started)) return ps.status === 0;
8363
+ return Math.abs(started - Date.parse(sc.spawnedAt)) < 15e3;
8364
+ }
8365
+ function identityOf(plan, adapterHarness) {
8366
+ return {
8367
+ role: plan.role,
8368
+ route: plan.route,
8369
+ ticket: plan.ticket ?? null,
8370
+ model: plan.model,
8371
+ toolchain: plan.toolchain ?? LEGACY_TOOLCHAIN_HARNESS,
8372
+ harness: adapterHarness,
8373
+ provider: plan.provider ?? LEGACY_TOOLCHAIN_PROVIDER,
8374
+ eventsPath: plan.eventsPath,
8375
+ streamPath: plan.streamPath,
8376
+ agentLog: plan.agentLog ? withoutKey(plan.agentLog) : void 0,
8377
+ promptVersion: plan.promptVersion,
8378
+ promptSha: plan.promptSha
8379
+ };
8380
+ }
8381
+ var withoutKey = ({ apiKey: _apiKey, ...rest }) => rest;
8317
8382
  function openSink(path) {
8318
8383
  try {
8319
8384
  mkdirSync6(dirname6(path), { recursive: true });
@@ -8327,172 +8392,347 @@ function endSink(sink) {
8327
8392
  sink.end();
8328
8393
  return finished(sink).catch(() => void 0);
8329
8394
  }
8395
+ var FileTail = class {
8396
+ offset = 0;
8397
+ decoder = new StringDecoder("utf8");
8398
+ path;
8399
+ constructor(path) {
8400
+ this.path = path;
8401
+ }
8402
+ poll() {
8403
+ let fd;
8404
+ try {
8405
+ fd = openSync3(this.path, "r");
8406
+ } catch {
8407
+ return "";
8408
+ }
8409
+ try {
8410
+ let text = "";
8411
+ const chunk = Buffer.alloc(64 * 1024);
8412
+ for (; ; ) {
8413
+ const n = readSync(fd, chunk, 0, chunk.length, this.offset);
8414
+ if (n <= 0) break;
8415
+ this.offset += n;
8416
+ text += this.decoder.write(chunk.subarray(0, n));
8417
+ }
8418
+ return text;
8419
+ } finally {
8420
+ closeSync3(fd);
8421
+ }
8422
+ }
8423
+ };
8424
+ var TAIL_POLL_MS = 200;
8425
+ var RunObserver = class {
8426
+ cycles = [];
8427
+ attribution = new TicketAttributionTracker();
8428
+ result;
8429
+ /** Set once the stream carries the harness's final result (turn count or cost). */
8430
+ sawResult = false;
8431
+ muted = false;
8432
+ /**
8433
+ * The label stamped on events. Starts as the poll hint and switches to the
8434
+ * ticket the run actually claims (CREW-1776); `id.ticket` stays the hint.
8435
+ */
8436
+ liveTicket;
8437
+ /** Called after `liveTicket` changes, so the sidecar can be rewritten. */
8438
+ onClaim;
8439
+ buf = "";
8440
+ mapper;
8441
+ id;
8442
+ adapter;
8443
+ eventsSink;
8444
+ emitCycle;
8445
+ constructor(id, adapter, eventsSink, emitCycle) {
8446
+ this.id = id;
8447
+ this.adapter = adapter;
8448
+ this.eventsSink = eventsSink;
8449
+ this.emitCycle = emitCycle;
8450
+ this.liveTicket = id.ticket ?? void 0;
8451
+ this.mapper = adapter.createStreamMapper?.() ?? { map: (p) => adapter.mapStreamLine(p), flush: () => [] };
8452
+ }
8453
+ feed(text) {
8454
+ this.buf += text;
8455
+ const lines = this.buf.split("\n");
8456
+ this.buf = lines.pop() ?? "";
8457
+ for (const line of lines) this.handleLine(line);
8458
+ }
8459
+ end() {
8460
+ if (this.buf) this.handleLine(this.buf);
8461
+ this.buf = "";
8462
+ try {
8463
+ this.consume(void 0, this.mapper.flush());
8464
+ } catch {
8465
+ }
8466
+ }
8467
+ handleLine(line) {
8468
+ if (!line) return;
8469
+ let parsed;
8470
+ try {
8471
+ parsed = JSON.parse(line);
8472
+ } catch {
8473
+ return;
8474
+ }
8475
+ try {
8476
+ const got = this.adapter.extractResult(parsed);
8477
+ if (got) {
8478
+ this.result = { ...this.result, ...Object.fromEntries(Object.entries(got).filter(([, v]) => v !== void 0)) };
8479
+ if (got.numTurns !== void 0 || got.costUsd !== void 0) this.sawResult = true;
8480
+ }
8481
+ this.consume(parsed, this.mapper.map(parsed));
8482
+ } catch {
8483
+ }
8484
+ }
8485
+ // Turns mapped events (and, when there is a source line, its token usage)
8486
+ // into sink writes and buffered cycles. `parsed` is undefined for events
8487
+ // the mapper flushes at end of stream, which have no line of their own.
8488
+ consume(parsed, events) {
8489
+ const plan = this.id;
8490
+ let turnText = "";
8491
+ for (const ev of events) if ((ev.kind === "thought" || ev.kind === "text") && ev.text) this.attribution.note(ev.text);
8492
+ for (const ev of events) {
8493
+ if (ev.kind === "tool" && isClaimWrite(ev.tool, ev.input)) {
8494
+ const claimed = this.attribution.current;
8495
+ if (claimed && claimed !== this.liveTicket) {
8496
+ if (!this.muted) {
8497
+ this.eventsSink?.write(`${JSON.stringify({
8498
+ at: (/* @__PURE__ */ new Date()).toISOString(),
8499
+ cycle: this.emitCycle,
8500
+ route: plan.route,
8501
+ step: "agent",
8502
+ level: "info",
8503
+ message: `ticket: ${this.liveTicket ?? "none"} \u2192 ${claimed}`,
8504
+ role: plan.role,
8505
+ ticket: claimed,
8506
+ data: { kind: "ticket", from: this.liveTicket ?? null, to: claimed }
8507
+ })}
8508
+ `);
8509
+ }
8510
+ this.liveTicket = claimed;
8511
+ if (!this.muted) this.onClaim?.();
8512
+ }
8513
+ }
8514
+ const e = {
8515
+ at: (/* @__PURE__ */ new Date()).toISOString(),
8516
+ cycle: this.emitCycle,
8517
+ route: plan.route,
8518
+ step: "agent",
8519
+ level: "info",
8520
+ message: ev.kind === "tool" ? `${ev.tool ?? "tool"}${ev.target ? ` ${ev.target}` : ""}` : ev.kind,
8521
+ role: plan.role,
8522
+ ...this.liveTicket ? { ticket: this.liveTicket } : {},
8523
+ data: { kind: ev.kind, ...ev.text ? { text: ev.text } : {}, ...ev.tool ? { tool: ev.tool } : {}, ...ev.target ? { target: ev.target } : {} }
8524
+ };
8525
+ if (!this.muted) this.eventsSink?.write(`${JSON.stringify(e)}
8526
+ `);
8527
+ if ((ev.kind === "thought" || ev.kind === "text") && ev.text) {
8528
+ turnText += `${ev.text}
8529
+ `;
8530
+ const cycleIndex = this.cycles.length;
8531
+ this.cycles.push({
8532
+ cycleIndex,
8533
+ occurredAt: e.at,
8534
+ thinking: ev.text,
8535
+ // A conversational `text` turn is tagged 'assistant' so it's
8536
+ // distinguishable from a thinking block once both share this
8537
+ // buffer (ISSUE-594) — matching the precedent
8538
+ // `agent-chat.service.ts`'s logChatRun (ISSUE-504) already set
8539
+ // for the same column. A `thought` cycle stays untagged, same
8540
+ // as before this change.
8541
+ ...ev.kind === "text" ? { role: "assistant" } : {}
8542
+ });
8543
+ if (!this.muted) {
8544
+ this.eventsSink?.write(`${JSON.stringify({
8545
+ ...e,
8546
+ message: "cycle",
8547
+ data: { kind: "cycle", cycleIndex, text: ev.text }
8548
+ })}
8549
+ `);
8550
+ }
8551
+ }
8552
+ }
8553
+ const turnTokens = parsed === void 0 ? void 0 : this.adapter.extractTurnTokens(parsed);
8554
+ if (turnTokens !== void 0) this.attribution.add(turnText, turnTokens);
8555
+ }
8556
+ };
8557
+ function writeRunMeta(id, startedAt, liveTicket, extra) {
8558
+ try {
8559
+ writeFileSync8(`${id.streamPath}.meta.json`, JSON.stringify({
8560
+ route: id.route,
8561
+ role: id.role,
8562
+ hint: id.ticket,
8563
+ ticket: liveTicket ?? id.ticket,
8564
+ startedAt,
8565
+ model: id.model,
8566
+ toolchain: id.toolchain,
8567
+ harness: id.harness,
8568
+ provider: id.provider,
8569
+ exitCode: null,
8570
+ running: true,
8571
+ ...extra
8572
+ }));
8573
+ } catch {
8574
+ }
8575
+ }
8576
+ async function finishRun(id, obs, emit, startedAt, end, agentLogKey) {
8577
+ const attributedTicket = obs.attribution.winner(obs.liveTicket);
8578
+ const identity = { toolchain: id.toolchain, harness: id.harness, provider: id.provider };
8579
+ const { result } = obs;
8580
+ try {
8581
+ writeRunMeta(id, startedAt, obs.liveTicket, {
8582
+ attributedTicket: attributedTicket ?? null,
8583
+ running: void 0,
8584
+ exitCode: end.code,
8585
+ ...end.interrupted ? { interrupted: end.interrupted } : {},
8586
+ sessionId: result?.sessionId ?? null,
8587
+ numTurns: result?.numTurns ?? null,
8588
+ totalCostUsd: result?.costUsd ?? null
8589
+ });
8590
+ } catch {
8591
+ }
8592
+ const ms2 = Date.now() - Date.parse(startedAt);
8593
+ emit.emit(end.interrupted ? "agent run interrupted" : `agent run finished (exit ${end.code ?? "unknown"})`, {
8594
+ data: {
8595
+ ms: ms2,
8596
+ code: end.code,
8597
+ ...end.interrupted ? { interrupted: end.interrupted } : {},
8598
+ ...identity,
8599
+ ...result?.sessionId ? { sessionId: result.sessionId } : {},
8600
+ ...result?.numTurns !== void 0 ? { numTurns: result.numTurns } : {},
8601
+ ...result?.costUsd !== void 0 ? { totalCostUsd: result.costUsd } : {}
8602
+ }
8603
+ });
8604
+ if (id.agentLog && agentLogKey) {
8605
+ try {
8606
+ await reportAgentRun({ ...id.agentLog, apiKey: agentLogKey }, {
8607
+ ticketReference: attributedTicket,
8608
+ outcome: end.interrupted ? "interrupted" : end.code === 0 || end.code === null && obs.sawResult ? "success" : "error",
8609
+ startedAt,
8610
+ finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
8611
+ cycles: obs.cycles,
8612
+ model: id.model,
8613
+ tokensIn: result?.inputTokens,
8614
+ tokensOut: result?.outputTokens,
8615
+ cacheReadTokens: result?.cacheReadTokens,
8616
+ cacheWriteTokens: result?.cacheWriteTokens,
8617
+ costUsd: result?.costUsd,
8618
+ harness: identity.harness,
8619
+ provider: identity.provider,
8620
+ promptVersion: id.promptVersion,
8621
+ promptSha: id.promptSha
8622
+ });
8623
+ } catch (e) {
8624
+ emit.warn(`could not report agent log: ${e.message}`, { step: "agent" });
8625
+ }
8626
+ }
8627
+ return { ms: ms2, ticket: attributedTicket };
8628
+ }
8330
8629
  function spawnAgent(plan, emit) {
8331
8630
  const adapter = adapterFor(plan.harness ?? LEGACY_TOOLCHAIN_HARNESS);
8332
8631
  const started = Date.now();
8333
8632
  const startedAt = new Date(started).toISOString();
8334
8633
  for (const w of plan.warnings ?? []) emit.warn(w, { step: "agent", role: plan.role });
8335
- const rawSink = openSink(plan.streamPath);
8336
8634
  const eventsSink = openSink(plan.eventsPath);
8635
+ const id = identityOf(plan, adapter.harness);
8337
8636
  return new Promise((resolve5, reject) => {
8637
+ let streamFd;
8638
+ try {
8639
+ mkdirSync6(dirname6(plan.streamPath), { recursive: true });
8640
+ streamFd = openSync3(plan.streamPath, "a");
8641
+ } catch (e) {
8642
+ void endSink(eventsSink);
8643
+ reject(new AgentSpawnError(`cannot open the stream file ${plan.streamPath}: ${e.message}`));
8644
+ return;
8645
+ }
8338
8646
  const child = spawn2(plan.bin, plan.args, {
8339
8647
  cwd: plan.cwd,
8340
8648
  env: { ...scrubbedEnv(process.env, adapter.harness), ...plan.setEnv },
8341
- stdio: ["pipe", "pipe", "inherit"]
8649
+ stdio: ["pipe", streamFd, "inherit"],
8650
+ detached: true
8342
8651
  });
8343
- let buf = "";
8344
- let result;
8345
- const cycles = [];
8346
- const attribution = new TicketAttributionTracker();
8347
- const mapper = adapter.createStreamMapper?.() ?? { map: (p) => adapter.mapStreamLine(p), flush: () => [] };
8348
- const handleLine = (line) => {
8349
- if (!line) return;
8350
- try {
8351
- rawSink?.write(`${line}
8352
- `);
8353
- } catch {
8354
- }
8355
- let parsed;
8356
- try {
8357
- parsed = JSON.parse(line);
8358
- } catch {
8359
- return;
8360
- }
8652
+ closeSync3(streamFd);
8653
+ if (child.pid !== void 0) {
8654
+ const sidecar = {
8655
+ pid: child.pid,
8656
+ ownerPid: process.pid,
8657
+ spawnedAt: (/* @__PURE__ */ new Date()).toISOString(),
8658
+ role: plan.role,
8659
+ route: plan.route,
8660
+ ticket: plan.ticket ?? null,
8661
+ ticketRecordId: plan.ticketRecordId ?? null,
8662
+ model: plan.model,
8663
+ toolchain: id.toolchain,
8664
+ harness: id.harness,
8665
+ provider: id.provider,
8666
+ eventsPath: plan.eventsPath,
8667
+ agentLog: id.agentLog,
8668
+ promptVersion: plan.promptVersion,
8669
+ promptSha: plan.promptSha
8670
+ };
8361
8671
  try {
8362
- const got = adapter.extractResult(parsed);
8363
- if (got) result = { ...result, ...Object.fromEntries(Object.entries(got).filter(([, v]) => v !== void 0)) };
8364
- consume(parsed, mapper.map(parsed));
8672
+ writeFileSync8(pidPathFor(plan.streamPath), JSON.stringify(sidecar));
8365
8673
  } catch {
8366
8674
  }
8367
- };
8368
- const consume = (parsed, events) => {
8369
- {
8370
- let turnText = "";
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
- });
8675
+ }
8676
+ const obs = new RunObserver(id, adapter, eventsSink, emit.cycle);
8677
+ const meta = { pid: child.pid ?? null };
8678
+ obs.onClaim = () => writeRunMeta(id, startedAt, obs.liveTicket, meta);
8679
+ if (child.pid !== void 0) writeRunMeta(id, startedAt, obs.liveTicket, meta);
8680
+ const tail = new FileTail(plan.streamPath);
8681
+ const timer = setInterval(() => obs.feed(tail.poll()), TAIL_POLL_MS);
8419
8682
  let spawnFailed = false;
8420
8683
  child.on("error", (err) => {
8421
8684
  spawnFailed = true;
8685
+ clearInterval(timer);
8422
8686
  reject(new AgentSpawnError(`cannot run ${plan.bin}: ${err.message}`));
8423
8687
  });
8424
8688
  child.on("close", async (code) => {
8425
- if (buf) handleLine(buf);
8426
- try {
8427
- consume(void 0, mapper.flush());
8428
- } catch {
8429
- }
8430
- await Promise.all([endSink(rawSink), endSink(eventsSink)]);
8689
+ clearInterval(timer);
8690
+ obs.feed(tail.poll());
8691
+ obs.end();
8692
+ await endSink(eventsSink);
8431
8693
  if (spawnFailed) return;
8432
- const attributedTicket = attribution.winner(plan.ticket);
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
- }
8694
+ const { ms: ms2 } = await finishRun(id, obs, emit, startedAt, { code: code ?? 1 }, plan.agentLog?.apiKey);
8488
8695
  resolve5({ code: code ?? 1, ms: ms2 });
8489
8696
  });
8490
- child.stdin.on("error", (err) => {
8697
+ child.stdin?.on("error", (err) => {
8491
8698
  emit.warn(`agent closed stdin before the prompt was delivered: ${err.message}`, { step: "agent" });
8492
8699
  });
8493
- child.stdin.end(plan.prompt);
8700
+ child.stdin?.end(plan.prompt);
8494
8701
  });
8495
8702
  }
8703
+ async function reattachRun(streamPath, sc, emit, o = {}) {
8704
+ const adapter = adapterFor(sc.harness);
8705
+ const isAlive = o.isAlive ?? runIsAlive;
8706
+ const id = { ...sc, streamPath };
8707
+ const eventsSink = openSink(sc.eventsPath);
8708
+ const obs = new RunObserver(id, adapter, eventsSink, emit.cycle);
8709
+ const tail = new FileTail(streamPath);
8710
+ obs.muted = true;
8711
+ obs.feed(tail.poll());
8712
+ obs.muted = false;
8713
+ const adopted = isAlive(sc);
8714
+ if (adopted) {
8715
+ o.onAdopted?.();
8716
+ const pollMs = o.pollMs ?? TAIL_POLL_MS;
8717
+ while (isAlive(sc)) {
8718
+ obs.feed(tail.poll());
8719
+ await new Promise((r) => setTimeout(r, pollMs));
8720
+ }
8721
+ }
8722
+ obs.feed(tail.poll());
8723
+ obs.end();
8724
+ await endSink(eventsSink);
8725
+ const interrupted = obs.sawResult ? void 0 : adopted ? "no-result" : "daemon-exit";
8726
+ const { ms: ms2, ticket } = await finishRun(
8727
+ id,
8728
+ obs,
8729
+ emit,
8730
+ sc.spawnedAt,
8731
+ { code: null, ...interrupted ? { interrupted } : {} },
8732
+ o.agentLogKey
8733
+ );
8734
+ return { adopted, ...interrupted ? { interrupted } : {}, ms: ms2, ticket };
8735
+ }
8496
8736
 
8497
8737
  // src/run-worktree.ts
8498
8738
  import { existsSync as existsSync14 } from "fs";
@@ -8601,6 +8841,8 @@ var describeRunWorktreePlan = (p) => {
8601
8841
  return `reusing ${p.path} (${p.branch} is already level with the remote)`;
8602
8842
  case "recut":
8603
8843
  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}`;
8844
+ case "resume-dirty":
8845
+ return `${p.path} has uncommitted changes from this seat's previous run (interrupted) \u2014 resuming`;
8604
8846
  case "leave-dirty":
8605
8847
  return `left ${p.path} alone: it has uncommitted changes (someone may be editing it by hand)`;
8606
8848
  case "leave-unpushed":
@@ -8612,7 +8854,7 @@ function newBranchName(cfg, t, role) {
8612
8854
  const { prefix } = ticketBranchContext(t);
8613
8855
  return branchRenderer(t, role)(effectiveBranchTemplate(cfg, prefix));
8614
8856
  }
8615
- function planRunWorktree(dir, cfg, t, role) {
8857
+ function planRunWorktree(dir, cfg, t, role, opts = {}) {
8616
8858
  const { remote, base } = cfg.branch;
8617
8859
  const hasRemote = remoteConfigured(dir, remote);
8618
8860
  if (hasRemote) fetchRemote(dir, remote);
@@ -8622,7 +8864,9 @@ function planRunWorktree(dir, cfg, t, role) {
8622
8864
  const baseRef = hasRemote ? `${remote}/${base}` : base;
8623
8865
  if (existing && !existing.prunable && existsSync14(existing.path)) {
8624
8866
  const branch = existing.branch;
8625
- if (status(existing.path).length > 0) return { action: "leave-dirty", path: existing.path, branch };
8867
+ if (status(existing.path).length > 0) {
8868
+ return { action: opts.resumeInterrupted ? "resume-dirty" : "leave-dirty", path: existing.path, branch };
8869
+ }
8626
8870
  if (!branch) return { action: "leave-unpushed", path: existing.path, branch: "(detached HEAD)" };
8627
8871
  if (!hasRemote) return { action: "reuse", path: existing.path, branch };
8628
8872
  if (remoteBranchExists(dir, remote, branch)) {
@@ -8649,6 +8893,8 @@ function applyRunWorktree(dir, cfg, plan) {
8649
8893
  case "leave-dirty":
8650
8894
  case "leave-unpushed":
8651
8895
  return null;
8896
+ case "resume-dirty":
8897
+ return plan.path;
8652
8898
  case "reuse":
8653
8899
  if (!worktrees(dir).some((w) => w.path === plan.path)) git(dir, ["worktree", "add", plan.path, plan.branch]);
8654
8900
  return plan.path;
@@ -10464,7 +10710,7 @@ function missingReleaserWarning(repo, release, ships, now = Date.now()) {
10464
10710
  }
10465
10711
 
10466
10712
  // src/watch.ts
10467
- import { watch as watchFile, existsSync as existsSync17, statSync as statSync6, openSync as openSync3, readSync, closeSync as closeSync3 } from "fs";
10713
+ import { watch as watchFile, existsSync as existsSync17, statSync as statSync6, openSync as openSync4, readSync as readSync2, closeSync as closeSync4 } from "fs";
10468
10714
  var E = "\x1B[";
10469
10715
  var dim = (s) => `${E}2m${s}${E}0m`;
10470
10716
  var bold = (s) => `${E}1m${s}${E}0m`;
@@ -10569,10 +10815,10 @@ function readFrom(file, from) {
10569
10815
  const size = statSync6(file).size;
10570
10816
  if (size < from) return readFrom(file, 0);
10571
10817
  if (size === from) return { events: [], offset: from };
10572
- const fd = openSync3(file, "r");
10818
+ const fd = openSync4(file, "r");
10573
10819
  const buf = Buffer.alloc(size - from);
10574
- readSync(fd, buf, 0, buf.length, from);
10575
- closeSync3(fd);
10820
+ readSync2(fd, buf, 0, buf.length, from);
10821
+ closeSync4(fd);
10576
10822
  const text = buf.toString("utf8");
10577
10823
  const lastNewline = text.lastIndexOf("\n");
10578
10824
  if (lastNewline === -1) return { events: [], offset: from };
@@ -11811,7 +12057,7 @@ function planEventsSweep(stateDir, retentionDays, now = Date.now()) {
11811
12057
  }
11812
12058
 
11813
12059
  // src/cli.ts
11814
- 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";
12060
+ import { readFileSync as readFileSync19, existsSync as existsSync25, mkdirSync as mkdirSync13, writeFileSync as writeFileSync14, unlinkSync as unlinkSync5, copyFileSync as copyFileSync2, renameSync as renameSync2, statSync as statSync9 } from "fs";
11815
12061
  import { createInterface } from "readline/promises";
11816
12062
  import { dirname as dirOf, resolve as resolvePath } from "path";
11817
12063
 
@@ -12551,8 +12797,94 @@ function syncPassengerSyncDaemons(plans, cfg, stateDir, deps = {}) {
12551
12797
  }
12552
12798
 
12553
12799
  // src/daemon.ts
12554
- import { existsSync as existsSync23, mkdirSync as mkdirSync12, readFileSync as readFileSync17, readdirSync as readdirSync8, rmSync as rmSync9, statSync as statSync8, writeFileSync as writeFileSync13 } from "fs";
12800
+ import { existsSync as existsSync24, mkdirSync as mkdirSync12, readFileSync as readFileSync18, readdirSync as readdirSync9, rmSync as rmSync9, statSync as statSync8, writeFileSync as writeFileSync13 } from "fs";
12801
+ import { join as join24 } from "path";
12802
+
12803
+ // src/interrupted.ts
12804
+ import { existsSync as existsSync23, readdirSync as readdirSync8, readFileSync as readFileSync17 } from "fs";
12555
12805
  import { join as join23 } from "path";
12806
+ var INTERRUPTED_MARKER = "<!-- crew:interrupted -->";
12807
+ function interruptedComment(b) {
12808
+ 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.";
12809
+ return `${INTERRUPTED_MARKER}
12810
+ 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.`;
12811
+ }
12812
+ var handled = /* @__PURE__ */ new Set();
12813
+ var pidAlive = (pid) => {
12814
+ try {
12815
+ process.kill(pid, 0);
12816
+ return true;
12817
+ } catch (e) {
12818
+ return e.code === "EPERM";
12819
+ }
12820
+ };
12821
+ function runFinished(streamPath) {
12822
+ const path = `${streamPath}.meta.json`;
12823
+ if (!existsSync23(path)) return false;
12824
+ try {
12825
+ return JSON.parse(readFileSync17(path, "utf8")).running !== true;
12826
+ } catch {
12827
+ return true;
12828
+ }
12829
+ }
12830
+ function orphanedStreams(o) {
12831
+ const dir = join23(o.stateDir, "streams");
12832
+ if (!existsSync23(dir)) return [];
12833
+ const ownerAlive = o.isOwnerAlive ?? pidAlive;
12834
+ const out = [];
12835
+ for (const name of readdirSync8(dir)) {
12836
+ if (!name.endsWith(".jsonl") || name.endsWith(".events.jsonl")) continue;
12837
+ const streamPath = join23(dir, name);
12838
+ if (runFinished(streamPath)) continue;
12839
+ const sidecar = readRunSidecar(streamPath);
12840
+ if (!sidecar || sidecar.route !== o.routeLabel) continue;
12841
+ if (ownerAlive(sidecar.ownerPid)) continue;
12842
+ out.push({ streamPath, sidecar });
12843
+ }
12844
+ return out;
12845
+ }
12846
+ async function reconcileRuns(o) {
12847
+ let adopted = 0;
12848
+ let interrupted = 0;
12849
+ for (const { streamPath, sidecar } of orphanedStreams(o)) {
12850
+ if (handled.has(streamPath)) continue;
12851
+ handled.add(streamPath);
12852
+ const isAlive = o.isAlive ?? runIsAlive;
12853
+ const lock = isAlive(sidecar) ? o.state.acquireRun(sidecar.role, o.maxConcurrentAgents) : null;
12854
+ const work = (async () => {
12855
+ try {
12856
+ const result = await reattachRun(streamPath, sidecar, o.emit, {
12857
+ agentLogKey: await o.agentLogKey?.(sidecar.role).catch(() => void 0),
12858
+ isAlive: o.isAlive,
12859
+ pollMs: o.pollMs,
12860
+ onAdopted: () => o.emit.emit(`adopted ${sidecar.role} run (pid ${sidecar.pid}) left by a previous crew process`, { step: "agent", role: sidecar.role })
12861
+ });
12862
+ if (result.adopted) adopted++;
12863
+ else interrupted++;
12864
+ try {
12865
+ await o.afterRun?.(sidecar, result);
12866
+ } catch (e) {
12867
+ o.emit.warn(`could not follow up on ${sidecar.role} run ${streamPath}: ${e.message}`, { step: "agent" });
12868
+ }
12869
+ } catch (e) {
12870
+ o.emit.warn(`could not reattach ${sidecar.role} run ${streamPath}: ${e.message}`, { step: "agent" });
12871
+ } finally {
12872
+ if (lock?.ok) lock.release();
12873
+ }
12874
+ })();
12875
+ if (isAlive(sidecar)) {
12876
+ if (o.inFlight) {
12877
+ o.inFlight.add(work);
12878
+ void work.finally(() => o.inFlight.delete(work));
12879
+ }
12880
+ } else {
12881
+ await work;
12882
+ }
12883
+ }
12884
+ return { adopted, interrupted };
12885
+ }
12886
+
12887
+ // src/daemon.ts
12556
12888
  var DEFAULT_FLOOR_MS = 5e3;
12557
12889
  var DEFAULT_CEILING_MS = INTERVAL_SECONDS * 1e3;
12558
12890
  var defaultSleep = (ms2) => new Promise((resolve5) => setTimeout(resolve5, ms2));
@@ -12601,8 +12933,8 @@ function latestMtime(path) {
12601
12933
  if (stat.isFile()) return stat.mtimeMs;
12602
12934
  if (!stat.isDirectory()) return 0;
12603
12935
  let latest = stat.mtimeMs;
12604
- for (const entry of readdirSync8(path, { withFileTypes: true })) {
12605
- latest = Math.max(latest, latestMtime(join23(path, entry.name)));
12936
+ for (const entry of readdirSync9(path, { withFileTypes: true })) {
12937
+ latest = Math.max(latest, latestMtime(join24(path, entry.name)));
12606
12938
  }
12607
12939
  return latest;
12608
12940
  }
@@ -12611,13 +12943,13 @@ function computeTreeSignature(paths) {
12611
12943
  }
12612
12944
  function makeStaleChecker(paths, opts = {}) {
12613
12945
  const initial = computeTreeSignature(paths);
12614
- return () => computeTreeSignature(paths) !== initial || opts.reloadSentinel !== void 0 && existsSync23(opts.reloadSentinel);
12946
+ return () => computeTreeSignature(paths) !== initial || opts.reloadSentinel !== void 0 && existsSync24(opts.reloadSentinel);
12615
12947
  }
12616
12948
  function reloadSentinelFor(stateDir) {
12617
- return join23(stateDir, "reload-requested");
12949
+ return join24(stateDir, "reload-requested");
12618
12950
  }
12619
12951
  function daemonStampFor(stateDir) {
12620
- return join23(stateDir, "daemon.started");
12952
+ return join24(stateDir, "daemon.started");
12621
12953
  }
12622
12954
  function noteDaemonStarted(stateDir, now = Date.now()) {
12623
12955
  mkdirSync12(stateDir, { recursive: true });
@@ -12627,7 +12959,7 @@ function noteDaemonStarted(stateDir, now = Date.now()) {
12627
12959
  }
12628
12960
  function readDaemonStarted(stateDir) {
12629
12961
  try {
12630
- const v = JSON.parse(readFileSync17(daemonStampFor(stateDir), "utf8")).startedAt;
12962
+ const v = JSON.parse(readFileSync18(daemonStampFor(stateDir), "utf8")).startedAt;
12631
12963
  return typeof v === "number" ? v : null;
12632
12964
  } catch {
12633
12965
  return null;
@@ -12680,6 +13012,11 @@ async function runOnePass(o) {
12680
13012
  const spawn5 = o.spawnRoleAgent ?? runRoleAgent;
12681
13013
  const breaker = o.spawnBreaker ?? new SpawnBreaker();
12682
13014
  const ctx = { route: o.route, ship: o.ship, state: o.state, emit, getSessionStore: o.getSessionStore };
13015
+ try {
13016
+ await (o.reconcile ?? reconcileForRoute)(ctx, o);
13017
+ } catch (e) {
13018
+ emit.warn(`could not reconcile earlier agent runs: ${e.message}`, { step: "agent" });
13019
+ }
12683
13020
  const decision = await decide(ctx);
12684
13021
  o.state.attention(o.route.route).persist(decision.attention);
12685
13022
  if (decision.sweep.length) {
@@ -12777,6 +13114,54 @@ async function publishShipAttention(o) {
12777
13114
  } catch {
12778
13115
  }
12779
13116
  }
13117
+ async function reconcileForRoute(ctx, o) {
13118
+ const { route, ship, emit } = ctx;
13119
+ await reconcileRuns({
13120
+ stateDir: ship.stateDir,
13121
+ routeLabel: routeSlug(route.route),
13122
+ emit,
13123
+ state: o.state,
13124
+ maxConcurrentAgents: o.maxConcurrentAgents,
13125
+ inFlight: o.inFlight,
13126
+ agentLogKey: async (role) => (await resolveSeatKey(route, role, ctx.getSessionStore)).apiKey,
13127
+ afterRun: async (sc, result) => {
13128
+ const identity = await resolveSeatKey(route, sc.role, ctx.getSessionStore);
13129
+ const tracker = new Tracker(route, ship, { apiKey: identity.apiKey });
13130
+ const memberId = route.resolved?.seats[sc.role];
13131
+ try {
13132
+ if (!sc.ticketRecordId) return;
13133
+ if (result.interrupted) {
13134
+ if (!memberId) return;
13135
+ const t = await tracker.ticket(sc.ticketRecordId);
13136
+ const target = repoTargetFor(route, t.repo_id);
13137
+ const num = String(t[tracker.contract.columns.key] ?? "").replace(/^\D+/, "");
13138
+ const wt = target && num ? worktreeForNumber(target.dir, num) : null;
13139
+ let dirty = null;
13140
+ try {
13141
+ if (wt) dirty = status(wt.path).length > 0;
13142
+ } catch {
13143
+ }
13144
+ await tracker.postEvent(sc.ticketRecordId, interruptedComment({
13145
+ branch: wt?.branch ?? null,
13146
+ dirty,
13147
+ reason: result.interrupted === "daemon-exit" ? "the crew daemon exited" : "it exited without a result"
13148
+ }), memberId, "interrupted");
13149
+ } else {
13150
+ await guardRunHandoff(route, ship, tracker, sc.role, sc.ticketRecordId, emit, sc.spawnedAt);
13151
+ }
13152
+ } finally {
13153
+ if (memberId) {
13154
+ await tracker.setCrewStatus(memberId, "idle").catch((e) => {
13155
+ emit.warn(`could not clear crew status: ${e.message}`, { step: "agent" });
13156
+ });
13157
+ }
13158
+ await tracker.beatIdle(ship.name).catch((e) => {
13159
+ emit.warn(`could not beat ship idle: ${e.message}`, { step: "agent" });
13160
+ });
13161
+ }
13162
+ }
13163
+ });
13164
+ }
12780
13165
  async function runRoleAgent(role, decision, ctx) {
12781
13166
  const { route, ship, emit } = ctx;
12782
13167
  const identity = await resolveSeatKey(route, role, ctx.getSessionStore);
@@ -12866,6 +13251,7 @@ async function runRoleAgent(role, decision, ctx) {
12866
13251
  cycle: emit.cycle,
12867
13252
  ticket: ticketHint,
12868
13253
  repo: ticketRepo,
13254
+ ticketRecordId: workingId,
12869
13255
  ...personaPromptOptions(await fetchPersonaPrompt(route, role, { userAgent: ship.userAgent })),
12870
13256
  skillOverrides: await fetchSkillOverrides(route, { userAgent: ship.userAgent }),
12871
13257
  agentPersona: await fetchSeatPersona(route, role, { userAgent: ship.userAgent }),
@@ -13442,14 +13828,14 @@ if (command === "passenger-sync-daemon") {
13442
13828
  }
13443
13829
  if (host === "linux" && hasSystemd) {
13444
13830
  const label = labelFor(CREW_HOME, "run");
13445
- const staleTimer = join24(homedir8(), ".config", "systemd", "user", `${label}.timer`);
13446
- if (existsSync24(staleTimer)) {
13831
+ const staleTimer = join25(homedir8(), ".config", "systemd", "user", `${label}.timer`);
13832
+ if (existsSync25(staleTimer)) {
13447
13833
  process.stdout.write(
13448
13834
  `removing stale run timer from before the persistent-service cutover: ${staleTimer}${dryRun ? " (dry run)" : ""}
13449
13835
  `
13450
13836
  );
13451
13837
  if (!dryRun) {
13452
- spawnSync5("systemctl", ["--user", "disable", "--now", `${label}.timer`], { cwd: CREW_HOME });
13838
+ spawnSync6("systemctl", ["--user", "disable", "--now", `${label}.timer`], { cwd: CREW_HOME });
13453
13839
  try {
13454
13840
  unlinkSync5(staleTimer);
13455
13841
  } catch {
@@ -13469,7 +13855,7 @@ if (command === "passenger-sync-daemon") {
13469
13855
  const tracker = new Tracker(c, cfg.ship);
13470
13856
  problems.push(...validateContract(tracker.contract).map((p) => `contract: ${p}`));
13471
13857
  for (const r of await resolvedRepos(c)) {
13472
- if (!existsSync24(r.dir)) problems.push(`repo ${r.name}: ${r.dir} is missing on disk`);
13858
+ if (!existsSync25(r.dir)) problems.push(`repo ${r.name}: ${r.dir} is missing on disk`);
13473
13859
  if (!satisfies(host, r.config.platform)) problems.push(`repo ${r.name}: requires ${r.config.platform}, this host is ${host}`);
13474
13860
  problems.push(...validateEffective(r.config).map((p) => `repo ${r.name}: ${p}`));
13475
13861
  }
@@ -13487,8 +13873,8 @@ if (command === "passenger-sync-daemon") {
13487
13873
  }
13488
13874
  async function doctorFix(o) {
13489
13875
  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) => existsSync24(p);
13491
- const crontab = o.host === "linux" ? spawnSync5("crontab", ["-l"], { encoding: "utf8" }).stdout ?? "" : "";
13876
+ const unitExists = (p) => existsSync25(p);
13877
+ const crontab = o.host === "linux" ? spawnSync6("crontab", ["-l"], { encoding: "utf8" }).stdout ?? "" : "";
13492
13878
  const schedulerAbsent = o.host !== "windows" ? !schedulerInstalled(planInstall(cfg.ship, CREW_HOME, o.host, detectSystemd(), "run"), unitExists, crontab) : true;
13493
13879
  const plan = planFix({ routes, schedulerAbsent, host: o.host });
13494
13880
  for (const n of plan.notes) process.stdout.write(`fix: ${n}
@@ -13504,7 +13890,7 @@ if (command === "passenger-sync-daemon") {
13504
13890
  for (const a of plan.actions) {
13505
13891
  if (a.kind === "enable") {
13506
13892
  if (prompter && !await prompter.confirm(`Route ${a.route} passes every check \u2014 set enabled: true in ${cfg.configFile}?`, false)) continue;
13507
- writeFileSync14(cfg.configFile, planEnable(readFileSync18(cfg.configFile, "utf8"), a.route));
13893
+ writeFileSync14(cfg.configFile, planEnable(readFileSync19(cfg.configFile, "utf8"), a.route));
13508
13894
  enabledSound.add(a.route);
13509
13895
  changed2.push(`enabled route ${a.route} in ${cfg.configFile}`);
13510
13896
  } else {
@@ -14439,7 +14825,7 @@ ${changed2.map((c) => ` - ${c}
14439
14825
  }
14440
14826
  let failed = false;
14441
14827
  for (const cmd2 of commands) {
14442
- const res2 = spawnSync5(cmd2[0], cmd2.slice(1), { cwd: CREW_HOME, encoding: "utf8" });
14828
+ const res2 = spawnSync6(cmd2[0], cmd2.slice(1), { cwd: CREW_HOME, encoding: "utf8" });
14443
14829
  if (res2.stdout) process.stdout.write(res2.stdout);
14444
14830
  if (res2.stderr) process.stderr.write(res2.stderr);
14445
14831
  if (cmd2 === control.startCommand && (res2.error || res2.status !== 0)) {
@@ -14457,7 +14843,7 @@ ${changed2.map((c) => ` - ${c}
14457
14843
  `);
14458
14844
  break;
14459
14845
  }
14460
- const res = spawnSync5(cmd[0], cmd.slice(1), { cwd: CREW_HOME, encoding: "utf8" });
14846
+ const res = spawnSync6(cmd[0], cmd.slice(1), { cwd: CREW_HOME, encoding: "utf8" });
14461
14847
  if (res.stdout) process.stdout.write(res.stdout);
14462
14848
  if (res.stderr) process.stderr.write(res.stderr);
14463
14849
  if (daemonSub !== "status" && (res.error || res.status !== 0)) {
@@ -14480,7 +14866,7 @@ ${changed2.map((c) => ` - ${c}
14480
14866
  process.exit(1);
14481
14867
  }
14482
14868
  requireArmed("run the daemon loop");
14483
- const watchPaths = isCompiledBinary(import.meta.url) ? [process.execPath] : [join24(CREW_HOME, "src"), join24(CREW_HOME, "dist"), join24(CREW_HOME, "bin")];
14869
+ const watchPaths = isCompiledBinary(import.meta.url) ? [process.execPath] : [join25(CREW_HOME, "src"), join25(CREW_HOME, "dist"), join25(CREW_HOME, "bin")];
14484
14870
  const configPaths = daemonConfigPaths(cfg);
14485
14871
  watchPaths.push(...configPaths);
14486
14872
  noteDaemonStarted(cfg.ship.stateDir);
@@ -14642,7 +15028,7 @@ ${changed2.map((c) => ` - ${c}
14642
15028
  `);
14643
15029
  if (!dryRun) {
14644
15030
  const resolvedPath = resolvedPathFor(cfg.ship.stateDir, target.route);
14645
- const raw = existsSync24(resolvedPath) ? JSON.parse(readFileSync18(resolvedPath, "utf8")) : {};
15031
+ const raw = existsSync25(resolvedPath) ? JSON.parse(readFileSync19(resolvedPath, "utf8")) : {};
14646
15032
  raw.agentPersonas = personaResult.agentPersonas;
14647
15033
  raw.agentSkills = skillResult.agentSkills;
14648
15034
  if (personaResult.releaseAgentId) raw.releaseAgentId = personaResult.releaseAgentId;
@@ -14702,7 +15088,7 @@ ${changed2.map((c) => ` - ${c}
14702
15088
  `);
14703
15089
  if (!dryRun) {
14704
15090
  const resolvedPath = resolvedPathFor(cfg.ship.stateDir, target.route);
14705
- const raw = existsSync24(resolvedPath) ? JSON.parse(readFileSync18(resolvedPath, "utf8")) : {};
15091
+ const raw = existsSync25(resolvedPath) ? JSON.parse(readFileSync19(resolvedPath, "utf8")) : {};
14706
15092
  raw.agentSkills = result.agentSkills;
14707
15093
  mkdirSync13(dirOf(resolvedPath), { recursive: true });
14708
15094
  writeFileSync14(resolvedPath, `${JSON.stringify(raw, null, 2)}
@@ -14718,14 +15104,14 @@ ${changed2.map((c) => ` - ${c}
14718
15104
  process.exit(2);
14719
15105
  };
14720
15106
  if (sub !== "add" && sub !== "list") usage2();
14721
- const configText = readFileSync18(cfg.configFile, "utf8");
15107
+ const configText = readFileSync19(cfg.configFile, "utf8");
14722
15108
  let routeName = positional[2];
14723
15109
  let addPath = ".";
14724
15110
  let addName;
14725
15111
  if (sub === "list" && !routeName) usage2();
14726
15112
  if (sub === "add") {
14727
15113
  try {
14728
- const a = parseAddArgs(positional.slice(2), routeNamesOf(configText), (p) => existsSync24(resolvePath(p.replace(/^~(?=\/|$)/, homedir8()))));
15114
+ const a = parseAddArgs(positional.slice(2), routeNamesOf(configText), (p) => existsSync25(resolvePath(p.replace(/^~(?=\/|$)/, homedir8()))));
14729
15115
  routeName = a.route;
14730
15116
  addPath = a.path;
14731
15117
  addName = a.name;
@@ -14740,9 +15126,9 @@ ${changed2.map((c) => ` - ${c}
14740
15126
  }
14741
15127
  const resolvedFile = resolvedPathFor(cfg.ship.stateDir, routeName);
14742
15128
  let resolvedRemotes;
14743
- if (existsSync24(resolvedFile)) {
15129
+ if (existsSync25(resolvedFile)) {
14744
15130
  try {
14745
- resolvedRemotes = JSON.parse(readFileSync18(resolvedFile, "utf8"));
15131
+ resolvedRemotes = JSON.parse(readFileSync19(resolvedFile, "utf8"));
14746
15132
  } catch {
14747
15133
  }
14748
15134
  }
@@ -14757,14 +15143,14 @@ ${changed2.map((c) => ` - ${c}
14757
15143
  for (const r of repos) {
14758
15144
  const origin2 = originOf(r.dir);
14759
15145
  const row = matchRepoName(origin2, resolvedRemotes);
14760
- const note = !existsSync24(r.dir) ? "missing on disk" : row ? `Repos row: ${row}` : `no Repos row matches ${normalizeRemote(origin2) ?? "this checkout (no origin remote)"}`;
15146
+ const note = !existsSync25(r.dir) ? "missing on disk" : row ? `Repos row: ${row}` : `no Repos row matches ${normalizeRemote(origin2) ?? "this checkout (no origin remote)"}`;
14761
15147
  process.stdout.write(`${r.name} ${r.dir} ${note}
14762
15148
  `);
14763
15149
  }
14764
15150
  break;
14765
15151
  }
14766
15152
  const target = resolvePath(addPath.replace(/^~(?=\/|$)/, homedir8()));
14767
- const top = existsSync24(target) ? gitOk(target, ["rev-parse", "--show-toplevel"]) : null;
15153
+ const top = existsSync25(target) ? gitOk(target, ["rev-parse", "--show-toplevel"]) : null;
14768
15154
  if (!top) throw new ReposError(`${target} is not a git checkout`);
14769
15155
  const origin = originOf(top);
14770
15156
  const plan = planRepoAdd({
@@ -14813,8 +15199,8 @@ ${changed2.map((c) => ` - ${c}
14813
15199
  const created = await rowTracker.createRepoRow(rowPayload);
14814
15200
  process.stdout.write(`created Repos row ${String(rowPayload.name)} (${String(rowPayload.remote)}) ${created.id}
14815
15201
  `);
14816
- if (existsSync24(resolvedFile)) {
14817
- const raw = JSON.parse(readFileSync18(resolvedFile, "utf8"));
15202
+ if (existsSync25(resolvedFile)) {
15203
+ const raw = JSON.parse(readFileSync19(resolvedFile, "utf8"));
14818
15204
  raw.repoNames = { ...raw.repoNames, [created.id]: String(rowPayload.name) };
14819
15205
  raw.repoRemotes = { ...raw.repoRemotes, [created.id]: String(rowPayload.remote) };
14820
15206
  writeFileSync14(resolvedFile, `${JSON.stringify(raw, null, 2)}
@@ -15060,10 +15446,10 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
15060
15446
  operator = await pickOperator(found.holds);
15061
15447
  }
15062
15448
  let previous;
15063
- const hadPreviousFile = existsSync24(resolvedPath);
15449
+ const hadPreviousFile = existsSync25(resolvedPath);
15064
15450
  if (hadPreviousFile) {
15065
15451
  try {
15066
- previous = JSON.parse(readFileSync18(resolvedPath, "utf8"));
15452
+ previous = JSON.parse(readFileSync19(resolvedPath, "utf8"));
15067
15453
  } catch {
15068
15454
  }
15069
15455
  }
@@ -15163,7 +15549,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
15163
15549
  const keyPersisted = keychainBacked || apiKeyFile !== void 0;
15164
15550
  const wizard = firstRun;
15165
15551
  const writeFirstRun = wizard !== void 0 && !dryRun && keyPersisted;
15166
- const routeDir = value("dir") ?? (writeFirstRun ? join24(DEFAULT_REPOS_BASE_PATH, found.workspaceSlug) : void 0);
15552
+ const routeDir = value("dir") ?? (writeFirstRun ? join25(DEFAULT_REPOS_BASE_PATH, found.workspaceSlug) : void 0);
15167
15553
  const routeBlock = renderConnection(
15168
15554
  found,
15169
15555
  route2,
@@ -15539,7 +15925,19 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
15539
15925
  process.exit(2);
15540
15926
  }
15541
15927
  const roleWt = positional.find((p) => p === "dev" || p === "design" || p === "qa");
15542
- const planWt = planRunWorktree(repoWt.dir, repoWt.config, ticketWt, roleWt);
15928
+ let resumeWt = false;
15929
+ try {
15930
+ const trWt = new Tracker(routeWt, cfg.ship);
15931
+ const heldWt = Object.values(routeWt.resolved?.seats ?? {}).includes(ticketWt[trWt.contract.columns.assignee]);
15932
+ if (heldWt) {
15933
+ const cut = await trWt.latestEvent(ticketWt.id, "interrupted");
15934
+ const pushed = await trWt.latestEvent(ticketWt.id, "pushed");
15935
+ const at = (e) => e?.occurred_at ?? e?.created_at ?? "";
15936
+ resumeWt = cut !== void 0 && at(cut) > at(pushed);
15937
+ }
15938
+ } catch {
15939
+ }
15940
+ const planWt = planRunWorktree(repoWt.dir, repoWt.config, ticketWt, roleWt, { resumeInterrupted: resumeWt });
15543
15941
  if (dryRun) {
15544
15942
  emit.emit(`would ${describeRunWorktreePlan(planWt)}`);
15545
15943
  break;
@@ -15685,7 +16083,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
15685
16083
  }
15686
16084
  const host = hostPlatform();
15687
16085
  const control = host === "windows" ? null : planDaemonControl(CREW_HOME, host, detectSystemd());
15688
- const unitStatus = control?.statusCommand ? spawnSync5(control.statusCommand[0], control.statusCommand.slice(1), { cwd: CREW_HOME, encoding: "utf8" }) : null;
16086
+ const unitStatus = control?.statusCommand ? spawnSync6(control.statusCommand[0], control.statusCommand.slice(1), { cwd: CREW_HOME, encoding: "utf8" }) : null;
15689
16087
  const unitRunning = unitStatus !== null && !unitStatus.error && unitStatus.status === 0;
15690
16088
  if (!unitRunning) {
15691
16089
  process.stdout.write("crew reload: no daemon unit is running \u2014 nothing to reload (the next run reads crew.yaml fresh)\n");
@@ -15697,7 +16095,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
15697
16095
  process.stdout.write("crew reload: requested \u2014 the daemon finishes its in-flight pass, then relaunches with the new config\n");
15698
16096
  if (flag("now") && control?.stopCommand && control.startCommand) {
15699
16097
  for (const cmd of [control.stopCommand, control.startCommand]) {
15700
- const res = spawnSync5(cmd[0], cmd.slice(1), { cwd: CREW_HOME, encoding: "utf8" });
16098
+ const res = spawnSync6(cmd[0], cmd.slice(1), { cwd: CREW_HOME, encoding: "utf8" });
15701
16099
  if (cmd === control.startCommand && (res.error || res.status !== 0)) {
15702
16100
  process.stderr.write(`crew reload: ${cmd.join(" ")} exited ${res.status ?? "error"}
15703
16101
  `);
@@ -15743,7 +16141,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
15743
16141
  }
15744
16142
  case "log": {
15745
16143
  try {
15746
- const text = readFileSync18(cfg.ship.logFile, "utf8").split("\n");
16144
+ const text = readFileSync19(cfg.ship.logFile, "utf8").split("\n");
15747
16145
  process.stdout.write(`${text.slice(-(Number(value("tail")) || 40)).join("\n")}
15748
16146
  `);
15749
16147
  } catch {
@@ -15953,13 +16351,28 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
15953
16351
  );
15954
16352
  try {
15955
16353
  const evFile = eventFileFor(cfg.ship.stateDir);
15956
- const evSize = existsSync24(evFile) ? statSync9(evFile).size : 0;
16354
+ const evSize = existsSync25(evFile) ? statSync9(evFile).size : 0;
15957
16355
  process.stdout.write(
15958
16356
  evSize > cfg.ship.eventsRotateBytes * 4 ? `events log: WARN ${evFile} is ${Math.round(evSize / 1048576)} MB (> 4x eventsRotateBytes); run \`crew reap\` to rotate it
15959
16357
  ` : "events log: ok\n"
15960
16358
  );
15961
16359
  } catch {
15962
16360
  }
16361
+ if (host !== "windows") {
16362
+ const unitPlan = planInstall(cfg.ship, CREW_HOME, host, detectSystemd(), "run");
16363
+ const abandons = unitAbandonsAgents(unitPlan, (p) => {
16364
+ try {
16365
+ return readFileSync19(p, "utf8");
16366
+ } catch {
16367
+ return null;
16368
+ }
16369
+ });
16370
+ if (abandons === false) {
16371
+ process.stdout.write(
16372
+ "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"
16373
+ );
16374
+ }
16375
+ }
15963
16376
  const daemonStartedAt = readDaemonStarted(cfg.ship.stateDir);
15964
16377
  if (daemonStartedAt !== null) {
15965
16378
  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.6",
3
+ "version": "0.50.1",
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",