@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 CHANGED
@@ -1,5 +1,9 @@
1
1
  # @alecsibilia/luca
2
2
 
3
+ ## 14.0.0-alpha.13
4
+
5
+ No changes in this release.
6
+
3
7
  ## 14.0.0-alpha.12
4
8
 
5
9
  No changes in this release.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luca/board",
3
- "version": "14.0.0-alpha.12",
3
+ "version": "14.0.0-alpha.13",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }
@@ -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.12"
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, not what the tests are, so a run can change
11
- * them halfway without making its earlier results wrong: a repo that swaps
12
- * a slow `prepare` for a fast cached one gets it on the next resume.
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 { bunTestCommands, type EngineConfig } from '../config/engine-config'
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
- ? `Timed out after ${durationText(limit)}, so the engine stopped it and everything it started.${timeout_hint === undefined ? '' : ` ${timeout_hint}`}\n`
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(await shellCheck({ name: 'test', command, cwd }))
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.12",
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. |