@edgehero/pi-dispatch 4.3.1 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (256) hide show
  1. package/.env.example +84 -3
  2. package/README.md +20 -7
  3. package/deploy/console.service +52 -0
  4. package/deploy/docker-compose.yml +3 -1
  5. package/deploy/pi-dispatch-valkey.container +4 -2
  6. package/deploy/sandbox-gateway.service +23 -0
  7. package/deploy/worker-env-wrapper.cmd +6 -0
  8. package/deploy/worker-env-wrapper.sh +10 -1
  9. package/package.json +117 -19
  10. package/src/agent-snapshot.mjs +49 -0
  11. package/src/answerer-runtime.mjs +194 -0
  12. package/src/answerer.mjs +178 -0
  13. package/src/approval-policy.mjs +76 -0
  14. package/src/approval-runtime.mjs +188 -0
  15. package/src/approval-session.mjs +137 -0
  16. package/src/approval-state.mjs +170 -0
  17. package/src/auth-cli.mjs +86 -0
  18. package/src/auth-connections.mjs +145 -0
  19. package/src/auth-policy.mjs +32 -0
  20. package/src/auth-store.mjs +97 -0
  21. package/src/azure-host.mjs +66 -4
  22. package/src/azure-prompt.mjs +5 -4
  23. package/src/backend-conformance.mjs +34 -2
  24. package/src/backend-local.mjs +103 -34
  25. package/src/backend-openshell.mjs +1540 -0
  26. package/src/backend-podman.mjs +63 -2
  27. package/src/backend-registry.mjs +61 -4
  28. package/src/backends.mjs +395 -6
  29. package/src/bullmq-internals.mjs +208 -0
  30. package/src/canary-cli.mjs +28 -0
  31. package/src/canary-lock.mjs +41 -0
  32. package/src/canary-runtime.mjs +237 -0
  33. package/src/canary.mjs +53 -0
  34. package/src/cancel-cli.mjs +47 -34
  35. package/src/cancel-state.mjs +213 -15
  36. package/src/capabilities.mjs +36 -1
  37. package/src/capacity-cli.mjs +28 -8
  38. package/src/capacity-records.mjs +75 -16
  39. package/src/capacity.mjs +65 -0
  40. package/src/chat-cli.mjs +288 -0
  41. package/src/chat-fork.mjs +547 -0
  42. package/src/chat-mode.mjs +156 -0
  43. package/src/chat-view.mjs +720 -0
  44. package/src/checkpoint-paths.mjs +113 -0
  45. package/src/cli.mjs +128 -41
  46. package/src/comment-tables.mjs +322 -0
  47. package/src/community-auth-adapters.mjs +87 -0
  48. package/src/community-auth-helper.mjs +76 -0
  49. package/src/community-auth-metadata.mjs +6 -0
  50. package/src/community-auth-output-pins.mjs +38 -0
  51. package/src/community-auth-patches.mjs +168 -0
  52. package/src/community-auth-pins.mjs +38 -0
  53. package/src/config.mjs +190 -29
  54. package/src/connection.mjs +83 -7
  55. package/src/console-answer.mjs +113 -0
  56. package/src/console-audit.mjs +604 -0
  57. package/src/console-bootstrap.mjs +159 -0
  58. package/src/console-client.mjs +848 -0
  59. package/src/console-probe.mjs +106 -0
  60. package/src/console-tokens.mjs +459 -0
  61. package/src/container-names.mjs +51 -0
  62. package/src/container-spec.mjs +20 -1
  63. package/src/cpu-reserve.mjs +4 -1
  64. package/src/defer-reasons.mjs +200 -0
  65. package/src/defer-write.mjs +208 -0
  66. package/src/deployment-venue.mjs +23 -6
  67. package/src/docker-run.mjs +11 -0
  68. package/src/doctor-bundles.mjs +130 -0
  69. package/src/doctor-model-proposals.mjs +109 -0
  70. package/src/doctor-openshell.mjs +238 -0
  71. package/src/doctor-results.mjs +602 -0
  72. package/src/doctor-spawner.mjs +215 -0
  73. package/src/doctor.mjs +1639 -196
  74. package/src/dollar-budget.mjs +212 -3
  75. package/src/endpoint-status.mjs +100 -0
  76. package/src/env-allowlist.mjs +96 -6
  77. package/src/env-file.mjs +6 -4
  78. package/src/file-expect.mjs +47 -0
  79. package/src/forgejo-host.mjs +42 -5
  80. package/src/forgejo-prompt.mjs +5 -4
  81. package/src/forges.mjs +33 -3
  82. package/src/fork-job.mjs +58 -0
  83. package/src/fork-policy.mjs +23 -0
  84. package/src/fork-store.mjs +338 -0
  85. package/src/get-token.mjs +1 -1
  86. package/src/git-dirty.mjs +119 -14
  87. package/src/git-hardening.mjs +85 -0
  88. package/src/github-host.mjs +31 -6
  89. package/src/github-prompt.mjs +7 -16
  90. package/src/gitlab-host.mjs +42 -3
  91. package/src/gitlab-prompt.mjs +5 -4
  92. package/src/held-document.mjs +23 -0
  93. package/src/held-replies.mjs +325 -0
  94. package/src/held-settle.mjs +266 -0
  95. package/src/hook-runs.mjs +188 -0
  96. package/src/host-budget.mjs +8 -5
  97. package/src/host-clone.mjs +72 -0
  98. package/src/host-loads.mjs +557 -0
  99. package/src/host-registry.mjs +66 -6
  100. package/src/host-telemetry.mjs +92 -0
  101. package/src/http-body.mjs +78 -0
  102. package/src/image-preflight.mjs +156 -13
  103. package/src/import-pi.mjs +60 -172
  104. package/src/inbox-relay.mjs +482 -0
  105. package/src/inbox-settle.mjs +196 -0
  106. package/src/index.mjs +734 -129
  107. package/src/init.mjs +35 -5
  108. package/src/job-data-version.mjs +75 -0
  109. package/src/job-id.mjs +30 -15
  110. package/src/job-layout.mjs +22 -0
  111. package/src/job-size.mjs +16 -0
  112. package/src/live-jobs.mjs +14 -6
  113. package/src/live-probes.mjs +6 -2
  114. package/src/live-transcript.mjs +872 -0
  115. package/src/loaded-record.mjs +609 -0
  116. package/src/mcp-config.mjs +1 -1
  117. package/src/mcp-preflight.mjs +36 -5
  118. package/src/model-attempts.mjs +84 -0
  119. package/src/model-catalog.mjs +83 -0
  120. package/src/model-cooldowns.mjs +22 -0
  121. package/src/model-failover-protocol.mjs +11 -0
  122. package/src/model-failover.mjs +134 -0
  123. package/src/model-observation.mjs +50 -0
  124. package/src/model-proposal-protocol.mjs +21 -0
  125. package/src/model-proposals.mjs +75 -0
  126. package/src/model-ref.mjs +67 -10
  127. package/src/model-roles-file.mjs +24 -0
  128. package/src/model-roles.mjs +73 -0
  129. package/src/model-status.mjs +34 -0
  130. package/src/models-json.mjs +4 -1
  131. package/src/money.mjs +1 -1
  132. package/src/oauth-openai.mjs +121 -0
  133. package/src/older-state.mjs +249 -0
  134. package/src/on-failure.mjs +17 -13
  135. package/src/open-browser.mjs +31 -2
  136. package/src/opener-cli.mjs +135 -0
  137. package/src/opener-protocol.mjs +27 -0
  138. package/src/opener-terminal.mjs +128 -0
  139. package/src/openshell-cli.mjs +155 -0
  140. package/src/openshell-policy.mjs +163 -0
  141. package/src/outbox.mjs +14 -0
  142. package/src/output-cap.mjs +1 -0
  143. package/src/package-canary-record.mjs +26 -0
  144. package/src/package-canary.mjs +135 -0
  145. package/src/package-names.mjs +9 -0
  146. package/src/package-stage.mjs +418 -0
  147. package/src/packages.mjs +24 -16
  148. package/src/parked.mjs +2 -1
  149. package/src/pause-windows.mjs +136 -17
  150. package/src/podman-stack.mjs +40 -14
  151. package/src/portfolio-snapshot.mjs +26 -7
  152. package/src/prepare-github.mjs +41 -77
  153. package/src/prepare-local.mjs +32 -2
  154. package/src/prepare.mjs +45 -11
  155. package/src/private-store-boundary.mjs +70 -0
  156. package/src/processor.mjs +1550 -173
  157. package/src/projects.mjs +23 -0
  158. package/src/prompt-fence.mjs +18 -0
  159. package/src/provider-key.mjs +7 -0
  160. package/src/provider-steering.mjs +1 -0
  161. package/src/question-context.mjs +35 -0
  162. package/src/question-group-runtime.mjs +91 -0
  163. package/src/question-groups.mjs +171 -0
  164. package/src/queue.mjs +264 -55
  165. package/src/reason-catalog.mjs +448 -0
  166. package/src/receiver-status.mjs +230 -0
  167. package/src/record-reason.mjs +37 -0
  168. package/src/recovery-queue.mjs +566 -0
  169. package/src/redact-secrets.mjs +219 -0
  170. package/src/reserve-estimate.mjs +101 -0
  171. package/src/reserved-env.mjs +36 -1
  172. package/src/retention.mjs +67 -0
  173. package/src/run-budget-ledger.mjs +283 -0
  174. package/src/run-checkpoint.mjs +627 -0
  175. package/src/run-container.mjs +749 -33
  176. package/src/run-history.mjs +791 -45
  177. package/src/run-mirror.mjs +75 -7
  178. package/src/run-progress.mjs +751 -0
  179. package/src/runtime-observations.mjs +3 -1
  180. package/src/runtime-settings.mjs +272 -37
  181. package/src/sandbox-cli.mjs +78 -2
  182. package/src/sandbox-deployment.mjs +51 -0
  183. package/src/sandbox-gateway-protocol.mjs +378 -0
  184. package/src/sandbox-hosts.mjs +100 -0
  185. package/src/sandbox-inventory.mjs +83 -0
  186. package/src/sandbox-policy.mjs +225 -0
  187. package/src/sandbox-prepare-requests.mjs +145 -0
  188. package/src/sandbox-prepare.mjs +285 -0
  189. package/src/sandbox-refs.mjs +70 -0
  190. package/src/sandbox-session-monitor.mjs +4 -0
  191. package/src/sandbox-session-watch.mjs +124 -0
  192. package/src/sandbox-sessions.mjs +161 -0
  193. package/src/sandbox-store.mjs +63 -6
  194. package/src/sandbox-workspace-guard.mjs +39 -0
  195. package/src/sandbox.mjs +27 -200
  196. package/src/scheduler-stall-guard.mjs +14 -8
  197. package/src/schedules.mjs +19 -6
  198. package/src/secret-bundles.mjs +245 -0
  199. package/src/secret-profiles.mjs +103 -3
  200. package/src/secrets.mjs +19 -9
  201. package/src/service-env.mjs +38 -5
  202. package/src/service.mjs +638 -106
  203. package/src/session-key.mjs +60 -0
  204. package/src/session-store.mjs +168 -10
  205. package/src/setup-contract.mjs +40 -0
  206. package/src/setup-egress.mjs +113 -0
  207. package/src/setup-host-entry.mjs +33 -0
  208. package/src/setup-host.mjs +242 -0
  209. package/src/setup-lock.mjs +41 -0
  210. package/src/size-records.mjs +15 -3
  211. package/src/start.mjs +1353 -135
  212. package/src/status-comment.mjs +415 -0
  213. package/src/stderr-transcript.mjs +318 -0
  214. package/src/steer-ack.mjs +122 -0
  215. package/src/steer-cli.mjs +213 -0
  216. package/src/steer-framing.mjs +185 -0
  217. package/src/steer-inbox.mjs +693 -0
  218. package/src/store-version.mjs +139 -0
  219. package/src/subscription-config.mjs +56 -0
  220. package/src/subscription-limits.mjs +3 -0
  221. package/src/subscription-quota.mjs +259 -0
  222. package/src/subscription-runtime.mjs +123 -0
  223. package/src/subscription-status.mjs +42 -0
  224. package/src/subscription-wait.mjs +11 -0
  225. package/src/subscriptions.mjs +7 -1
  226. package/src/thinking-level.mjs +19 -0
  227. package/src/touch-outbox.mjs +61 -0
  228. package/src/touch-state.mjs +133 -0
  229. package/src/triggers-file.mjs +299 -39
  230. package/src/triggers.mjs +780 -38
  231. package/src/trust-paths.mjs +171 -0
  232. package/src/up.mjs +398 -51
  233. package/src/updates-cli.mjs +61 -0
  234. package/src/updates-look.mjs +127 -0
  235. package/src/updates-reconcile.mjs +182 -0
  236. package/src/updates-subscription-adapters.mjs +8 -0
  237. package/src/updates.mjs +252 -0
  238. package/src/valkey-auth.mjs +32 -9
  239. package/src/valkey-endpoint.mjs +14 -4
  240. package/src/wait-for.mjs +1 -1
  241. package/src/wait-state.mjs +62 -3
  242. package/src/watch-board.mjs +38 -0
  243. package/src/watch-closer.mjs +1 -1
  244. package/src/watch-contract.mjs +102 -0
  245. package/src/watch-decision.mjs +78 -0
  246. package/src/watch-drainer.mjs +14 -0
  247. package/src/watch-issue-adapter.mjs +44 -0
  248. package/src/watch-issue.mjs +111 -0
  249. package/src/watch-record.mjs +10 -0
  250. package/src/watch-runtime.mjs +167 -0
  251. package/src/watch-store.mjs +183 -0
  252. package/src/watch-trace.mjs +34 -0
  253. package/src/workspace-checkpoint.mjs +1750 -0
  254. package/src/zero-touch-fixes.mjs +99 -0
  255. package/src/zero-touch.mjs +225 -0
  256. package/src/repeat-slot.mjs +0 -13
