@alecsibilia/luca 14.0.0-alpha.12 → 14.0.0-alpha.13
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/CHANGELOG.md +4 -0
- package/board/package.json +1 -1
- package/board/server/luca-version.ts +1 -1
- package/engine/config/config-reload.ts +21 -3
- package/engine/config/engine-config.ts +17 -0
- package/engine/core/execute-build.ts +8 -1
- package/engine/gates/gate-runner.ts +17 -11
- package/engine/gates/test-runner.ts +21 -3
- package/engine/shell/run-command.ts +26 -0
- package/package.json +1 -1
- package/skills/luca-unstick/SKILL.md +1 -1
package/CHANGELOG.md
CHANGED
package/board/package.json
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The Luca version of this board, stamped when the package was packed. */
|
|
2
|
-
export const LUCA_VERSION: string = "14.0.0-alpha.
|
|
2
|
+
export const LUCA_VERSION: string = "14.0.0-alpha.13"
|
|
@@ -7,20 +7,24 @@ import type { EngineConfig } from './engine-config'
|
|
|
7
7
|
/**
|
|
8
8
|
* The config fields a resumed run takes from the repo's
|
|
9
9
|
* `.luca/config.json` again (#516). They say how the engine builds a
|
|
10
|
-
* checkout before its tests,
|
|
11
|
-
* them halfway without making its earlier
|
|
12
|
-
* a slow `prepare` for a fast cached one
|
|
10
|
+
* checkout before its tests, or how long a test may run, not what the
|
|
11
|
+
* tests are, so a run can change them halfway without making its earlier
|
|
12
|
+
* results wrong: a repo that swaps a slow `prepare` for a fast cached one
|
|
13
|
+
* gets it on the next resume.
|
|
13
14
|
*
|
|
14
15
|
* - `prepare`: the command run before each test run in a checkout.
|
|
15
16
|
* - `prepare_timeout_ms`: how long that command may run.
|
|
16
17
|
* - `prepare_concurrency`: how many prepare runs may go at once. The
|
|
17
18
|
* prepare slots take the limit on each call (`prepareCheck`), so a new
|
|
18
19
|
* one counts from the next prepare run.
|
|
20
|
+
* - `test_timeout_ms`: how long each test command may run (#542). Each run
|
|
21
|
+
* of a test command reads it then, so a new one counts from the next.
|
|
19
22
|
*/
|
|
20
23
|
export const RELOADED_FIELDS = [
|
|
21
24
|
'prepare',
|
|
22
25
|
'prepare_timeout_ms',
|
|
23
26
|
'prepare_concurrency',
|
|
27
|
+
'test_timeout_ms',
|
|
24
28
|
] as const
|
|
25
29
|
|
|
26
30
|
/**
|
|
@@ -79,6 +83,11 @@ export const ConfigChangeSchema = z.discriminatedUnion('field', [
|
|
|
79
83
|
from: positiveInt,
|
|
80
84
|
to: positiveInt,
|
|
81
85
|
}),
|
|
86
|
+
z.object({
|
|
87
|
+
field: z.literal('test_timeout_ms'),
|
|
88
|
+
from: positiveInt,
|
|
89
|
+
to: positiveInt,
|
|
90
|
+
}),
|
|
82
91
|
])
|
|
83
92
|
|
|
84
93
|
export type ConfigChange = z.infer<typeof ConfigChangeSchema>
|
|
@@ -115,6 +124,11 @@ export const configChanges = ({
|
|
|
115
124
|
from: current.prepare_concurrency ?? null,
|
|
116
125
|
to: next.prepare_concurrency ?? null,
|
|
117
126
|
},
|
|
127
|
+
{
|
|
128
|
+
field: 'test_timeout_ms',
|
|
129
|
+
from: current.test_timeout_ms ?? null,
|
|
130
|
+
to: next.test_timeout_ms ?? null,
|
|
131
|
+
},
|
|
118
132
|
]
|
|
119
133
|
return all.filter(({ from, to }) => from !== to)
|
|
120
134
|
}
|
|
@@ -139,6 +153,10 @@ const applyChange = ({
|
|
|
139
153
|
return change.to === null
|
|
140
154
|
? omit(config, 'prepare_concurrency')
|
|
141
155
|
: { ...config, prepare_concurrency: change.to }
|
|
156
|
+
case 'test_timeout_ms':
|
|
157
|
+
return change.to === null
|
|
158
|
+
? omit(config, 'test_timeout_ms')
|
|
159
|
+
: { ...config, test_timeout_ms: change.to }
|
|
142
160
|
}
|
|
143
161
|
}
|
|
144
162
|
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
DecisionModelIdSchema,
|
|
8
8
|
type DecisionModelId,
|
|
9
9
|
} from '../jev/jev-schemas'
|
|
10
|
+
import { DEFAULT_COMMAND_TIMEOUT_MS } from '../shell/run-command'
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* The engine config file, relative to the root of the repo a run works on.
|
|
@@ -86,6 +87,11 @@ export const EngineConfigSchema = z.object({
|
|
|
86
87
|
* engine's default (`DEFAULT_PREPARE_CONCURRENCY`, 1).
|
|
87
88
|
*/
|
|
88
89
|
prepare_concurrency: z.number().int().positive().optional(),
|
|
90
|
+
/**
|
|
91
|
+
* How long each test command may run, in milliseconds; left out, the
|
|
92
|
+
* engine's default (`DEFAULT_COMMAND_TIMEOUT_MS`, 5 minutes).
|
|
93
|
+
*/
|
|
94
|
+
test_timeout_ms: z.number().int().positive().optional(),
|
|
89
95
|
test_file_patterns: z.array(z.string()).default(['**/*.test.ts']),
|
|
90
96
|
test_setup_files: z.array(z.string()).default([]),
|
|
91
97
|
rule_files: z.array(z.string()).default([]),
|
|
@@ -121,6 +127,17 @@ export const prepareTimeoutOf = ({
|
|
|
121
127
|
config: EngineConfig
|
|
122
128
|
}): number => config.prepare_timeout_ms ?? DEFAULT_PREPARE_TIMEOUT_MS
|
|
123
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Each test command's time limit (#542): the config's `test_timeout_ms`,
|
|
132
|
+
* else the engine's default for any command. It holds for every run of a
|
|
133
|
+
* test command (baseline, red check, gates); types and lint keep the default.
|
|
134
|
+
*
|
|
135
|
+
* @example
|
|
136
|
+
* testTimeoutOf({ config: { ...config, test_timeout_ms: 900_000 } }) // 900_000
|
|
137
|
+
*/
|
|
138
|
+
export const testTimeoutOf = ({ config }: { config: EngineConfig }): number =>
|
|
139
|
+
config.test_timeout_ms ?? DEFAULT_COMMAND_TIMEOUT_MS
|
|
140
|
+
|
|
124
141
|
/**
|
|
125
142
|
* How many prepare runs may go at once when the config doesn't say: 1. A
|
|
126
143
|
* build is often heavy on the CPU, so builds at the same time only slow each
|
|
@@ -19,7 +19,11 @@ import {
|
|
|
19
19
|
type AgentRole,
|
|
20
20
|
type RoleResult,
|
|
21
21
|
} from '../agents/role-results'
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
bunTestCommands,
|
|
24
|
+
testTimeoutOf,
|
|
25
|
+
type EngineConfig,
|
|
26
|
+
} from '../config/engine-config'
|
|
23
27
|
import { checkAlreadyDone } from '../gates/already-done-check'
|
|
24
28
|
import { prepareCheck, runGates, shellCheck } from '../gates/gate-runner'
|
|
25
29
|
import { endedText } from '../gates/gate-schemas'
|
|
@@ -291,6 +295,7 @@ const runRedCheck = async ({
|
|
|
291
295
|
cwd: path,
|
|
292
296
|
commands: bunTestCommands(context),
|
|
293
297
|
test_files,
|
|
298
|
+
timeout_ms: testTimeoutOf({ config: context.config }),
|
|
294
299
|
report_file: await reportFile({
|
|
295
300
|
context,
|
|
296
301
|
ticket: action.ticket,
|
|
@@ -409,6 +414,7 @@ const checkAlreadyDoneIn = async ({
|
|
|
409
414
|
onlyTestFiles({ command, files: runnable })
|
|
410
415
|
),
|
|
411
416
|
test_files: runnable,
|
|
417
|
+
timeout_ms: testTimeoutOf({ config: context.config }),
|
|
412
418
|
report_file: await reportFile({
|
|
413
419
|
context,
|
|
414
420
|
ticket: action.ticket,
|
|
@@ -1394,6 +1400,7 @@ export const executeBuildAction = async ({
|
|
|
1394
1400
|
cwd: path,
|
|
1395
1401
|
commands: bunTestCommands(context),
|
|
1396
1402
|
test_files: await testFilesIn({ context, cwd: path }),
|
|
1403
|
+
timeout_ms: testTimeoutOf({ config: context.config }),
|
|
1397
1404
|
report_file: await reportFile({
|
|
1398
1405
|
context,
|
|
1399
1406
|
ticket: action.ticket,
|
|
@@ -1,27 +1,21 @@
|
|
|
1
1
|
import type { GateCheck, GateName } from './gate-schemas'
|
|
2
2
|
import { PREPARE_SLOTS } from './prepare-slots'
|
|
3
|
-
import { reportFileFor, runTests } from './test-runner'
|
|
3
|
+
import { reportFileFor, runTests, TEST_TIMEOUT_HINT } from './test-runner'
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
6
|
prepareConcurrencyOf,
|
|
7
7
|
prepareTimeoutOf,
|
|
8
8
|
testCommands,
|
|
9
|
+
testTimeoutOf,
|
|
9
10
|
type EngineConfig,
|
|
10
11
|
} from '../config/engine-config'
|
|
11
12
|
import {
|
|
12
13
|
clipOutput,
|
|
13
14
|
DEFAULT_COMMAND_TIMEOUT_MS,
|
|
14
15
|
runShell,
|
|
16
|
+
timedOutLine,
|
|
15
17
|
} from '../shell/run-command'
|
|
16
18
|
|
|
17
|
-
/** A time limit in words: `30 minutes`, `1 minute`, `0.3 seconds`. */
|
|
18
|
-
const durationText = (ms: number): string => {
|
|
19
|
-
const [amount, unit] =
|
|
20
|
-
ms >= 60_000 ? [ms / 60_000, 'minute'] : [ms / 1000, 'second']
|
|
21
|
-
const rounded = Math.round(amount * 10) / 10
|
|
22
|
-
return `${rounded} ${unit}${rounded === 1 ? '' : 's'}`
|
|
23
|
-
}
|
|
24
|
-
|
|
25
19
|
/**
|
|
26
20
|
* Runs one shell command as a named check: `ok` on exit 0, with the clipped
|
|
27
21
|
* output only when it failed. One that runs past `timeout_ms` is stopped,
|
|
@@ -50,7 +44,7 @@ export const shellCheck = async ({
|
|
|
50
44
|
const result = await runShell({ command, cwd, timeout_ms: limit })
|
|
51
45
|
const ok = result.exit_code === 0
|
|
52
46
|
const timedOut = result.timed_out
|
|
53
|
-
?
|
|
47
|
+
? timedOutLine({ timeout_ms: limit, hint: timeout_hint })
|
|
54
48
|
: ''
|
|
55
49
|
return {
|
|
56
50
|
name,
|
|
@@ -106,6 +100,8 @@ export const prepareCheck = async ({
|
|
|
106
100
|
* types, lint. All must pass. Unset gates are left out; intake refuses a
|
|
107
101
|
* config with no test command. A `bun` test command runs with bun's JUnit
|
|
108
102
|
* reporter; a `pass_fail` one runs as written and is judged by its exit code.
|
|
103
|
+
* Each test command gets the config's time limit (see `testTimeoutOf`);
|
|
104
|
+
* types and lint get the engine's default.
|
|
109
105
|
*
|
|
110
106
|
* With an `install` command (see `installCommand`), the engine runs it first,
|
|
111
107
|
* as the `install` check. A failed install stops there: the other gates
|
|
@@ -152,11 +148,20 @@ export const runGates = async ({
|
|
|
152
148
|
if (!prepared.ok) return { ok: false, checks }
|
|
153
149
|
}
|
|
154
150
|
const { types, lint } = config.checks
|
|
151
|
+
const timeout_ms = testTimeoutOf({ config })
|
|
155
152
|
for (const [index, { run: command, results }] of testCommands({
|
|
156
153
|
config,
|
|
157
154
|
}).entries()) {
|
|
158
155
|
if (results === 'pass_fail') {
|
|
159
|
-
checks.push(
|
|
156
|
+
checks.push(
|
|
157
|
+
await shellCheck({
|
|
158
|
+
name: 'test',
|
|
159
|
+
command,
|
|
160
|
+
cwd,
|
|
161
|
+
timeout_ms,
|
|
162
|
+
timeout_hint: TEST_TIMEOUT_HINT,
|
|
163
|
+
})
|
|
164
|
+
)
|
|
160
165
|
continue
|
|
161
166
|
}
|
|
162
167
|
const run = await runTests({
|
|
@@ -164,6 +169,7 @@ export const runGates = async ({
|
|
|
164
169
|
command,
|
|
165
170
|
test_files,
|
|
166
171
|
report_file: reportFileFor({ report_file, index }),
|
|
172
|
+
timeout_ms,
|
|
167
173
|
})
|
|
168
174
|
checks.push({
|
|
169
175
|
name: 'test',
|
|
@@ -2,7 +2,7 @@ import { existsSync } from 'node:fs'
|
|
|
2
2
|
|
|
3
3
|
import type { TestCase, TestRun } from './gate-schemas'
|
|
4
4
|
|
|
5
|
-
import { clipOutput, runShell } from '../shell/run-command'
|
|
5
|
+
import { clipOutput, runShell, timedOutLine } from '../shell/run-command'
|
|
6
6
|
|
|
7
7
|
const decode = (text: string): string =>
|
|
8
8
|
text
|
|
@@ -83,6 +83,13 @@ export const testFilesAmong = ({
|
|
|
83
83
|
|
|
84
84
|
const NO_TEST_FILES = /0 test files matching|No tests found!/
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* How to give a test command longer, for the timed-out message. Every test
|
|
88
|
+
* command says the same, whatever its kind (#542).
|
|
89
|
+
*/
|
|
90
|
+
export const TEST_TIMEOUT_HINT =
|
|
91
|
+
'To give it longer, raise `test_timeout_ms` in `.luca/config.json`.'
|
|
92
|
+
|
|
86
93
|
/**
|
|
87
94
|
* Runs the engine config's test command with bun's JUnit reporter, so each
|
|
88
95
|
* test case's outcome is known. The command must be a `bun test` command; the
|
|
@@ -90,22 +97,29 @@ const NO_TEST_FILES = /0 test files matching|No tests found!/
|
|
|
90
97
|
*
|
|
91
98
|
* A repo with no test files counts as a pass: `bun test` exits 1 there, but a
|
|
92
99
|
* baseline with nothing to break is fine.
|
|
100
|
+
*
|
|
101
|
+
* One that runs past `timeout_ms` (see `testTimeoutOf`) is stopped, with
|
|
102
|
+
* everything it started, and fails; its output starts by saying so, and how
|
|
103
|
+
* to give it longer (#542).
|
|
93
104
|
*/
|
|
94
105
|
export const runTests = async ({
|
|
95
106
|
cwd,
|
|
96
107
|
command,
|
|
97
108
|
test_files,
|
|
98
109
|
report_file,
|
|
110
|
+
timeout_ms,
|
|
99
111
|
}: {
|
|
100
112
|
cwd: string
|
|
101
113
|
command: string
|
|
102
114
|
/** The worktree's test files, to spot files that produced no results. */
|
|
103
115
|
test_files: string[]
|
|
104
116
|
report_file: string
|
|
117
|
+
timeout_ms: number
|
|
105
118
|
}): Promise<TestRun> => {
|
|
106
119
|
const shell = await runShell({
|
|
107
120
|
command: `${command} --reporter=junit --reporter-outfile='${report_file}'`,
|
|
108
121
|
cwd,
|
|
122
|
+
timeout_ms,
|
|
109
123
|
})
|
|
110
124
|
const output = `${shell.stdout}\n${shell.stderr}`
|
|
111
125
|
const noTestFiles = NO_TEST_FILES.test(output)
|
|
@@ -124,7 +138,7 @@ export const runTests = async ({
|
|
|
124
138
|
files_without_results: test_files.filter(
|
|
125
139
|
(file) => !withResults.has(file)
|
|
126
140
|
),
|
|
127
|
-
output: clipOutput({ text: output })
|
|
141
|
+
output: `${shell.timed_out ? timedOutLine({ timeout_ms, hint: TEST_TIMEOUT_HINT }) : ''}${clipOutput({ text: output })}`,
|
|
128
142
|
}
|
|
129
143
|
}
|
|
130
144
|
|
|
@@ -176,18 +190,21 @@ export const reportFileFor = ({
|
|
|
176
190
|
* empty passing run.
|
|
177
191
|
*
|
|
178
192
|
* @example
|
|
179
|
-
* const run = await runBunTests({ cwd, commands: ['bun test'], test_files, report_file })
|
|
193
|
+
* const run = await runBunTests({ cwd, commands: ['bun test'], test_files, report_file, timeout_ms: testTimeoutOf({ config }) })
|
|
180
194
|
*/
|
|
181
195
|
export const runBunTests = async ({
|
|
182
196
|
cwd,
|
|
183
197
|
commands,
|
|
184
198
|
test_files,
|
|
185
199
|
report_file,
|
|
200
|
+
timeout_ms,
|
|
186
201
|
}: {
|
|
187
202
|
cwd: string
|
|
188
203
|
commands: string[]
|
|
189
204
|
test_files: string[]
|
|
190
205
|
report_file: string
|
|
206
|
+
/** Each command's time limit (see `runTests`). */
|
|
207
|
+
timeout_ms: number
|
|
191
208
|
}): Promise<TestRun> => {
|
|
192
209
|
const runs: TestRun[] = []
|
|
193
210
|
for (const [index, command] of commands.entries()) {
|
|
@@ -197,6 +214,7 @@ export const runBunTests = async ({
|
|
|
197
214
|
command,
|
|
198
215
|
test_files,
|
|
199
216
|
report_file: reportFileFor({ report_file, index }),
|
|
217
|
+
timeout_ms,
|
|
200
218
|
})
|
|
201
219
|
)
|
|
202
220
|
}
|
|
@@ -172,6 +172,32 @@ export const runShell = ({
|
|
|
172
172
|
}): Promise<CommandResult> =>
|
|
173
173
|
runCommand({ cmd: ['sh', '-c', command], cwd, timeout_ms })
|
|
174
174
|
|
|
175
|
+
/** A time limit in words: `30 minutes`, `1 minute`, `0.3 seconds`. */
|
|
176
|
+
export const durationText = (ms: number): string => {
|
|
177
|
+
const [amount, unit] =
|
|
178
|
+
ms >= 60_000 ? [ms / 60_000, 'minute'] : [ms / 1000, 'second']
|
|
179
|
+
const rounded = Math.round(amount * 10) / 10
|
|
180
|
+
return `${rounded} ${unit}${rounded === 1 ? '' : 's'}`
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The line a timed-out command's output starts with: how long it had, that
|
|
185
|
+
* the engine stopped it, then `hint`, if any.
|
|
186
|
+
*
|
|
187
|
+
* @example
|
|
188
|
+
* timedOutLine({ timeout_ms: 300_000, hint: 'To give it longer, raise `test_timeout_ms`.' })
|
|
189
|
+
* // 'Timed out after 5 minutes, so the engine stopped it and everything it started. To give it longer, raise `test_timeout_ms`.\n'
|
|
190
|
+
*/
|
|
191
|
+
export const timedOutLine = ({
|
|
192
|
+
timeout_ms,
|
|
193
|
+
hint,
|
|
194
|
+
}: {
|
|
195
|
+
timeout_ms: number
|
|
196
|
+
/** How to give the command longer. */
|
|
197
|
+
hint?: string
|
|
198
|
+
}): string =>
|
|
199
|
+
`Timed out after ${durationText(timeout_ms)}, so the engine stopped it and everything it started.${hint === undefined ? '' : ` ${hint}`}\n`
|
|
200
|
+
|
|
175
201
|
/** Keeps the start and end of long output, for the journal and prompts. */
|
|
176
202
|
export const clipOutput = ({
|
|
177
203
|
text,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alecsibilia/luca",
|
|
3
3
|
"description": "Luca: turns a spec on GitHub Issues into a reviewed pull request, run from Paseo with Claude Code",
|
|
4
|
-
"version": "14.0.0-alpha.
|
|
4
|
+
"version": "14.0.0-alpha.13",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Alec Sibilia <sibilia.alec@gmail.com>",
|
|
7
7
|
"homepage": "https://github.com/asibilia/luca-framework#readme",
|
|
@@ -64,7 +64,7 @@ Find the stuck reason: `ticket_stuck` (a ticket), `final_review_stuck` (the fina
|
|
|
64
64
|
| `nothing_new_to_test` | The test-writer found nothing to test. | Two cases. (a) The ticket changes no behavior: add the `refactor` label to the ticket, then `retry #n` (a label change starts it over as a refactor). (b) The work is already on the base branch: check the commit it names is really there (`git log --oneline <base> -- <file>`). If nothing else in the run is left to build, reply `stop`, then offer to close the ticket with a comment naming the commit. If other tickets still build, reply `retry #n`: on Luca 14.0.0-alpha.2 or newer, a fresh test-writer can answer "already done", and the engine checks it and closes the ticket itself. |
|
|
65
65
|
| `red_check_failed` | The new tests don't fail the way they should, after every fix round (or a test update left a criterion with no test). | Read the red check's problems. Usually a criterion is vague, or a "new" test already passes. Make the ticket's criteria clearer (a text change starts it over), or fix the tests in the worktree (step 4). Then `retry #n`. |
|
|
66
66
|
| `agent_failed` | An agent failed on every try it had. | Read the last failed try. A guard failure means it wrote a file its role may not (such as a report file); a result failure means bad output. If the cause is in the ticket (unclear, too big), fix the ticket and `retry #n`. If it looks like the model had a bad day, `retry #n`. If it keeps failing the same way, it may be an engine bug (see below). |
|
|
67
|
-
| `gates_failed` | The checks (tests, types, lint) still fail after every fix round. | Read the failing check's output. If it's a real bug in the ticket's code, fix it in the worktree only if it's small and clear, else make the ticket clearer; then `retry #n`. If the check fails on `main` too (a flaky or broken test), fix that first. |
|
|
67
|
+
| `gates_failed` | The checks (tests, types, lint) still fail after every fix round. | Read the failing check's output. If it's a real bug in the ticket's code, fix it in the worktree only if it's small and clear, else make the ticket clearer; then `retry #n`. If the check fails on `main` too (a flaky or broken test), fix that first. If a test check's output starts with "Timed out", the tests ran out of time (5 minutes by default): raise `test_timeout_ms` in `.luca/config.json`. A run takes the new value when it resumes. |
|
|
68
68
|
| `changes_requested` | The ticket review still asks for changes after every fix round. | Read the open findings. If they matter, fix them in the worktree (or clarify the ticket) and `retry #n`. If they can wait, `skip #n` keeps the rest of the run going. |
|
|
69
69
|
| `join_failed` | The ticket still clashes with the run branch after every rebase. | `retry #n` puts it on the run branch's tip again (uncommitted edits in its worktree are replaced). If two tickets keep fighting over the same lines, `skip #n` and build it in a later run. |
|
|
70
70
|
| `join_gates_failed` | The checks fail once the ticket joins the run branch. Its join was undone, so the run branch is safe. | Usually two tickets that pass alone but break together. `retry #n` fixes it on top of the run branch. |
|