@alecsibilia/luca 14.0.0-alpha.28 → 14.0.0-alpha.29

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 (38) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/board/client/board-look.ts +3 -0
  3. package/board/package.json +1 -1
  4. package/board/server/board-server-check.ts +24 -5
  5. package/board/server/board-server-follow.ts +143 -0
  6. package/board/server/board-server-read.ts +50 -23
  7. package/board/server/board-server-start.ts +1 -0
  8. package/board/server/board-server-store.ts +32 -8
  9. package/board/server/board-server-types.ts +11 -3
  10. package/board/server/board-server.ts +11 -6
  11. package/board/server/board-vocabulary-run.ts +4 -0
  12. package/board/server/board-vocabulary.ts +2 -0
  13. package/board/server/describe-record.ts +4 -1
  14. package/board/server/engine-watch.ts +33 -2
  15. package/board/server/luca-version.ts +1 -1
  16. package/board/server/reduce-board-create.ts +1 -0
  17. package/board/server/reduce-board-run.ts +23 -1
  18. package/board/server/reduce-board-settle.ts +3 -0
  19. package/board/server/repo-match.ts +74 -0
  20. package/board/server/run-registry.ts +12 -4
  21. package/board/shared/board-state-run.ts +1 -0
  22. package/board/shared/board-state.ts +6 -0
  23. package/engine/board/board-sync.ts +7 -3
  24. package/engine/cli/live-runs.ts +120 -6
  25. package/engine/cli/luca-run.ts +12 -2
  26. package/engine/cli/run-modes.ts +1 -0
  27. package/engine/core/execute-pull-request.ts +11 -3
  28. package/engine/core/execute.ts +29 -6
  29. package/engine/core/network-wait.ts +88 -0
  30. package/engine/journal/journal-entry-schema.ts +6 -0
  31. package/engine/journal/journal-kinds.ts +2 -0
  32. package/engine/journal/journal-record-schema.ts +6 -0
  33. package/engine/journal/network-wait-entries.ts +29 -0
  34. package/engine/journal/replay.ts +4 -0
  35. package/engine/journal/ticket-replay.ts +2 -0
  36. package/engine/tracker/gh-retry.ts +38 -5
  37. package/engine/tracker/tracker.ts +30 -1
  38. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # @alecsibilia/luca
2
2
 
3
+ ## 14.0.0-alpha.29
4
+
5
+ No changes in this release.
6
+
3
7
  ## 14.0.0-alpha.28
4
8
 
5
9
  No changes in this release.