@@ -0,0 +1,448 @@
1
+ /**
2
+ * The reason catalog (issue #710, `INT-REASON-CATALOG`): one sentence and one next step for every reason a run record can
3
+ * carry, so the operator console and the `/dispatch` panel can say what happened and what to do instead of printing a
4
+ * token.
5
+ *
6
+ * BUILT FROM THE COMMENT TABLES, NEVER FROM A COPY. Where the worker already posts a FIXED sentence for a reason (its
7
+ * forge comment), that sentence IS the catalog's, read from the table the call site posts from (comment-tables.mjs, and
8
+ * mcp-preflight.mjs for the MCP refusals' one template, filled without a server name). A copy here would drift from the
9
+ * comment the issue actually got, and `AGENTS.md`'s rule is that a hand-written table restating a derivable source is
10
+ * derived or pinned. The test (reason-catalog.test.mjs) holds every derived entry identical to its source, and makes any
11
+ * table keyed by a reason the catalog's source for it unless the reason is named there as ambiguous.
12
+ *
13
+ * WRITTEN HERE, AND SAID SO, for three kinds of reason:
14
+ * - a reason whose comment carries runtime values (a wait profile, an image ref, a cap's numbers, a proxy name): the
15
+ * template stays at its call site and this holds a general sentence for the reason;
16
+ * - a reason that posts no comment (a prepare-stage refusal, a silent one, a failed retry);
17
+ * - a reason two or more paths share with different comments, where the record cannot say which one ran, so the
18
+ * general sentence covers them all: `model-not-allowed` (before starting and from the runner), `mcp-config-invalid`
19
+ * (from the pickup and from the runner), `secret-name-reserved` (for `run.secrets` and for a bundle),
20
+ * `operator-cancel` (cancelled while running, or before it started: index.mjs posts CANCELLED_BEFORE_START_COMMENT),
21
+ * `awaiting-input` (the issue's reply line, the pull request's comment-phrase one, or the held reply already taken),
22
+ * `job-user-unmappable` (JOB_USER_COMMENTS' cause rows), `envelope-mismatch` (ENVELOPE_MISMATCH_COMMENTS' cause
23
+ * rows) and `podman-conf-widens-job` (PODMAN_CONF_COMMENTS' source and finding rows).
24
+ * `next` is always written here: no table had a "what to do" line.
25
+ *
26
+ * EVERY STRING IS FIXED. No sentence and no next step takes a record value, and none holds a control byte, so a surface
27
+ * may print them without escaping beyond its own (the console still escapes everything it renders). A token that is not
28
+ * a key here (a newer worker's reason, or anything else) is `{ known: false }`, never a guess: the caller shows the
29
+ * token as it is and says it has no explanation yet.
30
+ *
31
+ * `anchor` points at the token's own heading in `docs/reasons.md`, whose `###` headings are exactly these keys (a test
32
+ * holds them both ways).
33
+ */
34
+
35
+ import {
36
+ MODEL_AGENTS_INVALID_COMMENT, APPROVAL_COMMENTS, BUNDLE_COMMENTS,
37
+ MODEL_NOT_ALLOWED_COMMENTS,
38
+ FAILED_COMMENT_BY_REASON,
39
+ OPENSHELL_REFUSALS,
40
+ RECOVERY_COMMENTS,
41
+ RECOVERY_HOST_LOST_COMMENT,
42
+ REFUSAL_COMMENTS,
43
+ SIZE_REFUSAL_COMMENTS,
44
+ TERMINAL_COMMENTS,
45
+ modelUnknownComment,
46
+ } from "./comment-tables.mjs";
47
+ import { mcpRefusalComment } from "./mcp-preflight.mjs";
48
+
49
+ /** The page every anchor points into, relative to the repository root. */
50
+ export const REASONS_DOC = "docs/reasons.md";
51
+
52
+ const imageRefusal = (what, so) => `Refused: the job image does not declare ${what}, so ${so}. Rebuild the image from a version that has this feature. Not run.`;
53
+ const REBUILD = "Rebuild the job image from a release that declares the feature, or set the trigger's `run.image` to one that does.";
54
+
55
+ /**
56
+ * Every top-level reason: `[token, sentence, next]`. Grouped as `docs/reasons.md` groups them. A sentence written as a
57
+ * table read is derived; a string literal is written here for one of the three reasons in the header.
58
+ */
59
+ const ROWS = [
60
+ ["watch-unavailable", "The worker could not resolve the watch reference safely. No investigation started.", "Check watch runtime configuration and worker availability."],
61
+ ["watch-query-credential-missing", "A reviewed log query requires a host credential that is unavailable. No investigation started.", "Configure the named read-only query credential on the worker host."],
62
+ ["watch-cooldown", "The finding arrived during its fingerprint cooldown. No additional investigation started.", "Wait for cooldown to end or inspect the existing finding."],
63
+ ["watch-cap", "The source, rule or active-fingerprint cap refused the finding before paid work.", "Inspect the source and caps before changing the reviewed limits."],
64
+ ["watch-unmatched", "The finding matched no reviewed watch rule. No investigation started.", "Check the source and structured match fields."],
65
+ ["watch-ignored", "The reviewed decision selected ignore. Only the decision was charged.", "Inspect its probabilities and thresholds if the outcome was unexpected."],
66
+ ["decide-refused", "The decision model or its bounded price was refused.", "Use a known input-only priced decision model and a sufficient reviewed cap."],
67
+ ["question-group-unavailable", "Question grouping is not configured on this worker.", "Check the Zero touch grouping status and reviewed configuration before trying again."],
68
+ ["question-group-config-changed", "The reviewed question grouping settings changed.", "Check the Zero touch grouping status and reviewed configuration before trying again."],
69
+ ["question-group-source-expired", "The private source for this question expired.", "Check the Zero touch grouping status and reviewed configuration before trying again."],
70
+ ["question-group-source-unavailable", "The private source for this question could not be read.", "Check the Zero touch grouping status and reviewed configuration before trying again."],
71
+ ["question-group-source-oversize", "The private source exceeds the grouping input limit.", "Check the Zero touch grouping status and reviewed configuration before trying again."],
72
+ ["question-group-candidates-expired", "A group exemplar expired, so matching would be incomplete.", "Check the Zero touch grouping status and reviewed configuration before trying again."],
73
+ ["question-group-limit", "The repository has reached the bounded candidate group limit.", "Check the Zero touch grouping status and reviewed configuration before trying again."],
74
+ ["question-group-stale", "The question groups changed while a job was being prepared.", "Check the Zero touch grouping status and reviewed configuration before trying again."],
75
+ ["question-group-refused", TERMINAL_COMMENTS["question-group-refused"], "Check the Zero touch grouping status and reviewed configuration before trying again."],
76
+ ["question-group-unanswered", TERMINAL_COMMENTS["question-group-unanswered"], "Check the Zero touch grouping status and reviewed configuration before trying again."],
77
+ ["question-group-output-invalid", "The grouping result did not satisfy the closed result contract.", "Check the Zero touch grouping status and reviewed configuration before trying again."],
78
+ ["job-image-question-grouping-unsupported", "The job image does not declare isolated question grouping.", "Check the Zero touch grouping status and reviewed configuration before trying again."],
79
+ ["job-image-load-exclusions-unsupported", "The job image does not declare support for this job's exclusions, so its runner would not honour them. No container started and nothing was spent.", "Rebuild the job image from this release."],
80
+ ["job-image-package-canary-unsupported", "The job image does not declare package canaries, so it could not report whether the candidate loaded. No container started and nothing was spent.", "Rebuild the job image from this release."],
81
+ ["job-image-answer-draft-unsupported", "The job image does not declare restricted, authenticated answer drafting. No draft job started.", "Rebuild the image before enabling answer rules."],
82
+ ["decide-unanswered", "The decision did not produce a usable answer; its reviewed human fallback applies.", "Inspect the decision trace and the fallback issue."],
83
+ ["watch-reference-invalid", "The queued watch reference does not identify the reviewed finding. No investigation started.", "Check the producer and worker versions and submit a new authenticated finding."],
84
+ ["watch-config-changed", "The reviewed watch rule changed or disappeared before pickup. No investigation started.", "Review the current configuration and submit the finding again if still wanted."],
85
+ ["watch-window-pending", "The digest window is still open. No investigation started.", "Wait until the finding is due and inspect delayed queue health."],
86
+ ["watch-threshold", "The completed digest did not reach its minimum count. No investigation started.", "Inspect the digest counts and review the configured threshold."],
87
+ ["watch-evidence-expired", "The private finding evidence expired or is unavailable. No investigation started.", "Check evidence retention and queue delay before submitting a new finding."],
88
+ ["watch-issue-target-missing", "The selected action needs a reviewed issue target that is unavailable.", "Configure an explicit supported forge issue target."],
89
+ ["watch-trace-unavailable", "The investigation trace could not be collected completely.", "Check the trace store and collector; treat the investigation record as incomplete."],
90
+ ["watch-output-unavailable", "The completed investigation output could not be collected safely.", "Check the output files, schema and store before retrying."],
91
+ ["watch-project-mismatch", "The watch source project differs from the resolved investigation folder project. Nothing was spent.", "Correct the reviewed source and folder project pairing."],
92
+ ["watch-config-invalid", "The job image refused its watch isolation marker or incompatible integrations.", "Use a current watch-capable image and read-only reviewed configuration."],
93
+ ["watch-issue-cap", "The source reached its durable watch issue identity limit before calling the forge.", "Inspect existing issue mappings and reduce fingerprint cardinality."],
94
+ ["watch-issue-uncertain", "An earlier issue creation has an uncertain result; another issue was not created.", "Reconcile its stable marker on the forge before retrying or changing the mapping."],
95
+ ["watch-issue-close-policy-changed", "The quiet-close policy changed before the issue could be closed.", "Inspect the current reviewed rule and issue status."],
96
+ ["watch-issue-close-raced", "A new finding arrived during issue closure; the host reopened the issue.", "Inspect the ongoing incident and its latest count."],
97
+ ["watch-issue-close-uncertain", "The forge outcome of quiet closure is uncertain.", "Inspect the issue on the forge and retry reconciliation."],
98
+ ["job-image-watch-unsupported", "The job image does not declare read-only watch isolation. The investigation was refused.", "Rebuild the image from a watch-capable release or select a compatible reviewed image."],
99
+ ["watch-board-invalid", "The proposed watch board failed its inert block schema or trace provenance checks. The previous board remains.", "Inspect the proposal and produce a schema-valid board with trace references."],
100
+ ["watch-board-oversize", "The proposed watch board exceeded 16 KiB. The previous board remains.", "Reduce the proposal before producing another board."],
101
+
102
+ ["model-role-unknown", "Refused: the requested model role is not declared in the reviewed role map. Nothing was spent.", "Add or correct the role in triggers.json modelRoles, then run the trigger again."],
103
+ ["model-role-provider-conflict", "Refused: the trigger's provider conflicts with its mapped role provider. Nothing was spent.", "Remove run.provider or make it agree with modelRoles."],
104
+ ["model-agents-invalid", MODEL_AGENTS_INVALID_COMMENT, "Correct the base commit's .pi/agents frontmatter and run again."],
105
+ ["model-roles-invalid", TERMINAL_COMMENTS["model-roles-invalid"], "Check the reviewed role map, its effective 16-model ceiling, and the job image inputs."],
106
+ ["model-roles-shadowed", TERMINAL_COMMENTS["model-roles-shadowed"], "Remove the conflicting subagent extension or staged package and run again."],
107
+ ["job-image-model-roles-unsupported", "Refused: the job image does not declare modelRoles support. Rebuild it before running role-mapped jobs. Not run.", REBUILD],
108
+ ...Object.entries(APPROVAL_COMMENTS).map(([token, sentence]) => [token, sentence, "Check the current approval, its saved session, and the trigger declaration; reload before replying again."]),
109
+ // ── Stopped while running ───────────────────────────────────────────────────────────────────────────────────────
110
+ ["worker-abort", TERMINAL_COMMENTS["worker-abort"], "Check the worker log for the 30-minute limit or a shutdown, then run the job again if its work is still wanted."],
111
+ ["operator-cancel", "Stopped: the operator cancelled this run. If it had started, partial work may exist; if it was cancelled before it started, nothing was spent. Not retried.", "Nothing to fix. Run the job again if its work is still wanted."],
112
+ ["agent-cancel", TERMINAL_COMMENTS["agent-cancel"], "Ask an operator to review the agent's audit record before starting replacement work."],
113
+ ["answer-draft-refused", TERMINAL_COMMENTS["answer-draft-refused"], "Check the hand-written answer rule and the source run; no answer was sent."],
114
+ ["answer-draft-config-changed", "The reviewed answer configuration or source identity changed before its draft could be used.", "Review the current rule. The parked question still waits for a person."],
115
+ ["answer-draft-unavailable", "Draft state could not be read or published.", "Check the deployment's store and retained run sources. Do not repeat a paid job for its metric."],
116
+ ["runner-policy", TERMINAL_COMMENTS["runner-policy"], "Read the job log for the turn or token budget it reached, or the refusal inside the container, and adjust the trigger."],
117
+ ["provider-auth-refused", TERMINAL_COMMENTS["provider-auth-refused"], "Check the provider key on the worker and what it may use, then run the job again."],
118
+ ["cost-cap", TERMINAL_COMMENTS["cost-cap"], "Raise the trigger's `run.maxCostUsd` or the deployment's `PI_MAX_COST_USD` if the work needs more, then run the job again."],
119
+ ["model-not-allowed", "Stopped: an AI model this job would call is not on its list of allowed models, or the run tried to change an AI request in a way it does not allow, so that call was not made. When this was decided before the job started, no container was started and nothing was spent.", "Check the trigger's `run.models` and `PI_ALLOWED_MODELS`, and list every model the job may call, fallbacks included, or check the job log for a refused request."],
120
+ ["cost-cap-unenforceable", TERMINAL_COMMENTS["cost-cap-unenforceable"], "Update the job image to a release that enforces cost limits, then run the job again."],
121
+ ["model-policy-unenforceable", TERMINAL_COMMENTS["model-policy-unenforceable"], "Update the job image to a release that enforces model lists, then run the job again."],
122
+ ["oom-killed", TERMINAL_COMMENTS["oom-killed"], "Raise this project's job memory size, or make the job use less memory, then run it again."],
123
+ ["awaiting-input", "The run asked a question and stopped to wait for the answer. Its work so far is saved, and the next run continues from there. If a reply arrived while it was stopping, that reply was taken as the answer and the run continues on its own.", "If no reply was taken yet, answer the question: reply on the issue or pull request (on a pull request, start the reply with this repository's pi-dispatch comment phrase), or answer from the console's run page."],
124
+ ["not-parked", "Not run: a reply reached this job as the answer to a waiting run, but no run on this issue was waiting, so it was ordinary conversation. Nothing was spent.", "Nothing to fix. The stale waiting marker was removed from the issue."],
125
+ // ── Budgets and caps ────────────────────────────────────────────────────────────────────────────────────────────
126
+ ["over-budget", "Refused: the deployment's cap on how many jobs may start in the day, week or month was already reached, so no container was started. Not run.", "Wait for the window to roll over, or raise the cap with `/dispatch set` if it is too low."],
127
+ ["soft-hold", "Refused: the spend in a budget window is inside the soft-hold band below its cap, so new starts are paused. Not run.", "Wait for the window to roll over, or raise the cap or narrow the hold band."],
128
+ ["daily-token-cap", "Refused: the tokens spent today had already reached the daily token cap, so no container was started. Not run.", "Wait for the next UTC day, or raise the daily token cap."],
129
+ ["dollar-cap", "Refused: a dollar budget (the deployment's, a repository's or folder's, a project's or a model's) had no room left for this run's cost limit, or is smaller than it, so no container was started and nothing was spent. Not run.", "Wait for the window to roll over, or raise that budget or lower the run's cost limit."],
130
+ ["allocation-cap", "Refused: the share of the budget split for this job's project had no room left for this run's cost limit, or is smaller than it, so no container was started and nothing was spent. Not run.", "Wait for the window to roll over or for the next priorities plan. The split sets this number, so raising a budget does not help."],
131
+ ["envelope-mismatch", "Refused: this worker's budget envelope is missing, or differs from the one the applied budget split was made for, so no container was started and nothing was spent. Not run.", "Run `pi-dispatch doctor` on each worker and make their envelopes agree, or turn delegated allocation off on all of them."],
132
+ ["portfolio-no-envelope", "Refused: this portfolio job writes a priorities plan, and this worker has no envelope that lets a portfolio job apply one, so it was not run and nothing was spent.", "Install the envelope with delegation on and `portfolio-job` among its writers, or remove the portfolio flag from the trigger."],
133
+ ["portfolio-snapshot-oversize", "Refused: the portfolio snapshot this job would read is larger than a job may carry, so no container was started and nothing was spent. Not run.", "Govern fewer projects or members in the envelope, then run the job again."],
134
+ ["scope-cap", "Refused: this repository's or folder's run cap for the window was already reached, so no container was started and nothing was spent. Not run.", "Wait for the window to roll over, or raise that row in `scoped-limits.json`."],
135
+ ["project-cap", "Refused: this project's run cap for the window was already reached, so no container was started and nothing was spent. Not run.", "Wait for the window to roll over, or raise the project's row in `scoped-limits.json`."],
136
+ ["job-size-exceeds-host", SIZE_REFUSAL_COMMENTS["job-size-exceeds-host"], "Lower this project's job size, or raise the worker's host budget (`pi-dispatch doctor` shows both)."],
137
+ ["job-size-exceeds-share", SIZE_REFUSAL_COMMENTS["job-size-exceeds-share"], "Lower this project's job size, or raise its host share (`pi-dispatch doctor` shows both)."],
138
+ // ── Triggers and waits ──────────────────────────────────────────────────────────────────────────────────────────
139
+ ["once-already-spent", "Refused: this one-shot trigger was already spent by an earlier run, so a later close did not start another. Not run.", "Delete `on.disarmed` from the trigger entry to arm it again."],
140
+ ["trigger-skew", "Refused: the trigger sets a field the job reached the worker without, so it would have run as though the trigger did not set it. Not run.", "Run the job again if the trigger changed after it was queued; otherwise update or restart the stale service."],
141
+ ["wait-skew", "Refused: the trigger declares wait conditions, but the job reached the worker without them, so it would have run at once. Not run.", "Update the service below the version that carries the field, or restart the one still reading an older triggers file."],
142
+ ["wait-unreadable", "Refused: this job carries wait conditions the worker cannot read or cannot hand to a check, so it cannot honour them. Not run.", "Run the worker at the release of the service that queued the job, or fix the condition in the trigger."],
143
+ ["wait-profile-unknown", "Refused: the job waits on a check that no wait profile on this deployment declares. Not run.", "Declare the wait profile on the worker, or fix its name in the trigger's `waitFor`."],
144
+ ["wait-superseded", "Not run: another delivery for the same target is already waiting, or already finished waiting, on the same conditions.", "Nothing to fix. The other delivery carries the work."],
145
+ ["wait-after-beyond-max", "Refused: the trigger's `after` instant is further out than this deployment lets a job wait. Not run.", "Move the `after` instant closer, or raise the deployment's maximum wait."],
146
+ ["wait-refused", "Not run: the wait check reported that its condition will never clear.", "Read the check's answer in the worker log, then fix the condition or the check."],
147
+ ["wait-unanswerable", "Not run: the wait check could not answer several times in a row.", "Run the wait profile's check by hand on the worker to see why it fails."],
148
+ ["wait-expired", "Not run: the job gave up waiting, because its condition did not clear within the maximum wait or number of checks.", "Run the job again once the condition holds, or allow a longer wait."],
149
+ // ── Models and providers ────────────────────────────────────────────────────────────────────────────────────────
150
+ ["model-unknown", modelUnknownComment(null), "Check the trigger's model and provider names, or fix the overlay `models.json` that `pi-dispatch doctor` names."],
151
+ ["model-failover-no-candidate", "Stopped before starting: none of this job's reviewed models can run safely, so no container was started and nothing was spent.", "Check the trigger's run.fallbackModels, its allowed list, provider credentials, pricing and egress. The worker log names each skipped model and its fixed reason."],
152
+ ["auth-policy-invalid", "Stopped before starting: the configured subscription connection cannot authorize this job (auth-policy-invalid). No container was started and nothing was spent.", "Run `pi-dispatch auth status` on the connection owner host, check its adapter, quota and finite model policy, then reconnect or authorize one ordinary trial when appropriate."],
153
+ ["auth-adapter-unsupported", "Stopped before starting: the configured subscription connection cannot authorize this job (auth-adapter-unsupported). No container was started and nothing was spent.", "Run `pi-dispatch auth status` on the connection owner host, check its adapter, quota and finite model policy, then reconnect or authorize one ordinary trial when appropriate."],
154
+ ["auth-adapter-unapproved", "Stopped before starting: the configured subscription connection cannot authorize this job (auth-adapter-unapproved). No container was started and nothing was spent.", "Run `pi-dispatch auth status` on the connection owner host, check its adapter, quota and finite model policy, then reconnect or authorize one ordinary trial when appropriate."],
155
+ ["auth-owner-host", "Stopped before starting: the configured subscription connection cannot authorize this job (auth-owner-host). No container was started and nothing was spent.", "Run `pi-dispatch auth status` on the connection owner host, check its adapter, quota and finite model policy, then reconnect or authorize one ordinary trial when appropriate."],
156
+ ["auth-adapter-unavailable", "Stopped before starting: the configured subscription connection cannot authorize this job (auth-adapter-unavailable). No container was started and nothing was spent.", "Run `pi-dispatch auth status` on the connection owner host, check its adapter, quota and finite model policy, then reconnect or authorize one ordinary trial when appropriate."],
157
+ ["auth-adapter-conflicting", "Stopped before starting: the staged packages contain competing reviewed Claude subscription adapters. No container was started and nothing was spent.", "Keep exactly one reviewed Claude adapter in pi-packages.json, restage with `pi-dispatch import-pi --with-packages`, and check the selected connection's root and digest before retrying."],
158
+ ["auth-capability-unsupported", "Stopped before starting: the configured subscription connection cannot authorize this job (auth-capability-unsupported). No container was started and nothing was spent.", "Run `pi-dispatch auth status` on the connection owner host, check its adapter, quota and finite model policy, then reconnect or authorize one ordinary trial when appropriate."],
159
+ ["auth-endpoint-unsupported", "Stopped before starting: the configured subscription connection cannot authorize this job (auth-endpoint-unsupported). No container was started and nothing was spent.", "Run `pi-dispatch auth status` on the connection owner host, check its adapter, quota and finite model policy, then reconnect or authorize one ordinary trial when appropriate."],
160
+ ["auth-reauth-required", "Stopped before starting: the configured subscription connection cannot authorize this job (auth-reauth-required). No container was started and nothing was spent.", "Run `pi-dispatch auth status` on the connection owner host, check its adapter, quota and finite model policy, then reconnect or authorize one ordinary trial when appropriate."],
161
+ ["auth-refresh-uncertain", "Stopped before starting: the configured subscription connection cannot authorize this job (auth-refresh-uncertain). No container was started and nothing was spent.", "Run `pi-dispatch auth status` on the connection owner host, check its adapter, quota and finite model policy, then reconnect or authorize one ordinary trial when appropriate."],
162
+ ["auth-store-unavailable", "Stopped before starting: the configured subscription connection cannot authorize this job (auth-store-unavailable). No container was started and nothing was spent.", "Run `pi-dispatch auth status` on the connection owner host, check its adapter, quota and finite model policy, then reconnect or authorize one ordinary trial when appropriate."],
163
+ ["auth-snapshot-too-short", "Stopped before starting: the configured subscription connection cannot authorize this job (auth-snapshot-too-short). No container was started and nothing was spent.", "Run `pi-dispatch auth status` on the connection owner host, check its adapter, quota and finite model policy, then reconnect or authorize one ordinary trial when appropriate."],
164
+ ["auth-route-unbounded", "Stopped before starting: the configured subscription connection cannot authorize this job (auth-route-unbounded). No container was started and nothing was spent.", "Run `pi-dispatch auth status` on the connection owner host, check its adapter, quota and finite model policy, then reconnect or authorize one ordinary trial when appropriate."],
165
+ ["auth-venue-unsupported", "Stopped before starting: the configured subscription connection cannot authorize this job (auth-venue-unsupported). No container was started and nothing was spent.", "Run `pi-dispatch auth status` on the connection owner host, check its adapter, quota and finite model policy, then reconnect or authorize one ordinary trial when appropriate."],
166
+ ["auth-adapter-disabled", "Stopped before starting: the configured subscription connection cannot authorize this job (auth-adapter-disabled). No container was started and nothing was spent.", "This job goes without the connection's adapter, so reconnecting cannot help. Take the adapter's package off the trigger's `run.excludeExtensions`, or take its `\"packages\": false` off. `pi-dispatch auth status` on the connection owner host names the adapter."],
167
+ ["auth-credit-cap-required", "Stopped before starting: the configured subscription connection cannot authorize this job (auth-credit-cap-required). No container was started and nothing was spent.", "Run `pi-dispatch auth status` on the connection owner host, check its adapter, quota and finite model policy, then reconnect or authorize one ordinary trial when appropriate."],
168
+ ["auth-mixed-billing", "Stopped before starting: this job's finite model routes mix a bound subscription connection with an unbound API billing route. No container was started and nothing was spent.", "Use only reviewed subscription-bound routes for this job, or use a separate job with only unbound API routes. A subscription fallback cannot spend an API key."],
169
+ ["auth-binding-changed", "Stopped before starting: the configured subscription connection cannot authorize this job (auth-binding-changed). No container was started and nothing was spent.", "Run `pi-dispatch auth status` on the connection owner host, check its adapter, quota and finite model policy, then reconnect or authorize one ordinary trial when appropriate."],
170
+ ["auth-model-unverified", "Stopped before starting: this subscription connection has no verified physical model and endpoint for the requested route. No container was started and nothing was spent.", "Use a supported pinned model on the connection's direct provider endpoint and check the reviewed model list."],
171
+ ["auth-usage-unavailable", "Stopped before starting: the subscription usage and paid-overage state could not be established. No container was started and nothing was spent.", "Run `pi-dispatch auth usage` for this connection on its owner host; check authentication and the bounded usage endpoint before retrying."],
172
+ ["subscription-quota", "Stopped before starting: the configured subscription connection cannot authorize this job (subscription-quota). No container was started and nothing was spent.", "Run `pi-dispatch auth status` on the connection owner host, check its adapter, quota and finite model policy, then reconnect or authorize one ordinary trial when appropriate."],
173
+ ["subscription-extra-usage", "Stopped before starting: the configured subscription connection cannot authorize this job (subscription-extra-usage). No container was started and nothing was spent.", "Run `pi-dispatch auth status` on the connection owner host, check its adapter, quota and finite model policy, then reconnect or authorize one ordinary trial when appropriate."],
174
+ ["subscription-stale-binding", "Stopped before starting: this subscription admission used an obsolete connection owner or credential generation. No container was started and nothing was spent.", "Reload connection status on its owner host and retry through the current reviewed binding; do not copy an old credential snapshot."],
175
+ ["subscription-observation-required", "Stopped before starting: a measured provider throttle invalidated this subscription connection's cached quota and paid-overage observations. No new container was started and nothing was spent on this admission.", "Refresh usage on the connection's owning host before retrying. A provider throttle alone does not prove that the subscription plan is exhausted."],
176
+ ["provider-unconfigured", "Refused: this deployment has no usable credential for the job's AI provider, so no container was started and nothing was spent. Not run.", "Run `pi-dispatch doctor` on the worker: it names the variable to set."],
177
+ // ── The job image ───────────────────────────────────────────────────────────────────────────────────────────────
178
+ ["job-image-missing", "Refused: the job image this job runs is not present on the worker host, so no container was started and nothing was spent. Not run.", "Pull or build the image on the worker, or set the trigger's `run.image` to one that is there."],
179
+ ["job-image-forge-unsupported", "Refused: the job image does not support this job's forge (it ships no client for it). Not run.", "Set the trigger's `run.image` to an image that declares this forge."],
180
+ ["job-image-replicas-unsupported", imageRefusal("replica support", "its baked guardrails would name the wrong branch"), REBUILD],
181
+ ["job-image-commands-unsupported", imageRefusal("command support", "its runner would not dispatch `run.command`"), REBUILD],
182
+ ["job-image-exclude-tools-unsupported", imageRefusal("exclude-tools support", "its runner would ignore `run.excludeTools`"), REBUILD],
183
+ ["job-image-model-policy-unsupported", imageRefusal("allowed-model support", "its runner cannot hold this job to the models it may use"), REBUILD],
184
+ ["job-image-model-failover-unsupported", imageRefusal("model-failover support", "its runner cannot report the signed provider failure and replay-safety evidence this job needs"), REBUILD],
185
+ ["job-image-subscription-auth-unsupported", imageRefusal("subscription authentication", "it cannot verify the admitted access snapshot, community adapter or final request cost"), REBUILD],
186
+ ["job-image-cost-cap-unsupported", imageRefusal("cost-cap support", "its runner would ignore the per-job dollar cap"), REBUILD],
187
+ ["job-image-any-uid-unsupported", imageRefusal("`anyUid`", "it cannot run as this worker's own uid, which this host's container runtime requires"), REBUILD],
188
+ ["job-image-mcp-unsupported", imageRefusal("MCP support", "its runner would not connect the servers `run.mcp` names"), REBUILD],
189
+ ["job-image-forge-mcp-unsupported", imageRefusal("the forge's own MCP server", "it does not carry the server `run.mcp` names as `forge`"), REBUILD],
190
+ ["job-image-await-input-unsupported", imageRefusal("support for stopping to wait for an answer", "its runner would ignore `run.awaitInput`"), REBUILD],
191
+ ["job-image-progress-unsupported", imageRefusal("support for reporting progress", "its runner would ignore `run.progress`"), REBUILD],
192
+ ["job-image-recover-unsupported", imageRefusal("support for continuing a lost run", "its runner would ignore `run.recover`"), REBUILD],
193
+ ["job-image-steer-unsupported", imageRefusal("support for receiving messages while it runs", "its runner would ignore `run.steer`"), REBUILD],
194
+ ["job-image-sandbox-layout-unsupported", imageRefusal("support for a venue that copies the job's files into its sandbox", "its runner would not find them"), REBUILD],
195
+ ["job-image-observable-runner-unsupported", imageRefusal("support for a venue whose sandbox must observe the runner", "the job could reach neither its model nor anything else"), REBUILD],
196
+ // ── The venue and the host ──────────────────────────────────────────────────────────────────────────────────────
197
+ ["backend-unblessed", "Refused: the trigger asks for a venue this deployment does not bless (`PI_BACKENDS`). Not run.", "Add the venue to `PI_BACKENDS` on the worker, or change the trigger's `run.backend`."],
198
+ ["backend-floor-unobserved", "Refused: this deployment's `PI_BACKEND_FLOOR` requires a guarantee this host is not observed to provide right now, so the job was not run. Not run.", "Run `pi-dispatch doctor` on the worker to see which observation failed, then fix the runtime or lower the floor."],
199
+ ["venue-local-folders-unsupported", "Refused: this job runs against a folder on this machine, and its venue copies a job's files instead of editing a folder in place. Not run.", "Run it on a venue that mounts folders (local or podman) with the trigger's `run.backend`."],
200
+ ["job-user-unmappable", "Refused: the worker host's container runtime cannot give this job a non-root user that can read and write its own files, or answered in a form the worker cannot read, so the job had no user it could safely run as. Not run.", "Run `pi-dispatch doctor` on the worker: it names the cause (for example a rootless daemon, userns-remap, a worker running as root, a worker whose primary group is root or the Docker socket's, or a runtime answer it cannot read)."],
201
+ ["podman-conf-widens-job", "Refused: the worker host's Podman configuration (rootful Podman's, the podman venue's files, or its running rootless network) lets a job's container reach more than the venue allows, or could not be read in full. Not run.", "Run `pi-dispatch doctor` on the worker: it names the file and key to change, or the network to restart."],
202
+ ["podman-service-restart-hold-expired", FAILED_COMMENT_BY_REASON["podman-service-restart-hold-expired"], "Restart rootful Podman's service while no local job runs, then run the job again."],
203
+ ["netns-keeper-not-holding", FAILED_COMMENT_BY_REASON["netns-keeper-not-holding"], "Run `pi-dispatch doctor` on the worker for the exact fix, then run the job again."],
204
+ ["netns-keeper-crash-loop", FAILED_COMMENT_BY_REASON["netns-keeper-crash-loop"], "Read the keeper's log (`journalctl --user -u pi-dispatch-netns-keeper.service`) and run `pi-dispatch doctor`."],
205
+ ["egress-proxy-missing", "Refused: this deployment runs jobs behind an egress policy, and its allowlist proxy is not on this host, so the job could not have reached its provider. Not run.", "Start the proxy (`pi-dispatch up` from the deployment folder, or as `docs/podman.md` says for the podman venue), or set `PI_EGRESS=0`."],
206
+ ["egress-proxy-stopped", "Refused: this deployment runs jobs behind an egress policy, and its allowlist proxy is not running, so the job could not have reached its provider. Not run.", "Start the proxy (`pi-dispatch up` from the deployment folder, or as `docs/podman.md` says for the podman venue), or set `PI_EGRESS=0`."],
207
+ ["container-never-started", "Failed: the container runtime never handed control to the job (it could not create or start the container, or a check before it could not run), so nothing was spent. The job was retried and did not start.", "Run `pi-dispatch doctor` on the worker and check the container runtime, then run the job again."],
208
+ ["container-detached", "Failed: the job's container outlived the command that started it (a lost connection to the runtime), so the worker could not see how it ended. The job was retried.", "Check the worker's connection to its container runtime, and stop any job container left running."],
209
+ ["container-infra-exit", "Failed: the job's container ended with the runner's infrastructure exit (an AI provider error or rate limit, a lost network, or a crash inside the container), so the worker counted it as an infrastructure fault and retried it while attempts were left. Partial work may exist.", "Read the `exit` line in the job log for what the runner met, then run the job again if every attempt was used."],
210
+ ["container-exit-unknown", "Failed: the job's container ended with an exit code the runner never uses (for example a container stopped from outside the worker), so the worker could not tell what happened and retried it while attempts were left. Partial work may exist.", "Read the job log for how the container ended: a job image older or newer than the worker, or a container stopped from outside the worker, can cause this."],
211
+ ["worker-error", "Failed: the worker met an error it has no reason for (a forge or Valkey call that failed, or a fault in the worker itself). The record keeps none of the error's own words, which can name an API path or a repository.", "Read the worker log's `job_failed` line for this job, then run the job again."],
212
+ ["config-refused", REFUSAL_COMMENTS["config-refused"], "Run `pi-dispatch doctor` on the worker: it names the setting or file to fix."],
213
+ ["settings-overlay-invalid", "Refused: the deployment's settings file (the overlay `/dispatch set` writes) is present but invalid, so every job is refused rather than run on settings nobody chose. Not run.", "Fix the key `pi-dispatch doctor` names, or delete the settings file to start from an empty overlay."],
214
+ // ── The OpenShell venues ────────────────────────────────────────────────────────────────────────────────────────
215
+ ["openshell-gateway-not-local", OPENSHELL_REFUSALS["openshell-gateway-not-local"], "Run the OpenShell gateway on this host, or run the job on another venue."],
216
+ ["openshell-global-policy", OPENSHELL_REFUSALS["openshell-global-policy"], "Remove the gateway-wide policy from the OpenShell gateway, or run the job on another venue."],
217
+ ["openshell-landlock-missing", OPENSHELL_REFUSALS["openshell-landlock-missing"], "Run OpenShell jobs on a host whose kernel offers Landlock, or run the job on another venue."],
218
+ ["openshell-recover-unsupported", OPENSHELL_REFUSALS["openshell-recover-unsupported"], "Run the job on another venue, or drop `run.recover` from the trigger."],
219
+ ["openshell-credential-reads-value", OPENSHELL_REFUSALS["openshell-credential-reads-value"], "Run the job on another venue, or with a provider whose key is sent as it is."],
220
+ ["openshell-gateway-untested", OPENSHELL_REFUSALS["openshell-gateway-untested"], "Run the OpenShell release this pi-dispatch release names (`pi-dispatch doctor` says which)."],
221
+ ["openshell-egress-untranslatable", OPENSHELL_REFUSALS["openshell-egress-untranslatable"], "Change the egress allowlist entry `pi-dispatch doctor` names, or run the job on another venue."],
222
+ ["openshell-bind-mounts-disabled", OPENSHELL_REFUSALS["openshell-bind-mounts-disabled"], "Allow driver config and bind mounts in the gateway's `gateway.toml`, turn resource admission off, and restart the gateway."],
223
+ ["openshell-ptrace-scope-unprotected", OPENSHELL_REFUSALS["openshell-ptrace-scope-unprotected"], "Set `kernel.yama.ptrace_scope` to 1 on the worker host."],
224
+ ["openshell-ptrace-scope-too-strict", OPENSHELL_REFUSALS["openshell-ptrace-scope-too-strict"], "Set `kernel.yama.ptrace_scope` to exactly 1 on the worker host."],
225
+ ["openshell-bind-sandbox-lingering", OPENSHELL_REFUSALS["openshell-bind-sandbox-lingering"], "Check the sandbox on the OpenShell gateway and remove it, then check the folder's files before running the job again."],
226
+ ["openshell-bundle-endpoint-not-allowed", OPENSHELL_REFUSALS["openshell-bundle-endpoint-not-allowed"], "Add the host to `egress-allowlist.conf` as itself, or remove it from the bundle's endpoints."],
227
+ ["openshell-bundle-endpoint-shared", OPENSHELL_REFUSALS["openshell-bundle-endpoint-shared"], "Remove that host from the bundle's endpoints."],
228
+ ["openshell-bundle-endpoint-unknown", OPENSHELL_REFUSALS["openshell-bundle-endpoint-unknown"], "Fix the name in the bundle's endpoints, or add the variable in the secrets manager."],
229
+ ["openshell-bundle-name-taken", OPENSHELL_REFUSALS["openshell-bundle-name-taken"], "Rename the variable in the secrets manager."],
230
+ ["openshell-bundle-unpassable", OPENSHELL_REFUSALS["openshell-bundle-unpassable"], "Run the project on another venue, or change the bundle's endpoints."],
231
+ ["openshell-bundle-image-too-old", OPENSHELL_REFUSALS["openshell-bundle-image-too-old"], "Rebuild the job image from a release that declares `bundleEnvFile`."],
232
+ // ── The repository or folder ────────────────────────────────────────────────────────────────────────────────────
233
+ ["unprotected-branch", REFUSAL_COMMENTS["unprotected-branch"], "Protect the repository's default branch, then run the job again."],
234
+ ["sha-gone", "Refused: the commit this job was resolved to is no longer on the forge (the default branch moved past it), so it could not be fetched. Not run.", "Run the job again: it resolves the default branch afresh."],
235
+ ["local-folder-is-sandbox", "Refused: this folder is a kept sandbox workspace for a person, so it cannot become an agent job workspace. Not run.", "Choose the original repository outside the sandbox retention directory."],
236
+ ["local-folder-not-a-repo", "Refused: this job's folder is not a git repository (it has no `.git` at its root). Not run.", "Run `git init` in the folder and commit once, or point the job at a repository."],
237
+ ["local-folder-no-commit", "Refused: this job's folder is a git repository with no commit yet. Not run.", "Commit once in the folder, then run the job again."],
238
+ ["local-folder-unreadable-repo", "Refused: git could not read this job's repository (its HEAD, a ref or an object could not be resolved). Not run.", "Run `git status` in the folder to see what is wrong, and repair the repository."],
239
+ ["local-folder-escaped", "Refused: this job's folder is named inside a run root or a cron trigger's folder, but it now resolves outside it through a link, so it was not mounted. Not run.", "List the real folder as a run root or as the trigger's `run.folder`, or remove the link."],
240
+ ["local-folder-holds-envelope", "Refused: this job's folder is the budget envelope's folder or a folder above it, so a job there could edit the envelope. Not run.", "Keep the envelope file outside every job's folder."],
241
+ ["local-folder-holds-subscription-auth", "Refused: this job's folder contains the private subscription credential store, so mounting it would expose refresh credentials. Not run.", "Keep the subscription credential store outside every local job's folder."],
242
+ ["local-folder-project-changed", REFUSAL_COMMENTS["local-folder-project-changed"], "Check the links inside the run root, and list the folder under the project it really belongs to."],
243
+ ["pi-too-many-files", "Refused: the repository's `.pi/` holds more files than a job may carry, in all or in one skill. Not run.", "Remove files from `.pi/` on the default branch, then run the job again."],
244
+ ["pi-file-too-large", "Refused: the repository's `.pi/` holds a file larger than a job may carry. Not run.", "Move the large file out of `.pi/` on the default branch, then run the job again."],
245
+ ["pi-too-large", "Refused: the repository's `.pi/` holds more bytes in all than a job may carry. Not run.", "Trim `.pi/` on the default branch, then run the job again."],
246
+ ["pi-path-collision", "Refused: the repository's `.pi/` holds two paths that differ only in letter case, which a host that ignores case would write as one. Not run.", "Rename one of the two paths in `.pi/` on the default branch."],
247
+ ["package-canary-invalid", "Refused: this canary does not name one package at one exact version. No container started and nothing was spent.", "Start the canary again from the Updates page."],
248
+ ["package-canary-packages-off", "Refused: this trigger sets `run.packages: false`, so it has no staged copy for a candidate to replace, and the run would say nothing about the jobs this package goes into. Nothing was spent.", "Run the canary as a trigger that loads staged packages."],
249
+ ["package-canary-excluded", "Refused: this trigger leaves this package out in `run.excludeExtensions`, so the run would say nothing about the jobs this package goes into. Nothing was spent.", "Run the canary as a trigger that loads this package."],
250
+ ["package-canary-resume", "Refused: this trigger resumes its transcript, so its next run would continue the candidate's work, and a canary is for one job. Nothing was spent.", "Run the canary as a trigger that does not resume."],
251
+ ["package-canary-not-staged", "Refused: this package is not staged on the host that took the canary, so there is nothing for the candidate to replace. Nothing was spent.", "Stage the package on that host, or run the canary as a trigger served where it is staged."],
252
+ ["package-canary-adapter", "Refused: this package is a reviewed subscription adapter, whose identity depends on its exact tree. Nothing was spent.", "Update an adapter with a reviewed pi-dispatch release."],
253
+ ["package-canary-version-missing", "Refused: the registry has no such version of this package. Nothing was spent.", "Check the version on the Updates page, and look again."],
254
+ ["package-canary-refused", "Refused: the candidate installed, but failed staging's checks: it is not a pi package, its manifest leaves its own folder, or a dependency is not inside it. No container started and nothing was spent.", "Run `pi-dispatch import-pi --with-packages` with that version on a test overlay to read the reason."],
255
+ ["skills-dir-missing", REFUSAL_COMMENTS["skills-dir-missing"], "Create the directory on the worker host, or fix the trigger's `run.skillsDir`."],
256
+ ["skills-dir-empty", "Refused: the trigger's `run.skillsDir` holds no skill directory, so the job would have run without the skills its flow was written against. Not run.", "Put the skills in the directory, or fix the trigger's `run.skillsDir`."],
257
+ ["skills-dir-too-large", "Refused: the trigger's `run.skillsDir` holds more skill directories or more bytes than one trigger may inject. Not run.", "Split the skills across triggers, or remove the ones this trigger does not need."],
258
+ ["skills-dir-too-many-files", "Refused: the trigger's `run.skillsDir` holds more files than one trigger may inject. Not run.", "Remove files from the skills, or split them across triggers."],
259
+ ["skills-dir-too-deep", "Refused: the trigger's `run.skillsDir` nests folders deeper than one trigger may inject. Not run.", "Flatten the skills' folders."],
260
+ ["skills-dir-unreadable", "Refused: the worker could not read the trigger's `run.skillsDir` (a file or folder in it is unreadable). Not run.", "Fix the permissions of the skills directory on the worker host."],
261
+ // ── Sessions and secrets ────────────────────────────────────────────────────────────────────────────────────────
262
+ ["sessions-dir-unset", REFUSAL_COMMENTS["sessions-dir-unset"], "Set `PI_SESSIONS_DIR` on the worker, or drop `run.resume` from the trigger."],
263
+ ["secret-profile-unknown", REFUSAL_COMMENTS["secret-profile-unknown"], "Run `pi-dispatch doctor` on the worker to see which profiles it has, then declare the profile or fix its name."],
264
+ ["secret-profile-ambiguous", REFUSAL_COMMENTS["secret-profile-ambiguous"], "Remove the profile from either the environment or the settings overlay."],
265
+ ["secret-name-reserved", "Refused: a secret this job would receive (from `run.secrets` or its project's bundle) uses a name this deployment already uses for the job's own credentials or configuration, so one of the two values would be silently ignored. Not run.", "Rename the variable in the triggers file or in the secrets manager (the worker log names it)."],
266
+ ["secret-unresolved", "Refused: a secret resolver (for `run.secrets` or for the project's bundle) refused, printed nothing, or printed something unusable, so the job would have started without that secret. Not run.", "Run the resolver by hand on the worker against its reference to see why."],
267
+ ["secret-resolver-unreachable", "Failed: a secret resolver could not answer (it could not reach its manager, timed out, or exited in a way it does not use for a refusal), so the job was retried and did not start.", "Check that the secrets manager is reachable from the worker, then run the job again."],
268
+ ["secret-bundle-profile-unknown", BUNDLE_COMMENTS["secret-bundle-profile-unknown"], "Declare the bundle profile on the worker, or fix its name in the triggers file's `secretBundles`."],
269
+ ["secret-bundle-profile-ambiguous", BUNDLE_COMMENTS["secret-bundle-profile-ambiguous"], "Remove the bundle profile from either the environment or the settings overlay."],
270
+ ["secret-bundle-resume", BUNDLE_COMMENTS["secret-bundle-resume"], "Set `\"secretBundle\": false` on the trigger, or drop `run.resume`."],
271
+ ["secret-bundle-trigger-changed", BUNDLE_COMMENTS["secret-bundle-trigger-changed"], "Run the job again from the current trigger."],
272
+ // ── MCP servers ─────────────────────────────────────────────────────────────────────────────────────────────────
273
+ ["mcp-list-invalid", mcpRefusalComment("mcp-list-invalid", null), "Run every service of the deployment at the same release, then run the job again."],
274
+ ["mcp-config-invalid", "Refused: the list of MCP servers could not be used: the overlay `mcp.json` before the job started, or the list the worker wrote for the job inside its container. Not run.", "Fix the overlay `mcp.json` (`pi-dispatch doctor` checks it), and run a worker and a job image of the same release."],
275
+ ["mcp-server-unknown", mcpRefusalComment("mcp-server-unknown", null), "Declare the server in the overlay `mcp.json`, or fix the name in `run.mcp`."],
276
+ ["mcp-secret-unmapped", mcpRefusalComment("mcp-secret-unmapped", null), "Pass the credential the server needs in the trigger's `run.secrets`."],
277
+ ["mcp-secret-reserved", mcpRefusalComment("mcp-secret-reserved", null), "Give the server's credential a variable of its own."],
278
+ ["mcp-excluded", mcpRefusalComment("mcp-excluded", null), "Remove the server from `run.mcp`, or keep a tool that reaches it out of `run.excludeTools`."],
279
+ ["mcp-egress-unreachable", mcpRefusalComment("mcp-egress-unreachable", null), "Add the server's host to the egress allowlist, or drop the server from `run.mcp`."],
280
+ ["mcp-package-runner", mcpRefusalComment("mcp-package-runner", null), "Allow the package registry in the egress allowlist, or install the server in the job image."],
281
+ ["mcp-unavailable", TERMINAL_COMMENTS["mcp-unavailable"], "Check that the server starts and accepts the job's credentials (the job log names it on the `exit` line)."],
282
+ ["mcp-config-mismatch", TERMINAL_COMMENTS["mcp-config-mismatch"], "Run a worker and a job image of the same release."],
283
+ ["mcp-secret-missing", TERMINAL_COMMENTS["mcp-secret-missing"], "Check the secrets the trigger passes in `run.secrets`."],
284
+ ["mcp-command-missing", TERMINAL_COMMENTS["mcp-command-missing"], "Install the server's program in the job image or the overlay."],
285
+ ["mcp-shadowed", TERMINAL_COMMENTS["mcp-shadowed"], "Remove the extension that registers the server's tools, or the trigger's MCP servers."],
286
+ // ── Answers, progress and steering ──────────────────────────────────────────────────────────────────────────────
287
+ ["await-input-shadowed", TERMINAL_COMMENTS["await-input-shadowed"], "Remove the extension that registers the wait tool, or the trigger's `run.awaitInput`."],
288
+ ["await-input-without-session", TERMINAL_COMMENTS["await-input-without-session"], "Run a worker and a job image of the same release."],
289
+ ["report-progress-shadowed", TERMINAL_COMMENTS["report-progress-shadowed"], "Remove the extension that registers the progress tool, or the trigger's `run.progress`."],
290
+ ["exclude-tool-not-loaded", TERMINAL_COMMENTS["exclude-tool-not-loaded"], "Fix the name in `run.excludeExtensionTools`, stage the package that brings the tool on this host, or if the extension registers the tool after it loads, exclude that extension in `run.excludeExtensions`. If only a repository registers the tool, take the name off the key, since a job on every repository stops the same way."],
291
+ ["steer-inbox-missing", TERMINAL_COMMENTS["steer-inbox-missing"], "Run a worker and a job image of the same release."],
292
+ ["steer-no-exit-key", TERMINAL_COMMENTS["steer-no-exit-key"], "Run a worker and a job image of the same release."],
293
+ // ── Durable runs ────────────────────────────────────────────────────────────────────────────────────────────────
294
+ ["recover-store-unset", RECOVERY_COMMENTS["recover-store-unset"], "Set `PI_CHECKPOINTS_DIR` or `PI_SESSIONS_DIR` on the worker, or drop `run.recover` from the trigger."],
295
+ ["recovery-enqueued", "Failed, continuing: this attempt was lost to an infrastructure fault, and the run continues from its last checkpoint in a recovery job.", "Nothing to do yet. Follow the recovery job's own run."],
296
+ ["recovery-host-lost", RECOVERY_HOST_LOST_COMMENT, "Check the worker that held the checkpoint, then run the job again."],
297
+ ["recovery-cap", RECOVERY_COMMENTS["recovery-cap"], "Read the worker log for why each attempt was lost, then run the job again."],
298
+ ["recovery-checkpoint-absent", RECOVERY_COMMENTS["recovery-checkpoint-absent"], "Read the worker log for why the checkpoint could not be used, then run the job again."],
299
+ ["recovery-superseded", "Not run: the run this recovery would continue had already reached its final outcome, so there was nothing left to continue.", "Nothing to fix."],
300
+ ["recovery-unsafe-extensions", RECOVERY_COMMENTS["recovery-unsafe-extensions"], "Run the job again from the start. A repository with `.pi/extensions/` is never recovered."],
301
+ ["recovery-failed", RECOVERY_COMMENTS["recovery-failed"], "Read the worker log for why the continuation was lost, then run the job again."],
302
+ ];
303
+
304
+ const entry = (anchor, sentence, next) => Object.freeze({ sentence, next, anchor });
305
+ /**
306
+ * A frozen token table from rows. A token twice is a load-time error, not a silent override: `Object.fromEntries` keeps
307
+ * the LAST row, so a second row added for a token (a copy-paste, a merge) would quietly replace the first one's text.
308
+ */
309
+ function tableOf(rows, anchorOf) {
310
+ const out = {};
311
+ for (const [token, sentence, next] of rows) {
312
+ if (Object.hasOwn(out, token)) throw new Error(`reason-catalog: the token ${token} has two rows`);
313
+ out[token] = entry(anchorOf(token), sentence, next);
314
+ }
315
+ return Object.freeze(out);
316
+ }
317
+
318
+ /** token -> `{ sentence, next, anchor }`, every reason the run record's `reason` can carry (INT-RUN-HISTORY-FILE-CONTRACT). */
319
+ export const REASONS = tableOf(ROWS, (token) => `${REASONS_DOC}#${token}`);
320
+
321
+ /**
322
+ * The `why` variants: a record whose `why` says which of a reason's comments was posted gets that comment as its
323
+ * sentence. Only where the comment table is keyed by it; every other `why` keeps the reason's own sentence.
324
+ */
325
+ export const WHY_VARIANTS = Object.freeze({
326
+ "model-unknown": (why) => (typeof why === "string" && why.startsWith("overlay-") ? modelUnknownComment(why) : null),
327
+ "model-not-allowed": (why) => (why === "fallback-unlisted" ? MODEL_NOT_ALLOWED_COMMENTS["fallback-unlisted"] : null),
328
+ });
329
+
330
+ // ── The nested reasons ──────────────────────────────────────────────────────────────────────────────────────────────
331
+
332
+ const SESSION_ANCHOR = `${REASONS_DOC}#session-reasons`;
333
+ const RESUME_NEXT = "Nothing to fix: the job ran from the start. See the session docs if you expected it to continue.";
334
+
335
+ /** The record's `session.reason` (run-history.mjs `SESSION_REASONS`): why a job resumed, or ran from the start. */
336
+ export const SESSION_REASON_TEXT = tableOf(
337
+ [
338
+ ["resumed", "The job continued the conversation saved for its key.", "Nothing to do."],
339
+ ["absent", "No saved conversation: the first run for this key, or the previous one saved nothing, so the job ran from the start.", "Nothing to do."],
340
+ ["expired", "The saved conversation was older than the session time to live, so the job ran from the start.", "Raise `PI_SESSIONS_TTL_DAYS` if conversations should last longer."],
341
+ ["conversation-too-old", "The conversation began longer ago than the session's maximum age, so the job ran from the start.", "Raise `PI_SESSION_MAX_AGE_DAYS` if conversations should run longer."],
342
+ ["resume-chain-too-long", "This conversation had been continued as many times in a row as allowed, so the job ran from the start.", "Raise `PI_SESSION_MAX_RESUME_CHAIN` if longer chains are wanted."],
343
+ ["context-too-full", "The saved conversation already filled too much of its model's context window, so the job ran from the start.", "Raise `PI_SESSION_MAX_CONTEXT_PCT`, or use a model with a larger window."],
344
+ ["compaction-summary-empty", "The saved conversation holds a compaction with an empty summary, so continuing it would silently lose earlier turns. The job ran from the start.", "Check the token or cost cap that cut the summary call short."],
345
+ ["too-large", "The saved conversation was larger than the session size cap, so the job ran from the start.", "Raise `PI_SESSION_MAX_BYTES` if larger conversations are wanted."],
346
+ ["unparseable", "The saved conversation is not a pi session file, so the job ran from the start. It is rejected again on every run until it is replaced or expires.", "Remove the key's transcript from the session store, or wait for the next completed run to replace it."],
347
+ ["not-a-regular-file", "The saved conversation is not a regular file (a link, for one), so it was not used.", "Remove what stands at the key's transcript in the session store."],
348
+ ["key-not-a-directory", "The key's folder in the session store is not a folder, so its conversation was neither read nor saved.", "Remove what stands at the key's name in the session store."],
349
+ ["transcript-diverted", "The worker could not confirm the conversation was saved at its key, so it was not saved.", "Check what else writes into the session store."],
350
+ ["venue-changed", "The saved conversation was written on another venue than this job runs on, so the job ran from the start.", "Run the trigger on one venue if its conversations should continue."],
351
+ ["pi-version-changed", "The job image runs another pi than wrote the saved conversation, so the job ran from the start.", RESUME_NEXT],
352
+ ["transcript-replaced", "The saved conversation changed while this job was reading it (usually another run on the key saving a new one), so the job ran from the start.", RESUME_NEXT],
353
+ ["locked", "Another job was saving this key's conversation at the same time, so this run's conversation was not saved.", "Nothing to fix: the other job's save stands."],
354
+ ["promote-failed", "Saving the conversation failed (a full disk, or a permission change in the session store), so this run's conversation was not saved.", "Check the session store's disk and permissions on the worker."],
355
+ ["disabled", "The trigger does not set `run.resume`, so the job did not continue a saved conversation.", "Set `run.resume` on the trigger if its runs should continue one conversation."],
356
+ ],
357
+ () => SESSION_ANCHOR,
358
+ );
359
+
360
+ /** The record's `recoverSkipped` (run-history.mjs `RECOVER_SKIPPED_REASONS`): why a job that set `run.recover` ran without it. */
361
+ export const RECOVER_SKIPPED_TEXT = tableOf(
362
+ [
363
+ ["local", "A folder job is never recovered, so it ran as it would without `run.recover`.", "Nothing to fix: only forge jobs are recovered."],
364
+ ["no-run-id", "The job's queue id could not be derived from its data, so nothing could be kept for it, and it ran without recovery.", "Check the worker log; a job queued by another release can cause this."],
365
+ ["base-unprimed", "The checkpoint store could not take the run's base commit, so no checkpoint of it could ever be restored, and it ran without recovery.", "Check the checkpoint store's disk and permissions on the worker."],
366
+ ["workspace-extensions", "The repository's base commit holds `.pi/extensions/`, whose imports could load code the agent wrote, so the run was not armed for recovery.", "Nothing to fix: such a repository runs without recovery by design."],
367
+ ],
368
+ () => `${REASONS_DOC}#recovery-skipped-reasons`,
369
+ );
370
+
371
+ const PLAN_ANCHOR = `${REASONS_DOC}#plan-reasons`;
372
+
373
+ /** The record's `plan.reason` (run-history.mjs `PLAN_RECORD_REASONS`): why a portfolio job's priorities plan did not apply. */
374
+ export const PLAN_REASON_TEXT = tableOf(
375
+ [
376
+ ["plan-absent", "The portfolio job completed without writing a priorities plan.", "Check the flow's prompt, and the next report's opening line."],
377
+ ["plan-not-portfolio", "A plan was written by a job that is not a portfolio job (its flag was removed, or it was a manual or chained run), so it was refused.", "Run the plan from the trigger that carries the portfolio flag."],
378
+ ["plan-oversize", "The plan file was larger than a plan may be, so it was refused.", "Check the flow: a plan is a small file."],
379
+ ["plan-not-regular-file", "The plan was not a regular file (a link or a folder), so it was refused.", "Check the flow: it must write the plan as a plain file."],
380
+ ["plan-unreadable", "The plan file could not be read, so it was refused.", "Check the worker log for the error."],
381
+ ["plan-parse-error", "The plan file was not valid JSON, so it was refused.", "Check the flow's output; a smaller model often gets the format wrong."],
382
+ ["plan-collect-error", "The worker could not tell what became of the plan (it may have applied), so it wrote nothing more.", "Read the allocation audit file and the worker log."],
383
+ ["plan-invalid", "The plan broke a rule of the plan format, so it was refused.", "Read the audit file for the field and the rule."],
384
+ ["delegation-off", "The envelope does not delegate allocation, so no plan may apply.", "Turn delegation on in the envelope if plans should apply."],
385
+ ["writer-not-allowed", "The envelope does not list this writer among the ones that may apply a plan.", "Add `portfolio-job` to the envelope's writers if this job should apply plans."],
386
+ ["envelope-mismatch", "This worker's envelope differs from the one the current split was made for, so the plan was refused.", "Run `pi-dispatch doctor` on each worker and make their envelopes agree."],
387
+ ["plan-duplicate", "The same plan had already been applied, so nothing changed.", "Nothing to fix."],
388
+ ["plan-stale", "Another plan applied after the one this plan was written against, so it was refused.", "Nothing to fix: the next plan starts from the current split."],
389
+ ["plan-too-soon", "The plan came sooner after the last one than the envelope's minimum interval allows, so it was refused.", "Wait out the interval, or shorten `minIntervalHours`."],
390
+ ["plan-incomplete", "The plan left out a project or member it must name, so it was refused.", "Read the audit file for what was missing."],
391
+ ["plan-busy", "Another plan or a revert was being applied at the same moment, so this plan was refused.", "Run the portfolio job again if this plan should still apply."],
392
+ ],
393
+ () => PLAN_ANCHOR,
394
+ );
395
+
396
+ /** The nested tables `explainReason` reads by `field`, keyed by the record's field path. */
397
+ export const NESTED_REASONS = Object.freeze({ "session.reason": SESSION_REASON_TEXT, recoverSkipped: RECOVER_SKIPPED_TEXT, "plan.reason": PLAN_REASON_TEXT });
398
+
399
+ // ── Why a run went without a feature (moved from admin/src/read-model.mjs in issue #710, which re-exports them) ───────
400
+ // Each is a CLAUSE the surfaces print after the token ("skipped (fork): ..."), not a sentence, so they keep their shape.
401
+
402
+ /**
403
+ * Why a job ran without its project's bundle, one sentence per `bundleSkipped` reason (the worker's
404
+ * BUNDLE_SKIPPED_REASONS, held to this table by a test so a new reason cannot render as nothing).
405
+ */
406
+ export const BUNDLE_SKIPPED_TEXT = Object.freeze({
407
+ manual: "a manual run (no trigger made it), so it got no bundle",
408
+ chained: "a chained /outbox child, so it got no bundle: the grant was to the trigger, not to what its job queues",
409
+ "opted-out": "its trigger sets run.secretBundle: false",
410
+ fork: "a pull request from another repository, and the bundle does not set forkReviews",
411
+ "head-unverified": "the forge would not say whose pull request this is (401, 403 or 404), so it ran as a fork's, without the bundle",
412
+ });
413
+
414
+ /** Why a steer-armed run went without steering (the record's `steerSkipped`), keyed like STEER_SKIPPED_REASONS. */
415
+ export const STEER_SKIPPED_TEXT = Object.freeze({
416
+ "venue-copies": "its venue copies the job's files (no /inbox mount) and its job image does not declare steerStdin, so no message could reach the run",
417
+ "no-exit-key": "its job image does not declare exitAuth, so no delivery could be verified",
418
+ "no-transcript": "nothing could read the delivery acks: on a venue that copies the job's files its job image does not declare transcriptStderr (the acks come back over that stream), or no transcript could be staged, or this worker has no tail or relay wired",
419
+ "no-inbox": "the run's inbox directory could not be made",
420
+ });
421
+
422
+ /** Why a run had no live transcript, and so no live progress (the record's `liveSkipped`), keyed like LIVE_SKIPPED_REASONS. */
423
+ export const LIVE_SKIPPED_TEXT = Object.freeze({
424
+ "venue-copies": "its venue copies the job's files and does not stream the transcript back",
425
+ "no-capability": "its venue streams the transcript only from a job image that declares transcriptStderr, and this one does not",
426
+ "no-exit-key": "its job image does not declare exitAuth, so a streamed transcript could not be verified",
427
+ });
428
+
429
+ /**
430
+ * Explain a reason: `{ known: true, token, sentence, next, anchor }`, or `{ known: false }` for anything that is not a
431
+ * key of the table (a newer worker's token, a non-string, `__proto__`). `why` picks a variant of the sentence where the
432
+ * reason has one (`WHY_VARIANTS`); `field` names a nested reason (`session.reason`, `recoverSkipped`, `plan.reason`)
433
+ * instead of the record's top-level `reason`. Never throws, whatever it is given: `opts` may be absent, `null` or any
434
+ * value (a non-object reads as no options), and a throwing getter on it answers `{ known: false }` rather than escaping
435
+ * into a surface that renders a list of records.
436
+ */
437
+ export function explainReason(token, opts) {
438
+ try {
439
+ const { why = null, field = null } = opts !== null && typeof opts === "object" ? opts : {};
440
+ const table = field === null ? REASONS : typeof field === "string" && Object.hasOwn(NESTED_REASONS, field) ? NESTED_REASONS[field] : null;
441
+ if (table === null || typeof token !== "string" || !Object.hasOwn(table, token)) return { known: false };
442
+ const found = table[token];
443
+ const variant = field === null && Object.hasOwn(WHY_VARIANTS, token) ? WHY_VARIANTS[token](why) : null;
444
+ return { known: true, token, sentence: variant ?? found.sentence, next: found.next, anchor: found.anchor };
445
+ } catch {
446
+ return { known: false };
447
+ }
448
+ }