@joshuafolkken/kit 1.1379.0 → 1.1387.0

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 (84) hide show
  1. package/.claude/skills/workflow-commands/backlogrun-progress.md +2 -3
  2. package/.claude/skills/workflow-commands/backlogrun-steps.md +2 -4
  3. package/.claude/skills/workflow-commands/retrospective.md +1 -2
  4. package/dist/hooks/batch-guard.js +6 -6
  5. package/dist/hooks/{chunk-RW4XMF47.js → chunk-2YSW3CXH.js} +133 -234
  6. package/dist/hooks/{chunk-4RWRP3FF.js → chunk-DCY2GWHC.js} +5 -5
  7. package/dist/hooks/{chunk-DHSKBAQ2.js → chunk-EAQEGME7.js} +1 -1
  8. package/dist/hooks/{chunk-E7LZL5AN.js → chunk-EBSHN25W.js} +3 -3
  9. package/dist/hooks/{chunk-HGA4EG5S.js → chunk-GJ7JPIHN.js} +15 -15
  10. package/dist/hooks/{chunk-TG2AB3W6.js → chunk-GNDJ7NJ7.js} +88 -501
  11. package/dist/hooks/{chunk-546X4PX3.js → chunk-IYVOLN6A.js} +3 -3
  12. package/dist/hooks/{chunk-NBEAGPS4.js → chunk-J3KSS7II.js} +1 -1
  13. package/dist/hooks/{chunk-7ZM464OW.js → chunk-LMWBRJIP.js} +3 -3
  14. package/dist/hooks/{chunk-TORRSNDJ.js → chunk-SL4GQ2XP.js} +3 -3
  15. package/dist/hooks/{chunk-PV3FWDSQ.js → chunk-STDSEPWU.js} +2 -2
  16. package/dist/hooks/{chunk-YSDAH73S.js → chunk-U32VSRNG.js} +420 -4
  17. package/dist/hooks/{chunk-QARPP36N.js → chunk-WNFJLBXI.js} +3 -3
  18. package/dist/hooks/codex-hook-adapter.js +13 -13
  19. package/dist/hooks/duplicate-read-guard.js +7 -7
  20. package/dist/hooks/format-edited.js +3 -3
  21. package/dist/hooks/inputs.json +213 -212
  22. package/dist/hooks/investigation-guard.js +7 -7
  23. package/dist/hooks/pretool-guard.js +12 -12
  24. package/dist/hooks/session-lang.js +65 -9
  25. package/dist/hooks/stop-guard.js +104 -32
  26. package/dist/josh.js +2 -2
  27. package/package.json +1 -1
  28. package/prompts/collaboration-workflow/upstream-interrupt.md +1 -1
  29. package/scripts/backlog/backlog-budget-cli.ts +2 -20
  30. package/scripts/backlog/backlog-budget.ts +72 -109
  31. package/scripts/backlog/backlog-drive-cli.ts +12 -7
  32. package/scripts/backlog/backlog-drive-launch.ts +2 -0
  33. package/scripts/backlog/backlog-drive-owner.ts +72 -5
  34. package/scripts/backlog/backlog-drive.ts +5 -2
  35. package/scripts/backlog/backlog-offer-cli.ts +1 -15
  36. package/scripts/backlog/backlogrun-launch.ts +56 -5
  37. package/scripts/document/document-byte-budget.ts +1 -1
  38. package/scripts/epic/epic-candidate-confirm.ts +1 -1
  39. package/scripts/epic/epic-cross-repo.ts +4 -4
  40. package/scripts/epic/epic-next.ts +1 -1
  41. package/scripts/hooks/stop-guard.ts +20 -9
  42. package/scripts/init/start-plan.ts +15 -4
  43. package/scripts/init/start-steps.ts +31 -3
  44. package/scripts/init/start.ts +20 -13
  45. package/scripts/josh/josh-commands-ai.ts +1 -1
  46. package/scripts/josh/josh-commands-project.ts +1 -1
  47. package/scripts/lane/lane-cli.ts +1 -3
  48. package/scripts/run/board/run-board-header.ts +137 -88
  49. package/scripts/run/board/run-board-labels.ts +18 -17
  50. package/scripts/run/board/run-board-machine.ts +21 -8
  51. package/scripts/run/board/run-board-read.ts +9 -2
  52. package/scripts/run/board/run-board-render.ts +1 -5
  53. package/scripts/run/board/run-board-setup.ts +73 -0
  54. package/scripts/run/board/run-board-span.ts +49 -8
  55. package/scripts/run/board/run-board-status.ts +10 -23
  56. package/scripts/run/board/run-board-tick.ts +11 -2
  57. package/scripts/run/carry/run-carry-added.ts +1 -1
  58. package/scripts/run/carry/run-carry-change.ts +1 -1
  59. package/scripts/run/carry/run-carry-cli.ts +36 -60
  60. package/scripts/run/carry/run-carry-ended.ts +10 -2
  61. package/scripts/run/carry/run-carry.ts +38 -68
  62. package/scripts/run/cut/run-cut.ts +4 -4
  63. package/scripts/run/event/run-event-render.ts +1 -1
  64. package/scripts/run/event/run-event-scope.ts +4 -8
  65. package/scripts/run/event/run-event-setup.ts +47 -0
  66. package/scripts/run/event/run-event-stream-emit.ts +0 -25
  67. package/scripts/run/event/run-event-stream.ts +41 -28
  68. package/scripts/run/merge/run-merge-collect.ts +1 -1
  69. package/scripts/run/merge/run-merge-steps.ts +1 -1
  70. package/scripts/run/progress/run-progress-read.ts +1 -1
  71. package/scripts/run/run-headless.ts +2 -2
  72. package/scripts/run/run-status-cli.ts +1 -5
  73. package/scripts/run/run-step.ts +2 -9
  74. package/scripts/run/run-stop-notify.ts +1 -1
  75. package/scripts/run/run-stranded-detect.ts +61 -15
  76. package/scripts/run/run-stranded.ts +25 -17
  77. package/scripts/run/wake/run-wake-cli.ts +58 -15
  78. package/scripts/run/wake/run-wake-describe.ts +5 -5
  79. package/scripts/run/wake/run-wake-driver.ts +13 -4
  80. package/scripts/run/wake/run-wake-handoff.ts +1 -1
  81. package/scripts/run/wake/run-wake-loop.ts +4 -21
  82. package/scripts/run/wake/run-wake-restart.ts +1 -1
  83. package/scripts/run/wake/run-wake.ts +6 -7
  84. package/scripts/backlog/backlog-idle.ts +0 -88
