issue-flow 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/README.md +1 -1
  2. package/dist/{analyze-7B5TORNK.js → analyze-OEET7KGE.js} +17 -11
  3. package/dist/analyze-OEET7KGE.js.map +1 -0
  4. package/dist/{apply-WUJHDD7A.js → apply-ZIA3C52S.js} +6 -4
  5. package/dist/{apply-WUJHDD7A.js.map → apply-ZIA3C52S.js.map} +1 -1
  6. package/dist/{chunk-6N7LZZ5U.js → chunk-3QHK4YQA.js} +204 -107
  7. package/dist/chunk-3QHK4YQA.js.map +1 -0
  8. package/dist/chunk-42PJFMAR.js +605 -0
  9. package/dist/chunk-42PJFMAR.js.map +1 -0
  10. package/dist/{chunk-PL6CNMVS.js → chunk-4JSOYE4G.js} +105 -19
  11. package/dist/chunk-4JSOYE4G.js.map +1 -0
  12. package/dist/chunk-56KS3TFQ.js +703 -0
  13. package/dist/chunk-56KS3TFQ.js.map +1 -0
  14. package/dist/{chunk-AAY6KM7N.js → chunk-73ATPIYX.js} +160 -154
  15. package/dist/chunk-73ATPIYX.js.map +1 -0
  16. package/dist/{chunk-MKD2D7WE.js → chunk-A4SLHOW7.js} +70 -13
  17. package/dist/chunk-A4SLHOW7.js.map +1 -0
  18. package/dist/chunk-BAUPQXPE.js +173 -0
  19. package/dist/chunk-BAUPQXPE.js.map +1 -0
  20. package/dist/{chunk-HC2MT7BX.js → chunk-BYZDPR7N.js} +14 -581
  21. package/dist/chunk-BYZDPR7N.js.map +1 -0
  22. package/dist/chunk-CD5ULRVC.js +615 -0
  23. package/dist/chunk-CD5ULRVC.js.map +1 -0
  24. package/dist/chunk-DIDUVL6O.js +1365 -0
  25. package/dist/chunk-DIDUVL6O.js.map +1 -0
  26. package/dist/{chunk-47VIN33V.js → chunk-EZATO3DW.js} +7 -5
  27. package/dist/{chunk-47VIN33V.js.map → chunk-EZATO3DW.js.map} +1 -1
  28. package/dist/{chunk-JW7WLSZ6.js → chunk-FADTCQGM.js} +48 -12
  29. package/dist/chunk-FADTCQGM.js.map +1 -0
  30. package/dist/{chunk-GANSDJV7.js → chunk-FJP3UQGD.js} +15 -13
  31. package/dist/chunk-FJP3UQGD.js.map +1 -0
  32. package/dist/chunk-KMMASOA6.js +134 -0
  33. package/dist/chunk-KMMASOA6.js.map +1 -0
  34. package/dist/{chunk-HQYXVIH6.js → chunk-LAFNLLQP.js} +3 -3
  35. package/dist/chunk-LWRSMTTZ.js +18 -0
  36. package/dist/chunk-LWRSMTTZ.js.map +1 -0
  37. package/dist/{chunk-MSPUB635.js → chunk-M5UCXTIT.js} +15 -12
  38. package/dist/chunk-M5UCXTIT.js.map +1 -0
  39. package/dist/chunk-M66SJHQQ.js +206 -0
  40. package/dist/chunk-M66SJHQQ.js.map +1 -0
  41. package/dist/{chunk-OBLVLIUS.js → chunk-MJULIKQU.js} +42 -6
  42. package/dist/chunk-MJULIKQU.js.map +1 -0
  43. package/dist/{chunk-RKTEV6EG.js → chunk-NS6R37II.js} +4 -4
  44. package/dist/{run-H75MD5BQ.js → chunk-NTMMF5UD.js} +515 -544
  45. package/dist/chunk-NTMMF5UD.js.map +1 -0
  46. package/dist/{chunk-UBIQXCVE.js → chunk-QCPGHBSZ.js} +16 -13
  47. package/dist/chunk-QCPGHBSZ.js.map +1 -0
  48. package/dist/{chunk-LVKQ2AKT.js → chunk-TSOLWK7Y.js} +8 -6
  49. package/dist/chunk-TSOLWK7Y.js.map +1 -0
  50. package/dist/{chunk-VCKX262Z.js → chunk-XQUSIEKA.js} +13 -10
  51. package/dist/chunk-XQUSIEKA.js.map +1 -0
  52. package/dist/chunk-Y5ZDJ4PJ.js +76 -0
  53. package/dist/chunk-Y5ZDJ4PJ.js.map +1 -0
  54. package/dist/cli.js +156 -26
  55. package/dist/cli.js.map +1 -1
  56. package/dist/{config-4THHOXNN.js → config-PRJGWW7J.js} +15 -3
  57. package/dist/execute-XBNVL753.js +18 -0
  58. package/dist/{generate-6RA2MFJB.js → generate-EGB4KV2B.js} +18 -11
  59. package/dist/generate-EGB4KV2B.js.map +1 -0
  60. package/dist/init-AJHCXJBP.js +10 -0
  61. package/dist/operations-BYT626KQ.js +365 -0
  62. package/dist/operations-BYT626KQ.js.map +1 -0
  63. package/dist/plan-QOZ3RIO6.js +21 -0
  64. package/dist/{policy-XQW5NUIE.js → policy-6S4GFC76.js} +6 -4
  65. package/dist/{policy-XQW5NUIE.js.map → policy-6S4GFC76.js.map} +1 -1
  66. package/dist/pr-EBBHD2ZX.js +26 -0
  67. package/dist/pr-review-VVMW5PO4.js +20 -0
  68. package/dist/prd-JQSZF7PR.js +21 -0
  69. package/dist/resume-PPCNQVVV.js +221 -0
  70. package/dist/resume-PPCNQVVV.js.map +1 -0
  71. package/dist/review-3F6MARWH.js +19 -0
  72. package/dist/run-RXE5GDQO.js +36 -0
  73. package/dist/run-RXE5GDQO.js.map +1 -0
  74. package/dist/state-manager-2EDVDBAR.js +34 -0
  75. package/dist/state-manager-2EDVDBAR.js.map +1 -0
  76. package/dist/{web-ANQQTTRD.js → web-EF5WIIGG.js} +13 -8
  77. package/dist/{web-ANQQTTRD.js.map → web-EF5WIIGG.js.map} +1 -1
  78. package/package.json +1 -1
  79. package/prompts/pr.md +11 -4
  80. package/web/public/app.js +12 -0
  81. package/dist/analyze-7B5TORNK.js.map +0 -1
  82. package/dist/chunk-3BOHTH4V.js +0 -58
  83. package/dist/chunk-3BOHTH4V.js.map +0 -1
  84. package/dist/chunk-6N7LZZ5U.js.map +0 -1
  85. package/dist/chunk-AAY6KM7N.js.map +0 -1
  86. package/dist/chunk-GANSDJV7.js.map +0 -1
  87. package/dist/chunk-HC2MT7BX.js.map +0 -1
  88. package/dist/chunk-HDV3YDTG.js +0 -52
  89. package/dist/chunk-HDV3YDTG.js.map +0 -1
  90. package/dist/chunk-JW7WLSZ6.js.map +0 -1
  91. package/dist/chunk-LVKQ2AKT.js.map +0 -1
  92. package/dist/chunk-MKD2D7WE.js.map +0 -1
  93. package/dist/chunk-MSPUB635.js.map +0 -1
  94. package/dist/chunk-OBLVLIUS.js.map +0 -1
  95. package/dist/chunk-PL6CNMVS.js.map +0 -1
  96. package/dist/chunk-UBIQXCVE.js.map +0 -1
  97. package/dist/chunk-VCKX262Z.js.map +0 -1
  98. package/dist/chunk-VQ5VN7DF.js +0 -1018
  99. package/dist/chunk-VQ5VN7DF.js.map +0 -1
  100. package/dist/execute-5OEKLM5X.js +0 -15
  101. package/dist/generate-6RA2MFJB.js.map +0 -1
  102. package/dist/init-PRMOTQ7N.js +0 -9
  103. package/dist/plan-KENR6MJ3.js +0 -17
  104. package/dist/pr-KDGKG4BP.js +0 -21
  105. package/dist/pr-review-CAMAB7KO.js +0 -17
  106. package/dist/prd-V3MWDIHK.js +0 -17
  107. package/dist/review-7LN47SAN.js +0 -15
  108. package/dist/run-H75MD5BQ.js.map +0 -1
  109. /package/dist/{chunk-HQYXVIH6.js.map → chunk-LAFNLLQP.js.map} +0 -0
  110. /package/dist/{chunk-RKTEV6EG.js.map → chunk-NS6R37II.js.map} +0 -0
  111. /package/dist/{config-4THHOXNN.js.map → config-PRJGWW7J.js.map} +0 -0
  112. /package/dist/{execute-5OEKLM5X.js.map → execute-XBNVL753.js.map} +0 -0
  113. /package/dist/{init-PRMOTQ7N.js.map → init-AJHCXJBP.js.map} +0 -0
  114. /package/dist/{plan-KENR6MJ3.js.map → plan-QOZ3RIO6.js.map} +0 -0
  115. /package/dist/{pr-KDGKG4BP.js.map → pr-EBBHD2ZX.js.map} +0 -0
  116. /package/dist/{pr-review-CAMAB7KO.js.map → pr-review-VVMW5PO4.js.map} +0 -0
  117. /package/dist/{prd-V3MWDIHK.js.map → prd-JQSZF7PR.js.map} +0 -0
  118. /package/dist/{review-7LN47SAN.js.map → review-3F6MARWH.js.map} +0 -0
