@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.
- package/.claude/skills/workflow-commands/backlogrun-progress.md +2 -3
- package/.claude/skills/workflow-commands/backlogrun-steps.md +2 -4
- package/.claude/skills/workflow-commands/retrospective.md +1 -2
- package/dist/hooks/batch-guard.js +6 -6
- package/dist/hooks/{chunk-RW4XMF47.js → chunk-2YSW3CXH.js} +133 -234
- package/dist/hooks/{chunk-4RWRP3FF.js → chunk-DCY2GWHC.js} +5 -5
- package/dist/hooks/{chunk-DHSKBAQ2.js → chunk-EAQEGME7.js} +1 -1
- package/dist/hooks/{chunk-E7LZL5AN.js → chunk-EBSHN25W.js} +3 -3
- package/dist/hooks/{chunk-HGA4EG5S.js → chunk-GJ7JPIHN.js} +15 -15
- package/dist/hooks/{chunk-TG2AB3W6.js → chunk-GNDJ7NJ7.js} +88 -501
- package/dist/hooks/{chunk-546X4PX3.js → chunk-IYVOLN6A.js} +3 -3
- package/dist/hooks/{chunk-NBEAGPS4.js → chunk-J3KSS7II.js} +1 -1
- package/dist/hooks/{chunk-7ZM464OW.js → chunk-LMWBRJIP.js} +3 -3
- package/dist/hooks/{chunk-TORRSNDJ.js → chunk-SL4GQ2XP.js} +3 -3
- package/dist/hooks/{chunk-PV3FWDSQ.js → chunk-STDSEPWU.js} +2 -2
- package/dist/hooks/{chunk-YSDAH73S.js → chunk-U32VSRNG.js} +420 -4
- package/dist/hooks/{chunk-QARPP36N.js → chunk-WNFJLBXI.js} +3 -3
- package/dist/hooks/codex-hook-adapter.js +13 -13
- package/dist/hooks/duplicate-read-guard.js +7 -7
- package/dist/hooks/format-edited.js +3 -3
- package/dist/hooks/inputs.json +213 -212
- package/dist/hooks/investigation-guard.js +7 -7
- package/dist/hooks/pretool-guard.js +12 -12
- package/dist/hooks/session-lang.js +65 -9
- package/dist/hooks/stop-guard.js +104 -32
- package/dist/josh.js +2 -2
- package/package.json +1 -1
- package/prompts/collaboration-workflow/upstream-interrupt.md +1 -1
- package/scripts/backlog/backlog-budget-cli.ts +2 -20
- package/scripts/backlog/backlog-budget.ts +72 -109
- package/scripts/backlog/backlog-drive-cli.ts +12 -7
- package/scripts/backlog/backlog-drive-launch.ts +2 -0
- package/scripts/backlog/backlog-drive-owner.ts +72 -5
- package/scripts/backlog/backlog-drive.ts +5 -2
- package/scripts/backlog/backlog-offer-cli.ts +1 -15
- package/scripts/backlog/backlogrun-launch.ts +56 -5
- package/scripts/document/document-byte-budget.ts +1 -1
- package/scripts/epic/epic-candidate-confirm.ts +1 -1
- package/scripts/epic/epic-cross-repo.ts +4 -4
- package/scripts/epic/epic-next.ts +1 -1
- package/scripts/hooks/stop-guard.ts +20 -9
- package/scripts/init/start-plan.ts +15 -4
- package/scripts/init/start-steps.ts +31 -3
- package/scripts/init/start.ts +20 -13
- package/scripts/josh/josh-commands-ai.ts +1 -1
- package/scripts/josh/josh-commands-project.ts +1 -1
- package/scripts/lane/lane-cli.ts +1 -3
- package/scripts/run/board/run-board-header.ts +137 -88
- package/scripts/run/board/run-board-labels.ts +18 -17
- package/scripts/run/board/run-board-machine.ts +21 -8
- package/scripts/run/board/run-board-read.ts +9 -2
- package/scripts/run/board/run-board-render.ts +1 -5
- package/scripts/run/board/run-board-setup.ts +73 -0
- package/scripts/run/board/run-board-span.ts +49 -8
- package/scripts/run/board/run-board-status.ts +10 -23
- package/scripts/run/board/run-board-tick.ts +11 -2
- package/scripts/run/carry/run-carry-added.ts +1 -1
- package/scripts/run/carry/run-carry-change.ts +1 -1
- package/scripts/run/carry/run-carry-cli.ts +36 -60
- package/scripts/run/carry/run-carry-ended.ts +10 -2
- package/scripts/run/carry/run-carry.ts +38 -68
- package/scripts/run/cut/run-cut.ts +4 -4
- package/scripts/run/event/run-event-render.ts +1 -1
- package/scripts/run/event/run-event-scope.ts +4 -8
- package/scripts/run/event/run-event-setup.ts +47 -0
- package/scripts/run/event/run-event-stream-emit.ts +0 -25
- package/scripts/run/event/run-event-stream.ts +41 -28
- package/scripts/run/merge/run-merge-collect.ts +1 -1
- package/scripts/run/merge/run-merge-steps.ts +1 -1
- package/scripts/run/progress/run-progress-read.ts +1 -1
- package/scripts/run/run-headless.ts +2 -2
- package/scripts/run/run-status-cli.ts +1 -5
- package/scripts/run/run-step.ts +2 -9
- package/scripts/run/run-stop-notify.ts +1 -1
- package/scripts/run/run-stranded-detect.ts +61 -15
- package/scripts/run/run-stranded.ts +25 -17
- package/scripts/run/wake/run-wake-cli.ts +58 -15
- package/scripts/run/wake/run-wake-describe.ts +5 -5
- package/scripts/run/wake/run-wake-driver.ts +13 -4
- package/scripts/run/wake/run-wake-handoff.ts +1 -1
- package/scripts/run/wake/run-wake-loop.ts +4 -21
- package/scripts/run/wake/run-wake-restart.ts +1 -1
- package/scripts/run/wake/run-wake.ts +6 -7
- 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
|
|
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
|
|
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
|
-
// **
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
// **
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
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
|
|
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 —
|
|
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
|
-
|
|
105
|
-
|
|
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
|
|
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
|
|
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
|
|
140
|
-
//
|
|
141
|
-
//
|
|
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
|
|
144
|
-
//
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
196
|
-
//
|
|
197
|
-
//
|
|
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
|
-
//
|
|
216
|
-
//
|
|
217
|
-
// the other branch, where
|
|
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,
|
|
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
|
|
199
|
+
if (left_ms > 0) return { verdict: WATCH_VERDICT, reason: watching_reason(input, left_ms) }
|
|
226
200
|
|
|
227
|
-
return
|
|
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
|
|
231
|
-
// rather than stopping on something a person must act on:
|
|
232
|
-
//
|
|
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
|
|
209
|
+
return input.answer === 'exhausted' && max_decision(input) === undefined
|
|
236
210
|
}
|
|
237
211
|
|
|
238
|
-
// The answers whose decision no count changes, once the
|
|
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
|
-
|
|
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
|
-
|
|
233
|
+
IDLE_WAIT_HOURS,
|
|
268
234
|
MS_PER_MINUTE,
|
|
269
|
-
|
|
270
|
-
PARKED_REASON,
|
|
235
|
+
OFFER_INTERVAL_MS,
|
|
271
236
|
RUN_VERDICT,
|
|
272
237
|
STOP_VERDICT,
|
|
273
238
|
TRIAGE_REASON,
|
|
274
239
|
TRIAGE_VERDICT,
|
|
275
|
-
|
|
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'
|
|
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 =
|
|
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 {
|
|
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
|
-
|
|
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 =
|
|
15
|
-
if (read.kind !== 'carried') throw new Error(
|
|
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
|
|
356
|
-
//
|
|
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
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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 }
|