@alecsibilia/luca 14.0.0-alpha.2 → 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.
Files changed (41) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +1 -1
  3. package/board/index.client.tsx +1 -1
  4. package/board/package.json +1 -1
  5. package/board/server/board-server.ts +224 -46
  6. package/board/server/board-vocabulary.ts +15 -0
  7. package/board/server/engine-launch.ts +20 -3
  8. package/board/server/engine-watch.ts +45 -5
  9. package/board/server/luca-version.ts +1 -1
  10. package/board/server/make-rows.ts +6 -0
  11. package/board/server/reduce-board.ts +28 -0
  12. package/board/server/run-registry.ts +8 -4
  13. package/board/shared/board-state.ts +1 -1
  14. package/engine/agents/role-instructions.ts +10 -1
  15. package/engine/agents/role-prompts.ts +64 -0
  16. package/engine/cli/going-runs.ts +182 -36
  17. package/engine/cli/live-runs.ts +73 -0
  18. package/engine/cli/run-modes.ts +48 -4
  19. package/engine/cli/upgrade-command.ts +4 -1
  20. package/engine/cli/upgrade.ts +28 -6
  21. package/engine/config/engine-config.ts +26 -0
  22. package/engine/core/already-done.ts +41 -0
  23. package/engine/core/decide-build.ts +216 -22
  24. package/engine/core/decide-final-review.ts +2 -3
  25. package/engine/core/execute-build.ts +245 -25
  26. package/engine/core/execute-changeset.ts +2 -2
  27. package/engine/core/execute.ts +1 -0
  28. package/engine/core/fix-loop-text.ts +37 -1
  29. package/engine/core/loop-caps.ts +8 -0
  30. package/engine/core/stuck-text.ts +29 -1
  31. package/engine/gates/already-done-check.ts +120 -0
  32. package/engine/gates/gate-runner.ts +22 -11
  33. package/engine/gates/prepare-slots.ts +62 -0
  34. package/engine/gates/red-check.ts +9 -1
  35. package/engine/gates/test-runner.ts +21 -0
  36. package/engine/git/git-adapter.ts +32 -0
  37. package/engine/git/outside-links.ts +55 -0
  38. package/engine/journal/journal-record.ts +72 -2
  39. package/engine/journal/replay.ts +129 -21
  40. package/engine/testing/build-fixtures.ts +55 -0
  41. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # @alecsibilia/luca
2
2
 
3
+ ## 14.0.0-alpha.3
4
+
5
+ No changes in this release.
6
+
3
7
  ## 14.0.0-alpha.2
4
8
 
