@boopkit/cli 0.6.18 → 0.7.1
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.
|
@@ -1,22 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"@boopkit/cap-api": "0.4.5",
|
|
3
|
-
"@boopkit/cap-background-jobs": "0.
|
|
3
|
+
"@boopkit/cap-background-jobs": "0.5.0",
|
|
4
4
|
"@boopkit/cap-cloud-storage": "0.5.0",
|
|
5
|
-
"@boopkit/cap-coding": "0.4.
|
|
6
|
-
"@boopkit/cap-database": "0.4.
|
|
7
|
-
"@boopkit/cap-email": "0.4.
|
|
5
|
+
"@boopkit/cap-coding": "0.4.7",
|
|
6
|
+
"@boopkit/cap-database": "0.4.5",
|
|
7
|
+
"@boopkit/cap-email": "0.4.4",
|
|
8
8
|
"@boopkit/cap-hello": "0.4.1",
|
|
9
9
|
"@boopkit/cap-noticeboard": "0.4.1",
|
|
10
10
|
"@boopkit/cap-oauth-client": "0.1.1",
|
|
11
|
-
"@boopkit/cap-oauth-server": "0.1
|
|
12
|
-
"@boopkit/cap-product-design": "0.6.
|
|
13
|
-
"@boopkit/cap-project-mgmt": "0.4.
|
|
11
|
+
"@boopkit/cap-oauth-server": "0.3.1",
|
|
12
|
+
"@boopkit/cap-product-design": "0.6.11",
|
|
13
|
+
"@boopkit/cap-project-mgmt": "0.4.12",
|
|
14
14
|
"@boopkit/cap-services": "0.4.3",
|
|
15
15
|
"@boopkit/cap-skills": "0.5.6",
|
|
16
|
-
"@boopkit/cap-testing": "0.5.
|
|
17
|
-
"@boopkit/cap-users": "0.
|
|
16
|
+
"@boopkit/cap-testing": "0.5.8",
|
|
17
|
+
"@boopkit/cap-users": "0.9.0",
|
|
18
18
|
"@boopkit/cap-ux": "0.2.3",
|
|
19
|
-
"@boopkit/cap-web": "0.5.
|
|
19
|
+
"@boopkit/cap-web": "0.5.6",
|
|
20
20
|
"@boopkit/cap-workflows": "0.2.2",
|
|
21
21
|
"@boopkit/logger": "0.4.0"
|
|
22
22
|
}
|
package/dist/commands/loop.d.ts
CHANGED
|
@@ -29,6 +29,13 @@ export interface LoopOptions {
|
|
|
29
29
|
watch?: boolean;
|
|
30
30
|
reuseSession?: boolean;
|
|
31
31
|
}
|
|
32
|
+
interface IssueInfo {
|
|
33
|
+
id: string;
|
|
34
|
+
title: string;
|
|
35
|
+
priority: string;
|
|
36
|
+
issue_type: string;
|
|
37
|
+
description: string;
|
|
38
|
+
}
|
|
32
39
|
interface ClaudeRunResult {
|
|
33
40
|
exitCode: number;
|
|
34
41
|
limitHit: boolean;
|
|
@@ -36,6 +43,14 @@ interface ClaudeRunResult {
|
|
|
36
43
|
peakContext?: number;
|
|
37
44
|
/** The model's context window (tokens), from the result's modelUsage. */
|
|
38
45
|
contextWindow?: number;
|
|
46
|
+
/** How many tools the session called. None means it did no work (see sessionDidWork). */
|
|
47
|
+
toolCalls?: number;
|
|
48
|
+
/**
|
|
49
|
+
* What the session said as it ended: the text of its result, or failing that
|
|
50
|
+
* its last message, or failing that the last line it wrote to stderr. When a
|
|
51
|
+
* session fails to start, this is where the cause is named.
|
|
52
|
+
*/
|
|
53
|
+
finalText?: string;
|
|
39
54
|
}
|
|
40
55
|
export interface PlanResult {
|
|
41
56
|
featureId: string | null;
|
|
@@ -229,6 +244,15 @@ export declare function getMatchingReadyIssues(prefix: string, includeFeatures:
|
|
|
229
244
|
maxBuffer?: number;
|
|
230
245
|
}) => string;
|
|
231
246
|
}): ReadyIssue[];
|
|
247
|
+
/**
|
|
248
|
+
* The instructions every loop session starts from. A session acts on this text
|
|
249
|
+
* literally and reads it whether or not it opens a skill, so step 5 carries the
|
|
250
|
+
* test-run rule itself (focused tests plus the static check; the full suite only
|
|
251
|
+
* when the issue asks) and names no command, because the text is the same in
|
|
252
|
+
* every project and the commands are not. For the same reason it names no
|
|
253
|
+
* project.
|
|
254
|
+
*/
|
|
255
|
+
export declare function buildPrompt(issue: IssueInfo): string;
|
|
232
256
|
/**
|
|
233
257
|
* Default per-issue budget cap in USD. The planning phase and each task each get
|
|
234
258
|
* this cap. Single source of truth — imported by cli.ts for its --budget help
|
|
@@ -243,12 +267,48 @@ export declare const DEFAULT_BUDGET_USD = "25.00";
|
|
|
243
267
|
* also skips resuming a `blocked` issue entirely (see shouldResumeUnclosedIssue)
|
|
244
268
|
* — this clause is the belt-and-suspenders for a block raised mid-resume.
|
|
245
269
|
*/
|
|
246
|
-
export declare const RESUME_PROMPT = "The previous session ended without closing this issue. Reload the state and continue.\n\nIMPORTANT: If the previous session marked this issue blocked with a diagnostic, the block stands. Do not re-open it or work around it. Read the issue's notes and the previous session's commits, confirm the block is still appropriate, and stop without taking further action.\n\nOtherwise, complete the workflow:\n- Finish any remaining implementation\n- Run tests
|
|
270
|
+
export declare const RESUME_PROMPT = "The previous session ended without closing this issue. Reload the state and continue.\n\nIMPORTANT: If the previous session marked this issue blocked with a diagnostic, the block stands. Do not re-open it or work around it. Read the issue's notes and the previous session's commits, confirm the block is still appropriate, and stop without taking further action.\n\nOtherwise, complete the workflow:\n- Finish any remaining implementation\n- Run the tests that step 5 of your first instructions describes: the issue's own test scope if it states one, otherwise the focused tests for what you changed plus the static check, and not the full suite unless the issue asks for it\n- Commit changes referencing the issue ID\n- Close the issue with bd close\n- Push with: git pull --rebase && git push\n\nDo not pause for review of completed work. Complete everything now.";
|
|
247
271
|
/**
|
|
248
272
|
* Decide whether a reused session should rotate before taking the next issue.
|
|
249
273
|
* Rotates once peak observed occupancy reaches ROTATE_FRACTION of the window.
|
|
250
274
|
* Pure so it is unit-testable without a running loop.
|
|
251
275
|
*/
|
|
252
276
|
export declare function shouldRotateSession(peakContext: number, contextWindow: number, fraction?: number): boolean;
|
|
253
|
-
|
|
277
|
+
/**
|
|
278
|
+
* How many sessions in a row may end without doing any work before the loop
|
|
279
|
+
* stops. Fixed, with no flag: no caller needs a different number. A claim that
|
|
280
|
+
* fails before a session starts counts as one such session (boop-rfof).
|
|
281
|
+
*
|
|
282
|
+
* A claim that cannot start work costs four sessions at the default
|
|
283
|
+
* --resume-limit (the first run plus three resumes), so five means the loop has
|
|
284
|
+
* claimed an issue a second time and that claim did not start either. In the
|
|
285
|
+
* incident this rule comes from (boop-to45.2), expired credentials produced
|
|
286
|
+
* 1,543 such sessions in 64 minutes, each one turn long and over within a second.
|
|
287
|
+
*/
|
|
288
|
+
export declare const NO_WORK_SESSION_LIMIT = 5;
|
|
289
|
+
/**
|
|
290
|
+
* Whether a session did any work, which here means it called at least one tool.
|
|
291
|
+
* A session cannot read a file, claim its issue or run a command without one,
|
|
292
|
+
* so a session with no tool calls started nothing, whatever the reason: expired
|
|
293
|
+
* or missing credentials, an upstream outage, a `claude` binary that is not
|
|
294
|
+
* there. The test deliberately ignores how long the session lasted and what its
|
|
295
|
+
* error said, because an outage that retries for a minute before giving up is
|
|
296
|
+
* the same fault as a credential check that fails at once.
|
|
297
|
+
*/
|
|
298
|
+
export declare function sessionDidWork(run: ClaudeRunResult): boolean;
|
|
299
|
+
/**
|
|
300
|
+
* Split a session's stream-json output into complete lines. A stdout chunk ends
|
|
301
|
+
* wherever the pipe buffer happened to fill, so the last piece of a chunk may be
|
|
302
|
+
* the first part of a line whose remainder arrives in the next chunk: it comes
|
|
303
|
+
* back as `rest`, for the caller to pass in as `carry` with the next chunk and
|
|
304
|
+
* to flush when the stream closes. Pure so a test can feed it a line cut in two.
|
|
305
|
+
*/
|
|
306
|
+
export declare function splitStreamLines(carry: string, chunk: string): {
|
|
307
|
+
lines: string[];
|
|
308
|
+
rest: string;
|
|
309
|
+
};
|
|
310
|
+
export declare function runLoop(options: LoopOptions, deps?: {
|
|
311
|
+
/** Runs one Claude session for an issue. Tests substitute this; the planning phase is not affected. */
|
|
312
|
+
runClaudeFn?: RunClaudeFn;
|
|
313
|
+
}): Promise<void>;
|
|
254
314
|
export {};
|
package/dist/commands/loop.js
CHANGED
|
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.RESUME_PROMPT = exports.DEFAULT_BUDGET_USD = exports.DEFAULT_MODEL = exports.BD_READ_MAX_BUFFER = void 0;
|
|
36
|
+
exports.NO_WORK_SESSION_LIMIT = exports.RESUME_PROMPT = exports.DEFAULT_BUDGET_USD = exports.DEFAULT_MODEL = exports.BD_READ_MAX_BUFFER = void 0;
|
|
37
37
|
exports.assertDoltServerMode = assertDoltServerMode;
|
|
38
38
|
exports.discoverDoltServer = discoverDoltServer;
|
|
39
39
|
exports.resolveDefaultBranch = resolveDefaultBranch;
|
|
@@ -52,12 +52,16 @@ exports.isIssueInScope = isIssueInScope;
|
|
|
52
52
|
exports.isClaimableReadyIssue = isClaimableReadyIssue;
|
|
53
53
|
exports.shouldCloseFeatureRoot = shouldCloseFeatureRoot;
|
|
54
54
|
exports.getMatchingReadyIssues = getMatchingReadyIssues;
|
|
55
|
+
exports.buildPrompt = buildPrompt;
|
|
55
56
|
exports.shouldRotateSession = shouldRotateSession;
|
|
57
|
+
exports.sessionDidWork = sessionDidWork;
|
|
58
|
+
exports.splitStreamLines = splitStreamLines;
|
|
56
59
|
exports.runLoop = runLoop;
|
|
57
60
|
const child_process_1 = require("child_process");
|
|
58
61
|
const fs = __importStar(require("fs"));
|
|
59
62
|
const path = __importStar(require("path"));
|
|
60
63
|
const crypto_1 = require("crypto");
|
|
64
|
+
const string_decoder_1 = require("string_decoder");
|
|
61
65
|
/**
|
|
62
66
|
* Output cap for every `bd … --json` read the loop makes.
|
|
63
67
|
*
|
|
@@ -879,8 +883,16 @@ function isIssueClosed(id) {
|
|
|
879
883
|
return false;
|
|
880
884
|
}
|
|
881
885
|
}
|
|
886
|
+
/**
|
|
887
|
+
* The instructions every loop session starts from. A session acts on this text
|
|
888
|
+
* literally and reads it whether or not it opens a skill, so step 5 carries the
|
|
889
|
+
* test-run rule itself (focused tests plus the static check; the full suite only
|
|
890
|
+
* when the issue asks) and names no command, because the text is the same in
|
|
891
|
+
* every project and the commands are not. For the same reason it names no
|
|
892
|
+
* project.
|
|
893
|
+
*/
|
|
882
894
|
function buildPrompt(issue) {
|
|
883
|
-
return `You are working on issue ${issue.id} in
|
|
895
|
+
return `You are working on issue ${issue.id} in this project.
|
|
884
896
|
|
|
885
897
|
## Issue: ${issue.title}
|
|
886
898
|
Type: ${issue.issue_type} | Priority: ${issue.priority}
|
|
@@ -893,7 +905,10 @@ ${issue.description}
|
|
|
893
905
|
2. Claim the issue: bd update ${issue.id} --claim
|
|
894
906
|
3. Read relevant skills from skills/ (list folders, read each SKILL.md) before making changes
|
|
895
907
|
4. Implement the changes following TDD-Lite: observe, implement, verify
|
|
896
|
-
5. Run tests
|
|
908
|
+
5. Run the tests this issue needs, and no more:
|
|
909
|
+
- If the issue description states which tests to run, run exactly those and skip the rest of this step. The issue's scope replaces the default below; do not run both.
|
|
910
|
+
- Otherwise, run the focused tests for the files you changed, plus the project's whole-program static check. The boop-testing-testing skill (skills/boop-testing-testing/SKILL.md) and the PROJECT.md beside it give this project's commands for both. If the project has no static check, run the focused tests alone.
|
|
911
|
+
- Run the full test suite only when the issue description asks for it. It runs once per branch, on the task that completes the branch, and not on the tasks before it.
|
|
897
912
|
6. Commit your changes with a message referencing ${issue.id}
|
|
898
913
|
7. Close the issue: bd close ${issue.id} --reason "<what you did>"
|
|
899
914
|
8. Push: git pull --rebase && git push
|
|
@@ -924,7 +939,7 @@ IMPORTANT: If the previous session marked this issue blocked with a diagnostic,
|
|
|
924
939
|
|
|
925
940
|
Otherwise, complete the workflow:
|
|
926
941
|
- Finish any remaining implementation
|
|
927
|
-
- Run tests
|
|
942
|
+
- Run the tests that step 5 of your first instructions describes: the issue's own test scope if it states one, otherwise the focused tests for what you changed plus the static check, and not the full suite unless the issue asks for it
|
|
928
943
|
- Commit changes referencing the issue ID
|
|
929
944
|
- Close the issue with bd close
|
|
930
945
|
- Push with: git pull --rebase && git push
|
|
@@ -957,6 +972,42 @@ function shouldRotateSession(peakContext, contextWindow, fraction = ROTATE_FRACT
|
|
|
957
972
|
const window = contextWindow > 0 ? contextWindow : DEFAULT_CONTEXT_WINDOW;
|
|
958
973
|
return peakContext >= fraction * window;
|
|
959
974
|
}
|
|
975
|
+
/**
|
|
976
|
+
* How many sessions in a row may end without doing any work before the loop
|
|
977
|
+
* stops. Fixed, with no flag: no caller needs a different number. A claim that
|
|
978
|
+
* fails before a session starts counts as one such session (boop-rfof).
|
|
979
|
+
*
|
|
980
|
+
* A claim that cannot start work costs four sessions at the default
|
|
981
|
+
* --resume-limit (the first run plus three resumes), so five means the loop has
|
|
982
|
+
* claimed an issue a second time and that claim did not start either. In the
|
|
983
|
+
* incident this rule comes from (boop-to45.2), expired credentials produced
|
|
984
|
+
* 1,543 such sessions in 64 minutes, each one turn long and over within a second.
|
|
985
|
+
*/
|
|
986
|
+
exports.NO_WORK_SESSION_LIMIT = 5;
|
|
987
|
+
/**
|
|
988
|
+
* Whether a session did any work, which here means it called at least one tool.
|
|
989
|
+
* A session cannot read a file, claim its issue or run a command without one,
|
|
990
|
+
* so a session with no tool calls started nothing, whatever the reason: expired
|
|
991
|
+
* or missing credentials, an upstream outage, a `claude` binary that is not
|
|
992
|
+
* there. The test deliberately ignores how long the session lasted and what its
|
|
993
|
+
* error said, because an outage that retries for a minute before giving up is
|
|
994
|
+
* the same fault as a credential check that fails at once.
|
|
995
|
+
*/
|
|
996
|
+
function sessionDidWork(run) {
|
|
997
|
+
return (run.toolCalls ?? 0) > 0;
|
|
998
|
+
}
|
|
999
|
+
/**
|
|
1000
|
+
* Split a session's stream-json output into complete lines. A stdout chunk ends
|
|
1001
|
+
* wherever the pipe buffer happened to fill, so the last piece of a chunk may be
|
|
1002
|
+
* the first part of a line whose remainder arrives in the next chunk: it comes
|
|
1003
|
+
* back as `rest`, for the caller to pass in as `carry` with the next chunk and
|
|
1004
|
+
* to flush when the stream closes. Pure so a test can feed it a line cut in two.
|
|
1005
|
+
*/
|
|
1006
|
+
function splitStreamLines(carry, chunk) {
|
|
1007
|
+
const pieces = (carry + chunk).split('\n');
|
|
1008
|
+
const rest = pieces.pop() ?? '';
|
|
1009
|
+
return { lines: pieces.filter(Boolean), rest };
|
|
1010
|
+
}
|
|
960
1011
|
function runClaude(prompt, opts, logFile, readableLogFile) {
|
|
961
1012
|
return new Promise((resolve) => {
|
|
962
1013
|
const args = [
|
|
@@ -980,7 +1031,7 @@ function runClaude(prompt, opts, logFile, readableLogFile) {
|
|
|
980
1031
|
const claudeBin = findClaudeBinary();
|
|
981
1032
|
if (!claudeBin) {
|
|
982
1033
|
console.error('\x1b[31m[bd-loop]\x1b[0m claude binary not found');
|
|
983
|
-
resolve({ exitCode: 1, limitHit: false });
|
|
1034
|
+
resolve({ exitCode: 1, limitHit: false, toolCalls: 0, finalText: 'claude binary not found' });
|
|
984
1035
|
return;
|
|
985
1036
|
}
|
|
986
1037
|
const logStream = fs.createWriteStream(logFile, { flags: 'a' });
|
|
@@ -1006,11 +1057,20 @@ function runClaude(prompt, opts, logFile, readableLogFile) {
|
|
|
1006
1057
|
// the run drives session rotation; contextWindow comes from the result.
|
|
1007
1058
|
let peakContext = 0;
|
|
1008
1059
|
let contextWindow;
|
|
1060
|
+
// What the no-work rule in runLoop needs from this session: whether it called
|
|
1061
|
+
// any tool, and what it said last.
|
|
1062
|
+
let toolCalls = 0;
|
|
1063
|
+
let lastAssistantText = '';
|
|
1064
|
+
let resultText = '';
|
|
1065
|
+
let lastStderrLine = '';
|
|
1009
1066
|
child.stdin.write(prompt);
|
|
1010
1067
|
child.stdin.end();
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1068
|
+
// A line longer than the pipe buffer arrives in several chunks, so the
|
|
1069
|
+
// unterminated tail of each chunk waits here for the rest of its line. The
|
|
1070
|
+
// decoder does the same for a multi-byte character cut between chunks.
|
|
1071
|
+
const stdoutDecoder = new string_decoder_1.StringDecoder('utf8');
|
|
1072
|
+
let stdoutTail = '';
|
|
1073
|
+
const readLines = (lines) => {
|
|
1014
1074
|
for (const line of lines) {
|
|
1015
1075
|
try {
|
|
1016
1076
|
const msg = JSON.parse(line);
|
|
@@ -1024,6 +1084,7 @@ function runClaude(prompt, opts, logFile, readableLogFile) {
|
|
|
1024
1084
|
.map((c) => c.text);
|
|
1025
1085
|
if (texts.length > 0) {
|
|
1026
1086
|
const textBlob = texts.join('');
|
|
1087
|
+
lastAssistantText = textBlob;
|
|
1027
1088
|
if (isStandaloneUsageLimitMessage(textBlob)) {
|
|
1028
1089
|
limitHit = true;
|
|
1029
1090
|
}
|
|
@@ -1036,6 +1097,7 @@ function runClaude(prompt, opts, logFile, readableLogFile) {
|
|
|
1036
1097
|
const tools = (msg.message?.content || [])
|
|
1037
1098
|
.filter((c) => c.type === 'tool_use')
|
|
1038
1099
|
.map((c) => `${c.name} ${JSON.stringify(c.input).substring(0, 80)}`);
|
|
1100
|
+
toolCalls += tools.length;
|
|
1039
1101
|
for (const t of tools) {
|
|
1040
1102
|
console.log(`\x1b[32m[bd-loop]\x1b[0m 🔧 ${t}`);
|
|
1041
1103
|
if (readableStream) {
|
|
@@ -1044,7 +1106,7 @@ function runClaude(prompt, opts, logFile, readableLogFile) {
|
|
|
1044
1106
|
}
|
|
1045
1107
|
}
|
|
1046
1108
|
else if (msg.type === 'result') {
|
|
1047
|
-
|
|
1109
|
+
resultText = typeof msg.result === 'string' ? msg.result : '';
|
|
1048
1110
|
if ((msg.is_error || msg.subtype === 'error') && isUsageLimitText(resultText)) {
|
|
1049
1111
|
limitHit = true;
|
|
1050
1112
|
}
|
|
@@ -1073,22 +1135,38 @@ function runClaude(prompt, opts, logFile, readableLogFile) {
|
|
|
1073
1135
|
}
|
|
1074
1136
|
}
|
|
1075
1137
|
}
|
|
1138
|
+
};
|
|
1139
|
+
child.stdout.on('data', (chunk) => {
|
|
1140
|
+
logStream.write(chunk);
|
|
1141
|
+
const split = splitStreamLines(stdoutTail, stdoutDecoder.write(chunk));
|
|
1142
|
+
stdoutTail = split.rest;
|
|
1143
|
+
readLines(split.lines);
|
|
1076
1144
|
});
|
|
1077
1145
|
child.stderr.on('data', (chunk) => {
|
|
1078
1146
|
logStream.write(chunk);
|
|
1147
|
+
const stderrLines = chunk.toString().split('\n').filter((l) => l.trim());
|
|
1148
|
+
if (stderrLines.length > 0)
|
|
1149
|
+
lastStderrLine = stderrLines[stderrLines.length - 1];
|
|
1079
1150
|
if (isStandaloneUsageLimitMessage(chunk.toString())) {
|
|
1080
1151
|
limitHit = true;
|
|
1081
1152
|
}
|
|
1082
1153
|
});
|
|
1083
1154
|
child.on('close', (code) => {
|
|
1155
|
+
// Output that ended without a newline is still a line.
|
|
1156
|
+
const lastLine = stdoutTail + stdoutDecoder.end();
|
|
1157
|
+
stdoutTail = '';
|
|
1158
|
+
if (lastLine)
|
|
1159
|
+
readLines([lastLine]);
|
|
1084
1160
|
logStream.end();
|
|
1085
1161
|
if (readableStream)
|
|
1086
1162
|
readableStream.end();
|
|
1087
|
-
|
|
1163
|
+
const finalText = (resultText || lastAssistantText || lastStderrLine).trim() || undefined;
|
|
1164
|
+
resolve({ exitCode: code ?? 1, limitHit, peakContext, contextWindow, toolCalls, finalText });
|
|
1088
1165
|
});
|
|
1089
1166
|
});
|
|
1090
1167
|
}
|
|
1091
1168
|
async function processIssue(issue, opts) {
|
|
1169
|
+
const runClaude = opts.runClaudeFn;
|
|
1092
1170
|
const sessionId = opts.session?.id ?? (0, crypto_1.randomUUID)();
|
|
1093
1171
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').substring(0, 19);
|
|
1094
1172
|
const logFile = path.join(opts.logDir, `${issue.id}_${timestamp}.log`);
|
|
@@ -1109,12 +1187,16 @@ async function processIssue(issue, opts) {
|
|
|
1109
1187
|
}
|
|
1110
1188
|
catch {
|
|
1111
1189
|
try {
|
|
1112
|
-
(0, child_process_1.execSync)(`git checkout ${branchName}
|
|
1190
|
+
(0, child_process_1.execSync)(`git checkout ${branchName}`, { encoding: 'utf8', cwd: opts.cwd, stdio: 'pipe' });
|
|
1113
1191
|
console.log(`\x1b[33m[bd-loop]\x1b[0m Branch ${branchName} exists, checked out`);
|
|
1114
1192
|
}
|
|
1115
|
-
catch {
|
|
1116
|
-
|
|
1117
|
-
|
|
1193
|
+
catch (e) {
|
|
1194
|
+
// The issue stays open and ready, so this claim counts as an attempt
|
|
1195
|
+
// that did no work; otherwise the loop would claim it again indefinitely.
|
|
1196
|
+
const gitError = String(e.stderr || e.message).trim();
|
|
1197
|
+
const claimFailure = `Failed to create/checkout branch ${branchName}: ${gitError}`;
|
|
1198
|
+
console.error(`\x1b[31m[bd-loop]\x1b[0m ${claimFailure}`);
|
|
1199
|
+
return { issueId: issue.id, closed: false, resumes: 0, logFile, limitHit: false, noWorkStreak: opts.noWorkStreak + 1, claimFailure };
|
|
1118
1200
|
}
|
|
1119
1201
|
}
|
|
1120
1202
|
}
|
|
@@ -1134,12 +1216,21 @@ async function processIssue(issue, opts) {
|
|
|
1134
1216
|
let anyLimitHit = initialRun.limitHit;
|
|
1135
1217
|
let peakContext = initialRun.peakContext ?? 0;
|
|
1136
1218
|
let contextWindow = initialRun.contextWindow;
|
|
1219
|
+
// Any session that does work starts the count again, so an issue that takes
|
|
1220
|
+
// many working sessions never approaches the limit.
|
|
1221
|
+
let noWorkStreak = sessionDidWork(initialRun) ? 0 : opts.noWorkStreak + 1;
|
|
1222
|
+
let lastSessionText = initialRun.finalText;
|
|
1137
1223
|
if (initialRun.limitHit) {
|
|
1138
1224
|
console.error('\x1b[31m[bd-loop]\x1b[0m Claude usage limit reached; stopping retries for this issue');
|
|
1139
1225
|
}
|
|
1140
1226
|
// Resume loop
|
|
1141
1227
|
let resumeCount = 0;
|
|
1142
1228
|
while (resumeCount < opts.resumeLimit && !initialRun.limitHit) {
|
|
1229
|
+
// Enough sessions in a row have done no work. Another resume would be one
|
|
1230
|
+
// more of the same, so hand back to runLoop, which stops the loop.
|
|
1231
|
+
if (noWorkStreak >= exports.NO_WORK_SESSION_LIMIT) {
|
|
1232
|
+
break;
|
|
1233
|
+
}
|
|
1143
1234
|
if (isIssueClosed(issue.id)) {
|
|
1144
1235
|
console.log(`\x1b[32m[bd-loop]\x1b[0m ✅ Issue ${issue.id} closed successfully`);
|
|
1145
1236
|
break;
|
|
@@ -1168,6 +1259,8 @@ async function processIssue(issue, opts) {
|
|
|
1168
1259
|
peakContext = resumeRun.peakContext;
|
|
1169
1260
|
if (resumeRun.contextWindow)
|
|
1170
1261
|
contextWindow = resumeRun.contextWindow;
|
|
1262
|
+
noWorkStreak = sessionDidWork(resumeRun) ? 0 : noWorkStreak + 1;
|
|
1263
|
+
lastSessionText = resumeRun.finalText;
|
|
1171
1264
|
if (resumeRun.limitHit) {
|
|
1172
1265
|
anyLimitHit = true;
|
|
1173
1266
|
console.error('\x1b[31m[bd-loop]\x1b[0m Claude usage limit reached during resume; stopping retries for this issue');
|
|
@@ -1179,7 +1272,7 @@ async function processIssue(issue, opts) {
|
|
|
1179
1272
|
console.log(`\x1b[32m[bd-loop]\x1b[0m ✅ Done: ${issue.id}`);
|
|
1180
1273
|
}
|
|
1181
1274
|
else {
|
|
1182
|
-
console.error(`\x1b[31m[bd-loop]\x1b[0m ⚠️ Issue ${issue.id} not closed after ${
|
|
1275
|
+
console.error(`\x1b[31m[bd-loop]\x1b[0m ⚠️ Issue ${issue.id} not closed after ${resumeCount} resume attempts`);
|
|
1183
1276
|
console.error(`\x1b[31m[bd-loop]\x1b[0m Check log: ${logFile}`);
|
|
1184
1277
|
}
|
|
1185
1278
|
if (originalBranch) {
|
|
@@ -1190,12 +1283,12 @@ async function processIssue(issue, opts) {
|
|
|
1190
1283
|
// Best effort
|
|
1191
1284
|
}
|
|
1192
1285
|
}
|
|
1193
|
-
return { issueId: issue.id, closed, resumes: resumeCount, logFile, limitHit: anyLimitHit, sessionId, peakContext, contextWindow };
|
|
1286
|
+
return { issueId: issue.id, closed, resumes: resumeCount, logFile, limitHit: anyLimitHit, sessionId, peakContext, contextWindow, noWorkStreak, lastSessionText };
|
|
1194
1287
|
}
|
|
1195
1288
|
function sleep(ms) {
|
|
1196
1289
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1197
1290
|
}
|
|
1198
|
-
async function runLoop(options) {
|
|
1291
|
+
async function runLoop(options, deps) {
|
|
1199
1292
|
let prefix = options.prefix;
|
|
1200
1293
|
const work = options.work;
|
|
1201
1294
|
const flow = options.flow;
|
|
@@ -1386,6 +1479,34 @@ async function runLoop(options) {
|
|
|
1386
1479
|
// Issue id at each session START (the first entry is the initial session, not a
|
|
1387
1480
|
// rotation). Actual rotations are everything after the first — see the summary.
|
|
1388
1481
|
const sessionStarts = [];
|
|
1482
|
+
// Attempts in a row that did no work, counted across claims and across
|
|
1483
|
+
// issues: sessions that called no tool, and claims that failed before a
|
|
1484
|
+
// session started. See NO_WORK_SESSION_LIMIT.
|
|
1485
|
+
let noWorkStreak = 0;
|
|
1486
|
+
// An issue left open is claimed again on the next pass, and --resume-limit
|
|
1487
|
+
// only bounds one claim, so without this stop a loop whose claims cannot
|
|
1488
|
+
// start work would claim the same issue indefinitely (boop-to45.2). It
|
|
1489
|
+
// applies under --poll as well: waiting does not repair a credential.
|
|
1490
|
+
// Returns whether the loop has to stop.
|
|
1491
|
+
const stopIfWorkIsNotStarting = (last) => {
|
|
1492
|
+
if (noWorkStreak < exports.NO_WORK_SESSION_LIMIT)
|
|
1493
|
+
return false;
|
|
1494
|
+
console.error(`\x1b[31m[bd-loop]\x1b[0m Stopping: the last ${noWorkStreak} attempts in a row did no work ` +
|
|
1495
|
+
`(a session that ended without calling a single tool, or a claim that failed before a session started), ` +
|
|
1496
|
+
`so work is not starting. This is NOT a clean finish.`);
|
|
1497
|
+
if (last.claimFailure) {
|
|
1498
|
+
console.error(`\x1b[31m[bd-loop]\x1b[0m The last claim, of ${last.issueId}, failed before a session started: ${last.claimFailure}`);
|
|
1499
|
+
}
|
|
1500
|
+
else {
|
|
1501
|
+
const said = last.lastSessionText
|
|
1502
|
+
? last.lastSessionText.replace(/\s+/gu, ' ').substring(0, 500)
|
|
1503
|
+
: '(nothing: it produced no output)';
|
|
1504
|
+
console.error(`\x1b[31m[bd-loop]\x1b[0m The last session, on ${last.issueId}, said: ${said}`);
|
|
1505
|
+
}
|
|
1506
|
+
console.error(`\x1b[31m[bd-loop]\x1b[0m Fix that cause and start the loop again.${last.logFile ? ` Log: ${last.logFile}` : ''}`);
|
|
1507
|
+
process.exitCode = 1;
|
|
1508
|
+
return true;
|
|
1509
|
+
};
|
|
1389
1510
|
while (true) {
|
|
1390
1511
|
let nextId = null;
|
|
1391
1512
|
if (prefix) {
|
|
@@ -1470,7 +1591,12 @@ async function runLoop(options) {
|
|
|
1470
1591
|
// Process the issue
|
|
1471
1592
|
const issue = getIssueInfo(nextId);
|
|
1472
1593
|
if (!issue) {
|
|
1473
|
-
|
|
1594
|
+
const claimFailure = `Failed to get issue details for ${nextId}`;
|
|
1595
|
+
console.error(`\x1b[31m[bd-loop]\x1b[0m ${claimFailure}`);
|
|
1596
|
+
// The issue is still ready, so the next pass finds it again at once.
|
|
1597
|
+
noWorkStreak++;
|
|
1598
|
+
if (stopIfWorkIsNotStarting({ issueId: nextId, claimFailure }))
|
|
1599
|
+
break;
|
|
1474
1600
|
continue;
|
|
1475
1601
|
}
|
|
1476
1602
|
// Decide the session this issue runs under. Reuse a healthy carried session;
|
|
@@ -1502,8 +1628,11 @@ async function runLoop(options) {
|
|
|
1502
1628
|
logDir,
|
|
1503
1629
|
cwd: loopCwd,
|
|
1504
1630
|
session,
|
|
1631
|
+
noWorkStreak,
|
|
1632
|
+
runClaudeFn: deps?.runClaudeFn || runClaude,
|
|
1505
1633
|
});
|
|
1506
1634
|
results.push(result);
|
|
1635
|
+
noWorkStreak = result.noWorkStreak;
|
|
1507
1636
|
if (result.closed)
|
|
1508
1637
|
totalClosed++;
|
|
1509
1638
|
// Fold this issue's observed context back into the carried session so the next
|
|
@@ -1521,6 +1650,9 @@ async function runLoop(options) {
|
|
|
1521
1650
|
console.error('\x1b[31m[bd-loop]\x1b[0m Credit/usage limit hit — stopping loop');
|
|
1522
1651
|
break;
|
|
1523
1652
|
}
|
|
1653
|
+
// A failed claim wrote no session log, so there is none to point at.
|
|
1654
|
+
if (stopIfWorkIsNotStarting(result.claimFailure ? { ...result, logFile: undefined } : result))
|
|
1655
|
+
break;
|
|
1524
1656
|
}
|
|
1525
1657
|
// Always-worktree: leave the worktree and branch in place for the operator to
|
|
1526
1658
|
// review and merge by hand (manual merge is the v1 contract). No checkout-back —
|
package/docs/loop.md
CHANGED
|
@@ -29,6 +29,8 @@ Without `--run`, the command shows what it would do without executing anything.
|
|
|
29
29
|
|
|
30
30
|
Each Claude session gets a full prompt with issue details and instructions to work autonomously through the complete workflow: claim, implement, test, commit, close, and push.
|
|
31
31
|
|
|
32
|
+
The prompt does not tell a session to run the whole test suite. If the issue says which tests to run, the session runs those. Otherwise it runs the focused tests for the files it changed plus the project's static check, using the commands the `boop-testing-testing` skill and the `PROJECT.md` beside it give. The full suite runs only when an issue asks for it, which is normally the task that completes the branch, and again when `boop loop --merge` integrates the branch.
|
|
33
|
+
|
|
32
34
|
### Planning mode (`--work` + `--flow`)
|
|
33
35
|
|
|
34
36
|
When `--work` and `--flow` are provided, boop loop runs a **plan-then-execute pipeline**:
|
|
@@ -120,7 +122,8 @@ Session logs are written to `labs/claude-runs/` in the project directory. Each i
|
|
|
120
122
|
|
|
121
123
|
- **Dry-run by default** — nothing executes without `--run`
|
|
122
124
|
- **Budget caps** — each issue is capped at `--budget` USD
|
|
123
|
-
- **Resume limits** —
|
|
125
|
+
- **Resume limits** — bounds the sessions spent on one claim of an issue (default: 3 resumes after the first run)
|
|
126
|
+
- **Sessions that do no work stop the loop** — if five sessions in a row end without calling a single tool, the loop prints what the last one said and stops with exit code 1. This is what ends a run whose sessions cannot start, for example because the Claude Code credentials have expired or the API is down, which would otherwise claim the same issue again indefinitely. A claim that fails before any session starts counts as well: the issue's details could not be read, or with `--per-issue-branch` its branch could not be created or checked out. The loop then prints that failure in place of a session's last words. Any session that does work sets the count back to zero. The number is fixed.
|
|
124
127
|
- **Permission skipping** — enabled by default for unattended operation; use `--no-skip-perms` to require approval for each tool call
|
|
125
128
|
- **A failed queue read is never a clean finish** — if the loop cannot read the ready queue it says so and stops with exit code 1, rather than reporting "No more ready issues" and exiting 0. See [Reading the ready queue](#reading-the-ready-queue).
|
|
126
129
|
|
package/package.json
CHANGED
|
@@ -26,6 +26,29 @@ await MessageQueueService.publish('batch-ready', [message1, message2]);
|
|
|
26
26
|
- **Error recovery**: If `publish()` catches an error (e.g., channel closed), it resets connection state and returns `false` — the next call will reconnect
|
|
27
27
|
- **Shutdown**: The publish connection is tracked and closed by `close()` alongside the listener connection
|
|
28
28
|
|
|
29
|
+
### Publishing to a queue that does not exist
|
|
30
|
+
|
|
31
|
+
`publish()` does not declare the queue; only `listen()` does.
|
|
32
|
+
A broker confirms a message that it cannot put in any queue and then discards it,
|
|
33
|
+
so a publish to a name that no consumer has ever listened on returns `true` and the message is lost.
|
|
34
|
+
|
|
35
|
+
Pass `{ mandatory: true }` as the third argument when the publisher cannot be sure the queue exists:
|
|
36
|
+
when the name is composed at run time (for example with `targetPrefix`),
|
|
37
|
+
or when the publisher can start before the consumer ever has.
|
|
38
|
+
|
|
39
|
+
```typescript
|
|
40
|
+
const queued = await MessageQueueService.publish('batch-ready', message, { targetPrefix, mandatory: true });
|
|
41
|
+
if (!queued) {
|
|
42
|
+
// Not queued anywhere: record or report it.
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
- The message is sent with the AMQP `mandatory` flag, and the broker returns it when no queue has the destination's name
|
|
47
|
+
- `publish()` then logs `publish to "<queue>" was returned by the broker: no queue has that name` and returns `false`
|
|
48
|
+
- The connection state is **not** reset in this case — the connection is healthy, and the next `publish()` reuses it
|
|
49
|
+
- A queue that exists but has no consumer running still takes the message; the option reports a missing queue, not a missing worker
|
|
50
|
+
- Without the option, `publish()` is unchanged
|
|
51
|
+
|
|
29
52
|
### Why persistent (not per-call)
|
|
30
53
|
|
|
31
54
|
The original implementation opened a new AMQP connection for every `publish()` call, then immediately closed it. This works for infrequent publishing but fails under rapid iteration (e.g., `ch-export-push` publishing 177 batch-ready messages in seconds). CloudAMQP's shared plan rate-limits connection churn, causing `ECONNREFUSED` errors.
|
|
@@ -44,8 +67,12 @@ The original implementation opened a new AMQP connection for every `publish()` c
|
|
|
44
67
|
|
|
45
68
|
- **Lazy init**: No connection is opened until the first `listen()` call
|
|
46
69
|
- **Shared**: All listeners share the same connection; each gets its own channel with independent prefetch and consumer
|
|
47
|
-
- **Auto-reconnect**: If the connection drops (`error` or `close` event), the
|
|
48
|
-
- **
|
|
70
|
+
- **Auto-reconnect**: If the connection drops (`error` or `close` event), the service opens one new connection and attaches every consumer again — same queue, options and handler — without another `listen()` call. While the broker is unreachable it retries, waiting 1s and doubling up to 30s
|
|
71
|
+
- **Logging**: The drop is logged at `warn` (`listener connection lost … re-attaching N consumer(s)`), each failed attempt at `error`, and each consumer that is consuming again at `info` (`re-attached consumer on "<queue>"`)
|
|
72
|
+
- **Redelivery**: A message that was delivered but not yet ACKed when the connection dropped is delivered again by the broker, so a handler can see the same message twice. Handlers must tolerate processing a message a second time. This repeat does not count as a retry (see [Retry & Dead Letter Queue](#retry--dead-letter-queue-dlq)). An ACK or NACK for the first delivery — including a deferred one in manual-ACK mode — cannot be sent on the closed channel; the service logs it at `warn` and does not throw
|
|
73
|
+
- **Cancelled consumers**: A `Subscription` that was cancelled is not attached again. `cancel()` after a re-attach stops the consumer that is current at that moment, and `consumerTag` reports the new broker-assigned tag
|
|
74
|
+
- **Closed channel**: Only the connection is watched. A single consumer's channel that the broker closes while the connection stays up is not re-attached
|
|
75
|
+
- **Shutdown**: The listener connection is closed by `close()` alongside the publish connection. `close()` ends consumption for good and never starts a re-attach
|
|
49
76
|
|
|
50
77
|
### Why shared (not per-queue)
|
|
51
78
|
|
|
@@ -61,10 +88,11 @@ The `MessageQueueService.listen()` function supports two modes controlled by `op
|
|
|
61
88
|
MessageQueueService.listen('queue-name', handler);
|
|
62
89
|
```
|
|
63
90
|
|
|
64
|
-
- Handler signature: `async handler(msg) → boolean`
|
|
91
|
+
- Handler signature: `async handler(msg, delivery) → boolean`
|
|
65
92
|
- Returns `true` → message ACKed immediately
|
|
66
|
-
- Returns `false` →
|
|
67
|
-
- Throws →
|
|
93
|
+
- Returns `false` → the message is delivered again after a backoff, and moves to the DLQ when its last attempt fails
|
|
94
|
+
- Throws → the same as returning `false`, and the error is logged
|
|
95
|
+
- `delivery` is `{ retryCount, lastAttempt }` (the `Delivery` type exported by `@boopkit/cap-background-jobs`); a handler that takes only `msg` keeps working
|
|
68
96
|
- **Use case**: Simple request-response consumers (e.g., entity replication, long running jobs)
|
|
69
97
|
|
|
70
98
|
### Manual-ACK Mode
|
|
@@ -76,7 +104,8 @@ MessageQueueService.listen('queue-name', handler, { manualAck: true, prefetch: N
|
|
|
76
104
|
- Handler signature: `async handler(msg, ack, nack)`
|
|
77
105
|
- `ack()` and `nack()` are guarded — only the first call takes effect
|
|
78
106
|
- Handler is responsible for calling `ack()` or `nack()` (can be deferred)
|
|
79
|
-
-
|
|
107
|
+
- `nack()`, a throw without settling, or resolving without settling → the message is delivered again after a backoff, and moves to the DLQ when its last attempt fails
|
|
108
|
+
- The handler does not receive a `Delivery`
|
|
80
109
|
- **Use case**: Buffered/batch consumers where ACK is deferred until a batch operation completes
|
|
81
110
|
|
|
82
111
|
## Retry & Dead Letter Queue (DLQ)
|
|
@@ -90,10 +119,34 @@ Every queue gets automatic retry and DLQ infrastructure:
|
|
|
90
119
|
| `{name}.failed` | DLQ — messages that exceed max retries land here |
|
|
91
120
|
|
|
92
121
|
**Retry behavior**:
|
|
93
|
-
-
|
|
94
|
-
-
|
|
122
|
+
- A failed message is ACKed and a copy is published to the retry queue's exchange (`{name}.retry.exchange`) with a TTL. When the TTL expires the broker dead-letters it to the main exchange, which delivers it to the main queue, and the handler is called again
|
|
123
|
+
- Exponential backoff: `min(BASE * 2^retryCount, MAX)` — BASE=4s, MAX=60s, so the waits are 4s, 8s, 16s, 32s, then 60s
|
|
124
|
+
- Max retries: 5 (configurable via `MAX_MESSAGE_RETRIES` env var). With the default, a handler that keeps failing is called six times: the first delivery and five retries
|
|
95
125
|
- Retry count tracked in `x-retry-count` message header
|
|
96
|
-
-
|
|
126
|
+
- When the last attempt fails → the message is published to the `{name}.failed` queue and the delivery is ACKed
|
|
127
|
+
|
|
128
|
+
**Handlers must be safe to run more than once for one message.** Every failure leads to another call with the same payload, so a side effect the handler completed before it failed (an email sent, a row inserted) happens again unless the handler checks first. Make the work idempotent: check whether it was already done, or upsert on an id carried in the payload.
|
|
129
|
+
|
|
130
|
+
Versions of `@boopkit/cap-background-jobs` up to 0.4.3 did not redeliver: the broker discarded a failed message when its retry TTL expired, and nothing reached the DLQ. A project upgrading from one of those versions has handlers that have never been called twice for a message, so review each one.
|
|
131
|
+
|
|
132
|
+
**The last attempt.** An auto-ACK handler's second argument tells it which delivery it is handling:
|
|
133
|
+
|
|
134
|
+
| Field | Meaning |
|
|
135
|
+
|-------|---------|
|
|
136
|
+
| `retryCount` | How many times the message has already been retried; `0` on its first delivery |
|
|
137
|
+
| `lastAttempt` | `true` on the delivery whose failure moves the message to the DLQ, `false` while retries remain |
|
|
138
|
+
|
|
139
|
+
Use `lastAttempt` for something that should happen once per failed message and not once per attempt:
|
|
140
|
+
|
|
141
|
+
```typescript
|
|
142
|
+
import type { Delivery } from '@boopkit/cap-background-jobs';
|
|
143
|
+
|
|
144
|
+
async function onMessage(msg: unknown, delivery: Delivery): Promise<boolean> {
|
|
145
|
+
const ok = await recordInboundEmail(msg);
|
|
146
|
+
if (!ok && delivery.lastAttempt) await notifyAdmin('an inbound email failed on every attempt', msg);
|
|
147
|
+
return ok;
|
|
148
|
+
}
|
|
149
|
+
```
|
|
97
150
|
|
|
98
151
|
## Buffered Batch ACK Pattern
|
|
99
152
|
|
|
@@ -23,5 +23,5 @@ Read [reference/boop-guide.ref.md](reference/boop-guide.ref.md) to understand ho
|
|
|
23
23
|
- `skills/` — process skills for agents (each skill is a folder with a `SKILL.md`); also linked from `.claude/skills/` for Claude Code's built-in skill discovery
|
|
24
24
|
- `reference/` — architecture and coding standards ("what exists now")
|
|
25
25
|
- `specs/` — design docs and specifications ("future state")
|
|
26
|
-
- `tests/` —
|
|
26
|
+
- `tests/` — tests. Before you run or write one, read [skills/boop-testing-testing/SKILL.md](skills/boop-testing-testing/SKILL.md) and [skills/boop-testing-testing/PROJECT.md](skills/boop-testing-testing/PROJECT.md): they say which tests a change runs, when the whole suite runs, and this project's commands for both
|
|
27
27
|
- `.boop/` — project manifest (managed by boop)
|