@@ -1,12 +1,17 @@
1
1
  // `josh backlog:budget` — whether a `backlogrun` may start more work, keep watching, or finish.
2
2
  //
3
3
  // `backlog:next` says what may start; it says nothing about when the run itself should end. Without
4
- // this a run would finish the moment the backlog read empty, so an issue a person opted in minutes
4
+ // this a run would finish the moment nothing could proceed, so an issue a person opted in minutes
5
5
  // later would need a whole new session.
6
6
  //
7
7
  // **The idle watch is on by default and the maximum is not.** A `backlogrun` with neither flag given
8
- // watches an empty backlog for `DEFAULT_IDLE_MINUTES` before it finishes, and takes as many issues as
9
- // the backlog holds. `--idle 0` is how the watch is turned off.
8
+ // watches for `IDLE_WAIT_HOURS` once nothing can proceed before it finishes, and takes as many issues
9
+ // as the backlog holds. `--idle 0` is how the watch is turned off.
10
+ //
11
+ // **Every reason nothing can proceed is watched, not only an empty backlog.** Everything opted in
12
+ // waiting on a person, and a listing that could not be read, both change without this run doing
13
+ // anything — a person opts the prerequisite in, GitHub answers again — so ending on either would miss
14
+ // the issue opted in minutes later, which is the one thing the watch exists to catch.
10
15
  //
11
16
  // The decision is a command's rather than the loop's own arithmetic, for the reason `josh delegate`
12
17
  // and `josh review:level` are commands: a count and an elapsed time kept in an agent's head are a
@@ -14,29 +19,23 @@
14
19
 
15
20
  const MS_PER_MINUTE = 60_000
16
21
  const MINUTES_PER_HOUR = 60
17
- // The whole-run bound, decided here like the idle watch and the maximum rather than by an agent reading
18
- // a clock.
19
- const WHOLE_RUN_BUDGET_HOURS = 8
20
- const WHOLE_RUN_BUDGET_MINUTES = WHOLE_RUN_BUDGET_HOURS * MINUTES_PER_HOUR
21
- const WHOLE_RUN_BUDGET_MS = WHOLE_RUN_BUDGET_MINUTES * MS_PER_MINUTE
22
22
 
23
- // **The default idle watch, and why it is this number.**
24
- //
25
- // - **Below it the watch is a coin flip.** What it waits for is a person noticing the run has gone
26
- // quiet, filing an issue and applying `auto-ok`. Ten minutes does not reliably outlast that.
27
- // - **Above it the run pays for nothing.** At `IDLE_POLL_MINUTES` a 30-minute watch is six asks —
28
- // about one child's worth of turns, spent while the run holds no working tree and no lane.
29
- // - **It is about the length of one child** — 12 to 28 minutes measured — so a run that has emptied its backlog waits roughly as long as one more issue would have taken.
30
- const DEFAULT_IDLE_MINUTES = 30
23
+ // **How long a run with nothing to do waits, and the only time bound a run has.** It is counted from
24
+ // when the run last had work, never from its start: a run still working, or still inside this wait,
25
+ // is not ended by its age. Eight hours is one unattended night — a person who opts an issue in before
26
+ // they sleep, or after they wake, finds the run still there to pick it up.
27
+ const IDLE_WAIT_HOURS = 8
28
+ const DEFAULT_IDLE_MINUTES = IDLE_WAIT_HOURS * MINUTES_PER_HOUR
31
29
  const DEFAULT_IDLE_MS = DEFAULT_IDLE_MINUTES * MS_PER_MINUTE
32
30
 
33
- // **What a watching run sleeps between asks, and it is not the loop's 60-second polling interval.**
34
- // That one is sized to a child's `fullrun`, which finishes in minutes; a watch is waiting on a person
35
- // to file an issue and opt it in, which happens on human timescales. Polling a watch every minute
36
- // would spend thirty of the parent session's own requests — each one billing the whole session
37
- // history — to learn nothing thirty times. `backlogrun-progress.md` → "Waiting, and never waiting forever" holds
31
+ // **The one interval between two asks for work, whether lanes are running or nothing is.** The driver
32
+ // and the wake supervisor both read it from here. The loop is a process, so an ask spends no AI turn,
33
+ // and a watch is polled as often as a running batch; a child collected in between is asked about at
34
+ // once, without waiting it out. `backlogrun-progress.md` → "Waiting, and never waiting forever" holds
38
35
  // the row; the figure is here because `idle_watch_reason` below has to say it.