5
9
  No changes in this release.
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.
@@ -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 }) => {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luca/board",
3
- "version": "14.0.0-alpha.2",
3
+ "version": "14.0.0-alpha.3",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }
@@ -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 { target } = parsed
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
- const headerAdded = enqueue({
587
- run_id,
588
- work: () => appendRows({ memory, rows: [header({ memory })] }),
589
- })
590
- let timer: ReturnType<typeof setTimeout> | undefined
591
- await Promise.race([
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, repo, token, log_path } = memory.entry
1010
+ const { run_id, log_path } = memory.entry
809
1011
  let pid: number | null = null
810
1012
  try {
811
- pid = spawn_engine({
812
- command: engine.command,
813
- args: [...engine.lead_args, ...resumeArgs({ run_id, repo })],
814
- env: childEnv({ token }),
815
- cwd: repo,
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
 
@@ -224,6 +224,20 @@ export const BOARD_VOCABULARY = {
224
224
  tests: z.array(z.string()).catch([]),
225
225
  code: z.array(z.string()).catch([]),
226
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
+ }),
227
241
  run_branch_pushed: z.looseObject({ branch: z.string() }),
228
242
  ticket_stuck: z.looseObject({
229
243
  reason: z.string(),
@@ -417,6 +431,7 @@ const BoardEntrySchema = z.discriminatedUnion('kind', [
417
431
  entry({ kind: 'gates_run' }),
418
432
  entry({ kind: 'ticket_joined' }),
419
433
  entry({ kind: 'ticket_rebased' }),
434
+ entry({ kind: 'tests_sent_back' }),
420
435
  entry({ kind: 'run_branch_pushed' }),
421
436
  entry({ kind: 'ticket_stuck' }),
422
437
  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`, or `demo`.
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: RunTarget } | { ok: false; message: string } => {
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 = `luca-run --resume ${run_id}`
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 run ${resume}. Its log is ${log_path}.`
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, run ${resume}.`
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, run ${resume}. Its log is ${log_path}.`
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, run ${resume}. Its log is ${log_path}.`
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"
2
+ export const LUCA_VERSION: string = "14.0.0-alpha.3"
@@ -13,6 +13,7 @@ import {
13
13
  roleWords,
14
14
  shortSha,
15
15
  startKind,
16
+ testsSentBackText,
16
17
  } from './reduce-board'
17
18
 
18
19
  import {
@@ -373,6 +374,11 @@ export const describeRecord = ({
373
374
  text: `${at}${lowerFirst({ text: rebasedText({ content: record.content }) })}.`,
374
375
  tone: 'warning',
375
376
  })
377
+ case 'tests_sent_back':
378
+ return event({
379
+ text: `${at}${lowerFirst({ text: testsSentBackText({ content: record.content }) })}.`,
380
+ tone: 'warning',
381
+ })
376
382
  case 'ticket_stuck':
377
383
  return event({
378
384
  text: `${at}stuck. ${reasonText({ reason: record.content.reason })}`,
@@ -1135,6 +1135,21 @@ const applyKind = ({
1135
1135
  })
1136
1136
  case 'ticket_rebased':
1137
1137
  return ticketRebased({ state, record })
1138
+ case 'tests_sent_back':
1139
+ return updateTicket({
1140
+ state,
1141
+ number: ticket,
1142
+ update: (card) => ({
1143
+ ...card,
1144
+ step: 0,
1145
+ role: null,
1146
+ activity: 'updating tests for the run branch',
1147
+ tried: withTried({
1148
+ tried: card.tried,
1149
+ line: testsSentBackText({ content: record.content }),
1150
+ }),
1151
+ }),
1152
+ })
1138
1153
  case 'run_branch_pushed':
1139
1154
  return updateTicket({
1140
1155
  state,
@@ -1731,6 +1746,19 @@ export const rebasedText = ({
1731
1746
  : `Clashed with the run branch in ${files.join(', ')}`
1732
1747
  }
1733
1748
 
1749
+ /** A test sent back to the test-writer after a rebase (#489), in words. */
1750
+ export const testsSentBackText = ({
1751
+ content,
1752
+ }: {
1753
+ content: Extract<BoardRecord, { kind: 'tests_sent_back' }>['content']
1754
+ }): string => {
1755
+ const test = [content.bad_test.file, content.bad_test.name]
1756
+ .filter(Boolean)
1757
+ .join(' > ')
1758
+ const joined = content.joined.map(({ ticket }) => `#${ticket}`).join(', ')
1759
+ return `Test update ${content.round}: ${test || 'a test'} is wrong on the run branch${joined === '' ? '' : ` after ${joined} joined`}; back to the test-writer`
1760
+ }
1761
+
1734
1762
  /**
1735
1763
  * A ticket sent back onto the run branch builds again: its tests first if
1736
1764
  * test files clashed, else its code; then the checks and a fresh review,
@@ -28,7 +28,10 @@ export const RunEntrySchema = z.object({
28
28
  log_path: z.string(),
29
29
  /** How the run's engine ended, once it did; `null` while it may run. */
30
30
  ended: EngineEndedSchema.nullable().default(null),
31
- /** How many times the plugin restarted the run by itself (#375). */
31
+ /**
32
+ * How many times the plugin restarted the run by itself (#375); back to
33
+ * 0 when the owner resumes it (#493).
34
+ */
32
35
  restarts: z.number().int().min(0).default(0),
33
36
  })
34
37
 
@@ -136,15 +139,16 @@ export const createRunRegistry = ({
136
139
  write()
137
140
  },
138
141
  /**
139
- * Changes a known run's `ended` or `restarts` and writes the file.
140
- * Returns the changed entry, or `null` for an unknown run.
142
+ * Changes a known run's `ended`, `restarts`, or `token` (a resume
143
+ * gets a new one) and writes the file. Returns the changed entry, or
144
+ * `null` for an unknown run.
141
145
  */
142
146
  update: ({
143
147
  run_id,
144
148
  change,
145
149
  }: {
146
150
  run_id: string
147
- change: Partial<Pick<RunEntry, 'ended' | 'restarts'>>
151
+ change: Partial<Pick<RunEntry, 'ended' | 'restarts' | 'token'>>
148
152
  }): RunEntry | null => {
149
153
  reread()
150
154
  const known = entries.find((entry) => entry.run_id === run_id)
@@ -15,7 +15,7 @@ export const PLUGIN_ID = 'luca-board'
15
15
  export const POLL_MS = 2000
16
16
 
17
17
  /** How to call the slash command, as shown to people. */
18
- export const RUN_USAGE = '/luca-run <spec number> | demo'
18
+ export const RUN_USAGE = '/luca-run <spec number> | demo | resume <run id>'
19
19
 
20
20
  /** The side panel's text when this workspace has no run yet. */
21
21
  export const EMPTY_BOARD_TEXT =