@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.
Files changed (53) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +1 -1
  3. package/board/client/board-panel.tsx +41 -4
  4. package/board/index.client.tsx +1 -1
  5. package/board/package.json +1 -1
  6. package/board/server/board-server.ts +224 -46
  7. package/board/server/board-vocabulary.ts +42 -1
  8. package/board/server/engine-launch.ts +20 -3
  9. package/board/server/engine-watch.ts +45 -5
  10. package/board/server/luca-version.ts +1 -1
  11. package/board/server/make-rows.ts +55 -9
  12. package/board/server/reduce-board.ts +82 -2
  13. package/board/server/run-registry.ts +8 -4
  14. package/board/shared/board-state.ts +20 -1
  15. package/engine/agents/role-instructions.ts +10 -1
  16. package/engine/agents/role-prompts.ts +64 -0
  17. package/engine/cli/going-runs.ts +182 -36
  18. package/engine/cli/live-runs.ts +73 -0
  19. package/engine/cli/run-modes.ts +77 -9
  20. package/engine/cli/setup.ts +5 -48
  21. package/engine/cli/upgrade-command.ts +4 -1
  22. package/engine/cli/upgrade.ts +28 -6
  23. package/engine/config/engine-config.ts +26 -0
  24. package/engine/core/already-done.ts +44 -1
  25. package/engine/core/decide-build.ts +216 -22
  26. package/engine/core/decide-final-review.ts +2 -3
  27. package/engine/core/decide.ts +1 -0
  28. package/engine/core/execute-build.ts +245 -25
  29. package/engine/core/execute-changeset.ts +2 -2
  30. package/engine/core/execute-stuck.ts +15 -1
  31. package/engine/core/execute.ts +48 -5
  32. package/engine/core/fix-loop-text.ts +37 -1
  33. package/engine/core/left-out.ts +120 -0
  34. package/engine/core/loop-caps.ts +8 -0
  35. package/engine/core/not-in-run.ts +7 -3
  36. package/engine/core/pull-request-text.ts +4 -1
  37. package/engine/core/stuck-text.ts +29 -1
  38. package/engine/gates/already-done-check.ts +120 -0
  39. package/engine/gates/gate-runner.ts +22 -11
  40. package/engine/gates/prepare-slots.ts +62 -0
  41. package/engine/gates/red-check.ts +9 -1
  42. package/engine/gates/test-runner.ts +21 -0
  43. package/engine/git/git-adapter.ts +32 -0
  44. package/engine/git/outside-links.ts +55 -0
  45. package/engine/intake/intake-checks.ts +115 -16
  46. package/engine/intake/intake-schemas.ts +37 -2
  47. package/engine/journal/journal-record.ts +103 -4
  48. package/engine/journal/replay.ts +133 -21
  49. package/engine/testing/build-fixtures.ts +55 -0
  50. package/engine/tracker/github-tracker.ts +196 -37
  51. package/engine/tracker/in-memory-tracker.ts +5 -2
  52. package/engine/tracker/tracker.ts +94 -2
  53. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # @alecsibilia/luca
2
2
 
3
+ ## 14.0.0-alpha.4
4
+
5
+ No changes in this release.
6
+
7
+ ## 14.0.0-alpha.3
8
+
9
+ No changes in this release.
10
+
3
11
  ## 14.0.0-alpha.2
4
12
 
5
13
  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.
@@ -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} />
@@ -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.4",
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
 
@@ -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
- /** The run ends with nothing to do; `already_done` is empty at intake (#484). */
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`, 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.4"