@tablation/crew 0.79.10 → 0.80.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 +164 -24
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -2981,9 +2981,11 @@ function describeRelease(o, route) {
2981
2981
  level: "fail",
2982
2982
  headline: `${route}: release blocked \u2014 tests failed`,
2983
2983
  detail: withHookTail(
2984
- o.composition ? `the target stays on the previous release
2984
+ (o.composition ? `the target stays on the previous release
2985
2985
 
2986
- ${describeComposition(o.composition)}` : "the target stays on the previous release",
2986
+ ${describeComposition(o.composition)}` : "the target stays on the previous release") + (o.isolation ? `
2987
+
2988
+ ${o.isolation}` : ""),
2987
2989
  o.hookFailure
2988
2990
  ),
2989
2991
  ...o.composition ? { head: o.composition.head } : {}
@@ -3013,7 +3015,9 @@ ${describeComposition(o.composition)}` : "the target stays on the previous relea
3013
3015
  return {
3014
3016
  level: "fail",
3015
3017
  headline: `${route}: build FAILED`,
3016
- detail: withHookTail("the target stays on the previous release \u2014 nothing was deployed", o.hookFailure)
3018
+ detail: withHookTail(`the target stays on the previous release \u2014 nothing was deployed${o.isolation ? `
3019
+
3020
+ ${o.isolation}` : ""}`, o.hookFailure)
3017
3021
  };
3018
3022
  }
3019
3023
  if (o.blockedCycles && o.blockedCycles >= 2) {
@@ -11311,7 +11315,7 @@ function planBuildFailureBounce(cwd, c, base, remote) {
11311
11315
  return { ...c, base, remote, worktree: worktreeForBranch(cwd, c.branch) };
11312
11316
  }
11313
11317
  function buildFailureComment(b) {
11314
- const how = b.how === "files" ? `The failing output names files that only \`${b.branch}\` changed among the branches in that release.` : `Built alone on the previous \`${b.base}\`, \`${b.branch}\` fails the same hook.`;
11318
+ const how = b.how === "pair" && b.sibling ? `Each branch passes the hook alone; \`${b.branch}\` together with ${b.sibling.key} (\`${b.sibling.branch}\`) fails it. ${b.sibling.key} was verified first, so the fix belongs on this branch: \`git merge ${b.remote}/${b.sibling.branch}\` first, then fix what disagrees. Fix it on this branch, never a fresh one.` : b.how === "files" ? `The failing output names files that only \`${b.branch}\` changed among the branches in that release.` : `Built alone on the previous \`${b.base}\`, \`${b.branch}\` fails the same hook.`;
11315
11319
  return `${BUILD_FAILURE_MARKER}
11316
11320
 
11317
11321
  **Back with the dev lane \u2014 this branch builds on its own but not on current \`${b.base}\`.**
@@ -11326,8 +11330,46 @@ ${b.tail.trimEnd()}
11326
11330
 
11327
11331
  Nothing was written to \`${b.base}\` for this ticket; the rest of the release shipped without it.`;
11328
11332
  }
