@1aboveio/skills 1.0.7 → 1.0.9

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 (224) hide show
  1. package/.codex-plugin/plugin.json +13 -0
  2. package/.codex-plugin/resources.json +1827 -0
  3. package/README.md +22 -0
  4. package/package.json +9 -2
  5. package/skills/app-dev/airflow/SKILL.md +1 -1
  6. package/skills/app-dev/airflow/release-check.mjs +3 -1
  7. package/skills/app-dev/android-foundation/SKILL.md +92 -34
  8. package/skills/app-dev/android-foundation/assets/android-ci-job.yml +13 -7
  9. package/skills/app-dev/android-foundation/references/ci.md +23 -13
  10. package/skills/app-dev/android-foundation/references/delivery-focus.md +29 -0
  11. package/skills/app-dev/android-foundation/references/diagnosis.md +136 -0
  12. package/skills/app-dev/android-foundation/references/gotchas.md +20 -11
  13. package/skills/app-dev/android-foundation/references/setup.md +15 -6
  14. package/skills/app-dev/android-foundation/release-check.mjs +3 -1
  15. package/skills/app-dev/android-foundation/scripts/device-run.sh +148 -42
  16. package/skills/app-dev/app-debug/SKILL.md +1 -1
  17. package/skills/app-dev/app-debug/release-check.mjs +3 -1
  18. package/skills/app-dev/better-auth/SKILL.md +1 -1
  19. package/skills/app-dev/better-auth/release-check.mjs +3 -1
  20. package/skills/app-dev/data-catalog/SKILL.md +1 -1
  21. package/skills/app-dev/data-catalog/release-check.mjs +3 -1
  22. package/skills/app-dev/data-quality/SKILL.md +1 -1
  23. package/skills/app-dev/data-quality/release-check.mjs +3 -1
  24. package/skills/app-dev/ios-foundation/SKILL.md +1 -1
  25. package/skills/app-dev/ios-foundation/release-check.mjs +3 -1
  26. package/skills/app-dev/monorepo/SKILL.md +1 -1
  27. package/skills/app-dev/monorepo/release-check.mjs +3 -1
  28. package/skills/app-dev/nextjs-fullstack/SKILL.md +1 -1
  29. package/skills/app-dev/nextjs-fullstack/release-check.mjs +3 -1
  30. package/skills/app-dev/prisma-setup/SKILL.md +1 -1
  31. package/skills/app-dev/prisma-setup/release-check.mjs +3 -1
  32. package/skills/app-dev/pyspark/SKILL.md +1 -1
  33. package/skills/app-dev/pyspark/release-check.mjs +3 -1
  34. package/skills/app-dev/python-backend/SKILL.md +1 -1
  35. package/skills/app-dev/python-backend/release-check.mjs +3 -1
  36. package/skills/app-dev/shadcn/SKILL.md +1 -1
  37. package/skills/app-dev/shadcn/release-check.mjs +3 -1
  38. package/skills/app-dev/zod-v4/SKILL.md +1 -1
  39. package/skills/app-dev/zod-v4/release-check.mjs +3 -1
  40. package/skills/cicd/cloud-build/SKILL.md +1 -1
  41. package/skills/cicd/cloud-build/release-check.mjs +3 -1
  42. package/skills/cicd/cloud-debug/SKILL.md +1 -1
  43. package/skills/cicd/cloud-debug/release-check.mjs +3 -1
  44. package/skills/cicd/cloud-deploy/SKILL.md +1 -1
  45. package/skills/cicd/cloud-deploy/release-check.mjs +3 -1
  46. package/skills/cicd/docker/SKILL.md +1 -1
  47. package/skills/cicd/docker/release-check.mjs +3 -1
  48. package/skills/cicd/github-actions/SKILL.md +1 -1
  49. package/skills/cicd/github-actions/release-check.mjs +3 -1
  50. package/skills/cicd/github-flow/SKILL.md +1 -1
  51. package/skills/cicd/github-flow/release-check.mjs +3 -1
  52. package/skills/cicd/google-cloud/SKILL.md +1 -1
  53. package/skills/cicd/google-cloud/release-check.mjs +3 -1
  54. package/skills/cicd/mergify/SKILL.md +1 -1
  55. package/skills/cicd/mergify/release-check.mjs +3 -1
  56. package/skills/cicd/podman/SKILL.md +1 -1
  57. package/skills/cicd/podman/release-check.mjs +3 -1
  58. package/skills/content/article-review/SKILL.md +1 -1
  59. package/skills/content/article-review/release-check.mjs +3 -1
  60. package/skills/content/article-to-image/SKILL.md +1 -1
  61. package/skills/content/article-to-image/release-check.mjs +3 -1
  62. package/skills/content/flowchart-svg/SKILL.md +1 -1
  63. package/skills/content/flowchart-svg/release-check.mjs +3 -1
  64. package/skills/content/image-gen/SKILL.md +1 -1
  65. package/skills/content/image-gen/release-check.mjs +3 -1
  66. package/skills/content/mermaid/SKILL.md +1 -1
  67. package/skills/content/mermaid/release-check.mjs +3 -1
  68. package/skills/content/wechat-article-writer/SKILL.md +1 -1
  69. package/skills/content/wechat-article-writer/release-check.mjs +3 -1
  70. package/skills/content/wechat-publisher/SKILL.md +1 -1
  71. package/skills/content/wechat-publisher/release-check.mjs +3 -1
  72. package/skills/content/wechat-search/SKILL.md +1 -1
  73. package/skills/content/wechat-search/release-check.mjs +3 -1
  74. package/skills/content/xai-search/SKILL.md +88 -13
  75. package/skills/content/xai-search/release-check.mjs +3 -1
  76. package/skills/content/xai-search/scripts/xai_search.sh +125 -149
  77. package/skills/core/distill-writing/SKILL.md +1 -1
  78. package/skills/core/distill-writing/release-check.mjs +3 -1
  79. package/skills/core/doc-to-md/SKILL.md +1 -1
  80. package/skills/core/doc-to-md/release-check.mjs +3 -1
  81. package/skills/core/editorial-review/SKILL.md +1 -1
  82. package/skills/core/editorial-review/release-check.mjs +3 -1
  83. package/skills/core/house-style/SKILL.md +1 -1
  84. package/skills/core/house-style/release-check.mjs +3 -1
  85. package/skills/core/plain-language/SKILL.md +1 -1
  86. package/skills/core/plain-language/release-check.mjs +3 -1
  87. package/skills/core/polish-cn/SKILL.md +1 -1
  88. package/skills/core/polish-cn/release-check.mjs +3 -1
  89. package/skills/core/reg-parser/SKILL.md +1 -1
  90. package/skills/core/reg-parser/release-check.mjs +3 -1
  91. package/skills/core/terminology/SKILL.md +1 -1
  92. package/skills/core/terminology/release-check.mjs +3 -1
  93. package/skills/core/write-doc/SKILL.md +1 -1
  94. package/skills/core/write-doc/release-check.mjs +3 -1
  95. package/skills/engineering/better-review/SKILL.md +1 -1
  96. package/skills/engineering/better-review/release-check.mjs +3 -1
  97. package/skills/engineering/contributing/SKILL.md +1 -1
  98. package/skills/engineering/contributing/release-check.mjs +3 -1
  99. package/skills/engineering/e2e-test/SKILL.md +1 -1
  100. package/skills/engineering/e2e-test/release-check.mjs +3 -1
  101. package/skills/engineering/engineering-runtime/SKILL.md +1 -1
  102. package/skills/engineering/engineering-runtime/coherence/workflow.json +13 -13
  103. package/skills/engineering/engineering-runtime/release-check.mjs +3 -1
  104. package/skills/engineering/engineering-runtime/scripts/installation-resolver.mjs +191 -0
  105. package/skills/engineering/engineering-runtime/scripts/workflow-coherence.mjs +51 -10
  106. package/skills/engineering/ensure-coverage/SKILL.md +1 -1
  107. package/skills/engineering/ensure-coverage/release-check.mjs +3 -1
  108. package/skills/engineering/golden-path/SKILL.md +1 -1
  109. package/skills/engineering/golden-path/release-check.mjs +3 -1
  110. package/skills/engineering/harness-runtime/SKILL.md +1 -1
  111. package/skills/engineering/harness-runtime/release-check.mjs +3 -1
  112. package/skills/engineering/resolve-issues/SKILL.md +11 -20
  113. package/skills/engineering/resolve-issues/references/delivery-progress.md +63 -0
  114. package/skills/engineering/resolve-issues/references/expire.md +24 -11
  115. package/skills/engineering/resolve-issues/references/implementation.md +9 -1
  116. package/skills/engineering/resolve-issues/references/preflight.md +7 -0
  117. package/skills/engineering/resolve-issues/references/shared-modules.md +27 -0
  118. package/skills/engineering/resolve-issues/references/task-plan.md +12 -5
  119. package/skills/engineering/resolve-issues/release-check.mjs +3 -1
  120. package/skills/engineering/resolve-issues/scripts/active-installation.mjs +32 -0
  121. package/skills/engineering/resolve-issues/scripts/install-hooks.mjs +3 -0
  122. package/skills/engineering/resolve-issues/scripts/rehydrate.mjs +17 -1
  123. package/skills/engineering/resolve-release/SKILL.md +1 -1
  124. package/skills/engineering/resolve-release/release-check.mjs +3 -1
  125. package/skills/engineering/rush-issues/SKILL.md +5 -2
  126. package/skills/engineering/rush-issues/release-check.mjs +3 -1
  127. package/skills/engineering/rush-release/SKILL.md +1 -1
  128. package/skills/engineering/rush-release/release-check.mjs +3 -1
  129. package/skills/engineering/test-quality/SKILL.md +1 -1
  130. package/skills/engineering/test-quality/release-check.mjs +3 -1
  131. package/skills/risk-compliance/compliance-audit/SKILL.md +1 -1
  132. package/skills/risk-compliance/compliance-audit/release-check.mjs +3 -1
  133. package/skills/risk-compliance/contract-compare/SKILL.md +1 -1
  134. package/skills/risk-compliance/contract-compare/release-check.mjs +3 -1
  135. package/skills/risk-compliance/contract-review/SKILL.md +1 -1
  136. package/skills/risk-compliance/contract-review/release-check.mjs +3 -1
  137. package/skills/risk-compliance/doc-authenticity/SKILL.md +1 -1
  138. package/skills/risk-compliance/doc-authenticity/release-check.mjs +3 -1
  139. package/skills/risk-compliance/fraud-analysis/SKILL.md +1 -1
  140. package/skills/risk-compliance/fraud-analysis/release-check.mjs +3 -1
  141. package/skills/risk-compliance/payment-analysis/SKILL.md +1 -1
  142. package/skills/risk-compliance/payment-analysis/release-check.mjs +3 -1
  143. package/skills/risk-compliance/similar-domain/SKILL.md +1 -1
  144. package/skills/risk-compliance/similar-domain/release-check.mjs +3 -1
  145. package/skills/risk-compliance/tld-detection/SKILL.md +1 -1
  146. package/skills/risk-compliance/tld-detection/release-check.mjs +3 -1
  147. package/skills/app-dev/android-foundation/scripts/device-run.test.mjs +0 -172
  148. package/skills/app-dev/data-catalog/scripts/data-catalog.test.mjs +0 -36
  149. package/skills/app-dev/data-quality/scripts/data-quality.test.mjs +0 -12
  150. package/skills/cicd/github-actions/scripts/ci-run-audit.test.mjs +0 -637
  151. package/skills/cicd/github-flow/scripts/flow.test.mjs +0 -114
  152. package/skills/cicd/github-flow/scripts/persist-conventions.test.mjs +0 -108
  153. package/skills/cicd/mergify/scripts/audit.test.mjs +0 -201
  154. package/skills/cicd/mergify/scripts/entrypoints.test.mjs +0 -106
  155. package/skills/cicd/mergify/scripts/pr-620-mergify-comments.fixture.json +0 -30
  156. package/skills/cicd/mergify/scripts/stack-push.test.mjs +0 -233
  157. package/skills/cicd/mergify/scripts/watch-pr-delivery.test.mjs +0 -1826
  158. package/skills/content/image-gen/scripts/credentials.test.mjs +0 -212
  159. package/skills/content/image-gen/scripts/options.test.mjs +0 -245
  160. package/skills/content/xai-search/tests/smoke_test.sh +0 -36
  161. package/skills/core/polish-cn/scripts/polish-cn.test.mjs +0 -244
  162. package/skills/core/reg-parser/scripts/reg-parser.test.mjs +0 -165
  163. package/skills/engineering/better-review/scripts/pr-context.test.mjs +0 -168
  164. package/skills/engineering/better-review/scripts/rereview-scope.test.mjs +0 -587
  165. package/skills/engineering/better-review/scripts/scan-diff.test.mjs +0 -414
  166. package/skills/engineering/better-review/scripts/vendor-sync.test.mjs +0 -96
  167. package/skills/engineering/contributing/scripts/persist-conventions.test.mjs +0 -118
  168. package/skills/engineering/e2e-test/scripts/presentation-checks.test.mjs +0 -106
  169. package/skills/engineering/e2e-test/scripts/presentation-perceivability.test.mjs +0 -117
  170. package/skills/engineering/e2e-test/scripts/presentation-reachability.test.mjs +0 -69
  171. package/skills/engineering/e2e-test/scripts/presentation-render-health.test.mjs +0 -98
  172. package/skills/engineering/e2e-test/scripts/presentation-sweep.test.mjs +0 -71
  173. package/skills/engineering/e2e-test/scripts/presentation-temporal.test.mjs +0 -59
  174. package/skills/engineering/e2e-test/scripts/project-route-manifest.test.mjs +0 -76
  175. package/skills/engineering/engineering-runtime/engineering-runtime.test.mjs +0 -68
  176. package/skills/engineering/engineering-runtime/scripts/invocation-policy.test.mjs +0 -95
  177. package/skills/engineering/engineering-runtime/scripts/main-module.test.mjs +0 -288
  178. package/skills/engineering/engineering-runtime/scripts/required-check-plan.test.mjs +0 -96
  179. package/skills/engineering/ensure-coverage/scripts/adapters/nextjs-inventory.test.mjs +0 -96
  180. package/skills/engineering/ensure-coverage/scripts/check-quarantine-expiry.test.mjs +0 -47
  181. package/skills/engineering/ensure-coverage/scripts/ci-audit.test.mjs +0 -86
  182. package/skills/engineering/ensure-coverage/scripts/coverage-ledger.test.mjs +0 -447
  183. package/skills/engineering/ensure-coverage/scripts/design-parity.test.mjs +0 -552
  184. package/skills/engineering/ensure-coverage/scripts/lint-tests.test.mjs +0 -289
  185. package/skills/engineering/ensure-coverage/scripts/mock-policy-config.test.mjs +0 -119
  186. package/skills/engineering/golden-path/scripts/golden-path.test.mjs +0 -1457
  187. package/skills/engineering/harness-runtime/discover-models.test.mjs +0 -363
  188. package/skills/engineering/harness-runtime/harness-runtime.test.mjs +0 -967
  189. package/skills/engineering/harness-runtime/model-catalog-seed.test.mjs +0 -829
  190. package/skills/engineering/harness-runtime/model-catalog.test.mjs +0 -366
  191. package/skills/engineering/resolve-issues/scripts/cleanup-runs.test.mjs +0 -280
  192. package/skills/engineering/resolve-issues/scripts/dropbox.test.mjs +0 -346
  193. package/skills/engineering/resolve-issues/scripts/findings-reuse.test.mjs +0 -88
  194. package/skills/engineering/resolve-issues/scripts/github-reviews.test.mjs +0 -264
  195. package/skills/engineering/resolve-issues/scripts/install-hooks.test.mjs +0 -79
  196. package/skills/engineering/resolve-issues/scripts/issue-tree.test.mjs +0 -107
  197. package/skills/engineering/resolve-issues/scripts/manual-invocation.test.mjs +0 -14
  198. package/skills/engineering/resolve-issues/scripts/next-actions-capacity.test.mjs +0 -149
  199. package/skills/engineering/resolve-issues/scripts/next-actions.test.mjs +0 -915
  200. package/skills/engineering/resolve-issues/scripts/plan-sync.test.mjs +0 -156
  201. package/skills/engineering/resolve-issues/scripts/preflight-models.test.mjs +0 -448
  202. package/skills/engineering/resolve-issues/scripts/ready-check.test.mjs +0 -217
  203. package/skills/engineering/resolve-issues/scripts/rehydrate.test.mjs +0 -91
  204. package/skills/engineering/resolve-issues/scripts/review-verdict.test.mjs +0 -1060
  205. package/skills/engineering/resolve-issues/scripts/size-check.test.mjs +0 -458
  206. package/skills/engineering/resolve-release/scripts/apply.test.mjs +0 -99
  207. package/skills/engineering/resolve-release/scripts/dark-flags.test.mjs +0 -100
  208. package/skills/engineering/resolve-release/scripts/flow.test.mjs +0 -340
  209. package/skills/engineering/resolve-release/scripts/green-head.test.mjs +0 -101
  210. package/skills/engineering/resolve-release/scripts/npm-version-absence.test.mjs +0 -426
  211. package/skills/engineering/resolve-release/scripts/plan-npm.test.mjs +0 -659
  212. package/skills/engineering/resolve-release/scripts/plan-toml-boundary.test.mjs +0 -317
  213. package/skills/engineering/resolve-release/scripts/plan-uv.test.mjs +0 -291
  214. package/skills/engineering/resolve-release/scripts/plan.test.mjs +0 -428
  215. package/skills/engineering/test-quality/scripts/lint-tests.test.mjs +0 -591
  216. package/skills/engineering/test-quality/scripts/mock-policy-config.test.mjs +0 -119
  217. package/skills/engineering/test-quality/scripts/persist-conventions.test.mjs +0 -104
  218. package/skills/risk-compliance/compliance-audit/scripts/compliance-audit.test.mjs +0 -14
  219. package/skills/risk-compliance/doc-authenticity/manual-invocation.test.mjs +0 -12
  220. package/skills/risk-compliance/doc-authenticity/scripts/validate_verdict.test.mjs +0 -212
  221. package/skills/risk-compliance/tld-detection/scripts/detect_ladders.test.mjs +0 -14
  222. package/skills/risk-compliance/tld-detection/scripts/detect_streams.test.mjs +0 -14
  223. package/skills/risk-compliance/tld-detection/tests/test_detect_ladders.py +0 -134
  224. package/skills/risk-compliance/tld-detection/tests/test_detect_streams.py +0 -520