@@ -144,6 +144,7 @@ export const statusColor = ({
144
144
  return theme.colors.statusDanger
145
145
  case 'limit_wait':
146
146
  case 'usage_line_wait':
147
+ case 'network_wait':
147
148
  return theme.colors.statusWarning
148
149
  case 'starting':
149
150
  case 'intake':
@@ -195,6 +196,8 @@ export const statusText = ({ status }: { status: RunStatus }): string => {
195
196
  return 'limit wait'
196
197
  case 'usage_line_wait':
197
198
  return 'paused at the usage line'
199
+ case 'network_wait':
200
+ return 'Waiting for GitHub to be reachable'
198
201
  case 'done':
199
202
  return 'done'
200
203
  case 'refused':
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luca/board",
3
- "version": "14.0.0-alpha.28",
3
+ "version": "14.0.0-alpha.29",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }
@@ -2,10 +2,12 @@
2
2
  * The engine check: finds runs whose engine process is gone although the run
3
3
  * hasn't ended, restarts the ones that can go on from their journal, and
4
4
  * marks the rest as stopped, with why. The runs the plugin didn't start are
5
- * only labelled (#635), never restarted.
5
+ * only labelled (#635), never restarted. A run marked ended whose journal
6
+ * grew and that has a live engine again comes back (#701).
6
7
  */
7
8
 
8
9
  import type { EngineProcess } from './board-server-engine'
10
+ import type { JournalFollow } from './board-server-follow'
9
11
  import type { RunStore } from './board-server-store'
10
12
  import type {
11
13
  BoardServerConfig,
@@ -34,15 +36,17 @@ export const createEngineChecks = ({
34
36
  store,
35
37
  engine,
36
38
  reads,
39
+ follow,
37
40
  }: {
38
41
  config: BoardServerConfig
39
42
  store: RunStore
40
43
  engine: EngineProcess
41
- /** The runs the plugin didn't start, and how to read them again. */
44
+ /** The runs the plugin didn't start, and how to read every journal again. */
42
45
  reads: {
43
46
  outside: Map<string, OutsideRun>
44
- readOutside: () => Promise<void>
47
+ readJournals: (args?: { release_held?: boolean }) => Promise<void>
45
48
  }
49
+ follow: JournalFollow
46
50
  }) => {
47
51
  const {
48
52
  list_processes,
@@ -171,7 +175,8 @@ export const createEngineChecks = ({
171
175
  /** One check: see `checkEngines`. */
172
176
  const runCheck = async (): Promise<EngineCheck> => {
173
177
  await ready
174
- await reads.readOutside()
178
+ // Rows held since the plugin loaded stop waiting for a push (#701).
179
+ await reads.readJournals({ release_held: true })
175
180
  const summary: EngineCheck = { restarted: [], stopped: [] }
176
181
  const candidates = [...runs.values()].filter((memory) =>
177
182
  needsEngine({ state: memory.state })
@@ -179,7 +184,14 @@ export const createEngineChecks = ({
179
184
  const others = [...reads.outside.values()].filter((run) =>
180
185
  needsEngine({ state: run.state })
181
186
  )
182
- if (candidates.length === 0 && others.length === 0) return summary
187
+ const returning = follow.returning()
188
+ if (
189
+ candidates.length === 0 &&
190
+ others.length === 0 &&
191
+ returning.length === 0
192
+ ) {
193
+ return summary
194
+ }
183
195
  let command_lines: string[]
184
196
  try {
185
197
  command_lines = await list_processes()
@@ -192,6 +204,13 @@ export const createEngineChecks = ({
192
204
  return summary
193
205
  }
194
206
  const live = liveRunIds({ command_lines })
207
+ // An ended run whose journal grew and that has a live engine (such
208
+ // as `luca-run --resume`) is live again (#701).
209
+ await Promise.all(
210
+ returning
211
+ .filter((memory) => live.has(memory.entry.run_id))
212
+ .map((memory) => follow.comeBack({ memory }))
213
+ )
195
214
  await labelOutside({ others, live })
196
215
  const dead = candidates.filter(
197
216
  (memory) => !live.has(memory.entry.run_id)
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Following the runs this plugin started on disk (#701): their journals are
3
+ * read again when they grow, so a run whose engine can't push (such as one
4
+ * resumed with `luca-run --resume`) still shows live. A run marked ended
5
+ * comes back when its journal grows and a live engine runs it.
6
+ */
7
+ import type { RunStore } from './board-server-store'
8
+ import type { BoardServerConfig, RunMemory } from './board-server-types'
9
+ import { RESUMED_CLI_TEXT, resumedCliRow } from './engine-watch'
10
+ import { settle } from './reduce-board-settle'
11
+ import type { JournalFile } from './run-journals'
12
+
13
+ import type { BoardRow } from '../shared/board-rows'
14
+
15
+ export const createJournalFollow = ({
16
+ config,
17
+ store,
18
+ }: {
19
+ config: BoardServerConfig
20
+ store: RunStore
21
+ }) => {
22
+ const { log, now } = config
23
+ const {
24
+ runs,
25
+ enqueue,
26
+ applyInOrder,
27
+ appendRows,
28
+ header,
29
+ journalOf,
30
+ updateEntry,
31
+ } = store
32
+
33
+ /** Takes the rows a run still holds from its rebuild at plugin load. */
34
+ const takeHeld = ({ memory }: { memory: RunMemory }): BoardRow[] => {
35
+ const rows = [...memory.unsent_rows.values()].flat()
36
+ memory.unsent_rows.clear()
37
+ return rows
38
+ }
39
+
40
+ /**
41
+ * Reads again the journal of each run this plugin started that changed
42
+ * since the last read, and applies the records past its `next_seq`
43
+ * (the cursor pushes share), adding their rows to the chat at once.
44
+ * The rows a run still holds from its rebuild at plugin load
45
+ * (`unsent_rows`) go first, so they reach the chat even when no push
46
+ * comes: with the first rows read from disk, or on an engine check
47
+ * (`release_held`) after the first read since the load.
48
+ */
49
+ const readFollowed = async ({
50
+ journals,
51
+ release_held = false,
52
+ }: {
53
+ journals: JournalFile[]
54
+ release_held?: boolean
55
+ }) => {
56
+ await Promise.all(
57
+ journals.map(({ run_id, size, changed_ms }) => {
58
+ const memory = runs.get(run_id)
59
+ if (!memory) return
60
+ const changed =
61
+ memory.size !== size || memory.changed_ms !== changed_ms
62
+ // The first read since the load leaves a push its chance.
63
+ const release =
64
+ release_held &&
65
+ memory.size !== null &&
66
+ memory.unsent_rows.size > 0
67
+ if (!changed && !release) return
68
+ return enqueue({
69
+ run_id,
70
+ work: async () => {
71
+ const { rows, applied } = applyInOrder({
72
+ memory,
73
+ records: changed ? await journalOf({ run_id }) : [],
74
+ })
75
+ memory.size = size
76
+ memory.changed_ms = changed_ms
77
+ const held =
78
+ applied > 0 || release ? takeHeld({ memory }) : []
79
+ if (applied === 0 && held.length === 0) return
80
+ await appendRows({
81
+ memory,
82
+ rows: [
83
+ ...held,
84
+ ...[...rows.values()].flat(),
85
+ header({ memory }),
86
+ ],
87
+ })
88
+ },
89
+ })
90
+ })
91
+ )
92
+ }
93
+
94
+ /** The runs marked ended whose journal grew past `ended_at_seq`. */
95
+ const returning = (): RunMemory[] =>
96
+ [...runs.values()].filter(
97
+ ({ entry, next_seq }) =>
98
+ entry.ended !== null &&
99
+ entry.ended_at_seq !== null &&
100
+ next_seq - 1 > entry.ended_at_seq
101
+ )
102
+
103
+ /**
104
+ * Brings back a run marked ended that has a live engine: clears its
105
+ * `ended`, re-adds its header, and says so in its chat. Its restart
106
+ * count is kept.
107
+ */
108
+ const comeBack = ({ memory }: { memory: RunMemory }) =>
109
+ enqueue({
110
+ run_id: memory.entry.run_id,
111
+ work: async () => {
112
+ if (memory.entry.ended === null) return
113
+ const { run_id } = memory.entry
114
+ const seq = memory.next_seq - 1
115
+ updateEntry({ memory, change: { ended: null } })
116
+ const time = now().toISOString()
117
+ memory.rows_stopped = false
118
+ memory.append_failures = 0
119
+ memory.state = settle({
120
+ state: {
121
+ ...memory.state,
122
+ run: { ...memory.state.run, engine_ended: null },
123
+ latest: RESUMED_CLI_TEXT,
124
+ latest_at: time,
125
+ },
126
+ })
127
+ log(
128
+ `[${run_id}] came back: its journal grew to seq ${seq} and a live engine runs it.`
129
+ )
130
+ await appendRows({
131
+ memory,
132
+ rows: [
133
+ resumedCliRow({ run_id, seq, time }),
134
+ header({ memory }),
135
+ ],
136
+ })
137
+ },
138
+ })
139
+
140
+ return { readFollowed, returning, comeBack }
141
+ }
142
+
143
+ export type JournalFollow = ReturnType<typeof createJournalFollow>
@@ -1,17 +1,19 @@
1
1
  /**
2
2
  * What the side panel reads and posts: `board.read` (the runs started from
3
- * a workspace's chats and the runs the plugin didn't start, read from their
4
- * journals), and `reply.post`.
3
+ * a workspace's chats and the runs the plugin didn't start, all followed on
4
+ * disk through their journals), and `reply.post`.
5
5
  */
6
6
  import { resolve } from 'node:path'
7
7
 
8
8
  import { z } from 'zod'
9
9
 
10
+ import type { JournalFollow } from './board-server-follow'
10
11
  import type { RunStore } from './board-server-store'
11
12
  import type { BoardServerConfig, OutsideRun } from './board-server-types'
12
13
  import { readLiveFile, withLive } from './live-file'
13
14
  import { createBoardState } from './reduce-board'
14
15
  import type { ReplyRun } from './reply-post'
16
+ import { createRepoMatch } from './repo-match'
15
17
  import { listJournals } from './run-journals'
16
18
 
17
19
  import type {
@@ -38,22 +40,31 @@ const OUTSIDE_RUNS_SHOWN = 50
38
40
  export const createBoardReads = ({
39
41
  config,
40
42
  store,
43
+ follow,
41
44
  }: {
42
45
  config: BoardServerConfig
43
46
  store: RunStore
47
+ follow: JournalFollow
44
48
  }) => {
45
- const { runs_dir = null } = config
49
+ const { runs_dir = null, run_command, env, home_dir } = config
46
50
  const { runs, poster, applyInOrder, journalOf, ready } = store
47
51
  const outside = new Map<string, OutsideRun>()
52
+ const { sameRepo } = createRepoMatch({ run_command, env, home_dir })
48
53
 
49
54
  /**
50
- * Reads the journals of runs this plugin didn't start, the latest
51
- * changed first: a new or changed journal is read again and its new
52
- * records applied.
55
+ * Reads the journals on disk, the latest changed first: a new or changed
56
+ * journal is read again and its new records applied. The runs this
57
+ * plugin started are all followed (see `readFollowed`, which
58
+ * `release_held` passes to); of the others, the latest
59
+ * `OUTSIDE_RUNS_SHOWN`.
53
60
  */
54
- const readOutside = async () => {
61
+ const readJournals = async ({
62
+ release_held = false,
63
+ }: { release_held?: boolean } = {}) => {
55
64
  if (runs_dir === null) return
56
- const journals = (await listJournals({ runs_dir }))
65
+ const all = await listJournals({ runs_dir })
66
+ await follow.readFollowed({ journals: all, release_held })
67
+ const journals = all
57
68
  .filter((journal) => !runs.has(journal.run_id))
58
69
  .slice(0, OUTSIDE_RUNS_SHOWN)
59
70
  const shown = new Set(journals.map((journal) => journal.run_id))
@@ -92,31 +103,47 @@ export const createBoardReads = ({
92
103
  )
93
104
  }
94
105
 
106
+ /** The runs this plugin didn't start that are in `directory`'s repo. */
107
+ const outsideIn = async ({
108
+ directory,
109
+ }: {
110
+ directory: string | null
111
+ }): Promise<OutsideRun[]> => {
112
+ if (directory === null) return []
113
+ const others = [...outside.values()]
114
+ const shown = await Promise.all(
115
+ others.map((run) =>
116
+ run.repo === null
117
+ ? false
118
+ : sameRepo({ directory, repo: run.repo })
119
+ )
120
+ )
121
+ return others.filter((_, index) => shown[index])
122
+ }
123
+
95
124
  /**
96
125
  * `board.read`: the runs started from the workspace's chats and the runs
97
- * this plugin didn't start whose repo is the workspace's folder, newest
98
- * first, and one of them in full, with its live file laid over it.
126
+ * this plugin didn't start whose repo is in the workspace folder's git
127
+ * repo, newest first, and one of them in full, with its live file laid
128
+ * over it.
99
129
  */
100
130
  const readBoard = async (
101
131
  input: BoardReadInput
102
132
  ): Promise<BoardReadOutput> => {
103
133
  await ready
104
- await readOutside()
134
+ await readJournals()
105
135
  const directory = input.directory ? resolve(input.directory) : null
106
136
  const list = [
107
137
  ...[...runs.values()].filter(
108
138
  (memory) => memory.entry.workspace_id === input.workspace_id
109
139
  ),
110
- ...[...outside.values()]
111
- .filter((run) => directory !== null && run.repo === directory)
112
- .map((run) => ({
113
- state: {
114
- ...run.state,
115
- engine_gone:
116
- run.engine_gone &&
117
- needsEngine({ state: run.state }),
118
- },
119
- })),
140
+ ...(await outsideIn({ directory })).map((run) => ({
141
+ state: {
142
+ ...run.state,
143
+ engine_gone:
144
+ run.engine_gone && needsEngine({ state: run.state }),
145
+ },
146
+ })),
120
147
  ]
121
148
  .map(({ state }) => state)
122
149
  .toSorted((left, right) =>
@@ -167,7 +194,7 @@ export const createBoardReads = ({
167
194
  input: ReplyPostInput
168
195
  ): Promise<ReplyPostOutput> => {
169
196
  await ready
170
- await readOutside()
197
+ await readJournals()
171
198
  const memory = runs.get(input.run_id)
172
199
  const other = outside.get(input.run_id)
173
200
  const run: ReplyRun | null = memory
@@ -186,5 +213,5 @@ export const createBoardReads = ({
186
213
  return poster.postReply({ run, input })
187
214
  }
188
215
 
189
- return { outside, readOutside, readBoard, postReply }
216
+ return { outside, readJournals, readBoard, postReply }
190
217
  }
@@ -106,6 +106,7 @@ export const createStarter = ({
106
106
  started_at: now().toISOString(),
107
107
  log_path,
108
108
  ended: null,
109
+ ended_at_seq: null,
109
110
  restarts: 0,
110
111
  }
111
112
  registry.add({ entry })
@@ -172,7 +172,8 @@ export const createRunStore = ({ config }: { config: BoardServerConfig }) => {
172
172
  /**
173
173
  * Knows a run this plugin started, rebuilt from its journal `records`.
174
174
  * Their rows aren't added now; while the engine may still send, they are
175
- * kept until it sends those records again (see `handleEngineEvent`).
175
+ * kept until it sends those records again (see `handleEngineEvent`) or
176
+ * the disk follow adds them (see `readFollowed`).
176
177
  */
177
178
  const remember = ({
178
179
  entry,
@@ -194,6 +195,8 @@ export const createRunStore = ({ config }: { config: BoardServerConfig }) => {
194
195
  append_failures: 0,
195
196
  rows_stopped: false,
196
197
  unsent_rows: new Map(),
198
+ size: null,
199
+ changed_ms: null,
197
200
  }
198
201
  const { rows } = applyInOrder({ memory, records })
199
202
  // A run whose engine ended stays ended after a plugin restart.
@@ -221,23 +224,44 @@ export const createRunStore = ({ config }: { config: BoardServerConfig }) => {
221
224
  const journals = await Promise.all(
222
225
  entries.map(({ run_id }) => journalOf({ run_id }))
223
226
  )
224
- entries.forEach((entry, index) =>
225
- remember({ entry, records: journals[index] ?? [] })
226
- )
227
+ entries.forEach((entry, index) => {
228
+ const memory = remember({ entry, records: journals[index] ?? [] })
229
+ // An entry ended before `ended_at_seq` existed ended at its
230
+ // journal's newest seq.
231
+ if (entry.ended && entry.ended_at_seq === null) {
232
+ updateEntry({
233
+ memory,
234
+ change: { ended_at_seq: memory.next_seq - 1 },
235
+ })
236
+ }
237
+ })
227
238
  }
228
239
 
229
- /** Changes a run's registry entry, and its copy in memory. */
240
+ /**
241
+ * Changes a run's registry entry, and its copy in memory. Setting
242
+ * `ended` also sets `ended_at_seq` to the newest seq applied; clearing it
243
+ * clears that too.
244
+ */
230
245
  const updateEntry = ({
231
246
  memory,
232
247
  change,
233
248
  }: {
234
249
  memory: RunMemory
235
- change: Partial<Pick<RunEntry, 'ended' | 'restarts' | 'token'>>
250
+ change: Partial<
251
+ Pick<RunEntry, 'ended' | 'ended_at_seq' | 'restarts' | 'token'>
252
+ >
236
253
  }) => {
254
+ const full =
255
+ change.ended === undefined
256
+ ? change
257
+ : {
258
+ ...change,
259
+ ended_at_seq: change.ended ? memory.next_seq - 1 : null,
260
+ }
237
261
  memory.entry = registry.update({
238
262
  run_id: memory.entry.run_id,
239
- change,
240
- }) ?? { ...memory.entry, ...change }
263
+ change: full,
264
+ }) ?? { ...memory.entry, ...full }
241
265
  }
242
266
 
243
267
  /** Marks a run's engine as ended (kept in the registry) and updates its header. */
@@ -68,11 +68,19 @@ export type RunMemory = Replay & {
68
68
  append_failures: number
69
69
  rows_stopped: boolean
70
70
  /**
71
- * The rows of records rebuilt from disk, by seq, that the chat may not
72
- * have: the engine may have written them while the plugin was down.
73
- * Added when the engine sends those records again.
71
+ * The rows of records rebuilt from disk at plugin load, by seq, that the
72
+ * chat may not have: the engine may have written them while the plugin
73
+ * was down. Added when the engine sends those records again, or, as an
74
+ * engine that can't push never does, by the disk follow (#701): with the
75
+ * first rows it reads, or on an engine check after its first read.
74
76
  */
75
77
  unsent_rows: Map<number, BoardRow[]>
78
+ /**
79
+ * Its journal's size and change time when the board last read it from
80
+ * disk (#701), `null` before the first read; a change reads it again.
81
+ */
82
+ size: number | null
83
+ changed_ms: number | null
76
84
  }
77
85
 
78
86
  /**
@@ -7,6 +7,8 @@
7
7
  * - `board-server-start.ts` and `board-server-resume.ts`: `run.start` and
8
8
  * `/luca-run resume`.
9
9
  * - `board-server-events.ts`: `engine.event`.
10
+ * - `board-server-follow.ts`: following the plugin's runs on disk, and an
11
+ * ended run that comes back.
10
12
  * - `board-server-read.ts`: `board.read` and `reply.post`, and the runs the
11
13
  * plugin didn't start.
12
14
  * - `board-server-check.ts`: the engine check, which also labels the runs
@@ -15,6 +17,7 @@
15
17
  import { createEngineChecks } from './board-server-check'
16
18
  import { createEngineProcess } from './board-server-engine'
17
19
  import { createEngineEvents } from './board-server-events'
20
+ import { createJournalFollow } from './board-server-follow'
18
21
  import { createBoardReads } from './board-server-read'
19
22
  import { createResumer } from './board-server-resume'
20
23
  import { createStarter } from './board-server-start'
@@ -40,10 +43,11 @@ export type {
40
43
  * checks (`checkEngines`) read processes through `list_processes` and ask
41
44
  * the engine through `run_command`.
42
45
  *
43
- * With a `runs_dir`, every run is rebuilt from its journal there: on start
44
- * the runs this plugin started, and on each `board.read` the runs it didn't
45
- * start (shown read-only in the workspace whose folder is their repo). Without one, a restarted
46
- * plugin knows its runs only from the registry and the engine's next send.
46
+ * With a `runs_dir`, every run is rebuilt from its journal there on start,
47
+ * and followed there on each `board.read` and engine check: the runs this
48
+ * plugin started, and the runs it didn't start (shown read-only in each
49
+ * workspace in their repo). Without one, a restarted plugin knows its runs
50
+ * only from the registry and the engine's next send.
47
51
  *
48
52
  * Each run's `engine.event` work goes through its own queue, so rows never
49
53
  * interleave. A failed row append is logged and never loses board state.
@@ -73,8 +77,9 @@ export const createBoardServer = (config: BoardServerConfig) => {
73
77
  const resumer = createResumer({ config, store, engine })
74
78
  const starter = createStarter({ config, store, engine, resumer })
75
79
  const events = createEngineEvents({ store })
76
- const reads = createBoardReads({ config, store })
77
- const checks = createEngineChecks({ config, store, engine, reads })
80
+ const follow = createJournalFollow({ config, store })
81
+ const reads = createBoardReads({ config, store, follow })
82
+ const checks = createEngineChecks({ config, store, engine, reads, follow })
78
83
 
79
84
  /** Resolves when every queued piece of work (and any check) has finished. */
80
85
  const idle = async () => {
@@ -52,6 +52,10 @@ export const RUN_VOCABULARY = {
52
52
  }),
53
53
  /** No earlier run on the repo is going anymore. */
54
54
  repo_wait_ended: z.looseObject({}),
55
+ /** A step can't reach GitHub; the run waits for it (#701). */
56
+ network_wait_started: z.looseObject({ step: z.string() }),
57
+ /** The step's wait for GitHub is over: it worked, or the run gave up. */
58
+ network_wait_ended: z.looseObject({ step: z.string() }),
55
59
  /** The engine started again on the run's journal, on Luca's `luca_version`. */
56
60
  engine_resumed: z.looseObject({ luca_version: z.string() }),
57
61
  /**
@@ -34,6 +34,8 @@ const BoardEntrySchema = z.discriminatedUnion('kind', [
34
34
  entry({ kind: 'run_started' }),
35
35
  entry({ kind: 'repo_wait_started' }),
36
36
  entry({ kind: 'repo_wait_ended' }),
37
+ entry({ kind: 'network_wait_started' }),
38
+ entry({ kind: 'network_wait_ended' }),
37
39
  entry({ kind: 'engine_resumed' }),
38
40
  entry({ kind: 'config_reloaded' }),
39
41
  entry({ kind: 'config_reload_refused' }),
@@ -376,9 +376,12 @@ export const describeRecord = ({
376
376
  text: `The ${record.content.count} new ${record.content.count === 1 ? 'memory went' : 'memories went'} on the spec issue, since there is no PR.`,
377
377
  tone: 'info',
378
378
  })
379
- // The repo wait shows as a banner on the panel (#632).
379
+ // The repo wait shows as a banner on the panel (#632), the network
380
+ // wait as the run's status (#701).
380
381
  case 'repo_wait_started':
381
382
  case 'repo_wait_ended':
383
+ case 'network_wait_started':
384
+ case 'network_wait_ended':
382
385
  case 'intake_read':
383
386
  case 'ticket_snapshot':
384
387
  case 'baseline_tests':
@@ -134,8 +134,8 @@ export type StopWhy =
134
134
  /**
135
135
  * How the owner resumes run `run_id` with the board attached: the
136
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.
137
+ * `luca-run --resume` runs without the board's token: the board follows it
138
+ * from its journal on disk, but the engine can't push to it.
139
139
  *
140
140
  * @example
141
141
  * resumeHint({ run_id: 'r1' }) // 'type /luca-run resume r1 in a Paseo chat'
@@ -212,6 +212,37 @@ export const restartRow = ({
212
212
  export const RESUMED_TEXT =
213
213
  'The run was resumed from its journal with /luca-run resume.'
214
214
 
215
+ /** The words of the chat row for an ended run that came back (#701). */
216
+ export const RESUMED_CLI_TEXT = 'Resumed from the command line.'
217
+
218
+ /**
219
+ * The chat row for an ended run that came back with a live engine the board
220
+ * didn't start, such as `luca-run --resume` (#701). Its id ends in the
221
+ * newest seq then, so a second comeback gets its own row.
222
+ *
223
+ * @example
224
+ * resumedCliRow({ run_id, seq: 1755, time: new Date().toISOString() })
225
+ * // id `${run_id}-resumed-cli-1755`
226
+ */
227
+ export const resumedCliRow = ({
228
+ run_id,
229
+ seq,
230
+ time,
231
+ }: {
232
+ run_id: string
233
+ seq: number
234
+ time: string
235
+ }): BoardRow => ({
236
+ id: `${run_id}-resumed-cli-${seq}`,
237
+ kind: ROW_KIND.event,
238
+ data: {
239
+ time,
240
+ ticket: null,
241
+ text: RESUMED_CLI_TEXT,
242
+ tone: 'info',
243
+ },
244
+ })
245
+
215
246
  /**
216
247
  * The chat row for a run resumed with `/luca-run resume`; its id is unique
217
248
  * per run and time, so each resume gets its own row.
@@ -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.28"
2
+ export const LUCA_VERSION: string = "14.0.0-alpha.29"
@@ -80,6 +80,7 @@ export const createBoardState = ({
80
80
  limit_wait: null,
81
81
  usage_line_wait: null,
82
82
  repo_wait: null,
83
+ network_waits: [],
83
84
  run_plan_used: [],
84
85
  needs_you: [],
85
86
  tickets: [],