@tablation/crew 0.17.0 → 0.19.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 (3) hide show
  1. package/README.md +1 -0
  2. package/dist/cli.js +122 -7
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -206,6 +206,7 @@ crew doctor [route] [--fix] preflight; --fix enables clean routes, offers cre
206
206
  crew ports [route] which checkout owns which ports, and what is up
207
207
  crew reap [route] kill servers left behind by removed worktrees
208
208
  crew drop [route] NNN remove a merged ticket's worktree and branch
209
+ crew worktree [route] NNN cut (or refresh) this run's worktree for a ticket from the remote; prints its path
209
210
  crew sync [route] fast-forward the checkout and its worktrees from the remote
210
211
  crew pause|resume [route] [R|release] pause everything, one role, or releases only (passengers keep running)
211
212
  crew log [route] tail the log
package/dist/cli.js CHANGED
@@ -2575,6 +2575,9 @@ function detectSystemd() {
2575
2575
  return !res.error;
2576
2576
  }
2577
2577
 
2578
+ // src/ship-attention.ts
2579
+ import { statfsSync } from "fs";
2580
+
2578
2581
  // src/git.ts
2579
2582
  import { execFileSync } from "child_process";
2580
2583
  import {
@@ -3049,14 +3052,94 @@ function baseAttention(repo, outcome, remote, base, nowIso = (/* @__PURE__ */ ne
3049
3052
  if (outcome.action === "level" || outcome.action === "fast-forwarded") return { clear: baseUnsafeKey(repo) };
3050
3053
  return null;
3051
3054
  }
3055
+ var diskLowKey = (repo) => `disk_low:${repo}`;
3056
+ var LOW_DISK_BYTES = 2 * 1024 ** 3;
3057
+ var gib = (bytes) => (bytes / 1024 ** 3).toFixed(1);
3058
+ function diskAttention(repo, freeBytes, thresholdBytes = LOW_DISK_BYTES, nowIso = (/* @__PURE__ */ new Date()).toISOString()) {
3059
+ if (freeBytes >= thresholdBytes) return { clear: diskLowKey(repo) };
3060
+ return {
3061
+ raise: {
3062
+ kind: "disk_low",
3063
+ key: diskLowKey(repo),
3064
+ since: nowIso,
3065
+ message: `${repo}: only ${gib(freeBytes)} GiB free beside the checkout (need ${gib(thresholdBytes)}) \u2014 no worktree can be set up until space is freed. Resumes automatically once there is room`
3066
+ }
3067
+ };
3068
+ }
3069
+ function freeBytesAt(dir) {
3070
+ try {
3071
+ const s = statfsSync(dir);
3072
+ return Number(s.bavail) * Number(s.bsize);
3073
+ } catch {
3074
+ return null;
3075
+ }
3076
+ }
3077
+ async function checkRepoDisks(o, freeAt = freeBytesAt, thresholdBytes = LOW_DISK_BYTES) {
3078
+ for (const target of reposOf(o.route)) {
3079
+ const free = freeAt(target.dir);
3080
+ if (free === null) continue;
3081
+ const att = diskAttention(target.name, free, thresholdBytes);
3082
+ if ("raise" in att) await raiseShipAttention(o, att.raise);
3083
+ else clearShipAttention(o.state, att.clear);
3084
+ }
3085
+ }
3086
+ function parseHookMissing(key, routeName) {
3087
+ const prefix = `hook_missing:${routeName}/`;
3088
+ if (!key.startsWith(prefix)) return null;
3089
+ const rest = key.slice(prefix.length);
3090
+ const slash = rest.lastIndexOf("/");
3091
+ if (slash <= 0) return null;
3092
+ return { repo: rest.slice(0, slash), hook: rest.slice(slash + 1) };
3093
+ }
3052
3094
  function repoStopFor(items, route) {
3053
- const stops = new Map(items.filter((i) => i.kind === "base_unsafe").map((i) => [i.key, i.message]));
3095
+ const stops = /* @__PURE__ */ new Map();
3096
+ for (const i of items) {
3097
+ let repo = null;
3098
+ if (i.kind === "base_unsafe") repo = i.key.slice("base_unsafe:".length);
3099
+ else if (i.kind === "disk_low") repo = i.key.slice("disk_low:".length);
3100
+ else if (i.kind === "hook_missing") {
3101
+ const h = parseHookMissing(i.key, route.route);
3102
+ if (h?.hook === "setup") repo = h.repo;
3103
+ }
3104
+ if (repo && !stops.has(repo)) stops.set(repo, i.message);
3105
+ }
3054
3106
  if (stops.size === 0) return () => null;
3055
3107
  return (t) => {
3056
3108
  const name = repoTargetFor(route, t.repo_id)?.name;
3057
- return name ? stops.get(baseUnsafeKey(name)) ?? null : null;
3109
+ return name ? stops.get(name) ?? null : null;
3058
3110
  };
3059
3111
  }
3112
+ function recheckEnvironmentStops(state, route, ship, deps = {}) {
3113
+ const onPath = deps.onPath ?? findOnPath;
3114
+ const dockerProblem = deps.dockerProblem ?? dockerPathProblem;
3115
+ const cleared = [];
3116
+ for (const item of state.shipAttention().previous()) {
3117
+ if (item.kind === "docker_missing") {
3118
+ if (!dockerProblem(ship)) {
3119
+ clearShipAttention(state, item.key);
3120
+ cleared.push("docker");
3121
+ }
3122
+ continue;
3123
+ }
3124
+ if (item.kind !== "hook_missing") continue;
3125
+ const h = parseHookMissing(item.key, route.route);
3126
+ const target = h && reposOf(route).find((t) => t.name === h.repo);
3127
+ if (!h || !target) continue;
3128
+ let cfg;
3129
+ try {
3130
+ cfg = resolveRepoConfig(loadRepoConfig(target.dir), void 0, target.dir);
3131
+ } catch {
3132
+ continue;
3133
+ }
3134
+ const script = cfg.hooks[h.hook];
3135
+ const cmd = script ? hookCommand(script) : null;
3136
+ if (!cmd || onPath(cmd, pathFor(ship))) {
3137
+ clearShipAttention(state, item.key);
3138
+ cleared.push(`${h.repo}/${h.hook}`);
3139
+ }
3140
+ }
3141
+ return cleared;
3142
+ }
3060
3143
  function recheckBaseStops(state, route) {
3061
3144
  const open = state.shipAttention().previous().filter((i) => i.kind === "base_unsafe");
3062
3145
  const cleared = [];
@@ -4695,6 +4778,10 @@ async function decideCycle(o) {
4695
4778
  for (const repo of recheckBaseStops(state, route)) {
4696
4779
  emit.emit(`${repo}: primary checkout is level again \u2014 building lanes resume for that repo`, { step: "select" });
4697
4780
  }
4781
+ for (const what of recheckEnvironmentStops(state, route, ship)) {
4782
+ emit.emit(`${what}: no longer missing from the scheduler PATH \u2014 stop cleared`, { step: "select" });
4783
+ }
4784
+ await checkRepoDisks({ state, emit, route, ship });
4698
4785
  const selectionInput = {
4699
4786
  repoStop: repoStopFor(openShipAttention(state), route),
4700
4787
  tickets,
@@ -6010,11 +6097,18 @@ function applyRunWorktree(dir, cfg, plan) {
6010
6097
  return plan.path;
6011
6098
  }
6012
6099
  }
6100
+ function worktreeHoldsUnpushed(path, remote) {
6101
+ const out = gitOk(path, ["for-each-ref", "--contains", "HEAD", "--count=1", "--format=%(refname)", `refs/remotes/${remote}/`]);
6102
+ return out === null || out.trim() === "";
6103
+ }
6013
6104
  async function finishRunWorktree(dir, cfg, branch) {
6014
6105
  const wt = worktrees(dir).find((w) => w.branch === branch && !w.prunable && w.path !== dir);
6015
6106
  if (!wt || !existsSync9(wt.path)) return { kind: "none" };
6016
6107
  if (cfg.hooks.handoff) return { kind: "kept-handoff", path: wt.path };
6017
6108
  if (status(wt.path).length > 0) return { kind: "kept-dirty", path: wt.path };
6109
+ if (remoteConfigured(dir, cfg.branch.remote) && worktreeHoldsUnpushed(wt.path, cfg.branch.remote)) {
6110
+ return { kind: "kept-unpushed", path: wt.path };
6111
+ }
6018
6112
  const n = Number(wt.path.match(/(\d+)$/)?.[1] ?? NaN);
6019
6113
  if (Number.isFinite(n)) {
6020
6114
  const pids = pidsInWorktree(wt.path, n);
@@ -6114,7 +6208,7 @@ async function enforceHandoff(i) {
6114
6208
  }
6115
6209
  }
6116
6210
  async function guardRunHandoff(route, ship, tracker, role, workingId, log) {
6117
- if (role !== "dev" && role !== "design" || !workingId) return null;
6211
+ if (!workingId || role !== "dev" && role !== "design" && role !== "qa") return null;
6118
6212
  const seatId = route.resolved?.seats[role];
6119
6213
  if (!seatId) return null;
6120
6214
  try {
@@ -6127,6 +6221,10 @@ async function guardRunHandoff(route, ship, tracker, role, workingId, log) {
6127
6221
  labels: { ...route.labels, ...o?.labels },
6128
6222
  branch: { ...route.branch, ...o?.branch }
6129
6223
  }, target.dir);
6224
+ if (role === "qa") {
6225
+ await retireRunWorktree(tracker, target.dir, cfg, t, role, seatId, ship.name, log);
6226
+ return null;
6227
+ }
6130
6228
  const affinity = await tracker.claimAffinity();
6131
6229
  const outcome = await enforceHandoff({
6132
6230
  writer: tracker,
@@ -6158,6 +6256,7 @@ async function retireRunWorktree(tracker, dir, cfg, t, role, seatId, shipName2,
6158
6256
  log.emit(`${t.issue_id}: kept worktree ${o.path} \u2014 this repo's handoff hook serves from it`, at);
6159
6257
  await tracker.postEvent(t.id, `worktree kept on ship ${shipName2} at \`${o.path}\`: the repo's \`handoff\` hook serves from it. QA on another ship cuts its own from \`${cfg.branch.remote}/${branch}\` instead.`, seatId);
6160
6258
  } else if (o.kind === "kept-dirty") log.warn(`${t.issue_id}: left worktree ${o.path} \u2014 it has uncommitted changes`, at);
6259
+ else if (o.kind === "kept-unpushed") log.warn(`${t.issue_id}: left worktree ${o.path} \u2014 its branch has commits that are not on ${cfg.branch.remote}`, at);
6161
6260
  } catch (e) {
6162
6261
  log.warn(`could not retire the run's worktree: ${e.message}`, { step: "handoff" });
6163
6262
  }
@@ -8936,18 +9035,22 @@ import { existsSync as existsSync15 } from "fs";
8936
9035
  import { basename as basename6 } from "path";
8937
9036
  function planWorktreeSweep(target, tickets, contract) {
8938
9037
  const terminal = new Set(contract.statuses.resolved.filter((s) => s !== contract.statuses.verified));
9038
+ const verified = contract.statuses.verified;
8939
9039
  const byNumber = new Map(tickets.map((t) => [t.issue_id.replace(/^\D+/, ""), t]));
8940
9040
  const actions = [];
8941
9041
  for (const w of worktrees(target.dir)) {
8942
9042
  const m = /(?:^|-)(\d+)$/.exec(basename6(w.path));
8943
9043
  if (!m) continue;
8944
9044
  const ticket = byNumber.get(m[1]);
8945
- if (!ticket || !terminal.has(ticket.status)) continue;
9045
+ if (!ticket) continue;
9046
+ const isVerified = ticket.status === verified;
9047
+ if (!isVerified && !terminal.has(ticket.status)) continue;
8946
9048
  actions.push({
8947
9049
  ticket,
8948
9050
  path: w.path,
8949
9051
  branch: w.branch,
8950
- keepBranch: ticket.status !== contract.statuses.deployed
9052
+ keepBranch: ticket.status !== contract.statuses.deployed,
9053
+ ...isVerified ? { requirePushed: true } : {}
8951
9054
  });
8952
9055
  }
8953
9056
  return actions;
@@ -8970,6 +9073,13 @@ async function applyWorktreeSweep(cwd, actions, dryRun, log, remote = "origin")
8970
9073
  continue;
8971
9074
  }
8972
9075
  }
9076
+ if (!dryRun && a.requirePushed && a.branch && existsSync15(a.path) && remoteConfigured(cwd, remote) && worktreeHoldsUnpushed(a.path, remote)) {
9077
+ log.warn(
9078
+ `not removing worktree ${a.path} for ${a.ticket.issue_id} (${a.ticket.status}): ${a.branch} has commits that are not on ${remote}`,
9079
+ { ticket: a.ticket.issue_id, step: "worktree" }
9080
+ );
9081
+ continue;
9082
+ }
8973
9083
  const n = Number(a.ticket.issue_id.replace(/^\D+/, ""));
8974
9084
  const pids = pidsInWorktree(a.path, n);
8975
9085
  if (pids.length) {
@@ -10966,8 +11076,9 @@ ${changed2.map((c) => ` - ${c}
10966
11076
  }
10967
11077
  try {
10968
11078
  const terminal = await tracker.terminalTickets();
11079
+ const verifiedNow = (await tracker.openTickets()).filter((t) => t.status === tracker.contract.statuses.verified);
10969
11080
  const scoped = ticketsByRepo(c, terminal).byRepo.get(target.name) ?? [];
10970
- const actions = planWorktreeSweep(target, scoped, tracker.contract);
11081
+ const actions = planWorktreeSweep(target, [...scoped, ...ticketsByRepo(c, verifiedNow).byRepo.get(target.name) ?? []], tracker.contract);
10971
11082
  if (actions.length) {
10972
11083
  remit.enter("worktree");
10973
11084
  const r = await applyWorktreeSweep(target.dir, actions, dryRun, remit, repo.branch.remote);
@@ -12344,8 +12455,12 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
12344
12455
  const tracker = new Tracker(route, cfg.ship);
12345
12456
  const terminal = await tracker.terminalTickets();
12346
12457
  const { byRepo } = ticketsByRepo(route, terminal);
12458
+ const verifiedByRepo = ticketsByRepo(
12459
+ route,
12460
+ (await tracker.openTickets()).filter((t) => t.status === tracker.contract.statuses.verified)
12461
+ ).byRepo;
12347
12462
  for (const r of await resolvedRepos(route)) {
12348
- const actions = planWorktreeSweep(r, byRepo.get(r.name) ?? [], tracker.contract);
12463
+ const actions = planWorktreeSweep(r, [...byRepo.get(r.name) ?? [], ...verifiedByRepo.get(r.name) ?? []], tracker.contract);
12349
12464
  const stale = planRemoteBranchCleanup(r.dir, r.config.branch.remote, r.config.branch.base, byRepo.get(r.name) ?? [], tracker.contract);
12350
12465
  if (stale.length) {
12351
12466
  anything = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.17.0",
3
+ "version": "0.19.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",