11329
- async function applyBuildFailureBounce(writer, b, contract, memberId, log, dryRun) {
11333
+ function priorBuildFailures(ticket, events) {
11334
+ const stamp = (e) => e.occurred_at ?? e.created_at ?? "";
11335
+ const mine = events.filter((e) => e.record_id === ticket.id);
11336
+ const pushes = mine.filter((e) => e.event_type === "pushed").map(stamp);
11337
+ return mine.filter((e) => e.event_type === "build_failed" && pushes.some((p) => p > stamp(e))).length;
11338
+ }
11339
+ function buildFailureEscalation(b) {
11340
+ return `${BUILD_FAILURE_MARKER}
11341
+
11342
+ **Failed the release gate again after a hand-back \u2014 stopping rather than looping.**
11343
+
11344
+ \`${b.branch}\` was handed back for breaking the \`${b.hook}\` hook, pushed, verified again, and breaks it once more. That is a call for a person, not another lap through the lanes.
11345
+
11346
+ \`\`\`
11347
+ ${b.tail.trimEnd()}
11348
+ \`\`\``;
11349
+ }
11350
+ async function applyBuildFailureBounce(writer, b, contract, memberId, log, dryRun, prior = { count: 0 }) {
11330
11351
  const t = { ticket: b.ticket.issue_id, step: "release" };
11352
+ if (prior.count > 0) {
11353
+ if (dryRun) {
11354
+ log.emit(`would escalate to ${contract.statuses.needsHuman} \u2014 handed back for the ${b.hook} hook before`, t);
11355
+ return { kind: "escalated" };
11356
+ }
11357
+ try {
11358
+ await writer.updateTicket(
11359
+ b.ticket.id,
11360
+ { status: contract.statuses.needsHuman, ...prior.operatorId ? { assignee_id: prior.operatorId } : {} },
11361
+ b.ticket.updated_at
11362
+ );
11363
+ await writer.postEvent(b.ticket.id, buildFailureEscalation(b), memberId, "build_failed_escalated").catch(() => {
11364
+ log.warn("escalated, but the note failed to post", t);
11365
+ });
11366
+ log.warn(`breaks the ${b.hook} hook again after a hand-back \u2014 escalated to ${contract.statuses.needsHuman}`, t);
11367
+ return { kind: "escalated" };
11368
+ } catch (e) {
11369
+ if (e instanceof StaleWriteError8) return { kind: "stale" };
11370
+ return { kind: "failed", why: e.message };
11371
+ }
11372
+ }
11331
11373
  if (dryRun) {
11332
11374
  log.emit(`would hand ${b.ticket.issue_id} back to ${contract.statuses.building} \u2014 its branch breaks the ${b.hook} hook`, t);
11333
11375
  return { kind: "handed-back" };
@@ -11341,6 +11383,13 @@ async function applyBuildFailureBounce(writer, b, contract, memberId, log, dryRu
11341
11383
  await writer.postEvent(b.ticket.id, buildFailureComment(b), memberId, "build_failed").catch(() => {
11342
11384
  log.warn("handed back, but the note failed to post", t);
11343
11385
  });
11386
+ await writer.postEvent(
11387
+ b.ticket.id,
11388
+ `${b.ticket.issue_tag ?? b.ticket.issue_id} left out of the release: ${b.hook} hook, found by ${b.how}${b.sibling ? `, together with ${b.sibling.key} (${b.sibling.branch})` : ""}`,
11389
+ memberId,
11390
+ "release_culprit"
11391
+ ).catch(() => {
11392
+ });
11344
11393
  log.emit(`handed back to ${contract.statuses.building} \u2014 breaks the ${b.hook} hook once merged with the rest`, t);
11345
11394
  return { kind: "handed-back" };
11346
11395
  } catch (e) {
@@ -12672,6 +12721,25 @@ async function runReleaseCycle(o, movedPast2, localRefSkips) {
12672
12721
  }
12673
12722
  }
12674
12723
  }
12724
+ var MAX_ISOLATE_PAIRS = 10;
12725
+ function pairOrder(n, max = MAX_ISOLATE_PAIRS) {
12726
+ const pairs = [];
12727
+ let truncated = false;
12728
+ outer: for (let j = 1; j < n; j++) {
12729
+ for (let i = 0; i < j; i++) {
12730
+ if (pairs.length >= max) {
12731
+ truncated = true;
12732
+ break outer;
12733
+ }
12734
+ pairs.push([i, j]);
12735
+ }
12736
+ }
12737
+ return { pairs, truncated };
12738
+ }
12739
+ function selfRepairLine(maxCandidates) {
12740
+ const cap = maxCandidates ?? DEFAULT_ISOLATE_MAX_CANDIDATES;
12741
+ return cap === 0 ? "culprit isolation is off (release.isolateFailures.maxCandidates: 0); a failed gate files an alert" : `culprit isolation up to ${cap} merged branches, ${MAX_ISOLATE_PAIRS} pairs before bisecting; a ticket handed back twice after a push escalates with an alert`;
12742
+ }
12675
12743
  var isolateCap = (o) => o.repo.release.isolateFailures?.maxCandidates ?? DEFAULT_ISOLATE_MAX_CANDIDATES;
12676
12744
  async function isolateAndRetry(o, name, output, merged, preMerge, decision, fresh, releaseBranch) {
12677
12745
  const applied = merged.filter((c) => c.sha && c.branch);
@@ -12696,13 +12764,13 @@ async function isolateAndRetry(o, name, output, merged, preMerge, decision, fres
12696
12764
  if (baseRun && baseRun.code !== 0) {
12697
12765
  o.emit.warn(`${name} fails on the base without ${only.branch} \u2014 not blaming the branch`, { ticket: only.ticket.issue_id });
12698
12766
  gitOk(o.cwd, ["reset", "--hard", head]);
12699
- return null;
12767
+ return { tried: `Isolation found no culprit: the ${name} hook also fails on the base alone, without ${only.branch}, so there is no branch to hand back.` };
12700
12768
  }
12701
12769
  o.emit.warn(`${name} failed with only ${only.branch} merged and passes without it \u2014 treating it as the culprit`, { ticket: only.ticket.issue_id });
12702
12770
  culprits.push({ c: only, how: "solo", tail: lastLines(output, HOOK_FAILURE_TAIL_LINES) });
12703
12771
  } else if (applied.length > cap) {
12704
12772
  o.emit.warn(`${name} failed with ${applied.length} branches merged, over release.isolateFailures.maxCandidates (${cap}) \u2014 not isolating`);
12705
- return null;
12773
+ return { tried: `Isolation was skipped: ${applied.length} branches were merged, over release.isolateFailures.maxCandidates (${cap}).` };
12706
12774
  } else {
12707
12775
  o.emit.warn(`${name} failed with ${applied.length} branches merged \u2014 building each alone to find the culprit`);
12708
12776
  for (const c of applied) {
@@ -12715,12 +12783,51 @@ async function isolateAndRetry(o, name, output, merged, preMerge, decision, fres
12715
12783
  culprits.push({ c, how: "solo", tail: lastLines(r.output, HOOK_FAILURE_TAIL_LINES) });
12716
12784
  }
12717
12785
  }
12786
+ if (culprits.length === 0) {
12787
+ const { pairs, truncated } = pairOrder(applied.length);
12788
+ for (const [i, j] of pairs) {
12789
+ gitOk(o.cwd, ["reset", "--hard", preMerge]);
12790
+ const a = mergeOne(o, applied[i]);
12791
+ const b = typeof a === "object" && "conflict" in a ? a : mergeOne(o, applied[j]);
12792
+ if (typeof b === "object" && "conflict" in b) continue;
12793
+ const r = await hook(o, name);
12794
+ if (r && r.code !== 0) {
12795
+ o.emit.warn(`${applied[j].branch} fails the ${name} hook together with ${applied[i].branch}`, { ticket: applied[j].ticket.issue_id });
12796
+ culprits.push({ c: applied[j], how: "pair", sibling: applied[i], tail: lastLines(r.output, HOOK_FAILURE_TAIL_LINES) });
12797
+ break;
12798
+ }
12799
+ }
12800
+ if (culprits.length === 0 && truncated) {
12801
+ let lo = 2, hi = applied.length;
12802
+ let tail = lastLines(output, HOOK_FAILURE_TAIL_LINES);
12803
+ while (lo < hi) {
12804
+ const mid = Math.floor((lo + hi) / 2);
12805
+ gitOk(o.cwd, ["reset", "--hard", preMerge]);
12806
+ let clean = true;
12807
+ for (const c of applied.slice(0, mid)) {
12808
+ const m = mergeOne(o, c);
12809
+ if (typeof m === "object" && "conflict" in m) {
12810
+ clean = false;
12811
+ break;
12812
+ }
12813
+ }
12814
+ const r = clean ? await hook(o, name) : null;
12815
+ if (r && r.code !== 0) {
12816
+ hi = mid;
12817
+ tail = lastLines(r.output, HOOK_FAILURE_TAIL_LINES);
12818
+ } else lo = mid + 1;
12819
+ }
12820
+ const owner = applied[hi - 1];
12821
+ o.emit.warn(`${owner.branch} is the first branch whose prefix fails the ${name} hook (bisected over ${applied.length})`, { ticket: owner.ticket.issue_id });
12822
+ culprits.push({ c: owner, how: "pair", sibling: applied[hi - 2], tail });
12823
+ }
12824
+ }
12718
12825
  }
12719
12826
  gitOk(o.cwd, ["reset", "--hard", preMerge]);
12720
12827
  if (culprits.length === 0) {
12721
12828
  o.emit.warn("no single branch could be blamed \u2014 failing the release as a whole");
12722
12829
  gitOk(o.cwd, ["reset", "--hard", head]);
12723
- return null;
12830
+ return { tried: `Isolation found no culprit: each of the ${applied.length} merged branches passes the ${name} hook alone and no pair among them fails it.` };
12724
12831
  }
12725
12832
  const out = new Set(culprits.map((x) => x.c.ticket.id));
12726
12833
  const rest = decision.merges.filter((c) => !out.has(c.ticket.id));
@@ -12732,7 +12839,8 @@ async function isolateAndRetry(o, name, output, merged, preMerge, decision, fres
12732
12839
  branch: x.c.branch,
12733
12840
  hook: name,
12734
12841
  tail: x.tail,
12735
- how: x.how
12842
+ how: x.how,
12843
+ ...x.sibling ? { sibling: { key: x.sibling.ticket.issue_tag ?? x.sibling.ticket.issue_id, branch: x.sibling.branch } } : {}
12736
12844
  }))
12737
12845
  };