39
- const IDLE_POLL_MINUTES = 5
36
+ const OFFER_INTERVAL_SECONDS = 60
37
+ const MS_PER_SECOND = 1000
38
+ const OFFER_INTERVAL_MS = OFFER_INTERVAL_SECONDS * MS_PER_SECOND
40
39
 
41
40
  const RUN_VERDICT = 'run'
42
41
  const WATCH_VERDICT = 'watch'
@@ -69,7 +68,7 @@ interface BudgetInput {
69
68
  // otherwise be measured against a maximum that cannot see it.
70
69
  running: number
71
70
  started_at_ms: number
72
- // When this run last had work — the most recent ask that was not `exhausted`, or the run's start.
71
+ // When this run last had work — its most recent launch or collected child, or the run's start.
73
72
  // Refreshing it is what restarts the idle watch when a candidate appears during one.
74
73
  active_at_ms: number
75
74
  now_ms: number
@@ -87,22 +86,28 @@ interface BudgetDecision {
87
86
  reason: string
88
87
  }
89
88
 
90
- const WHOLE_RUN_REASON = `The whole-run bound of ${String(WHOLE_RUN_BUDGET_HOURS)} hours has passed. An unattended run that has not finished overnight needs a person, not more waiting.`
91
-
92
- const PARKED_REASON =
93
- 'Nothing opted in can proceed without a person. Report the parked issues and finish.'
94
-
95
- const UNREADABLE_REASON =
96
- 'The backlog listing could not be read, so nothing was offered. Report what the command printed and finish — it is not an empty backlog.'
97
-
98
89
  const BLOCKED_REASON =
99
90
  'Everything opted in is blocked or already running, so waiting can still change the answer. Poll again at the polling interval.'
100
91
 
101
92
  const TRIAGE_REASON =
102
93
  'An opted-in candidate carries neither `run:solo` nor `run:lane`, so nothing starts until it is judged. Read each issue named above, record its order and one of the two labels with a comment, then ask again.'
103
94
 
104
- const NO_IDLE_WATCH_REASON =
105
- 'The backlog is empty and the idle watch was turned off with `--idle 0`, so the run finishes here.'
95
+ // What the run is waiting on, by the answer that opened the watch — the opening of every sentence a
96
+ // watch or its ending prints, so a person reads which of the three it was.
97
+ const EMPTY_BACKLOG = 'The backlog is empty'
98
+ const WAITING_ON: Readonly<Partial<Record<BacklogAnswer, string>>> = {
99
+ exhausted: EMPTY_BACKLOG,
100
+ parked: 'Nothing opted in can proceed without a person',
101
+ unreadable: 'The backlog listing could not be read',
102
+ }
103
+
104
+ function waiting_on(answer: BacklogAnswer): string {
105
+ return WAITING_ON[answer] ?? EMPTY_BACKLOG
106
+ }
107
+
108
+ function no_idle_watch_reason(answer: BacklogAnswer): string {
109
+ return `${waiting_on(answer)} and the idle watch was turned off with \`--idle 0\`, so the run finishes here.`
110
+ }
106
111
 
107
112
  function to_minutes(duration_ms: number): string {
108
113
  return String(Math.ceil(duration_ms / MS_PER_MINUTE))
@@ -119,39 +124,29 @@ function draining_reason(running: number, reason: string): string {
119
124
  return `Start nothing more and poll until the running children merge (running: ${String(running)}). Then report and finish — ${reason}`
120
125
  }
121
126
 
122
- function idle_expired_reason(idle_budget_ms: number): string {
123
- return `The backlog stayed empty for the whole ${to_minutes(idle_budget_ms)}-minute idle watch. Report what was done, including how many were picked up during the watch, and finish.`
124
- }
125
-
126
- // Never longer than the watch has left: "about 2 minutes left, ask again in 5 minutes" would overrun
127
- // the budget a person declared, and a sentence that contradicts itself is read as neither half.
128
- function next_ask_minutes(left_ms: number): string {
129
- const left_minutes = Number(to_minutes(left_ms))
130
-
131
- return String(Math.min(IDLE_POLL_MINUTES, left_minutes))
127
+ function idle_expired_reason(answer: BacklogAnswer, idle_budget_ms: number): string {
128
+ return `${waiting_on(answer)}, and stayed so for the whole ${to_minutes(idle_budget_ms)}-minute idle watch. Report what was done, including how many were picked up during the watch, and finish.`
132
129
  }
133
130
 
134
- function idle_watch_reason(left_ms: number): string {
135
- return `The backlog is empty and the idle watch has about ${to_minutes(left_ms)} minutes left. Release the working tree, ask again in ${next_ask_minutes(left_ms)} minutes, and restart the watch the moment a candidate appears.`
131
+ function idle_watch_reason(answer: BacklogAnswer, left_ms: number): string {
132
+ return `${waiting_on(answer)} and the idle watch has about ${to_minutes(left_ms)} minutes left. Release the working tree, ask again in ${String(OFFER_INTERVAL_SECONDS)} seconds, and restart the watch the moment a candidate appears.`
136
133
  }
137
134
 
138
135
  // **A watch that opens while children are still in lanes is not the idle watch's ordinary shape**: the
139
- // backlog can answer `exhausted` while this run's own children are still merging. Two things the
140
- // sentence above would get wrong there — the working tree is still held, because the hold is released
141
- // at the *last* child's merge, and a five-minute poll would leave a merge unnoticed for five minutes.
136
+ // backlog can offer nothing while this run's own children are still merging. The sentence above
137
+ // would get one thing wrong there — the working tree is still held, because the hold is released at
138
+ // the *last* child's merge.
142
139
  //
143
- // **The watch runs down while they drain, and the sentence does not promise otherwise.** Pausing it
144
- // would mean moving `active_at` on an ask that answered `exhausted`, which is the one thing that
145
- // moment is defined not to do — so a drain longer than the budget ends the run at the drain. That is
146
- // the honest ending: the backlog was empty at every ask, which is exactly what the watch measures.
147
- function running_watch_reason(running: number, left_ms: number): string {
148
- return `The backlog is empty and the idle watch has about ${to_minutes(left_ms)} minutes left, with ${String(running)} still running. Keep the working tree until they merge and poll at the polling interval, not the idle poll — the watch is running down meanwhile.`
140
+ // **The watch runs down while they drain, and restarts at each one collected.** A collected child is
141
+ // work, so it moves `active_at`; an ask that offered nothing never does.
142
+ function running_watch_reason(answer: BacklogAnswer, running: number, left_ms: number): string {
143
+ return `${waiting_on(answer)} and the idle watch has about ${to_minutes(left_ms)} minutes left, with ${String(running)} still running. Keep the working tree until they merge and poll at the polling interval — the watch is running down meanwhile.`
149
144
  }
150
145
 
151
146
  function watching_reason(input: BudgetInput, left_ms: number): string {
152
- if (input.running === 0) return idle_watch_reason(left_ms)
147
+ if (input.running === 0) return idle_watch_reason(input.answer, left_ms)
153
148
 
154
- return running_watch_reason(input.running, left_ms)
149
+ return running_watch_reason(input.answer, input.running, left_ms)
155
150
  }
156
151
 
157
152
  // What the maximum has already taken: merged issues and the children still running. Counting only
@@ -169,32 +164,10 @@ function run_reason(input: BudgetInput): string {
169
164
  return `Start at most ${String(max_issues - taken_by(input))} more: this run's maximum is ${String(max_issues)}, with ${String(input.merged)} merged and ${String(input.running)} running.`
170
165
  }
171
166
 
172
- // The two answers that end a run whatever the budgets say. Neither is a budget decision — the graph
173
- // said so — but both reach this command, because the loop asks one question rather than branching on
174
- // `backlog:next`'s word itself and then asking a second.
175
- const ANSWER_STOP_REASONS: Readonly<Partial<Record<BacklogAnswer, string>>> = {
176
- parked: PARKED_REASON,
177
- unreadable: UNREADABLE_REASON,
178
- }
179
-
180
- function whole_run_reason(input: BudgetInput): string | undefined {
181
- if (input.now_ms - input.started_at_ms < WHOLE_RUN_BUDGET_MS) return undefined
182
-
183
- return WHOLE_RUN_REASON
184
- }
185
-
186
- // The answer-level stops come first, ahead of every budget. Both of them say something the person
187
- // has to act on — issues are parked, or the listing could not be read — and the reason printed here
188
- // is quoted verbatim into the completion report, so a budget reason in front of one would report a
189
- // tidy ending for a run whose backlog actually broke.
190
- function stop_reason(input: BudgetInput): string | undefined {
191
- return ANSWER_STOP_REASONS[input.answer] ?? whole_run_reason(input)
192
- }
193
-
194
167
  // Every ending goes through here, so no ending abandons a lane. Children already started have to
195
- // merge and be reported whatever ended the run — the maximum, a parked backlog, a listing that could
196
- // not be read, or the whole-run bound — so a `stop` while any of them is still running would leave
197
- // open lanes behind and merged work unmentioned.
168
+ // merge and be reported whatever ended the run — the maximum, or a watch that ran out or was turned
169
+ // off — so a `stop` while any of them is still running would leave open lanes behind and merged work
170
+ // unmentioned.
198
171
  function stopping(input: BudgetInput, reason: string): BudgetDecision {
199
172
  if (input.running === 0) return { verdict: STOP_VERDICT, reason }
200
173
 
@@ -212,30 +185,31 @@ function max_decision(input: BudgetInput): BudgetDecision | undefined {
212
185
  return stopping(input, max_reached_reason(input.merged, max_issues))
213
186
  }
214
187
 
215
- // An empty backlog is watched rather than acted on, which is now the default — and the watch lives
216
- // *inside* the whole-run bound, which `stop_reason` has already applied above. Only `--idle 0` takes
217
- // the other branch, where an empty backlog is the run's ending.
188
+ // Nothing can proceed, so the run watches rather than ends — whichever of the three answers said so.
189
+ // The watch is the run's only time bound: it is counted from `active_at_ms`, so a run is never ended
190
+ // by how long ago it started. Only `--idle 0` takes the other branch, where the first such answer is
191
+ // the run's ending.
218
192
  function idle_decision(input: BudgetInput): BudgetDecision {
219
193
  const { idle_budget_ms } = input
220
194
 
221
- if (idle_budget_ms === undefined) return stopping(input, NO_IDLE_WATCH_REASON)
195
+ if (idle_budget_ms === undefined) return stopping(input, no_idle_watch_reason(input.answer))
222
196
 
223
197
  const left_ms = idle_budget_ms - (input.now_ms - input.active_at_ms)
224
198
 
225
- if (left_ms <= 0) return stopping(input, idle_expired_reason(idle_budget_ms))
199
+ if (left_ms > 0) return { verdict: WATCH_VERDICT, reason: watching_reason(input, left_ms) }
226
200
 
227
- return { verdict: WATCH_VERDICT, reason: watching_reason(input, left_ms) }
201
+ return stopping(input, idle_expired_reason(input.answer, idle_budget_ms))
228
202
  }
229
203
 
230
- // Whether a `stop` for this input is the run finishing — the idle watch ending on an empty backlog —
231
- // rather than stopping on something a person must act on: a parked backlog, an unreadable listing, the
232
- // maximum or the whole-run bound. The two end the carry record differently (`--end` against
233
- // `--end --stopped`), and `backlog:drive` reads which one from here.
204
+ // Whether a `stop` for this input is the run finishing — the watch ending on an empty backlog —
205
+ // rather than stopping on something a person must act on: the maximum, issues still waiting on a
206
+ // person, or a listing that never became readable. The two end the carry record differently (`--end`
207
+ // against `--end --stopped`), and `backlog:drive` reads which one from here.
234
208
  function is_finish(input: BudgetInput): boolean {
235
- return stop_reason(input) === undefined && max_decision(input) === undefined
209
+ return input.answer === 'exhausted' && max_decision(input) === undefined
236
210
  }
237
211
 
238
- // The answers whose decision no count changes, once the bounds above have been applied.
212
+ // The answers whose decision no count changes, once the maximum above has been applied.
239
213
  const FIXED_DECISIONS: Readonly<Partial<Record<BacklogAnswer, BudgetDecision>>> = {
240
214
  blocked: { verdict: WATCH_VERDICT, reason: BLOCKED_REASON },
241
215
  untriaged: { verdict: TRIAGE_VERDICT, reason: TRIAGE_REASON },
@@ -248,15 +222,7 @@ function answer_decision(input: BudgetInput): BudgetDecision {
248
222
  }
249
223
 
250
224
  function decide(input: BudgetInput): BudgetDecision {
251
- const reason = stop_reason(input)
252
-
253
- if (reason !== undefined) return stopping(input, reason)
254
-
255
- const capped = max_decision(input)
256
-
257
- if (capped !== undefined) return capped
258
-
259
- return answer_decision(input)
225
+ return max_decision(input) ?? answer_decision(input)
260
226
  }
261
227
 
262
228
  const backlog_budget = {
@@ -264,25 +230,22 @@ const backlog_budget = {
264
230
  BLOCKED_REASON,
265
231
  DEFAULT_IDLE_MINUTES,
266
232
  DEFAULT_IDLE_MS,
267
- IDLE_POLL_MINUTES,
233
+ IDLE_WAIT_HOURS,
268
234
  MS_PER_MINUTE,
269
- NO_IDLE_WATCH_REASON,
270
- PARKED_REASON,
235
+ OFFER_INTERVAL_MS,
271
236
  RUN_VERDICT,
272
237
  STOP_VERDICT,
273
238
  TRIAGE_REASON,
274
239
  TRIAGE_VERDICT,
275
- UNREADABLE_REASON,
240
+ WAITING_ON,
276
241
  WATCH_VERDICT,
277
- WHOLE_RUN_BUDGET_HOURS,
278
- WHOLE_RUN_BUDGET_MS,
279
- WHOLE_RUN_REASON,
280
242
  decide,
281
243
  draining_reason,
282
244
  idle_expired_reason,
283
245
  idle_watch_reason,
284
246
  is_finish,
285
247
  max_reached_reason,
248
+ no_idle_watch_reason,
286
249
  run_reason,
287
250
  running_watch_reason,
288
251
  taken_by,
@@ -13,10 +13,12 @@ import { run_carry, type RunCarry } from '#scripts/run/carry/run-carry'
13
13
  import { run_event_stream_emit } from '#scripts/run/event/run-event-stream-emit'
14
14
  import { run_merge_cli, type MergeResult } from '#scripts/run/merge/run-merge-cli'
15
15
  import { z } from 'zod'
16
+ import { backlog_budget } from './backlog-budget'
16
17
  import {
17
18
  backlog_drive,
18
19
  type DriveEnd,
19
20
  type DriveState,
21
+ type LoopConfig,
20
22
  type LoopPorts,
21
23
  type OfferRead,
22
24
  } from './backlog-drive'
@@ -46,7 +48,6 @@ const SUCCESS_EXIT_CODE = 0
46
48
  const FAILURE_EXIT_CODE = 1
47
49
  const should_forward_stderr = true
48
50
  const POLL_MS = 5000
49
- const OFFER_MS = 60_000
50
51
  const FIRST_LINE = 0
51
52
 
52
53
  // The `--json` object `backlog:offer` prints, keyed under its `JSON_KEY`.
@@ -71,17 +72,14 @@ async function read_record(): Promise<RunCarry | undefined> {
71
72
 
72
73
  const read = run_carry.read_carry(run_carry.carry_path(target))
73
74
 
74
- return read.kind === 'carried' || read.kind === 'expired' ? read.carry : undefined
75
+ return read.kind === 'carried' ? read.carry : undefined
75
76
  }
76
77
 
77
78
  async function read_offer(
78
79
  state: DriveState,
79
80
  context: DriveContext,
80
81
  ): Promise<OfferRead | undefined> {
81
- await backlog_drive_owner.assert_current(context.owner)
82
- const carry = await read_record()
83
-
84
- if (carry === undefined) return undefined
82
+ const carry = await backlog_drive_owner.assert_current(context.owner)
85
83
  const named = await backlog_drive_named_offer.read(carry, state, context)
86
84
  if (named !== undefined) return named
87
85
 
@@ -204,6 +202,11 @@ async function seeded_lanes(context: DriveContext): Promise<ReadonlyArray<string
204
202
  return backlog_drive_restore.restore(lanes, events, carry.merged_issues ?? [])
205
203
  }
206
204
 
205
+ // The interval between two asks is `backlog:budget`'s own, so the driver keeps the one its reason prints.
206
+ function loop_config(window_ms: number | undefined): LoopConfig {
207
+ return { poll_ms: POLL_MS, offer_ms: backlog_budget.OFFER_INTERVAL_MS, window_ms }
208
+ }
209
+
207
210
  async function drive(context: DriveContext): Promise<number> {
208
211
  const seeded = await seeded_lanes(context)
209
212
 
@@ -212,7 +215,7 @@ async function drive(context: DriveContext): Promise<number> {
212
215
  const initial =
213
216
  context.stopped === undefined ? fresh : backlog_drive.merge_stopped(context.stopped, fresh)
214
217
  const last = { state: { ...initial, exclude: [...context.exclude] } }
215
- const config = { poll_ms: POLL_MS, offer_ms: OFFER_MS, window_ms: context.window_ms }
218
+ const config = loop_config(context.window_ms)
216
219
  const end = await backlog_drive.run_loop(last.state, config, ports_of(context, seeded, last))
217
220
 
218
221
  console.info(end_line(end))
@@ -258,10 +261,12 @@ async function main(argv: ReadonlyArray<string>): Promise<void> {
258
261
  const backlog_drive_cli = {
259
262
  end_line,
260
263
  finish: backlog_drive_finish.finish,
264
+ loop_config,
261
265
  merge,
262
266
  merge_token,
263
267
  offer_argv: backlog_drive_offer_argv.offer_argv,
264
268
  parse: backlog_drive_args.parse,
269
+ read_offer,
265
270
  resume_line: backlog_drive_args.resume_line,
266
271
  run,
267
272
  to_offer,
@@ -1,5 +1,6 @@
1
1
  import { git_stash } from '#scripts/git/stash/git-stash'
2
2
  import { lane_launch_cli, type LaunchOutcome } from '#scripts/lane/lane-launch-cli'
3
+ import { run_event_setup } from '#scripts/run/event/run-event-setup'
3
4
  import { backlog_drive_owner } from './backlog-drive-owner'
4
5
 
5
6
  // The driver's lane launch. The parent that ran `josh latest` in the primary
@@ -28,6 +29,7 @@ async function latest_stash(): Promise<string | undefined> {
28
29
 
29
30
  async function launch(issue: string, owner: string): Promise<LaunchOutcome['kind']> {
30
31
  await backlog_drive_owner.assert_current(owner)
32
+ await run_event_setup.emit(run_event_setup.opening_lane(issue))
31
33
 
32
34
  const outcome = await lane_launch_cli.launch_lane({ issue, stash: await latest_stash() })
33
35
 
@@ -1,4 +1,18 @@
1
- import { run_carry, type RunCarry } from '#scripts/run/carry/run-carry'
1
+ import { existsSync } from 'node:fs'
2
+ import { poll } from '#scripts/lib/poll'
3
+ import { run_carry, type CarryRead, type RunCarry } from '#scripts/run/carry/run-carry'
4
+ import { run_carry_ended } from '#scripts/run/carry/run-carry-ended'
5
+ import { backlog_budget } from './backlog-budget'
6
+
7
+ // **A record that does not read is asked again before the driver gives up on it.** The driver is the
8
+ // only thing looking for work during the idle watch, so ending on one failed read throws away whatever
9
+ // is left of the watch. The read is repeated at the offer's own interval, and
10
+ // the count bounds it: a record that stays unreadable still ends the driver, with the reason said.
11
+ const READ_ATTEMPTS = 6
12
+ const ENDED_MESSAGE = 'The carry record is no longer active.'
13
+
14
+ // The run this driver last read, so a record that is gone can be told from one `run:carry --end` ended.
15
+ const seen: { started_at?: string } = {}
2
16
 
3
17
  function owns_record(carry: RunCarry, owner: string): boolean {
4
18
  const current = run_carry.owner_of(Number(owner))
@@ -7,16 +21,69 @@ function owns_record(carry: RunCarry, owner: string): boolean {
7
21
  return has_owner && run_carry.is_owner_live(carry) && !carry.is_handed_off
8
22
  }
9
23
 
10
- async function assert_current(owner: string): Promise<void> {
24
+ // `--end` records the run it removes, so an ended run answers at once rather than after the re-reads.
25
+ function is_ended(directory: string): boolean {
26
+ return run_carry_ended.has_ended(run_carry_ended.ended_path(directory), seen.started_at)
27
+ }
28
+
29
+ // Whether the file is there is said beside the read, because `none` is the answer both to a record
30
+ // that is gone and to one that is present and could not be opened.
31
+ function unread_note(read: CarryRead, directory: string): string {
32
+ const presence = existsSync(run_carry.carry_path(directory)) ? 'present' : 'absent'
33
+
34
+ return `The carry record read \`${read.kind}\` with its file ${presence}`
35
+ }
36
+
37
+ function is_settled(read: CarryRead, directory: string): boolean {
38
+ if (read.kind === 'carried' || is_ended(directory)) return true
39
+
40
+ console.error(`${unread_note(read, directory)}; reading it again.`)
41
+
42
+ return false
43
+ }
44
+
45
+ async function read_record(directory: string): Promise<CarryRead> {
46
+ const target = run_carry.carry_path(directory)
47
+ let read: CarryRead = { kind: 'none' }
48
+
49
+ await poll.poll_until(
50
+ async () => {
51
+ read = run_carry.read_carry(target)
52
+
53
+ return is_settled(read, directory)
54
+ },
55
+ {
56
+ attempts: READ_ATTEMPTS,
57
+ interval_ms: backlog_budget.OFFER_INTERVAL_MS,
58
+ sleeper: async (duration_ms) => {
59
+ await poll.sleep(duration_ms)
60
+ },
61
+ },
62
+ )
63
+
64
+ return read
65
+ }
66
+
67
+ function unread_message(read: CarryRead, directory: string): string {
68
+ if (is_ended(directory)) return ENDED_MESSAGE
69
+
70
+ return `${unread_note(read, directory)}, ${String(READ_ATTEMPTS)} times in a row.`
71
+ }
72
+
73
+ async function assert_current(owner: string): Promise<RunCarry> {
11
74
  const directory = await run_carry.repository_directory()
12
75
  if (directory === undefined) throw new Error('The carry record cannot be located.')
13
76
 
14
- const read = run_carry.read_carry(run_carry.carry_path(directory))
15
- if (read.kind !== 'carried') throw new Error('The carry record is no longer active.')
77
+ const read = await read_record(directory)
78
+ if (read.kind !== 'carried') throw new Error(unread_message(read, directory))
16
79
 
17
80
  if (!owns_record(read.carry, owner)) {
18
81
  throw new Error('The driver no longer owns the carry record.')
19
82
  }
83
+
84
+ seen.started_at = read.carry.started_at
85
+
86
+ return read.carry
20
87
  }
21
88
 
22
- export const backlog_drive_owner = { assert_current }
89
+ export const backlog_drive_owner = { READ_ATTEMPTS, assert_current }
@@ -140,9 +140,12 @@ function ended(reason: string, state: DriveState, token?: string, issue?: string
140
140
  }
141
141
  }
142
142
 
143
+ // Only an empty backlog drains: `backlog:offer` marks the drain on `exhausted` alone, so a watch over a
144
+ // parked or unreadable backlog has no retrospective for `run:step` to fire and stays the driver's.
143
145
  function is_drain(read: OfferRead, state: DriveState): boolean {
144
146
  return (
145
147
  read.verdict === WATCH_VERDICT &&
148
+ read.answer === 'exhausted' &&
146
149
  state.in_flight.length === 0 &&
147
150
  read.is_retrospective_owed === true
148
151
  )
@@ -352,8 +355,8 @@ async function run_pass(
352
355
 
353
356
  interface LoopConfig {
354
357
  poll_ms: number
355
- // The floor between two `backlog:offer` asks while nothing was collected — the same one-minute
356
- // cadence the wake supervisor polls the backlog on, so an idle stretch is not a network hammer.
358
+ // The floor between two `backlog:offer` asks while nothing was collected, running or idle alike —
359
+ // `backlog_budget.OFFER_INTERVAL_MS`, the interval the wake supervisor polls on too.
357
360
  offer_ms: number
358
361
  // How long one invocation may wait before returning `window`, or `undefined` for no bound. A headless
359
362
  // parent's foreground call is capped by its shell's timeout, so it passes one under that cap.
@@ -125,27 +125,19 @@ const budget_read_schema = z.object({
125
125
  [BUDGET_JSON_KEY]: z.string(),
126
126
  reason: z.string(),
127
127
  is_finish: z.boolean(),
128
- idle: z.string().optional(),
129
128
  })
130
129
 
131
130
  interface BudgetRead {
132
131
  verdict: string
133
132
  reason: string
134
133
  is_finish: boolean
135
- // The idle watch's window, as `backlog_idle.text_of` wrote it, when the budget opened one.
136
- idle?: string | undefined
137
134
  }
138
135
 
139
136
  function budget_of(out: string): BudgetRead | undefined {
140
137
  const read = json_value.parse_with(out, budget_read_schema)
141
138
  if (read === undefined) return undefined
142
139
 
143
- return {
144
- verdict: read[BUDGET_JSON_KEY],
145
- reason: read.reason,
146
- is_finish: read.is_finish,
147
- idle: read.idle,
148
- }
140
+ return { verdict: read[BUDGET_JSON_KEY], reason: read.reason, is_finish: read.is_finish }
149
141
  }
150
142
 
151
143
  function to_tokens(out: string): ReadonlyArray<string> {
@@ -225,12 +217,6 @@ async function decide(values: ParsedValues, counts: OfferCounts): Promise<number
225
217
 
226
218
  await mark_drain(decision.verdict, offer.answer, counts.running)
227
219
 
228
- // The idle window with the ask that read it, once per watching poll, so `run:board` counts the next
229
- // check from the poll that ran rather than guessing one.
230
- if (decision.idle !== undefined) {
231
- await run_event_stream_emit.emit_changed(run_event_stream.EVENT_KIND.IDLE, decision.idle)
232
- }
233
-
234
220
  return emit(decision, offer, values.json === true)
235
221
  }
236
222
 
@@ -11,6 +11,7 @@ import { stamp_file } from '#scripts/josh/stamp-file'
11
11
  import { json_value } from '#scripts/lib/json-value'
12
12
  import { run_carry, type CarryRead } from '#scripts/run/carry/run-carry'
13
13
  import { detached_launch, type LaunchResult } from '#scripts/run/detached-launch'
14
+ import { run_event_setup } from '#scripts/run/event/run-event-setup'
14
15
  import { run_headless } from '#scripts/run/run-headless'
15
16
  import { z } from 'zod'
16
17
 
@@ -34,10 +35,20 @@ const launch_schema = z.object({
34
35
  pid: z.number(),
35
36
  start: z.string().optional(),
36
37
  session: z.string().optional(),
38
+ // What the parent was started to run and when, so `run:board` has a run to draw and a clock to count
39
+ // before the parent claims the carry record. Absent on a record an earlier version wrote.
40
+ invocation: z.string().optional(),
41
+ started_at: z.string().optional(),
37
42
  })
38
43
 
39
44
  type LaunchRecord = z.infer<typeof launch_schema>
40
45
 
46
+ // A launched run the parent has not claimed yet, as far as its record says.
47
+ interface PendingLaunch {
48
+ invocation: string
49
+ started_at: string
50
+ }
51
+
41
52
  interface LaunchTarget {
42
53
  // The checkout the parent runs in, and its common git directory the records are keyed on.
43
54
  worktree: string
@@ -117,10 +128,48 @@ function note_to_stderr(note: string): void {
117
128
  console.error(`backlogrun: ${note}`)
118
129
  }
119
130
 
120
- function record_launch(git_directory: string, pid: number, session: string | undefined): void {
121
- const started = process_identity.read_start(pid)
131
+ // **The record and the run's first stage carry one moment**, so the `starting session` event is never
132
+ // dated before the start the board measures from.
133
+ function record_launch(
134
+ git_directory: string,
135
+ launch: Pick<LaunchRecord, 'pid' | 'session' | 'invocation'>,
136
+ ): void {
137
+ const now = new Date()
138
+ const identity = process_identity.read_start(launch.pid)
139
+ const record: LaunchRecord = { ...launch, start: identity, started_at: now.toISOString() }
140
+
141
+ stamp_file.replace_stamp(launch_path(git_directory), record)
142
+ run_event_setup.emit_in(git_directory, run_event_setup.STAGE.STARTING, now)
143
+ }
144
+
145
+ // The live launch record here, `undefined` once the parent it names is gone.
146
+ function live_launch(git_directory: string): LaunchRecord | undefined {
147
+ const record = read_launch(launch_path(git_directory))
148
+
149
+ return is_launch_live(record) ? record : undefined
150
+ }
151
+
152
+ // The launch `run:board` draws while no carry record stands: one whose record says what it runs and
153
+ // since when.
154
+ function pending_launch(git_directory: string): PendingLaunch | undefined {
155
+ const { invocation, started_at } = live_launch(git_directory) ?? {}
156
+
157
+ if (invocation === undefined || started_at === undefined) return undefined
158
+
159
+ return { invocation, started_at }
160
+ }
161
+
162
+ // When the launch that started `owner_pid` began, for `run:carry --begin` to start the run's clock
163
+ // from: **only the parent this launch started inherits it**, so a run typed inside a session — which has
164
+ // no launch record, or another parent's — still starts at its own `--begin`.
165
+ function started_for(git_directory: string, owner_pid: number | undefined): Date | undefined {
166
+ const record = live_launch(git_directory)
167
+
168
+ if (record?.started_at === undefined || record.pid !== owner_pid) return undefined
169
+
170
+ const started_ms = Date.parse(record.started_at)
122
171
 
123
- stamp_file.replace_stamp(launch_path(git_directory), { pid, start: started, session })
172
+ return Number.isNaN(started_ms) ? undefined : new Date(started_ms)
124
173
  }
125
174
 
126
175
  // The headless mark holds the parent's turn open while its lanes run, as `run:wake`'s does. The launch
@@ -160,7 +209,7 @@ function start(invocation: string, provider: AgentProvider, target: LaunchTarget
160
209
 
161
210
  if (result.kind === 'failed') return result
162
211
 
163
- record_launch(target.git_directory, result.pid, session)
212
+ record_launch(target.git_directory, { pid: result.pid, session, invocation })
164
213
 
165
214
  const launched = { pid: result.pid, session, log_path: log_path(target.git_directory) }
166
215
 
@@ -171,8 +220,10 @@ const backlogrun_launch = {
171
220
  argv_of,
172
221
  chosen_environment,
173
222
  is_running,
223
+ pending_launch,
174
224
  start,
225
+ started_for,
175
226
  }
176
227
 
177
- export type { Launched, LaunchTarget, StartResult }
228
+ export type { Launched, LaunchTarget, PendingLaunch, StartResult }
178
229
  export { backlogrun_launch }