sequant 2.9.0 → 2.10.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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +14 -3
- package/dist/bin/cli.js +97 -17
- package/dist/marketplace/external_plugins/sequant/.claude-plugin/plugin.json +1 -1
- package/dist/marketplace/external_plugins/sequant/.mcp.json +1 -1
- package/dist/marketplace/external_plugins/sequant/README.md +56 -2
- package/dist/marketplace/external_plugins/sequant/hooks/parallel-marker.sh +50 -0
- package/dist/marketplace/external_plugins/sequant/hooks/post-tool.sh +20 -7
- package/dist/marketplace/external_plugins/sequant/hooks/pre-tool.sh +75 -10
- package/dist/marketplace/external_plugins/sequant/skills/_shared/references/trust-model.md +18 -0
- package/dist/marketplace/external_plugins/sequant/skills/assess/SKILL.md +215 -390
- package/dist/marketplace/external_plugins/sequant/skills/assess/references/predicted-collision-detection.md +1 -1
- package/dist/marketplace/external_plugins/sequant/skills/docs/SKILL.md +4 -4
- package/dist/marketplace/external_plugins/sequant/skills/exec/SKILL.md +26 -6
- package/dist/marketplace/external_plugins/sequant/skills/fullsolve/SKILL.md +3 -1
- package/dist/marketplace/external_plugins/sequant/skills/loop/SKILL.md +38 -2
- package/dist/marketplace/external_plugins/sequant/skills/merger/SKILL.md +2 -2
- package/dist/marketplace/external_plugins/sequant/skills/qa/SKILL.md +337 -57
- package/dist/marketplace/external_plugins/sequant/skills/qa/references/anti-pattern-detection.md +6 -6
- package/dist/marketplace/external_plugins/sequant/skills/qa/references/call-site-review.md +2 -2
- package/dist/marketplace/external_plugins/sequant/skills/qa/references/code-review-checklist.md +2 -2
- package/dist/marketplace/external_plugins/sequant/skills/qa/references/fixtures/injection-issue-body.md +36 -0
- package/dist/marketplace/external_plugins/sequant/skills/qa/references/quality-gates.md +21 -7
- package/dist/marketplace/external_plugins/sequant/skills/qa/references/testing-requirements.md +1 -1
- package/dist/marketplace/external_plugins/sequant/skills/qa/scripts/quality-checks.sh +11 -11
- package/dist/marketplace/external_plugins/sequant/skills/release/SKILL.md +1 -1
- package/dist/marketplace/external_plugins/sequant/skills/setup/SKILL.md +5 -5
- package/dist/marketplace/external_plugins/sequant/skills/spec/SKILL.md +2 -0
- package/dist/marketplace/external_plugins/sequant/skills/test/SKILL.md +1 -1
- package/dist/src/commands/assess-render.d.ts +23 -0
- package/dist/src/commands/assess-render.js +60 -0
- package/dist/src/commands/doctor.js +8 -13
- package/dist/src/commands/init.js +13 -1
- package/dist/src/commands/locks.js +2 -0
- package/dist/src/commands/logs.js +14 -5
- package/dist/src/commands/merge.js +154 -2
- package/dist/src/commands/ready-tui-adapter.js +6 -1
- package/dist/src/commands/ready.d.ts +25 -3
- package/dist/src/commands/ready.js +39 -13
- package/dist/src/commands/resume.d.ts +113 -0
- package/dist/src/commands/resume.js +193 -0
- package/dist/src/commands/run-display.js +37 -4
- package/dist/src/commands/run-flags.d.ts +42 -1
- package/dist/src/commands/run-flags.js +53 -1
- package/dist/src/commands/run-progress.js +26 -1
- package/dist/src/commands/run.js +11 -10
- package/dist/src/commands/state.js +7 -0
- package/dist/src/commands/status.d.ts +9 -0
- package/dist/src/commands/status.js +24 -1
- package/dist/src/commands/sync.js +90 -19
- package/dist/src/commands/update.js +28 -5
- package/dist/src/lib/ac-parser.d.ts +14 -0
- package/dist/src/lib/ac-parser.js +99 -5
- package/dist/src/lib/assess/index.d.ts +10 -0
- package/dist/src/lib/assess/index.js +9 -0
- package/dist/src/lib/assess/renderer.d.ts +64 -0
- package/dist/src/lib/assess/renderer.js +481 -0
- package/dist/src/lib/assess/types.d.ts +224 -0
- package/dist/src/lib/assess/types.js +241 -0
- package/dist/src/lib/ci/outputs.d.ts +15 -2
- package/dist/src/lib/ci/outputs.js +17 -5
- package/dist/src/lib/cli-flags.d.ts +33 -0
- package/dist/src/lib/cli-flags.js +46 -0
- package/dist/src/lib/cli-ui/run-renderer-types.d.ts +21 -7
- package/dist/src/lib/cli-ui/run-renderer.d.ts +10 -0
- package/dist/src/lib/cli-ui/run-renderer.js +62 -0
- package/dist/src/lib/errors.d.ts +78 -6
- package/dist/src/lib/errors.js +133 -15
- package/dist/src/lib/locks/index.d.ts +3 -3
- package/dist/src/lib/locks/index.js +2 -2
- package/dist/src/lib/locks/lock-manager.d.ts +20 -2
- package/dist/src/lib/locks/lock-manager.js +47 -3
- package/dist/src/lib/locks/types.d.ts +34 -3
- package/dist/src/lib/locks/types.js +16 -0
- package/dist/src/lib/mcp-config.d.ts +45 -0
- package/dist/src/lib/mcp-config.js +77 -1
- package/dist/src/lib/merge-check/combined-branch-test.d.ts +21 -2
- package/dist/src/lib/merge-check/combined-branch-test.js +233 -110
- package/dist/src/lib/merge-check/command-result.d.ts +36 -0
- package/dist/src/lib/merge-check/command-result.js +56 -0
- package/dist/src/lib/merge-check/index.d.ts +4 -0
- package/dist/src/lib/merge-check/index.js +1 -1
- package/dist/src/lib/merge-check/report.js +5 -1
- package/dist/src/lib/merge-check/types.d.ts +9 -0
- package/dist/src/lib/merge-check/watch.d.ts +121 -0
- package/dist/src/lib/merge-check/watch.js +215 -0
- package/dist/src/lib/qa/infra-blocked-ci.d.ts +90 -0
- package/dist/src/lib/qa/infra-blocked-ci.js +80 -0
- package/dist/src/lib/settings.d.ts +14 -0
- package/dist/src/lib/settings.js +61 -8
- package/dist/src/lib/shutdown.d.ts +42 -2
- package/dist/src/lib/shutdown.js +75 -5
- package/dist/src/lib/skills-check.d.ts +26 -0
- package/dist/src/lib/skills-check.js +31 -0
- package/dist/src/lib/stacks.d.ts +110 -1
- package/dist/src/lib/stacks.js +244 -5
- package/dist/src/lib/templates.d.ts +74 -0
- package/dist/src/lib/templates.js +171 -16
- package/dist/src/lib/test-tautology-detector.d.ts +9 -2
- package/dist/src/lib/test-tautology-detector.js +153 -12
- package/dist/src/lib/version-check.d.ts +31 -0
- package/dist/src/lib/version-check.js +45 -2
- package/dist/src/lib/workflow/batch-executor.d.ts +125 -6
- package/dist/src/lib/workflow/batch-executor.js +477 -44
- package/dist/src/lib/workflow/chain-resume.d.ts +13 -11
- package/dist/src/lib/workflow/chain-resume.js +24 -20
- package/dist/src/lib/workflow/completed-status.d.ts +52 -0
- package/dist/src/lib/workflow/completed-status.js +57 -0
- package/dist/src/lib/workflow/config-resolver.d.ts +26 -0
- package/dist/src/lib/workflow/config-resolver.js +54 -2
- package/dist/src/lib/workflow/drivers/agent-driver.d.ts +7 -0
- package/dist/src/lib/workflow/drivers/aider.d.ts +2 -0
- package/dist/src/lib/workflow/drivers/aider.js +2 -0
- package/dist/src/lib/workflow/drivers/claude-code.d.ts +14 -0
- package/dist/src/lib/workflow/drivers/claude-code.js +20 -2
- package/dist/src/lib/workflow/git-diff-utils.d.ts +35 -3
- package/dist/src/lib/workflow/git-diff-utils.js +77 -4
- package/dist/src/lib/workflow/heartbeat.d.ts +38 -0
- package/dist/src/lib/workflow/heartbeat.js +90 -0
- package/dist/src/lib/workflow/log-writer.d.ts +44 -1
- package/dist/src/lib/workflow/log-writer.js +75 -6
- package/dist/src/lib/workflow/metrics-schema.d.ts +3 -3
- package/dist/src/lib/workflow/phase-executor.d.ts +203 -14
- package/dist/src/lib/workflow/phase-executor.js +457 -52
- package/dist/src/lib/workflow/phase-registry.js +2 -1
- package/dist/src/lib/workflow/platforms/github.d.ts +64 -0
- package/dist/src/lib/workflow/platforms/github.js +152 -0
- package/dist/src/lib/workflow/qa-cache.d.ts +6 -1
- package/dist/src/lib/workflow/qa-cache.js +22 -9
- package/dist/src/lib/workflow/ready-gate.d.ts +17 -4
- package/dist/src/lib/workflow/ready-gate.js +25 -9
- package/dist/src/lib/workflow/run-log-schema.d.ts +14 -0
- package/dist/src/lib/workflow/run-log-schema.js +47 -1
- package/dist/src/lib/workflow/run-orchestrator.d.ts +19 -0
- package/dist/src/lib/workflow/run-orchestrator.js +141 -20
- package/dist/src/lib/workflow/skills-preflight.d.ts +89 -0
- package/dist/src/lib/workflow/skills-preflight.js +112 -0
- package/dist/src/lib/workflow/state-cleanup.js +9 -2
- package/dist/src/lib/workflow/state-manager.d.ts +33 -0
- package/dist/src/lib/workflow/state-manager.js +83 -0
- package/dist/src/lib/workflow/state-schema.d.ts +26 -0
- package/dist/src/lib/workflow/state-schema.js +47 -0
- package/dist/src/lib/workflow/types.d.ts +126 -4
- package/dist/src/lib/workflow/types.js +1 -0
- package/dist/src/lib/workflow/worktree-manager.d.ts +28 -2
- package/dist/src/lib/workflow/worktree-manager.js +102 -23
- package/dist/src/mcp/tools/run.d.ts +9 -1
- package/dist/src/mcp/tools/run.js +20 -1
- package/dist/src/mcp/tools/status.js +6 -0
- package/dist/src/ui/tui/ElapsedTimer.d.ts +12 -3
- package/dist/src/ui/tui/ElapsedTimer.js +12 -10
- package/dist/src/ui/tui/IssueBox.js +10 -2
- package/dist/src/ui/tui/row-cap.js +2 -1
- package/dist/src/ui/tui/theme.d.ts +7 -0
- package/dist/src/ui/tui/theme.js +9 -0
- package/package.json +8 -7
- package/templates/hooks/parallel-marker.sh +50 -0
- package/templates/hooks/post-tool.sh +20 -7
- package/templates/hooks/pre-tool.sh +75 -10
- package/templates/scripts/cleanup-worktree.sh +182 -19
- package/templates/scripts/new-feature.sh +264 -16
- package/templates/skills/_shared/references/trust-model.md +18 -0
- package/templates/skills/assess/SKILL.md +215 -390
- package/templates/skills/assess/references/predicted-collision-detection.md +1 -1
- package/templates/skills/docs/SKILL.md +4 -4
- package/templates/skills/exec/SKILL.md +26 -6
- package/templates/skills/fullsolve/SKILL.md +3 -1
- package/templates/skills/loop/SKILL.md +38 -2
- package/templates/skills/merger/SKILL.md +2 -2
- package/templates/skills/qa/SKILL.md +337 -57
- package/templates/skills/qa/references/anti-pattern-detection.md +6 -6
- package/templates/skills/qa/references/call-site-review.md +2 -2
- package/templates/skills/qa/references/code-review-checklist.md +2 -2
- package/templates/skills/qa/references/fixtures/injection-issue-body.md +36 -0
- package/templates/skills/qa/references/quality-gates.md +21 -7
- package/templates/skills/qa/references/testing-requirements.md +1 -1
- package/templates/skills/qa/scripts/quality-checks.sh +11 -11
- package/templates/skills/release/SKILL.md +1 -1
- package/templates/skills/setup/SKILL.md +5 -5
- package/templates/skills/spec/SKILL.md +2 -0
- package/templates/skills/test/SKILL.md +1 -1
- package/dist/src/lib/phase-spinner.d.ts +0 -146
- package/dist/src/lib/phase-spinner.js +0 -255
- package/dist/src/lib/workflow/pr-operations.d.ts +0 -86
- package/dist/src/lib/workflow/pr-operations.js +0 -326
- package/dist/src/lib/workflow/run-summary.d.ts +0 -36
- package/dist/src/lib/workflow/run-summary.js +0 -142
|
@@ -13,8 +13,12 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { spawnSync } from "child_process";
|
|
15
15
|
import { ui, colors } from "../lib/cli-ui.js";
|
|
16
|
-
import { runMergeChecks, formatReportMarkdown, } from "../lib/merge-check/index.js";
|
|
16
|
+
import { runMergeChecks, formatReportMarkdown, resolveBranches, findMostRecentLog, resolveLogDir, } from "../lib/merge-check/index.js";
|
|
17
17
|
import { LockManager, formatLockedMessage } from "../lib/locks/index.js";
|
|
18
|
+
import { waitForChecks, resolveWatchTiming } from "../lib/merge-check/watch.js";
|
|
19
|
+
import { GitHubProvider } from "../lib/workflow/platforms/github.js";
|
|
20
|
+
/** Exit code for a watch that timed out — distinct from the 0/1/2 verdicts. */
|
|
21
|
+
const EXIT_WATCH_TIMEOUT = 3;
|
|
18
22
|
/**
|
|
19
23
|
* Determine exit code from batch verdict
|
|
20
24
|
*/
|
|
@@ -43,6 +47,126 @@ function getRepoRoot() {
|
|
|
43
47
|
}
|
|
44
48
|
return result.stdout.trim();
|
|
45
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Resolve the PR numbers to watch for a set of issues.
|
|
52
|
+
*
|
|
53
|
+
* Reuses `resolveBranches` (git remote + worktree scan) for the issue→branch
|
|
54
|
+
* map, then prefers the run-log `prNumber` and falls back to
|
|
55
|
+
* `viewPRByBranchSync`. Issues with no discoverable PR are returned in
|
|
56
|
+
* `unresolved` so the caller can report them rather than silently skipping.
|
|
57
|
+
*/
|
|
58
|
+
function resolveWatchTargets(issueNumbers, repoRoot, gh) {
|
|
59
|
+
const runLog = findMostRecentLog(resolveLogDir());
|
|
60
|
+
const branches = resolveBranches(issueNumbers, repoRoot, runLog);
|
|
61
|
+
const targets = [];
|
|
62
|
+
const unresolved = [];
|
|
63
|
+
for (const b of branches) {
|
|
64
|
+
const pr = b.prNumber ?? gh.viewPRByBranchSync(b.branch)?.number ?? null;
|
|
65
|
+
if (pr) {
|
|
66
|
+
targets.push({ issue: b.issueNumber, pr });
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
unresolved.push(b.issueNumber);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { targets, unresolved };
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Watch gate (#818): before the normal merge-check report runs, poll each
|
|
76
|
+
* resolved PR's CI rollup until every check is terminal.
|
|
77
|
+
*
|
|
78
|
+
* Contract: this **never merges**. It only decides *when* the existing report
|
|
79
|
+
* runs. Returns `true` when it has handled the outcome and the caller must stop
|
|
80
|
+
* (a dispatch block → BLOCKED/exit 2, a timeout → distinct message/exit 3, or no
|
|
81
|
+
* PR could be resolved); returns `false` when every PR reached a terminal state
|
|
82
|
+
* and the caller should fall through to `runMergeChecks` unchanged.
|
|
83
|
+
*
|
|
84
|
+
* Multiple PRs share a single wall-clock deadline so `--timeout` bounds the whole
|
|
85
|
+
* gate, not each PR independently.
|
|
86
|
+
*/
|
|
87
|
+
async function runWatchGate(issueNumbers, options, repoRoot) {
|
|
88
|
+
const gh = new GitHubProvider();
|
|
89
|
+
// Auto-detect issues from the most recent run log when none were given,
|
|
90
|
+
// mirroring runMergeChecks so `--watch` composes with auto-detection.
|
|
91
|
+
let issues = issueNumbers;
|
|
92
|
+
if (issues.length === 0) {
|
|
93
|
+
const runLog = findMostRecentLog(resolveLogDir());
|
|
94
|
+
if (runLog) {
|
|
95
|
+
issues = runLog.issues.map((i) => i.issueNumber);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const { targets, unresolved } = resolveWatchTargets(issues, repoRoot, gh);
|
|
99
|
+
if (targets.length === 0) {
|
|
100
|
+
const message = "Watch: no open PR found for " +
|
|
101
|
+
(issues.length > 0
|
|
102
|
+
? `issue(s) ${issues.map((i) => `#${i}`).join(", ")}`
|
|
103
|
+
: "the most recent run") +
|
|
104
|
+
". Push the branch and open a PR before running `merge --watch`.";
|
|
105
|
+
if (options.json) {
|
|
106
|
+
console.log(JSON.stringify({ watch: "blocked", error: message }, null, 2));
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
console.error(ui.errorBox("Watch Blocked", message));
|
|
110
|
+
}
|
|
111
|
+
process.exitCode = 2;
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
// Validated, never NaN — the shared deadline below depends on it (a NaN
|
|
115
|
+
// deadline would make every per-PR `remaining` NaN and defeat --timeout).
|
|
116
|
+
const { intervalMs, timeoutMs } = resolveWatchTiming(options);
|
|
117
|
+
const deadline = Date.now() + timeoutMs;
|
|
118
|
+
if (unresolved.length > 0 && !options.json) {
|
|
119
|
+
console.log(colors.muted(` ! No PR resolved for: ${unresolved.map((i) => `#${i}`).join(", ")} — skipping in watch.`));
|
|
120
|
+
}
|
|
121
|
+
for (const { issue, pr } of targets) {
|
|
122
|
+
if (!options.json) {
|
|
123
|
+
console.log(colors.muted(`Watching PR #${pr} (issue #${issue})…`));
|
|
124
|
+
}
|
|
125
|
+
const remaining = deadline - Date.now();
|
|
126
|
+
const result = await waitForChecks(pr, {
|
|
127
|
+
intervalMs,
|
|
128
|
+
// Share the deadline across PRs so the whole gate honors --timeout.
|
|
129
|
+
timeoutMs: Math.max(0, remaining),
|
|
130
|
+
onPoll: options.json
|
|
131
|
+
? undefined
|
|
132
|
+
: (msg) => console.log(colors.muted(` #${pr}: ${msg}`)),
|
|
133
|
+
}, gh);
|
|
134
|
+
if (result.status === "blocked") {
|
|
135
|
+
const reason = result.reason ?? "CI dispatch is blocked.";
|
|
136
|
+
if (options.json) {
|
|
137
|
+
console.log(JSON.stringify({
|
|
138
|
+
watch: "blocked",
|
|
139
|
+
pr,
|
|
140
|
+
issue,
|
|
141
|
+
reason,
|
|
142
|
+
checkName: result.checkName,
|
|
143
|
+
}, null, 2));
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
console.log(ui.errorBox("BLOCKED", `PR #${pr} (issue #${issue}) — ${reason}` +
|
|
147
|
+
(result.checkName ? `\n\nCheck: ${result.checkName}` : "")));
|
|
148
|
+
}
|
|
149
|
+
process.exitCode = 2;
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
if (result.status === "timeout") {
|
|
153
|
+
const reason = result.reason ?? "Watch timed out.";
|
|
154
|
+
if (options.json) {
|
|
155
|
+
console.log(JSON.stringify({ watch: "timeout", pr, issue, reason }, null, 2));
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
console.error(ui.errorBox("Watch Timed Out", reason));
|
|
159
|
+
}
|
|
160
|
+
process.exitCode = EXIT_WATCH_TIMEOUT;
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
// terminal → this PR is ready to be checked; continue to the next PR.
|
|
164
|
+
if (!options.json) {
|
|
165
|
+
console.log(colors.muted(` #${pr}: all checks terminal after ${result.polls} poll(s).`));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
46
170
|
/**
|
|
47
171
|
* Main merge command handler
|
|
48
172
|
*/
|
|
@@ -51,7 +175,14 @@ export async function mergeCommand(issues, options) {
|
|
|
51
175
|
if (!options.check && !options.scan && !options.review && !options.all) {
|
|
52
176
|
options.check = true;
|
|
53
177
|
}
|
|
54
|
-
|
|
178
|
+
let repoRoot;
|
|
179
|
+
try {
|
|
180
|
+
repoRoot = getRepoRoot();
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
console.error(colors.error("❌ Not in a git repository. Run merge from inside your project repo."));
|
|
184
|
+
process.exit(1);
|
|
185
|
+
}
|
|
55
186
|
const issueNumbers = issues
|
|
56
187
|
.map((i) => parseInt(i, 10))
|
|
57
188
|
.filter((n) => !isNaN(n));
|
|
@@ -81,6 +212,27 @@ export async function mergeCommand(issues, options) {
|
|
|
81
212
|
}
|
|
82
213
|
console.log("");
|
|
83
214
|
}
|
|
215
|
+
// Watch gate (#818): wait for CI to reach a terminal state before running the
|
|
216
|
+
// report. Returns true when it has already handled the outcome (dispatch
|
|
217
|
+
// block, timeout, or no PR to watch) and set the exit code — stop here.
|
|
218
|
+
if (options.watch) {
|
|
219
|
+
try {
|
|
220
|
+
const handled = await runWatchGate(issueNumbers, options, repoRoot);
|
|
221
|
+
if (handled)
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
226
|
+
if (options.json) {
|
|
227
|
+
console.log(JSON.stringify({ watch: "error", error: message }, null, 2));
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
console.error(ui.errorBox("Watch Failed", message));
|
|
231
|
+
}
|
|
232
|
+
process.exitCode = 2;
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
84
236
|
try {
|
|
85
237
|
const report = await runMergeChecks(issueNumbers, options, repoRoot);
|
|
86
238
|
if (options.json) {
|
|
@@ -77,7 +77,12 @@ export class ReadySnapshotAdapter {
|
|
|
77
77
|
this.status = "failed";
|
|
78
78
|
break;
|
|
79
79
|
}
|
|
80
|
-
|
|
80
|
+
// #804: an auto-wait surfaces here the same way activity does — as a
|
|
81
|
+
// refreshed nowLine on the still-running phase. Sharing one body is
|
|
82
|
+
// deliberate: a wait must NOT reach the complete/failed case, which
|
|
83
|
+
// would close out a phase that is merely paused.
|
|
84
|
+
case "activity":
|
|
85
|
+
case "waiting": {
|
|
81
86
|
if (this.currentPhase) {
|
|
82
87
|
this.currentPhase = {
|
|
83
88
|
...this.currentPhase,
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* human / no implementation), persists that state, and emits a structured gap
|
|
15
15
|
* report. See `src/lib/workflow/ready-gate.ts` for the engine.
|
|
16
16
|
*/
|
|
17
|
-
import { type ReadyPolicy } from "../lib/settings.js";
|
|
17
|
+
import { type ReadyPolicy, type SequantSettings } from "../lib/settings.js";
|
|
18
18
|
import { type ReadyResult } from "../lib/workflow/ready-gate.js";
|
|
19
19
|
export interface ReadyCommandOptions {
|
|
20
20
|
policy?: string;
|
|
@@ -29,8 +29,11 @@ export interface ReadyCommandOptions {
|
|
|
29
29
|
/**
|
|
30
30
|
* Exit code from a ready result.
|
|
31
31
|
* - 0: ready (awaiting human merge)
|
|
32
|
-
* - 1: not ready — needs human intervention (budget/iterations/stagnation
|
|
33
|
-
*
|
|
32
|
+
* - 1: not ready — needs human intervention (budget/iterations/stagnation,
|
|
33
|
+
* or a NO_VERDICT QA turn (#853): the implementation may exist, so this
|
|
34
|
+
* is a re-run case, not the "nothing there" exit)
|
|
35
|
+
* - 2: not ready — no implementation (#534), uncommitted-only work (#879), or
|
|
36
|
+
* hard error. Same class: nothing certifiable exists on the branch.
|
|
34
37
|
*/
|
|
35
38
|
export declare function getReadyExitCode(result: ReadyResult): number;
|
|
36
39
|
/**
|
|
@@ -40,6 +43,25 @@ export declare function getReadyExitCode(result: ReadyResult): number;
|
|
|
40
43
|
* @internal Exported for testing only.
|
|
41
44
|
*/
|
|
42
45
|
export declare function resolvePolicy(flag: string | undefined, settingsPolicy: ReadyPolicy): ReadyPolicy;
|
|
46
|
+
/**
|
|
47
|
+
* Resolve the numeric limits for a ready-gate run: CLI → settings → default.
|
|
48
|
+
*
|
|
49
|
+
* #833: these previously guarded only the CLI value (`typeof x === "number" &&
|
|
50
|
+
* x > 0`) and fell straight through to `settings.run.*` unchecked. But
|
|
51
|
+
* `settings.run.timeout` is user-authored JSON, and `ready`'s value does NOT
|
|
52
|
+
* pass through `buildExecutionConfig` — `ready-gate.ts`'s `buildPhaseConfig`
|
|
53
|
+
* assembles its own `ExecutionConfig`. So a `"timeout": 0` in settings.json
|
|
54
|
+
* reached `setTimeout` as a 0 ms delay and aborted every ready-gate phase on
|
|
55
|
+
* its first tick, with no warning: the #833 defect, on the path the original
|
|
56
|
+
* fix did not cover. Chaining `positiveOr` leaves no layer unchecked.
|
|
57
|
+
*
|
|
58
|
+
* @internal Exported for testing only.
|
|
59
|
+
*/
|
|
60
|
+
export declare function resolveReadyLimits(options: Pick<ReadyCommandOptions, "maxIterations" | "budget" | "timeout">, settings: Pick<SequantSettings, "run">): {
|
|
61
|
+
maxIterations: number;
|
|
62
|
+
tokenBudget: number | undefined;
|
|
63
|
+
phaseTimeout: number;
|
|
64
|
+
};
|
|
43
65
|
/**
|
|
44
66
|
* Locate the worktree path for an issue from `git worktree list`.
|
|
45
67
|
*
|
|
@@ -15,25 +15,32 @@
|
|
|
15
15
|
* report. See `src/lib/workflow/ready-gate.ts` for the engine.
|
|
16
16
|
*/
|
|
17
17
|
import { ui, colors } from "../lib/cli-ui.js";
|
|
18
|
-
import { getSettings } from "../lib/settings.js";
|
|
18
|
+
import { getSettings, } from "../lib/settings.js";
|
|
19
19
|
import { listWorktrees } from "../lib/workflow/worktree-manager.js";
|
|
20
20
|
import { GitHubProvider } from "../lib/workflow/platforms/github.js";
|
|
21
21
|
import { getStateManager } from "../lib/workflow/state-manager.js";
|
|
22
22
|
import { executePhaseWithRetry } from "../lib/workflow/phase-executor.js";
|
|
23
23
|
import { buildProgressWiring } from "./run-progress.js";
|
|
24
24
|
import { ReadySnapshotAdapter } from "./ready-tui-adapter.js";
|
|
25
|
+
import { DEFAULT_CONFIG } from "../lib/workflow/types.js";
|
|
26
|
+
import { positiveOr } from "../lib/workflow/config-resolver.js";
|
|
25
27
|
import { runReadyGate, parseNonGoals, } from "../lib/workflow/ready-gate.js";
|
|
26
28
|
/**
|
|
27
29
|
* Exit code from a ready result.
|
|
28
30
|
* - 0: ready (awaiting human merge)
|
|
29
|
-
* - 1: not ready — needs human intervention (budget/iterations/stagnation
|
|
30
|
-
*
|
|
31
|
+
* - 1: not ready — needs human intervention (budget/iterations/stagnation,
|
|
32
|
+
* or a NO_VERDICT QA turn (#853): the implementation may exist, so this
|
|
33
|
+
* is a re-run case, not the "nothing there" exit)
|
|
34
|
+
* - 2: not ready — no implementation (#534), uncommitted-only work (#879), or
|
|
35
|
+
* hard error. Same class: nothing certifiable exists on the branch.
|
|
31
36
|
*/
|
|
32
37
|
export function getReadyExitCode(result) {
|
|
33
38
|
if (result.ready)
|
|
34
39
|
return 0;
|
|
35
|
-
if (result.reason === "NO_IMPLEMENTATION"
|
|
40
|
+
if (result.reason === "NO_IMPLEMENTATION" ||
|
|
41
|
+
result.reason === "UNCOMMITTED_ONLY") {
|
|
36
42
|
return 2;
|
|
43
|
+
}
|
|
37
44
|
return 1;
|
|
38
45
|
}
|
|
39
46
|
/**
|
|
@@ -47,6 +54,33 @@ export function resolvePolicy(flag, settingsPolicy) {
|
|
|
47
54
|
return flag;
|
|
48
55
|
return settingsPolicy;
|
|
49
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Resolve the numeric limits for a ready-gate run: CLI → settings → default.
|
|
59
|
+
*
|
|
60
|
+
* #833: these previously guarded only the CLI value (`typeof x === "number" &&
|
|
61
|
+
* x > 0`) and fell straight through to `settings.run.*` unchecked. But
|
|
62
|
+
* `settings.run.timeout` is user-authored JSON, and `ready`'s value does NOT
|
|
63
|
+
* pass through `buildExecutionConfig` — `ready-gate.ts`'s `buildPhaseConfig`
|
|
64
|
+
* assembles its own `ExecutionConfig`. So a `"timeout": 0` in settings.json
|
|
65
|
+
* reached `setTimeout` as a 0 ms delay and aborted every ready-gate phase on
|
|
66
|
+
* its first tick, with no warning: the #833 defect, on the path the original
|
|
67
|
+
* fix did not cover. Chaining `positiveOr` leaves no layer unchecked.
|
|
68
|
+
*
|
|
69
|
+
* @internal Exported for testing only.
|
|
70
|
+
*/
|
|
71
|
+
export function resolveReadyLimits(options, settings) {
|
|
72
|
+
return {
|
|
73
|
+
maxIterations: positiveOr(options.maxIterations, positiveOr(settings.run.maxIterations, DEFAULT_CONFIG.maxIterations)),
|
|
74
|
+
// Budget is genuinely optional — `undefined` means "no budget", not "use a
|
|
75
|
+
// default" — so it keeps the two-state form rather than chaining.
|
|
76
|
+
tokenBudget: typeof options.budget === "number" &&
|
|
77
|
+
Number.isFinite(options.budget) &&
|
|
78
|
+
options.budget > 0
|
|
79
|
+
? options.budget
|
|
80
|
+
: undefined,
|
|
81
|
+
phaseTimeout: positiveOr(options.timeout, positiveOr(settings.run.timeout, DEFAULT_CONFIG.phaseTimeout)),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
50
84
|
/**
|
|
51
85
|
* Locate the worktree path for an issue from `git worktree list`.
|
|
52
86
|
*
|
|
@@ -70,15 +104,7 @@ export async function readyCommand(issueArg, options) {
|
|
|
70
104
|
}
|
|
71
105
|
const settings = await getSettings();
|
|
72
106
|
const policy = resolvePolicy(options.policy, settings.ready.policy);
|
|
73
|
-
const maxIterations
|
|
74
|
-
? options.maxIterations
|
|
75
|
-
: settings.run.maxIterations;
|
|
76
|
-
const tokenBudget = typeof options.budget === "number" && options.budget > 0
|
|
77
|
-
? options.budget
|
|
78
|
-
: undefined;
|
|
79
|
-
const phaseTimeout = typeof options.timeout === "number" && options.timeout > 0
|
|
80
|
-
? options.timeout
|
|
81
|
-
: settings.run.timeout;
|
|
107
|
+
const { maxIterations, tokenBudget, phaseTimeout } = resolveReadyLimits(options, settings);
|
|
82
108
|
const mcp = options.mcp !== false;
|
|
83
109
|
// Resolve the issue's existing worktree (reuses run/state worktree infra).
|
|
84
110
|
const worktreePath = resolveWorktreePath(issueNumber);
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sequant resume — re-enter runs halted on a waitable rate-limit window
|
|
3
|
+
* (#892).
|
|
4
|
+
*
|
|
5
|
+
* The durable counterpart to `--auto-wait` (#804): when a run halts on an
|
|
6
|
+
* exhausted `five_hour`/`seven_day` window (#860 classification), the halt
|
|
7
|
+
* path writes `windowHalt.resumeAt` to issue state and exits cleanly with the
|
|
8
|
+
* per-issue lock released. This command is the re-entry: a no-op before
|
|
9
|
+
* `resumeAt` (clear message, exit 0 — safe to invoke from cron/launchd every
|
|
10
|
+
* few minutes), and after `resumeAt` it re-runs the halted issues through the
|
|
11
|
+
* normal `sequant run` path with `--resume` semantics, so completed phases
|
|
12
|
+
* (GitHub markers) and completed issues (#837 state guard) are skipped.
|
|
13
|
+
*
|
|
14
|
+
* Re-entries are bounded per issue by {@link MAX_RESUME_REENTRIES} (AC-3), so
|
|
15
|
+
* a window that never reopens cannot ping-pong a scheduler: the bound halts
|
|
16
|
+
* with the same labeled terminal message a spent auto-wait produces today.
|
|
17
|
+
*/
|
|
18
|
+
import { StateManager } from "../lib/workflow/state-manager.js";
|
|
19
|
+
import { MAX_RESUME_REENTRIES } from "../lib/workflow/state-schema.js";
|
|
20
|
+
import type { IssueState } from "../lib/workflow/state-schema.js";
|
|
21
|
+
import type { RunOptions } from "../lib/workflow/types.js";
|
|
22
|
+
import type { LockFile } from "../lib/locks/index.js";
|
|
23
|
+
export { MAX_RESUME_REENTRIES };
|
|
24
|
+
/** One resumable (or not-yet / exhausted) issue in a {@link ResumePlan}. */
|
|
25
|
+
export interface ResumeCandidate {
|
|
26
|
+
issueNumber: number;
|
|
27
|
+
/** ISO timestamp after which re-entry can proceed. */
|
|
28
|
+
resumeAt: string;
|
|
29
|
+
/** Phase that halted. */
|
|
30
|
+
phase: string;
|
|
31
|
+
/** Re-entry attempts already consumed. */
|
|
32
|
+
reentries: number;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Partition of halted issues by what `sequant resume` should do with them.
|
|
36
|
+
* Pure data — computed by {@link planResume}, rendered/acted on by the
|
|
37
|
+
* command shell.
|
|
38
|
+
*/
|
|
39
|
+
export interface ResumePlan {
|
|
40
|
+
/** Past `resumeAt` and under the re-entry bound: re-run these. */
|
|
41
|
+
due: ResumeCandidate[];
|
|
42
|
+
/** Before `resumeAt`: report and exit 0 (AC-2 no-op contract). */
|
|
43
|
+
notYet: ResumeCandidate[];
|
|
44
|
+
/** Re-entry bound consumed (AC-3): terminal, needs human attention. */
|
|
45
|
+
exhausted: ResumeCandidate[];
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Decide what to do with every halted issue (#892 AC-2/AC-3).
|
|
49
|
+
*
|
|
50
|
+
* Pure so the before/after-`resumeAt` and bound edges are unit-testable with
|
|
51
|
+
* an injected clock. Issues whose persisted status is already completed
|
|
52
|
+
* (#837 vocabulary) are excluded even if a stale `windowHalt` survived — the
|
|
53
|
+
* run path would skip them anyway, so re-entering buys nothing.
|
|
54
|
+
*
|
|
55
|
+
* @param states All persisted issue states.
|
|
56
|
+
* @param requested Restrict to these issue numbers (empty = all halted).
|
|
57
|
+
* @param now Epoch ms clock, injectable for tests.
|
|
58
|
+
*/
|
|
59
|
+
export declare function planResume(states: Record<number, IssueState>, requested: number[], now: number): ResumePlan;
|
|
60
|
+
/**
|
|
61
|
+
* Terminal message for an issue whose re-entry bound is spent (#892 AC-3).
|
|
62
|
+
* Mirrors today's labeled halt vocabulary (`Rate limited — resets at …`,
|
|
63
|
+
* `auto-wait N/M`): names the real cause, the consumed bound, and the manual
|
|
64
|
+
* way out.
|
|
65
|
+
*
|
|
66
|
+
* @internal Exported for testing
|
|
67
|
+
*/
|
|
68
|
+
export declare function reentryBoundMessage(candidate: ResumeCandidate): string;
|
|
69
|
+
/**
|
|
70
|
+
* The delegated run options for a re-entry: `resume: true` (completed phases
|
|
71
|
+
* are skipped via GitHub markers) and nothing else. `RunOptions` is
|
|
72
|
+
* normalized so an absent field means "resolve from settings/defaults"
|
|
73
|
+
* (`config-resolver` only reacts to explicit `false` / `no*` fields), so a
|
|
74
|
+
* bare object behaves exactly like an attended `sequant run <issue> --resume`
|
|
75
|
+
* — MCP, retry ladder, rebase, and PR creation all follow the user's settings.
|
|
76
|
+
*
|
|
77
|
+
* @internal Exported for testing
|
|
78
|
+
*/
|
|
79
|
+
export declare function buildReentryRunOptions(): RunOptions;
|
|
80
|
+
/** Options accepted by {@link resumeCommand}. */
|
|
81
|
+
export interface ResumeCommandOptions {
|
|
82
|
+
/** Print the plan without consuming a re-entry or spawning a run. */
|
|
83
|
+
dryRun?: boolean;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Injectable collaborators for {@link resumeCommand}.
|
|
87
|
+
*
|
|
88
|
+
* @internal For testing only — production callers (bin/cli.ts) omit this and
|
|
89
|
+
* get the real StateManager / LockManager / runCommand / clock. bin/cli.ts
|
|
90
|
+
* wraps the action in an arrow function so commander's third positional (the
|
|
91
|
+
* Command instance) can never land here.
|
|
92
|
+
*/
|
|
93
|
+
export interface ResumeCommandDeps {
|
|
94
|
+
stateManager?: StateManager;
|
|
95
|
+
runFn?: (issues: string[], options: RunOptions) => Promise<void>;
|
|
96
|
+
/** Read-only lock probe; defaults to `LockManager.check`. */
|
|
97
|
+
checkLock?: (issue: number) => LockFile | null;
|
|
98
|
+
now?: () => number;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Command entry for `sequant resume [issues...]`.
|
|
102
|
+
*
|
|
103
|
+
* Exit contract (load-bearing for schedulers, AC-2):
|
|
104
|
+
* - nothing halted / nothing due yet → exit 0 (quiet no-op);
|
|
105
|
+
* - due issues → re-entry counter consumed, then delegates to the normal run
|
|
106
|
+
* path (its exit code stands);
|
|
107
|
+
* - a due issue whose lock is held by another session is skipped WITHOUT
|
|
108
|
+
* consuming a re-entry (the run path would skip it anyway; someone is
|
|
109
|
+
* already working on it) — exit 0 when that leaves nothing to run;
|
|
110
|
+
* - only exhausted issues → exit 1 with the labeled terminal message, so a
|
|
111
|
+
* wrapper can alert a human instead of silently looping forever.
|
|
112
|
+
*/
|
|
113
|
+
export declare function resumeCommand(issues: string[], options?: ResumeCommandOptions, deps?: ResumeCommandDeps): Promise<void>;
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sequant resume — re-enter runs halted on a waitable rate-limit window
|
|
3
|
+
* (#892).
|
|
4
|
+
*
|
|
5
|
+
* The durable counterpart to `--auto-wait` (#804): when a run halts on an
|
|
6
|
+
* exhausted `five_hour`/`seven_day` window (#860 classification), the halt
|
|
7
|
+
* path writes `windowHalt.resumeAt` to issue state and exits cleanly with the
|
|
8
|
+
* per-issue lock released. This command is the re-entry: a no-op before
|
|
9
|
+
* `resumeAt` (clear message, exit 0 — safe to invoke from cron/launchd every
|
|
10
|
+
* few minutes), and after `resumeAt` it re-runs the halted issues through the
|
|
11
|
+
* normal `sequant run` path with `--resume` semantics, so completed phases
|
|
12
|
+
* (GitHub markers) and completed issues (#837 state guard) are skipped.
|
|
13
|
+
*
|
|
14
|
+
* Re-entries are bounded per issue by {@link MAX_RESUME_REENTRIES} (AC-3), so
|
|
15
|
+
* a window that never reopens cannot ping-pong a scheduler: the bound halts
|
|
16
|
+
* with the same labeled terminal message a spent auto-wait produces today.
|
|
17
|
+
*/
|
|
18
|
+
import chalk from "chalk";
|
|
19
|
+
import { StateManager } from "../lib/workflow/state-manager.js";
|
|
20
|
+
import { MAX_RESUME_REENTRIES } from "../lib/workflow/state-schema.js";
|
|
21
|
+
import { isCompletedIssueStatus } from "../lib/workflow/completed-status.js";
|
|
22
|
+
import { formatResetTime } from "../lib/errors.js";
|
|
23
|
+
import { LockManager, formatLockedMessage } from "../lib/locks/index.js";
|
|
24
|
+
import { runCommand } from "./run.js";
|
|
25
|
+
// Re-exported for callers that treat the resume command as the feature's
|
|
26
|
+
// entry point; the constant itself lives beside the schema field it bounds.
|
|
27
|
+
export { MAX_RESUME_REENTRIES };
|
|
28
|
+
/**
|
|
29
|
+
* Decide what to do with every halted issue (#892 AC-2/AC-3).
|
|
30
|
+
*
|
|
31
|
+
* Pure so the before/after-`resumeAt` and bound edges are unit-testable with
|
|
32
|
+
* an injected clock. Issues whose persisted status is already completed
|
|
33
|
+
* (#837 vocabulary) are excluded even if a stale `windowHalt` survived — the
|
|
34
|
+
* run path would skip them anyway, so re-entering buys nothing.
|
|
35
|
+
*
|
|
36
|
+
* @param states All persisted issue states.
|
|
37
|
+
* @param requested Restrict to these issue numbers (empty = all halted).
|
|
38
|
+
* @param now Epoch ms clock, injectable for tests.
|
|
39
|
+
*/
|
|
40
|
+
export function planResume(states, requested, now) {
|
|
41
|
+
const plan = { due: [], notYet: [], exhausted: [] };
|
|
42
|
+
const requestedSet = new Set(requested);
|
|
43
|
+
for (const [key, state] of Object.entries(states)) {
|
|
44
|
+
const issueNumber = Number(key);
|
|
45
|
+
if (requested.length > 0 && !requestedSet.has(issueNumber))
|
|
46
|
+
continue;
|
|
47
|
+
if (!state.windowHalt)
|
|
48
|
+
continue;
|
|
49
|
+
if (isCompletedIssueStatus(state.status))
|
|
50
|
+
continue;
|
|
51
|
+
const candidate = {
|
|
52
|
+
issueNumber,
|
|
53
|
+
resumeAt: state.windowHalt.resumeAt,
|
|
54
|
+
phase: state.windowHalt.phase,
|
|
55
|
+
reentries: state.windowHalt.reentries,
|
|
56
|
+
};
|
|
57
|
+
if (candidate.reentries >= MAX_RESUME_REENTRIES) {
|
|
58
|
+
plan.exhausted.push(candidate);
|
|
59
|
+
}
|
|
60
|
+
else if (new Date(candidate.resumeAt).getTime() > now) {
|
|
61
|
+
plan.notYet.push(candidate);
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
plan.due.push(candidate);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// Deterministic output order regardless of state-file key order.
|
|
68
|
+
for (const bucket of [plan.due, plan.notYet, plan.exhausted]) {
|
|
69
|
+
bucket.sort((a, b) => a.issueNumber - b.issueNumber);
|
|
70
|
+
}
|
|
71
|
+
return plan;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Terminal message for an issue whose re-entry bound is spent (#892 AC-3).
|
|
75
|
+
* Mirrors today's labeled halt vocabulary (`Rate limited — resets at …`,
|
|
76
|
+
* `auto-wait N/M`): names the real cause, the consumed bound, and the manual
|
|
77
|
+
* way out.
|
|
78
|
+
*
|
|
79
|
+
* @internal Exported for testing
|
|
80
|
+
*/
|
|
81
|
+
export function reentryBoundMessage(candidate) {
|
|
82
|
+
const resetLabel = formatResetTime(new Date(candidate.resumeAt).getTime());
|
|
83
|
+
return (`Rate limited — re-entry bound reached ` +
|
|
84
|
+
`(${candidate.reentries}/${MAX_RESUME_REENTRIES}), window still closed ` +
|
|
85
|
+
`at last halt (${candidate.phase} phase, resumable at ${resetLabel}). ` +
|
|
86
|
+
`Re-run manually with: npx sequant run ${candidate.issueNumber} --resume`);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The delegated run options for a re-entry: `resume: true` (completed phases
|
|
90
|
+
* are skipped via GitHub markers) and nothing else. `RunOptions` is
|
|
91
|
+
* normalized so an absent field means "resolve from settings/defaults"
|
|
92
|
+
* (`config-resolver` only reacts to explicit `false` / `no*` fields), so a
|
|
93
|
+
* bare object behaves exactly like an attended `sequant run <issue> --resume`
|
|
94
|
+
* — MCP, retry ladder, rebase, and PR creation all follow the user's settings.
|
|
95
|
+
*
|
|
96
|
+
* @internal Exported for testing
|
|
97
|
+
*/
|
|
98
|
+
export function buildReentryRunOptions() {
|
|
99
|
+
return { resume: true };
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Command entry for `sequant resume [issues...]`.
|
|
103
|
+
*
|
|
104
|
+
* Exit contract (load-bearing for schedulers, AC-2):
|
|
105
|
+
* - nothing halted / nothing due yet → exit 0 (quiet no-op);
|
|
106
|
+
* - due issues → re-entry counter consumed, then delegates to the normal run
|
|
107
|
+
* path (its exit code stands);
|
|
108
|
+
* - a due issue whose lock is held by another session is skipped WITHOUT
|
|
109
|
+
* consuming a re-entry (the run path would skip it anyway; someone is
|
|
110
|
+
* already working on it) — exit 0 when that leaves nothing to run;
|
|
111
|
+
* - only exhausted issues → exit 1 with the labeled terminal message, so a
|
|
112
|
+
* wrapper can alert a human instead of silently looping forever.
|
|
113
|
+
*/
|
|
114
|
+
export async function resumeCommand(issues, options = {}, deps = {}) {
|
|
115
|
+
const requested = issues.map((raw) => {
|
|
116
|
+
const n = parseInt(raw, 10);
|
|
117
|
+
if (isNaN(n) || n <= 0) {
|
|
118
|
+
console.log(chalk.red(`❌ Invalid issue number: ${raw}`));
|
|
119
|
+
process.exitCode = 1;
|
|
120
|
+
}
|
|
121
|
+
return n;
|
|
122
|
+
});
|
|
123
|
+
if (process.exitCode === 1)
|
|
124
|
+
return;
|
|
125
|
+
const stateManager = deps.stateManager ?? new StateManager();
|
|
126
|
+
const runFn = deps.runFn ?? runCommand;
|
|
127
|
+
const checkLock = deps.checkLock ?? ((issue) => new LockManager().check(issue));
|
|
128
|
+
const now = deps.now ?? Date.now;
|
|
129
|
+
const states = await stateManager.getAllIssueStates();
|
|
130
|
+
const plan = planResume(states, requested, now());
|
|
131
|
+
if (plan.due.length === 0 &&
|
|
132
|
+
plan.notYet.length === 0 &&
|
|
133
|
+
plan.exhausted.length === 0) {
|
|
134
|
+
console.log(chalk.gray("No halted issues to resume (no windowHalt in state)."));
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
for (const candidate of plan.notYet) {
|
|
138
|
+
const resetLabel = formatResetTime(new Date(candidate.resumeAt).getTime());
|
|
139
|
+
console.log(chalk.yellow(`⏸ #${candidate.issueNumber} not yet resumable — window reopens at ` +
|
|
140
|
+
`${resetLabel} (${candidate.phase} phase halted)`));
|
|
141
|
+
}
|
|
142
|
+
for (const candidate of plan.exhausted) {
|
|
143
|
+
console.log(chalk.red(`❌ #${candidate.issueNumber}: ${reentryBoundMessage(candidate)}`));
|
|
144
|
+
}
|
|
145
|
+
if (plan.due.length === 0) {
|
|
146
|
+
// AC-2: pre-`resumeAt` is a clean no-op (exit 0). Only an exhausted-only
|
|
147
|
+
// outcome is terminal (exit 1) — there is nothing left for a scheduler to
|
|
148
|
+
// wait for.
|
|
149
|
+
if (plan.notYet.length === 0 && plan.exhausted.length > 0) {
|
|
150
|
+
process.exitCode = 1;
|
|
151
|
+
}
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (options.dryRun) {
|
|
155
|
+
for (const candidate of plan.due) {
|
|
156
|
+
console.log(chalk.green(`▶ #${candidate.issueNumber} due for re-entry ` +
|
|
157
|
+
`(${candidate.reentries + 1}/${MAX_RESUME_REENTRIES}) — dry run, not started`));
|
|
158
|
+
}
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
// Lock probe BEFORE consuming a re-entry: a due issue another session holds
|
|
162
|
+
// would be skipped by the run path's lock acquisition anyway, so consuming
|
|
163
|
+
// one of its bounded re-entries buys nothing. The probe is read-only and
|
|
164
|
+
// best-effort — a lock acquired in the window between this check and the
|
|
165
|
+
// run's own acquisition still costs the re-entry, which is the pre-existing
|
|
166
|
+
// (rare, recoverable) behavior, just with a much smaller race window.
|
|
167
|
+
const runnable = [];
|
|
168
|
+
for (const candidate of plan.due) {
|
|
169
|
+
const holder = checkLock(candidate.issueNumber);
|
|
170
|
+
if (holder) {
|
|
171
|
+
console.log(chalk.yellow(`⏭ #${candidate.issueNumber} skipped — re-entry not consumed. ` +
|
|
172
|
+
formatLockedMessage(candidate.issueNumber, holder)));
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
runnable.push(candidate);
|
|
176
|
+
}
|
|
177
|
+
if (runnable.length === 0) {
|
|
178
|
+
// Every due issue is being worked on by another session: nothing for a
|
|
179
|
+
// scheduler to do this tick, nothing terminal — exit 0 and try later.
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
// Consume the re-entry BEFORE running (AC-3): a re-entry that halts again
|
|
183
|
+
// on a still-closed window must already be counted, or the bound never
|
|
184
|
+
// trips. A re-entry that makes progress clears the record (and counter)
|
|
185
|
+
// via the halt path's success handling.
|
|
186
|
+
for (const candidate of runnable) {
|
|
187
|
+
const count = await stateManager.incrementWindowHaltReentries(candidate.issueNumber);
|
|
188
|
+
console.log(chalk.green(`▶ Resuming #${candidate.issueNumber} ` +
|
|
189
|
+
`(re-entry ${count ?? candidate.reentries + 1}/${MAX_RESUME_REENTRIES}, ` +
|
|
190
|
+
`halted in ${candidate.phase})`));
|
|
191
|
+
}
|
|
192
|
+
await runFn(runnable.map((candidate) => String(candidate.issueNumber)), buildReentryRunOptions());
|
|
193
|
+
}
|