12738
12846
  }
@@ -12743,6 +12851,7 @@ async function mergeAndRelease(o, decision, fresh, releaseBranch, isolated = fal
12743
12851
  const conflicts = [];
12744
12852
  const unbuildable = [];
12745
12853
  let applied = 0;
12854
+ let isolation;
12746
12855
  for (const c of decision.merges) {
12747
12856
  if (!c.branch || c.mergedSha) {
12748
12857
  if (c.skipReason === "already-released") {
@@ -12855,7 +12964,8 @@ async function mergeAndRelease(o, decision, fresh, releaseBranch, isolated = fal
12855
12964
  }
12856
12965
  if (r && r.code !== 0 && !isolated) {
12857
12966
  const out = await isolateAndRetry(o, "test", r.output, merged, preMerge, decision, fresh, releaseBranch);
12858
- if (out) return out;
12967
+ if (out && "tried" in out) isolation = out.tried;
12968
+ else if (out) return out;
12859
12969
  }
12860
12970
  if (r && r.code !== 0) {
12861
12971
  o.emit.error(`test gate FAILED \u2014 not deploying; the target stays on the previous release`);
@@ -12876,7 +12986,7 @@ async function mergeAndRelease(o, decision, fresh, releaseBranch, isolated = fal
12876
12986
  label: hookLabel(o.repo, "test"),
12877
12987
  tail: failureSummary(r.output, HOOK_FAILURE_TAIL_LINES)
12878
12988
  };
12879
- return { merged, conflicts, unbuildable, deployed: false, stopped: "tests failed", decision, gateRed, hookFailure: hookFailure2, composition };
12989
+ return { merged, conflicts, unbuildable, deployed: false, stopped: "tests failed", decision, gateRed, hookFailure: hookFailure2, composition, ...isolation ? { isolation } : {} };
12880
12990
  }
12881
12991
  o.state?.clearTestGateFailed?.();
12882
12992
  }
@@ -12915,7 +13025,8 @@ async function mergeAndRelease(o, decision, fresh, releaseBranch, isolated = fal
12915
13025
  }
12916
13026
  if (r && r.code !== 0 && !isolated) {
12917
13027
  const out = await isolateAndRetry(o, "build", r.output, merged, preMerge, decision, fresh, releaseBranch);
12918
- if (out) return out;
13028
+ if (out && "tried" in out) isolation = out.tried;
13029
+ else if (out) return out;
12919
13030
  }
12920
13031
  if (r && r.code !== 0) {
12921
13032
  o.emit.error("build failed \u2014 not deploying");
@@ -12923,7 +13034,7 @@ async function mergeAndRelease(o, decision, fresh, releaseBranch, isolated = fal
12923
13034
  git(o.cwd, ["reset", "--hard", preBump]);
12924
13035
  o.emit.warn(`dropped the unreleased version commit for ${version} (reset ${o.repo.branch.base} to ${preBump.slice(0, 8)}) so a retry starts clean`);
12925
13036
  }
12926
- return { merged, conflicts, unbuildable, version, deployed: false, stopped: "build failed", decision, hookFailure: hookFailure(o, "build", r.output) };
13037
+ return { merged, conflicts, unbuildable, version, deployed: false, stopped: "build failed", decision, hookFailure: hookFailure(o, "build", r.output), ...isolation ? { isolation } : {} };
12927
13038
  }
12928
13039
  }
12929
13040
  }
@@ -18294,21 +18405,25 @@ ${changed2.map((c) => ` - ${c}
18294
18405
  dryRun
18295
18406
  );
18296
18407
  }
18408
+ const alertPlacement = async () => {
18409
+ const placement = {};
18410
+ try {
18411
+ const repoId = repoIdForName(c, target.name);
18412
+ if (repoId) {
18413
+ placement.repo_id = repoId;
18414
+ const projectId = (await tracker.repoRow(repoId))?.project_id;
18415
+ if (projectId) placement.project_id = projectId;
18416
+ }
18417
+ } catch {
18418
+ }
18419
+ return placement;
18420
+ };
18297
18421
  const news = describeRelease(outcome, scope);
18298
18422
  if (news) {
18299
18423
  const notified = await notify(c, cfg.ship, news, remit, dryRun);
18300
18424
  if (!notified && news.level === "fail") {
18301
18425
  const memberId = releaseSeat(c);
18302
- const placement = {};
18303
- try {
18304
- const repoId = repoIdForName(c, target.name);
18305
- if (repoId) {
18306
- placement.repo_id = repoId;
18307
- const projectId = (await tracker.repoRow(repoId))?.project_id;
18308
- if (projectId) placement.project_id = projectId;
18309
- }
18310
- } catch {
18311
- }
18426
+ const placement = await alertPlacement();
18312
18427
  await applyFailureAlert(tracker, news, scope, all, memberId, remit, dryRun, placement).catch((e) => {
18313
18428
  remit.warn(`could not file/update a failure ticket: ${e.message}`, { step: "release" });
18314
18429
  });
@@ -18332,13 +18447,36 @@ ${changed2.map((c) => ` - ${c}
18332
18447
  }
18333
18448
  if (outcome.buildCulprits?.length) {
18334
18449
  const seat = releaseSeat(c);
18450
+ const prior = [...await tracker.events("build_failed"), ...await tracker.events("pushed")];
18335
18451
  for (const cu of outcome.buildCulprits) {
18336
18452
  try {
18337
18453
  const bounce = planBuildFailureBounce(target.dir, cu, repo.branch.base, repo.branch.remote);
18338
- const r = await applyBuildFailureBounce(tracker, bounce, tracker.contract, seat, remit, dryRun);
18454
+ const r = await applyBuildFailureBounce(
18455
+ tracker,
18456
+ bounce,
18457
+ tracker.contract,
18458
+ seat,
18459
+ remit,
18460
+ dryRun,
18461
+ { count: priorBuildFailures(cu.ticket, prior), operatorId: c.resolved?.operator }
18462
+ );
18339
18463
  if (r.kind === "failed") {
18340
18464
  remit.warn(`could not hand back ${cu.ticket.issue_id}: ${r.why}`, { step: "release" });
18341
18465
  }
18466
+ if (r.kind === "escalated" && !dryRun) {
18467
+ const key = cu.ticket.issue_tag ?? cu.ticket.issue_id;
18468
+ await applyFailureAlert(tracker, {
18469
+ level: "fail",
18470
+ headline: `${scope}: release blocked \u2014 ${key} broke the ${cu.hook} gate twice`,
18471
+ detail: `${key} (\`${cu.branch}\`) was handed back after a failed ${cu.hook} gate, pushed and re-verified, and failed it again. Isolation found it both times (${cu.how}), so it is escalated to the operator instead of another lap.
18472
+
18473
+ \`\`\`
18474
+ ${cu.tail.trimEnd()}
18475
+ \`\`\``
18476
+ }, scope, all, seat, remit, dryRun, await alertPlacement()).catch((e) => {
18477
+ remit.warn(`could not file/update a failure ticket: ${e.message}`, { step: "release" });
18478
+ });
18479
+ }
18342
18480
  } catch (e) {
18343
18481
  remit.warn(`could not hand back ${cu.ticket.issue_id}: ${e.message}`, { step: "release" });
18344
18482
  }
@@ -21546,6 +21684,8 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
21546
21684
  process.stdout.write(` - release readiness: ${p2}
21547
21685
  `);
21548
21686
  }
21687
+ process.stdout.write(` release self-repair: ${selfRepairLine(config.release.isolateFailures?.maxCandidates)}
21688
+ `);
21549
21689
  const dh = doctorBaseReport(r, config.branch.base, baseHealth);
21550
21690
  process.stdout.write(dh.text);
21551
21691
  if (dh.failed) process.exitCode = 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.79.10",
3
+ "version": "0.80.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",