@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
|
@@ -3,12 +3,16 @@ import { randomUUID } from 'node:crypto'
|
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import { fileURLToPath } from 'node:url'
|
|
5
5
|
import type { AgentProfile } from '#scripts/agent/agent-role-profile'
|
|
6
|
+
import { backlog_budget } from '#scripts/backlog/backlog-budget'
|
|
6
7
|
import { cli_flags } from '#scripts/lib/cli-flags'
|
|
7
8
|
import { poll } from '#scripts/lib/poll'
|
|
8
9
|
import { telegram_notify } from '#scripts/notify/telegram-notify'
|
|
9
10
|
import { run_carry, type CarryRead } from '#scripts/run/carry/run-carry'
|
|
11
|
+
import { run_carry_ended } from '#scripts/run/carry/run-carry-ended'
|
|
10
12
|
import { run_event_stream } from '#scripts/run/event/run-event-stream'
|
|
13
|
+
import { run_event_stream_emit } from '#scripts/run/event/run-event-stream-emit'
|
|
11
14
|
import { run_headless } from '#scripts/run/run-headless'
|
|
15
|
+
import { run_stranded_detect, type WatchedRun } from '#scripts/run/run-stranded-detect'
|
|
12
16
|
import { run_wake, type RunWake, type WakeStopReason, type WakeTidyResult } from './run-wake'
|
|
13
17
|
import { run_wake_describe, type WakeContext } from './run-wake-describe'
|
|
14
18
|
import { run_wake_driver } from './run-wake-driver'
|
|
@@ -34,7 +38,6 @@ const FAILURE_EXIT_CODE = 1
|
|
|
34
38
|
const ARGV_OFFSET = 2
|
|
35
39
|
const ONE_GROUP = 1
|
|
36
40
|
const MS_PER_SECOND = 1000
|
|
37
|
-
const DEFAULT_INTERVAL_SECONDS = 60
|
|
38
41
|
// Whole and positive. A zero interval is a busy loop against the temp directory, not a faster
|
|
39
42
|
// supervisor.
|
|
40
43
|
const INTERVAL_PATTERN = /^[1-9]\d*$/u
|
|
@@ -71,17 +74,15 @@ const WARNING_RECOVERY = `Check the wake command, then restart with \`pnpm josh
|
|
|
71
74
|
// `stopped` is a person's own `--stop` or a supervisor being superseded, and a warning channel that
|
|
72
75
|
// fired on those is one that stops being read.
|
|
73
76
|
//
|
|
74
|
-
// **`
|
|
75
|
-
//
|
|
77
|
+
// **`unreadable` warns**, because it ends the supervisor while a carry record is still sitting there
|
|
78
|
+
// handed off, so the run would otherwise be left asleep with nothing anywhere saying so.
|
|
76
79
|
// **Every reason is listed, and the silent two are listed as `undefined`.** A partial map would let a
|
|
77
|
-
//
|
|
80
|
+
// fifth stop reason compile straight past this and reach nobody, which is the defect class this
|
|
78
81
|
// section exists to close.
|
|
79
82
|
const STOP_BODIES: Record<WakeStopReason, string | undefined> = {
|
|
80
83
|
ended: undefined,
|
|
81
84
|
stopped: undefined,
|
|
82
85
|
failed: 'The supervisor could not continue the run and has stopped.',
|
|
83
|
-
expired:
|
|
84
|
-
'The carried run passed its whole-run bound, so the supervisor stopped without continuing it.',
|
|
85
86
|
unreadable:
|
|
86
87
|
'The carry record could not be read, so the supervisor stopped rather than guess at it.',
|
|
87
88
|
}
|
|
@@ -118,7 +119,7 @@ function note_to_stderr(note: string): void {
|
|
|
118
119
|
}
|
|
119
120
|
|
|
120
121
|
function to_interval_ms(value: string | boolean | undefined): number | undefined {
|
|
121
|
-
if (typeof value !== 'string') return
|
|
122
|
+
if (typeof value !== 'string') return backlog_budget.OFFER_INTERVAL_MS
|
|
122
123
|
if (!INTERVAL_PATTERN.test(value)) return undefined
|
|
123
124
|
|
|
124
125
|
const seconds = Number(value)
|
|
@@ -147,6 +148,7 @@ async function resolve_context(): Promise<WakeContext | undefined> {
|
|
|
147
148
|
|
|
148
149
|
return {
|
|
149
150
|
carry_target: run_carry.carry_path(directory),
|
|
151
|
+
ended_target: run_carry_ended.ended_path(directory),
|
|
150
152
|
wake_target: run_wake.wake_path(directory),
|
|
151
153
|
log_target,
|
|
152
154
|
event_target: run_event_stream.target_of(directory),
|
|
@@ -182,10 +184,13 @@ function wake_session(
|
|
|
182
184
|
return run_wake_session.launch({ argv: built.argv, ...target, profile }, note_to_stderr)
|
|
183
185
|
}
|
|
184
186
|
|
|
187
|
+
// **A record this supervisor owns itself is not a session at work.** A failed driver leaves the record
|
|
188
|
+
// adopted for this process, so reading that owner as live would have every restarted pass wait on
|
|
189
|
+
// itself and never drive again.
|
|
185
190
|
function ports_for(context: WakeContext, profile: AgentProfile): LoopPorts {
|
|
186
191
|
return {
|
|
187
192
|
read_carry: () => run_carry.read_carry(context.carry_target),
|
|
188
|
-
is_owner_live: (read) => read.kind === 'carried' && run_carry.
|
|
193
|
+
is_owner_live: (read) => read.kind === 'carried' && run_carry.is_held_by_another(read.carry),
|
|
189
194
|
has_work: async () => true,
|
|
190
195
|
new_session_id: () => randomUUID(),
|
|
191
196
|
hand_off: () => {
|
|
@@ -213,17 +218,48 @@ function warning_body(stop: LoopStop, body: string, context: WakeContext): strin
|
|
|
213
218
|
return [body, log_hint(context), stop.note].filter(Boolean).join('\n')
|
|
214
219
|
}
|
|
215
220
|
|
|
216
|
-
|
|
217
|
-
|
|
221
|
+
// Whether the warning reached a person — the answer `record_failure` leaves its marker on.
|
|
222
|
+
async function warn_of_stop(stop: LoopStop, body: string, context: WakeContext): Promise<boolean> {
|
|
223
|
+
return await telegram_notify.warn({
|
|
218
224
|
issue_title: WARNING_TITLE,
|
|
219
225
|
body: warning_body(stop, body, context),
|
|
220
226
|
recovery: WARNING_RECOVERY,
|
|
221
227
|
})
|
|
222
228
|
}
|
|
223
229
|
|
|
224
|
-
// **
|
|
230
|
+
// **A failed supervisor that leaves the run stranded says so on the stream, with its cause.** Nothing
|
|
231
|
+
// else writes there when the supervisor gives up, so a reader of the events would see the last merge
|
|
232
|
+
// and a run that looks as if it were still watching. The cause is the note's first line — the
|
|
233
|
+
// driver's own `error …` — because the rest is a stderr tail the log already holds.
|
|
234
|
+
//
|
|
235
|
+
// **Only a strand, and only one a person was told of.** A `failed` stop is not always a strand: a
|
|
236
|
+
// `run:carry --end` under a live driver fails it too, and that run ended cleanly. And the event is the
|
|
237
|
+
// marker `run_stranded_detect` holds its own notification back on, so it is left only when the warning
|
|
238
|
+
// was sent — after a failed send the stop-time detector still finds no marker, and notifies.
|
|
239
|
+
function is_strand_told(stop: LoopStop, watched: WatchedRun, is_warned: boolean): boolean {
|
|
240
|
+
if (!is_warned || stop.reason !== run_wake_loop.FAILED_REASON) return false
|
|
241
|
+
|
|
242
|
+
return run_stranded_detect.is_left_stranded(watched)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function record_failure(
|
|
246
|
+
stop: LoopStop,
|
|
247
|
+
watched: WatchedRun,
|
|
248
|
+
is_warned: boolean,
|
|
249
|
+
): Promise<void> {
|
|
250
|
+
if (!is_strand_told(stop, watched, is_warned)) return
|
|
251
|
+
|
|
252
|
+
const [cause = ''] = (stop.note ?? '').split('\n', 1)
|
|
253
|
+
|
|
254
|
+
await run_event_stream_emit.emit(
|
|
255
|
+
run_event_stream.EVENT_KIND.STRANDED,
|
|
256
|
+
`The supervisor failed and stopped watching the run — ${cause}`,
|
|
257
|
+
)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// **Only a failed wake exits non-zero; `unreadable` warns but exits zero.** The
|
|
225
261
|
// verdict-and-exit-code table in `docs/josh-commands-automation.md` is a contract callers branch on,
|
|
226
|
-
// and what
|
|
262
|
+
// and what that one needs is the notification, not a different exit code.
|
|
227
263
|
function exit_code_of(reason: WakeStopReason): number {
|
|
228
264
|
return reason === run_wake_loop.FAILED_REASON ? FAILURE_EXIT_CODE : SUCCESS_EXIT_CODE
|
|
229
265
|
}
|
|
@@ -236,13 +272,17 @@ function stop_body(reason: WakeStopReason): string | undefined {
|
|
|
236
272
|
return STOP_BODIES[reason]
|
|
237
273
|
}
|
|
238
274
|
|
|
239
|
-
|
|
275
|
+
// `started_at` is the run this supervisor took on — what `record_failure` tells an ended run by once
|
|
276
|
+
// the carry record that held it no longer reads.
|
|
277
|
+
async function finish(stop: LoopStop, context: WakeContext, started_at: string): Promise<number> {
|
|
240
278
|
const body = stop_body(stop.reason)
|
|
241
279
|
|
|
242
280
|
if (body === undefined) return report(stop.reason)
|
|
243
281
|
|
|
244
282
|
console.error(stop.note ?? body)
|
|
245
|
-
await warn_of_stop(stop, body, context)
|
|
283
|
+
const is_warned = await warn_of_stop(stop, body, context)
|
|
284
|
+
|
|
285
|
+
await record_failure(stop, { ...context, started_at }, is_warned)
|
|
246
286
|
|
|
247
287
|
return report(stop.reason, exit_code_of(stop.reason))
|
|
248
288
|
}
|
|
@@ -338,7 +378,7 @@ async function loop(context: WakeContext, interval_ms: number): Promise<number>
|
|
|
338
378
|
|
|
339
379
|
tidy_up(context.wake_target)
|
|
340
380
|
|
|
341
|
-
return await finish(stop, context)
|
|
381
|
+
return await finish(stop, context, read.carry.started_at)
|
|
342
382
|
}
|
|
343
383
|
|
|
344
384
|
// **The verdict tracks whether the supervisor is actually running, not merely whether a record is
|
|
@@ -421,6 +461,9 @@ const run_wake_cli = {
|
|
|
421
461
|
SUPERVISING_VERDICT,
|
|
422
462
|
UNKNOWN_VERDICT,
|
|
423
463
|
USAGE,
|
|
464
|
+
finish,
|
|
465
|
+
ports_for,
|
|
466
|
+
record_failure,
|
|
424
467
|
run,
|
|
425
468
|
stop_body,
|
|
426
469
|
warning_body,
|
|
@@ -19,6 +19,8 @@ const UNREADABLE_TIME = 'an unreadable time'
|
|
|
19
19
|
// checkout — a lane must never be where the next session resumes, because a lane belongs to one child.
|
|
20
20
|
interface WakeContext {
|
|
21
21
|
carry_target: string
|
|
22
|
+
// Where `run:carry --end` records the run it removes — what tells an ended run from an unread one.
|
|
23
|
+
ended_target: string
|
|
22
24
|
wake_target: string
|
|
23
25
|
// Where everything this supervisor starts writes its output. It is named
|
|
24
26
|
// in `--list` and in every warning, because a path nobody is told is a file nobody reads.
|
|
@@ -32,7 +34,7 @@ interface WakeContext {
|
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
function carry_cuts(read: CarryRead): string {
|
|
35
|
-
if (read.kind === 'carried'
|
|
37
|
+
if (read.kind === 'carried') return String(read.carry.cuts)
|
|
36
38
|
|
|
37
39
|
return UNKNOWN_CUTS
|
|
38
40
|
}
|
|
@@ -70,15 +72,13 @@ function deadline(marked_at: string | undefined, limit_ms: number): string {
|
|
|
70
72
|
}
|
|
71
73
|
|
|
72
74
|
// A live owner that is this supervisor is its own driver at work; any other is a session spending the
|
|
73
|
-
// budget, which has no deadline of its own
|
|
75
|
+
// budget, which has no deadline of its own.
|
|
74
76
|
function owner_wait(wake: RunWake, carry: RunCarry): string {
|
|
75
77
|
if (carry.owner_pid === wake.pid) {
|
|
76
78
|
return 'waiting: the driver holds the carry record and is running'
|
|
77
79
|
}
|
|
78
80
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
return `waiting: process ${String(carry.owner_pid)} holds the carry record; the driver starts at its next cut or once it exits, and the record expires at ${expiry}`
|
|
81
|
+
return `waiting: process ${String(carry.owner_pid)} holds the carry record; the driver starts at its next cut or once it exits`
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
const WAIT_LINES: Partial<Record<WakeDecision['kind'], (wake: RunWake) => string>> = {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { josh_command, type JoshResult } from '#scripts/josh/josh-run'
|
|
2
2
|
import { run_carry, type CarryRead } from '#scripts/run/carry/run-carry'
|
|
3
|
+
import { run_event_setup } from '#scripts/run/event/run-event-setup'
|
|
3
4
|
import { run_invocation } from '#scripts/run/run-invocation'
|
|
4
5
|
import type { DriveResult } from './run-wake-loop'
|
|
5
6
|
|
|
@@ -125,10 +126,16 @@ function read_result(
|
|
|
125
126
|
return driver_result(result.out, run_carry.read_carry(target), invocation, result.err)
|
|
126
127
|
}
|
|
127
128
|
|
|
129
|
+
// **A record this process already owns is claimed again.** A driver that failed leaves the record
|
|
130
|
+
// adopted for the supervisor and not handed off, and the restarted pass re-drives it from here; only a
|
|
131
|
+
// record another live process holds is refused.
|
|
128
132
|
function claim_record(target: string, invocation: string): boolean {
|
|
129
133
|
const current = run_carry.read_carry(target)
|
|
130
134
|
if (current.kind !== 'carried' || current.carry.invocation !== invocation) return false
|
|
131
|
-
|
|
135
|
+
|
|
136
|
+
if (current.carry.is_handed_off !== true && run_carry.is_held_by_another(current.carry)) {
|
|
137
|
+
return false
|
|
138
|
+
}
|
|
132
139
|
|
|
133
140
|
return run_carry.adopt_carry(target, current.carry, run_carry.owner_of(process.pid)) !== undefined
|
|
134
141
|
}
|
|
@@ -159,9 +166,9 @@ function is_mechanical(result: JoshResult): boolean {
|
|
|
159
166
|
)
|
|
160
167
|
}
|
|
161
168
|
|
|
162
|
-
// A record that
|
|
163
|
-
//
|
|
164
|
-
//
|
|
169
|
+
// A record that was removed during the pause answers `undefined`: its stale verdict must not reach a
|
|
170
|
+
// session, so the supervisor's own next pass re-reads the record and ends the run as `ended` or
|
|
171
|
+
// `stopped`; a rerun would only fail on it and report the run as `failed`.
|
|
165
172
|
async function run_driver(
|
|
166
173
|
target: string,
|
|
167
174
|
args: ReadonlyArray<string>,
|
|
@@ -188,6 +195,8 @@ async function drive(target: string, wait: Pause = pause): Promise<DriveResult>
|
|
|
188
195
|
return { kind: 'failed', note: 'The driver could not claim the carry record.' }
|
|
189
196
|
}
|
|
190
197
|
|
|
198
|
+
await run_event_setup.emit(run_event_setup.STAGE.HANDING_OFF)
|
|
199
|
+
|
|
191
200
|
return read_result(
|
|
192
201
|
await run_driver(target, args, wait, RERUN_LIMIT),
|
|
193
202
|
target,
|
|
@@ -21,7 +21,7 @@ function hand_off(target: string): void {
|
|
|
21
21
|
|
|
22
22
|
if (read.kind !== 'carried' || read.carry.is_handed_off === true) return
|
|
23
23
|
|
|
24
|
-
stamp_file.
|
|
24
|
+
stamp_file.replace_stamp(target, { ...read.carry, is_handed_off: true })
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
const run_wake_handoff = { hand_off }
|
|
@@ -24,12 +24,6 @@ const STOPPED_REASON: WakeStopReason = 'stopped'
|
|
|
24
24
|
const FAILED_REASON: WakeStopReason = 'failed'
|
|
25
25
|
const NO_ATTEMPTS = 0
|
|
26
26
|
const STOPPED_BY_PERSON: LoopStop = { reason: STOPPED_REASON, note: undefined }
|
|
27
|
-
// **The back-off after repeated whiffs, and its ceiling.** While a launch is
|
|
28
|
-
// deferred for want of work, each wait is as long as the idle stretch so far — so the polling interval
|
|
29
|
-
// doubles pass by pass — capped at this multiple of the configured one. Eight times the one-minute
|
|
30
|
-
// default is eight minutes: far fewer backlog reads over a long empty stretch, and still well inside
|
|
31
|
-
// the idle ceiling, so work that appears is picked up within one capped wait.
|
|
32
|
-
const MAX_BACKOFF_FACTOR = 8
|
|
33
27
|
|
|
34
28
|
interface LoopPorts {
|
|
35
29
|
// The carry record, re-read every pass rather than cached: it is written by the parent loop of a
|
|
@@ -157,18 +151,6 @@ async function decide_pass(wake: RunWake, ports: LoopPorts): Promise<WakeDecisio
|
|
|
157
151
|
return run_wake.decide({ ...input, has_work: await ports.has_work(input.read) })
|
|
158
152
|
}
|
|
159
153
|
|
|
160
|
-
// The wait before the next pass: the configured interval, stretched while a launch is being deferred
|
|
161
|
-
// to the length of the idle stretch so far and capped at `MAX_BACKOFF_FACTOR` times the interval.
|
|
162
|
-
function next_interval(wake: RunWake | undefined, interval_ms: number, now: Date): number {
|
|
163
|
-
const idle_since = wake?.idle_since === undefined ? NaN : Date.parse(wake.idle_since)
|
|
164
|
-
|
|
165
|
-
if (Number.isNaN(idle_since)) return interval_ms
|
|
166
|
-
|
|
167
|
-
const stretched = Math.max(interval_ms, now.getTime() - idle_since)
|
|
168
|
-
|
|
169
|
-
return Math.min(stretched, interval_ms * MAX_BACKOFF_FACTOR)
|
|
170
|
-
}
|
|
171
|
-
|
|
172
154
|
// The record is re-read at the top of every pass rather than carried in a variable, so a `--stop` that
|
|
173
155
|
// removed it ends the loop at the next interval and a `--list` reads what the loop actually wrote.
|
|
174
156
|
// One pass: decide, act, write the result back. `undefined` means carry on to the next interval.
|
|
@@ -187,6 +169,9 @@ async function run_pass(
|
|
|
187
169
|
// The record is re-read at the top of every pass rather than carried in a variable, so a `--stop` that
|
|
188
170
|
// removed it ends the loop at the next interval and a `--list` reads what the loop actually wrote.
|
|
189
171
|
//
|
|
172
|
+
// **The wait between two passes is the configured interval, however long the run has been idle**: a
|
|
173
|
+
// launch deferred for want of work is asked about again as soon as a running one would be.
|
|
174
|
+
//
|
|
190
175
|
// **`read_own_wake` rather than `read_wake`, and the difference is a whole pass.**
|
|
191
176
|
// A record that has become a *successor's* is not this loop's to act on, and reading it back would
|
|
192
177
|
// have this pass decide from another supervisor's counters — spawning a session for a cut that
|
|
@@ -203,7 +188,7 @@ async function run_loop(target: string, ports: LoopPorts, interval_ms: number):
|
|
|
203
188
|
if (stop !== undefined) return stop
|
|
204
189
|
|
|
205
190
|
// eslint-disable-next-line no-await-in-loop -- polling: each read waits on the state the previous one saw
|
|
206
|
-
await ports.sleep(
|
|
191
|
+
await ports.sleep(interval_ms)
|
|
207
192
|
wake = run_wake.read_own_wake(target)
|
|
208
193
|
}
|
|
209
194
|
|
|
@@ -212,8 +197,6 @@ async function run_loop(target: string, ports: LoopPorts, interval_ms: number):
|
|
|
212
197
|
|
|
213
198
|
const run_wake_loop = {
|
|
214
199
|
FAILED_REASON,
|
|
215
|
-
MAX_BACKOFF_FACTOR,
|
|
216
|
-
next_interval,
|
|
217
200
|
run_loop,
|
|
218
201
|
}
|
|
219
202
|
|
|
@@ -15,7 +15,7 @@ interface RestartPorts {
|
|
|
15
15
|
// One run of the loop over the record the caller claimed once and holds across every restart, so a
|
|
16
16
|
// `--stop` landing in the pause is read by the next pass as the person's stop.
|
|
17
17
|
pass: () => Promise<LoopStop>
|
|
18
|
-
// Whether the carry record can still be continued — `carried`, neither
|
|
18
|
+
// Whether the carry record can still be continued — `carried`, neither unreadable nor gone.
|
|
19
19
|
is_resumable: () => boolean
|
|
20
20
|
pause: () => Promise<void>
|
|
21
21
|
note: (text: string) => void
|
|
@@ -9,7 +9,7 @@ import { z } from 'zod'
|
|
|
9
9
|
|
|
10
10
|
// The process outside the conversation that starts the next session after a `backlogrun` cut.
|
|
11
11
|
// Whether to wake is the carry record's answer, never a judgement: `carried` and handed off wakes;
|
|
12
|
-
// `none
|
|
12
|
+
// `none` and `unreadable` stop. A hand-off is set by `run:carry --cut`,
|
|
13
13
|
// or by this supervisor when it recovers a record whose owner died, so a live session never reads as
|
|
14
14
|
// waiting. The supervisor owns the record while its deterministic driver spends the budget; a judgment
|
|
15
15
|
// branch hands it off so one AI session can adopt it.
|
|
@@ -23,8 +23,8 @@ const WAKE_LOG_SUFFIX = '.log'
|
|
|
23
23
|
const WAKE_GRACE_MS = 600_000
|
|
24
24
|
// Retried before failing: stopping ends a run nobody is watching.
|
|
25
25
|
const MAX_WAKE_ATTEMPTS = 3
|
|
26
|
-
//
|
|
27
|
-
// `backlogrun` idle
|
|
26
|
+
// A run with nothing to start still needs one session to finish it, so a deferral is bounded by the
|
|
27
|
+
// `backlogrun` idle wait itself.
|
|
28
28
|
const IDLE_CEILING_MS = backlog_budget.DEFAULT_IDLE_MS
|
|
29
29
|
const NO_WAKES = 0
|
|
30
30
|
const ONE_WAKE = 1
|
|
@@ -49,11 +49,11 @@ interface RunWake {
|
|
|
49
49
|
woke_pid?: number | undefined
|
|
50
50
|
// Every `--session-id` this forced, so `josh time --run` attributes whiffs only to its own sessions.
|
|
51
51
|
spawned?: ReadonlyArray<string> | undefined
|
|
52
|
-
// Start of one unbroken idle stretch: bounds the deferral
|
|
52
|
+
// Start of one unbroken idle stretch: bounds the deferral.
|
|
53
53
|
idle_since?: string | undefined
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
type WakeStopReason = 'ended' | '
|
|
56
|
+
type WakeStopReason = 'ended' | 'unreadable' | 'failed' | 'stopped'
|
|
57
57
|
|
|
58
58
|
// `superseded`: another supervisor holds the record and this process is the replaced one.
|
|
59
59
|
type WakeTidyResult = 'absent' | 'removed' | 'superseded'
|
|
@@ -87,9 +87,8 @@ const WAIT_DECISION: WakeDecision = { kind: 'wait' }
|
|
|
87
87
|
const PENDING_DECISION: WakeDecision = { kind: 'pending' }
|
|
88
88
|
const FAILED_DECISION: WakeDecision = { kind: 'failed' }
|
|
89
89
|
|
|
90
|
-
const STOP_REASONS: Record<'none' | '
|
|
90
|
+
const STOP_REASONS: Record<'none' | 'unreadable', WakeStopReason> = {
|
|
91
91
|
none: 'ended',
|
|
92
|
-
expired: 'expired',
|
|
93
92
|
unreadable: 'unreadable',
|
|
94
93
|
}
|
|
95
94
|
|
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
import { backlog_budget, type BudgetInput } from './backlog-budget'
|
|
2
|
-
|
|
3
|
-
// The idle watch as a window a person can read: when the run began waiting on
|
|
4
|
-
// an empty backlog, when the wait ends, and which bound ends it. Without it the moment the wait began
|
|
5
|
-
// would live only in `backlog:drive`'s memory as `--active`, so a reader of the event stream could
|
|
6
|
-
// tell the run was waiting but not until when. The budget names the
|
|
7
|
-
// window, `backlog:offer` records it as an `idle` event, and `run:board` reads it back — all through the
|
|
8
|
-
// text written and parsed here, so the three never spell it differently.
|
|
9
|
-
|
|
10
|
-
type IdleBound = 'idle' | 'whole-run'
|
|
11
|
-
|
|
12
|
-
interface IdleWindow {
|
|
13
|
-
since_ms: number
|
|
14
|
-
until_ms: number
|
|
15
|
-
// Which bound ends the wait: the idle budget running out, or the whole-run bound arriving first.
|
|
16
|
-
bound: IdleBound
|
|
17
|
-
// When the watching loop last asked the backlog — the poll the next one is counted from.
|
|
18
|
-
asked_ms: number
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const TEXT_PATTERN =
|
|
22
|
-
/^idle since (?<since>\S+) until (?<until>\S+) \((?<bound>idle|whole-run)\) asked (?<asked>\S+)$/u
|
|
23
|
-
const IDLE_BOUND: IdleBound = 'idle'
|
|
24
|
-
const WHOLE_RUN_BOUND: IdleBound = 'whole-run'
|
|
25
|
-
|
|
26
|
-
// The window an idle watch opened at `active_at_ms` runs for, as asked at `now_ms`, or `undefined` when
|
|
27
|
-
// the watch is off. The wait ends at the earlier of the idle budget and the whole-run bound — the order
|
|
28
|
-
// `decide` applies.
|
|
29
|
-
function window_of(input: BudgetInput): IdleWindow | undefined {
|
|
30
|
-
if (input.idle_budget_ms === undefined) return undefined
|
|
31
|
-
|
|
32
|
-
const idle_until = input.active_at_ms + input.idle_budget_ms
|
|
33
|
-
const run_until = input.started_at_ms + backlog_budget.WHOLE_RUN_BUDGET_MS
|
|
34
|
-
const bound = run_until < idle_until ? WHOLE_RUN_BOUND : IDLE_BOUND
|
|
35
|
-
|
|
36
|
-
return {
|
|
37
|
-
since_ms: input.active_at_ms,
|
|
38
|
-
until_ms: Math.min(idle_until, run_until),
|
|
39
|
-
bound,
|
|
40
|
-
asked_ms: input.now_ms,
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function iso_of(ms: number): string {
|
|
45
|
-
return new Date(ms).toISOString()
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function text_of(window: IdleWindow): string {
|
|
49
|
-
const { since_ms, until_ms, bound, asked_ms } = window
|
|
50
|
-
|
|
51
|
-
return `idle since ${iso_of(since_ms)} until ${iso_of(until_ms)} (${bound}) asked ${iso_of(asked_ms)}`
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function is_bound(value: string | undefined): value is IdleBound {
|
|
55
|
-
return value === IDLE_BOUND || value === WHOLE_RUN_BOUND
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// A malformed stamp reads as no window rather than as `NaN`, so a mangled line is never drawn.
|
|
59
|
-
function window_from(groups: Record<string, string>, bound: IdleBound): IdleWindow | undefined {
|
|
60
|
-
const since_ms = Date.parse(String(groups['since']))
|
|
61
|
-
const until_ms = Date.parse(String(groups['until']))
|
|
62
|
-
const asked_ms = Date.parse(String(groups['asked']))
|
|
63
|
-
|
|
64
|
-
return Number.isNaN(since_ms + until_ms + asked_ms)
|
|
65
|
-
? undefined
|
|
66
|
-
: { since_ms, until_ms, bound, asked_ms }
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function parse(text: string): IdleWindow | undefined {
|
|
70
|
-
const groups = TEXT_PATTERN.exec(text)?.groups
|
|
71
|
-
|
|
72
|
-
if (groups === undefined || !is_bound(groups['bound'])) return undefined
|
|
73
|
-
|
|
74
|
-
return window_from(groups, groups['bound'])
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// The next time the watching loop asks the backlog again: one `IDLE_POLL_MINUTES` after the ask the
|
|
78
|
-
// stream recorded, never past the end of the wait — the interval `idle_watch_reason` tells the loop.
|
|
79
|
-
function next_check_ms(window: IdleWindow): number {
|
|
80
|
-
const poll_ms = backlog_budget.IDLE_POLL_MINUTES * backlog_budget.MS_PER_MINUTE
|
|
81
|
-
|
|
82
|
-
return Math.min(window.asked_ms + poll_ms, window.until_ms)
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const backlog_idle = { next_check_ms, parse, text_of, window_of }
|
|
86
|
-
|
|
87
|
-
export { backlog_idle }
|
|
88
|
-
export type { IdleBound, IdleWindow }
|