@alecsibilia/luca 14.0.0-alpha.34 → 14.0.0-alpha.35

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.35
4
+
5
+ No changes in this release.
6
+
3
7
  ## 14.0.0-alpha.34
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.34",
3
+ "version": "14.0.0-alpha.35",
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.34"
2
+ export const LUCA_VERSION: string = "14.0.0-alpha.35"
@@ -275,6 +275,9 @@ export const executeBuildAction = async ({
275
275
  exclusive: action.exclusive,
276
276
  })
277
277
  case 'join_run_branch': {
278
+ // Already joined: its commits are on the run branch, and its
279
+ // join keeps the record of which ones are its own (#719).
280
+ if (state.tickets[action.ticket]?.joined?.ok === true) return
278
281
  const worktree = ticketWorktree({ state, ticket: action.ticket })
279
282
  const runBranch = need({
280
283
  value: state.run_branch,
@@ -83,9 +83,10 @@ type InFlight = { action: EngineAction; done: Promise<void> }
83
83
  * only alone, once everything in flight has settled; and one action on
84
84
  * the run branch at a time (a new worktree, a join, its gates, a push, a
85
85
  * rebase). A stop action waits for everything in flight, then the journal is
86
- * read again. If an action throws (such as a launcher stop), the rest are
87
- * let finish, then the first error is thrown. `max_steps` counts the actions
88
- * started.
86
+ * read again. A step that ends while the engine decides makes it decide
87
+ * again, so no step runs twice from a stale decision (#719). If an action
88
+ * throws (such as a launcher stop), the rest are let finish, then the first
89
+ * error is thrown. `max_steps` counts the actions started.
89
90
  *
90
91
  * With `jev`, the decision model is asked around each step in **shadow
91
92
  * mode** and its answers are journaled but never acted on (see
@@ -350,8 +351,21 @@ export const runEngine = async ({
350
351
  // and no fix round goes to a closed one.
351
352
  await closeFinished({ all: false })
352
353
  const records = journal.read()
354
+ const readWhile = [...inFlight.keys()]
353
355
  const crashes = crashesIn(records)
354
356
  const decided = await decideNow(records)
357
+ // A step that ended while the engine decided freed its key, but
358
+ // the decision was made from records where it had not ended: it
359
+ // would run that step again, such as a second join (#719).
360
+ // Decide again from the journal as it is now. The journal tells
361
+ // first: `step_ended` is written before the key is freed.
362
+ const endedSince = journal
363
+ .read()
364
+ .slice(records.length)
365
+ .some(({ kind }) => kind === 'step_ended')
366
+ if (endedSince || readWhile.some((key) => !inFlight.has(key))) {
367
+ continue
368
+ }
355
369
  const actions = redosFirst({ actions: decided.actions, crashes })
356
370
  const stop = actions.find((action) => stops.has(action.type))
357
371
  if (stop !== undefined && inFlight.size === 0) {
@@ -107,6 +107,12 @@ const LiveFeedEntrySchema = z.object({
107
107
  /** The whole live file, as each write has it. */
108
108
  export const LiveFileSchema = z.object({
109
109
  written_at: z.string(),
110
+ /**
111
+ * The engine's pid (#727), so `/luca-watch` and `/luca-unstick` can
112
+ * tell a live engine from a stopped one whatever the file's age.
113
+ * Missing in files from older engines.
114
+ */
115
+ pid: z.number().int().optional(),
110
116
  /** The run's first plan-usage reading, as its `rate_limit_event` sent it. */
111
117
  plan_usage: z.record(z.string(), z.unknown()).nullable(),
112
118
  agents: z.array(LiveAgentSchema),
@@ -203,6 +209,7 @@ export const createLiveFile = ({ file }: { file: string }) => {
203
209
  changed = false
204
210
  const live: LiveFile = {
205
211
  written_at: new Date(at).toISOString(),
212
+ pid: process.pid,
206
213
  plan_usage,
207
214
  agents: [...agents].map(([key, agent]) => ({
208
215
  ...agent,
@@ -0,0 +1,77 @@
1
+ import { mkdir } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+
4
+ import {
5
+ gatesRun,
6
+ implemented,
7
+ intakePassed,
8
+ practiceTicket,
9
+ runBranchCreated,
10
+ stuckReported,
11
+ ticketStuck,
12
+ ticketWorktreeCreated,
13
+ } from './build-fixtures'
14
+ import { recordsFrom } from './intake-fixtures'
15
+
16
+ import type { JournalEntry } from '../journal/journal-record'
17
+
18
+ /**
19
+ * Journals for the `/luca-unstick` helper's tests (`stuck-summary*.test.ts`),
20
+ * written to disk as the engine writes them.
21
+ */
22
+
23
+ export const UNSTICK_TICKETS = [
24
+ practiceTicket({ number: 11, title: 'Add sum' }),
25
+ practiceTicket({ number: 12, title: 'Add product' }),
26
+ ]
27
+
28
+ /** Ticket #11 stuck on a bad test, reported on the spec. */
29
+ export const stuckOnBadTest = (): JournalEntry[] => [
30
+ ...intakePassed({ tickets: UNSTICK_TICKETS }),
31
+ runBranchCreated(),
32
+ ticketWorktreeCreated({ ticket: 11 }),
33
+ implemented({
34
+ ticket: 11,
35
+ outcome: 'bad_test',
36
+ reason: 'It pins build addresses.',
37
+ }),
38
+ gatesRun({ ticket: 11, target: 'ticket', ok: false }),
39
+ ticketStuck({ ticket: 11, reason: 'bad_test', detail: 'Sent back twice.' }),
40
+ stuckReported({ ticket: 11 }),
41
+ ]
42
+
43
+ /** A run's journal to write: its entries, repo, and first seq. */
44
+ export type UnstickRun = {
45
+ /** The runs folder. */
46
+ dir: string
47
+ run_id?: string
48
+ entries: JournalEntry[]
49
+ repo?: string
50
+ first_seq?: number
51
+ }
52
+
53
+ /**
54
+ * Writes a run's journal to the runs folder `dir`, its seqs starting at
55
+ * `first_seq` and its `run_started` naming `repo`, and returns its path.
56
+ */
57
+ export const writeRunFile = async ({
58
+ dir,
59
+ run_id = 'run-1',
60
+ entries,
61
+ repo = '/repos/app',
62
+ first_seq = 1,
63
+ }: UnstickRun): Promise<string> => {
64
+ const records = recordsFrom({ entries }).map((record) => ({
65
+ ...(record.kind === 'run_started'
66
+ ? { ...record, content: { ...record.content, repo } }
67
+ : record),
68
+ seq: record.seq + first_seq - 1,
69
+ }))
70
+ await mkdir(join(dir, run_id), { recursive: true })
71
+ const file = join(dir, run_id, 'journal.jsonl')
72
+ await Bun.write(
73
+ file,
74
+ records.map((record) => JSON.stringify(record)).join('\n') + '\n'
75
+ )
76
+ return file
77
+ }
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.34",
4
+ "version": "14.0.0-alpha.35",
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",
@@ -36,8 +36,8 @@
36
36
  },
37
37
  "dependencies": {
38
38
  "@anthropic-ai/claude-agent-sdk": "0.3.273",
39
- "@getpaseo/client": "0.9.1",
40
- "@getpaseo/plugin": "0.9.1",
39
+ "@getpaseo/client": "0.14.0",
40
+ "@getpaseo/plugin": "0.14.0",
41
41
  "@modelcontextprotocol/sdk": "1.30.0",
42
42
  "lodash": "^4.17.21",
43
43
  "zod": "^4.3.6"
@@ -10,6 +10,7 @@
10
10
  * are installed.
11
11
  */
12
12
  import { existsSync, readFileSync } from 'node:fs'
13
+ import { join } from 'node:path'
13
14
 
14
15
  /** Every journal record kind the helper reads. */
15
16
  export const KINDS_READ = [
@@ -459,14 +460,57 @@ export const registryEntry = ({
459
460
  }
460
461
  }
461
462
 
462
- /** Whether a run's engine is running now, from `ps`; `null` when unknown. */
463
+ /**
464
+ * A live file written at most this long ago means a live engine: the
465
+ * board's `LIVE_STALE_MS` (`packages/board/shared/board-state-live.ts`).
466
+ * The engine writes it at least every 5 seconds while it runs.
467
+ */
468
+ export const LIVE_STALE_MS = 60_000
469
+
470
+ /** What a run's live file says of its engine: when it was written, and its pid. */
471
+ export type LiveFacts = {
472
+ written_at: string
473
+ /** `null` in a file from an engine older than #727. */
474
+ pid: number | null
475
+ }
476
+
477
+ /**
478
+ * Whether a run's engine is running now; `null` when unknown. It runs when
479
+ * a command line from `ps` names the run, or its live file shows it. An
480
+ * engine started with `luca-run --spec <n>` and no `--run-id` names no run
481
+ * id (#727), so only its live file shows it. When the file names the
482
+ * engine's pid, it runs exactly while `alive(pid)`, however old the file
483
+ * (a laptop asleep) or fresh (an engine that just stopped). A file with no
484
+ * pid counts while written at most `LIVE_STALE_MS` from `now`, either way,
485
+ * as the board checks.
486
+ */
463
487
  export const engineRunning = ({
464
488
  run_id,
465
489
  command_lines,
490
+ live,
491
+ alive,
492
+ now,
466
493
  }: {
467
494
  run_id: string
468
495
  command_lines: string[] | null
496
+ /** The run's live file (`readLiveFile`), or `null`. */
497
+ live: LiveFacts | null
498
+ alive: (pid: number) => boolean
499
+ now: number
469
500
  }): boolean | null => {
501
+ if (live !== null) {
502
+ if (live.pid !== null) {
503
+ if (alive(live.pid)) return true
504
+ } else {
505
+ const written = Date.parse(live.written_at)
506
+ if (
507
+ Number.isFinite(written) &&
508
+ Math.abs(now - written) <= LIVE_STALE_MS
509
+ ) {
510
+ return true
511
+ }
512
+ }
513
+ }
470
514
  if (command_lines === null) return null
471
515
  return command_lines.some((line) => {
472
516
  const tokens = line.split(/\s+/)
@@ -480,6 +524,43 @@ export const engineRunning = ({
480
524
  })
481
525
  }
482
526
 
527
+ /**
528
+ * Run `run_id`'s live file (`live.json`, kept by the engine while it runs):
529
+ * its `written_at` and the engine's `pid`, or `null` when it is missing or
530
+ * unreadable.
531
+ */
532
+ export const readLiveFile = ({
533
+ runs_dir,
534
+ run_id,
535
+ }: {
536
+ runs_dir: string
537
+ run_id: string
538
+ }): LiveFacts | null => {
539
+ const file = join(runs_dir, run_id, 'live.json')
540
+ if (!existsSync(file)) return null
541
+ try {
542
+ const live: unknown = JSON.parse(readFileSync(file, 'utf8'))
543
+ if (!isObject(live) || typeof live.written_at !== 'string') return null
544
+ return {
545
+ written_at: live.written_at,
546
+ pid: typeof live.pid === 'number' ? live.pid : null,
547
+ }
548
+ } catch {
549
+ return null
550
+ }
551
+ }
552
+
553
+ /** Whether process `pid` is alive: signal 0 checks without sending. */
554
+ export const pidAlive = (pid: number): boolean => {
555
+ try {
556
+ process.kill(pid, 0)
557
+ return true
558
+ } catch (error) {
559
+ // EPERM: it's alive, just not ours to signal.
560
+ return isObject(error) && error.code === 'EPERM'
561
+ }
562
+ }
563
+
483
564
  /** Every process's command line, or `null` when `ps` fails. */
484
565
  export const commandLines = (): string[] | null => {
485
566
  const ps = Bun.spawnSync(['ps', '-axww', '-o', 'command='])
@@ -40,6 +40,8 @@ import {
40
40
  numberOr,
41
41
  objectOf,
42
42
  openStuck,
43
+ pidAlive,
44
+ readLiveFile,
43
45
  readRecords,
44
46
  registryEntry,
45
47
  repoOfUrl,
@@ -54,7 +56,9 @@ export {
54
56
  cutOffSteps,
55
57
  engineRunning,
56
58
  KINDS_READ,
59
+ LIVE_STALE_MS,
57
60
  openStuck,
61
+ readLiveFile,
58
62
  readRecords,
59
63
  registryEntry,
60
64
  repoOfUrl,
@@ -539,7 +543,13 @@ const main = async () => {
539
543
  records,
540
544
  ticket: args.ticket,
541
545
  registry,
542
- running: engineRunning({ run_id, command_lines: commandLines() }),
546
+ running: engineRunning({
547
+ run_id,
548
+ command_lines: commandLines(),
549
+ live: readLiveFile({ runs_dir: args.runs_dir, run_id }),
550
+ alive: pidAlive,
551
+ now: Date.now(),
552
+ }),
543
553
  log_tail: logTail(registry?.log_path ?? null),
544
554
  })
545
555
  )
@@ -55,7 +55,7 @@ It polls the journal every five seconds and prints one JSON line, `{ "event", "l
55
55
 
56
56
  - `stuck`: a `stuck_reported`; `detail` is its comment body. Run the summary for the actual reason (from `ticket_stuck`, `final_review_stuck`, or `run_stuck`), then sections 3 to 5.
57
57
  - `ended`: the run is over (a `run_ended`, or an older run's `pull_request_opened`, `intake_refused`, `nothing_to_do`, `stop` reply, or a billing or crash `run_stopped`). Report the outcome in one line, with the PR URL when present, log it, and stop.
58
- - `engine_gone`: no live engine names the run. Section 6. The helper answers `engine_gone` at once while no engine is live, so after an engine-gone handoff (including a failed or used-up resume) log it and stop; the owner starts `/luca-watch` again after resuming the run.
58
+ - `engine_gone`: no live engine names the run, and the run's live file (`live.json`) shows none: the pid it names is not running, or, from an older engine with no pid, it is over a minute old. Section 6. The helper answers `engine_gone` at once while no engine is live, so after an engine-gone handoff (including a failed or used-up resume) log it and stop; the owner starts `/luca-watch` again after resuming the run.
59
59
  - `quiet`: five quiet minutes. Call it again with the same cursor and stay in the loop. Don't log quiet polls.
60
60
  - `error`: a missing journal, an unknown run, an invalid cursor, or bad arguments (exit code 2) stop the watcher, with a handoff. A process-inspection error (the process list kept failing) comes back only after the helper's five-minute wait, during which it still returns new journal records; it gets one serious handoff per changed error plus seq. Then call the helper again and keep processing journal events, but never resume without reliable process evidence. Any other journal-read error: try again on the next loop, and hand it off after three consecutive failures.
61
61
 
@@ -13,7 +13,8 @@
13
13
  * - `ended`: the oldest ending after the cursor (`run_ended`, or a legacy
14
14
  * one: `pull_request_opened`, `intake_refused`, `nothing_to_do`, a `stop`
15
15
  * reply, a billing or crash `run_stopped`), or one before it.
16
- * - `engine_gone`: no live engine names the run, and it has not ended.
16
+ * - `engine_gone`: no live engine names the run, its live file shows no
17
+ * live engine, and it has not ended.
17
18
  * - `quiet`: five minutes with none of these. Call it again.
18
19
  * - `error`: the journal can't be read; or the process list kept failing
19
20
  * until the quiet deadline, while the journal still showed nothing new.
@@ -23,7 +24,7 @@
23
24
  * skipped. A missing journal, an unknown run, an invalid cursor or bad
24
25
  * arguments print an `error` line and exit 2; every other event exits 0.
25
26
  *
26
- * It only reads: the journal and the process list. It imports nothing but
27
+ * It only reads: the journal, the run's live file, and the process list. It imports nothing but
27
28
  * Bun's and Node's own modules, since it runs from
28
29
  * `~/.claude/skills/luca-watch/`, where no packages are installed.
29
30
  */
@@ -185,6 +186,58 @@ const engineLive = ({
185
186
  )
186
187
  })
187
188
 
189
+ /** The engine's live file in a run folder: `LIVE_FILE` in `src/core/live-file.ts`. */
190
+ const LIVE_FILE = 'live.json'
191
+
192
+ /**
193
+ * A live file written at most this long ago means a live engine: the
194
+ * board's `LIVE_STALE_MS` (`packages/board/shared/board-state-live.ts`).
195
+ * The engine writes it at least every 5 seconds while it runs.
196
+ */
197
+ export const LIVE_STALE_MS = 60_000
198
+
199
+ /**
200
+ * Whether run `run_id`'s live file shows a live engine (#727). An engine
201
+ * started with `luca-run --spec <n>` and no `--run-id` makes the run id
202
+ * itself, so no command line names it; its live file still shows it. When
203
+ * the file names the engine's pid, the engine is live exactly while that
204
+ * pid is in `processes`, however old the file (a laptop asleep) or fresh
205
+ * (an engine that just stopped). A file from an older engine, with no pid,
206
+ * counts while it was written at most `LIVE_STALE_MS` from `now`, either
207
+ * way, as the board checks. A missing or unreadable file shows nothing.
208
+ */
209
+ const liveFileLive = async ({
210
+ runs_dir,
211
+ run_id,
212
+ processes,
213
+ now,
214
+ }: {
215
+ runs_dir: string
216
+ run_id: string
217
+ processes: ProcessRow[]
218
+ now: number
219
+ }): Promise<boolean> => {
220
+ try {
221
+ const live: unknown = await Bun.file(
222
+ join(runs_dir, run_id, LIVE_FILE)
223
+ ).json()
224
+ if (!isObject(live)) return false
225
+ if (typeof live.pid === 'number') {
226
+ return processes.some(({ pid }) => pid === live.pid)
227
+ }
228
+ const written_at =
229
+ typeof live.written_at === 'string'
230
+ ? Date.parse(live.written_at)
231
+ : Number.NaN
232
+ return (
233
+ Number.isFinite(written_at) &&
234
+ Math.abs(now - written_at) <= LIVE_STALE_MS
235
+ )
236
+ } catch {
237
+ return false
238
+ }
239
+ }
240
+
188
241
  /** `pid` and every parent above it, from `processes`. Pure. */
189
242
  const ancestorPids = ({
190
243
  pid,
@@ -274,7 +327,15 @@ export const waitForChange = async ({
274
327
  const own = own_pids.flatMap((pid) =>
275
328
  ancestorPids({ pid, processes: rows })
276
329
  )
277
- if (!engineLive({ run_id, processes: rows, own_pids: own })) {
330
+ if (
331
+ !engineLive({ run_id, processes: rows, own_pids: own }) &&
332
+ !(await liveFileLive({
333
+ runs_dir,
334
+ run_id,
335
+ processes: rows,
336
+ now: now(),
337
+ }))
338
+ ) {
278
339
  return {
279
340
  event: 'engine_gone',
280
341
  last_seq: highest,