automata-cli 0.8.1 → 0.9.0-develop.387

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/dist/index.js CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  readConfig,
12
12
  readRawConfig,
13
13
  writeConfig
14
- } from "./chunk-MIPBYVK5.js";
14
+ } from "./chunk-42OV4UI4.js";
15
15
 
16
16
  // src/index.ts
17
17
  import { Command as Command7 } from "commander";
@@ -26,6 +26,60 @@ var version = packageJson.version;
26
26
 
27
27
  // src/commands/config.ts
28
28
  import { Command } from "commander";
29
+
30
+ // src/git/releaseFlow.ts
31
+ var RELEASE_FLOWS = ["gitflow", "trunk"];
32
+ var RELEASE_FLOW_CONFIG_KEY = "git.releaseFlow";
33
+ function isReleaseFlow(value) {
34
+ return typeof value === "string" && RELEASE_FLOWS.includes(value);
35
+ }
36
+ function describeReleaseFlowSource(source) {
37
+ switch (source) {
38
+ case "config":
39
+ return `configured as ${RELEASE_FLOW_CONFIG_KEY}`;
40
+ case "develop-present":
41
+ return "detected: origin/develop exists";
42
+ case "develop-absent":
43
+ return "detected: origin/develop does not exist";
44
+ }
45
+ }
46
+ function invalidReleaseFlowMessage(value) {
47
+ return `Invalid ${RELEASE_FLOW_CONFIG_KEY} ${JSON.stringify(value)} in .automata/config.json; expected one of: ${RELEASE_FLOWS.join(", ")}.
48
+ Fix it with: automata config set git-release-flow <gitflow|trunk>`;
49
+ }
50
+ function planRelease(flow, version2, trunk, trunkIsLocal) {
51
+ if (flow === "trunk") {
52
+ const message = `chore(release): ${version2}`;
53
+ return [
54
+ { args: ["commit", "--allow-empty", "-m", message], desc: `git commit --allow-empty -m "${message}"` },
55
+ { args: ["tag", version2], desc: `git tag ${version2}` },
56
+ {
57
+ args: ["push", "--atomic", "origin", trunk, version2],
58
+ desc: `git push --atomic origin ${trunk} ${version2}`
59
+ }
60
+ ];
61
+ }
62
+ const releaseBranch = `release/${version2}`;
63
+ const checkoutTrunk = trunkIsLocal ? { args: ["checkout", trunk], desc: `git checkout ${trunk}` } : {
64
+ args: ["checkout", "-b", trunk, `origin/${trunk}`],
65
+ desc: `git checkout -b ${trunk} origin/${trunk}`
66
+ };
67
+ return [
68
+ { args: ["checkout", "-b", releaseBranch], desc: `git checkout -b ${releaseBranch}` },
69
+ checkoutTrunk,
70
+ { args: ["merge", "--no-ff", releaseBranch], desc: `git merge --no-ff ${releaseBranch}` },
71
+ { args: ["tag", version2], desc: `git tag ${version2}` },
72
+ { args: ["checkout", "develop"], desc: `git checkout develop` },
73
+ { args: ["merge", "--no-ff", releaseBranch], desc: `git merge --no-ff ${releaseBranch}` },
74
+ { args: ["branch", "-d", releaseBranch], desc: `git branch -d ${releaseBranch}` },
75
+ {
76
+ args: ["push", "origin", "develop", trunk, version2],
77
+ desc: `git push origin develop ${trunk} ${version2}`
78
+ }
79
+ ];
80
+ }
81
+
82
+ // src/commands/config.ts
29
83
  var VALID_TYPES = ["gh", "azdo"];
30
84
  var VALID_TECHNIQUES = ["label", "assignee", "title-contains"];
31
85
  var VALID_EXECUTORS = ["claude", "codex"];
@@ -184,6 +238,20 @@ var configSetDoWorkLockStaleMinutes = new Command("do-work-lock-stale-minutes").
184
238
  process.stdout.write(`do-work lock staleness set to: ${String(minutes)} minutes
185
239
  `);
186
240
  });