@@ -1,915 +0,0 @@
1
- import { test } from 'node:test'
2
- import assert from 'node:assert/strict'
3
- import { spawnSync } from 'node:child_process'
4
- import fs from 'node:fs'
5
- import os from 'node:os'
6
- import path from 'node:path'
7
- import { fileURLToPath } from 'node:url'
8
-
9
- import { nextActions as scheduleActions, readReviews, readActivity, readQuestions, parseCliArgs, NextActionsError, RULES, SKILL_PATH } from './next-actions.mjs'
10
-
11
- // Existing workflow cases use an explicit fixture budget; live-capacity cases are separate.
12
- const nextActions = (plan, reviews = {}, capacity = 4, options = {}) => scheduleActions(plan, reviews, capacity, options)
13
- const SCRIPT = path.join(path.dirname(fileURLToPath(import.meta.url)), 'next-actions.mjs')
14
- const plan = (units, extra = {}) => ({ skill: 'resolve-issues', deliveryMode: 'stack', mergeMode: 'autonomous', units, ...extra })
15
- const kinds = (r) => r.due.map((a) => `${a.action}:${a.unit ?? ''}`)
16
-
17
- test('a free slot and a pending unit with met dependencies: fill the slot', () => {
18
- const r = nextActions(plan([{ id: 'a', status: 'pending', stage: 'pending' }, { id: 'b', status: 'pending', dependsOn: ['a'] }]))
19
- assert.deepEqual(kinds(r), ['fill-slot:a'])
20
- })
21
-
22
- test('a unit waiting on an excluded issue stays not started, and the run can finish around it', () => {
23
- // A unit waits on environment work the run recorded as excluded: starting it
24
- // would retry work only a human can unblock. It is not started, never blocked.
25
- const excluded = [{ issue: 'o/r#1800', reason: 'environment the run cannot build' }]
26
- const units = [{ id: '1801', status: 'pending', dependsOn: ['1800'] }, { id: '1802', status: 'pending' }]
27
- assert.deepEqual(kinds(nextActions(plan(units, { spec: 'o/r#1', excluded }))), ['fill-slot:1802'])
28
- const rest = [{ id: '1801', status: 'pending', dependsOn: ['1800'] }, { id: '1802', status: 'succeeded' }]
29
- const r = nextActions(plan(rest, { spec: 'o/r#1', excluded }))
30
- assert.deepEqual(kinds(r), ['finish:'])
31
- assert.match(r.due[0].detail, /not started: 1801 \(waits on 1800\)/)
32
- const legacy = [{ id: '1801', status: 'blocked', dependsOn: ['1800'] }, { id: '1802', status: 'succeeded' }]
33
- assert.deepEqual(kinds(nextActions(plan(legacy, { spec: 'o/r#1', excluded }), {}, 4, { resume: true })), ['finish:'])
34
- assert.deepEqual(kinds(nextActions(plan(units, { spec: 'o/r#1' }))), ['fill-slot:1801', 'fill-slot:1802'])
35
- })
36
-
37
- test('a unit behind a blocked unit is not started: it gets no action and does not hold finish', () => {
38
- const units = [
39
- { id: 'a', status: 'blocked', stage: 'implement', blockedReason: 'retry-implement', autoRetries: 3 },
40
- { id: 'b', status: 'pending', dependsOn: ['a'] },
41
- { id: 'c', status: 'pending', dependsOn: ['b'] },
42
- { id: 'd', status: 'succeeded' },
43
- ]
44
- const r = nextActions(plan(units))
45
- assert.deepEqual(kinds(r), ['finish:'])
46
- assert.match(r.due[0].detail, /not started: b \(waits on a\), c \(waits on a\)/)
47
- })
48
-
49
- test('--resume reopens a unit wrongly marked blocked while it waited on its parents as not started', () => {
50
- // A coordinator marked dependents blocked while their parent was still coding; reopening them
51
- // in flight would resume them before their parent's code exists.
52
- const units = [
53
- { id: 'p', status: 'in-flight', stage: 'implement', worker: 1, workerKind: 'implement' },
54
- { id: 'c', status: 'blocked', stage: 'implement', dependsOn: ['p'] },
55
- ]
56
- const r = nextActions(plan(units), {}, 4, { resume: true })
57
- assert.deepEqual(kinds(r), ['unblock:c'], 'unblocked, but not resumed: its parent is still coding')
58
- const ready = [{ ...units[0], stage: 'done', worker: null }, units[1]]
59
- assert.deepEqual(kinds(nextActions(plan(ready), {}, 4, { resume: true })), ['unblock:c', 'open-pr:p', 'resume:c'], 'once the parent is done, it resumes in its kept workspace')
60
- const partial = [units[0], { ...units[1], stage: 'validate', headSha: 'abc1234', workspace: '/w/c' }]
61
- const early = nextActions(plan(partial), {}, 4, { resume: true })
62
- assert.deepEqual(kinds(early), ['unblock:c'], 'partial commits do not let it run ahead of its parent')
63
- assert.match(early.due[0].detail, /only waits on p: set it pending \(not started\)/)
64
- const later = nextActions(plan([{ ...units[0], stage: 'done', worker: null }, { ...partial[1], status: 'pending' }]))
65
- assert.match(later.due.find((a) => a.unit === 'c').detail, /in its kept workspace/)
66
- })
67
-
68
- test('a child starts once its parent code is done in both modes; in wait mode its PR waits for the merge', () => {
69
- // wiki-kb #12 (no queue): children idled 45-114 minutes per hop until each parent merged.
70
- const units = [{ id: 'p', status: 'in-flight', stage: 'done', worker: 1, workerKind: 'implement' }, { id: 'c', status: 'pending', dependsOn: ['p'] }]
71
- assert.deepEqual(kinds(nextActions(plan(units))), ['fill-slot:c'])
72
- assert.deepEqual(kinds(nextActions(plan(units, { deliveryMode: 'wait' }))), ['fill-slot:c'])
73
- const coded = [{ id: 'p', status: 'in-flight', stage: 'done', pr: '#1', headSha: 'aaa', worker: 1, workerKind: 'implement' }, { id: 'c', status: 'in-flight', stage: 'done', dependsOn: ['p'] }]
74
- assert.deepEqual(kinds(nextActions(plan(coded, { deliveryMode: 'wait' }))), [], 'no PR until the parent merges')
75
- const merged = nextActions(plan([{ ...coded[0], status: 'succeeded' }, coded[1]], { deliveryMode: 'wait' }))
76
- assert.deepEqual(kinds(merged), ['open-pr:c'])
77
- assert.match(merged.due[0].detail, /rebase it onto the target first/)
78
- })
79
-
80
- test('chain mode: a finished unit chains on the open run PR it shares files with, as soon as that PR is open', () => {
81
- // skyee-ai-risk #3097: in wait mode 12 PRs edited the same six registration files; #3120's merge
82
- // dequeued three queued PRs on conflicts and the run rebuilt everything as a hand-made train.
83
- const pr = (id, files, extra = {}) => ({ id, status: 'in-flight', stage: 'done', pr: `#${id}`, headSha: `${id}aaaaaa`, branch: `rush/${id}`, files, ...extra })
84
- const chainPlan = (units) => plan(units, { deliveryMode: 'chain' })
85
- const open = (r, id) => r.due.find((a) => a.action === 'open-pr' && a.unit === id).detail
86
- const shared = nextActions(chainPlan([pr('a', ['ci.yml', 'svc.py']), pr('b', ['docs/b.md']), { id: 'c', status: 'in-flight', stage: 'done', files: ['svc.py', 'c.py'] }]))
87
- // stack mode picks its parent the same way; only the PR base differs.
88
- const stackShared = nextActions(plan([pr('a', ['ci.yml', 'svc.py']), pr('b', ['docs/b.md']), { id: 'c', status: 'in-flight', stage: 'done', files: ['svc.py', 'c.py'] }]))
89
- assert.match(open(stackShared, 'c'), /stacked on unit a \(#a; shares svc\.py\).*gh pr create --draft --base rush\/a, then link the line as one GitHub stack, bottom first: gh stack link #a the new PR/)
90
- assert.match(open(shared, 'c'), /chained on unit a \(#a; shares svc\.py\): rebase onto rush\/a at aaaaaaa now/)
91
- assert.match(open(shared, 'c'), /set stacksOn to \[a\] and parentHead to that HEAD/)
92
- const lone = nextActions(chainPlan([pr('a', ['a.py']), { id: 'c', status: 'in-flight', stage: 'done', files: ['c.py'] }]))
93
- assert.match(open(lone, 'c'), /on the target: no open run PR shares its files/)
94
- const unrecorded = nextActions(chainPlan([pr('a', ['a.py']), { id: 'c', status: 'in-flight', stage: 'done' }]))
95
- assert.match(open(unrecorded, 'c'), /record unit c's own changed files as files/)
96
- // A dependent opens once its parent's PR is open (wait mode would hold it until the merge).
97
- const dep = nextActions(chainPlan([pr('p', ['p.py']), { id: 'c', status: 'in-flight', stage: 'done', dependsOn: ['p'], files: ['c.py'] }]))
98
- assert.match(open(dep, 'c'), /chained on unit p \(#p; it builds on its code\)/)
99
- assert.deepEqual(kinds(nextActions(plan([pr('p', ['p.py']), { id: 'c', status: 'in-flight', stage: 'done', dependsOn: ['p'], files: ['c.py'] }], { deliveryMode: 'wait' }))).filter((k) => k.startsWith('open-pr')), [])
100
- })
101
-
102
- test('chain mode: the chain parent is the line that carries most of what the unit shares, and a second line is named', () => {
103
- const pr = (id, files, extra = {}) => ({ id, status: 'in-flight', stage: 'done', pr: `#${id}`, headSha: `${id}aaaaaa`, branch: `rush/${id}`, files, ...extra })
104
- const chainPlan = (units) => plan(units, { deliveryMode: 'chain' })
105
- const open = (r, id) => r.due.find((a) => a.action === 'open-pr' && a.unit === id).detail
106
- const deep = nextActions(chainPlan([pr('a', ['x']), pr('b', ['y'], { stacksOn: ['a'] }), { id: 'c', status: 'in-flight', stage: 'done', files: ['x', 'y'] }]))
107
- assert.match(open(deep, 'c'), /chained on unit b/, 'b carries a, so chaining on b covers both')
108
- // a1 #408 replay: 432 needs 429, but 431's line shared more of its files; chaining there made 432 wait
109
- // for its own dependency to land.
110
- const dep = nextActions(chainPlan([pr('p', ['x']), pr('d', ['y'], { stacksOn: ['p'], parentHead: 'paaaaaa', offLine: [] }), pr('o', ['x', 'z'], { stacksOn: ['p'], parentHead: 'paaaaaa', offLine: [] }), pr('o2', ['z'], { stacksOn: ['o'], parentHead: 'oaaaaaa', offLine: [] }), { id: 'c', status: 'in-flight', stage: 'done', dependsOn: ['d'], files: ['x', 'z'] }]))
111
- assert.match(open(dep, 'c'), /chained on unit d /, 'the line with its dependency wins over a line sharing more files')
112
- const split = nextActions(chainPlan([pr('a', ['x']), pr('b', ['y']), { id: 'c', status: 'in-flight', stage: 'done', files: ['x', 'y'] }]))
113
- assert.match(open(split, 'c'), /chained on unit b .*also shares files with a on another line/)
114
- const merged = nextActions(chainPlan([pr('a', ['x'], { status: 'succeeded' }), { id: 'c', status: 'in-flight', stage: 'done', files: ['x'] }]))
115
- assert.match(open(merged, 'c'), /no open run PR shares its files/, 'a merged PR is part of the target, not a chain parent')
116
- })
117
-
118
- test('chain mode: a line is delivered once, by enqueueing its deepest PR whose whole line passed review', () => {
119
- const ok = (id, extra = {}) => ({ id, status: 'in-flight', stage: 'done', pr: `#${id}`, headSha: `${id}aaaaaa`, reviewedHead: `${id}aaaaaa`, files: [id], ...extra })
120
- const chainPlan = (units, extra = {}) => plan(units, { deliveryMode: 'chain', ...extra })
121
- const line = [ok('a'), ok('b', { stacksOn: ['a'], parentHead: 'aaaaaaa', offLine: [] }), ok('c', { stacksOn: ['b'], parentHead: 'baaaaaa', offLine: [] })]
122
- const all = nextActions(chainPlan(line))
123
- assert.deepEqual(kinds(all), ['deliver:c'])
124
- assert.match(all.due[0].detail, /line \(a -> b -> c\).*enqueue only #c; its merge lands the line/)
125
- // b took a fix after c was built on it: c's branch still carries b's old HEAD, so enqueueing c would
126
- // land commits nobody reviewed. c restacks once b's new HEAD passes; until then only a and b ship.
127
- const fixed = [line[0], { ...line[1], headSha: 'b2aaaaa', reviewedHead: 'b2aaaaa' }, line[2]]
128
- const restack = nextActions(chainPlan(fixed))
129
- assert.deepEqual(kinds(restack), ['restack:c', 'deliver:b'])
130
- assert.match(restack.due[0].detail, /built on unit b's old HEAD baaaaaa; b passed at b2aaaaa: rebase onto it, set parentHead to b2aaaaa/)
131
- const reviewing = [line[0], { ...line[1], headSha: 'b2aaaaa', reviewedHead: 'baaaaaa' }, line[2]]
132
- assert.deepEqual(kinds(nextActions(chainPlan(reviewing))).filter((k) => k.startsWith('restack')), [], 'no restack while the parent\'s new HEAD is still in review')
133
- const middle = [line[0], { ...line[1], reviewedHead: null }, line[2]]
134
- assert.deepEqual(kinds(nextActions(chainPlan(middle))), ['start-review:b', 'deliver:a'], 'c waits for b; a ships alone meanwhile')
135
- const landed = nextActions(chainPlan([{ ...line[0], status: 'succeeded' }, line[1], line[2]]))
136
- assert.match(landed.due[0].detail, /line \(b -> c\)/)
137
- const tree = nextActions(chainPlan([ok('p'), ok('x', { stacksOn: ['p'], parentHead: 'paaaaaa', offLine: [] }), ok('y', { stacksOn: ['p'], parentHead: 'paaaaaa', offLine: [] })]))
138
- assert.deepEqual(kinds(tree), ['deliver:x', 'deliver:y'], 'two lines on one parent: each leaf delivers; the first merge lands the parent')
139
- assert.match(nextActions(chainPlan(line, { mergeMode: 'supervised' })).due[0].detail, /hand them back merge-ready together/)
140
- // Round-5 audit: a supervised stack is handed back whole; merging its deepest PR would only land it on its parent.
141
- const supStack = nextActions(plan(line, { mergeMode: 'supervised' })).due[0].detail
142
- assert.match(supStack, /hand the whole stack back merge-ready/)
143
- assert.doesNotMatch(supStack, /lands the whole line/)
144
- // A reciprocal stacksOn pair resolves to the lower id everywhere, so the pair still delivers once.
145
- const recip = nextActions(plan([ok('1', { stacksOn: ['2'], parentHead: '2aaaaaa', offLine: [] }), ok('2', { stacksOn: ['1'], parentHead: '1aaaaaa', offLine: [] })]))
146
- assert.deepEqual(kinds(recip), ['deliver:2'])
147
- const stacked = nextActions(plan(line))
148
- assert.deepEqual(kinds(stacked), ['deliver:c'], 'stack mode delivers a stack once, too')
149
- assert.match(stacked.due[0].detail, /stack \(a -> b -> c\).*queue only #c \(@mergifyio queue\); the queue takes every PR below it/)
150
- })
151
-
152
- test('stack mode: a GitHub stack is a line, so a parent holds one stacked child and another opens after it lands', () => {
153
- // Native stacks are linear (a PR belongs to one stack). Chain mode lets a parent have two lines (above).
154
- const ok = (id, extra = {}) => ({ id, status: 'in-flight', stage: 'done', pr: `#${id}`, headSha: `${id}aaaaaa`, reviewedHead: `${id}aaaaaa`, branch: `b/${id}`, files: [id], ...extra })
155
- const built = (id, extra = {}) => ({ id, status: 'in-flight', stage: 'done', files: ['p', id], ...extra })
156
- const open = (r) => kinds(r).filter((k) => k.startsWith('open-pr'))
157
- // Both children are built in one pass: only the first opens.
158
- assert.deepEqual(kinds(nextActions(plan([ok('p', { reviewedHead: null }), built('x'), built('y')]))).filter((k) => k !== 'start-review:p'), ['open-pr:x'])
159
- // Audit round 3: p is being queued, so no child links onto it this pass; after p lands, x opens on the target.
160
- assert.deepEqual(kinds(nextActions(plan([ok('p'), built('x')]))), ['deliver:p'])
161
- // One child already holds p: the other waits, in stack mode only.
162
- const held = [ok('p'), ok('x', { stacksOn: ['p'], parentHead: 'paaaaaa', offLine: [] }), built('y', { dependsOn: ['p'] })]
163
- assert.deepEqual(open(nextActions(plan(held))), [])
164
- assert.deepEqual(open(nextActions(plan(held, { deliveryMode: 'chain' }))), ['open-pr:y'], 'chain mode can fork')
165
- // A blocked holder still sits in p's GitHub stack: y still waits, and p ships alone (audit round 1: relinking
166
- // in the same pass raced p's queue entry, and gh stack unstack leaves a queued PR stacked).
167
- for (const status of ['blocked', 'cancelled']) {
168
- const blockedHolder = nextActions(plan([held[0], { ...held[1], status, blockedReason: 'review-handback' }, held[2]]))
169
- assert.deepEqual(kinds(blockedHolder), ['deliver:p'], status)
170
- }
171
- // p landed: the waiting child opens on the target.
172
- const landed = nextActions(plan([{ ...held[0], status: 'succeeded' }, held[1], held[2]]))
173
- assert.match(landed.due.find((a) => a.unit === 'y').detail, /on the target: no open run PR shares its files/)
174
- // A dependent never opens an early PR on a parent's branch in stack mode: its stack parent is picked once its code is done.
175
- const early = [ok('p'), { id: 'c', status: 'in-flight', stage: 'implement', worker: 1, workerKind: 'implement', headSha: 'caaaaaa', dependsOn: ['p'] }]
176
- assert.deepEqual(open(nextActions(plan(early))), [])
177
- assert.deepEqual(open(nextActions(plan(early, { deliveryMode: 'chain' }))), ['open-pr:c'])
178
- // A fork left by an older run: the lower id keeps the stack and delivers; the other ships after p lands.
179
- const fork = [ok('p'), ok('x', { stacksOn: ['p'], parentHead: 'paaaaaa', offLine: [] }), ok('y', { stacksOn: ['p'], parentHead: 'paaaaaa', offLine: [] })]
180
- assert.deepEqual(kinds(nextActions(plan(fork))), ['deliver:x'])
181
- assert.deepEqual(kinds(nextActions(plan(fork, { deliveryMode: 'chain' }))), ['deliver:x', 'deliver:y'])
182
- // Audit round 1: a blocked lower-id holder still holds p, so y is never delivered as p's stack.
183
- const blockedFork = nextActions(plan([fork[0], { ...fork[1], status: 'blocked', blockedReason: 'review-handback', reviewedHead: null }, fork[2]]))
184
- assert.deepEqual(kinds(blockedFork), ['deliver:p'])
185
- assert.doesNotMatch(blockedFork.due[0].detail, /#y/)
186
- const after = nextActions(plan([{ ...fork[0], status: 'succeeded' }, { ...fork[1], status: 'succeeded' }, fork[2]]))
187
- assert.deepEqual(kinds(after), ['restack:y'])
188
- assert.match(after.due[0].detail, /p landed, and unit y's PR sat outside its GitHub stack \(unit x held it\): rebase it onto the target, clear stacksOn and parentHead, retarget the PR/)
189
- assert.deepEqual(kinds(nextActions(plan([{ ...fork[0], status: 'succeeded' }, fork[1], { ...fork[2], stacksOn: [], parentHead: null }]))), ['deliver:x', 'deliver:y'], 'once restacked, it ships alone')
190
- })
191
-
192
- test('stack mode: one change per GitHub stack per pass, restacks first (audit round 4)', () => {
193
- const pr = (id, extra = {}) => ({ id, status: 'in-flight', stage: 'done', pr: `#${id}`, headSha: `${id}aaaaaa`, branch: `b/${id}`, offLine: [], ...extra })
194
- // b is built on a's old HEAD: restack it before a is queued; chain mode does both, as before.
195
- const stale = [pr('a', { headSha: 'a222222', reviewedHead: 'a222222' }), pr('b', { reviewedHead: 'baaaaaa', stacksOn: ['a'], parentHead: 'a111111' })]
196
- assert.deepEqual(kinds(nextActions(plan(stale))), ['restack:b'])
197
- assert.deepEqual(kinds(nextActions(plan(stale, { deliveryMode: 'chain' }))), ['restack:b', 'deliver:a'])
198
- // Audit round 5: a restack waiting for a slot holds nothing back; a queues meanwhile.
199
- const full = nextActions(plan(stale), {}, 0)
200
- assert.deepEqual(kinds(full), ['deliver:a'])
201
- assert.deepEqual(full.waiting.map((a) => `${a.action}:${a.unit}`), ['restack:b'])
202
- // A built c never links onto b while b is being rebased.
203
- const opening = [pr('q', { reviewedHead: null }), pr('a', { headSha: 'a222222', reviewedHead: 'a222222', dependsOn: ['q'] }), pr('b', { reviewedHead: 'baaaaaa', stacksOn: ['a'], parentHead: 'a111111', files: ['shared'] }), { id: 'c', status: 'in-flight', stage: 'done', files: ['shared', 'c'] }]
204
- assert.deepEqual(kinds(nextActions(plan(opening))).filter((k) => k !== 'start-review:q'), ['restack:b'])
205
- })
206
-
207
- test('stack mode: a passed PR moves off to the target, never onto a parent another child holds', () => {
208
- const now = Date.parse('2026-10-16T10:00:00Z')
209
- const pr = (id, extra = {}) => ({ id, status: 'in-flight', stage: 'done', pr: `#${id}`, headSha: `${id}aaaaaa`, branch: `b/${id}`, files: ['src/index.ts'], ...extra })
210
- const units = [pr('a', { reviewedHead: 'aaaaaaa' }), pr('b', { stacksOn: ['a'], parentHead: 'aaaaaaa', offLine: [] }), pr('c', { stacksOn: ['b'], parentHead: 'baaaaaa', offLine: [], reviewedHead: 'caaaaaa' })]
211
- const fixRound = { b: { round: 1, head: 'baaaaaa', complete: true, blocking: true, fix: true, awaiting: false, at: now }, c: { round: 1, head: 'caaaaaa', complete: true, blocking: false, fix: false, awaiting: false, at: now - 60_000 } }
212
- // Chain mode moves c onto a; in a GitHub stack b still holds a, so c goes to the target.
213
- const r = nextActions(plan(units), fixRound, 4, { now })
214
- const moved = r.due.find((a) => a.action === 'restack')
215
- assert.match(moved.detail, /waits on b.*rebase onto the target, clear stacksOn and parentHead.*retarget the PR to the target.*leaves its GitHub stack: before any PR in that stack is queued .*gh stack checkout #c, gh stack unstack, then gh stack link #a #b \(bottom first\)/)
216
- assert.ok(!kinds(r).includes('deliver:a'), 'a is not queued while its stack is taken apart')
217
- assert.ok(kinds(nextActions(plan(units, { deliveryMode: 'chain' }), fixRound, 4, { now })).includes('deliver:a'), 'chain mode delivers a meanwhile')
218
- // Audit round 2: c and its passed child d would both leave the stack, each relinking from the old shape.
219
- // Only c moves this pass; d is reconsidered on the next plan.
220
- const withD = [...units, pr('d', { stacksOn: ['c'], parentHead: 'caaaaaa', offLine: [], reviewedHead: 'daaaaaa', files: ['d.ts'] })]
221
- const two = nextActions(plan(withD), fixRound, 4, { now })
222
- assert.deepEqual(kinds(two).filter((k) => k.startsWith('restack') || k.startsWith('deliver')), ['restack:c'])
223
- assert.match(two.due[0].detail, /gh stack link #a #b and gh stack link #c #d/)
224
- // Audit round 3: while c leaves, a built d does not link onto c's old line.
225
- const withBuilt = [...units, { id: 'd', status: 'in-flight', stage: 'done', files: ['src/index.ts', 'd.ts'] }]
226
- assert.deepEqual(kinds(nextActions(plan(withBuilt), fixRound, 4, { now })).filter((k) => k.startsWith('restack') || k.startsWith('deliver') || k.startsWith('open-pr')), ['restack:c'])
227
- // c needs a's code: dropping a is never allowed, so c waits on b.
228
- const needsA = plan(units.map((u) => (u.id === 'c' ? { ...u, dependsOn: ['a'] } : u)))
229
- assert.deepEqual(kinds(nextActions(needsA, fixRound, 4, { now })).filter((k) => k.startsWith('restack')), [])
230
- })
231
-
232
- test('chain mode: a passed PR moves off a shared-file ancestor in a fix round at once, and off a slow one after 30 minutes', () => {
233
- // wiki-kb #12 replay: chained PRs waited 872 minutes in all behind slow shared-file reviews (wait mode: 534).
234
- const now = Date.parse('2026-10-16T10:00:00Z')
235
- const pr = (id, extra = {}) => ({ id, status: 'in-flight', stage: 'done', pr: `#${id}`, headSha: `${id}aaaaaa`, branch: `b/${id}`, files: ['src/index.ts'], ...extra })
236
- const units = [pr('a', { reviewedHead: 'aaaaaaa' }), pr('b', { stacksOn: ['a'], parentHead: 'aaaaaaa', offLine: [] }), pr('c', { stacksOn: ['b'], parentHead: 'baaaaaa', offLine: [], reviewedHead: 'caaaaaa' })]
237
- const chainPlan = plan(units, { deliveryMode: 'chain' })
238
- const passedAt = (min) => ({ round: 1, head: 'caaaaaa', complete: true, blocking: false, fix: false, awaiting: false, at: now - min * 60_000 })
239
- const run = (reviews) => nextActions(chainPlan, reviews, 4, { now })
240
- const fixRound = { b: { round: 1, head: 'baaaaaa', complete: true, blocking: true, fix: true, awaiting: false, at: now }, c: passedAt(1) }
241
- const moved = run(fixRound)
242
- assert.deepEqual(kinds(moved).filter((k) => k.startsWith('restack')), ['restack:c'])
243
- assert.match(moved.due.find((a) => a.action === 'restack').detail, /waits on b.*b is in a fix round or blocked.*rebase onto unit a's branch at aaaaaaa, set stacksOn to \[a\] and parentHead to that HEAD/)
244
- assert.deepEqual(kinds(run({ c: passedAt(10) })).filter((k) => k.startsWith('restack')), [], 'b is still in review: c waits a while')
245
- assert.match(run({ c: passedAt(31) }).due.find((a) => a.action === 'restack').detail, /still in review 31m after this pass/)
246
- const dependent = plan(units.map((u) => (u.id === 'c' ? { ...u, dependsOn: ['b'] } : u)), { deliveryMode: 'chain' })
247
- assert.deepEqual(kinds(nextActions(dependent, fixRound, 4, { now })).filter((k) => k.startsWith('restack')), [], 'c needs b\'s code: it never moves off b')
248
- const root = plan([pr('a', { stacksOn: [] }), { ...pr('b', { stacksOn: ['a'], parentHead: 'aaaaaaa', offLine: [] }) }, pr('c', { stacksOn: ['b'], parentHead: 'baaaaaa', offLine: [], reviewedHead: 'caaaaaa' })].map((u) => (u.id === 'b' ? { ...u, reviewedHead: u.headSha } : u)), { deliveryMode: 'chain' })
249
- const viaA = nextActions(root, { a: { round: 1, head: 'aaaaaaa', complete: true, blocking: true, fix: true, awaiting: false, at: now }, c: passedAt(1) }, 4, { now })
250
- assert.match(viaA.due.find((a) => a.action === 'restack').detail, /waits on a.*rebase onto the target, clear stacksOn/, 'b carries a, so c drops both and goes to the target')
251
- })
252
-
253
- test('chain mode audit: two-line dependencies, blocked dependencies, stacksOn loops, and headless parents', () => {
254
- const pr = (id, extra = {}) => ({ id, status: 'in-flight', stage: 'done', pr: `#${id}`, headSha: `${id}aaaaaa`, branch: `b/${id}`, files: [`${id}.py`], ...extra })
255
- const chainPlan = (units) => plan(units, { deliveryMode: 'chain' })
256
- // c needs a and b, which sit on different lines. identity #1154 replay: holding c's PR until one line
257
- // landed started its 884-minute review six hours late. c opens now on one line, carries the other as
258
- // offLine, and ships only after that line lands and a restack drops the copy.
259
- const both = nextActions(chainPlan([pr('a'), pr('b'), { id: 'c', status: 'in-flight', stage: 'done', dependsOn: ['a', 'b'], files: ['c.py'] }]))
260
- assert.match(both.due.find((x) => x.unit === 'c').detail, /chained on unit b .*set offLine to \[a\]/)
261
- const ok = (id, extra = {}) => pr(id, { reviewedHead: `${id}aaaaaa`, ...extra })
262
- const carrying = [ok('a'), ok('b'), ok('c', { dependsOn: ['a', 'b'], stacksOn: ['b'], parentHead: 'baaaaaa', offLine: ['a'] })]
263
- assert.deepEqual(kinds(nextActions(chainPlan(carrying))), ['deliver:a', 'deliver:b'], 'c waits while it carries a copy of a')
264
- const landed = nextActions(chainPlan([{ ...carrying[0], status: 'succeeded' }, carrying[1], carrying[2]]))
265
- assert.deepEqual(kinds(landed), ['restack:c', 'deliver:b'])
266
- assert.match(landed.due[0].detail, /a landed: rebase unit c onto unit b's HEAD baaaaaa to drop the copies of their commits, set parentHead to baaaaaa, clear offLine/)
267
- const clean = nextActions(chainPlan([{ ...carrying[0], status: 'succeeded' }, carrying[1], { ...carrying[2], offLine: [] }]))
268
- assert.deepEqual(kinds(clean), ['deliver:c'])
269
- // Round-3 audit: what c carries is derived from the plan, never trusted from offLine.
270
- const unrecorded = nextActions(chainPlan([carrying[0], carrying[1], { ...carrying[2], offLine: undefined }]))
271
- assert.deepEqual(kinds(unrecorded), ['restack:c', 'deliver:a', 'deliver:b'], 'a missing offLine holds c and restacks it')
272
- const stray = nextActions(chainPlan([ok('x'), carrying[1], ok('c', { dependsOn: ['b'], stacksOn: ['b'], parentHead: 'baaaaaa', offLine: ['x'] })]))
273
- assert.deepEqual(kinds(stray), ['deliver:x', 'deliver:c'], 'an offLine id that is not a dependency is ignored')
274
- const blockedDep = nextActions(chainPlan([{ ...carrying[0], status: 'succeeded' }, { ...carrying[1], status: 'blocked', blockedReason: 'review-handback' }, carrying[2]]))
275
- assert.deepEqual(kinds(blockedDep), ['finish:'], 'no cleanup while a dependency will not land')
276
- const both2 = nextActions(chainPlan([{ ...carrying[0], status: 'succeeded' }, { ...carrying[1], reviewedHead: null }, carrying[2]]), { b: { round: 1, head: 'baaaaaa', complete: true, blocking: true, fix: true, awaiting: false, at: Date.now() } }, 4, { now: Date.now() })
277
- assert.equal(kinds(both2).filter((k) => k === 'restack:c').length, 1, 'one restack per unit per pass')
278
- // A blocked dependency is never dropped: its dependent's PR waits, and the run can finish around it.
279
- const blocked = chainPlan([pr('p', { status: 'blocked', blockedReason: 'review-handback' }), pr('c', { dependsOn: ['p'], stacksOn: ['p'], parentHead: 'paaaaaa', offLine: [], reviewedHead: 'caaaaaa' })])
280
- const r = nextActions(blocked)
281
- assert.deepEqual(kinds(r), ['finish:'])
282
- assert.match(r.due[0].detail, /PR open, cannot land: c \(#c, waits on p\)/)
283
- // A built child whose parent is blocked but still has a PR is held, not opened, in chain and stack mode.
284
- for (const mode of ['chain', 'stack']) {
285
- const held = nextActions(plan([pr('p', { status: 'blocked', blockedReason: 'review-handback' }), { id: 'c', status: 'in-flight', stage: 'done', dependsOn: ['p'], files: ['c.py'] }], { deliveryMode: mode }))
286
- assert.deepEqual(kinds(held), ['finish:'], mode)
287
- assert.match(held.due[0].detail, /built, PR not opened: c \(waits on p\)/)
288
- }
289
- // Round-4 audit: a chained PR that never recorded offLine may carry copies that a landed dependency
290
- // hides; it restacks before it can ship.
291
- const legacy = nextActions(chainPlan([ok('b'), ok('c', { dependsOn: ['b'], stacksOn: ['b'], parentHead: 'baaaaaa' })]))
292
- assert.deepEqual(kinds(legacy), ['restack:c', 'deliver:b'])
293
- assert.match(legacy.due[0].detail, /has no recorded offLine: rebase it onto unit b's HEAD baaaaaa/)
294
- // A reciprocal stacksOn pair resolves to the lower id as parent (as in stack mode); a longer loop is a cycle.
295
- const pair = nextActions(chainPlan([pr('a', { stacksOn: ['b'], parentHead: 'baaaaaa', offLine: [], reviewedHead: 'aaaaaaa' }), pr('b', { stacksOn: ['a'], parentHead: 'aaaaaaa', offLine: [] })]), {}, 4, { now: Date.now() })
296
- assert.ok(!kinds(pair).some((k) => k.startsWith('cycle:')), kinds(pair).join(' '))
297
- const loop = nextActions(chainPlan([pr('a', { stacksOn: ['c'], parentHead: 'caaaaaa', offLine: [], reviewedHead: 'aaaaaaa' }), pr('b', { stacksOn: ['a'], parentHead: 'aaaaaaa', offLine: [] }), pr('c', { stacksOn: ['b'], parentHead: 'baaaaaa', offLine: [] })]), {}, 4, { now: Date.now() })
298
- assert.ok(kinds(loop).some((k) => k.startsWith('cycle:')), kinds(loop).join(' '))
299
- // A parent with no recorded HEAD cannot be chained on.
300
- const headless = nextActions(chainPlan([pr('p', { headSha: undefined, files: ['x.py'] }), { id: 'c', status: 'in-flight', stage: 'done', files: ['x.py'] }]))
301
- assert.match(headless.due.find((x) => x.unit === 'c').detail, /record headSha for unit p's PR/)
302
- })
303
-
304
- test('an unknown deliveryMode is refused, not read as stack', () => {
305
- assert.throws(() => nextActions(plan([{ id: 'a', status: 'pending' }], { deliveryMode: 'chained' })), /deliveryMode must be stack, chain, or wait: "chained"/)
306
- })
307
-
308
- test('chain mode: a PR whose chain parent will not land is restacked onto the target', () => {
309
- const units = [
310
- { id: 'p', status: 'blocked', blockedReason: 'review-handback', stage: 'done', pr: '#p', headSha: 'paaaaaa', files: ['x'] },
311
- { id: 'c', status: 'in-flight', stage: 'done', pr: '#c', headSha: 'caaaaaa', reviewedHead: 'caaaaaa', stacksOn: ['p'], files: ['x'], offLine: [] },
312
- ]
313
- const r = nextActions(plan(units, { deliveryMode: 'chain' }))
314
- assert.deepEqual(kinds(r), ['restack:c'])
315
- assert.match(r.due[0].detail, /rebase it onto the target without them/)
316
- const legacy = nextActions(plan([units[0], { ...units[1], offLine: undefined }], { deliveryMode: 'chain' }))
317
- assert.match(legacy.due.find((a) => a.action === 'restack').detail, /rebase it onto the target/, 'never onto a parent that will not land')
318
- assert.deepEqual(kinds(nextActions(plan(units, { deliveryMode: 'chain' }), {}, 0)).filter((k) => k.startsWith('restack')), [], 'a restack needs a slot')
319
- })
320
-
321
- test('a block without a listed reason is reopened on every call, not only on --resume', () => {
322
- // agentier #2366: the coordinator re-marked units blocked ("await actual suppliers", "gate
323
- // holds") minutes after --resume unblocked them, and the scheduler then printed finish.
324
- const units = [
325
- { id: 'a', status: 'blocked', stage: 'implement', note: 'await actual suppliers' },
326
- { id: 'b', status: 'blocked', stage: 'implement', blockedReason: 'awaiting-supplier' },
327
- { id: 'c', status: 'blocked', stage: 'implement', blockedReason: 'human-decision', humanItem: 5, humanNeed: 'add the STRIPE_KEY secret' },
328
- { id: 'd', status: 'succeeded' },
329
- ]
330
- const r = nextActions(plan(units))
331
- assert.deepEqual(kinds(r), ['unblock:a', 'unblock:b', 'resume:a', 'resume:b'], 'no finish while a unit is wrongly blocked')
332
- assert.match(r.due[0].detail, /without a reason from expire\.md's list \(blockedReason null\)/)
333
- const waiting = [{ id: 'p', status: 'in-flight', stage: 'implement', worker: 1, workerKind: 'implement' }, { id: 'w', status: 'blocked', dependsOn: ['p'], blockedReason: 'awaiting-supplier' }]
334
- const w = nextActions(plan(waiting))
335
- assert.deepEqual(kinds(w), ['unblock:w'])
336
- assert.match(w.due[0].detail, /only waits on p: set it pending/)
337
- const sized = { id: 's', status: 'blocked', stage: 'implement', blockedReason: 'size-hold' }
338
- assert.deepEqual(kinds(nextActions(plan([sized, units[3]], { skill: 'resolve-issues' }))), ['finish:'], 'resolve-issues hands a size hold back')
339
- assert.deepEqual(kinds(nextActions(plan([sized, units[3]], { skill: 'rush-issues' }))), ['unblock:s', 'resume:s'], 'rush never blocks for size')
340
- })
341
-
342
- test('a child built on a parent that will not land this run settles instead of stalling silently', () => {
343
- for (const mode of ['wait', 'stack']) {
344
- const units = [
345
- { id: '1', status: 'blocked', blockedReason: 'retry-ci', stage: 'done', autoRetries: 3 },
346
- { id: '2', status: 'in-flight', stage: 'done', dependsOn: ['1'] },
347
- ]
348
- const r = nextActions(plan(units, { deliveryMode: mode }))
349
- assert.deepEqual(kinds(r), ['finish:'], mode)
350
- assert.match(r.due[0].detail, /built, PR not opened: 2 \(waits on 1\)/)
351
- const cancelled = [{ ...units[0], status: 'cancelled' }, units[1]]
352
- assert.deepEqual(kinds(nextActions(plan(cancelled, { deliveryMode: mode }))), ['finish:'], `${mode}, cancelled parent`)
353
- }
354
- })
355
-
356
- test('a reasonless block with a stale worker never lets the run finish', () => {
357
- const r = nextActions(plan([{ id: '1', status: 'blocked', worker: 'w1', workerKind: 'implement' }, { id: '2', status: 'succeeded' }]))
358
- assert.deepEqual(kinds(r), ['unblock:1'])
359
- assert.match(r.due[0].detail, /if that worker is gone, clear it/)
360
- })
361
-
362
- test('an action left undone says how long it has been printed', () => {
363
- const units = [{ id: 'a', status: 'pending' }]
364
- const t0 = Date.parse('2026-10-13T07:00:00Z')
365
- const first = nextActions(plan(units), {}, 4, { now: t0 })
366
- assert.deepEqual(first.seen, { 'fill-slot:a': t0 })
367
- assert.doesNotMatch(first.due[0].detail, /still not done/)
368
- const later = nextActions(plan(units), {}, 4, { now: t0 + 92 * 60_000, seen: first.seen })
369
- assert.match(later.due[0].detail, /printed 92m ago and still not done/)
370
- assert.deepEqual(later.seen, { 'fill-slot:a': t0 }, 'the first time is kept')
371
- const started = nextActions(plan([{ id: 'a', status: 'in-flight', worker: 1, workerKind: 'implement' }]), {}, 4, { now: t0 + 93 * 60_000, seen: later.seen })
372
- assert.deepEqual(started.seen, {}, 'a done action drops out')
373
- const delivering = [{ id: 'd', status: 'in-flight', stage: 'done', pr: '#1', headSha: 'abc1234', reviewedHead: 'abc1234' }]
374
- const wait = nextActions(plan(delivering), {}, 4, { now: t0 + 30 * 60_000, seen: { 'deliver:d': t0 } })
375
- assert.doesNotMatch(wait.due[0].detail, /still not done/, 'deliver repeats while CI and the queue run')
376
- })
377
-
378
- test('a written review without a recorded verdict asks for review-verdict before anything else', () => {
379
- const r = nextActions(plan([
380
- { id: 'a', status: 'in-flight', stage: 'done', pr: '#1', headSha: 'aaa' },
381
- { id: 'b', status: 'pending' },
382
- ]), { a: { round: 1, head: 'aaa', fix: false } })
383
- assert.deepEqual(kinds(r), ['review-verdict:a', 'fill-slot:b'])
384
- })
385
-
386
- test('a fix brief for the current HEAD starts a fix; a pushed fix (new HEAD) starts the next review round', () => {
387
- const unit = { id: 'a', status: 'in-flight', stage: 'done', pr: '#1', headSha: 'aaa' }
388
- assert.deepEqual(kinds(nextActions(plan([unit]), { a: { round: 1, head: 'aaa', fix: true } })), ['start-fix:a'])
389
- const r = nextActions(plan([{ ...unit, headSha: 'bbb' }]), { a: { round: 1, head: 'aaa', fix: true } })
390
- assert.deepEqual(kinds(r), ['start-review:a'])
391
- assert.match(r.due[0].detail, /round 2/)
392
- assert.match(r.due[0].detail, /rereview-scope\.mjs --prior <run>\/reviews\/a-r1\.json .*--write-skip <run>\/reviews\/a-r2\.json; on skip run review-verdict/, 'a re-review picks its tier before spawning a reviewer')
393
- assert.doesNotMatch(nextActions(plan([unit])).due[0].detail, /rereview-scope/, 'the first review is always full')
394
- })
395
-
396
- test('review passed on the current HEAD: deliver (supervised hands back instead of enqueueing)', () => {
397
- const unit = { id: 'a', status: 'in-flight', stage: 'done', pr: '#1', headSha: 'abcdef12', reviewedHead: 'abcdef12' }
398
- assert.match(nextActions(plan([unit])).due[0].detail, /mark ready and merge \(enqueue under a queue\)/)
399
- assert.match(nextActions(plan([unit], { mergeMode: 'supervised' })).due[0].detail, /hand the PR back/)
400
- })
401
-
402
- test('code done without a PR opens one when the parents allow it', () => {
403
- const units = [
404
- { id: 'p', status: 'in-flight', stage: 'done', worker: 1, workerKind: 'implement' },
405
- { id: 'c', status: 'in-flight', stage: 'done', dependsOn: ['p'] },
406
- ]
407
- assert.deepEqual(kinds(nextActions(plan(units))), [], 'stack: wait for the parent PR')
408
- units[0].pr = '#9'
409
- assert.deepEqual(kinds(nextActions(plan(units))), ['open-pr:c'])
410
- })
411
-
412
- test('an in-flight unit that lost its worker is resumed', () => {
413
- assert.deepEqual(kinds(nextActions(plan([{ id: 'a', status: 'in-flight', stage: 'implement' }]))), ['resume:a'])
414
- })
415
-
416
- test('worker-needing actions are limited to free slots; the rest wait', () => {
417
- const busy = [1, 2, 3].map((n) => ({ id: `w${n}`, status: 'in-flight', stage: 'implement', worker: n, workerKind: 'implement' }))
418
- const r = nextActions(plan([...busy,
419
- { id: 'r', status: 'in-flight', stage: 'done', pr: '#1', headSha: 'x' },
420
- { id: 'n', status: 'pending' },
421
- { id: 'd', status: 'in-flight', stage: 'done', pr: '#2', headSha: 'y', reviewedHead: 'y' },
422
- ]))
423
- assert.deepEqual(kinds(r), ['start-review:r', 'deliver:d'], 'the review takes the last slot; deliver needs none')
424
- assert.deepEqual(r.waiting.map((a) => a.unit), ['n'])
425
- })
426
-
427
- test('busy units, delivered and blocked units get no action; all terminal means finish', () => {
428
- assert.deepEqual(kinds(nextActions(plan([{ id: 'a', status: 'in-flight', stage: 'done', pr: '#1', headSha: 'x', worker: 2, workerKind: 'review' }]))), [])
429
- const r = nextActions(plan([{ id: 'a', status: 'succeeded' }, { id: 'b', status: 'blocked', blockedReason: 'retry-ci', autoRetries: 3 }, { id: 'c', status: 'cancelled' }]))
430
- assert.deepEqual(kinds(r), ['finish:'])
431
- assert.throws(() => nextActions({ units: [] }), NextActionsError)
432
- })
433
-
434
- test('--resume reopens units blocked in an earlier invocation and schedules their next step', () => {
435
- const units = [
436
- { id: 'a', status: 'blocked', stage: 'implement', blockedReason: 'spawn-death', note: 'spawn-death twice', autoRetries: 3 },
437
- { id: 'b', status: 'blocked', stage: 'done', pr: '#1', headSha: 'abc1234', reviewedHead: 'abc1234', blockedReason: 'retry-ci', note: 'retry-ci: queue teardown, #1457', autoRetries: 3 },
438
- { id: 'c', status: 'succeeded' },
439
- ]
440
- assert.deepEqual(kinds(nextActions(plan(units))), ['finish:'], 'without --resume a blocked unit is terminal')
441
- const r = nextActions(plan(units), {}, 4, { resume: true })
442
- assert.deepEqual(kinds(r), ['unblock:a', 'unblock:b', 'deliver:b', 'resume:a'])
443
- assert.match(r.due[0].detail, /fresh limits/)
444
- })
445
-
446
- test('--resume reopens every block; a note never keeps a unit blocked', () => {
447
- const units = ['human-decision: approve HTTPS fixture', 'external-blocker: custodian cleanup required', 'retry-review: review-verdict exit 4']
448
- .map((note, i) => ({ id: `h${i}`, status: 'blocked', stage: 'implement', note }))
449
- assert.deepEqual(kinds(nextActions(plan(units), {}, 4, { resume: true })), ['unblock:h0', 'unblock:h1', 'unblock:h2', 'resume:h0', 'resume:h1', 'resume:h2'])
450
- })
451
-
452
- test('--resume reviews a handed-back HEAD again, full, instead of re-reading the old verdict', () => {
453
- const unit = { id: 'a', status: 'blocked', stage: 'done', pr: '#1', headSha: 'abc1234', note: 'retry-review: spec-question' }
454
- const handback = { a: { round: 2, head: 'abc1234', fix: false, complete: true, awaiting: false, blocking: true } }
455
- const r = nextActions(plan([unit]), handback, 4, { resume: true })
456
- assert.deepEqual(kinds(r), ['unblock:a', 'start-review:a'])
457
- assert.match(r.due[1].detail, /again against the issue as it reads now \(round 3, full\); review-verdict compares it with round 2/)
458
- assert.doesNotMatch(r.due[1].detail, /rereview-scope/, 'the same HEAD must not be scoped to a skip')
459
- })
460
-
461
- test('--resume sends an incomplete review, an awaited GitHub reviewer, and an unrecorded pass through review-verdict', () => {
462
- const unit = { id: 'a', status: 'blocked', stage: 'done', pr: '#1', headSha: 'abc1234', blockedReason: 'review-handback' }
463
- const incomplete = { a: { round: 2, head: 'abc1234', fix: false, complete: false, awaiting: false, blocking: true } }
464
- assert.deepEqual(kinds(nextActions(plan([unit]), incomplete, 4, { resume: true })), ['unblock:a', 'review-verdict:a'])
465
- const awaiting = { a: { round: 2, head: 'abc1234', fix: false, complete: true, awaiting: true, blocking: true } }
466
- assert.deepEqual(kinds(nextActions(plan([unit]), awaiting, 4, { resume: true })), ['unblock:a', 'review-verdict:a'])
467
- const handback = { a: { round: 2, head: 'abc1234', fix: false, complete: true, awaiting: false, blocking: true } }
468
- const unrecordedPass = { a: { round: 2, head: 'abc1234', fix: false, complete: true, awaiting: false, blocking: false } }
469
- assert.deepEqual(kinds(nextActions(plan([unit]), unrecordedPass, 4, { resume: true })), ['unblock:a', 'review-verdict:a'], 'a pass whose verdict was never recorded needs no new round')
470
- assert.deepEqual(kinds(nextActions(plan([unit]), handback)), ['finish:'], 'without --resume the handback stays terminal')
471
- })
472
-
473
- test('a stacksOn parent orders the PR in stack mode only; in wait mode PRs open as they are ready', () => {
474
- // wiki-kb #12: a PR that passed review waited an hour behind shared-file siblings, with no queue.
475
- const units = [
476
- { id: 'p', status: 'blocked', stage: 'done', pr: '#1', headSha: 'aaa', reviewedHead: 'aaa' },
477
- { id: 'c', status: 'blocked', stage: 'done', stacksOn: ['p'] },
478
- ]
479
- assert.deepEqual(kinds(nextActions(plan(units, { deliveryMode: 'wait' }), {}, 4, { resume: true })), ['unblock:p', 'unblock:c', 'deliver:p', 'open-pr:c'])
480
- assert.deepEqual(kinds(nextActions(plan(units), {}, 4, { resume: true })), ['unblock:p', 'unblock:c', 'deliver:p', 'open-pr:c'])
481
- })
482
-
483
- test('two units stacked on each other: the lower id opens its PR first in stack mode', () => {
484
- // Saved plans hold reciprocal stacksOn pairs; without a direction neither PR could ever open.
485
- const units = [
486
- { id: '1135', status: 'in-flight', stage: 'done', stacksOn: ['1134'] },
487
- { id: '1134', status: 'in-flight', stage: 'done', stacksOn: ['1135'] },
488
- ]
489
- assert.deepEqual(kinds(nextActions(plan(units))), ['open-pr:1134'])
490
- assert.deepEqual(kinds(nextActions(plan(units, { deliveryMode: 'wait' }))), ['open-pr:1135', 'open-pr:1134'])
491
- })
492
-
493
- test('a stacked unit whose stacksOn parent is blocked opens its PR on the target instead of waiting', () => {
494
- const units = [{ id: 'p', status: 'blocked', stage: 'implement', blockedReason: 'retry-implement' }, { id: 's', status: 'in-flight', stage: 'done', stacksOn: ['p'] }]
495
- assert.deepEqual(kinds(nextActions(plan(units))), ['open-pr:s'])
496
- })
497
-
498
- test('a dependsOn cycle is named as an action instead of idling, also after --resume', () => {
499
- const units = [{ id: 'a', status: 'pending', dependsOn: ['b'] }, { id: 'b', status: 'pending', dependsOn: ['a'] }, { id: 'c', status: 'succeeded' }]
500
- const r = nextActions(plan(units))
501
- assert.deepEqual(kinds(r), ['cycle:a'])
502
- assert.match(r.due[0].detail, /units a -> b -> a depend on each other/)
503
- const blocked = units.map((u) => (u.id === 'c' ? u : { ...u, status: 'blocked', stage: 'implement' }))
504
- assert.deepEqual(kinds(nextActions(plan(blocked), {}, 4, { resume: true })), ['cycle:a', 'unblock:a', 'unblock:b'])
505
- })
506
-
507
- test('a stacksOn parent never holds its child\'s start; a dependsOn parent does', () => {
508
- const units = [
509
- { id: 'p', status: 'in-flight', stage: 'implement', worker: 1, workerKind: 'implement' },
510
- { id: 's', status: 'pending', stacksOn: ['p'] },
511
- { id: 'd', status: 'pending', dependsOn: ['p'] },
512
- ]
513
- assert.deepEqual(kinds(nextActions(plan(units), {})), ['fill-slot:s'], 'the stacked unit codes alongside its parent')
514
- const coded = [
515
- { id: 'p', status: 'in-flight', stage: 'done' },
516
- { id: 's', status: 'in-flight', stage: 'done', stacksOn: ['p'] },
517
- ]
518
- assert.deepEqual(kinds(nextActions(plan(coded), {})), ['open-pr:p'], 'its PR still waits for the parent\'s PR')
519
- })
520
-
521
- test('CLI reads the newest review round per unit and exits 0 whether or not actions are due; --exit-due exits 1 when due', (t) => {
522
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'next-actions-'))
523
- t.after(() => fs.rmSync(dir, { recursive: true, force: true }))
524
- fs.mkdirSync(path.join(dir, 'reviews'))
525
- fs.writeFileSync(path.join(dir, 'reviews', 'a-r1.json'), JSON.stringify({ head: 'old' }))
526
- fs.writeFileSync(path.join(dir, 'reviews', 'a-r2.json'), JSON.stringify({ head: 'aaa', complete: true, external: { awaiting: ['vera932'] }, findings: [{ severity: 'P2' }, { severity: 'P1' }] }))
527
- fs.writeFileSync(path.join(dir, 'reviews', 'a-r2.fix.json'), '{}')
528
- const { at, ...newest } = readReviews(dir).a
529
- assert.ok(Number.isFinite(at), 'the newest round records when it was written')
530
- assert.deepEqual(newest, { round: 2, head: 'aaa', fix: true, complete: true, awaiting: true, blocking: true })
531
- fs.writeFileSync(path.join(dir, 'task-plan.json'), JSON.stringify(plan([{ id: 'a', status: 'in-flight', stage: 'done', pr: '#1', headSha: 'aaa' }])))
532
- const due = spawnSync('node', [SCRIPT, '--slots', '4', '--run', dir], { encoding: 'utf8' })
533
- assert.equal(due.status, 0, due.stderr)
534
- assert.match(due.stdout, /^start-fix\s+give reviews\/a-r2\.fix\.json/m)
535
- assert.equal(spawnSync('node', [SCRIPT, '--slots', '4', '--run', dir, '--exit-due', '--peek']).status, 1)
536
- const dueJson = JSON.parse(spawnSync('node', [SCRIPT, '--slots', '4', '--run', dir, '--json', '--peek'], { encoding: 'utf8' }).stdout)
537
- assert.equal(dueJson.exitDue, true)
538
- assert.ok(dueJson.due.length > 0 && 'waiting' in dueJson && dueJson.mode === 'full')
539
- fs.writeFileSync(path.join(dir, 'task-plan.json'), JSON.stringify(plan([{ id: 'a', status: 'in-flight', stage: 'done', pr: '#1', headSha: 'aaa', worker: 1, workerKind: 'implement' }])))
540
- assert.equal(spawnSync('node', [SCRIPT, '--slots', '4', '--run', dir]).status, 0)
541
- assert.equal(spawnSync('node', [SCRIPT, '--slots', '4', '--run', dir, '--exit-due']).status, 0, '--exit-due exits 0 when nothing is due')
542
- assert.equal(JSON.parse(spawnSync('node', [SCRIPT, '--slots', '4', '--run', dir, '--json', '--peek'], { encoding: 'utf8' }).stdout).exitDue, false)
543
- assert.equal(spawnSync('node', [SCRIPT, '--slots', '4', '--run', path.join(dir, 'nope')]).status, 2)
544
- // A plan without `skill` would read as resolve-issues; the CLI refuses it instead of guessing.
545
- fs.writeFileSync(path.join(dir, 'task-plan.json'), JSON.stringify({ ...plan([{ id: 'a', status: 'pending' }]), skill: undefined }))
546
- const unnamed = spawnSync('node', [SCRIPT, '--slots', '4', '--run', dir], { encoding: 'utf8' })
547
- assert.equal(unnamed.status, 2)
548
- assert.match(unnamed.stderr, /needs "skill": "resolve-issues" or "rush-issues"/)
549
- assert.equal(spawnSync('node', [SCRIPT, '--run', dir, '--exit-due', '--bogus']).status, 2)
550
- assert.throws(() => parseCliArgs(['--slots', '-1', '--run', dir]), NextActionsError)
551
- assert.equal(parseCliArgs(['--run', dir, '--slots', '4', '--resume']).resume, true)
552
- assert.equal(parseCliArgs(['--run', dir, '--slots', '4']).resume, false)
553
- })
554
-
555
- test('CLI consumes a revisioned workflow plan without changing the plan', (t) => {
556
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'next-actions-workflow-'))
557
- t.after(() => fs.rmSync(dir, { recursive: true, force: true }))
558
- const planPath = path.join(dir, 'task-plan.json')
559
- const savedPlan = JSON.stringify(plan([
560
- { id: '2510', status: 'in-flight', stage: 'done', pr: '#1', headSha: 'abc123' },
561
- ], { planRevision: 12, runDir: dir, decisions: [] }), null, 2)
562
- fs.writeFileSync(planPath, savedPlan)
563
-
564
- const result = spawnSync('node', [SCRIPT, '--slots', '4', '--run', dir, '--json'], { encoding: 'utf8' })
565
- assert.equal(result.status, 0, result.stderr)
566
- assert.deepEqual(kinds(JSON.parse(result.stdout)), ['start-review:2510'])
567
- assert.equal(fs.readFileSync(planPath, 'utf8'), savedPlan)
568
- })
569
-
570
- test('a worker silent past the quiet limit is checked first; an active or young one is not', () => {
571
- const now = Date.parse('2026-10-11T11:19:00Z')
572
- const units = [
573
- { id: 'stuck', status: 'in-flight', stage: 'implement', worker: 1, workerKind: 'implement', workerSince: '2026-10-11T08:16:00Z' },
574
- { id: 'busy', status: 'in-flight', stage: 'implement', worker: 2, workerKind: 'implement', workerSince: '2026-10-11T08:16:00Z' },
575
- { id: 'young', status: 'in-flight', stage: 'implement', worker: 3, workerKind: 'implement', workerSince: '2026-10-11T11:00:00Z' },
576
- { id: 'next', status: 'pending' },
577
- ]
578
- const activity = { stuck: Date.parse('2026-10-11T09:10:00Z'), busy: Date.parse('2026-10-11T11:15:00Z') }
579
- const r = nextActions(plan(units), {}, 4, { now, activity })
580
- assert.deepEqual(kinds(r), ['check-worker:stuck', 'fill-slot:next'])
581
- assert.match(r.due[0].detail, /129m: read its latest output; if it is stuck, stop it and resume/)
582
- assert.deepEqual(kinds(nextActions(plan(units), {}, 4, { now, activity, quietMinutes: 180 })), ['fill-slot:next'])
583
- assert.deepEqual(kinds(nextActions(plan(units), {}, 4, { activity })), ['fill-slot:next'], 'no clock, no check')
584
- })
585
-
586
- test('readActivity finds the newest workspace change and ignores dependency output', (t) => {
587
- const ws = fs.mkdtempSync(path.join(os.tmpdir(), 'na-act-'))
588
- t.after(() => fs.rmSync(ws, { recursive: true, force: true }))
589
- fs.mkdirSync(path.join(ws, 'src'))
590
- fs.mkdirSync(path.join(ws, 'node_modules'))
591
- fs.writeFileSync(path.join(ws, 'src', 'a.ts'), 'x')
592
- fs.writeFileSync(path.join(ws, 'node_modules', 'dep.js'), 'x')
593
- const old = new Date('2026-10-11T09:00:00Z')
594
- fs.utimesSync(path.join(ws, 'src', 'a.ts'), old, old)
595
- const act = readActivity(plan([{ id: 'u', status: 'in-flight', worker: 1, workerKind: 'implement', workspace: ws }, { id: 'idle', status: 'in-flight', workspace: ws }]))
596
- assert.deepEqual(Object.keys(act), ['u'], 'only units with a worker are probed')
597
- assert.equal(act.u, old.getTime(), 'the newer node_modules file is skipped')
598
- })
599
-
600
- test('--quiet-minutes is parsed and validated', () => {
601
- assert.equal(parseCliArgs(['--run', 'r', '--slots', '4', '--quiet-minutes', '30']).quietMinutes, 30)
602
- assert.throws(() => parseCliArgs(['--run', 'r', '--slots', '4', '--quiet-minutes', '0']), NextActionsError)
603
- })
604
-
605
- test('issue-tree changes come first and hold finish until they are applied', () => {
606
- const sync = { synced: true, source: 'live', actions: [{ action: 'add-unit', unit: '1343', detail: 'add unit 1343' }] }
607
- assert.deepEqual(kinds(nextActions(plan([{ id: 'b', status: 'pending' }]), {}, 4, { sync })), ['add-unit:1343', 'fill-slot:b'])
608
- assert.deepEqual(kinds(nextActions(plan([{ id: 'a', status: 'succeeded' }]), {}, 4, { sync })), ['add-unit:1343'], 'no finish while the tree has new work')
609
- })
610
-
611
- test('finish needs a fetch in the same call for a GitHub spec; a failed fetch never finishes', () => {
612
- const done = plan([{ id: 'a', status: 'succeeded' }])
613
- assert.deepEqual(kinds(nextActions(done, {}, 4, { sync: { synced: true, source: 'snapshot', actions: [] } })), ['sync:'])
614
- assert.deepEqual(kinds(nextActions(done, {}, 4, { sync: { synced: false, source: 'none', missingTree: true, actions: [] } })), ['sync:'])
615
- assert.deepEqual(kinds(nextActions(done, {}, 4, { sync: { synced: true, source: 'live', actions: [] } })), ['finish:'])
616
- const down = nextActions(done, {}, 4, { sync: { synced: true, source: 'snapshot', fetchError: [{ key: 'o/r#1', error: 'HTTP 502' }], actions: [] } })
617
- assert.deepEqual(kinds(down), ['sync:'], 'agentier #2366 finished twice on a failed GraphQL fetch')
618
- assert.match(down.due[0].detail, /could not be read \(HTTP 502\)/)
619
- assert.deepEqual(kinds(nextActions(done)), ['finish:'], 'a local spec has no tree to check')
620
- })
621
-
622
- test('CLI diffs the saved issue tree without a GitHub call', (t) => {
623
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'next-actions-tree-'))
624
- t.after(() => fs.rmSync(dir, { recursive: true, force: true }))
625
- const node = (n, extra = {}) => ({ key: `o/r#${n}`, repo: 'o/r', number: n, title: `t${n}`, state: 'open', labels: [], blockedBy: [], children: [], parent: 'o/r#1', ...extra })
626
- fs.writeFileSync(path.join(dir, 'issue-tree.json'), JSON.stringify({ root: 'o/r#1', complete: true, nodes: { 'o/r#1': node(1, { children: ['o/r#2', 'o/r#3'], parent: null }), 'o/r#2': node(2), 'o/r#3': node(3) } }))
627
- fs.writeFileSync(path.join(dir, 'task-plan.json'), JSON.stringify(plan([{ id: '2', status: 'succeeded' }], { spec: 'o/r#1' })))
628
- const env = { ...process.env, PATH: dir } // no gh on PATH: any GitHub call would fail
629
- const out = spawnSync(process.execPath, [SCRIPT, '--slots', '4', '--run', dir], { encoding: 'utf8', env })
630
- assert.equal(out.status, 0, out.stderr)
631
- assert.match(out.stdout, /^add-unit\s+add unit 3/m)
632
- assert.doesNotMatch(out.stdout, /^finish/m)
633
- assert.equal(parseCliArgs(['--run', dir, '--slots', '4', '--sync']).sync, true)
634
- })
635
-
636
- test('a rush run splits a size-held unit instead of finishing with it deferred', () => {
637
- // fmm #1343: rush re-blocked a unit held at 9,386 lines on the strength of a sentence an
638
- // earlier agent wrote into the issue, and the run finished with nothing split.
639
- const held = { id: '1343', status: 'blocked', blockedReason: 'size-hold', sizeCheck: { verdict: 'stop', estimatedLines: 7220 } }
640
- const done = { id: '1342', status: 'succeeded' }
641
- const rush = (units) => plan(units, { skill: 'rush-issues' })
642
- assert.deepEqual(kinds(nextActions(rush([held, done]))), ['split:1343'], 'no finish while a held unit waits')
643
- assert.deepEqual(kinds(nextActions(rush([held, done]), {}, 4, { resume: true })), ['split:1343'], 'split, not unblock and resume')
644
- assert.deepEqual(kinds(nextActions(rush([{ ...held, status: 'pending', sizeCheck: { scope: 'stop' } }]))), ['split:1343'], 'never started whole')
645
- // `hold` is only the run's verdict; a unit's own verdict is ok, warn, or stop.
646
- assert.deepEqual(kinds(nextActions(rush([{ ...held, status: 'pending', sizeCheck: { verdict: 'hold' } }]))), ['fill-slot:1343'], 'a runnable unit in a held run starts')
647
- assert.deepEqual(kinds(nextActions(rush([{ ...held, sizeCheck: { verdict: 'warn' } }, done]))), ['unblock:1343', 'resume:1343'], 'a cleared unit is not held')
648
- assert.deepEqual(kinds(nextActions(plan([held, done], { skill: 'resolve-issues' }))), ['finish:'], 'resolve-issues hands it back')
649
- const whole = { ...held, status: 'in-flight', sizeCheck: { verdict: 'stop', runWhole: true } }
650
- assert.deepEqual(kinds(nextActions(rush([whole]))), ['resume:1343'], 'no cut shrinks it: run it whole')
651
- })
652
-
653
- test('a human deferral that names no human-only item is the run\'s own and reopens (pi 01a147fb)', () => {
654
- // fmm-express #1747: the rush run deferred the unit as human-decision because the harness
655
- // refused its process_list tool, a refusal a shell or worker could route around, not item 5.
656
- const done = { id: 'd', status: 'succeeded' }
657
- const bare = { id: '1747', status: 'blocked', stage: 'implement', blockedReason: 'human-decision', note: 'process_list refused, no workaround' }
658
- const r = nextActions(plan([bare, done], { skill: 'rush-issues' }))
659
- assert.deepEqual(kinds(r), ['unblock:1747', 'resume:1747'], 'no finish around an unnamed deferral')
660
- assert.match(r.due[0].detail, /without naming its human-only item \(humanItem 1-5 and humanNeed/)
661
- for (const bad of [{ humanItem: 6, humanNeed: 'x' }, { humanItem: '5', humanNeed: 'x' }, { humanItem: 5 }, { humanItem: 5, humanNeed: ' ' }]) {
662
- assert.deepEqual(kinds(nextActions(plan([{ ...bare, ...bad }, done]))).slice(0, 1), ['unblock:1747'], JSON.stringify(bad))
663
- }
664
- const named = { ...bare, humanItem: 3, humanNeed: 'route *.blob.core.windows.net past the router proxy' }
665
- assert.deepEqual(kinds(nextActions(plan([named, done]))), ['finish:'], 'a named item stays deferred')
666
- })
667
-
668
- test('a rush split leaves alone what no split helps: a human gate, a running worker, finished code', () => {
669
- const excluded = [{ issue: 'o/r#1142', reason: 'signed release' }]
670
- const stop = { verdict: 'stop' }
671
- const units = [
672
- { id: 'gated', status: 'blocked', dependsOn: ['1142'], sizeCheck: stop },
673
- { id: 'busy', status: 'in-flight', worker: 1, workerKind: 'implement', sizeCheck: stop },
674
- { id: 'coded', status: 'blocked', stage: 'done', sizeCheck: stop, blockedReason: 'retry-ci' },
675
- { id: '1142', status: 'blocked', sizeCheck: stop, blockedReason: 'human-decision', humanItem: 1, humanNeed: 'sign the release' },
676
- ]
677
- assert.deepEqual(kinds(nextActions(plan(units, { skill: 'rush-issues', spec: 'o/r#1', excluded }))), [])
678
- })
679
-
680
- test('a HEAD pushed after a passing review is carried, not reviewed', () => {
681
- const unit = { id: 'a', status: 'in-flight', stage: 'done', pr: '#1', headSha: 'bbbbbbbb', reviewedHead: 'aaaaaaaa' }
682
- const passed = { round: 3, head: 'aaaaaaaa', fix: false, complete: true, blocking: false, awaiting: false }
683
- const r = nextActions(plan([unit]), { a: passed })
684
- assert.deepEqual(kinds(r), ['carry:a'])
685
- assert.match(r.due[0].detail, /review-verdict\.mjs --carry --review <run>\/reviews\/a-r4\.json --head bbbbbbbb --base /, 'nothing glued to the sha a caller copies')
686
- assert.equal(r.busy, 0)
687
- assert.deepEqual(kinds(nextActions(plan([unit]), { a: { ...passed, blocking: true } })), ['start-review:a'], 'a P0/P1 fix is reviewed')
688
- assert.deepEqual(kinds(nextActions(plan([unit]), { a: { ...passed, awaiting: true } })), ['start-review:a'])
689
- assert.deepEqual(kinds(nextActions(plan([unit]), { a: { ...passed, complete: false } })), ['start-review:a'])
690
- assert.deepEqual(kinds(nextActions(plan([{ ...unit, reviewedHead: undefined }]), { a: passed })), ['start-review:a'], 'no recorded pass, no carry')
691
- })
692
-
693
- test('GitHub feedback merged after a pass sends the HEAD to a fix, not to delivery', () => {
694
- const unit = { id: 'a', status: 'in-flight', stage: 'done', pr: '#1', headSha: 'aaaaaaaa', reviewedHead: 'aaaaaaaa' }
695
- const r = nextActions(plan([unit]), { a: { round: 5, head: 'aaaaaaaa', fix: true, complete: true, blocking: true, awaiting: false } })
696
- assert.deepEqual(kinds(r), ['start-fix:a'])
697
- assert.deepEqual(kinds(nextActions(plan([unit]), { a: { round: 5, head: 'aaaaaaaa', fix: false, complete: true, blocking: false } })), ['deliver:a'])
698
- })
699
-
700
- test('a unit runs only implement, fix, and review workers: any other kind, or none, is flagged first', () => {
701
- // fmm-express 1125 (2026-10-15): after a compaction dropped "no architecture or product
702
- // workers", the coordinator ran architects and a product manager on units, recorded as
703
- // workerRole "architect-provider". A missing workerKind is flagged too, or such a worker hides.
704
- const units = [
705
- { id: 'arch', status: 'in-flight', stage: 'validate', worker: 1, workerRole: 'architect-provider' },
706
- { id: 'pm', status: 'in-flight', stage: 'validate', worker: 2, workerKind: 'product-manager' },
707
- { id: 'ok', status: 'in-flight', stage: 'implement', worker: 3, workerKind: 'fix' },
708
- { id: 'n', status: 'pending' },
709
- ]
710
- const r = nextActions(plan(units))
711
- assert.deepEqual(kinds(r), ['worker-kind:arch', 'worker-kind:pm', 'fill-slot:n'])
712
- assert.match(r.due[0].detail, /has no workerKind: set it to implement, fix, or review/)
713
- assert.match(r.due[1].detail, /is a "product-manager": a unit runs only implement, fix, and review workers\. Stop it/)
714
- const clean = units.map((u) => (u.worker ? { ...u, workerKind: 'implement' } : u))
715
- assert.deepEqual(kinds(nextActions(plan(clean))), ['fill-slot:n'])
716
- })
717
-
718
- test('text output prints the long rules only with --resume or --rules, and otherwise a one-line pointer to the skill', () => {
719
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'na-rules-'))
720
- fs.writeFileSync(path.join(dir, 'task-plan.json'), JSON.stringify(plan([{ id: 'a', status: 'pending' }])))
721
- const call = (...extra) => spawnSync(process.execPath, [SCRIPT, '--slots', '4', '--run', dir, '--peek', ...extra], { encoding: 'utf8' })
722
- const lastOf = (out) => out.stdout.trim().split('\n').at(-1)
723
- const short = call()
724
- assert.equal(lastOf(short), `${'rules'.padEnd(15)}Rules: ${SKILL_PATH} (--rules prints them)`)
725
- assert.ok(!short.stdout.includes(RULES), 'the long RULES line is off by default')
726
- assert.equal(lastOf(call('--rules')), `${'rules'.padEnd(15)}${RULES} Full text: ${SKILL_PATH}`)
727
- assert.equal(lastOf(call('--resume')), `${'rules'.padEnd(15)}${RULES} Full text: ${SKILL_PATH}`)
728
- for (const mode of ['narrow', 'off']) {
729
- assert.ok(!lastOf(call('--mode', mode)).includes('The run ends only when'), `${mode}: short by default`)
730
- assert.match(lastOf(call('--mode', mode, '--rules')), /The run ends only when this prints "run done"/)
731
- }
732
- assert.ok(fs.existsSync(SKILL_PATH), 'the path points at the installed SKILL.md')
733
- const json = spawnSync(process.execPath, [SCRIPT, '--slots', '4', '--run', dir, '--json', '--peek'], { encoding: 'utf8' })
734
- assert.doesNotThrow(() => JSON.parse(json.stdout), 'JSON output stays parseable')
735
- })
736
-
737
- test('rush decides a worker question before resuming or deferring, and finish waits for it (agentier #2533)', () => {
738
- // The fix worker reported it needed a transport-boundary choice; the run resumed it twice with
739
- // the same scope and deferred it as timeout-task. The question now holds the unit until answered.
740
- const rush = (units, extra = {}) => plan(units, { skill: 'rush-issues', ...extra })
741
- const ask = { '2533': [{ key: 'results/2533-attempt2.json', ask: 'which boundary proves the upload body completed' }] }
742
- const live = { id: '2533', status: 'in-flight', stage: 'implement', workspace: '/w/2533' }
743
- const r = nextActions(rush([live]), {}, 4, { questions: ask })
744
- assert.deepEqual(kinds(r), ['decide:2533'], 'no resume while the question is open')
745
- assert.match(r.due[0].detail, /automatic answer.*answers "results\/2533-attempt2\.json" and source agent-recommendation/)
746
- // Deferring without answering is not settled: finish waits.
747
- const deferred = { ...live, status: 'blocked', blockedReason: 'timeout-task' }
748
- const d = nextActions(rush([deferred]), {}, 4, { questions: ask })
749
- assert.deepEqual(kinds(d), ['decide:2533'])
750
- assert.match(d.due[0].detail, /set it in-flight/)
751
- // Once a decision answers it, the unit's normal steps return: resume, or a settled deferral.
752
- const answered = { decisions: [{ id: 'd-9', answers: 'results/2533-attempt2.json' }] }
753
- assert.deepEqual(kinds(nextActions(rush([live], answered), {}, 4, { questions: ask })), ['resume:2533'])
754
- // An answered timeout-task block gets its fresh attempt with the decision, instead of ending the run.
755
- assert.deepEqual(kinds(nextActions(rush([deferred], answered), {}, 4, { questions: ask })), ['retry-blocked:2533'])
756
- // A busy unit's question waits for its worker.
757
- assert.deepEqual(kinds(nextActions(rush([{ ...live, worker: 1, workerKind: 'fix' }]), {}, 4, { questions: ask })), [])
758
- })
759
-
760
- test('resolve-issues never blocks on the human: it takes the automatic answer, even for a unit already blocked on the question', () => {
761
- // rush-3097: the run stopped to ask which merge path to take; the stop hook had to push it back.
762
- const ask = { a: [{ key: 'results/a-1.json', ask: 'keep the old endpoint?' }] }
763
- const live = { id: 'a', status: 'in-flight', stage: 'implement' }
764
- const r = nextActions(plan([live]), {}, 4, { questions: ask })
765
- assert.deepEqual(kinds(r), ['decide:a'])
766
- assert.match(r.due[0].detail, /Take the automatic answer \(preflight\.md#never-block-on-the-human\)/)
767
- assert.doesNotMatch(r.due[0].detail, /Ask the human/)
768
- const waiting = nextActions(plan([{ ...live, status: 'blocked', blockedReason: 'human-decision' }]), {}, 4, { questions: ask })
769
- assert.deepEqual(kinds(waiting), ['decide:a'], 'a unit blocked waiting for a reply is answered, not left waiting')
770
- const deferred = plan([{ ...live, status: 'blocked', blockedReason: 'human-decision', humanItem: 2, humanNeed: 'approve dropping the required check' }], { decisions: [{ id: 'd1', answers: 'results/a-1.json' }] })
771
- assert.deepEqual(kinds(nextActions(deferred, {}, 4, { questions: ask })), ['finish:'], 'once a decision defers it, the run finishes around it')
772
- })
773
-
774
- test('a review decide file at HEAD is decided, then review-verdict reruns with --decision (agentier #2521)', () => {
775
- const unit = { id: '2521', status: 'in-flight', stage: 'done', pr: '#2541', headSha: 'a00ccf9' }
776
- const decide = { '2521': { round: 2, head: 'a00ccf9', fix: false, complete: true, awaiting: false, blocking: true, decide: true, ask: 'R1: mixed selectors bypass refusal' } }
777
- const rush = (extra = {}) => plan([unit], { skill: 'rush-issues', ...extra })
778
- const r = nextActions(rush(), decide)
779
- assert.deepEqual(kinds(r), ['decide:2521'])
780
- assert.match(r.due[0].detail, /reviews\/2521-r2\.decide\.json: R1: mixed selectors bypass refusal/)
781
- const after = nextActions(rush({ decisions: [{ id: 'd-3', answers: ['reviews/2521-r2.decide.json'] }] }), decide)
782
- assert.deepEqual(kinds(after), ['review-verdict:2521'])
783
- assert.match(after.due[0].detail, /with --decision d-3/)
784
- // A deferred decide unit on --resume is decided, not reviewed again as a handback.
785
- const blocked = { ...unit, status: 'blocked', blockedReason: 'review-handback' }
786
- assert.deepEqual(kinds(nextActions(plan([blocked], { skill: 'rush-issues' }), decide, 4, { resume: true })), ['decide:2521', 'unblock:2521'])
787
- // A decide file for an older HEAD asks nothing.
788
- assert.deepEqual(kinds(nextActions(rush(), { '2521': { ...decide['2521'], head: 'old' } })), ['start-review:2521'])
789
- })
790
-
791
- test('CLI reads worker questions from results and decide files from reviews', (t) => {
792
- const run = fs.mkdtempSync(path.join(os.tmpdir(), 'next-actions-decide-'))
793
- t.after(() => fs.rmSync(run, { recursive: true, force: true }))
794
- fs.mkdirSync(path.join(run, 'results'))
795
- fs.mkdirSync(path.join(run, 'reviews'))
796
- fs.writeFileSync(path.join(run, 'results', '7-attempt1.json'), JSON.stringify({ decisionNeeded: { question: 'retry or rewrite?', recommendation: 'rewrite' } }))
797
- fs.writeFileSync(path.join(run, 'results', 'x-done.json'), JSON.stringify({ unit: '8', decisionNeeded: null }))
798
- fs.writeFileSync(path.join(run, 'results', 'named.json'), JSON.stringify({ unit: '9', decisionNeeded: 'which schema?' }))
799
- fs.writeFileSync(path.join(run, 'results', 'broken.json'), '{')
800
- assert.deepEqual(readQuestions(run), { 7: [{ key: 'results/7-attempt1.json', ask: 'retry or rewrite?' }], 9: [{ key: 'results/named.json', ask: 'which schema?' }] })
801
- fs.writeFileSync(path.join(run, 'reviews', '7-r2.json'), JSON.stringify({ head: 'h', complete: true, findings: [{ severity: 'P1' }] }))
802
- fs.writeFileSync(path.join(run, 'reviews', '7-r2.decide.json'), JSON.stringify({ question: [{ id: 'R1', summary: 'void the receipt too?' }] }))
803
- const { at: _at, ...seven } = readReviews(run)['7']
804
- assert.deepEqual(seven, { round: 2, head: 'h', fix: false, complete: true, awaiting: false, blocking: true, decide: true, ask: 'R1: void the receipt too?' })
805
- fs.writeFileSync(path.join(run, 'task-plan.json'), JSON.stringify(plan([{ id: '7', status: 'in-flight', stage: 'implement' }], { skill: 'rush-issues' })))
806
- const out = spawnSync(process.execPath, [SCRIPT, '--slots', '4', '--run', run, '--peek', '--rules'], { encoding: 'utf8' })
807
- assert.equal(out.status, 0, out.stderr)
808
- assert.match(out.stdout, /^decide +unit 7 needs a decision \(results\/7-attempt1\.json: retry or rewrite\?\)/m)
809
- assert.match(out.stdout, /Decide before you defer/)
810
- })
811
-
812
- test('a unit still proving itself with commits gets its draft PR before it resumes', () => {
813
- // #1688 ran 11 hours of emulator work on 28 local commits: the PR waited for code done.
814
- const units = [{ id: '1688', status: 'in-flight', stage: 'validate', headSha: 'abc123' }]
815
- assert.deepEqual(kinds(nextActions(plan(units))), ['open-pr:1688', 'resume:1688'])
816
- const open = [{ id: '1688', status: 'in-flight', stage: 'validate', headSha: 'abc123', pr: 'https://github.com/o/r/pull/1' }]
817
- assert.deepEqual(kinds(nextActions(plan(open))), ['resume:1688'])
818
- const fresh = [{ id: '1688', status: 'in-flight', stage: 'implement' }]
819
- assert.deepEqual(kinds(nextActions(plan(fresh))), ['resume:1688'])
820
- })
821
-
822
- test('a block that can clear by itself gets automatic attempts at growing gaps before the run finishes', () => {
823
- // skyee #3179/#3180 and agentier #2509 printed finish on offline runners and a GitHub TLS fault;
824
- // the human had to type "try again" each time.
825
- const at = Date.parse('2026-10-17T04:00:00Z')
826
- const units = [{ id: '3187', status: 'blocked', blockedReason: 'retry-ci', blockedAt: '2026-10-17T04:00:00Z' }, { id: '3186', status: 'succeeded' }]
827
- const early = nextActions(plan(units), {}, 4, { now: at + 10 * 60_000 })
828
- assert.deepEqual(kinds(early), ['wait:3187'])
829
- assert.match(early.due[0].detail, /2026-10-17T04:30:00\.000Z.*do not finish/)
830
- assert.deepEqual(kinds(nextActions(plan(units), {}, 4, { now: at + 31 * 60_000 })), ['retry-blocked:3187'])
831
- const second = [{ ...units[0], autoRetries: 1 }, units[1]]
832
- assert.deepEqual(kinds(nextActions(plan(second), {}, 4, { now: at + 60 * 60_000 })), ['wait:3187'], 'the second gap is two hours')
833
- const spent = [{ ...units[0], autoRetries: 3 }, units[1]]
834
- assert.deepEqual(kinds(nextActions(plan(spent), {}, 4, { now: at + 24 * 3600_000 })), ['finish:'], 'after three attempts the block is settled')
835
- const human = [{ id: '2493', status: 'blocked', blockedReason: 'human-decision', humanItem: 1, humanNeed: 'product owner resolves the runtime limit' }, units[1]]
836
- assert.deepEqual(kinds(nextActions(plan(human), {}, 4, { now: at })), ['finish:'], 'a human-only block never retries by itself')
837
- })
838
-
839
- test('an outage blocker the run cannot fix is rechecked over time, so its dependents ship once it clears', () => {
840
- // skyee #3201: every shared runner went offline; the run deferred and printed finish, and the
841
- // runners were back hours later. The blocker unit now rechecks instead of ending the run.
842
- const at = Date.parse('2026-10-16T16:00:00Z')
843
- const units = [
844
- { id: '3201', status: 'blocked', blockedReason: 'external-blocker', blockedAt: '2026-10-16T16:00:00Z' },
845
- { id: '3179', status: 'pending', dependsOn: ['3201'] },
846
- { id: '3118', status: 'succeeded' },
847
- ]
848
- assert.deepEqual(kinds(nextActions(plan(units), {}, 4, { now: at + 5 * 60_000 })), ['wait:3201'])
849
- const due = nextActions(plan(units), {}, 4, { now: at + 40 * 60_000 })
850
- assert.deepEqual(kinds(due), ['retry-blocked:3201'])
851
- assert.match(due.due[0].detail, /rerun the command its issue records.*close the blocker issue and mark 3201 succeeded so the units behind it continue/)
852
- const cleared = [{ ...units[0], status: 'succeeded' }, units[1], units[2]]
853
- assert.deepEqual(kinds(nextActions(plan(cleared), {}, 4, { now: at + 41 * 60_000 })), ['fill-slot:3179'])
854
- })
855
-
856
- test('the narrow view says whether the run is done and which units have work, not each step', async () => {
857
- const { narrowView } = await import('./next-actions.mjs')
858
- const units = [{ id: '1688', status: 'in-flight', stage: 'validate', headSha: 'abc' }, { id: '1677', status: 'succeeded' }]
859
- const view = narrowView(plan(units), nextActions(plan(units)))
860
- assert.match(view, /run not done: units have work/)
861
- assert.match(view, /units with work: 1688 \(choose each unit's next step yourself\)/)
862
- assert.doesNotMatch(view, /open-pr|resume/)
863
- const done = [{ id: '1677', status: 'succeeded' }]
864
- assert.match(narrowView(plan(done), nextActions(plan(done))), /run done/)
865
- })
866
-
867
- test('the scheduler mode sets how much the text output prescribes; the run plan picks the default', () => {
868
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'na-mode-'))
869
- const write = (extra) => fs.writeFileSync(path.join(dir, 'task-plan.json'), JSON.stringify(plan([{ id: '1688', status: 'in-flight', stage: 'validate', headSha: 'abc' }], extra)))
870
- const run = (...args) => spawnSync(process.execPath, [SCRIPT, '--run', dir, '--slots', '2', '--peek', ...args], { encoding: 'utf8' }).stdout
871
- write({})
872
- assert.match(run(), /^open-pr\s+open unit 1688's PR as a draft now/m, 'full is the default')
873
- assert.match(run('--mode', 'narrow'), /run not done: units have work\nunits with work: 1688/)
874
- const off = run('--mode', 'off')
875
- assert.match(off, /^run not done\n/)
876
- assert.doesNotMatch(off, /open-pr|1688/)
877
- write({ schedulerMode: 'narrow' })
878
- assert.match(run(), /units with work: 1688/, 'the plan\'s schedulerMode is the default')
879
- assert.equal(JSON.parse(run('--json')).mode, 'narrow')
880
- const bad = spawnSync(process.execPath, [SCRIPT, '--run', dir, '--slots', '2', '--peek', '--mode', 'loose'], { encoding: 'utf8' })
881
- assert.match(bad.stderr, /scheduler mode must be full, narrow, off/)
882
- })
883
-
884
- test('the early draft PR opens while the worker still runs, but never behind a parent that will not land', () => {
885
- const working = [{ id: '1688', status: 'in-flight', stage: 'implement', headSha: 'abc1234', worker: 4, workerKind: 'implement' }]
886
- assert.deepEqual(kinds(nextActions(plan(working))), ['open-pr:1688'])
887
- const behind = [{ id: 'p', status: 'blocked', blockedReason: 'human-decision', humanItem: 3, humanNeed: 'run the live migration' },
888
- { id: 'c', status: 'in-flight', stage: 'implement', headSha: 'abc1234', dependsOn: ['p'], worker: 2, workerKind: 'implement' }]
889
- assert.deepEqual(kinds(nextActions(plan(behind))).filter((k) => k.startsWith('open-pr')), [])
890
- })
891
-
892
- test('only blocks that can clear by waiting are retried over time; a due retry prints while other units work', () => {
893
- const at = Date.parse('2026-10-17T04:00:00Z')
894
- const stuck = [{ id: 'a', status: 'blocked', blockedReason: 'retry-implement', blockedAt: '2026-10-17T04:00:00Z' }, { id: 'b', status: 'succeeded' }]
895
- assert.deepEqual(kinds(nextActions(plan(stuck), {}, 4, { now: at + 9 * 3600_000 })), ['finish:'], 'retry-implement settles: waiting does not fix it')
896
- const busy = [{ id: 'ci', status: 'blocked', blockedReason: 'retry-ci', blockedAt: '2026-10-17T04:00:00Z' },
897
- { id: 'w', status: 'in-flight', stage: 'implement', worker: 1, workerKind: 'implement' }]
898
- assert.deepEqual(kinds(nextActions(plan(busy), {}, 4, { now: at + 31 * 60_000 })), ['retry-blocked:ci'])
899
- const older = [{ id: 'ci', status: 'blocked', blockedReason: 'retry-ci' }, { id: 'w', status: 'in-flight', stage: 'implement', worker: 1, workerKind: 'implement' }]
900
- assert.deepEqual(kinds(nextActions(plan(older), {}, 4, { now: at })), [], 'an untimed block waits until nothing else is left')
901
- assert.deepEqual(kinds(nextActions(plan([older[0], { id: 'w', status: 'succeeded' }]), {}, 4, { now: at })), ['retry-blocked:ci'], 'then it gets one fresh attempt instead of finish')
902
- })
903
-
904
- test('finish and wait exit 0 like any other answer; --exit-due makes any due action exit 1', (t) => {
905
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'na-finish-'))
906
- t.after(() => fs.rmSync(dir, { recursive: true, force: true }))
907
- fs.writeFileSync(path.join(dir, 'task-plan.json'), JSON.stringify(plan([{ id: 'a', status: 'succeeded', stage: 'done' }])))
908
- const call = (...extra) => spawnSync(process.execPath, [SCRIPT, '--slots', '4', '--run', dir, '--peek', ...extra], { encoding: 'utf8' })
909
- const done = call('--json')
910
- assert.equal(done.status, 0, done.stderr)
911
- const out = JSON.parse(done.stdout)
912
- assert.deepEqual(out.due.map((a) => a.action), ['finish'])
913
- assert.equal(out.exitDue, true)
914
- assert.equal(call('--exit-due').status, 1)
915
- })