@tablation/crew 0.49.6 → 0.50.0

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