241
+ var configSetDoWorkDumpOnBlock = new Command("do-work-dump-on-block").description(
242
+ "Set whether `do-work` prints the full health report when a tick exits having done nothing"
243
+ ).argument("<value>", `true or false (default: ${String(DEFAULT_DO_WORK.dumpOnBlock)})`).action((value) => {
244
+ const normalized = value.trim().toLowerCase();
245
+ if (normalized !== "true" && normalized !== "false") {
246
+ process.stderr.write(`Error: do-work-dump-on-block must be true or false (got "${value}").
247
+ `);
248
+ process.exit(1);
249
+ }
250
+ const dumpOnBlock = normalized === "true";
251
+ writeDoWork({ dumpOnBlock });
252
+ process.stdout.write(`do-work dump on block set to: ${String(dumpOnBlock)}
253
+ `);
254
+ });
187
255
  var configSetDoWorkPrompt = new Command("do-work-prompt").description("Set the turn instructions for a `do-work` turn kind (prompt text or a .md filename)").argument("<turn-kind>", `Turn kind: ${VALID_TURN_KINDS.join(", ")}`).argument("<value>", "Prompt text, or a plain .md filename inside .automata/").action((turnKind, value) => {
188
256
  if (!VALID_TURN_KINDS.includes(turnKind)) {
189
257
  process.stderr.write(
@@ -225,12 +293,24 @@ var configSetGitTrunkBranch = new Command("git-trunk-branch").description("Pin t
225
293
  process.stdout.write(`git trunk branch set to: ${branch}
226
294
  `);
227
295
  });
228
- var configSet = new Command("set").description("Set a configuration value").addCommand(configSetType).addCommand(configSetIssueDiscoveryTechnique).addCommand(configSetIssueDiscoveryValue).addCommand(configSetClaudeSystemPrompt).addCommand(configSetAllowedUsers).addCommand(configSetAgentUser).addCommand(configSetDoWorkBaseBranch).addCommand(configSetDoWorkProtectedBranches).addCommand(configSetDoWorkExecutor).addCommand(configSetDoWorkModel).addCommand(configSetDoWorkEffort).addCommand(configSetDoWorkMaxRuns).addCommand(configSetDoWorkLockStaleMinutes).addCommand(configSetDoWorkPrompt).addCommand(configSetGitTrunkBranch);
296
+ var configSetGitReleaseFlow = new Command("git-release-flow").description("Pin the release procedure `publish-release` runs, instead of detecting it").argument("<value>", `Release flow: ${RELEASE_FLOWS.join(" | ")} (when the key is absent, the flow is detected from origin/develop)`).action((value) => {
297
+ const flow = value.trim();
298
+ if (!isReleaseFlow(flow)) {
299
+ process.stderr.write(`Error: invalid release flow "${value}". Must be one of: ${RELEASE_FLOWS.join(", ")}
300
+ `);
301
+ process.exit(1);
302
+ }
303
+ const current = readRawConfig();
304
+ writeConfig({ ...current, git: { ...current.git, releaseFlow: flow } });
305
+ process.stdout.write(`git release flow set to: ${flow}
306
+ `);
307
+ });
308
+ var configSet = new Command("set").description("Set a configuration value").addCommand(configSetType).addCommand(configSetIssueDiscoveryTechnique).addCommand(configSetIssueDiscoveryValue).addCommand(configSetClaudeSystemPrompt).addCommand(configSetAllowedUsers).addCommand(configSetAgentUser).addCommand(configSetDoWorkBaseBranch).addCommand(configSetDoWorkProtectedBranches).addCommand(configSetDoWorkExecutor).addCommand(configSetDoWorkModel).addCommand(configSetDoWorkEffort).addCommand(configSetDoWorkMaxRuns).addCommand(configSetDoWorkLockStaleMinutes).addCommand(configSetDoWorkDumpOnBlock).addCommand(configSetDoWorkPrompt).addCommand(configSetGitTrunkBranch).addCommand(configSetGitReleaseFlow);
229
309
  var configCommand = new Command("config").description("Configure automata settings").addCommand(configSet).action(async () => {
230
310
  const [{ render }, React, { ConfigWizard }] = await Promise.all([
231
311
  import("ink"),
232
312
  import("react"),
233
- import("./ConfigWizard-GULICHML.js")
313
+ import("./ConfigWizard-RGXIC7OS.js")
234
314
  ]);
235
315
  const { waitUntilExit } = render(React.createElement(ConfigWizard));
236
316
  await waitUntilExit();
@@ -291,6 +371,38 @@ function getPrInfo() {
291
371
  };
292
372
  }
293
373
 
374
+ // src/run/commandTrace.ts
375
+ var sink = null;
376
+ function startCommandTrace() {
377
+ sink = [];
378
+ }
379
+ function stopCommandTrace() {
380
+ sink = null;
381
+ }
382
+ function takeCommandTrace() {
383
+ const taken = sink;
384
+ sink = null;
385
+ return taken;
386
+ }
387
+ function recordCommand(command, args, startedAt, exitCode) {
388
+ if (sink === null) return;
389
+ sink.push({
390
+ command,
391
+ args: [...args],
392
+ // A clock that went backwards must not produce a negative duration in a
393
+ // report an operator is reading for anomalies.
394
+ durationMs: Math.max(0, Date.now() - startedAt),
395
+ exitCode
396
+ });
397
+ }
398
+ function describeTracedCommand(traced) {
399
+ const rendered = [traced.command, ...traced.args].join(" ");
400
+ const flat = rendered.replace(/\s+/g, " ").trim();
401
+ const shown = flat.length <= MAX_COMMAND_LENGTH ? flat : `${flat.slice(0, MAX_COMMAND_LENGTH)}\u2026`;
402
+ return `${shown} \u2014 ${String(traced.durationMs)}ms exit ${String(traced.exitCode)}`;
403
+ }
404
+ var MAX_COMMAND_LENGTH = 300;
405
+
294
406
  // src/git/trunkDetection.ts
295
407
  var TRUNK_CANDIDATES = ["main", "master"];
296
408
  var TRUNK_BRANCH_CONFIG_KEY = "git.trunkBranch";
@@ -331,11 +443,14 @@ Set it explicitly with: automata config set git-trunk-branch <branch>`;
331
443
 
332
444
  // src/git/gitService.ts
333
445
  function run2(cmd, args) {
446
+ const startedAt = Date.now();
334
447
  const result = spawnSync2(cmd, args, { encoding: "utf8" });
448
+ const status = result.status ?? 1;
449
+ recordCommand(cmd, args, startedAt, result.error ? -1 : status);
335
450
  return {
336
451
  stdout: result.stdout ?? "",
337
452
  stderr: result.stderr ?? "",
338
- status: result.status ?? 1
453
+ status
339
454
  };
340
455
  }
341
456
  function getCurrentBranch() {
@@ -1098,17 +1213,44 @@ ${stderr.trim()}`);
1098
1213
  }
1099
1214
  return stdout.trim().length > 0;
1100
1215
  }
1101
- function checkReleasePreconditions() {
1216
+ function probeRemoteBranch(branch) {
1217
+ const ref = `refs/heads/${branch}`;
1218
+ const { status, stderr } = run2("git", ["ls-remote", "--exit-code", "origin", ref]);
1219
+ if (status === 0) return { ok: true, exists: true };
1220
+ if (status === 2) return { ok: true, exists: false };
1221
+ return {
1222
+ ok: false,
1223
+ message: stderr.trim() || `git ls-remote --exit-code origin ${ref} failed.`
1224
+ };
1225
+ }
1226
+ function resolveReleaseFlow() {
1227
+ const configured = readRawConfig().git?.releaseFlow;
1228
+ if (configured !== void 0) {
1229
+ if (!isReleaseFlow(configured)) return { ok: false, message: invalidReleaseFlowMessage(configured) };
1230
+ return { ok: true, flow: configured, source: "config" };
1231
+ }
1232
+ const develop = probeRemoteBranch("develop");
1233
+ if (!develop.ok) {
1234
+ return {
1235
+ ok: false,
1236
+ message: `Could not check whether origin has a 'develop' branch to choose the release flow.
1237
+ ${develop.message}
1238
+ Set it explicitly with: automata config set git-release-flow <gitflow|trunk>`
1239
+ };
1240
+ }
1241
+ return develop.exists ? { ok: true, flow: "gitflow", source: "develop-present" } : { ok: true, flow: "trunk", source: "develop-absent" };
1242
+ }
1243
+ function checkReleasePreconditions(expectedBranch) {
1102
1244
  let branch;
1103
1245
  try {
1104
1246
  branch = getCurrentBranch();
1105
1247
  } catch (err) {
1106
1248
  return { ok: false, message: err.message };
1107
1249
  }
1108
- if (branch !== "develop") {
1250
+ if (branch !== expectedBranch) {
1109
1251
  return {
1110
1252
  ok: false,
1111
- message: `publish-release must be run from the 'develop' branch (currently on '${branch}').`
1253
+ message: `publish-release must be run from the '${expectedBranch}' branch (currently on '${branch}').`
1112
1254
  };
1113
1255
  }
1114
1256
  if (hasUncommittedChanges()) {
@@ -1119,25 +1261,8 @@ function checkReleasePreconditions() {
1119
1261
  }
1120
1262
  return { ok: true };
1121
1263
  }
1122
- function publishRelease(version2, dryRun, trunk) {
1123
- const releaseBranch = `release/${version2}`;
1124
- const checkoutTrunk = localBranchExists(trunk) ? { args: ["checkout", trunk], desc: `git checkout ${trunk}` } : {
1125
- args: ["checkout", "-b", trunk, `origin/${trunk}`],
1126
- desc: `git checkout -b ${trunk} origin/${trunk}`
1127
- };
1128
- const steps = [
1129
- { args: ["checkout", "-b", releaseBranch], desc: `git checkout -b ${releaseBranch}` },
1130
- checkoutTrunk,
1131
- { args: ["merge", "--no-ff", releaseBranch], desc: `git merge --no-ff ${releaseBranch}` },
1132
- { args: ["tag", version2], desc: `git tag ${version2}` },
1133
- { args: ["checkout", "develop"], desc: `git checkout develop` },
1134
- { args: ["merge", "--no-ff", releaseBranch], desc: `git merge --no-ff ${releaseBranch}` },
1135
- { args: ["branch", "-d", releaseBranch], desc: `git branch -d ${releaseBranch}` },
1136
- {
1137
- args: ["push", "origin", "develop", trunk, version2],
1138
- desc: `git push origin develop ${trunk} ${version2}`
1139
- }
1140
- ];
1264
+ function publishRelease(version2, dryRun, trunk, flow) {
1265
+ const steps = planRelease(flow, version2, trunk, flow === "gitflow" && localBranchExists(trunk));
1141
1266
  for (const step of steps) {
1142
1267
  if (dryRun) {
1143
1268
  process.stdout.write(`[dry-run] ${step.desc}
@@ -1565,10 +1690,13 @@ var finishFeatureCmd = new Command2("finish-feature").description("Clean up a me
1565
1690
  process.exit(1);
1566
1691
  }
1567
1692
  });
1568
- var publishReleaseCmd = new Command2("publish-release").description("Execute the full GitFlow release sequence and push to origin").argument("[version]", "Release version in X.Y.Z format (auto-detected from the trunk tag if omitted)").option("--dry-run", "Print git commands without executing them").addHelpText(
1693
+ var publishReleaseCmd = new Command2("publish-release").description("Execute the release sequence (GitFlow or trunk-based) and push to origin").argument("[version]", "Release version in X.Y.Z format (auto-detected from the trunk tag if omitted)").option("--dry-run", "Print git commands without executing them").addHelpText(
1569
1694
  "after",
1570
1695
  `
1571
- Release sequence:
1696
+ Release flow: git.releaseFlow in .automata/config.json if set, else detected \u2014
1697
+ gitflow when origin has a 'develop' branch, trunk when it does not.
1698
+
1699
+ GitFlow sequence (run from develop):
1572
1700
  1. git checkout -b release/<version>
1573
1701
  2. git checkout <trunk> && git merge --no-ff release/<version>
1574
1702
  3. git tag <version>
@@ -1576,6 +1704,11 @@ Release sequence:
1576
1704
  5. git branch -d release/<version>
1577
1705
  6. git push origin develop <trunk> <version>
1578
1706
 
1707
+ Trunk sequence (run from <trunk>; it may be ahead of origin, not behind):
1708
+ 1. git commit --allow-empty -m "chore(release): <version>"
1709
+ 2. git tag <version>
1710
+ 3. git push --atomic origin <trunk> <version>
1711
+
1579
1712
  <trunk> is resolved from origin \u2014 git.trunkBranch in .automata/config.json if
1580
1713
  set, else origin/HEAD, the remote's advertised HEAD, or a probe of main/master.
1581
1714
  Tags are fetched from origin first, in --dry-run too, so the version a dry run
@@ -1586,16 +1719,10 @@ and the minor segment is incremented (e.g. 1.2.0 \u2192 1.3.0).
1586
1719
 
1587
1720
  CHANGELOG.md must already carry a '## [<version>] - YYYY-MM-DD' section for the
1588
1721
  version being released; the release is refused otherwise, in --dry-run too, and
1589
- before any branch, merge or tag is created. A repository with no CHANGELOG.md is
1590
- not subject to the check.`
1722
+ before any commit, branch, merge or tag is created. A repository with no
1723
+ CHANGELOG.md is not subject to the check.`
1591
1724
  ).action((version2, options) => {
1592
1725
  const dryRun = options.dryRun ?? false;
1593
- const preconditions = checkReleasePreconditions();
1594
- if (!preconditions.ok) {
1595
- process.stderr.write(`Error: ${preconditions.message}
1596
- `);
1597
- process.exit(1);
1598
- }
1599
1726
  const trunk = resolveTrunkBranch();
1600
1727
  if (!trunk.ok) {
1601
1728
  process.stderr.write(`Error: ${unresolvedTrunkMessage(trunk.attempted)}
@@ -1604,7 +1731,22 @@ not subject to the check.`
1604
1731
  }
1605
1732
  const trunkBranch = trunk.branch;
1606
1733
  const trunkRef = `origin/${trunkBranch}`;
1734
+ const flow = resolveReleaseFlow();
1735
+ if (!flow.ok) {
1736
+ process.stderr.write(`Error: ${flow.message}
1737
+ `);
1738
+ process.exit(1);
1739
+ }
1740
+ const releaseFlow = flow.flow;
1741
+ const preconditions = checkReleasePreconditions(releaseFlow === "gitflow" ? "develop" : trunkBranch);
1742
+ if (!preconditions.ok) {
1743
+ process.stderr.write(`Error: ${preconditions.message}
1744
+ `);
1745
+ process.exit(1);
1746
+ }
1607
1747
  process.stdout.write(`Trunk branch: ${trunkBranch} (${describeTrunkSource(trunk.source, trunkBranch)})
1748
+ `);
1749
+ process.stdout.write(`Release flow: ${releaseFlow} (${describeReleaseFlowSource(flow.source)})
1608
1750
  `);
1609
1751
  const fetched = fetchTrunkAndTags(trunkBranch);
1610
1752
  if (!fetched.ok) {
@@ -1651,7 +1793,7 @@ ${fetched.message}
1651
1793
  `);
1652
1794
  }
1653
1795
  try {
1654
- publishRelease(resolvedVersion, dryRun, trunkBranch);
1796
+ publishRelease(resolvedVersion, dryRun, trunkBranch, releaseFlow);
1655
1797
  } catch (err) {
1656
1798
  process.stderr.write(`Error: ${err.message}
1657
1799
  `);
@@ -1672,7 +1814,9 @@ import { Command as Command3 } from "commander";
1672
1814
  import { spawnSync as spawnSync3 } from "child_process";
1673
1815
  var GITHUB_ISSUE_COMMENT_URL_RE = /github\.com\/([^/]+\/[^/]+)\/issues\/\d+#issuecomment-(\d+)/;
1674
1816
  function run3(cmd, args) {
1817
+ const startedAt = Date.now();
1675
1818
  const result = spawnSync3(cmd, args, { encoding: "utf8" });
1819
+ recordCommand(cmd, args, startedAt, result.error ? -1 : result.status ?? 1);
1676
1820
  if (result.error) {
1677
1821
  const err = result.error;
1678
1822
  if (err.code === "ENOENT") {
@@ -1828,7 +1972,9 @@ function addCopilotReviewer(prNumber) {
1828
1972
  // src/github/ghWorkService.ts
1829
1973
  import { spawnSync as spawnSync4 } from "child_process";
1830
1974
  function run4(cmd, args) {
1975
+ const startedAt = Date.now();
1831
1976
  const result = spawnSync4(cmd, args, { encoding: "utf8" });
1977
+ recordCommand(cmd, args, startedAt, result.error ? -1 : result.status ?? 1);
1832
1978
  if (result.error) {
1833
1979
  const err = result.error;
1834
1980
  if (err.code === "ENOENT") {
@@ -3666,19 +3812,108 @@ function analyseAnswer(messages, p, marker, watermark) {
3666
3812
  }
3667
3813
 
3668
3814
  // src/run/runLock.ts
3669
- import { writeFileSync, readFileSync as readFileSync4, unlinkSync, mkdirSync, renameSync, linkSync, statSync as statSync2 } from "fs";
3815
+ import { writeFileSync as writeFileSync2, readFileSync as readFileSync5, unlinkSync as unlinkSync2, mkdirSync, renameSync, linkSync, statSync as statSync2 } from "fs";
3670
3816
  import { randomUUID } from "crypto";
3671
3817
  import { hostname } from "os";
3818
+ import { join as join4 } from "path";
3819
+
3820
+ // src/run/heartbeat.ts
3821
+ import { unlinkSync, readFileSync as readFileSync4, writeFileSync } from "fs";
3672
3822
  import { join as join3 } from "path";
3823
+ var HEARTBEAT_DIR = ".automata";
3824
+ var HEARTBEAT_FILE = "automata-heartbeat.json";
3825
+ var HEARTBEAT_RELATIVE_PATH = `${HEARTBEAT_DIR}/${HEARTBEAT_FILE}`;
3826
+ var PHASES = ["pre-flight", "discovery", "item", "summary"];
3827
+ function isPhase(value) {
3828
+ return typeof value === "string" && PHASES.includes(value);
3829
+ }
3830
+ function heartbeatPath(cwd = process.cwd()) {
3831
+ return join3(cwd, HEARTBEAT_DIR, HEARTBEAT_FILE);
3832
+ }
3833
+ function buildHeartbeat(token, update, now = /* @__PURE__ */ new Date()) {
3834
+ return {
3835
+ token,
3836
+ updatedAt: now.toISOString(),
3837
+ phase: update.phase,
3838
+ item: update.item ?? null,
3839
+ executor: update.executor ?? null
3840
+ };
3841
+ }
3842
+ function formatHeartbeat(heartbeat) {
3843
+ return JSON.stringify(heartbeat, null, 2) + "\n";
3844
+ }
3845
+ function parseHeartbeat(content) {
3846
+ let parsed;
3847
+ try {
3848
+ parsed = JSON.parse(content);
3849
+ } catch {
3850
+ return null;
3851
+ }
3852
+ if (typeof parsed !== "object" || parsed === null) return null;
3853
+ const raw = parsed;
3854
+ if (typeof raw.token !== "string" || raw.token.length === 0) return null;
3855
+ if (typeof raw.updatedAt !== "string") return null;
3856
+ if (!isPhase(raw.phase)) return null;
3857
+ return {
3858
+ token: raw.token,
3859
+ updatedAt: raw.updatedAt,
3860
+ phase: raw.phase,
3861
+ item: parseItem(raw.item),
3862
+ executor: parseExecutor(raw.executor)
3863
+ };
3864
+ }
3865
+ function parseItem(value) {
3866
+ if (typeof value !== "object" || value === null) return null;
3867
+ const raw = value;
3868
+ if (typeof raw.index !== "number" || typeof raw.total !== "number") return null;
3869
+ if (typeof raw.subject !== "string") return null;
3870
+ return { index: raw.index, total: raw.total, subject: raw.subject };
3871
+ }
3872
+ function parseExecutor(value) {
3873
+ if (typeof value !== "object" || value === null) return null;
3874
+ const raw = value;
3875
+ if (typeof raw.command !== "string" || typeof raw.startedAt !== "string") return null;
3876
+ return { command: raw.command, startedAt: raw.startedAt };
3877
+ }
3878
+ function writeHeartbeat(token, update, cwd = process.cwd(), now = /* @__PURE__ */ new Date()) {
3879
+ try {
3880
+ writeFileSync(heartbeatPath(cwd), formatHeartbeat(buildHeartbeat(token, update, now)), "utf8");
3881
+ } catch {
3882
+ }
3883
+ }
3884
+ function clearHeartbeat(token, cwd = process.cwd()) {
3885
+ if (readHeartbeat(token, cwd) === null) return;
3886
+ try {
3887
+ unlinkSync(heartbeatPath(cwd));
3888
+ } catch {
3889
+ }
3890
+ }
3891
+ function readHeartbeat(token, cwd = process.cwd()) {
3892
+ let content;
3893
+ try {
3894
+ content = readFileSync4(heartbeatPath(cwd), "utf8");
3895
+ } catch {
3896
+ return null;
3897
+ }
3898
+ const heartbeat = parseHeartbeat(content);
3899
+ if (heartbeat?.token !== token) return null;
3900
+ return heartbeat;
3901
+ }
3902
+
3903
+ // src/run/runLock.ts
3673
3904
  var LOCK_DIR = ".automata";
3674
3905
  var LOCK_FILE = "automata.lock";
3675
3906
  var RUN_LOCK_RELATIVE_PATH = `${LOCK_DIR}/${LOCK_FILE}`;
3907
+ var AUTOMATA_OWN_PATHS = [
3908
+ RUN_LOCK_RELATIVE_PATH,
3909
+ HEARTBEAT_RELATIVE_PATH
3910
+ ];
3676
3911
  function lockPath() {
3677
- return join3(process.cwd(), LOCK_DIR, LOCK_FILE);
3912
+ return join4(process.cwd(), LOCK_DIR, LOCK_FILE);
3678
3913
  }
3679
3914
  function processStartedAt(pid) {
3680
3915
  try {
3681
- const stat = readFileSync4(`/proc/${String(pid)}/stat`, "utf8");
3916
+ const stat = readFileSync5(`/proc/${String(pid)}/stat`, "utf8");
3682
3917
  const afterComm = stat.slice(stat.lastIndexOf(")") + 2);
3683
3918
  const field = afterComm.split(" ")[19];
3684
3919
  return field === void 0 || field.length === 0 ? null : field;
@@ -3696,7 +3931,7 @@ function isAlive(pid) {
3696
3931
  }
3697
3932
  function readOwnerError(path) {
3698
3933
  try {
3699
- readFileSync4(path, "utf8");
3934
+ readFileSync5(path, "utf8");
3700
3935
  return null;
3701
3936
  } catch (err) {
3702
3937
  return err.message;
@@ -3704,7 +3939,7 @@ function readOwnerError(path) {
3704
3939
  }
3705
3940
  function readOwner(path) {
3706
3941
  try {
3707
- const parsed = JSON.parse(readFileSync4(path, "utf8"));
3942
+ const parsed = JSON.parse(readFileSync5(path, "utf8"));
3708
3943
  if (typeof parsed.pid !== "number" || typeof parsed.startedAt !== "string") return null;
3709
3944
  return {
3710
3945
  pid: parsed.pid,
@@ -3712,7 +3947,8 @@ function readOwner(path) {
3712
3947
  host: parsed.host ?? "unknown",
3713
3948
  command: parsed.command ?? "unknown",
3714
3949
  token: parsed.token ?? "",
3715
- pidStartedAt: parsed.pidStartedAt
3950
+ pidStartedAt: parsed.pidStartedAt,
3951
+ cwd: parsed.cwd
3716
3952
  };
3717
3953
  } catch {
3718
3954
  return null;
@@ -3741,10 +3977,16 @@ function heldTooLong(owner, staleMinutes) {
3741
3977
  }
3742
3978
  function makeHandle(path, token) {
3743
3979
  let released = false;
3980
+ const cwd = process.cwd();
3744
3981
  return {
3982
+ heartbeat(update) {
3983
+ if (released) return;
3984
+ writeHeartbeat(token, update, cwd);
3985
+ },
3745
3986
  release() {
3746
3987
  if (released) return;
3747
3988
  released = true;
3989
+ clearHeartbeat(token, cwd);
3748
3990
  const current = readOwner(path);
3749
3991
  if (current !== null && current.token !== token) {
3750
3992
  return;
@@ -3758,7 +4000,7 @@ function makeHandle(path, token) {
3758
4000
  const owner = readOwner(takenAway);
3759
4001
  if (owner === null || owner.token === token) {
3760
4002
  try {
3761
- unlinkSync(takenAway);
4003
+ unlinkSync2(takenAway);
3762
4004
  } catch {
3763
4005
  }
3764
4006
  return;
@@ -3768,7 +4010,7 @@ function makeHandle(path, token) {
3768
4010
  } catch {
3769
4011
  }
3770
4012
  try {
3771
- unlinkSync(takenAway);
4013
+ unlinkSync2(takenAway);
3772
4014
  } catch {
3773
4015
  }
3774
4016
  }
@@ -3781,11 +4023,12 @@ function publishLock(path, command, token) {
3781
4023
  host: hostname(),
3782
4024
  command,
3783
4025
  token,
3784
- pidStartedAt: processStartedAt(process.pid) ?? void 0
4026
+ pidStartedAt: processStartedAt(process.pid) ?? void 0,
4027
+ cwd: process.cwd()
3785
4028
  };
3786
4029
  const staging = `${path}.staging.${token}`;
3787
4030
  try {
3788
- writeFileSync(staging, JSON.stringify(owner, null, 2) + "\n", { encoding: "utf8", flag: "wx" });
4031
+ writeFileSync2(staging, JSON.stringify(owner, null, 2) + "\n", { encoding: "utf8", flag: "wx" });
3789
4032
  } catch {
3790
4033
  return false;
3791
4034
  }
@@ -3797,7 +4040,7 @@ function publishLock(path, command, token) {
3797
4040
  return false;
3798
4041
  } finally {
3799
4042
  try {
3800
- unlinkSync(staging);
4043
+ unlinkSync2(staging);
3801
4044
  } catch {
3802
4045
  }
3803
4046
  }
@@ -3805,7 +4048,7 @@ function publishLock(path, command, token) {
3805
4048
  function acquireRunLock(command, staleMinutes) {
3806
4049
  const path = lockPath();
3807
4050
  const token = randomUUID();
3808
- mkdirSync(join3(process.cwd(), LOCK_DIR), { recursive: true });
4051
+ mkdirSync(join4(process.cwd(), LOCK_DIR), { recursive: true });
3809
4052
  if (publishLock(path, command, token)) {
3810
4053
  return { ok: true, handle: makeHandle(path, token) };
3811
4054
  }
@@ -3840,7 +4083,7 @@ function claimStaleLock(path, token, expected) {
3840
4083
  } catch {
3841
4084
  }
3842
4085
  try {
3843
- unlinkSync(claimed);
4086
+ unlinkSync2(claimed);
3844
4087
  } catch {
3845
4088
  }
3846
4089
  return false;
@@ -3852,7 +4095,7 @@ var CLAIM_STALE_MS = 6e4;
3852
4095
  function acquireClaim(path) {
3853
4096
  const claimPath = `${path}.claim`;
3854
4097
  try {
3855
- writeFileSync(claimPath, JSON.stringify({ pid: process.pid, at: (/* @__PURE__ */ new Date()).toISOString() }), {
4098
+ writeFileSync2(claimPath, JSON.stringify({ pid: process.pid, at: (/* @__PURE__ */ new Date()).toISOString() }), {
3856
4099
  encoding: "utf8",
3857
4100
  flag: "wx"
3858
4101
  });
@@ -3867,11 +4110,11 @@ function acquireClaim(path) {
3867
4110
  return null;
3868
4111
  }
3869
4112
  try {
3870
- unlinkSync(`${claimPath}.abandoned.${String(process.pid)}`);
4113
+ unlinkSync2(`${claimPath}.abandoned.${String(process.pid)}`);
3871
4114
  } catch {
3872
4115
  }
3873
4116
  try {
3874
- writeFileSync(claimPath, JSON.stringify({ pid: process.pid, at: (/* @__PURE__ */ new Date()).toISOString() }), {
4117
+ writeFileSync2(claimPath, JSON.stringify({ pid: process.pid, at: (/* @__PURE__ */ new Date()).toISOString() }), {
3875
4118
  encoding: "utf8",
3876
4119
  flag: "wx"
3877
4120
  });
@@ -3883,7 +4126,7 @@ function acquireClaim(path) {
3883
4126
  function claimIsAbandoned(claimPath) {
3884
4127
  let at = Number.NaN;
3885
4128
  try {
3886
- const raw = JSON.parse(readFileSync4(claimPath, "utf8"));
4129
+ const raw = JSON.parse(readFileSync5(claimPath, "utf8"));
3887
4130
  if (raw.at !== void 0) at = Date.parse(raw.at);
3888
4131
  } catch {
3889
4132
  }
@@ -3915,13 +4158,13 @@ function reclaim(path, command, token, expected) {
3915
4158
  return { ok: false, heldBy: readOwner(path) ?? UNKNOWN_OWNER, suspect: false };
3916
4159
  } finally {
3917
4160
  try {
3918
- unlinkSync(claimed);
4161
+ unlinkSync2(claimed);
3919
4162
  } catch {
3920
4163
  }
3921
4164
  }
3922
4165
  } finally {
3923
4166
  try {
3924
- unlinkSync(claimPath);
4167
+ unlinkSync2(claimPath);
3925
4168
  } catch {
3926
4169
  }
3927
4170
  }
@@ -3946,7 +4189,12 @@ function inspectRunLock(staleMinutes, now = Date.now()) {
3946
4189
  }
3947
4190
  const held = owner;
3948
4191
  const kind = heldTooLong(held, staleMinutes) ? "suspect" : "held";
3949
- return { kind, owner: held, heldForMs: heldForMs(held, now) };
4192
+ return {
4193
+ kind,
4194
+ owner: held,
4195
+ heldForMs: heldForMs(held, now),
4196
+ heartbeat: readHeartbeat(held.token, held.cwd ?? process.cwd())
4197
+ };
3950
4198
  }
3951
4199
 
3952
4200
  // src/git/workspaceService.ts
@@ -3958,7 +4206,7 @@ function dirtyTree() {
3958
4206
  };
3959
4207
  }
3960
4208
  function prepareBaseBranch(baseBranch) {
3961
- if (hasUncommittedChanges([RUN_LOCK_RELATIVE_PATH])) return dirtyTree();
4209
+ if (hasUncommittedChanges([...AUTOMATA_OWN_PATHS])) return dirtyTree();
3962
4210
  const checkout = checkoutBranch(baseBranch);
3963
4211
  if (!checkout.ok) {
3964
4212
  return { ok: false, reason: "checkout-failed", detail: checkout.stderr };
@@ -4054,7 +4302,7 @@ function divergenceRefusal(headRefName, pullError) {
4054
4302
  };
4055
4303
  }
4056
4304
  function preparePrBranch(headRefName) {
4057
- if (hasUncommittedChanges([RUN_LOCK_RELATIVE_PATH])) return dirtyTree();
4305
+ if (hasUncommittedChanges([...AUTOMATA_OWN_PATHS])) return dirtyTree();
4058
4306
  const previousRemoteSha = revParse(`refs/remotes/origin/${headRefName}`);
4059
4307
  const fetched = fetchBranch(headRefName);
4060
4308
  if (!fetched.ok) {
@@ -4130,7 +4378,7 @@ function findOpenPr(branch) {
4130
4378
  return prs.find((pr) => pr.state === "OPEN") ?? null;
4131
4379
  }
4132
4380
  function rescueUncommittedChanges(options, now) {
4133
- if (!hasUncommittedChanges([RUN_LOCK_RELATIVE_PATH])) {
4381
+ if (!hasUncommittedChanges([...AUTOMATA_OWN_PATHS])) {
4134
4382
  options.log(" rescue nothing to do; the working tree is clean\n");
4135
4383
  return { kind: "clean" };
4136
4384
  }
@@ -4143,7 +4391,7 @@ function rescueUncommittedChanges(options, now) {
4143
4391
  );
4144
4392
  return { kind: "would-rescue", branch: target.branch, createdBranch: target.createdBranch };
4145
4393
  }
4146
- const staged = stageAllExcept([RUN_LOCK_RELATIVE_PATH]);
4394
+ const staged = stageAllExcept([...AUTOMATA_OWN_PATHS]);
4147
4395
  if (!staged.ok) {
4148
4396
  options.log(` rescue FAILED to stage the changes: ${staged.stderr}
4149
4397
  `);
@@ -4422,13 +4670,13 @@ import {
4422
4670
  accessSync as accessSync2,
4423
4671
  appendFileSync,
4424
4672
  constants as constants2,
4425
- readFileSync as readFileSync5,
4673
+ readFileSync as readFileSync6,
4426
4674
  renameSync as renameSync2,
4427
- unlinkSync as unlinkSync2,
4428
- writeFileSync as writeFileSync2
4675
+ unlinkSync as unlinkSync3,
4676
+ writeFileSync as writeFileSync3
4429
4677
  } from "fs";
4430
4678
  import { randomUUID as randomUUID2 } from "crypto";
4431
- import { dirname as dirname2, join as join4 } from "path";
4679
+ import { dirname as dirname2, join as join5 } from "path";
4432
4680
  var EXECUTION_LOG_FILE = "automata-execution.log";
4433
4681
  var WORK_LOG_FILE = "automata-work.log";
4434
4682
  var MAX_EXECUTION_LINES = 1e3;
@@ -4444,6 +4692,14 @@ var OUTCOMES = [
4444
4692
  function operationLogDirectory() {
4445
4693
  return dirname2(process.cwd());
4446
4694
  }
4695
+ function inspectLogDirectory(dir = operationLogDirectory(), cwd = process.cwd()) {
4696
+ try {
4697
+ accessSync2(dir, constants2.W_OK);
4698
+ return { dir, cwd, writable: true, detail: null };
4699
+ } catch (err) {
4700
+ return { dir, cwd, writable: false, detail: err.message };
4701
+ }
4702
+ }
4447
4703
  function repoField(repo) {
4448
4704
  if (repo === null) return "-";
4449
4705
  const flat = oneLine(repo);
@@ -4550,18 +4806,18 @@ function pruneOldRecords(content, now, maxAgeMs) {
4550
4806
  }).map((group) => group.lines.join("\n") + "\n").join("");
4551
4807
  }
4552
4808
  function appendWithRetention(dir, file, content, retain) {
4553
- const target = join4(dir, file);
4809
+ const target = join5(dir, file);
4554
4810
  appendFileSync(target, content, "utf8");
4555
- const existing = readFileSync5(target, "utf8");
4811
+ const existing = readFileSync6(target, "utf8");
4556
4812
  const retained = retain(existing);
4557
4813
  if (retained === existing) return;
4558
4814
  const temp = `${target}.${randomUUID2()}.tmp`;
4559
4815
  try {
4560
- writeFileSync2(temp, retained, "utf8");
4816
+ writeFileSync3(temp, retained, "utf8");
4561
4817
  renameSync2(temp, target);
4562
4818
  } finally {
4563
4819
  try {
4564
- unlinkSync2(temp);
4820
+ unlinkSync3(temp);
4565
4821
  } catch {
4566
4822
  }
4567
4823
  }
@@ -4599,7 +4855,7 @@ function isOutcome(value) {
4599
4855
  }
4600
4856
  function readLogFile(path) {
4601
4857
  try {
4602
- return { content: readFileSync5(path, "utf8"), error: null };
4858
+ return { content: readFileSync6(path, "utf8"), error: null };
4603
4859
  } catch (err) {
4604
4860
  if (err.code === "ENOENT") return { content: null, error: null };
4605
4861
  return { content: null, error: err.message };
@@ -4665,7 +4921,7 @@ function parseExecutionLine(line) {
4665
4921
  };
4666
4922
  }
4667
4923
  function readExecutionTicks(options = {}) {
4668
- const path = join4(options.dir ?? operationLogDirectory(), EXECUTION_LOG_FILE);
4924
+ const path = join5(options.dir ?? operationLogDirectory(), EXECUTION_LOG_FILE);
4669
4925
  const { content, error } = readLogFile(path);
4670
4926
  if (content === null) return emptyResult(path, error, isFiltered(options.repo));
4671
4927
  const entries = [];
@@ -4727,7 +4983,7 @@ function parseWorkItem(line) {
4727
4983
  };
4728
4984
  }
4729
4985
  function readWorkRecords(options = {}) {
4730
- const path = join4(options.dir ?? operationLogDirectory(), WORK_LOG_FILE);
4986
+ const path = join5(options.dir ?? operationLogDirectory(), WORK_LOG_FILE);
4731
4987
  const { content, error } = readLogFile(path);
4732
4988
  if (content === null) return emptyResult(path, error, isFiltered(options.repo));
4733
4989
  const entries = [];
@@ -4779,11 +5035,14 @@ function readWorkRecords(options = {}) {
4779
5035
  import { spawnSync as spawnSync7 } from "child_process";
4780
5036
  var GIT_BIN = resolveCommand("git");
4781
5037
  function git(args) {
5038
+ const startedAt = Date.now();
4782
5039
  const result = spawnSync7(GIT_BIN, args, { encoding: "utf8" });
5040
+ const status = result.status ?? 1;
5041
+ recordCommand(GIT_BIN, args, startedAt, result.error ? -1 : status);
4783
5042
  return {
4784
5043
  stdout: result.stdout ?? "",
4785
5044
  stderr: result.stderr ?? "",
4786
- status: result.status ?? 1
5045
+ status
4787
5046
  };
4788
5047
  }
4789
5048
  function notARepo(baseBranch, detail) {
@@ -4804,7 +5063,8 @@ function notARepo(baseBranch, detail) {
4804
5063
  };
4805
5064
  }
4806
5065
  function isOwnLockFile(porcelainEntry) {
4807
- return porcelainEntry.slice(3).trim().endsWith(RUN_LOCK_RELATIVE_PATH);
5066
+ const path = porcelainEntry.slice(3).trim();
5067
+ return AUTOMATA_OWN_PATHS.some((own) => path.endsWith(own));
4808
5068
  }
4809
5069
  function divergence(upstream, branch) {
4810
5070
  const { stdout, status } = git([
@@ -4890,6 +5150,9 @@ function inspectRepoStatus(options) {
4890
5150
  }
4891
5151
 
4892
5152
  // src/run/checkReport.ts
5153
+ function finding(summary, command) {
5154
+ return { summary, command };
5155
+ }
4893
5156
  var SECTION_ORDER = [
4894
5157
  "lock",
4895
5158
  "ticks",
@@ -4951,36 +5214,84 @@ function build(id, lines, problems, data) {
4951
5214
  id,
4952
5215
  title: SECTION_TITLES[id],
4953
5216
  lines,
4954
- problems: problems.map((summary) => ({ section: id, summary })),
5217
+ problems: problems.map((item) => ({ section: id, ...item })),
4955
5218
  data
4956
5219
  };
4957
5220
  }
4958
5221
  function describeOwner(owner) {
4959
5222
  return `pid ${String(owner.pid)} on ${owner.host}, \`${owner.command}\`, since ${owner.startedAt}`;
4960
5223
  }
4961
- function lockSection(status, staleMinutes) {
4962
- const data = { status: status.kind, staleMinutes };
5224
+ function describeOwnerLocation(owner, context, lines, problems) {
5225
+ if (owner.cwd === void 0) {
5226
+ lines.push(" working directory: not recorded (lock written by an older automata)");
5227
+ return;
5228
+ }
5229
+ lines.push(` working directory: ${owner.cwd}`);
5230
+ if (owner.cwd === context.cwd) return;
5231
+ const ownerLogs = context.parentOf(owner.cwd);
5232
+ lines.push(` logs to ${ownerLogs} (this check reads ${context.logDirectory})`);
5233
+ problems.push(
5234
+ finding(
5235
+ `the tick holding the lock runs from \`${owner.cwd}\` and writes its operation logs to \`${ownerLogs}\`, while this check reads \`${context.logDirectory}\` \u2014 the two will not agree about what has run; run the check from the directory the scheduler uses`,
5236
+ `ls -l ${ownerLogs}/automata-execution.log`
5237
+ )
5238
+ );
5239
+ }
5240
+ function describeHeartbeat(heartbeat, now, lines) {
5241
+ if (heartbeat === void 0 || heartbeat === null) {
5242
+ lines.push(" phase: not reported (no heartbeat from this holder)");
5243
+ return;
5244
+ }
5245
+ const updated = Date.parse(heartbeat.updatedAt);
5246
+ const age = Number.isNaN(updated) ? "unknown" : `${describeDuration(now.getTime() - updated)} ago`;
5247
+ const item = heartbeat.item === null ? "" : ` \u2014 item ${String(heartbeat.item.index)} of ${String(heartbeat.item.total)}, ${heartbeat.item.subject}`;
5248
+ lines.push(` phase: ${heartbeat.phase}${item} (updated ${age})`);
5249
+ if (heartbeat.executor === null) return;
5250
+ const started = Date.parse(heartbeat.executor.startedAt);
5251
+ const running = Number.isNaN(started) ? `since ${heartbeat.executor.startedAt}` : `running for ${describeDuration(now.getTime() - started)}`;
5252
+ lines.push(` executor: ${heartbeat.executor.command}, ${running}`);
5253
+ }
5254
+ function lockSection(status, staleMinutes, context, now = /* @__PURE__ */ new Date()) {
5255
+ const data = {
5256
+ status: status.kind,
5257
+ staleMinutes,
5258
+ cwd: context.cwd,
5259
+ logDirectory: context.logDirectory
5260
+ };
4963
5261
  switch (status.kind) {
4964
5262
  case "free":
4965
5263
  return build("lock", ["no tick is running in this checkout"], [], data);
4966
5264
  case "held": {
4967
5265
  const held = status.heldForMs === null ? "" : ` (${describeDuration(status.heldForMs)} so far)`;
4968
- return build("lock", [`a tick is running: ${describeOwner(status.owner)}${held}`], [], {
5266
+ const lines = [`a tick is running: ${describeOwner(status.owner)}${held}`];
5267
+ const problems = [];
5268
+ describeOwnerLocation(status.owner, context, lines, problems);
5269
+ describeHeartbeat(status.heartbeat, now, lines);
5270
+ return build("lock", lines, problems, {
4969
5271
  ...data,
4970
5272
  owner: status.owner,
4971
- heldForMs: status.heldForMs
5273
+ heldForMs: status.heldForMs,
5274
+ heartbeat: status.heartbeat ?? null
4972
5275
  });
4973
5276
  }
4974
5277
  case "suspect": {
4975
5278
  const held = status.heldForMs === null ? "unknown" : describeDuration(status.heldForMs);
4976
- return build(
4977
- "lock",
4978
- [`a tick has held the lock for ${held}: ${describeOwner(status.owner)}`],
4979
- [
4980
- `the run lock has been held longer than ${String(staleMinutes)} minutes by a process whose identity cannot be verified; if no executor is running, kill the holder or delete \`.automata/automata.lock\``
4981
- ],
4982
- { ...data, owner: status.owner, heldForMs: status.heldForMs }
5279
+ const lines = [`a tick has held the lock for ${held}: ${describeOwner(status.owner)}`];
5280
+ const problems = [];
5281
+ describeOwnerLocation(status.owner, context, lines, problems);
5282
+ describeHeartbeat(status.heartbeat, now, lines);
5283
+ problems.push(
5284
+ finding(
5285
+ `the run lock has been held longer than ${String(staleMinutes)} minutes by a process whose identity cannot be verified; if no executor is running, kill the holder or delete \`.automata/automata.lock\``,
5286
+ `ps -p ${String(status.owner.pid)} -o pid,lstart,args`
5287
+ )
4983
5288
  );
5289
+ return build("lock", lines, problems, {
5290
+ ...data,
5291
+ owner: status.owner,
5292
+ heldForMs: status.heldForMs,
5293
+ heartbeat: status.heartbeat ?? null
5294
+ });
4984
5295
  }
4985
5296
  case "stale":
4986
5297
  return build(
@@ -4989,7 +5300,10 @@ function lockSection(status, staleMinutes) {
4989
5300
  status.owner === null ? "a run lock exists but could not be parsed" : `a stale run lock is present: ${describeOwner(status.owner)}`
4990
5301
  ],
4991
5302
  [
4992
- "a stale run lock is present; the next tick reclaims it automatically, so no action is needed unless ticks keep being turned away"
5303
+ finding(
5304
+ "a stale run lock is present; the next tick reclaims it automatically, so no action is needed unless ticks keep being turned away",
5305
+ "cat .automata/automata.lock"
5306
+ )
4993
5307
  ],
4994
5308
  { ...data, owner: status.owner, heldForMs: status.heldForMs }
4995
5309
  );
@@ -4997,7 +5311,12 @@ function lockSection(status, staleMinutes) {
4997
5311
  return build(
4998
5312
  "lock",
4999
5313
  [`the run lock could not be read: ${status.detail}`],
5000
- [`the run lock at \`.automata/automata.lock\` could not be read: ${status.detail}`],
5314
+ [
5315
+ finding(
5316
+ `the run lock at \`.automata/automata.lock\` could not be read: ${status.detail}`,
5317
+ "ls -l .automata/automata.lock"
5318
+ )
5319
+ ],
5001
5320
  { ...data, detail: status.detail }
5002
5321
  );
5003
5322
  }
@@ -5019,28 +5338,56 @@ function describeMissingTicks(read) {
5019
5338
  if (read.error !== null) {
5020
5339
  return {
5021
5340
  line: `the execution log could not be read: ${read.error}`,
5022
- problem: `the execution log \`${read.path}\` could not be read: ${read.error}`
5341
+ problem: finding(
5342
+ `the execution log \`${read.path}\` could not be read: ${read.error}`,
5343
+ `ls -l ${read.path}`
5344
+ ),
5345
+ explainedByLiveTick: false
5023
5346
  };
5024
5347
  }
5025
5348
  if (!read.present) {
5026
5349
  return {
5027
5350
  line: `no execution log at ${read.path}`,
5028
- problem: `no execution log at \`${read.path}\` \u2014 no tick has ever run here, or automata cannot write to the workspace root; check that the scheduler runs \`do-work\` from inside the checkout`
5351
+ problem: finding(
5352
+ `no execution log at \`${read.path}\` \u2014 no tick has ever run here, or automata cannot write to the workspace root; check that the scheduler runs \`do-work\` from inside the checkout`,
5353
+ `ls -la ${read.path}`
5354
+ ),
5355
+ explainedByLiveTick: true
5029
5356
  };
5030
5357
  }
5031
5358
  if (read.entries.length === 0) {
5032
5359
  return {
5033
5360
  line: "the execution log holds no tick for this repository",
5034
- problem: "the execution log holds no tick for this repository \u2014 the scheduler has never successfully run `do-work` here"
5361
+ problem: finding(
5362
+ "the execution log holds no tick for this repository \u2014 the scheduler has never successfully run `do-work` here",
5363
+ `tail -5 ${read.path}`
5364
+ ),
5365
+ explainedByLiveTick: true
5035
5366
  };
5036
5367
  }
5037
5368
  return null;
5038
5369
  }
5039
- function describeTickHistory(ticks, cadence, now, lines, problems) {
5370
+ function describeLogDirectory(directory, lines, problems) {
5371
+ const writable = directory.writable ? "writable" : `not writable: ${directory.detail ?? "unknown"}`;
5372
+ lines.push(
5373
+ `log directory: ${directory.dir} (the parent of the working directory ${directory.cwd}), ${writable}`
5374
+ );
5375
+ if (directory.writable) return;
5376
+ problems.push(
5377
+ finding(
5378
+ `the operation log directory \`${directory.dir}\` is not writable (${directory.detail ?? "unknown"}), so every tick records nothing and this report can only ever be blank; make it writable by the account the scheduler runs as`,
5379
+ `ls -ld ${directory.dir}`
5380
+ )
5381
+ );
5382
+ }
5383
+ function describeTickHistory(ticks, cadence, now, logPath, lines, problems) {
5040
5384
  lines.push(`last tick: ${describeTick(ticks[0], now)}`);
5041
5385
  if (ticks[0].exitCode !== 0) {
5042
5386
  problems.push(
5043
- `the last tick exited ${String(ticks[0].exitCode)} \u2014 see \`Last work\` below and the work log for the item that failed`
5387
+ finding(
5388
+ `the last tick exited ${String(ticks[0].exitCode)} \u2014 see \`Last work\` below and the work log for the item that failed`,
5389
+ "automata do-work --check --verbose"
5390
+ )
5044
5391
  );
5045
5392
  }
5046
5393
  const lockHeld = ticks.filter((tick) => tick.note === "lock-held").length;
@@ -5050,7 +5397,10 @@ function describeTickHistory(ticks, cadence, now, lines, problems) {
5050
5397
  );
5051
5398
  if (lockHeld === ticks.length && ticks.length > 1) {
5052
5399
  problems.push(
5053
- "every recorded tick was turned away by a held run lock \u2014 a previous tick is wedged; see `Run lock` above"
5400
+ finding(
5401
+ "every recorded tick was turned away by a held run lock \u2014 a previous tick is wedged; see `Run lock` above",
5402
+ "cat .automata/automata.lock"
5403
+ )
5054
5404
  );
5055
5405
  }
5056
5406
  if (cadence.medianIntervalMs === null) {
@@ -5060,24 +5410,38 @@ function describeTickHistory(ticks, cadence, now, lines, problems) {
5060
5410
  lines.push(`cadence: about one tick every ${describeDuration(cadence.medianIntervalMs)}`);
5061
5411
  if (cadence.silent) {
5062
5412
  problems.push(
5063
- `no tick for ${describeDuration(cadence.sinceNewestMs ?? 0)}, against a usual interval of ${describeDuration(cadence.medianIntervalMs)} \u2014 the scheduler appears to have stopped firing (automata does not manage the scheduler; check it on this host)`
5413
+ finding(
5414
+ `no tick for ${describeDuration(cadence.sinceNewestMs ?? 0)}, against a usual interval of ${describeDuration(cadence.medianIntervalMs)} \u2014 the scheduler appears to have stopped firing (automata does not manage the scheduler; check it on this host)`,
5415
+ // The log's own tail is the only thing automata can offer: the
5416
+ // scheduler belongs to the host, and naming a `crontab -l` here would
5417
+ // be a guess about how this installation is driven.
5418
+ `tail -5 ${logPath}`
5419
+ )
5064
5420
  );
5065
5421
  }
5066
5422
  }
5067
5423
  function unfilteredLine(unit) {
5068
5424
  return `not filtered by repository: the slug could not be resolved, so ${unit} from other checkouts may be shown`;
5069
5425
  }
5070
- function tickSection(read, now) {
5426
+ function tickSection(read, now, directory, lockStatus) {
5071
5427
  const lines = [];
5072
5428
  const problems = [];
5073
5429
  const ticks = read.entries;
5074
5430
  const cadence = tickCadence(ticks, now);
5431
+ const tickInFlight = lockStatus.kind === "held" || lockStatus.kind === "suspect";
5432
+ describeLogDirectory(directory, lines, problems);
5075
5433
  const missing = describeMissingTicks(read);
5076
5434
  if (missing !== null) {
5077
5435
  lines.push(missing.line);
5078
- problems.push(missing.problem);
5436
+ if (tickInFlight && missing.explainedByLiveTick) {
5437
+ lines.push(
5438
+ "a tick is running and has not recorded itself yet \u2014 the log is written when a tick ends, so this is the expected state for a first tick still in flight"
5439
+ );
5440
+ } else {
5441
+ problems.push(missing.problem);
5442
+ }
5079
5443
  } else {
5080
- describeTickHistory(ticks, cadence, now, lines, problems);
5444
+ describeTickHistory(ticks, cadence, now, read.path, lines, problems);
5081
5445
  }
5082
5446
  if (!read.filtered && read.present) lines.push(unfilteredLine("line(s)"));
5083
5447
  if (read.skipped > 0)
@@ -5095,7 +5459,9 @@ function tickSection(read, now) {
5095
5459
  otherRepos: read.otherRepos,
5096
5460
  filtered: read.filtered,
5097
5461
  logPath: read.path,
5098
- logPresent: read.present
5462
+ logPresent: read.present,
5463
+ logDirectory: directory,
5464
+ tickInFlight
5099
5465
  });
5100
5466
  }
5101
5467
  function describeWorkItem(item) {
@@ -5115,7 +5481,9 @@ function workSection(read, now) {
5115
5481
  const records = read.entries;
5116
5482
  if (read.error !== null) {
5117
5483
  lines.push(`the work log could not be read: ${read.error}`);
5118
- problems.push(`the work log \`${read.path}\` could not be read: ${read.error}`);
5484
+ problems.push(
5485
+ finding(`the work log \`${read.path}\` could not be read: ${read.error}`, `ls -l ${read.path}`)
5486
+ );
5119
5487
  } else if (!read.present || records.length === 0) {
5120
5488
  lines.push("no tick has invoked the executor in the retained window");
5121
5489
  } else {
@@ -5138,7 +5506,12 @@ function workSection(read, now) {
5138
5506
  function describeCheckout(status, lines, problems) {
5139
5507
  if (status.branch === null) {
5140
5508
  lines.push(`HEAD is detached at ${status.head ?? "an unknown commit"}`);
5141
- problems.push("HEAD is detached; the pre-flight expects a branch, so check one out");
5509
+ problems.push(
5510
+ finding(
5511
+ "HEAD is detached; the pre-flight expects a branch, so check one out",
5512
+ "git status --short --branch"
5513
+ )
5514
+ );
5142
5515
  } else {
5143
5516
  const at = status.head === null ? "" : ` at ${status.head}`;
5144
5517
  lines.push(`on ${status.branch}${at}`);
@@ -5146,7 +5519,10 @@ function describeCheckout(status, lines, problems) {
5146
5519
  if (status.statusError !== null) {
5147
5520
  lines.push(`the working tree could not be inspected: ${status.statusError}`);
5148
5521
  problems.push(
5149
- `\`git status\` failed (${status.statusError}), so whether the working tree is clean is unknown; the pre-flight runs the same command and stops every item when it cannot answer`
5522
+ finding(
5523
+ `\`git status\` failed (${status.statusError}), so whether the working tree is clean is unknown; the pre-flight runs the same command and stops every item when it cannot answer`,
5524
+ "git status --porcelain"
5525
+ )
5150
5526
  );
5151
5527
  return;
5152
5528
  }
@@ -5157,21 +5533,30 @@ function describeCheckout(status, lines, problems) {
5157
5533
  lines.push(`working tree has ${String(status.dirtyPaths.length)} uncommitted change(s):`);
5158
5534
  for (const path of status.dirtyPaths) lines.push(` ${path}`);
5159
5535
  problems.push(
5160
- `the working tree has ${String(status.dirtyPaths.length)} uncommitted change(s); the pre-flight will try to rescue them onto a branch, and every item skips as \`dirty-tree\` if that fails`
5536
+ finding(
5537
+ `the working tree has ${String(status.dirtyPaths.length)} uncommitted change(s); the pre-flight will try to rescue them onto a branch, and every item skips as \`dirty-tree\` if that fails`,
5538
+ "git status --porcelain"
5539
+ )
5161
5540
  );
5162
5541
  }
5163
5542
  function describeBaseBranch(status, lines, problems) {
5164
5543
  if (!status.baseLocal) {
5165
5544
  lines.push(`base branch ${status.baseBranch} does not exist in this checkout`);
5166
5545
  problems.push(
5167
- `the base branch \`${status.baseBranch}\` does not exist locally; either check it out or correct \`doWork.baseBranch\` with \`automata config set do-work-base-branch <branch>\``
5546
+ finding(
5547
+ `the base branch \`${status.baseBranch}\` does not exist locally; either check it out or correct \`doWork.baseBranch\` with \`automata config set do-work-base-branch <branch>\``,
5548
+ "git branch --list --all"
5549
+ )
5168
5550
  );
5169
5551
  return;
5170
5552
  }
5171
5553
  if (status.upstream === null) {
5172
5554
  lines.push(`base branch ${status.baseBranch} has no upstream`);
5173
5555
  problems.push(
5174
- `the base branch \`${status.baseBranch}\` has no upstream, so the pre-flight cannot fast-forward it`
5556
+ finding(
5557
+ `the base branch \`${status.baseBranch}\` has no upstream, so the pre-flight cannot fast-forward it`,
5558
+ `git branch -vv --list ${status.baseBranch}`
5559
+ )
5175
5560
  );
5176
5561
  return;
5177
5562
  }
@@ -5180,7 +5565,10 @@ function describeBaseBranch(status, lines, problems) {
5180
5565
  `base branch ${status.baseBranch} has no tracking configuration; counted against ${status.upstream}`
5181
5566
  );
5182
5567
  problems.push(
5183
- `the base branch \`${status.baseBranch}\` has no upstream configured, so the pre-flight's \`git pull --ff-only\` fails even though ${status.upstream} exists; set it with \`git branch --set-upstream-to=${status.upstream} ${status.baseBranch}\``
5568
+ finding(
5569
+ `the base branch \`${status.baseBranch}\` has no upstream configured, so the pre-flight's \`git pull --ff-only\` fails even though ${status.upstream} exists; set it with \`git branch --set-upstream-to=${status.upstream} ${status.baseBranch}\``,
5570
+ `git branch -vv --list ${status.baseBranch}`
5571
+ )
5184
5572
  );
5185
5573
  }
5186
5574
  const freshness = status.refreshed ? "" : " (not refreshed)";
@@ -5191,14 +5579,20 @@ function describeBaseBranch(status, lines, problems) {
5191
5579
  );
5192
5580
  if (status.ahead === null || status.behind === null) {
5193
5581
  problems.push(
5194
- `the divergence of \`${status.baseBranch}\` from ${status.upstream} could not be read, so whether the pre-flight's fast-forward pull will succeed is unknown; try \`git rev-list --left-right --count ${status.upstream}...${status.baseBranch}\` to see git's own error`
5582
+ finding(
5583
+ `the divergence of \`${status.baseBranch}\` from ${status.upstream} could not be read, so whether the pre-flight's fast-forward pull will succeed is unknown; try \`git rev-list --left-right --count ${status.upstream}...${status.baseBranch}\` to see git's own error`,
5584
+ `git rev-list --left-right --count ${status.upstream}...${status.baseBranch}`
5585
+ )
5195
5586
  );
5196
5587
  return;
5197
5588
  }
5198
5589
  if (status.ahead === 0) return;
5199
5590
  if (status.behind > 0) {
5200
5591
  problems.push(
5201
- `the base branch \`${status.baseBranch}\` has diverged from ${status.upstream} (${String(status.ahead)} ahead, ${String(status.behind)} behind); the pre-flight's fast-forward pull will fail until that is resolved by hand`
5592
+ finding(
5593
+ `the base branch \`${status.baseBranch}\` has diverged from ${status.upstream} (${String(status.ahead)} ahead, ${String(status.behind)} behind); the pre-flight's fast-forward pull will fail until that is resolved by hand`,
5594
+ `git log --oneline --left-right ${status.upstream}...${status.baseBranch}`
5595
+ )
5202
5596
  );
5203
5597
  return;
5204
5598
  }
@@ -5211,7 +5605,7 @@ function gitSection(status) {
5211
5605
  return build(
5212
5606
  "git",
5213
5607
  [`not a usable git repository: ${status.error}`],
5214
- [`not a usable git repository: ${status.error}`],
5608
+ [finding(`not a usable git repository: ${status.error}`, "git rev-parse --is-inside-work-tree")],
5215
5609
  {
5216
5610
  ...status
5217
5611
  }
@@ -5222,7 +5616,10 @@ function gitSection(status) {
5222
5616
  if (status.fetchError !== null) {
5223
5617
  lines.push(`fetch failed: ${status.fetchError}`);
5224
5618
  problems.push(
5225
- `\`git fetch\` failed (${status.fetchError}); the ahead/behind figures above are from the last successful fetch and may be out of date`
5619
+ finding(
5620
+ `\`git fetch\` failed (${status.fetchError}); the ahead/behind figures above are from the last successful fetch and may be out of date`,
5621
+ "git fetch origin"
5622
+ )
5226
5623
  );
5227
5624
  }
5228
5625
  return build("git", lines, problems, { ...status });
@@ -5240,26 +5637,38 @@ function assembleReport(input) {
5240
5637
  offline: input.offline,
5241
5638
  sections: ordered,
5242
5639
  problems,
5243
- exitCode: problems.length === 0 ? 0 : 1
5640
+ exitCode: problems.length === 0 ? 0 : 1,
5641
+ trace: input.trace ?? null,
5642
+ blocked: input.blocked ?? null
5244
5643
  };
5245
5644
  }
5645
+ function renderBlock(title, body, empty) {
5646
+ return [title, ...(body.length === 0 ? [empty] : body).map((line) => ` ${line}`), ""];
5647
+ }
5648
+ function renderTrace(trace) {
5649
+ return renderBlock(
5650
+ `Commands (${String(trace.length)})`,
5651
+ trace.map(describeTracedCommand),
5652
+ "(no git or gh command was run)"
5653
+ );
5654
+ }
5655
+ function renderProblems(problems) {
5656
+ const body = problems.flatMap((problem) => [
5657
+ `\xB7 ${problem.section}: ${problem.summary}`,
5658
+ // Indented under its problem rather than beside it: the command is a
5659
+ // follow-up to that one finding, not another finding.
5660
+ ...problem.command === null ? [] : [` try: ${problem.command}`]
5661
+ ]);
5662
+ return [`Problems (${String(problems.length)})`, ...body.map((line) => ` ${line}`), ""];
5663
+ }
5246
5664
  function renderText(report) {
5247
5665
  const head = `automata do-work --check \u2014 ${report.repo ?? "unknown repository"} \u2014 ${report.generatedAt.toISOString()}`;
5248
- const parts = [head, ""];
5666
+ const parts = report.blocked === null ? [head, ""] : [`blocked: ${report.blocked.trigger}`, head, ""];
5249
5667
  for (const section of report.sections) {
5250
- parts.push(section.title);
5251
- if (section.lines.length === 0) {
5252
- parts.push(" (nothing to report)");
5253
- } else {
5254
- for (const line of section.lines) parts.push(` ${line}`);
5255
- }
5256
- parts.push("");
5257
- }
5258
- if (report.problems.length > 0) {
5259
- parts.push(`Problems (${String(report.problems.length)})`);
5260
- for (const problem of report.problems) parts.push(` \xB7 ${problem.section}: ${problem.summary}`);
5261
- parts.push("");
5668
+ parts.push(...renderBlock(section.title, section.lines, "(nothing to report)"));
5262
5669
  }
5670
+ if (report.trace !== null) parts.push(...renderTrace(report.trace));
5671
+ if (report.problems.length > 0) parts.push(...renderProblems(report.problems));
5263
5672
  parts.push(
5264
5673
  report.problems.length === 0 ? "RESULT: healthy" : `RESULT: ${String(report.problems.length)} problem(s) found`
5265
5674
  );
@@ -5281,13 +5690,21 @@ function toJson(report) {
5281
5690
  offline: report.offline,
5282
5691
  exitCode: report.exitCode,
5283
5692
  problems: report.problems,
5284
- sections
5693
+ sections,
5694
+ trace: report.trace,
5695
+ blocked: report.blocked
5285
5696
  };
5286
5697
  }
5287
5698
 
5288
5699
  // src/commands/doWork.ts
5700
+ import { dirname as dirname3 } from "path";
5289
5701
  var inFlightMarker = null;
5290
5702
  var inFlightSync = null;
5703
+ var lockHandle = null;
5704
+ var heartbeatItem = null;
5705
+ function beat(update) {
5706
+ lockHandle?.heartbeat(update);
5707
+ }
5291
5708
  function subjectLabel(issue, pr) {
5292
5709
  if (issue !== null) return `#${String(issue)}`;
5293
5710
  return pr === null ? "#?" : `PR #${String(pr)}`;
@@ -5342,11 +5759,6 @@ function resolveSettingsResult(options, verifyIdentity) {
5342
5759
  throw err;
5343
5760
  }
5344
5761
  }
5345
- function resolveSettings(options) {
5346
- const result = resolveSettingsResult(options, options.dryRun !== true);
5347
- if (!result.ok) fail(result.error);
5348
- return result.settings;
5349
- }
5350
5762
  function requireParticipants(config) {
5351
5763
  if (config.remoteType !== "gh") {
5352
5764
  failSettings(
@@ -5419,6 +5831,7 @@ function buildSettings(options, verifyIdentity) {
5419
5831
  configEfforts: doWork.effort,
5420
5832
  maxRuns: options.maxRuns !== void 0 ? parsePositiveInt(options.maxRuns, "--max-runs") : doWork.maxRunsPerTick ?? DEFAULT_DO_WORK.maxRunsPerTick,
5421
5833
  lockStaleMinutes: doWork.lockStaleMinutes ?? DEFAULT_DO_WORK.lockStaleMinutes,
5834
+ dumpOnBlock: doWork.dumpOnBlock ?? DEFAULT_DO_WORK.dumpOnBlock,
5422
5835
  limit: parsePositiveInt(options.limit, "--limit"),
5423
5836
  participants: { allowedUsers, agentUser },
5424
5837
  prompts: {
@@ -5580,11 +5993,19 @@ function validateDoWorkConfig(section) {
5580
5993
  validateOptionalString(section, "baseBranch", "doWork.baseBranch");
5581
5994
  validateOptionalInt(section, "maxRunsPerTick", "doWork.maxRunsPerTick", 0, "a non-negative integer (0 = unlimited)");
5582
5995
  validateOptionalInt(section, "lockStaleMinutes", "doWork.lockStaleMinutes", 1, "a positive integer");
5996
+ validateOptionalBoolean(section, "dumpOnBlock", "doWork.dumpOnBlock");
5583
5997
  validateProtectedBranches(section["protectedBranches"]);
5584
5998
  validateSettingContainer(section["models"], "models", ["claude", "codex"]);
5585
5999
  validateSettingContainer(section["effort"], "effort", ["claude", "codex"]);
5586
6000
  validateSettingContainer(section["prompts"], "prompts", ["issueDiscuss", "prWork", "prOrphan"]);
5587
6001
  }
6002
+ function validateOptionalBoolean(container, key, path) {
6003
+ const value = container[key];
6004
+ if (value === void 0 || value === null) return;
6005
+ if (typeof value !== "boolean") {
6006
+ failSettings(`${path} must be true or false, got ${JSON.stringify(value)}.`);
6007
+ }
6008
+ }
5588
6009
  function validateProtectedBranches(value) {
5589
6010
  if (value === void 0 || value === null) return;
5590
6011
  const isNonEmptyString = (b) => typeof b === "string" && b.trim().length > 0;
@@ -5894,6 +6315,11 @@ function reportNoAnswer(item, marker, runError) {
5894
6315
  return runError === null ? { outcome: "answered-no-reply", detail: "run finished but posted no answer", reason: "no-answer" } : { outcome: "failed", detail: `run failed: ${runError.message}`, reason: "no-answer" };
5895
6316
  }
5896
6317
  async function invokeExecutor(prompt, execution, silent) {
6318
+ beat({
6319
+ phase: "item",
6320
+ item: heartbeatItem,
6321
+ executor: { command: execution.executor, startedAt: (/* @__PURE__ */ new Date()).toISOString() }
6322
+ });
5897
6323
  if (execution.executor === "codex") {
5898
6324
  await runCodex(prompt, { model: execution.model, effort: execution.effort });
5899
6325
  return;
@@ -6143,37 +6569,82 @@ var doWorkCommand = new Command6("do-work").description(
6143
6569
  ).option(
6144
6570
  "--no-fetch",
6145
6571
  "With --check: make no network call at all \u2014 no `git fetch` and no `gh` query. Ahead/behind is reported from the last fetch and the selection section does not run"
6572
+ ).option(
6573
+ "--verbose",
6574
+ "Show the work behind the report: every `git` and `gh` invocation with its duration and exit code, the discovery query as sent, and every candidate considered. Applies to --check and to a blocked-exit dump"
6146
6575
  ).option("--json", "Emit the work plan and outcomes as JSON on stdout").option("--silent", "Suppress step-by-step Claude output; show only the final summary").action(async (options) => {
6576
+ const exitCode = await runDoWork(options);
6577
+ if (exitCode !== 0) process.exit(exitCode);
6578
+ });
6579
+ async function runDoWork(options) {
6580
+ try {
6581
+ return await runDoWorkTraced(options);
6582
+ } finally {
6583
+ stopCommandTrace();
6584
+ }
6585
+ }
6586
+ async function runDoWorkTraced(options) {
6147
6587
  const startedAt = Date.now();
6148
6588
  if (options.check === true) {
6149
6589
  if (options.dryRun === true) {
6150
6590
  process.stderr.write(
6151
6591
  "Error: --check and --dry-run are two different read-only reports; run one or the other.\n"
6152
6592
  );
6153
- process.exit(1);
6593
+ return 1;
6154
6594
  }
6155
- const exitCode2 = runCheck(options);
6156
- if (exitCode2 !== 0) process.exit(exitCode2);
6157
- return;
6595
+ return runCheck(options);
6158
6596
  }
6597
+ if (options.verbose === true) startCommandTrace();
6159
6598
  if (options.dryRun !== true) loggableInvocation = { startedAt };
6160
- const settings = resolveSettings(options);
6599
+ const resolved = resolveSettingsResult(options, options.dryRun !== true);
6600
+ if (!resolved.ok) {
6601
+ const payload = dumpBlocked(options, {
6602
+ reason: "config-invalid",
6603
+ trigger: `configuration is not usable \u2014 ${resolved.error}`,
6604
+ selection: null,
6605
+ resolved
6606
+ });
6607
+ if (payload !== null) out(JSON.stringify({ blocked: payload, exitCode: 1 }, null, 2) + "\n");
6608
+ fail(resolved.error);
6609
+ }
6610
+ const settings = resolved.settings;
6161
6611
  if (options.dryRun === true) {
6162
6612
  const { exitCode: exitCode2 } = await runTick(settings, options);
6163
- if (exitCode2 !== 0) process.exit(exitCode2);
6164
- return;
6613
+ return exitCode2;
6165
6614
  }
6166
6615
  loggableInvocation = null;
6167
6616
  const lock = acquireRunLock("do-work", settings.lockStaleMinutes);
6168
6617
  if (!lock.ok) {
6169
- const exitCode2 = reportLockHeld(lock, settings, options);
6618
+ const exitCode2 = reportLockHeld(lock, settings, options, resolved);
6170
6619
  logTick([], exitCode2, startedAt, "lock-held");
6171
- if (exitCode2 !== 0) process.exit(exitCode2);
6172
- return;
6620
+ return exitCode2;
6621
+ }
6622
+ const { exitCode, reports } = await runTickUnderLock(lock.handle, settings, options);
6623
+ logTick(reports, exitCode, startedAt);
6624
+ return exitCode;
6625
+ }
6626
+ async function runTickUnderLock(handle, settings, options) {
6627
+ lockHandle = handle;
6628
+ const onSignal = interruptHandler(handle);
6629
+ process.once("SIGINT", onSignal);
6630
+ process.once("SIGTERM", onSignal);
6631
+ try {
6632
+ return await runTick(settings, options);
6633
+ } catch (err) {
6634
+ process.stderr.write(`Error: ${err.message}
6635
+ `);
6636
+ return { exitCode: 1, reports: [] };
6637
+ } finally {
6638
+ lockHandle = null;
6639
+ heartbeatItem = null;
6640
+ handle.release();
6641
+ process.removeListener("SIGINT", onSignal);
6642
+ process.removeListener("SIGTERM", onSignal);
6173
6643
  }
6174
- const handle = lock.handle;
6644
+ }
6645
+ function interruptHandler(handle) {
6175
6646
  let shuttingDown = false;
6176
- const onSignal = () => {
6647
+ return () => {
6177
6648
  if (shuttingDown) return;
6178
6649
  shuttingDown = true;
6179
6650
  progress("\nInterrupted: stopping the executor before releasing the run lock\u2026\n");
@@ -6189,26 +6660,7 @@ var doWorkCommand = new Command6("do-work").description(
6189
6660
  process.exit(130);
6190
6661
  });
6191
6662
  };
6192
- process.once("SIGINT", onSignal);
6193
- process.once("SIGTERM", onSignal);
6194
- let exitCode;
6195
- let reports = [];
6196
- try {
6197
- const result = await runTick(settings, options);
6198
- exitCode = result.exitCode;
6199
- reports = result.reports;
6200
- } catch (err) {
6201
- process.stderr.write(`Error: ${err.message}
6202
- `);
6203
- exitCode = 1;
6204
- } finally {
6205
- handle.release();
6206
- process.removeListener("SIGINT", onSignal);
6207
- process.removeListener("SIGTERM", onSignal);
6208
- }
6209
- logTick(reports, exitCode, startedAt);
6210
- if (exitCode !== 0) process.exit(exitCode);
6211
- });
6663
+ }
6212
6664
  function resolveRepoSlug() {
6213
6665
  try {
6214
6666
  const slug = getRepoSlug();
@@ -6217,7 +6669,24 @@ function resolveRepoSlug() {
6217
6669
  return null;
6218
6670
  }
6219
6671
  }
6220
- function collectSelection(settings) {
6672
+ function buildSelectionEvidence(settings, issues, linkMap, decisions, verbose) {
6673
+ const issuePass = issuePassEnabled(settings);
6674
+ const orphanPass = orphanPassEnabled(settings);
6675
+ return {
6676
+ decisions,
6677
+ issuePass,
6678
+ orphanPass,
6679
+ issues: verbose && issuePass ? issues : null,
6680
+ // Read off the link map rather than off `discoverOrphanPrs`, which returns
6681
+ // only the survivors — the whole point here is to show what was dropped.
6682
+ orphans: verbose && orphanPass ? linkMap.orphans.map((candidate) => ({
6683
+ number: candidate.pr.number,
6684
+ title: candidate.pr.title,
6685
+ matched: prMatchesFilter(candidate, settings)
6686
+ })) : null
6687
+ };
6688
+ }
6689
+ function collectSelection(settings, verbose) {
6221
6690
  const issues = discoverIssues(settings);
6222
6691
  const linkMap = getOpenPrLinkMap();
6223
6692
  const policy = {
@@ -6225,52 +6694,135 @@ function collectSelection(settings) {
6225
6694
  defaultBranch: linkMap.defaultBranch,
6226
6695
  protectedBranches: settings.protectedBranches
6227
6696
  };
6228
- return skipDuplicateHeadBranches([
6697
+ const decisions = skipDuplicateHeadBranches([
6229
6698
  ...issues.map((issue) => decideWork(buildIssueState(issue, linkMap), settings.participants, policy)),
6230
6699
  ...discoverOrphanPrs(settings, linkMap).map(
6231
6700
  (candidate) => decideOrphanPrWork({ prSurface: getPrSurface(candidate.pr.number) }, settings.participants, policy)
6232
6701
  )
6233
6702
  ]);
6703
+ return buildSelectionEvidence(settings, issues, linkMap, decisions, verbose);
6234
6704
  }
6235
- function selectionSection(settings, offline) {
6236
- const section = (lines2, problems, data) => ({
6705
+ function makeSelectionSection(lines, problems, data) {
6706
+ return {
6237
6707
  id: "selection",
6238
6708
  title: sectionTitle("selection"),
6239
- lines: lines2,
6240
- problems: problems.map((summary) => ({ section: "selection", summary })),
6709
+ lines,
6710
+ problems: problems.map((problem) => ({ section: "selection", ...problem })),
6241
6711
  data
6242
- });
6712
+ };
6713
+ }
6714
+ function describeSelectionEvidence(settings, evidence, lines) {
6715
+ lines.push(
6716
+ ...describeIssueEvidence(settings, evidence),
6717
+ ...describeOrphanEvidence(settings, evidence)
6718
+ );
6719
+ }
6720
+ function heading(text, count) {
6721
+ return `${text}${count === 0 ? "" : ":"}`;
6722
+ }
6723
+ function describeIssueEvidence(settings, evidence) {
6724
+ if (!evidence.issuePass) {
6725
+ return [
6726
+ `issue discovery: not run \u2014 --pr ${String(settings.onlyPr)} restricts this tick to that pull request`
6727
+ ];
6728
+ }
6729
+ const restriction = settings.onlyIssue === void 0 ? "" : `, restricted to #${String(settings.onlyIssue)}`;
6730
+ const lines = [
6731
+ `discovery query: ${settings.technique} = ${settings.discoveryValue}, limit ${String(settings.limit)}${restriction}`
6732
+ ];
6733
+ if (evidence.issues === null) return lines;
6734
+ lines.push(heading(`discovery returned ${String(evidence.issues.length)} issue(s)`, evidence.issues.length));
6735
+ for (const issue of evidence.issues) {
6736
+ lines.push(` #${String(issue.number)} ${issue.title}`);
6737
+ }
6738
+ return lines;
6739
+ }
6740
+ function describeOrphanEvidence(settings, evidence) {
6741
+ if (!evidence.orphanPass) {
6742
+ return [
6743
+ `orphan pull-request pass: not run \u2014 --issue ${String(settings.onlyIssue)} restricts this tick to that issue`
6744
+ ];
6745
+ }
6746
+ if (evidence.orphans === null) return [];
6747
+ const restriction = settings.onlyPr === void 0 ? "" : ` (only PR #${String(settings.onlyPr)} would be taken up)`;
6748
+ const lines = [
6749
+ heading(
6750
+ `${String(evidence.orphans.length)} open orphan pull request(s) considered${restriction}`,
6751
+ evidence.orphans.length
6752
+ )
6753
+ ];
6754
+ for (const orphan of evidence.orphans) {
6755
+ const verdict = orphan.matched ? "matches the filter" : "dropped by the discovery filter";
6756
+ lines.push(` PR #${String(orphan.number)} ${orphan.title} \u2014 ${verdict}`);
6757
+ }
6758
+ return lines;
6759
+ }
6760
+ function describeSelectionDecisions(decisions) {
6761
+ const work = decisions.filter((decision) => decision.kind === "work").length;
6762
+ return [
6763
+ `${String(work)} of ${String(decisions.length)} candidate(s) would be picked up`,
6764
+ ...describePlan(decisions).trimEnd().split("\n")
6765
+ ];
6766
+ }
6767
+ function selectionSection(settings, offline, verbose) {
6243
6768
  if (offline) {
6244
- return section(
6769
+ return makeSelectionSection(
6245
6770
  ["not run: --no-fetch makes no network call, and the selection needs live GitHub data"],
6246
6771
  [],
6247
6772
  { ran: false, detail: "offline", plan: [] }
6248
6773
  );
6249
6774
  }
6250
6775
  if (settings === null) {
6251
- return section(
6776
+ return makeSelectionSection(
6252
6777
  ["not run: the configuration could not be resolved \u2014 see Environment below"],
6253
6778
  [],
6254
6779
  { ran: false, detail: "configuration invalid", plan: [] }
6255
6780
  );
6256
6781
  }
6257
- let decisions;
6782
+ let evidence;
6258
6783
  try {
6259
- decisions = collectSelection(settings);
6784
+ evidence = collectSelection(settings, verbose);
6260
6785
  } catch (err) {
6261
6786
  const detail = err.message;
6262
- return section(
6787
+ return makeSelectionSection(
6263
6788
  [`could not be computed: ${detail}`],
6264
- [`the GitHub selection could not be computed: ${detail}`],
6789
+ [
6790
+ {
6791
+ summary: `the GitHub selection could not be computed: ${detail}`,
6792
+ command: `gh issue list --limit ${String(settings.limit)}`
6793
+ }
6794
+ ],
6265
6795
  { ran: false, detail, plan: [] }
6266
6796
  );
6267
6797
  }
6268
- const work = decisions.filter((decision) => decision.kind === "work").length;
6269
- const lines = [
6270
- `${String(work)} of ${String(decisions.length)} candidate(s) would be picked up`,
6271
- ...describePlan(decisions).trimEnd().split("\n")
6272
- ];
6273
- return section(lines, [], { ran: true, detail: null, plan: decisions.map(toPlanJson) });
6798
+ const lines = [];
6799
+ if (verbose) describeSelectionEvidence(settings, evidence, lines);
6800
+ lines.push(...describeSelectionDecisions(evidence.decisions));
6801
+ return makeSelectionSection(lines, [], selectionData(evidence));
6802
+ }
6803
+ function selectionData(evidence) {
6804
+ return {
6805
+ ran: true,
6806
+ detail: null,
6807
+ plan: evidence.decisions.map(toPlanJson),
6808
+ issues: evidence.issues,
6809
+ orphans: evidence.orphans,
6810
+ issuePass: evidence.issuePass,
6811
+ orphanPass: evidence.orphanPass
6812
+ };
6813
+ }
6814
+ function selectionSectionFromEvidence(settings, evidence, verbose) {
6815
+ if (evidence === null) {
6816
+ return makeSelectionSection(
6817
+ ["not run: the tick was blocked before discovery"],
6818
+ [],
6819
+ { ran: false, detail: "blocked before discovery", plan: [] }
6820
+ );
6821
+ }
6822
+ const lines = [];
6823
+ if (verbose && settings !== null) describeSelectionEvidence(settings, evidence, lines);
6824
+ lines.push(...describeSelectionDecisions(evidence.decisions));
6825
+ return makeSelectionSection(lines, [], selectionData(evidence));
6274
6826
  }
6275
6827
  function describeExecutor(settings, lines, problems) {
6276
6828
  const executor = settings.withOption ?? settings.configExecutor ?? "claude";
@@ -6279,15 +6831,16 @@ function describeExecutor(settings, lines, problems) {
6279
6831
  const onPath = resolvedPath !== command;
6280
6832
  lines.push(`default executor: ${executor} (${onPath ? resolvedPath : "not found on PATH"})`);
6281
6833
  if (!onPath) {
6282
- problems.push(
6283
- `the \`${command}\` command is not on PATH, so every run this tick would fail. Under cron the PATH is not your login shell's \u2014 set it in the crontab or use an absolute path`
6284
- );
6834
+ problems.push({
6835
+ summary: `the \`${command}\` command is not on PATH, so every run this tick would fail. Under cron the PATH is not your login shell's \u2014 set it in the crontab or use an absolute path`,
6836
+ command: `command -v ${command}`
6837
+ });
6285
6838
  }
6286
6839
  return { executor, command, onPath };
6287
6840
  }
6288
- function describeGitHubIdentity(options, settings, lines, problems) {
6289
- if (options.fetch === false) {
6290
- lines.push("`gh` authentication not checked: --no-fetch");
6841
+ function describeGitHubIdentity(checkIdentity, settings, lines, problems, reason) {
6842
+ if (!checkIdentity) {
6843
+ lines.push(`\`gh\` authentication not checked: ${reason}`);
6291
6844
  return { ghAvailable: null, ghLogin: null, identityProblem: null };
6292
6845
  }
6293
6846
  let identity;
@@ -6296,14 +6849,18 @@ function describeGitHubIdentity(options, settings, lines, problems) {
6296
6849
  } catch (err) {
6297
6850
  const detail = err.message;
6298
6851
  lines.push(`\`gh\` could not be queried: ${detail}`);
6299
- problems.push(`\`gh\` could not be queried (${detail}); install it and run \`gh auth login\``);
6852
+ problems.push({
6853
+ summary: `\`gh\` could not be queried (${detail}); install it and run \`gh auth login\``,
6854
+ command: "gh auth status"
6855
+ });
6300
6856
  return { ghAvailable: false, ghLogin: null, identityProblem: null };
6301
6857
  }
6302
6858
  if (identity.kind === "unavailable") {
6303
6859
  lines.push(`\`gh\` could not name an account: ${identity.detail}`);
6304
- problems.push(
6305
- `\`gh api user\` failed (${identity.detail}), so \`gh\` is not authenticated here and every GitHub call a tick makes would fail; run \`gh auth login\`, or set \`GH_TOKEN\` in the scheduler's environment`
6306
- );
6860
+ problems.push({
6861
+ summary: `\`gh api user\` failed (${identity.detail}), so \`gh\` is not authenticated here and every GitHub call a tick makes would fail; run \`gh auth login\`, or set \`GH_TOKEN\` in the scheduler's environment`,
6862
+ command: "gh auth status"
6863
+ });
6307
6864
  return { ghAvailable: false, ghLogin: null, identityProblem: null };
6308
6865
  }
6309
6866
  const ghLogin = identity.kind === "login" ? identity.login : null;
@@ -6315,7 +6872,9 @@ function describeGitHubIdentity(options, settings, lines, problems) {
6315
6872
  settings.participants.agentUser,
6316
6873
  settings.participants.allowedUsers
6317
6874
  );
6318
- if (identityProblem !== null) problems.push(identityProblem);
6875
+ if (identityProblem !== null) {
6876
+ problems.push({ summary: identityProblem, command: "automata config get" });
6877
+ }
6319
6878
  return { ghAvailable: true, ghLogin, identityProblem };
6320
6879
  }
6321
6880
  function readRemoteType() {
@@ -6325,7 +6884,7 @@ function readRemoteType() {
6325
6884
  return null;
6326
6885
  }
6327
6886
  }
6328
- function environmentSection(options, resolved, repo) {
6887
+ function environmentSection(checkIdentity, identitySkipReason, resolved, repo) {
6329
6888
  const lines = [`automata ${version}`];
6330
6889
  const problems = [];
6331
6890
  const remoteType = readRemoteType();
@@ -6338,20 +6897,24 @@ function environmentSection(options, resolved, repo) {
6338
6897
  };
6339
6898
  if (repo === null) {
6340
6899
  lines.push("repository slug could not be resolved from `origin`");
6341
- problems.push(
6342
- "the repository slug could not be resolved; `gh` needs an `origin` remote pointing at GitHub to read issues, and the operation logs cannot attribute their lines without it"
6343
- );
6900
+ problems.push({
6901
+ summary: "the repository slug could not be resolved; `gh` needs an `origin` remote pointing at GitHub to read issues, and the operation logs cannot attribute their lines without it",
6902
+ command: "git remote -v"
6903
+ });
6344
6904
  } else {
6345
6905
  lines.push(`repository ${repo}`);
6346
6906
  }
6347
6907
  if (!resolved.ok) {
6348
6908
  lines.push(`configuration is not usable: ${resolved.error}`);
6349
- problems.push(`the configuration is not usable: ${resolved.error}`);
6909
+ problems.push({
6910
+ summary: `the configuration is not usable: ${resolved.error}`,
6911
+ command: "automata config get"
6912
+ });
6350
6913
  return {
6351
6914
  id: "environment",
6352
6915
  title: sectionTitle("environment"),
6353
6916
  lines,
6354
- problems: problems.map((summary) => ({ section: "environment", summary })),
6917
+ problems: problems.map((problem) => ({ section: "environment", ...problem })),
6355
6918
  // `ghAvailable` is null rather than false: nothing was asked of `gh`,
6356
6919
  // which is not the same as having asked and been refused.
6357
6920
  data: { ...data, ghAvailable: null, ghLogin: null, identityProblem: null }
@@ -6367,16 +6930,17 @@ function environmentSection(options, resolved, repo) {
6367
6930
  );
6368
6931
  const { executor, command, onPath } = describeExecutor(settings, lines, problems);
6369
6932
  const { ghAvailable, ghLogin, identityProblem } = describeGitHubIdentity(
6370
- options,
6933
+ checkIdentity,
6371
6934
  settings,
6372
6935
  lines,
6373
- problems
6936
+ problems,
6937
+ identitySkipReason
6374
6938
  );
6375
6939
  return {
6376
6940
  id: "environment",
6377
6941
  title: sectionTitle("environment"),
6378
6942
  lines,
6379
- problems: problems.map((summary) => ({ section: "environment", summary })),
6943
+ problems: problems.map((problem) => ({ section: "environment", ...problem })),
6380
6944
  data: {
6381
6945
  ...data,
6382
6946
  discovery: { technique: settings.technique, value: settings.discoveryValue },
@@ -6392,30 +6956,121 @@ function environmentSection(options, resolved, repo) {
6392
6956
  }
6393
6957
  };
6394
6958
  }
6959
+ function buildCheckReport(input) {
6960
+ const settings = input.resolved.ok ? input.resolved.settings : null;
6961
+ const staleMinutes = settings?.lockStaleMinutes ?? DEFAULT_DO_WORK.lockStaleMinutes;
6962
+ const baseBranch = settings?.baseBranch ?? DEFAULT_DO_WORK.baseBranch;
6963
+ const lockStatus = inspectRunLock(staleMinutes);
6964
+ const logDirectory = inspectLogDirectory();
6965
+ const lockContext = {
6966
+ cwd: logDirectory.cwd,
6967
+ logDirectory: logDirectory.dir,
6968
+ parentOf: dirname3
6969
+ };
6970
+ return assembleReport({
6971
+ generatedAt: input.now,
6972
+ repo: input.repo,
6973
+ offline: input.offline,
6974
+ blocked: input.blocked,
6975
+ sections: [
6976
+ lockSection(lockStatus, staleMinutes, lockContext, input.now),
6977
+ tickSection(
6978
+ readExecutionTicks({ repo: input.repo, limit: TICK_HISTORY }),
6979
+ input.now,
6980
+ logDirectory,
6981
+ lockStatus
6982
+ ),
6983
+ workSection(readWorkRecords({ repo: input.repo, limit: WORK_HISTORY }), input.now),
6984
+ gitSection(inspectRepoStatus({ baseBranch, fetch: input.fetch })),
6985
+ input.selection,
6986
+ environmentSection(input.checkIdentity, input.identitySkipReason, input.resolved, input.repo)
6987
+ ],
6988
+ // Taken last, so the trace covers every command the collectors above made.
6989
+ trace: takeCommandTrace()
6990
+ });
6991
+ }
6395
6992
  function runCheck(options) {
6993
+ const verbose = options.verbose === true;
6994
+ if (verbose) startCommandTrace();
6396
6995
  const now = /* @__PURE__ */ new Date();
6397
6996
  const offline = options.fetch === false;
6398
6997
  const repo = resolveRepoSlug();
6399
6998
  const resolved = resolveSettingsResult(options, false);
6400
6999
  const settings = resolved.ok ? resolved.settings : null;
6401
- const staleMinutes = settings?.lockStaleMinutes ?? DEFAULT_DO_WORK.lockStaleMinutes;
6402
- const baseBranch = settings?.baseBranch ?? DEFAULT_DO_WORK.baseBranch;
6403
- const report = assembleReport({
6404
- generatedAt: now,
7000
+ const report = buildCheckReport({
7001
+ now,
6405
7002
  repo,
7003
+ resolved,
7004
+ selection: selectionSection(settings, offline, verbose),
7005
+ fetch: !offline,
7006
+ checkIdentity: !offline,
7007
+ identitySkipReason: "--no-fetch",
6406
7008
  offline,
6407
- sections: [
6408
- lockSection(inspectRunLock(staleMinutes), staleMinutes),
6409
- tickSection(readExecutionTicks({ repo, limit: TICK_HISTORY }), now),
6410
- workSection(readWorkRecords({ repo, limit: WORK_HISTORY }), now),
6411
- gitSection(inspectRepoStatus({ baseBranch, fetch: !offline })),
6412
- selectionSection(settings, offline),
6413
- environmentSection(options, resolved, repo)
6414
- ]
7009
+ blocked: null
6415
7010
  });
6416
7011
  out(options.json === true ? JSON.stringify(toJson(report), null, 2) + "\n" : renderText(report));
6417
7012
  return report.exitCode;
6418
7013
  }
7014
+ function dumpOnBlockEnabled(resolved) {
7015
+ if (resolved.ok) return resolved.settings.dumpOnBlock;
7016
+ try {
7017
+ return readConfig().doWork?.dumpOnBlock ?? DEFAULT_DO_WORK.dumpOnBlock;
7018
+ } catch {
7019
+ return DEFAULT_DO_WORK.dumpOnBlock;
7020
+ }
7021
+ }
7022
+ function buildBlockedReport(context, verbose) {
7023
+ return buildCheckReport({
7024
+ now: /* @__PURE__ */ new Date(),
7025
+ repo: resolveRepoSlug(),
7026
+ resolved: context.resolved,
7027
+ selection: selectionSectionFromEvidence(
7028
+ context.resolved.ok ? context.resolved.settings : null,
7029
+ context.selection,
7030
+ verbose
7031
+ ),
7032
+ fetch: false,
7033
+ checkIdentity: false,
7034
+ identitySkipReason: "the tick is exiting and the dump makes no network call",
7035
+ offline: true,
7036
+ blocked: { reason: context.reason, trigger: context.trigger }
7037
+ });
7038
+ }
7039
+ function dumpBlocked(options, context) {
7040
+ if (!dumpOnBlockEnabled(context.resolved)) return null;
7041
+ try {
7042
+ const report = buildBlockedReport(context, options.verbose === true);
7043
+ if (options.json === true) {
7044
+ return { reason: context.reason, trigger: context.trigger, report: toJson(report) };
7045
+ }
7046
+ progress(renderText(report));
7047
+ return null;
7048
+ } catch (err) {
7049
+ progress(`Warning: could not render the blocked-exit report: ${err.message}
7050
+ `);
7051
+ return null;
7052
+ }
7053
+ }
7054
+ function classifyBlocked(hygiene, decisions, items, reports) {
7055
+ const ran = reports.some((report) => report.ranExecutor === true);
7056
+ if (ran) return null;
7057
+ if (hygiene.degraded) {
7058
+ return {
7059
+ reason: "preflight-failed",
7060
+ trigger: `the pre-flight did not prepare the checkout (${describePreflightFailures(hygiene).join("; ")})`
7061
+ };
7062
+ }
7063
+ if (items.length === 0) {
7064
+ return {
7065
+ reason: "no-candidates",
7066
+ trigger: `no candidate was picked up (0 of ${String(decisions.length)})`
7067
+ };
7068
+ }
7069
+ return {
7070
+ reason: "all-skipped",
7071
+ trigger: `${String(items.length)} item(s) selected, none reached the executor`
7072
+ };
7073
+ }
6419
7074
  function toTickLogItem(report) {
6420
7075
  return {
6421
7076
  subject: reportLabel(report),
@@ -6447,22 +7102,41 @@ function logTick(reports, exitCode, startedAt, note) {
6447
7102
  items: reports.map(toTickLogItem)
6448
7103
  });
6449
7104
  }
6450
- function reportLockHeld(lock, settings, options) {
7105
+ function reportLockHeld(lock, settings, options, resolved) {
6451
7106
  const held = lock.heldBy;
6452
7107
  const sentence = `Another automata instance is already running here (pid ${String(held.pid)} on ${held.host}, started ${held.startedAt}, command ${held.command}). Doing nothing.
6453
7108
  `;
6454
7109
  const suspectSentence = lock.suspect ? `Warning: that lock has been held longer than ${String(settings.lockStaleMinutes)} minutes. If no tick is really running, its process id was probably reused; remove ${RUN_LOCK_RELATIVE_PATH} once you have confirmed that.
6455
7110
  ` : "";
6456
7111
  const exitCode = lock.suspect ? 2 : 0;
7112
+ const context = {
7113
+ reason: "lock-held",
7114
+ trigger: `run lock held by pid ${String(held.pid)} on ${held.host}`,
7115
+ selection: null,
7116
+ resolved
7117
+ };
6457
7118
  if (options.json === true) {
6458
7119
  progress(sentence + suspectSentence);
6459
7120
  out(
6460
- JSON.stringify({ lockHeld: true, suspect: lock.suspect, heldBy: held, plan: [], items: [], exitCode }, null, 2) + "\n"
7121
+ JSON.stringify(
7122
+ {
7123
+ lockHeld: true,
7124
+ suspect: lock.suspect,
7125
+ heldBy: held,
7126
+ blocked: dumpBlocked(options, context),
7127
+ plan: [],
7128
+ items: [],
7129
+ exitCode
7130
+ },
7131
+ null,
7132
+ 2
7133
+ ) + "\n"
6461
7134
  );
6462
- } else {
6463
- out(sentence);
6464
- if (suspectSentence) progress(suspectSentence);
7135
+ return exitCode;
6465
7136
  }
7137
+ out(sentence);
7138
+ if (suspectSentence) progress(suspectSentence);
7139
+ dumpBlocked(options, context);
6466
7140
  return exitCode;
6467
7141
  }
6468
7142
  function explainInterruptedMarker() {
@@ -6479,6 +7153,7 @@ function explainInterruptedMarker() {
6479
7153
  }
6480
7154
  }
6481
7155
  async function runTick(settings, options) {
7156
+ beat({ phase: "pre-flight" });
6482
7157
  const hygiene = runRepoHygiene({
6483
7158
  baseBranch: settings.baseBranch,
6484
7159
  protectedBranches: settings.protectedBranches,
@@ -6486,6 +7161,7 @@ async function runTick(settings, options) {
6486
7161
  agentUser: settings.participants.agentUser,
6487
7162
  log: progress
6488
7163
  });
7164
+ beat({ phase: "discovery" });
6489
7165
  const issues = discoverIssues(settings);
6490
7166
  const linkMap = getOpenPrLinkMap();
6491
7167
  const policy = {
@@ -6511,49 +7187,85 @@ ${describePlan(decisions)}`;
6511
7187
  reportDryRun(items, decisions, settings, options, hygiene);
6512
7188
  return { exitCode: 0, reports: [] };
6513
7189
  }
7190
+ const { reports, deferred } = await processItems(items, settings, options, hygiene);
7191
+ heartbeatItem = null;
7192
+ beat({ phase: "summary" });
7193
+ reports.push(...reportDeferred(deferred, settings));
7194
+ const degraded = hygiene.degraded || reports.some((report) => report.outcome !== "answered");
7195
+ const exitCode = degraded ? 2 : 0;
7196
+ const blockedBy = classifyBlocked(hygiene, decisions, items, reports);
7197
+ const context = blockedBy === null ? null : {
7198
+ ...blockedBy,
7199
+ selection: buildSelectionEvidence(
7200
+ settings,
7201
+ issues,
7202
+ linkMap,
7203
+ decisions,
7204
+ options.verbose === true
7205
+ ),
7206
+ resolved: { ok: true, settings }
7207
+ };
7208
+ reportTickOutcome({ options, hygiene, decisions, reports, context, exitCode });
7209
+ return { exitCode, reports };
7210
+ }
7211
+ async function processItems(items, settings, options, hygiene) {
6514
7212
  const reports = [];
6515
7213
  const deferred = [];
6516
7214
  const preflightCauses = describePreflightFailures(hygiene);
6517
7215
  let runsUsed = 0;
6518
- for (const item of items) {
7216
+ for (const [index, item] of items.entries()) {
6519
7217
  if (settings.maxRuns > 0 && runsUsed >= settings.maxRuns) {
6520
7218
  deferred.push(item);
6521
7219
  continue;
6522
7220
  }
6523
- let report;
6524
- try {
6525
- report = await processItem(item, settings, options.silent === true, preflightCauses);
6526
- } catch (err) {
6527
- progress(` failed: ${err.message}
6528
- `);
6529
- report = {
6530
- issue: item.issue?.number ?? null,
6531
- pr: item.pr?.number ?? null,
6532
- title: itemTitle(item),
6533
- turn: item.turn,
6534
- outcome: "failed",
6535
- detail: err.message,
6536
- sync: inFlightSync ?? void 0
6537
- };
6538
- }
7221
+ heartbeatItem = { index: index + 1, total: items.length, subject: itemLabel(item).trim() };
7222
+ beat({ phase: "item", item: heartbeatItem });
7223
+ const report = await reportForItem(item, settings, options, preflightCauses);
6539
7224
  if (report.ranExecutor === true) runsUsed++;
6540
7225
  reports.push(report);
6541
7226
  }
6542
- for (const item of deferred) {
7227
+ return { reports, deferred };
7228
+ }
7229
+ async function reportForItem(item, settings, options, preflightCauses) {
7230
+ try {
7231
+ return await processItem(item, settings, options.silent === true, preflightCauses);
7232
+ } catch (err) {
7233
+ progress(` failed: ${err.message}
7234
+ `);
7235
+ return {
7236
+ issue: item.issue?.number ?? null,
7237
+ pr: item.pr?.number ?? null,
7238
+ title: itemTitle(item),
7239
+ turn: item.turn,
7240
+ outcome: "failed",
7241
+ detail: err.message,
7242
+ sync: inFlightSync ?? void 0
7243
+ };
7244
+ }
7245
+ }
7246
+ function reportDeferred(deferred, settings) {
7247
+ return deferred.map((item) => {
6543
7248
  progress(`
6544
7249
  ${itemLabel(item)} deferred: --max-runs / maxRunsPerTick reached.
6545
7250
  `);
6546
- reports.push({
7251
+ return {
6547
7252
  issue: item.issue?.number ?? null,
6548
7253
  pr: item.pr?.number ?? null,
6549
7254
  title: itemTitle(item),
6550
7255
  turn: item.turn,
6551
7256
  outcome: "deferred",
6552
7257
  detail: `run cap of ${String(settings.maxRuns)} reached`
6553
- });
6554
- }
6555
- const degraded = hygiene.degraded || reports.some((report) => report.outcome !== "answered");
6556
- const exitCode = degraded ? 2 : 0;
7258
+ };
7259
+ });
7260
+ }
7261
+ function reportTickOutcome({
7262
+ options,
7263
+ hygiene,
7264
+ decisions,
7265
+ reports,
7266
+ context,
7267
+ exitCode
7268
+ }) {
6557
7269
  if (options.json) {
6558
7270
  out(
6559
7271
  JSON.stringify(
@@ -6562,17 +7274,18 @@ ${itemLabel(item)} deferred: --max-runs / maxRunsPerTick reached.
6562
7274
  preflight: toHygieneJson(hygiene),
6563
7275
  plan: decisions.map(toPlanJson),
6564
7276
  items: reports.map(toItemJson),
7277
+ blocked: context === null ? null : dumpBlocked(options, context),
6565
7278
  exitCode
6566
7279
  },
6567
7280
  null,
6568
7281
  2
6569
7282
  ) + "\n"
6570
7283
  );
6571
- } else {
6572
- summarizeHygiene(hygiene);
6573
- summarize(reports);
7284
+ return;
6574
7285
  }
6575
- return { exitCode, reports };
7286
+ summarizeHygiene(hygiene);
7287
+ summarize(reports);
7288
+ if (context !== null) dumpBlocked(options, context);
6576
7289
  }
6577
7290
  function toRunJson(entry) {
6578
7291
  if (entry.kind === "refused") {