@tablation/crew 0.94.0 → 0.95.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/crew.example.yaml CHANGED
@@ -56,6 +56,7 @@ ship:
56
56
  # deep: { model: claude-opus-5-5 }
57
57
  # args: [] # appended to the harness argv
58
58
  # env: {} # added to the session environment
59
+ # maxRunMinutes: 240 # wall-clock limit per agent run (default 240; 0 = none); a route's maxRunMinutes overrides it (CREW-2786)
59
60
  # gemini: { harness: gemini, bin: gemini, model: gemini-2.5-pro }
60
61
 
61
62
  # shell: bash # defaults per platform
package/dist/cli.js CHANGED
@@ -2665,7 +2665,8 @@ var ROUTE_KEYS = /* @__PURE__ */ new Set([
2665
2665
  "promptSet",
2666
2666
  "hostPassengers",
2667
2667
  "hostPeriscopes",
2668
- "toolchain"
2668
+ "toolchain",
2669
+ "maxRunMinutes"
2669
2670
  ]);
2670
2671
  function parseWorktreeName(v, where, file) {
2671
2672
  const problem = typeof v === "string" ? worktreeNameProblem(v) : "must be a string";
@@ -3075,7 +3076,21 @@ Create the first of those:
3075
3076
  function expandCommand(cmd, base) {
3076
3077
  return cmd.includes("/") || cmd.startsWith("~") ? expand(cmd, base) : cmd;
3077
3078
  }
3078
- var TOOLCHAIN_KEYS = /* @__PURE__ */ new Set(["harness", "bin", "launcher", "launcherBin", "model", "tiers", "args", "env", "maxThinkingTokens", "auth"]);
3079
+ var TOOLCHAIN_KEYS = /* @__PURE__ */ new Set(["harness", "bin", "launcher", "launcherBin", "model", "tiers", "args", "env", "maxThinkingTokens", "auth", "maxRunMinutes"]);
3080
+ var MAX_RUN_MINUTES_CEILING = Math.floor((2 ** 31 - 1) / 6e4);
3081
+ function parseMaxRunMinutes(raw, where, missing) {
3082
+ if (raw === void 0) return void 0;
3083
+ const n = Number(raw);
3084
+ if (!Number.isInteger(n) || n < 0) {
3085
+ missing.add(`${where} must be a non-negative integer number of minutes, 0 for no limit (got "${String(raw)}")`);
3086
+ return void 0;
3087
+ }
3088
+ if (n > MAX_RUN_MINUTES_CEILING) {
3089
+ missing.add(`${where} is too large (${n}); the most it can be is ${MAX_RUN_MINUTES_CEILING} minutes \u2014 use 0 for no limit`);
3090
+ return void 0;
3091
+ }
3092
+ return n;
3093
+ }
3079
3094
  function parsePair(raw, missing) {
3080
3095
  if (raw === void 0) return void 0;
3081
3096
  if (!Array.isArray(raw)) {
@@ -3132,6 +3147,7 @@ function parseToolchains(raw, base, missing, file) {
3132
3147
  if (!Number.isInteger(maxThinkingTokens) || maxThinkingTokens < 0) {
3133
3148
  missing.add(`${where}.maxThinkingTokens must be a non-negative integer (got "${entryRaw.maxThinkingTokens}")`);
3134
3149
  }
3150
+ const maxRunMinutes = parseMaxRunMinutes(entryRaw.maxRunMinutes, `${where}.maxRunMinutes`, missing);
3135
3151
  let auth;
3136
3152
  if (entryRaw.auth !== void 0) {
3137
3153
  if (entryRaw.auth !== "subscription" && entryRaw.auth !== "inherit") {
@@ -3153,7 +3169,8 @@ function parseToolchains(raw, base, missing, file) {
3153
3169
  tiers,
3154
3170
  args: Array.isArray(args) ? args.map(String) : [],
3155
3171
  env: Object.fromEntries(Object.entries(env).map(([k, v]) => [k, String(v)])),
3156
- maxThinkingTokens
3172
+ maxThinkingTokens,
3173
+ ...maxRunMinutes !== void 0 ? { maxRunMinutes } : {}
3157
3174
  });
3158
3175
  }
3159
3176
  return out;
@@ -3161,6 +3178,7 @@ function parseToolchains(raw, base, missing, file) {
3161
3178
  function parseOneRoute(c, where, file, base, stateDir, raw, missing, crewHome, toolchainNames) {
3162
3179
  rejectUnknownKeys(c, ROUTE_KEYS, `${where}`, file);
3163
3180
  const toolchain = c?.toolchain === void 0 ? void 0 : String(c.toolchain);
3181
+ const maxRunMinutes = parseMaxRunMinutes(c?.maxRunMinutes, `${where}.maxRunMinutes`, missing);
3164
3182
  if (toolchain !== void 0 && !toolchainNames.has(toolchain)) {
3165
3183
  missing.add(`${where}.toolchain "${toolchain}" is not defined in ship.toolchains (have: ${[...toolchainNames].join(", ")})`);
3166
3184
  }
@@ -3224,6 +3242,7 @@ function parseOneRoute(c, where, file, base, stateDir, raw, missing, crewHome, t
3224
3242
  repoOverrides,
3225
3243
  worktreePrefix: c?.worktreePrefix ? String(c.worktreePrefix) : void 0,
3226
3244
  ...toolchain !== void 0 ? { toolchain } : {},
3245
+ ...maxRunMinutes !== void 0 ? { maxRunMinutes } : {},
3227
3246
  promptsDir: resolvePromptsDir(c?.promptSet ? String(c.promptSet) : void 0, crewHome, base),
3228
3247
  weight,
3229
3248
  baseUrl: String(c?.baseUrl ?? raw.ship?.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, ""),
@@ -7563,6 +7582,28 @@ var Tracker = class {
7563
7582
  if (!auth.commentShip) missing.push(`Comments.${this.contract.comments.ship}`);
7564
7583
  return missing;
7565
7584
  }
7585
+ /**
7586
+ * The runtime-map columns (ISSUE-2791) this workspace's template has not
7587
+ * taken yet, as `table.column` names for `crew doctor`. Each is read as its
7588
+ * default when absent, so this is informational: `Repos.forge_host`
7589
+ * (default `github.com`), `Ships.kind` (`developer_machine`),
7590
+ * `Ships.instance_class` and `Ships.relay_url` (blank). A table the route
7591
+ * does not have is skipped, not reported.
7592
+ */
7593
+ async missingRuntimeColumns() {
7594
+ const wanted = [
7595
+ [this.route.resolved?.reposModelId, "Repos", ["forge_host"]],
7596
+ [this.route.resolved?.shipsModelId, "Ships", ["kind", "instance_class", "relay_url"]]
7597
+ ];
7598
+ const missing = [];
7599
+ for (const [modelId, label, columns] of wanted) {
7600
+ if (!modelId) continue;
7601
+ const have = await this.columnNames(modelId);
7602
+ if (have.size === 0) continue;
7603
+ for (const c of columns) if (!have.has(c)) missing.push(`${label}.${c}`);
7604
+ }
7605
+ return missing;
7606
+ }
7566
7607
  /**
7567
7608
  * The public `/v/<slug>/r/<token>` link for one ticket (ISSUE-928) —
7568
7609
  * fetch-or-create via `POST /views/:viewId/record-links`, so a repeated
@@ -9190,6 +9231,55 @@ function skillMarkerWarnings(promptsDir, personaPrompts) {
9190
9231
  return out;
9191
9232
  }
9192
9233
 
9234
+ // src/run-limit.ts
9235
+ var DEFAULT_MAX_RUN_MINUTES = 240;
9236
+ var KILL_GRACE_MS = 5e3;
9237
+ var WALL_CLOCK_REASON = "wall-clock";
9238
+ function resolveMaxRunMinutes(route2, toolchain) {
9239
+ return route2 ?? toolchain ?? DEFAULT_MAX_RUN_MINUTES;
9240
+ }
9241
+ function deadlineFor(spawnedAt, maxRunMinutes) {
9242
+ if (!maxRunMinutes || maxRunMinutes <= 0) return void 0;
9243
+ const t = Date.parse(spawnedAt);
9244
+ return Number.isNaN(t) ? void 0 : new Date(t + maxRunMinutes * 6e4).toISOString();
9245
+ }
9246
+ function msUntilDeadline(deadline, now = Date.now()) {
9247
+ if (!deadline) return void 0;
9248
+ const t = Date.parse(deadline);
9249
+ return Number.isNaN(t) ? void 0 : t - now;
9250
+ }
9251
+ async function killRunGroup(pid, o = {}) {
9252
+ const alive = o.isAlive ?? ((p) => {
9253
+ try {
9254
+ process.kill(p, 0);
9255
+ return true;
9256
+ } catch (e) {
9257
+ return e.code === "EPERM";
9258
+ }
9259
+ });
9260
+ const signal = (sig) => {
9261
+ try {
9262
+ process.kill(-pid, sig);
9263
+ } catch {
9264
+ try {
9265
+ process.kill(pid, sig);
9266
+ } catch {
9267
+ }
9268
+ }
9269
+ };
9270
+ signal("SIGTERM");
9271
+ const end = Date.now() + (o.graceMs ?? KILL_GRACE_MS);
9272
+ while (alive(pid) && Date.now() < end) await new Promise((r) => setTimeout(r, 50));
9273
+ if (alive(pid)) signal("SIGKILL");
9274
+ }
9275
+ var RUN_KILLED_MARKER = "<!-- crew:run-killed -->";
9276
+ function runKilledComment(b) {
9277
+ 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.";
9278
+ return `${RUN_KILLED_MARKER}
9279
+
9280
+ The agent run on this ticket was stopped at its wall-clock limit of ${b.limitMinutes} minutes before it handed on${b.branch ? `; its branch is \`${b.branch}\`` : ""}. ${tree} The worktree is left as it was. The next run on this seat resumes from it rather than starting over.`;
9281
+ }
9282
+
9193
9283
  // src/agent.ts
9194
9284
  var DIGEST_MAX_AGE_SECONDS = 600;
9195
9285
  var LEGACY_TOOLCHAIN_HARNESS = "claude";
@@ -9299,6 +9389,7 @@ function planAgentRun(o) {
9299
9389
  toolchain: toolchain.name,
9300
9390
  harness: toolchain.harness,
9301
9391
  provider: toolchainProvider(toolchain),
9392
+ maxRunMinutes: resolveMaxRunMinutes(o.route.maxRunMinutes, toolchain.maxRunMinutes),
9302
9393
  resolution: { toolchain: seat.toolchainWhy, model: seat.modelWhy },
9303
9394
  warnings: [...seat.warnings ?? [], ...expanded.warnings],
9304
9395
  prompt,
@@ -9388,6 +9479,17 @@ function identityOf(plan, adapterHarness) {
9388
9479
  promptSha: plan.promptSha
9389
9480
  };
9390
9481
  }
9482
+ async function killForDeadline(pid, emit, role, ticket, spawnedAt, limitMinutes) {
9483
+ const elapsedMs = Date.now() - Date.parse(spawnedAt);
9484
+ const limit = limitMinutes ?? Math.round(elapsedMs / 6e4);
9485
+ emit.emit(`run_killed: ${role} run exceeded its wall-clock limit of ${limit} min (ran ${Math.round(elapsedMs / 6e4)} min)`, {
9486
+ role,
9487
+ ...ticket ? { ticket } : {},
9488
+ data: { event: "run_killed", reason: WALL_CLOCK_REASON, limitMinutes: limit, elapsedMs, pid }
9489
+ });
9490
+ await killRunGroup(pid);
9491
+ return { limitMinutes: limit, elapsedMs };
9492
+ }
9391
9493
  var withoutKey = ({ apiKey: _apiKey, ...rest }) => rest;
9392
9494
  function openSink(path) {
9393
9495
  try {
@@ -9747,13 +9849,16 @@ function spawnAgent(plan, emit, hintMoved) {
9747
9849
  detached: true
9748
9850
  });
9749
9851
  closeSync3(streamFd);
9852
+ const spawnedAt = (/* @__PURE__ */ new Date()).toISOString();
9853
+ const deadline = deadlineFor(spawnedAt, plan.maxRunMinutes);
9750
9854
  if (child.pid !== void 0) {
9751
9855
  const sidecar = {
9752
9856
  pid: child.pid,
9753
9857
  ownerPid: process.pid,
9754
- spawnedAt: (/* @__PURE__ */ new Date()).toISOString(),
9858
+ spawnedAt,
9755
9859
  role: plan.role,
9756
9860
  route: plan.route,
9861
+ ...deadline ? { deadline, maxRunMinutes: plan.maxRunMinutes } : {},
9757
9862
  ticket: plan.ticket ?? null,
9758
9863
  ticketRecordId: plan.ticketRecordId ?? null,
9759
9864
  repo: plan.repo ?? null,
@@ -9773,25 +9878,45 @@ function spawnAgent(plan, emit, hintMoved) {
9773
9878
  }
9774
9879
  }
9775
9880
  const obs = new RunObserver(id, adapter, eventsSink, emit.cycle);
9776
- const meta = { pid: child.pid ?? null };
9881
+ const meta = { pid: child.pid ?? null, ...deadline ? { deadline } : {} };
9777
9882
  obs.onClaim = () => writeRunMeta(id, startedAt, obs.liveTicket, meta);
9778
9883
  if (child.pid !== void 0) writeRunMeta(id, startedAt, obs.liveTicket, meta);
9779
9884
  const tail3 = new FileTail(plan.streamPath);
9780
9885
  const timer = setInterval(() => obs.feed(tail3.poll()), TAIL_POLL_MS);
9886
+ let killed;
9887
+ const limitTimer = child.pid !== void 0 && deadline ? setTimeout(() => {
9888
+ const pid = child.pid;
9889
+ void killForDeadline(pid, emit, plan.role, obs.liveTicket ?? plan.ticket ?? null, spawnedAt, plan.maxRunMinutes).then((k) => {
9890
+ killed = k;
9891
+ });
9892
+ }, Math.max(0, msUntilDeadline(deadline) ?? 0)) : void 0;
9781
9893
  let spawnFailed = false;
9782
9894
  child.on("error", (err3) => {
9783
9895
  spawnFailed = true;
9784
9896
  clearInterval(timer);
9897
+ clearTimeout(limitTimer);
9785
9898
  reject(new AgentSpawnError(`cannot run ${plan.bin}: ${err3.message}`));
9786
9899
  });
9787
9900
  child.on("close", async (code) => {
9788
9901
  clearInterval(timer);
9902
+ clearTimeout(limitTimer);
9789
9903
  obs.feed(tail3.poll());
9790
9904
  obs.end();
9791
9905
  await endSink(eventsSink);
9792
9906
  if (spawnFailed) return;
9793
- const { ms: ms3, handoffPending } = await finishRun(id, obs, emit, startedAt, { code: code ?? 1 }, plan.agentLog?.apiKey, hintMoved);
9794
- resolve6({ code: code ?? 1, ms: ms3, ...handoffPending ? { handoffPending } : {} });
9907
+ if (limitTimer && !killed && !obs.sawResult && msUntilDeadline(deadline) <= 0) {
9908
+ killed = { limitMinutes: plan.maxRunMinutes ?? 0, elapsedMs: Date.now() - Date.parse(spawnedAt) };
9909
+ }
9910
+ const { ms: ms3, handoffPending } = await finishRun(
9911
+ id,
9912
+ obs,
9913
+ emit,
9914
+ startedAt,
9915
+ { code: code ?? 1, ...killed ? { interrupted: WALL_CLOCK_REASON } : {} },
9916
+ plan.agentLog?.apiKey,
9917
+ hintMoved
9918
+ );
9919
+ resolve6({ code: code ?? 1, ms: ms3, ...handoffPending ? { handoffPending } : {}, ...killed ? { killed } : {} });
9795
9920
  });
9796
9921
  child.stdin?.on("error", (err3) => {
9797
9922
  emit.warn(`agent closed stdin before the prompt was delivered: ${err3.message}`, { step: "agent" });
@@ -9810,18 +9935,23 @@ async function reattachRun(streamPath, sc, emit, o = {}) {
9810
9935
  obs.feed(tail3.poll());
9811
9936
  obs.muted = false;
9812
9937
  const adopted = isAlive(sc);
9938
+ let killed;
9813
9939
  if (adopted) {
9814
9940
  o.onAdopted?.();
9815
9941
  const pollMs = o.pollMs ?? TAIL_POLL_MS;
9816
9942
  while (isAlive(sc)) {
9817
9943
  obs.feed(tail3.poll());
9944
+ if (!killed && (msUntilDeadline(sc.deadline) ?? 1) <= 0) {
9945
+ killed = await killForDeadline(sc.pid, emit, sc.role, obs.liveTicket ?? sc.ticket, sc.spawnedAt, sc.maxRunMinutes);
9946
+ continue;
9947
+ }
9818
9948
  await new Promise((r) => setTimeout(r, pollMs));
9819
9949
  }
9820
9950
  }
9821
9951
  obs.feed(tail3.poll());
9822
9952
  obs.end();
9823
9953
  await endSink(eventsSink);
9824
- const interrupted = obs.sawResult ? void 0 : adopted ? "no-result" : "daemon-exit";
9954
+ const interrupted = killed ? WALL_CLOCK_REASON : obs.sawResult ? void 0 : adopted ? "no-result" : "daemon-exit";
9825
9955
  const { ms: ms3, ticket } = await finishRun(
9826
9956
  id,
9827
9957
  obs,
@@ -9830,7 +9960,7 @@ async function reattachRun(streamPath, sc, emit, o = {}) {
9830
9960
  { code: null, ...interrupted ? { interrupted } : {} },
9831
9961
  o.agentLogKey
9832
9962
  );
9833
- return { adopted, ...interrupted ? { interrupted } : {}, ms: ms3, ticket };
9963
+ return { adopted, ...interrupted ? { interrupted } : {}, ...killed ? { killed } : {}, ms: ms3, ticket };
9834
9964
  }
9835
9965
 
9836
9966
  // src/seat-keys.ts
@@ -14331,7 +14461,8 @@ function describeRun(run2, route2, seatIds, worktreeOf = (d, n) => {
14331
14461
  worktree: repoDir && num ? worktreeOf(repoDir, num) : null,
14332
14462
  stream: run2.streamPath,
14333
14463
  pid: run2.pid,
14334
- startedAt: run2.spawnedAt
14464
+ startedAt: run2.spawnedAt,
14465
+ deadline: run2.deadline ?? null
14335
14466
  };
14336
14467
  }
14337
14468
 
@@ -15505,7 +15636,10 @@ async function discover(o) {
15505
15636
  ...hostPeriscopes !== void 0 ? { host_periscopes: hostPeriscopes } : {},
15506
15637
  ...sshPublicKey !== void 0 ? { ssh_public_key: sshPublicKey } : {},
15507
15638
  ...ownerId !== void 0 ? { owner_id: ownerId } : {},
15508
- ...writeMachineId ? { machine_id: machineId } : {}
15639
+ ...writeMachineId ? { machine_id: machineId } : {},
15640
+ // ISSUE-2791: a ship connecting by hand is a developer machine; the
15641
+ // column arrives with a template update, so only written when present.
15642
+ ...shipColumns.has("kind") ? { kind: "developer_machine" } : {}
15509
15643
  });
15510
15644
  shipRowId = created.id;
15511
15645
  out.shipRow = { id: shipRowId, name: o.ship.name, action: "created", matchedBy };
@@ -18782,7 +18916,7 @@ async function reconcileForRoute(ctx, o) {
18782
18916
  if (wt) dirty = status(wt.path).length > 0;
18783
18917
  } catch {
18784
18918
  }
18785
- await tracker.postEvent(sc.ticketRecordId, interruptedComment({
18919
+ await tracker.postEvent(sc.ticketRecordId, result.killed ? runKilledComment({ limitMinutes: result.killed.limitMinutes, branch: wt?.branch ?? null, dirty }) : interruptedComment({
18786
18920
  branch: wt?.branch ?? null,
18787
18921
  dirty,
18788
18922
  reason: result.interrupted === "daemon-exit" ? "the crew daemon exited" : "it exited without a result"
@@ -18805,6 +18939,23 @@ async function reconcileForRoute(ctx, o) {
18805
18939
  }
18806
18940
  });
18807
18941
  }
18942
+ async function postRunKilled(route2, tracker, ticketId, memberId, limitMinutes, emit) {
18943
+ if (!ticketId || !memberId) return;
18944
+ try {
18945
+ const t = await tracker.ticket(ticketId);
18946
+ const target = repoTargetFor(route2, t.repo_id);
18947
+ const num = String(t[tracker.contract.columns.key] ?? "").replace(/^\D+/, "");
18948
+ const wt = target && num ? worktreeForNumber(target.dir, num) : null;
18949
+ let dirty = null;
18950
+ try {
18951
+ if (wt) dirty = status(wt.path).length > 0;
18952
+ } catch {
18953
+ }
18954
+ await tracker.postEvent(ticketId, runKilledComment({ limitMinutes, branch: wt?.branch ?? null, dirty }), memberId, "interrupted");
18955
+ } catch (e) {
18956
+ emit.warn(`could not post the wall-clock limit comment: ${e.message}`, { step: "agent" });
18957
+ }
18958
+ }
18808
18959
  async function runRoleAgent(role, decision, ctx, hooks) {
18809
18960
  const held = {};
18810
18961
  try {
@@ -18967,8 +19118,12 @@ async function runRoleAgentLocked(role, decision, ctx, hooks, held) {
18967
19118
  } catch {
18968
19119
  }
18969
19120
  }
18970
- await spawnAgent(plan, emit, hintMoved);
18971
- await guardRunHandoff(route2, ship, tracker, role, workingId, emit, runStart);
19121
+ const result = await spawnAgent(plan, emit, hintMoved);
19122
+ if (result.killed) {
19123
+ await postRunKilled(route2, tracker, workingId, memberId, result.killed.limitMinutes, emit);
19124
+ } else {
19125
+ await guardRunHandoff(route2, ship, tracker, role, workingId, emit, runStart);
19126
+ }
18972
19127
  } finally {
18973
19128
  await settleRunEnd({
18974
19129
  tracker,
@@ -24403,7 +24558,7 @@ watermark: ${state.watermark()}
24403
24558
  );
24404
24559
  }
24405
24560
  for (const x of runs) {
24406
- process.stdout.write(`running: ${x.role} \u2014 ${x.ticket ?? "(no ticket)"}${x.repo ? ` in ${x.repo}` : ""} (pid ${x.pid}, since ${x.spawnedAt})
24561
+ process.stdout.write(`running: ${x.role} \u2014 ${x.ticket ?? "(no ticket)"}${x.repo ? ` in ${x.repo}` : ""} (pid ${x.pid}, since ${x.spawnedAt}${x.deadline ? `, deadline ${x.deadline}` : ""})
24407
24562
  `);
24408
24563
  }
24409
24564
  if (shipRows.length) {
@@ -24613,6 +24768,11 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
24613
24768
  const missingAuthorship = await tracker.missingAuthorshipColumns();
24614
24769
  process.stdout.write(
24615
24770
  missingAuthorship.length === 0 ? "authorship: filed_by_id, ship_id present\n" : `authorship: missing ${missingAuthorship.join(", ")} \u2014 update the Issues template
24771
+ `
24772
+ );
24773
+ const missingRuntime = await tracker.missingRuntimeColumns();
24774
+ process.stdout.write(
24775
+ missingRuntime.length === 0 ? "runtime columns: forge_host, kind, instance_class, relay_url present\n" : `runtime columns: missing ${missingRuntime.join(", ")} \u2014 read as their defaults; update the Issues template
24616
24776
  `
24617
24777
  );
24618
24778
  process.stdout.write(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.94.0",
3
+ "version": "0.95.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",