@alecsibilia/luca 14.0.0-alpha.2 → 14.0.0-alpha.4
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 +8 -0
- package/README.md +1 -1
- package/board/client/board-panel.tsx +41 -4
- package/board/index.client.tsx +1 -1
- package/board/package.json +1 -1
- package/board/server/board-server.ts +224 -46
- package/board/server/board-vocabulary.ts +42 -1
- package/board/server/engine-launch.ts +20 -3
- package/board/server/engine-watch.ts +45 -5
- package/board/server/luca-version.ts +1 -1
- package/board/server/make-rows.ts +55 -9
- package/board/server/reduce-board.ts +82 -2
- package/board/server/run-registry.ts +8 -4
- package/board/shared/board-state.ts +20 -1
- package/engine/agents/role-instructions.ts +10 -1
- package/engine/agents/role-prompts.ts +64 -0
- package/engine/cli/going-runs.ts +182 -36
- package/engine/cli/live-runs.ts +73 -0
- package/engine/cli/run-modes.ts +77 -9
- package/engine/cli/setup.ts +5 -48
- package/engine/cli/upgrade-command.ts +4 -1
- package/engine/cli/upgrade.ts +28 -6
- package/engine/config/engine-config.ts +26 -0
- package/engine/core/already-done.ts +44 -1
- package/engine/core/decide-build.ts +216 -22
- package/engine/core/decide-final-review.ts +2 -3
- package/engine/core/decide.ts +1 -0
- package/engine/core/execute-build.ts +245 -25
- package/engine/core/execute-changeset.ts +2 -2
- package/engine/core/execute-stuck.ts +15 -1
- package/engine/core/execute.ts +48 -5
- package/engine/core/fix-loop-text.ts +37 -1
- package/engine/core/left-out.ts +120 -0
- package/engine/core/loop-caps.ts +8 -0
- package/engine/core/not-in-run.ts +7 -3
- package/engine/core/pull-request-text.ts +4 -1
- package/engine/core/stuck-text.ts +29 -1
- package/engine/gates/already-done-check.ts +120 -0
- package/engine/gates/gate-runner.ts +22 -11
- package/engine/gates/prepare-slots.ts +62 -0
- package/engine/gates/red-check.ts +9 -1
- package/engine/gates/test-runner.ts +21 -0
- package/engine/git/git-adapter.ts +32 -0
- package/engine/git/outside-links.ts +55 -0
- package/engine/intake/intake-checks.ts +115 -16
- package/engine/intake/intake-schemas.ts +37 -2
- package/engine/journal/journal-record.ts +103 -4
- package/engine/journal/replay.ts +133 -21
- package/engine/testing/build-fixtures.ts +55 -0
- package/engine/tracker/github-tracker.ts +196 -37
- package/engine/tracker/in-memory-tracker.ts +5 -2
- package/engine/tracker/tracker.ts +94 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
package/README.md
CHANGED
|
@@ -45,7 +45,7 @@ This is Luca v14, in alpha. Old Luca (v13) is still on npm; see [Coming from v13
|
|
|
45
45
|
| `luca init` | Sets up this computer, once (safe to run again): MuninnDB and its Claude Code entry, the board in Paseo with its engine and Bun paths, and the planning skills (`to-spec`, `to-tickets`, `setup-matt-pocock-skills`, `grilling`, `domain-modeling`). It asks before turning on Paseo's plugins, and leaves skills you already have alone. `--skip-skills` skips the skills. Inside a git repo, it then runs `luca setup` for that repo. |
|
|
46
46
|
| `luca setup` | Gets the repo in this folder ready: the labels a run needs (and the `release:*` labels when the repo uses changesets), `.luca/config.json` (a new one gets the repo's GitHub name as its vault; an existing vault is kept), and a check of GitHub sub-issues and dependencies. `--base <branch>` names the base branch. |
|
|
47
47
|
| `luca doctor` | Checks this computer, this repo when you're in one, and what v13 left behind. Each problem comes with its exact fix. `luca doctor --fix` fixes what's safe without asking: it never deletes (v13 files go to a dated backup folder) and never commits, and it lists the repo files to commit. |
|
|
48
|
-
| `luca upgrade` | Moves to the newest Luca on your channel: `alpha` stays on `alpha`, and it never goes back to v13. It refuses while any run is going (stuck runs and limit waits count) and lists them. Then it reloads the board and says when to run `/reload-skills`. |
|
|
48
|
+
| `luca upgrade` | Moves to the newest Luca on your channel: `alpha` stays on `alpha`, and it never goes back to v13. It refuses while any run is going (stuck runs and limit waits count) and lists them. A run that crashed, with no engine running it, doesn't stop it: upgrade says how to resume it on the new version. Then it reloads the board and says when to run `/reload-skills`. |
|
|
49
49
|
| `luca upgrade --to <version>` | Installs that exact version, an older one too, to go back when a version breaks. |
|
|
50
50
|
|
|
51
51
|
`init`, `setup`, and `upgrade` end with their part of `luca doctor`'s checks.
|
|
@@ -118,6 +118,9 @@ const LENS_STAGES: { stage: LensState; title: string; icon: string }[] = [
|
|
|
118
118
|
/** A card or stage key. */
|
|
119
119
|
type Key = string
|
|
120
120
|
|
|
121
|
+
/** The key of the fold for tickets left out for a person (#499). */
|
|
122
|
+
const FOR_A_PERSON: Key = 'for_a_person'
|
|
123
|
+
|
|
121
124
|
type DotKind = 'done' | 'current' | 'todo' | 'skip' | 'stopped'
|
|
122
125
|
|
|
123
126
|
const makeStyles = ({
|
|
@@ -1063,11 +1066,12 @@ export const BoardPanel = ({
|
|
|
1063
1066
|
)
|
|
1064
1067
|
const [folded, setFolded] = useState<ReadonlySet<Key>>(
|
|
1065
1068
|
() =>
|
|
1066
|
-
new Set(
|
|
1067
|
-
TICKET_STAGES.filter((entry) => entry.folded).map(
|
|
1069
|
+
new Set<Key>([
|
|
1070
|
+
...TICKET_STAGES.filter((entry) => entry.folded).map(
|
|
1068
1071
|
(entry) => entry.stage
|
|
1069
|
-
)
|
|
1070
|
-
|
|
1072
|
+
),
|
|
1073
|
+
FOR_A_PERSON,
|
|
1074
|
+
])
|
|
1071
1075
|
)
|
|
1072
1076
|
const look = { theme, styles }
|
|
1073
1077
|
const state = board.data?.selected ?? null
|
|
@@ -1293,6 +1297,39 @@ export const BoardPanel = ({
|
|
|
1293
1297
|
)
|
|
1294
1298
|
})}
|
|
1295
1299
|
|
|
1300
|
+
{state.for_a_person.length > 0 ? (
|
|
1301
|
+
<Section styles={styles}>
|
|
1302
|
+
<StageHeader
|
|
1303
|
+
title="For a person"
|
|
1304
|
+
hint="left out of this run: a person does these, or they wait on one"
|
|
1305
|
+
icon="User"
|
|
1306
|
+
count={state.for_a_person.length}
|
|
1307
|
+
color={theme.colors.foregroundMuted}
|
|
1308
|
+
folded={folded.has(FOR_A_PERSON)}
|
|
1309
|
+
onPress={() =>
|
|
1310
|
+
setFolded((set) =>
|
|
1311
|
+
toggled({ set, key: FOR_A_PERSON })
|
|
1312
|
+
)
|
|
1313
|
+
}
|
|
1314
|
+
{...look}
|
|
1315
|
+
/>
|
|
1316
|
+
{folded.has(FOR_A_PERSON) ? null : (
|
|
1317
|
+
<View style={styles.stageBody}>
|
|
1318
|
+
{state.for_a_person.map((ticket) => (
|
|
1319
|
+
<Text
|
|
1320
|
+
key={ticket.number}
|
|
1321
|
+
style={styles.muted}
|
|
1322
|
+
numberOfLines={2}
|
|
1323
|
+
>
|
|
1324
|
+
#{ticket.number} {ticket.title} ·{' '}
|
|
1325
|
+
{ticket.why}
|
|
1326
|
+
</Text>
|
|
1327
|
+
))}
|
|
1328
|
+
</View>
|
|
1329
|
+
)}
|
|
1330
|
+
</Section>
|
|
1331
|
+
) : null}
|
|
1332
|
+
|
|
1296
1333
|
{showsFinalReview({ state }) ? (
|
|
1297
1334
|
<>
|
|
1298
1335
|
<View style={styles.rule} />
|
package/board/index.client.tsx
CHANGED
|
@@ -31,7 +31,7 @@ export default function contribute(client: PluginClientContext) {
|
|
|
31
31
|
client.addSlashCommand({
|
|
32
32
|
name: 'luca-run',
|
|
33
33
|
description:
|
|
34
|
-
'Start a Luca run for a spec: live rows here, and the Luca board panel',
|
|
34
|
+
'Start a Luca run for a spec, or resume one: live rows here, and the Luca board panel',
|
|
35
35
|
argumentHint: RUN_USAGE.replace('/luca-run ', ''),
|
|
36
36
|
context: 'agent',
|
|
37
37
|
onSubmit: async ({ args, agent, workspace, rpc, openPanel }) => {
|
package/board/package.json
CHANGED
|
@@ -15,9 +15,11 @@ import {
|
|
|
15
15
|
liveRunIds,
|
|
16
16
|
MAX_AUTO_RESTARTS,
|
|
17
17
|
parseUnfinished,
|
|
18
|
+
RESUMED_TEXT,
|
|
18
19
|
restartRow,
|
|
19
20
|
restartText,
|
|
20
21
|
resumeArgs,
|
|
22
|
+
resumeRow,
|
|
21
23
|
UNFINISHED_TIMEOUT_MS,
|
|
22
24
|
type StopWhy,
|
|
23
25
|
type UnfinishedRun,
|
|
@@ -450,7 +452,7 @@ export const createBoardServer = ({
|
|
|
450
452
|
change,
|
|
451
453
|
}: {
|
|
452
454
|
memory: RunMemory
|
|
453
|
-
change: Partial<Pick<RunEntry, 'ended' | 'restarts'>>
|
|
455
|
+
change: Partial<Pick<RunEntry, 'ended' | 'restarts' | 'token'>>
|
|
454
456
|
}) => {
|
|
455
457
|
memory.entry = registry.update({
|
|
456
458
|
run_id: memory.entry.run_id,
|
|
@@ -503,15 +505,221 @@ export const createBoardServer = ({
|
|
|
503
505
|
})
|
|
504
506
|
}
|
|
505
507
|
|
|
508
|
+
/** Waits for rows being added, but at most `HEADER_WAIT_MS`. */
|
|
509
|
+
const waitForRows = async (adding: Promise<unknown>) => {
|
|
510
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
511
|
+
await Promise.race([
|
|
512
|
+
adding,
|
|
513
|
+
new Promise((resolve) => {
|
|
514
|
+
timer = setTimeout(resolve, HEADER_WAIT_MS)
|
|
515
|
+
}),
|
|
516
|
+
])
|
|
517
|
+
clearTimeout(timer)
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Engine checks and resumes run one at a time, so a resume and a check
|
|
522
|
+
* never both start an engine for the same run.
|
|
523
|
+
*/
|
|
524
|
+
let engine_work: Promise<unknown> = Promise.resolve()
|
|
525
|
+
const exclusive = <Result>(work: () => Promise<Result>) => {
|
|
526
|
+
const result = engine_work.then(work)
|
|
527
|
+
engine_work = result.catch(() => undefined)
|
|
528
|
+
return result
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** Spawns a run's engine with `--resume` and its token; returns its pid. */
|
|
532
|
+
const spawnResume = ({
|
|
533
|
+
memory,
|
|
534
|
+
engine,
|
|
535
|
+
on_failed,
|
|
536
|
+
}: {
|
|
537
|
+
memory: RunMemory
|
|
538
|
+
engine: { command: string; lead_args: string[] }
|
|
539
|
+
/** What the log says when the process fails after the spawn. */
|
|
540
|
+
on_failed: string
|
|
541
|
+
}): number | null => {
|
|
542
|
+
const { run_id, repo, token, log_path } = memory.entry
|
|
543
|
+
return spawn_engine({
|
|
544
|
+
command: engine.command,
|
|
545
|
+
args: [...engine.lead_args, ...resumeArgs({ run_id, repo })],
|
|
546
|
+
env: childEnv({ token }),
|
|
547
|
+
cwd: repo,
|
|
548
|
+
log_path,
|
|
549
|
+
on_error: (error) => {
|
|
550
|
+
log(`[${run_id}] ${on_failed}: ${errorText({ error })}`)
|
|
551
|
+
void enqueue({
|
|
552
|
+
run_id,
|
|
553
|
+
work: () =>
|
|
554
|
+
endRun({
|
|
555
|
+
memory,
|
|
556
|
+
ended: {
|
|
557
|
+
ok: false,
|
|
558
|
+
message: engineStoppedText({
|
|
559
|
+
why: {
|
|
560
|
+
kind: 'spawn_failed',
|
|
561
|
+
error: errorText({ error }),
|
|
562
|
+
},
|
|
563
|
+
run_id,
|
|
564
|
+
log_path,
|
|
565
|
+
}),
|
|
566
|
+
},
|
|
567
|
+
}),
|
|
568
|
+
})
|
|
569
|
+
},
|
|
570
|
+
}).pid
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* `/luca-run resume <run id>` (#493): goes on with a run this plugin
|
|
575
|
+
* started, from its journal, with the board attached again. Refused for
|
|
576
|
+
* an unknown run, a demo, a run that is over, a run whose engine is
|
|
577
|
+
* still running, or when `ps` fails. Otherwise the run gets a new token,
|
|
578
|
+
* loses its `ended`, and starts its restart count again at 0; it keeps
|
|
579
|
+
* its chat, workspace, repo, and log. The engine is spawned with
|
|
580
|
+
* `--resume`, and the run's chat gets a row saying so.
|
|
581
|
+
*/
|
|
582
|
+
const resumeRun = async ({
|
|
583
|
+
run_id,
|
|
584
|
+
input,
|
|
585
|
+
}: {
|
|
586
|
+
run_id: string
|
|
587
|
+
input: RunStartInput
|
|
588
|
+
}): Promise<RunStartOutput> => {
|
|
589
|
+
const refuse = (message: string): RunStartOutput => ({
|
|
590
|
+
ok: false,
|
|
591
|
+
message,
|
|
592
|
+
run_id: null,
|
|
593
|
+
})
|
|
594
|
+
const memory = runs.get(run_id)
|
|
595
|
+
if (!memory) {
|
|
596
|
+
return refuse(
|
|
597
|
+
`The board doesn't know run ${run_id}, so it can't resume it. Check the id: the run's header row and the board panel show it. A run the board didn't start goes on from a terminal with luca-run --resume ${run_id}, without the board.`
|
|
598
|
+
)
|
|
599
|
+
}
|
|
600
|
+
if (memory.entry.demo) {
|
|
601
|
+
return refuse(
|
|
602
|
+
`Run ${run_id} is a demo, and a demo can't be picked up again. Start a new one with /luca-run demo.`
|
|
603
|
+
)
|
|
604
|
+
}
|
|
605
|
+
if (OVER_PHASES.has(memory.state.run.phase)) {
|
|
606
|
+
return refuse(
|
|
607
|
+
`Run ${run_id} is over, so there is nothing to resume.`
|
|
608
|
+
)
|
|
609
|
+
}
|
|
610
|
+
const engine = await findEngine()
|
|
611
|
+
if (!engine.ok) return refuse(engine.message)
|
|
612
|
+
|
|
613
|
+
return exclusive(async () => {
|
|
614
|
+
let command_lines: string[]
|
|
615
|
+
try {
|
|
616
|
+
command_lines = await list_processes()
|
|
617
|
+
} catch (error) {
|
|
618
|
+
return refuse(
|
|
619
|
+
`Couldn't check whether run ${run_id}'s engine is still running (${errorText({ error })}), so it wasn't resumed: two engines on one run would be far worse. Try again.`
|
|
620
|
+
)
|
|
621
|
+
}
|
|
622
|
+
if (liveRunIds({ command_lines }).has(run_id)) {
|
|
623
|
+
return refuse(
|
|
624
|
+
`Run ${run_id} is still running (its engine is live), so there is nothing to resume.`
|
|
625
|
+
)
|
|
626
|
+
}
|
|
627
|
+
return enqueue({
|
|
628
|
+
run_id,
|
|
629
|
+
work: async (): Promise<RunStartOutput> => {
|
|
630
|
+
const before = memory.entry
|
|
631
|
+
// The new token is kept first: the engine may send
|
|
632
|
+
// before the spawn returns.
|
|
633
|
+
updateEntry({
|
|
634
|
+
memory,
|
|
635
|
+
change: {
|
|
636
|
+
token: randomBytes(24).toString('hex'),
|
|
637
|
+
ended: null,
|
|
638
|
+
restarts: 0,
|
|
639
|
+
},
|
|
640
|
+
})
|
|
641
|
+
let pid: number | null = null
|
|
642
|
+
try {
|
|
643
|
+
pid = spawnResume({
|
|
644
|
+
memory,
|
|
645
|
+
engine,
|
|
646
|
+
on_failed: 'the resumed engine failed',
|
|
647
|
+
})
|
|
648
|
+
} catch (error) {
|
|
649
|
+
updateEntry({
|
|
650
|
+
memory,
|
|
651
|
+
change: {
|
|
652
|
+
token: before.token,
|
|
653
|
+
ended: before.ended,
|
|
654
|
+
restarts: before.restarts,
|
|
655
|
+
},
|
|
656
|
+
})
|
|
657
|
+
return refuse(
|
|
658
|
+
`Couldn't start the engine: ${errorText({ error })}`
|
|
659
|
+
)
|
|
660
|
+
}
|
|
661
|
+
const { log_path, spec, agent_id } = memory.entry
|
|
662
|
+
log(
|
|
663
|
+
`[${run_id}] resumed with /luca-run resume: ${engine.command} with --resume (pid ${pid ?? '?'}), log ${log_path}`
|
|
664
|
+
)
|
|
665
|
+
memory.rows_stopped = false
|
|
666
|
+
memory.append_failures = 0
|
|
667
|
+
memory.state = {
|
|
668
|
+
...memory.state,
|
|
669
|
+
run: { ...memory.state.run, engine_ended: null },
|
|
670
|
+
latest: RESUMED_TEXT,
|
|
671
|
+
}
|
|
672
|
+
void enqueue({
|
|
673
|
+
run_id,
|
|
674
|
+
work: () =>
|
|
675
|
+
appendRows({
|
|
676
|
+
memory,
|
|
677
|
+
rows: [
|
|
678
|
+
resumeRow({
|
|
679
|
+
run_id,
|
|
680
|
+
time: now().toISOString(),
|
|
681
|
+
}),
|
|
682
|
+
header({ memory }),
|
|
683
|
+
],
|
|
684
|
+
}),
|
|
685
|
+
})
|
|
686
|
+
const what =
|
|
687
|
+
spec === null
|
|
688
|
+
? `run ${run_id}`
|
|
689
|
+
: `spec #${spec} (run ${run_id})`
|
|
690
|
+
const where =
|
|
691
|
+
agent_id === input.agent_id
|
|
692
|
+
? ''
|
|
693
|
+
: ' Its rows go on in the chat it started in.'
|
|
694
|
+
return {
|
|
695
|
+
ok: true,
|
|
696
|
+
run_id,
|
|
697
|
+
message: `Resumed ${what} from its journal.${where} Its log is ${log_path}.`,
|
|
698
|
+
}
|
|
699
|
+
},
|
|
700
|
+
})
|
|
701
|
+
})
|
|
702
|
+
}
|
|
703
|
+
|
|
506
704
|
/**
|
|
507
705
|
* `run.start`: parses the args, finds the engine, records the run, spawns
|
|
508
706
|
* the engine detached, and adds the chat's header row. Returns at once.
|
|
707
|
+
* `resume <run id>` goes on with a run instead (see `resumeRun`).
|
|
509
708
|
*/
|
|
510
709
|
const startRun = async (input: RunStartInput): Promise<RunStartOutput> => {
|
|
511
710
|
await ready
|
|
512
711
|
const parsed = parseRunArgs({ args: input.args })
|
|
513
712
|
if (!parsed.ok)
|
|
514
713
|
return { ok: false, message: parsed.message, run_id: null }
|
|
714
|
+
if (parsed.target.kind === 'resume') {
|
|
715
|
+
const output = await resumeRun({
|
|
716
|
+
run_id: parsed.target.run_id,
|
|
717
|
+
input,
|
|
718
|
+
})
|
|
719
|
+
const rows = queues.get(parsed.target.run_id)
|
|
720
|
+
if (output.ok && rows) await waitForRows(rows)
|
|
721
|
+
return output
|
|
722
|
+
}
|
|
515
723
|
|
|
516
724
|
const engine = await findEngine()
|
|
517
725
|
if (!engine.ok)
|
|
@@ -523,7 +731,7 @@ export const createBoardServer = ({
|
|
|
523
731
|
}
|
|
524
732
|
const token = randomBytes(24).toString('hex')
|
|
525
733
|
const log_path = join(log_dir, `${run_id}.log`)
|
|
526
|
-
const
|
|
734
|
+
const target = parsed.target
|
|
527
735
|
const entry: RunEntry = {
|
|
528
736
|
run_id,
|
|
529
737
|
token,
|
|
@@ -583,18 +791,12 @@ export const createBoardServer = ({
|
|
|
583
791
|
`[${run_id}] started ${engine.command} (pid ${pid ?? '?'}), log ${log_path}`
|
|
584
792
|
)
|
|
585
793
|
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
headerAdded,
|
|
593
|
-
new Promise((resolve) => {
|
|
594
|
-
timer = setTimeout(resolve, HEADER_WAIT_MS)
|
|
595
|
-
}),
|
|
596
|
-
])
|
|
597
|
-
clearTimeout(timer)
|
|
794
|
+
await waitForRows(
|
|
795
|
+
enqueue({
|
|
796
|
+
run_id,
|
|
797
|
+
work: () => appendRows({ memory, rows: [header({ memory })] }),
|
|
798
|
+
})
|
|
799
|
+
)
|
|
598
800
|
|
|
599
801
|
const what =
|
|
600
802
|
target.kind === 'demo' ? 'a demo run' : `spec #${target.spec}`
|
|
@@ -805,39 +1007,14 @@ export const createBoardServer = ({
|
|
|
805
1007
|
memory: RunMemory
|
|
806
1008
|
engine: { command: string; lead_args: string[] }
|
|
807
1009
|
}): Promise<{ ok: true } | { ok: false; error: string }> => {
|
|
808
|
-
const { run_id,
|
|
1010
|
+
const { run_id, log_path } = memory.entry
|
|
809
1011
|
let pid: number | null = null
|
|
810
1012
|
try {
|
|
811
|
-
pid =
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
log_path,
|
|
817
|
-
on_error: (error) => {
|
|
818
|
-
log(
|
|
819
|
-
`[${run_id}] the restarted engine failed: ${errorText({ error })}`
|
|
820
|
-
)
|
|
821
|
-
void enqueue({
|
|
822
|
-
run_id,
|
|
823
|
-
work: () =>
|
|
824
|
-
endRun({
|
|
825
|
-
memory,
|
|
826
|
-
ended: {
|
|
827
|
-
ok: false,
|
|
828
|
-
message: engineStoppedText({
|
|
829
|
-
why: {
|
|
830
|
-
kind: 'spawn_failed',
|
|
831
|
-
error: errorText({ error }),
|
|
832
|
-
},
|
|
833
|
-
run_id,
|
|
834
|
-
log_path,
|
|
835
|
-
}),
|
|
836
|
-
},
|
|
837
|
-
}),
|
|
838
|
-
})
|
|
839
|
-
},
|
|
840
|
-
}).pid
|
|
1013
|
+
pid = spawnResume({
|
|
1014
|
+
memory,
|
|
1015
|
+
engine,
|
|
1016
|
+
on_failed: 'the restarted engine failed',
|
|
1017
|
+
})
|
|
841
1018
|
} catch (error) {
|
|
842
1019
|
return { ok: false, error: errorText({ error }) }
|
|
843
1020
|
}
|
|
@@ -957,7 +1134,7 @@ export const createBoardServer = ({
|
|
|
957
1134
|
* const { restarted, stopped } = await board.checkEngines()
|
|
958
1135
|
*/
|
|
959
1136
|
const checkEngines = (): Promise<EngineCheck> => {
|
|
960
|
-
checking ??= runCheck
|
|
1137
|
+
checking ??= exclusive(runCheck)
|
|
961
1138
|
.catch((error: unknown) => {
|
|
962
1139
|
log(`The engine check failed: ${errorText({ error })}`)
|
|
963
1140
|
return { restarted: [], stopped: [] }
|
|
@@ -972,6 +1149,7 @@ export const createBoardServer = ({
|
|
|
972
1149
|
const idle = async () => {
|
|
973
1150
|
await ready
|
|
974
1151
|
await checking
|
|
1152
|
+
await engine_work
|
|
975
1153
|
await Promise.all([...queues.values()])
|
|
976
1154
|
}
|
|
977
1155
|
|
|
@@ -48,6 +48,26 @@ const TestRunSchema = z.looseObject({
|
|
|
48
48
|
|
|
49
49
|
const WorktreeSchema = z.looseObject({ branch: z.string() })
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* The open tickets intake left out for a person (#499): `for_a_person`, or
|
|
53
|
+
* `waits_on_person` with the tickets for a person it `waits_on`, and the
|
|
54
|
+
* blockers it waits `through`. Missing in older journals.
|
|
55
|
+
*/
|
|
56
|
+
const LeftOutListSchema = z
|
|
57
|
+
.array(
|
|
58
|
+
z.looseObject({
|
|
59
|
+
number: z.number().int(),
|
|
60
|
+
title: z.string().catch(''),
|
|
61
|
+
reason: z.enum(['for_a_person', 'waits_on_person']),
|
|
62
|
+
waits_on: z.array(z.number().int()).catch([]).default([]),
|
|
63
|
+
through: z.array(z.number().int()).catch([]).default([]),
|
|
64
|
+
})
|
|
65
|
+
)
|
|
66
|
+
.catch([])
|
|
67
|
+
.default([])
|
|
68
|
+
|
|
69
|
+
export type LeftOutList = z.infer<typeof LeftOutListSchema>
|
|
70
|
+
|
|
51
71
|
const tokenCount = z.number().min(0).catch(0)
|
|
52
72
|
|
|
53
73
|
/** One plan window's reading: `utilization` 0 to 1, `resetsAt` in seconds. */
|
|
@@ -128,13 +148,19 @@ export const BOARD_VOCABULARY = {
|
|
|
128
148
|
})
|
|
129
149
|
),
|
|
130
150
|
}),
|
|
131
|
-
/**
|
|
151
|
+
/**
|
|
152
|
+
* The run ends with nothing to do; `already_done` is empty at intake
|
|
153
|
+
* (#484), and `left_out` names the tickets left for a person (#499).
|
|
154
|
+
*/
|
|
132
155
|
nothing_to_do: z.looseObject({
|
|
133
156
|
already_done: z.array(z.number().int()).catch([]).default([]),
|
|
157
|
+
left_out: LeftOutListSchema,
|
|
134
158
|
}),
|
|
135
159
|
spec_snapshot: z.looseObject({
|
|
136
160
|
spec: z.looseObject({ number: z.number(), title: z.string() }),
|
|
137
161
|
ticket_order: z.array(z.number()).catch([]),
|
|
162
|
+
/** The tickets left out for a person (#499); older journals have none. */
|
|
163
|
+
left_out: LeftOutListSchema,
|
|
138
164
|
}),
|
|
139
165
|
ticket_snapshot: z.looseObject({
|
|
140
166
|
number: z.number().int(),
|
|
@@ -224,6 +250,20 @@ export const BOARD_VOCABULARY = {
|
|
|
224
250
|
tests: z.array(z.string()).catch([]),
|
|
225
251
|
code: z.array(z.string()).catch([]),
|
|
226
252
|
}),
|
|
253
|
+
/**
|
|
254
|
+
* After a rebase, a test the joined tickets made wrong went back to the
|
|
255
|
+
* ticket's test-writer (#489). `round` counts these per ticket.
|
|
256
|
+
*/
|
|
257
|
+
tests_sent_back: z.looseObject({
|
|
258
|
+
round: z.number().int().min(1).catch(1),
|
|
259
|
+
bad_test: z
|
|
260
|
+
.looseObject({
|
|
261
|
+
file: z.string().catch(''),
|
|
262
|
+
name: z.string().catch(''),
|
|
263
|
+
})
|
|
264
|
+
.catch({ file: '', name: '' }),
|
|
265
|
+
joined: z.array(z.looseObject({ ticket: z.number().int() })).catch([]),
|
|
266
|
+
}),
|
|
227
267
|
run_branch_pushed: z.looseObject({ branch: z.string() }),
|
|
228
268
|
ticket_stuck: z.looseObject({
|
|
229
269
|
reason: z.string(),
|
|
@@ -417,6 +457,7 @@ const BoardEntrySchema = z.discriminatedUnion('kind', [
|
|
|
417
457
|
entry({ kind: 'gates_run' }),
|
|
418
458
|
entry({ kind: 'ticket_joined' }),
|
|
419
459
|
entry({ kind: 'ticket_rebased' }),
|
|
460
|
+
entry({ kind: 'tests_sent_back' }),
|
|
420
461
|
entry({ kind: 'run_branch_pushed' }),
|
|
421
462
|
entry({ kind: 'ticket_stuck' }),
|
|
422
463
|
entry({ kind: 'run_stuck' }),
|
|
@@ -10,25 +10,42 @@ import type { EngineSettings } from '../shared/engine-settings'
|
|
|
10
10
|
* `import.meta.url` is undefined and the cwd is `/`.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
/** What a run builds: one spec, or the engine's safe practice run. */
|
|
13
|
+
/** What a new run builds: one spec, or the engine's safe practice run. */
|
|
14
14
|
export type RunTarget = { kind: 'spec'; spec: number } | { kind: 'demo' }
|
|
15
15
|
|
|
16
|
+
/** What `/luca-run` asks for: a new run, or to resume a run the board started. */
|
|
17
|
+
export type RunRequest = RunTarget | { kind: 'resume'; run_id: string }
|
|
18
|
+
|
|
16
19
|
/**
|
|
17
|
-
* Reads what followed `/luca-run`: `123`, `#123`,
|
|
20
|
+
* Reads what followed `/luca-run`: `123`, `#123`, `demo`, or
|
|
21
|
+
* `resume <run id>`.
|
|
18
22
|
*
|
|
19
23
|
* @example
|
|
20
24
|
* parseRunArgs({ args: '#12' }) // { ok: true, target: { kind: 'spec', spec: 12 } }
|
|
25
|
+
* parseRunArgs({ args: 'resume luca-20260928-124805-69hp' }) // { ok: true, target: { kind: 'resume', run_id: 'luca-20260928-124805-69hp' } }
|
|
21
26
|
* parseRunArgs({ args: 'soon' }).ok // false
|
|
22
27
|
*/
|
|
23
28
|
export const parseRunArgs = ({
|
|
24
29
|
args,
|
|
25
30
|
}: {
|
|
26
31
|
args: string
|
|
27
|
-
}): { ok: true; target:
|
|
32
|
+
}): { ok: true; target: RunRequest } | { ok: false; message: string } => {
|
|
28
33
|
const text = args.trim()
|
|
29
34
|
if (text.toLowerCase() === 'demo') {
|
|
30
35
|
return { ok: true, target: { kind: 'demo' } }
|
|
31
36
|
}
|
|
37
|
+
const words = text.split(/\s+/)
|
|
38
|
+
if (words[0]?.toLowerCase() === 'resume') {
|
|
39
|
+
const run_id = words[1]
|
|
40
|
+
return run_id !== undefined &&
|
|
41
|
+
words.length === 2 &&
|
|
42
|
+
!run_id.startsWith('-')
|
|
43
|
+
? { ok: true, target: { kind: 'resume', run_id } }
|
|
44
|
+
: {
|
|
45
|
+
ok: false,
|
|
46
|
+
message: `Which run? Give its id, as the board shows it. Usage: ${RUN_USAGE}`,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
32
49
|
const match = /^#?(\d+)$/.exec(text)
|
|
33
50
|
const spec = match?.[1] ? Number(match[1]) : 0
|
|
34
51
|
if (spec > 0 && Number.isSafeInteger(spec)) {
|
|
@@ -131,6 +131,18 @@ export type StopWhy =
|
|
|
131
131
|
| { kind: 'check_failed'; error: string }
|
|
132
132
|
| { kind: 'spawn_failed'; error: string }
|
|
133
133
|
|
|
134
|
+
/**
|
|
135
|
+
* How the owner resumes run `run_id` with the board attached: the
|
|
136
|
+
* `/luca-run resume` slash command (see `resumeRow`). A bare
|
|
137
|
+
* `luca-run --resume` would run without the board's token, so the board
|
|
138
|
+
* would show it read-only.
|
|
139
|
+
*
|
|
140
|
+
* @example
|
|
141
|
+
* resumeHint({ run_id: 'r1' }) // 'type /luca-run resume r1 in a Paseo chat'
|
|
142
|
+
*/
|
|
143
|
+
export const resumeHint = ({ run_id }: { run_id: string }): string =>
|
|
144
|
+
`type /luca-run resume ${run_id} in a Paseo chat`
|
|
145
|
+
|
|
134
146
|
/**
|
|
135
147
|
* What the board says when a run's engine is gone and the plugin won't
|
|
136
148
|
* restart it. The panel and header show it as "The engine stopped: <text>".
|
|
@@ -147,22 +159,22 @@ export const engineStoppedText = ({
|
|
|
147
159
|
run_id: string
|
|
148
160
|
log_path: string
|
|
149
161
|
}): string => {
|
|
150
|
-
const resume =
|
|
162
|
+
const resume = resumeHint({ run_id })
|
|
151
163
|
switch (why.kind) {
|
|
152
164
|
case 'demo':
|
|
153
165
|
return `its process is gone, and a demo can't be picked up again. Start a new one with /luca-run demo. Its log is ${log_path}.`
|
|
154
166
|
case 'not_listed':
|
|
155
167
|
return `its process is gone, and its journal has nothing to pick up again (it never wrote one, or the run already ended). Read its log: ${log_path}.`
|
|
156
168
|
case 'launcher_stopped':
|
|
157
|
-
return `The run stopped: ${why.reason}. It isn't restarted automatically, because it would stop the same way. Fix that, then
|
|
169
|
+
return `The run stopped: ${why.reason}. It isn't restarted automatically, because it would stop the same way. Fix that, then ${resume}. Its log is ${log_path}.`
|
|
158
170
|
case 'billing_stopped':
|
|
159
171
|
return `The run stopped for billing: ${why.reason}. It will not go on; start a new run once per-token billing is off.`
|
|
160
172
|
case 'restarts_used_up':
|
|
161
|
-
return `it stopped again after ${MAX_AUTO_RESTARTS} automatic restarts, so Paseo stopped restarting it. Read its log: ${log_path}. To try once more,
|
|
173
|
+
return `it stopped again after ${MAX_AUTO_RESTARTS} automatic restarts, so Paseo stopped restarting it. Read its log: ${log_path}. To try once more, ${resume}.`
|
|
162
174
|
case 'check_failed':
|
|
163
|
-
return `its process is gone, and Paseo couldn't check whether the run can go on (${why.error}). To go on from its journal,
|
|
175
|
+
return `its process is gone, and Paseo couldn't check whether the run can go on (${why.error}). To go on from its journal, ${resume}. Its log is ${log_path}.`
|
|
164
176
|
case 'spawn_failed':
|
|
165
|
-
return `its process is gone, and Paseo couldn't restart the engine: ${why.error}. To go on from its journal,
|
|
177
|
+
return `its process is gone, and Paseo couldn't restart the engine: ${why.error}. To go on from its journal, ${resume}. Its log is ${log_path}.`
|
|
166
178
|
}
|
|
167
179
|
}
|
|
168
180
|
|
|
@@ -195,3 +207,31 @@ export const restartRow = ({
|
|
|
195
207
|
tone: 'warning',
|
|
196
208
|
},
|
|
197
209
|
})
|
|
210
|
+
|
|
211
|
+
/** The words of the chat row for a run resumed with `/luca-run resume`. */
|
|
212
|
+
export const RESUMED_TEXT =
|
|
213
|
+
'The run was resumed from its journal with /luca-run resume.'
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The chat row for a run resumed with `/luca-run resume`; its id is unique
|
|
217
|
+
* per run and time, so each resume gets its own row.
|
|
218
|
+
*
|
|
219
|
+
* @example
|
|
220
|
+
* resumeRow({ run_id, time: new Date().toISOString() })
|
|
221
|
+
*/
|
|
222
|
+
export const resumeRow = ({
|
|
223
|
+
run_id,
|
|
224
|
+
time,
|
|
225
|
+
}: {
|
|
226
|
+
run_id: string
|
|
227
|
+
time: string
|
|
228
|
+
}): BoardRow => ({
|
|
229
|
+
id: `${run_id}-resumed-${time}`,
|
|
230
|
+
kind: ROW_KIND.event,
|
|
231
|
+
data: {
|
|
232
|
+
time,
|
|
233
|
+
ticket: null,
|
|
234
|
+
text: RESUMED_TEXT,
|
|
235
|
+
tone: 'info',
|
|
236
|
+
},
|
|
237
|
+
})
|
|
@@ -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.4"
|