@alecsibilia/luca 14.0.0-alpha.1 → 14.0.0-alpha.3
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/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 +27 -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 +30 -2
- package/board/server/reduce-board.ts +69 -1
- package/board/server/run-registry.ts +8 -4
- package/board/shared/board-state.ts +1 -1
- package/engine/agents/role-instructions.ts +12 -1
- package/engine/agents/role-prompts.ts +64 -0
- package/engine/agents/role-results.ts +39 -5
- package/engine/cli/going-runs.ts +182 -36
- package/engine/cli/live-runs.ts +73 -0
- package/engine/cli/run-modes.ts +48 -4
- package/engine/cli/upgrade-command.ts +4 -1
- package/engine/cli/upgrade.ts +28 -6
- package/engine/config/engine-config.ts +51 -0
- package/engine/core/already-done.ts +180 -0
- package/engine/core/decide-build.ts +298 -39
- package/engine/core/decide-final-review.ts +2 -3
- package/engine/core/decide-usage.ts +5 -3
- package/engine/core/decide.ts +10 -4
- package/engine/core/execute-already-done.ts +65 -0
- package/engine/core/execute-build.ts +340 -35
- package/engine/core/execute-changeset.ts +6 -2
- package/engine/core/execute.ts +14 -1
- package/engine/core/fix-loop-text.ts +37 -1
- package/engine/core/learner-digest.ts +3 -1
- package/engine/core/loop-caps.ts +8 -0
- package/engine/core/not-in-run.ts +29 -0
- package/engine/core/pull-request-text.ts +45 -4
- package/engine/core/session-close.ts +10 -3
- package/engine/core/stuck-text.ts +33 -4
- package/engine/gates/already-done-check.ts +120 -0
- package/engine/gates/gate-runner.ts +66 -10
- package/engine/gates/gate-schemas.ts +14 -0
- package/engine/gates/leftover-scan.ts +7 -2
- 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 +101 -4
- package/engine/git/outside-links.ts +55 -0
- package/engine/git/path-text.ts +34 -0
- package/engine/guards/worktree-state.ts +14 -2
- package/engine/journal/journal-record.ts +134 -3
- package/engine/journal/replay.ts +170 -21
- package/engine/shell/run-command.ts +109 -7
- package/engine/testing/build-fixtures.ts +121 -0
- package/engine/tracker/github-tracker.ts +5 -0
- package/engine/tracker/in-memory-tracker.ts +3 -0
- package/engine/tracker/tracker.ts +5 -0
- 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.
|
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
|
|
|
@@ -128,7 +128,10 @@ export const BOARD_VOCABULARY = {
|
|
|
128
128
|
})
|
|
129
129
|
),
|
|
130
130
|
}),
|
|
131
|
-
|
|
131
|
+
/** The run ends with nothing to do; `already_done` is empty at intake (#484). */
|
|
132
|
+
nothing_to_do: z.looseObject({
|
|
133
|
+
already_done: z.array(z.number().int()).catch([]).default([]),
|
|
134
|
+
}),
|
|
132
135
|
spec_snapshot: z.looseObject({
|
|
133
136
|
spec: z.looseObject({ number: z.number(), title: z.string() }),
|
|
134
137
|
ticket_order: z.array(z.number()).catch([]),
|
|
@@ -221,6 +224,20 @@ export const BOARD_VOCABULARY = {
|
|
|
221
224
|
tests: z.array(z.string()).catch([]),
|
|
222
225
|
code: z.array(z.string()).catch([]),
|
|
223
226
|
}),
|
|
227
|
+
/**
|
|
228
|
+
* After a rebase, a test the joined tickets made wrong went back to the
|
|
229
|
+
* ticket's test-writer (#489). `round` counts these per ticket.
|
|
230
|
+
*/
|
|
231
|
+
tests_sent_back: z.looseObject({
|
|
232
|
+
round: z.number().int().min(1).catch(1),
|
|
233
|
+
bad_test: z
|
|
234
|
+
.looseObject({
|
|
235
|
+
file: z.string().catch(''),
|
|
236
|
+
name: z.string().catch(''),
|
|
237
|
+
})
|
|
238
|
+
.catch({ file: '', name: '' }),
|
|
239
|
+
joined: z.array(z.looseObject({ ticket: z.number().int() })).catch([]),
|
|
240
|
+
}),
|
|
224
241
|
run_branch_pushed: z.looseObject({ branch: z.string() }),
|
|
225
242
|
ticket_stuck: z.looseObject({
|
|
226
243
|
reason: z.string(),
|
|
@@ -313,6 +330,12 @@ export const BOARD_VOCABULARY = {
|
|
|
313
330
|
ticket_skipped: z.looseObject({
|
|
314
331
|
because: z.number().int().nullable().catch(null),
|
|
315
332
|
}),
|
|
333
|
+
/** A ticket whose work was already on the base branch (#484), done by `shas`. */
|
|
334
|
+
ticket_already_done: z.looseObject({
|
|
335
|
+
shas: z.array(z.string()).catch([]),
|
|
336
|
+
}),
|
|
337
|
+
/** The engine closed an already-done ticket, as the run opens no PR. */
|
|
338
|
+
ticket_closed: z.looseObject({}),
|
|
316
339
|
|
|
317
340
|
// The final review (#367). Its agent, gate, scan, commit, and push
|
|
318
341
|
// records are the usual kinds with `ticket: null`.
|
|
@@ -408,6 +431,7 @@ const BoardEntrySchema = z.discriminatedUnion('kind', [
|
|
|
408
431
|
entry({ kind: 'gates_run' }),
|
|
409
432
|
entry({ kind: 'ticket_joined' }),
|
|
410
433
|
entry({ kind: 'ticket_rebased' }),
|
|
434
|
+
entry({ kind: 'tests_sent_back' }),
|
|
411
435
|
entry({ kind: 'run_branch_pushed' }),
|
|
412
436
|
entry({ kind: 'ticket_stuck' }),
|
|
413
437
|
entry({ kind: 'run_stuck' }),
|
|
@@ -428,6 +452,8 @@ const BoardEntrySchema = z.discriminatedUnion('kind', [
|
|
|
428
452
|
entry({ kind: 'reply_ignored' }),
|
|
429
453
|
entry({ kind: 'ticket_retried' }),
|
|
430
454
|
entry({ kind: 'ticket_skipped' }),
|
|
455
|
+
entry({ kind: 'ticket_already_done' }),
|
|
456
|
+
entry({ kind: 'ticket_closed' }),
|
|
431
457
|
entry({ kind: 'final_review_started' }),
|
|
432
458
|
entry({ kind: 'lens_started' }),
|
|
433
459
|
entry({ kind: 'lens_finished' }),
|
|
@@ -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.3"
|
|
@@ -11,7 +11,9 @@ import {
|
|
|
11
11
|
runReasonText,
|
|
12
12
|
reviewCountsText,
|
|
13
13
|
roleWords,
|
|
14
|
+
shortSha,
|
|
14
15
|
startKind,
|
|
16
|
+
testsSentBackText,
|
|
15
17
|
} from './reduce-board'
|
|
16
18
|
|
|
17
19
|
import {
|
|
@@ -216,11 +218,16 @@ export const describeRecord = ({
|
|
|
216
218
|
text: `Intake refused the run. ${after.run.refusal.join('; ')}`,
|
|
217
219
|
tone: 'danger',
|
|
218
220
|
})
|
|
219
|
-
case 'nothing_to_do':
|
|
221
|
+
case 'nothing_to_do': {
|
|
222
|
+
const done = record.content.already_done
|
|
220
223
|
return event({
|
|
221
|
-
text:
|
|
224
|
+
text:
|
|
225
|
+
done.length === 0
|
|
226
|
+
? 'Nothing to do: the spec has no open tickets.'
|
|
227
|
+
: `Nothing to do: the work of ${done.map((number) => `#${number}`).join(', ')} was already on the base branch.`,
|
|
222
228
|
tone: 'info',
|
|
223
229
|
})
|
|
230
|
+
}
|
|
224
231
|
case 'spec_snapshot':
|
|
225
232
|
return event({
|
|
226
233
|
text: `Intake passed: spec #${record.content.spec.number}, ${record.content.ticket_order.length} tickets.`,
|
|
@@ -367,6 +374,11 @@ export const describeRecord = ({
|
|
|
367
374
|
text: `${at}${lowerFirst({ text: rebasedText({ content: record.content }) })}.`,
|
|
368
375
|
tone: 'warning',
|
|
369
376
|
})
|
|
377
|
+
case 'tests_sent_back':
|
|
378
|
+
return event({
|
|
379
|
+
text: `${at}${lowerFirst({ text: testsSentBackText({ content: record.content }) })}.`,
|
|
380
|
+
tone: 'warning',
|
|
381
|
+
})
|
|
370
382
|
case 'ticket_stuck':
|
|
371
383
|
return event({
|
|
372
384
|
text: `${at}stuck. ${reasonText({ reason: record.content.reason })}`,
|
|
@@ -389,6 +401,16 @@ export const describeRecord = ({
|
|
|
389
401
|
tone: 'info',
|
|
390
402
|
})
|
|
391
403
|
}
|
|
404
|
+
case 'ticket_already_done':
|
|
405
|
+
return event({
|
|
406
|
+
text: `${at}already done on the base branch, by ${record.content.shas.map((sha) => shortSha({ sha })).join(', ')}. Nothing to build.`,
|
|
407
|
+
tone: 'success',
|
|
408
|
+
})
|
|
409
|
+
case 'ticket_closed':
|
|
410
|
+
return event({
|
|
411
|
+
text: `${at}closed, since its work was already done.`,
|
|
412
|
+
tone: 'info',
|
|
413
|
+
})
|
|
392
414
|
case 'ticket_skipped': {
|
|
393
415
|
const { because } = record.content
|
|
394
416
|
return event({
|
|
@@ -689,6 +711,12 @@ const finishedText = ({
|
|
|
689
711
|
})
|
|
690
712
|
}
|
|
691
713
|
if (role === 'test-writer') {
|
|
714
|
+
if (result.outcome === 'already_done') {
|
|
715
|
+
return event({
|
|
716
|
+
text: `${at}the test-writer found the work already done.`,
|
|
717
|
+
tone: 'info',
|
|
718
|
+
})
|
|
719
|
+
}
|
|
692
720
|
return result.outcome === 'nothing_new_to_test'
|
|
693
721
|
? event({ text: `${at}nothing new to test.`, tone: 'info' })
|
|
694
722
|
: event({ text: `${at}tests written.`, tone: 'info' })
|