@@ -1,579 +1,285 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ ensureWebMonitor
4
+ } from "./chunk-FJP3UQGD.js";
5
+ import {
6
+ runPr
7
+ } from "./chunk-FADTCQGM.js";
2
8
  import {
3
9
  mostRecent,
4
10
  prReviewDir,
5
11
  readPrReviewIndex,
6
12
  runPrReview
7
- } from "./chunk-MKD2D7WE.js";
13
+ } from "./chunk-A4SLHOW7.js";
8
14
  import {
9
15
  runPrd
10
- } from "./chunk-MSPUB635.js";
16
+ } from "./chunk-M5UCXTIT.js";
11
17
  import {
12
18
  runReview
13
- } from "./chunk-VCKX262Z.js";
19
+ } from "./chunk-XQUSIEKA.js";
14
20
  import {
15
- ensureWebMonitor
16
- } from "./chunk-GANSDJV7.js";
21
+ JournalPublisher,
22
+ MultiPublisher,
23
+ PIPELINE_PHASES,
24
+ PIPELINE_PHASES_NO_BRANCH,
25
+ PIPELINE_PHASES_WITH_PR_REVIEW,
26
+ PipelineManager,
27
+ buildDependencyGraph,
28
+ buildExecutionPlan,
29
+ computeExecutionOrder,
30
+ describeCycles,
31
+ isQueueComplete,
32
+ loadExecutionPlan,
33
+ markQueueIssueBlocked,
34
+ markQueueIssueCompleted,
35
+ markQueueIssueFailed,
36
+ markQueueIssueInProgress,
37
+ markQueueIssueSkipped,
38
+ nextQueueIssue,
39
+ parseJournal,
40
+ saveExecutionPlan,
41
+ setQueueBranch,
42
+ setQueuePullRequest
43
+ } from "./chunk-56KS3TFQ.js";
44
+ import {
45
+ acquireRunLock,
46
+ describeRunLockOwner
47
+ } from "./chunk-BAUPQXPE.js";
17
48
  import {
18
49
  runInit
19
- } from "./chunk-RKTEV6EG.js";
50
+ } from "./chunk-NS6R37II.js";
20
51
  import {
21
52
  printQueueSummary,
22
53
  printRunSummary,
23
54
  runExecute
24
- } from "./chunk-6N7LZZ5U.js";
55
+ } from "./chunk-3QHK4YQA.js";
25
56
  import {
26
57
  listPullRequests,
27
58
  publishGitState
28
- } from "./chunk-LVKQ2AKT.js";
59
+ } from "./chunk-TSOLWK7Y.js";
29
60
  import {
30
61
  runPlan
31
- } from "./chunk-UBIQXCVE.js";
32
- import {
33
- runPr
34
- } from "./chunk-JW7WLSZ6.js";
35
- import "./chunk-3BOHTH4V.js";
62
+ } from "./chunk-QCPGHBSZ.js";
36
63
  import {
37
- emptyRelations,
38
64
  getProvider,
39
- resolveCommandIssue,
40
- uniqueIds
41
- } from "./chunk-AAY6KM7N.js";
42
- import "./chunk-PL6CNMVS.js";
43
- import "./chunk-HDV3YDTG.js";
65
+ resolveCommandIssue
66
+ } from "./chunk-73ATPIYX.js";
44
67
  import {
45
68
  beginUsageScope,
46
69
  getRunUsageTotals
47
- } from "./chunk-OBLVLIUS.js";
48
- import "./chunk-HQYXVIH6.js";
49
- import "./chunk-47VIN33V.js";
70
+ } from "./chunk-MJULIKQU.js";
71
+ import {
72
+ onShutdown
73
+ } from "./chunk-M66SJHQQ.js";
50
74
  import {
51
- executionPlanSchema,
75
+ getActiveResilienceConfig,
76
+ initResilienceConfig,
52
77
  loadIssuesConfig,
53
- loadWebConfig,
78
+ loadWebConfig
79
+ } from "./chunk-42PJFMAR.js";
80
+ import {
81
+ committedStoryIds,
82
+ describePreflight,
83
+ getBaseBranch,
84
+ preflightRepository,
54
85
  resolveIssuePaths,
55
86
  resolveProjectPaths,
56
- resolveQueuePaths
57
- } from "./chunk-VQ5VN7DF.js";
87
+ resolveQueuePaths,
88
+ run
89
+ } from "./chunk-DIDUVL6O.js";
58
90
  import {
59
91
  FilePublisher,
60
92
  NullPublisher,
93
+ getInactivityTimeout,
61
94
  isVerbose,
62
- isoNow,
63
- loadTaskPlan,
64
95
  printError,
65
96
  printInfo,
66
97
  printSuccess,
67
98
  printWarning,
68
- saveTaskPlan,
99
+ setInactivityTimeout,
69
100
  setOutputCallback,
70
101
  setSessionPublisher,
71
102
  setStoryStageCallback,
72
- setStoryUpdateCallback,
73
- writeFileAtomic
74
- } from "./chunk-HC2MT7BX.js";
103
+ setStoryUpdateCallback
104
+ } from "./chunk-BYZDPR7N.js";
105
+ import {
106
+ isoNow,
107
+ loadTaskPlan,
108
+ saveTaskPlan
109
+ } from "./chunk-CD5ULRVC.js";
75
110
 
76
111
  // src/commands/run.ts
77
112
  import { randomUUID } from "crypto";
78
- import { mkdir as mkdir2 } from "fs/promises";
113
+ import { mkdir, readFile, writeFile } from "fs/promises";
79
114
  import { join } from "path";
80
115
  import { execa } from "execa";
81
116
 
82
- // src/core/pipeline.ts
83
- var PIPELINE_PHASES = ["init", "prd", "plan", "execute", "review", "pr"];
84
- var PIPELINE_PHASES_WITH_PR_REVIEW = [...PIPELINE_PHASES, "pr-review"];
85
- var PIPELINE_PHASES_NO_BRANCH = PIPELINE_PHASES.filter(
86
- (p) => p !== "pr"
87
- );
88
- var PHASE_TO_FIELD = {
89
- init: null,
90
- prd: "prdCompleted",
91
- plan: "jsonCompleted",
92
- execute: "executionCompleted",
93
- review: "reviewCompleted",
94
- pr: "prCreated",
95
- "pr-review": "prReviewCompleted"
117
+ // src/core/decompose.ts
118
+ var DECOMPOSITION_THRESHOLDS = {
119
+ /** Timeouts on the same phase. Two is already a pattern, not bad luck. */
120
+ timeoutsPerPhase: 2,
121
+ /** Stories in the plan. Past this, the plan is a backlog. */
122
+ stories: 15,
123
+ /** Iterations in a row that completed no story. */
124
+ barrenIterations: 5,
125
+ /** Files touched on the branch. */
126
+ filesTouched: 40,
127
+ /** Characters in the issue body. */
128
+ issueBodyChars: 2e4
96
129
  };
97
- var PipelineManager = class {
98
- tasksJsonPath;
99
- plan;
100
- activePhases;
101
- constructor(plan, tasksJsonPath, activePhases) {
102
- this.plan = plan;
103
- this.tasksJsonPath = tasksJsonPath;
104
- this.activePhases = activePhases ?? PIPELINE_PHASES;
105
- }
106
- /**
107
- * Reload state from disk.
108
- */
109
- async reload() {
110
- this.plan = await loadTaskPlan(this.tasksJsonPath);
111
- }
112
- /**
113
- * Check if a phase is complete.
114
- */
115
- isPhaseComplete(phase) {
116
- const field = PHASE_TO_FIELD[phase];
117
- if (field === null) return true;
118
- return this.plan.pipeline?.[field] ?? false;
119
- }
120
- /**
121
- * Get the first incomplete phase in the pipeline.
122
- * Returns null if all phases are complete.
123
- */
124
- getNextPhase() {
125
- for (const phase of this.activePhases) {
126
- if (!this.isPhaseComplete(phase)) {
127
- return phase;
128
- }
130
+ var DECOMPOSITION_MIN_SIGNALS = 2;
131
+ function timeoutsByPhase(journal) {
132
+ const counts = /* @__PURE__ */ new Map();
133
+ let phase = "(unknown)";
134
+ for (const { event } of parseJournal(journal)) {
135
+ if (event.type === "phase:start") {
136
+ phase = event.phase;
137
+ continue;
129
138
  }
130
- return null;
131
- }
132
- /**
133
- * Get the current phase (alias for getNextPhase).
134
- */
135
- getCurrentPhase() {
136
- return this.getNextPhase();
137
- }
138
- /**
139
- * Check whether we can resume from a specific phase.
140
- * All prerequisite phases must be complete.
141
- */
142
- canResume(fromPhase) {
143
- const idx = this.activePhases.indexOf(fromPhase);
144
- if (idx < 0) return false;
145
- for (let i = 0; i < idx; i++) {
146
- if (!this.isPhaseComplete(this.activePhases[i])) {
147
- return false;
148
- }
149
- }
150
- return true;
151
- }
152
- /**
153
- * Mark a phase as complete and persist to disk.
154
- */
155
- async markPhaseComplete(phase) {
156
- const field = PHASE_TO_FIELD[phase];
157
- if (field === null) return;
158
- this.plan = {
159
- ...this.plan,
160
- pipeline: {
161
- ...this.plan.pipeline ?? {
162
- prdCompleted: false,
163
- jsonCompleted: false,
164
- executionCompleted: false,
165
- reviewCompleted: false,
166
- prCreated: false
167
- },
168
- [field]: true
169
- }
170
- };
171
- await saveTaskPlan(this.tasksJsonPath, this.plan);
172
- }
173
- };
174
-
175
- // src/execution/plan.ts
176
- import { mkdir, readFile } from "fs/promises";
177
- import { dirname } from "path";
178
- import { ZodError } from "zod";
179
-
180
- // src/issues/graph.ts
181
- var DEFAULT_MAX_NODES = 25;
182
- var DEFAULT_MAX_DEPTH = 3;
183
- function dependencyEdges(graph) {
184
- const edges = [];
185
- const seen = /* @__PURE__ */ new Set();
186
- const add = (from, to) => {
187
- if (from === to || !graph.nodes.has(from) || !graph.nodes.has(to)) return;
188
- const key = `${from}->${to}`;
189
- if (seen.has(key)) return;
190
- seen.add(key);
191
- edges.push({ from, to });
192
- };
193
- for (const node of graph.nodes.values()) {
194
- for (const blocker of node.relations.blockedBy) {
195
- add(blocker, node.id);
196
- }
197
- for (const blocked of node.relations.blocking) {
198
- add(node.id, blocked);
139
+ if (event.type !== "retry") continue;
140
+ const kind = event.kind;
141
+ const reason = event.reason ?? "";
142
+ const timedOut = kind === "timeout" || kind === "stalled" || /timed out|stalled/i.test(reason);
143
+ if (!timedOut) continue;
144
+ counts.set(phase, (counts.get(phase) ?? 0) + 1);
145
+ }
146
+ return counts;
147
+ }
148
+ function longestBarrenRun(journal) {
149
+ let passing = 0;
150
+ let current = 0;
151
+ let longest = 0;
152
+ for (const { event } of parseJournal(journal)) {
153
+ if (event.type === "iteration:start") {
154
+ current++;
155
+ longest = Math.max(longest, current);
156
+ continue;
199
157
  }
158
+ if (event.type !== "stories:update") continue;
159
+ const nowPassing = event.stories.filter((story) => story.passes).length;
160
+ if (nowPassing > passing) current = 0;
161
+ passing = Math.max(passing, nowPassing);
200
162
  }
201
- return edges;
163
+ return longest;
202
164
  }
203
- function findCycles(graph) {
204
- const adjacency = /* @__PURE__ */ new Map();
205
- for (const { from, to } of dependencyEdges(graph)) {
206
- const list = adjacency.get(from);
207
- if (list === void 0) {
208
- adjacency.set(from, [to]);
209
- } else {
210
- list.push(to);
165
+ function assessDecomposition(input) {
166
+ const signals = [];
167
+ const journal = input.journal ?? "";
168
+ for (const [phase, count] of timeoutsByPhase(journal)) {
169
+ if (count >= DECOMPOSITION_THRESHOLDS.timeoutsPerPhase) {
170
+ signals.push({
171
+ id: "repeated-timeouts",
172
+ detail: `The '${phase}' phase timed out ${count} times (threshold: ${DECOMPOSITION_THRESHOLDS.timeoutsPerPhase}).`
173
+ });
211
174
  }
212
175
  }
213
- const cycles = [];
214
- const reported = /* @__PURE__ */ new Set();
215
- const state = /* @__PURE__ */ new Map();
216
- const stack = [];
217
- const record = (cycle) => {
218
- let pivot = 0;
219
- for (let i = 1; i < cycle.length; i++) {
220
- if (compareIds(cycle[i], cycle[pivot]) < 0) pivot = i;
221
- }
222
- const normalized = [...cycle.slice(pivot), ...cycle.slice(0, pivot)];
223
- const key = normalized.join("->");
224
- if (reported.has(key)) return;
225
- reported.add(key);
226
- cycles.push(normalized);
227
- };
228
- const visit = (id) => {
229
- state.set(id, "visiting");
230
- stack.push(id);
231
- for (const next of adjacency.get(id) ?? []) {
232
- const status = state.get(next);
233
- if (status === "visiting") {
234
- record(stack.slice(stack.indexOf(next)));
235
- continue;
236
- }
237
- if (status === void 0) {
238
- visit(next);
239
- }
240
- }
241
- stack.pop();
242
- state.set(id, "done");
243
- };
244
- for (const id of graph.nodes.keys()) {
245
- if (!state.has(id)) visit(id);
176
+ const stories = input.plan?.userStories.length ?? 0;
177
+ if (stories > DECOMPOSITION_THRESHOLDS.stories) {
178
+ signals.push({
179
+ id: "story-count",
180
+ detail: `The plan has ${stories} user stories (threshold: ${DECOMPOSITION_THRESHOLDS.stories}).`
181
+ });
246
182
  }
247
- return cycles;
248
- }
249
- function compareIds(a, b) {
250
- const na = Number(a);
251
- const nb = Number(b);
252
- if (Number.isInteger(na) && Number.isInteger(nb)) {
253
- return na - nb;
183
+ const barren = longestBarrenRun(journal);
184
+ if (barren >= DECOMPOSITION_THRESHOLDS.barrenIterations) {
185
+ signals.push({
186
+ id: "barren-iterations",
187
+ detail: `${barren} iterations in a row completed no story (threshold: ${DECOMPOSITION_THRESHOLDS.barrenIterations}).`
188
+ });
254
189
  }
255
- return a < b ? -1 : a > b ? 1 : 0;
256
- }
257
- function neighbours(relations) {
258
- return uniqueIds(
259
- [
260
- ...relations.parent === null ? [] : [relations.parent],
261
- ...relations.children,
262
- ...relations.blockedBy,
263
- ...relations.blocking
264
- ],
265
- relations.id
266
- );
267
- }
268
- async function buildDependencyGraph(roots, fetchRelations, options = {}) {
269
- const maxNodes = options.maxNodes ?? DEFAULT_MAX_NODES;
270
- const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
271
- const warn = options.warn ?? (() => {
272
- });
273
- const known = /* @__PURE__ */ new Map();
274
- for (const issue of options.known ?? []) {
275
- known.set(issue.id, issue);
276
- }
277
- const rootIds = uniqueIds(roots);
278
- const nodes = /* @__PURE__ */ new Map();
279
- const queued = new Set(rootIds);
280
- let truncated = false;
281
- let frontier = rootIds.map((id) => ({ id, depth: 0 }));
282
- while (frontier.length > 0) {
283
- const next = [];
284
- for (const { id, depth } of frontier) {
285
- if (nodes.size >= maxNodes) {
286
- truncated = true;
287
- break;
288
- }
289
- let relations = null;
290
- try {
291
- relations = await fetchRelations(id);
292
- } catch (err) {
293
- warn(
294
- `Could not read the relations of issue '${id}': ${err instanceof Error ? err.message : String(err)}`
295
- );
296
- }
297
- let issue = known.get(id) ?? null;
298
- if (issue === null && options.fetchIssue !== void 0) {
299
- try {
300
- issue = await options.fetchIssue(id);
301
- } catch (err) {
302
- warn(`Could not read issue '${id}': ${err instanceof Error ? err.message : String(err)}`);
303
- }
304
- }
305
- const resolved = relations ?? emptyRelations(id);
306
- nodes.set(id, { id, relations: resolved, depth, issue, root: rootIds.includes(id) });
307
- if (depth >= maxDepth) {
308
- if (neighbours(resolved).some((neighbour) => !queued.has(neighbour))) {
309
- truncated = true;
310
- }
311
- continue;
312
- }
313
- for (const neighbour of neighbours(resolved)) {
314
- if (queued.has(neighbour)) continue;
315
- queued.add(neighbour);
316
- next.push({ id: neighbour, depth: depth + 1 });
317
- }
318
- }
319
- if (nodes.size >= maxNodes && next.length > 0) {
320
- truncated = true;
321
- break;
322
- }
323
- frontier = next;
324
- }
325
- const graph = {
326
- roots: rootIds,
327
- nodes,
328
- cycles: [],
329
- truncated,
330
- limits: { maxNodes, maxDepth }
331
- };
332
- linkReferencedBy(graph);
333
- graph.cycles = findCycles(graph);
334
- return graph;
335
- }
336
- function linkReferencedBy(graph) {
337
- for (const node of graph.nodes.values()) {
338
- for (const cited of node.relations.references) {
339
- const target = graph.nodes.get(cited);
340
- if (target === void 0 || target.id === node.id) continue;
341
- target.relations.referencedBy = uniqueIds(
342
- [...target.relations.referencedBy, node.id],
343
- target.id
344
- );
345
- }
190
+ const files = input.filesTouched ?? 0;
191
+ if (files > DECOMPOSITION_THRESHOLDS.filesTouched) {
192
+ signals.push({
193
+ id: "files-touched",
194
+ detail: `${files} files were touched on this branch (threshold: ${DECOMPOSITION_THRESHOLDS.filesTouched}).`
195
+ });
346
196
  }
347
- }
348
-
349
- // src/execution/order.ts
350
- var PRIORITY_LABELS = {
351
- high: "high",
352
- medium: "medium",
353
- low: "low"
354
- };
355
- var PRIORITY_RANK = { high: 0, medium: 1, low: 2 };
356
- var NO_PRIORITY_RANK = 3;
357
- function priorityOf(labels) {
358
- for (const label of labels) {
359
- const normalized = label.trim().toLowerCase();
360
- const direct = PRIORITY_LABELS[normalized];
361
- if (direct !== void 0) return direct;
362
- const qualified = /^(?:priority|prio|p)\s*[:/-]\s*(high|medium|low)$/.exec(normalized);
363
- const matched = qualified?.[1];
364
- if (matched !== void 0) return PRIORITY_LABELS[matched];
365
- }
366
- return null;
367
- }
368
- function hierarchyDepth(id, parentOf, selected) {
369
- let depth = 0;
370
- let current = parentOf.get(id) ?? null;
371
- const seen = /* @__PURE__ */ new Set([id]);
372
- while (current !== null && selected.has(current) && !seen.has(current)) {
373
- depth++;
374
- seen.add(current);
375
- current = parentOf.get(current) ?? null;
376
- }
377
- return depth;
378
- }
379
- function computeExecutionOrder(graph, options = {}) {
380
- const selected = new Set(
381
- (options.include ?? [...graph.nodes.keys()]).filter((id) => graph.nodes.has(id))
382
- );
383
- const parentOf = /* @__PURE__ */ new Map();
384
- const priority = /* @__PURE__ */ new Map();
385
- for (const id of selected) {
386
- const node = graph.nodes.get(id);
387
- parentOf.set(id, node?.relations.parent ?? null);
388
- priority.set(id, priorityOf(node?.issue?.labels ?? []));
389
- }
390
- const incoming = /* @__PURE__ */ new Map();
391
- const outgoing = /* @__PURE__ */ new Map();
392
- for (const id of selected) {
393
- incoming.set(id, /* @__PURE__ */ new Set());
394
- outgoing.set(id, []);
395
- }
396
- for (const { from, to } of dependencyEdges(graph)) {
397
- if (!selected.has(from) || !selected.has(to) || from === to) continue;
398
- incoming.get(to)?.add(from);
399
- outgoing.get(from)?.push(to);
400
- }
401
- const depth = /* @__PURE__ */ new Map();
402
- for (const id of selected) {
403
- depth.set(id, hierarchyDepth(id, parentOf, selected));
404
- }
405
- const rank = (id) => {
406
- const value = priority.get(id) ?? null;
407
- return value === null ? NO_PRIORITY_RANK : PRIORITY_RANK[value];
408
- };
409
- const better = (a, b) => {
410
- const byHierarchy = (depth.get(a) ?? 0) - (depth.get(b) ?? 0);
411
- if (byHierarchy !== 0) return byHierarchy;
412
- const byPriority = rank(a) - rank(b);
413
- if (byPriority !== 0) return byPriority;
414
- return compareIds(a, b);
415
- };
416
- const ready = [...selected].filter((id) => (incoming.get(id)?.size ?? 0) === 0);
417
- const order = [];
418
- while (ready.length > 0) {
419
- ready.sort(better);
420
- const next = ready.shift();
421
- order.push(next);
422
- for (const dependent of outgoing.get(next) ?? []) {
423
- const pending = incoming.get(dependent);
424
- if (pending === void 0) continue;
425
- pending.delete(next);
426
- if (pending.size === 0) {
427
- ready.push(dependent);
428
- }
429
- }
197
+ const body = input.issueBody?.length ?? 0;
198
+ if (body > DECOMPOSITION_THRESHOLDS.issueBodyChars) {
199
+ signals.push({
200
+ id: "issue-size",
201
+ detail: `The issue body is ${body} characters (threshold: ${DECOMPOSITION_THRESHOLDS.issueBodyChars}).`
202
+ });
430
203
  }
431
- if (order.length !== selected.size) {
432
- const cycles = graph.cycles.length > 0 ? graph.cycles : findCycles(graph);
433
- const relevant = cycles.filter((cycle) => cycle.every((id) => selected.has(id)));
434
- return { ok: false, cycles: relevant.length > 0 ? relevant : cycles };
204
+ if (input.hitMaxIterations === true) {
205
+ signals.push({
206
+ id: "max-iterations",
207
+ detail: "The execute loop stopped because it ran out of iterations, not because it finished."
208
+ });
435
209
  }
436
- return { ok: true, order };
437
- }
438
- function describeCycles(cycles) {
439
- return cycles.map((cycle) => [...cycle, cycle[0]].map((id) => `#${id}`).join(" \u2192 ")).join("; ");
210
+ return { signals, oversized: signals.length >= DECOMPOSITION_MIN_SIGNALS };
440
211
  }
441
-
442
- // src/execution/plan.ts
443
- function describeIssue(issue, id) {
444
- const parsed = Number.parseInt(id, 10);
445
- return {
446
- number: issue?.number ?? (Number.isNaN(parsed) ? null : parsed),
447
- title: issue?.title ?? "",
448
- url: issue?.remoteRef ?? null,
449
- source: issue?.source ?? "github"
450
- };
451
- }
452
- function buildExecutionPlan(input) {
453
- const now = input.now ?? isoNow;
454
- const at = now();
455
- const scheduled = new Set(input.order);
456
- const requested = new Set(input.requested);
457
- const issues = input.order.map((id, index) => {
458
- const node = input.graph.nodes.get(id);
459
- const relations = node?.relations;
460
- const described = describeIssue(node?.issue ?? null, id);
461
- return {
462
- id,
463
- number: described.number,
464
- title: described.title,
465
- url: described.url,
466
- source: described.source,
467
- position: index + 1,
468
- status: "pending",
469
- origin: requested.has(id) ? "requested" : "discovered",
470
- // Only the constraints that survive inside the queue: a blocker left out
471
- // of it is reported in `excluded`, not enforced as a dependency.
472
- dependsOn: (relations?.blockedBy ?? []).filter((blocker) => scheduled.has(blocker)),
473
- parent: relations?.parent ?? null,
474
- priority: priorityOf(node?.issue?.labels ?? []),
475
- heuristic: (relations?.heuristic.length ?? 0) > 0,
476
- failedPhase: null,
477
- lastError: null,
478
- startedAt: null,
479
- completedAt: null
480
- };
481
- });
482
- const excluded = [];
483
- for (const node of input.graph.nodes.values()) {
484
- if (scheduled.has(node.id)) continue;
485
- const described = describeIssue(node.issue, node.id);
486
- excluded.push({
487
- id: node.id,
488
- number: described.number,
489
- title: described.title,
490
- url: described.url,
491
- reason: "Discovered in the hierarchy but not selected for this run"
212
+ var STORIES_PER_SUBISSUE = 5;
213
+ function proposeSubIssues(plan) {
214
+ const pending = [...plan?.userStories ?? []].filter((story) => !story.passes).sort((a, b) => a.priority - b.priority);
215
+ if (pending.length === 0) return [];
216
+ const chunks = [];
217
+ for (let index = 0; index < pending.length; index += STORIES_PER_SUBISSUE) {
218
+ const stories = pending.slice(index, index + STORIES_PER_SUBISSUE);
219
+ const first = stories[0];
220
+ const last = stories[stories.length - 1];
221
+ const range = first === void 0 || last === void 0 || first.id === last.id ? first?.id ?? "" : `${first.id}\u2013${last.id}`;
222
+ chunks.push({
223
+ title: `${plan?.description ?? "Issue"} \u2014 part ${chunks.length + 1} (${range})`,
224
+ stories,
225
+ dependsOn: chunks.length === 0 ? [] : [chunks[chunks.length - 1]?.title ?? ""]
492
226
  });
493
227
  }
494
- return {
495
- schemaVersion: 1,
496
- id: input.requested[0] ?? input.order[0],
497
- project: input.projectId,
498
- requested: [...input.requested],
499
- branchName: null,
500
- noBranch: input.noBranch ?? false,
501
- prReview: input.prReview ?? false,
502
- status: "pending",
503
- createdAt: at,
504
- updatedAt: at,
505
- truncated: input.graph.truncated,
506
- issues,
507
- excluded
508
- };
228
+ return chunks;
509
229
  }
510
- async function loadExecutionPlan(path) {
511
- const raw = JSON.parse(await readFile(path, "utf-8"));
512
- try {
513
- return executionPlanSchema.parse(raw);
514
- } catch (err) {
515
- if (err instanceof ZodError) {
516
- const issues = err.issues.map((i) => ` - ${i.path.join(".")}: ${i.message}`).join("\n");
517
- throw new Error(`Invalid execution-plan.json at ${path}:
518
- ${issues}`);
519
- }
520
- throw err;
230
+ function buildDecompositionReport(input) {
231
+ const { issueNumber, assessment, plan, at } = input;
232
+ const proposals = proposeSubIssues(plan);
233
+ const lines = [];
234
+ lines.push(`# Issue #${issueNumber} looks larger than one run`, "");
235
+ lines.push(`Detected at ${at}.`, "");
236
+ lines.push(
237
+ "This is a report, not an action. Nothing has been split, and nothing will be",
238
+ "without you asking for it.",
239
+ ""
240
+ );
241
+ lines.push("## What was detected", "");
242
+ for (const signal of assessment.signals) {
243
+ lines.push(`- **${signal.id}** \u2014 ${signal.detail}`);
521
244
  }
522
- }
523
- async function saveExecutionPlan(path, plan, now = isoNow) {
524
- await mkdir(dirname(path), { recursive: true });
525
- const stamped = { ...plan, updatedAt: now() };
526
- await writeFileAtomic(path, `${JSON.stringify(stamped, null, 2)}
527
- `);
528
- }
529
- function nextQueueIssue(plan) {
530
- return plan.issues.find((entry) => entry.status === "in_progress") ?? plan.issues.find((entry) => entry.status === "failed") ?? plan.issues.find((entry) => entry.status === "pending") ?? null;
531
- }
532
- function queueStatus(plan) {
533
- if (plan.issues.some((entry) => entry.status === "failed")) return "failed";
534
- if (plan.issues.every((entry) => entry.status === "completed")) return "completed";
535
- if (plan.issues.some((entry) => entry.status !== "pending")) return "in_progress";
536
- return "pending";
537
- }
538
- function isQueueComplete(plan) {
539
- return plan.issues.every((entry) => entry.status === "completed");
540
- }
541
- function updateIssue(plan, id, patch) {
542
- const issues = plan.issues.map((entry) => entry.id === id ? { ...entry, ...patch } : entry);
543
- const next = { ...plan, issues };
544
- return { ...next, status: queueStatus(next) };
545
- }
546
- function markQueueIssueInProgress(plan, id, now = isoNow) {
547
- const current = plan.issues.find((entry) => entry.id === id);
548
- return updateIssue(plan, id, {
549
- status: "in_progress",
550
- // Preserved across a resume: the queue reports when the work on this Issue
551
- // really began, not when it was last picked up.
552
- startedAt: current?.startedAt ?? now(),
553
- failedPhase: null,
554
- lastError: null
555
- });
556
- }
557
- function markQueueIssueCompleted(plan, id, now = isoNow) {
558
- return updateIssue(plan, id, {
559
- status: "completed",
560
- completedAt: now(),
561
- failedPhase: null,
562
- lastError: null
563
- });
564
- }
565
- function markQueueIssueFailed(plan, id, failure2) {
566
- return updateIssue(plan, id, {
567
- status: "failed",
568
- failedPhase: failure2.phase,
569
- lastError: failure2.error
570
- });
571
- }
572
- function setQueueBranch(plan, branchName) {
573
- return { ...plan, branchName };
574
- }
575
- function setQueuePullRequest(plan, pullRequest) {
576
- return { ...plan, pullRequest };
245
+ lines.push("");
246
+ if (proposals.length === 0) {
247
+ lines.push("## Proposed split", "");
248
+ lines.push(
249
+ "No pending user stories were found, so there is nothing to cut here.",
250
+ "The signals above are about the run rather than about the plan.",
251
+ ""
252
+ );
253
+ return `${lines.join("\n")}
254
+ `;
255
+ }
256
+ lines.push("## Proposed split", "");
257
+ lines.push(
258
+ `The ${proposals.reduce((total, proposal) => total + proposal.stories.length, 0)} pending stories, in priority order, cut into ${proposals.length} sub-issues of at most ${STORIES_PER_SUBISSUE}. Each depends on the one before it, which is the only`,
259
+ "dependency shape that can be derived from the plan alone \u2014 read it as a starting",
260
+ "point, not as an answer.",
261
+ ""
262
+ );
263
+ for (const proposal of proposals) {
264
+ lines.push(`### ${proposal.title}`, "");
265
+ if (proposal.dependsOn.length > 0) {
266
+ lines.push(`Depends on: ${proposal.dependsOn.join(", ")}`, "");
267
+ }
268
+ for (const story of proposal.stories) {
269
+ lines.push(`- \`${story.id}\` ${story.title}`);
270
+ }
271
+ lines.push("");
272
+ }
273
+ lines.push("## What to do next", "");
274
+ lines.push(
275
+ "- Adjust the cut above until it matches how you would actually split the work;",
276
+ '- create the sub-issues (`issue-flow generate "\u2026"`) and run them as a queue;',
277
+ "- or decide the issue is fine as it is and re-run it \u2014 nothing here blocks that,",
278
+ " beyond the report you are reading.",
279
+ ""
280
+ );
281
+ return `${lines.join("\n")}
282
+ `;
577
283
  }
578
284
 
579
285
  // src/execution/confirm.ts
@@ -1162,6 +868,7 @@ async function readPrReviewOutcome(issue, tasksPath, requestedChanges) {
1162
868
  }
1163
869
  return outcome;
1164
870
  }
871
+ var DEFAULT_MAX_ISSUE_ATTEMPTS = 2;
1165
872
  async function runPipeline(issue, mode, from, noBranch, prReview, options = {}) {
1166
873
  let requested;
1167
874
  try {
@@ -1170,34 +877,102 @@ async function runPipeline(issue, mode, from, noBranch, prReview, options = {})
1170
877
  printError(err instanceof Error ? err.message : String(err));
1171
878
  return 1;
1172
879
  }
1173
- const first = await runIssueSession(requested[0], mode, {
1174
- from,
1175
- noBranch,
1176
- prReview,
1177
- requested,
1178
- runOptions: options
1179
- });
1180
- if (first.queue === void 0) {
1181
- return first.code;
880
+ const ownership = await claimRunOwnership(requested[0]);
881
+ if (!ownership.ok) {
882
+ printError(
883
+ `Another issue-flow run owns this project: ${describeRunLockOwner(ownership.owner)}.`
884
+ );
885
+ printInfo("Wait for it to finish, or stop that process before running again.");
886
+ return 1;
1182
887
  }
1183
- return runQueue(
1184
- first.queue.plan,
1185
- { mode, from, noBranch, prReview, runOptions: options },
1186
- first.queue.resolved
1187
- );
888
+ try {
889
+ const first = await runIssueSession(requested[0], mode, {
890
+ from,
891
+ noBranch,
892
+ prReview,
893
+ requested,
894
+ runOptions: options,
895
+ ...ownership.interruptedBy === null ? {} : { interruptedBy: ownership.interruptedBy }
896
+ });
897
+ if (first.queue === void 0) {
898
+ return first.code;
899
+ }
900
+ return runQueue(
901
+ first.queue.plan,
902
+ { mode, from, noBranch, prReview, runOptions: options },
903
+ first.queue.resolved
904
+ );
905
+ } finally {
906
+ await ownership.release();
907
+ }
908
+ }
909
+ var WRITING_PHASES = /* @__PURE__ */ new Set(["plan", "execute", "pr"]);
910
+ async function ensureRepositoryWritable(phase) {
911
+ if (!WRITING_PHASES.has(phase)) return;
912
+ const preflight = await preflightRepository({ intent: "resume-same-phase" });
913
+ if (preflight.ok) return;
914
+ for (const line of describePreflight(preflight)) {
915
+ printError(line);
916
+ }
917
+ throw new Error(`The repository is not in a state the ${phase} phase can write to`);
918
+ }
919
+ async function claimRunOwnership(target) {
920
+ let lockFile;
921
+ try {
922
+ lockFile = (await resolveProjectPaths()).runLockFile;
923
+ } catch {
924
+ return { ok: true, interruptedBy: null, release: async () => {
925
+ } };
926
+ }
927
+ const result = await acquireRunLock(lockFile, { target });
928
+ if (!result.ok) return result;
929
+ return {
930
+ ok: true,
931
+ interruptedBy: result.handle.reclaimedFrom,
932
+ release: () => result.handle.release()
933
+ };
1188
934
  }
1189
935
  async function runIssueSession(issueNumber, mode, input) {
1190
936
  const paths = await resolveIssuePaths(issueNumber);
937
+ const resilience = await initResilienceConfig();
938
+ const configuredInactivity = resilience.watchdog?.inactivityTimeoutMs;
939
+ if (configuredInactivity !== void 0 && getInactivityTimeout() === void 0) {
940
+ setInactivityTimeout(configuredInactivity);
941
+ }
1191
942
  const webConfig = await loadWebConfig();
1192
- let publisher = new NullPublisher();
943
+ const surfaces = [];
944
+ const journalEnabled = resilience.journal?.enabled === true;
945
+ if (webConfig.enabled || journalEnabled) {
946
+ await mkdir(paths.issueDir, { recursive: true });
947
+ }
1193
948
  if (webConfig.enabled) {
1194
- await mkdir2(paths.issueDir, { recursive: true });
1195
- publisher = new FilePublisher(paths.sessionFile, {
1196
- logLimit: webConfig.logLimit,
1197
- includeLogs: webConfig.includeLogs
1198
- });
949
+ surfaces.push(
950
+ new FilePublisher(paths.sessionFile, {
951
+ logLimit: webConfig.logLimit,
952
+ includeLogs: webConfig.includeLogs
953
+ })
954
+ );
955
+ }
956
+ if (journalEnabled) {
957
+ surfaces.push(
958
+ new JournalPublisher(paths.eventsFile, paths.rotatedEventsFile, {
959
+ logLimit: webConfig.logLimit,
960
+ includeLogs: webConfig.includeLogs,
961
+ ...resilience.journal?.maxFileBytes === void 0 ? {} : { maxFileBytes: resilience.journal.maxFileBytes }
962
+ })
963
+ );
1199
964
  }
965
+ const publisher = surfaces.length === 0 ? new NullPublisher() : surfaces.length === 1 ? surfaces[0] : new MultiPublisher(surfaces);
1200
966
  setSessionPublisher(publisher);
967
+ if (input.interruptedBy !== void 0) {
968
+ const previous = input.interruptedBy;
969
+ publisher.publish({
970
+ type: "log",
971
+ at: isoNow(),
972
+ level: "warn",
973
+ message: `Previous run interrupted: ${describeRunLockOwner(previous)}. Its lock was stale and has been taken over.`
974
+ });
975
+ }
1201
976
  if (webConfig.enabled) {
1202
977
  await ensureWebMonitor({
1203
978
  publisher,
@@ -1213,10 +988,34 @@ async function runIssueSession(issueNumber, mode, input) {
1213
988
  storyCount: 0,
1214
989
  elapsedSeconds: 0
1215
990
  };
991
+ const releaseCheckpoint = onShutdown({
992
+ phase: "checkpoint",
993
+ run: async () => {
994
+ await pauseIssue(paths.tasksFile, issueNumber);
995
+ publisher.publish({
996
+ type: "log",
997
+ at: isoNow(),
998
+ level: "warn",
999
+ message: `Interrupted during issue #${issueNumber}. A checkpoint was saved; resume with \`issue-flow resume ${issueNumber}\`.`
1000
+ });
1001
+ publisher.publish({ type: "session:end", at: isoNow(), status: "failed" });
1002
+ }
1003
+ });
1004
+ const releaseClose = onShutdown({
1005
+ phase: "close",
1006
+ run: async () => {
1007
+ await publisher.close();
1008
+ }
1009
+ });
1216
1010
  try {
1217
1011
  result = await runPipelinePhases(issueNumber, paths, mode, publisher, input);
1012
+ if (result.code !== 0) {
1013
+ await reportIfOversized(issueNumber, paths, result);
1014
+ }
1218
1015
  return result;
1219
1016
  } finally {
1017
+ releaseCheckpoint();
1018
+ releaseClose();
1220
1019
  if (result.queue === void 0) {
1221
1020
  publisher.publish({
1222
1021
  type: "session:end",
@@ -1228,10 +1027,127 @@ async function runIssueSession(issueNumber, mode, input) {
1228
1027
  setSessionPublisher(void 0);
1229
1028
  }
1230
1029
  }
1030
+ async function reportIfOversized(issueNumber, paths, result) {
1031
+ try {
1032
+ const journal = `${await readFileIfPresent(paths.rotatedEventsFile)}${await readFileIfPresent(paths.eventsFile)}`;
1033
+ const plan = await loadTaskPlan(paths.tasksFile).catch(() => null);
1034
+ const assessment = assessDecomposition({
1035
+ journal,
1036
+ plan,
1037
+ filesTouched: await countChangedFiles(),
1038
+ hitMaxIterations: result.failedPhase === "execute" && plan?.issueStatus !== "completed"
1039
+ });
1040
+ if (!assessment.oversized) return;
1041
+ const report = buildDecompositionReport({
1042
+ issueNumber,
1043
+ assessment,
1044
+ plan,
1045
+ at: isoNow()
1046
+ });
1047
+ const reportFile = paths.decompositionFile;
1048
+ await mkdir(paths.issueDir, { recursive: true });
1049
+ await writeFile(reportFile, report, "utf-8");
1050
+ printWarning(`Issue #${issueNumber} looks larger than one run. Report: ${reportFile}`);
1051
+ for (const signal of assessment.signals) {
1052
+ printWarning(` ${signal.detail}`);
1053
+ }
1054
+ await maybeCreateSubIssues(issueNumber, plan, reportFile);
1055
+ if (plan !== null) {
1056
+ await saveTaskPlan(paths.tasksFile, {
1057
+ ...plan,
1058
+ runState: {
1059
+ currentPhase: plan.runState?.currentPhase ?? result.failedPhase,
1060
+ attempt: plan.runState?.attempt ?? 0,
1061
+ owner: null,
1062
+ status: "blocked",
1063
+ blockedReason: `Looks larger than one run; see ${reportFile}`,
1064
+ lastHeartbeatAt: isoNow()
1065
+ }
1066
+ });
1067
+ }
1068
+ } catch {
1069
+ }
1070
+ }
1071
+ async function maybeCreateSubIssues(issueNumber, plan, reportFile) {
1072
+ if (getActiveResilienceConfig().decompose?.auto !== true) {
1073
+ printInfo(`Nothing was split. Read ${reportFile} and decide.`);
1074
+ return;
1075
+ }
1076
+ const committed = await committedStoryIds(await getBaseBranch()).catch(() => /* @__PURE__ */ new Set());
1077
+ if (committed.size > 0) {
1078
+ printWarning(
1079
+ `--auto-decompose did not run: ${committed.size} story(ies) are already committed on this branch. Splitting on top of committed work needs a person.`
1080
+ );
1081
+ return;
1082
+ }
1083
+ const proposals = proposeSubIssues(plan);
1084
+ if (proposals.length === 0) {
1085
+ printInfo("Nothing left to split: no pending story.");
1086
+ return;
1087
+ }
1088
+ const { runGenerate } = await import("./generate-EGB4KV2B.js");
1089
+ for (const proposal of proposals) {
1090
+ const instruction = [
1091
+ `Create a sub-issue of #${issueNumber}: ${proposal.title}.`,
1092
+ proposal.dependsOn.length === 0 ? "" : `It depends on: ${proposal.dependsOn.join(", ")}.`,
1093
+ "It covers exactly these user stories, and nothing else:",
1094
+ ...proposal.stories.map((story) => `- ${story.id} ${story.title}: ${story.description}`)
1095
+ ].filter((line) => line !== "").join("\n");
1096
+ const code = await runGenerate(instruction);
1097
+ if (code !== 0) {
1098
+ printWarning(
1099
+ `Could not create "${proposal.title}". The report at ${reportFile} still stands.`
1100
+ );
1101
+ return;
1102
+ }
1103
+ }
1104
+ }
1105
+ async function countChangedFiles() {
1106
+ try {
1107
+ const base = await getBaseBranch();
1108
+ const result = await run("git", ["diff", "--name-only", `${base}...HEAD`]);
1109
+ if (result.exitCode !== 0) return 0;
1110
+ return result.stdout.split("\n").filter((line) => line.trim() !== "").length;
1111
+ } catch {
1112
+ return 0;
1113
+ }
1114
+ }
1115
+ async function readFileIfPresent(path) {
1116
+ try {
1117
+ return await readFile(path, "utf-8");
1118
+ } catch {
1119
+ return "";
1120
+ }
1121
+ }
1122
+ async function pauseIssue(tasksFile, issueNumber) {
1123
+ try {
1124
+ const plan = await loadTaskPlan(tasksFile);
1125
+ await saveTaskPlan(tasksFile, {
1126
+ ...plan,
1127
+ runState: {
1128
+ ...plan.runState ?? {
1129
+ currentPhase: null,
1130
+ attempt: 0,
1131
+ lastHeartbeatAt: null,
1132
+ blockedReason: null,
1133
+ owner: null
1134
+ },
1135
+ status: "paused",
1136
+ lastHeartbeatAt: isoNow()
1137
+ }
1138
+ });
1139
+ } catch {
1140
+ printWarning(`Could not write a checkpoint for issue #${issueNumber}.`);
1141
+ }
1142
+ }
1231
1143
  async function runPipelinePhases(issueNumber, paths, mode, publisher, input) {
1232
1144
  const { from, noBranch, prReview, queue } = input;
1233
1145
  const continueNumbering = input.runOptions?.continueNumbering;
1234
1146
  const startUs = input.runOptions?.startUs;
1147
+ const executeRetry = {
1148
+ retryLimit: input.runOptions?.retryLimit,
1149
+ retryForever: input.runOptions?.retryForever
1150
+ };
1235
1151
  const tasksPath = paths.tasksFile;
1236
1152
  const sessionId = randomUUID();
1237
1153
  let publishedIssueNumber = toIssueNumber(issueNumber);
@@ -1399,6 +1315,7 @@ async function runPipelinePhases(issueNumber, paths, mode, publisher, input) {
1399
1315
  }
1400
1316
  const startIdx = Math.max(phaseOrder.indexOf(startPhase), 0);
1401
1317
  const makeRunner = (fn, phase) => async () => {
1318
+ await ensureRepositoryWritable(phase);
1402
1319
  const code = await fn();
1403
1320
  if (code !== 0) {
1404
1321
  throw new Error(`Phase ${phase} failed with exit code ${code}`);
@@ -1429,7 +1346,11 @@ async function runPipelinePhases(issueNumber, paths, mode, publisher, input) {
1429
1346
  }
1430
1347
  },
1431
1348
  execute: makeRunner(
1432
- () => runExecute(void 0, { issue: issueNumber, commitScope: queueCommitScope }),
1349
+ () => runExecute(void 0, {
1350
+ issue: issueNumber,
1351
+ commitScope: queueCommitScope,
1352
+ ...executeRetry
1353
+ }),
1433
1354
  "execute"
1434
1355
  ),
1435
1356
  review: async () => {
@@ -1453,7 +1374,8 @@ async function runPipelinePhases(issueNumber, paths, mode, publisher, input) {
1453
1374
  }
1454
1375
  const execCode = await runExecute(void 0, {
1455
1376
  issue: issueNumber,
1456
- commitScope: queueCommitScope
1377
+ commitScope: queueCommitScope,
1378
+ ...executeRetry
1457
1379
  });
1458
1380
  if (execCode !== 0) {
1459
1381
  throw new Error("Correction execution failed");
@@ -1640,9 +1562,14 @@ async function runQueue(initialPlan, options, resolvedPrimary) {
1640
1562
  const startedAtMs = Date.now();
1641
1563
  const summaries = [];
1642
1564
  let firstOfThisRun = true;
1565
+ const resilience = getActiveResilienceConfig();
1566
+ const failureMode = options.runOptions?.onIssueFailure ?? resilience.queue?.onIssueFailure ?? "stop";
1567
+ const maxIssueAttempts = resilience.queue?.maxIssueAttempts ?? DEFAULT_MAX_ISSUE_ATTEMPTS;
1568
+ const exhausted = /* @__PURE__ */ new Set();
1569
+ const failedIds = /* @__PURE__ */ new Set();
1643
1570
  try {
1644
1571
  while (true) {
1645
- const entry = nextQueueIssue(plan);
1572
+ const entry = nextQueueIssue(plan, { exclude: exhausted });
1646
1573
  if (entry === null) break;
1647
1574
  plan = markQueueIssueInProgress(plan, entry.id);
1648
1575
  await saveExecutionPlan(planFile, plan);
@@ -1675,20 +1602,55 @@ async function runQueue(initialPlan, options, resolvedPrimary) {
1675
1602
  }
1676
1603
  const usage = issueUsage.totals();
1677
1604
  if (result.code !== 0) {
1678
- plan = markQueueIssueFailed(plan, entry.id, {
1605
+ const failure2 = {
1679
1606
  phase: result.failedPhase,
1680
1607
  error: {
1681
1608
  category: "queue_issue_failed",
1682
1609
  message: `Issue #${entry.id} failed${result.failedPhase === null ? "" : ` in phase ${result.failedPhase}`}`,
1683
1610
  at: isoNow()
1684
1611
  }
1685
- });
1612
+ };
1613
+ const where = result.failedPhase === null ? "" : ` (phase ${result.failedPhase})`;
1614
+ if (failureMode === "stop") {
1615
+ plan = markQueueIssueFailed(plan, entry.id, failure2);
1616
+ await saveExecutionPlan(planFile, plan);
1617
+ printError(
1618
+ `Queue stopped at issue #${entry.id}${where}. The branch and every commit made so far were kept.`
1619
+ );
1620
+ printInfo(`Resume with: issue-flow run ${plan.requested.join(",")}`);
1621
+ return result.code;
1622
+ }
1623
+ if (failureMode === "block") {
1624
+ plan = markQueueIssueBlocked(
1625
+ plan,
1626
+ entry.id,
1627
+ `Failed${where} and --on-issue-failure block was in force`,
1628
+ failure2
1629
+ );
1630
+ await saveExecutionPlan(planFile, plan);
1631
+ exhausted.add(entry.id);
1632
+ failedIds.add(entry.id);
1633
+ printWarning(
1634
+ `Issue #${entry.id} failed${where} and is blocked for review. Continuing with the rest of the queue.`
1635
+ );
1636
+ continue;
1637
+ }
1638
+ const attempts = entry.attempts + 1;
1639
+ if (attempts >= maxIssueAttempts) {
1640
+ plan = markQueueIssueFailed(plan, entry.id, failure2);
1641
+ exhausted.add(entry.id);
1642
+ failedIds.add(entry.id);
1643
+ printWarning(
1644
+ `Issue #${entry.id} failed${where} after ${attempts} attempt(s). Continuing with the rest of the queue.`
1645
+ );
1646
+ } else {
1647
+ plan = markQueueIssueSkipped(plan, entry.id, failure2);
1648
+ printWarning(
1649
+ `Issue #${entry.id} failed${where}. Skipping it for now and coming back at the end of the queue.`
1650
+ );
1651
+ }
1686
1652
  await saveExecutionPlan(planFile, plan);
1687
- printError(
1688
- `Queue stopped at issue #${entry.id}${result.failedPhase === null ? "" : ` (phase ${result.failedPhase})`}. The branch and every commit made so far were kept.`
1689
- );
1690
- printInfo(`Resume with: issue-flow run ${plan.requested.join(",")}`);
1691
- return result.code;
1653
+ continue;
1692
1654
  }
1693
1655
  if (plan.branchName === null && result.branchName !== null) {
1694
1656
  plan = setQueueBranch(plan, result.branchName);
@@ -1703,7 +1665,7 @@ async function runQueue(initialPlan, options, resolvedPrimary) {
1703
1665
  usage
1704
1666
  });
1705
1667
  }
1706
- return await finishQueue(
1668
+ const code = await finishQueue(
1707
1669
  plan,
1708
1670
  planFile,
1709
1671
  summaries,
@@ -1712,6 +1674,14 @@ async function runQueue(initialPlan, options, resolvedPrimary) {
1712
1674
  options,
1713
1675
  resolvedPrimary
1714
1676
  );
1677
+ if (failedIds.size > 0) {
1678
+ printError(
1679
+ `${failedIds.size} issue(s) did not finish: ${[...failedIds].map((id) => `#${id}`).join(", ")}.`
1680
+ );
1681
+ printInfo(`Resume with: issue-flow run ${plan.requested.join(",")}`);
1682
+ return code === 0 ? 1 : code;
1683
+ }
1684
+ return code;
1715
1685
  } finally {
1716
1686
  queueUsage.end();
1717
1687
  }
@@ -1853,9 +1823,10 @@ async function adoptQueueBranch(tasksPath, queueBranch) {
1853
1823
  return queueBranch;
1854
1824
  }
1855
1825
  }
1826
+
1856
1827
  export {
1857
- publishIssueDetails,
1858
1828
  publishStorySeed,
1829
+ publishIssueDetails,
1859
1830
  runPipeline
1860
1831
  };
1861
- //# sourceMappingURL=run-H75MD5BQ.js.map
1832
+ //# sourceMappingURL=chunk-NTMMF5UD.js.map