@joshuafolkken/kit 1.1379.0 → 1.1383.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 (70) 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-YSDAH73S.js → chunk-2X2ART5G.js} +2 -2
  6. package/dist/hooks/{chunk-RW4XMF47.js → chunk-4MGHBTOM.js} +125 -232
  7. package/dist/hooks/{chunk-TG2AB3W6.js → chunk-6AC4C2IO.js} +45 -55
  8. package/dist/hooks/{chunk-HGA4EG5S.js → chunk-6MAW7BCM.js} +12 -12
  9. package/dist/hooks/{chunk-QARPP36N.js → chunk-7VSZ2N4B.js} +3 -3
  10. package/dist/hooks/{chunk-DHSKBAQ2.js → chunk-KC6LUZZ2.js} +1 -1
  11. package/dist/hooks/{chunk-4RWRP3FF.js → chunk-KCF2YE7O.js} +5 -5
  12. package/dist/hooks/{chunk-E7LZL5AN.js → chunk-LC56R53A.js} +3 -3
  13. package/dist/hooks/{chunk-546X4PX3.js → chunk-LOJTBU5V.js} +3 -3
  14. package/dist/hooks/{chunk-7ZM464OW.js → chunk-RYP3TSUJ.js} +3 -3
  15. package/dist/hooks/{chunk-TORRSNDJ.js → chunk-TT742SIO.js} +2 -2
  16. package/dist/hooks/{chunk-NBEAGPS4.js → chunk-Y5R47QHU.js} +1 -1
  17. package/dist/hooks/{chunk-PV3FWDSQ.js → chunk-YKUITFT7.js} +2 -2
  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 +199 -200
  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 +3 -3
  25. package/dist/hooks/stop-guard.js +19 -15
  26. package/dist/josh.js +1 -1
  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 +10 -3
  32. package/scripts/backlog/backlog-drive.ts +5 -2
  33. package/scripts/backlog/backlog-offer-cli.ts +1 -15
  34. package/scripts/epic/epic-candidate-confirm.ts +1 -1
  35. package/scripts/epic/epic-cross-repo.ts +4 -4
  36. package/scripts/epic/epic-next.ts +1 -1
  37. package/scripts/hooks/stop-guard.ts +20 -9
  38. package/scripts/josh/josh-commands-ai.ts +1 -1
  39. package/scripts/lane/lane-cli.ts +1 -3
  40. package/scripts/run/board/run-board-header.ts +107 -84
  41. package/scripts/run/board/run-board-labels.ts +18 -17
  42. package/scripts/run/board/run-board-machine.ts +21 -8
  43. package/scripts/run/board/run-board-render.ts +1 -5
  44. package/scripts/run/board/run-board-span.ts +0 -1
  45. package/scripts/run/board/run-board-status.ts +0 -17
  46. package/scripts/run/board/run-board-tick.ts +1 -0
  47. package/scripts/run/carry/run-carry-cli.ts +8 -62
  48. package/scripts/run/carry/run-carry-ended.ts +1 -1
  49. package/scripts/run/carry/run-carry.ts +22 -68
  50. package/scripts/run/cut/run-cut.ts +4 -4
  51. package/scripts/run/event/run-event-render.ts +0 -1
  52. package/scripts/run/event/run-event-scope.ts +4 -8
  53. package/scripts/run/event/run-event-stream-emit.ts +0 -25
  54. package/scripts/run/event/run-event-stream.ts +23 -31
  55. package/scripts/run/merge/run-merge-collect.ts +1 -1
  56. package/scripts/run/merge/run-merge-steps.ts +1 -1
  57. package/scripts/run/progress/run-progress-read.ts +1 -1
  58. package/scripts/run/run-headless.ts +2 -2
  59. package/scripts/run/run-status-cli.ts +1 -5
  60. package/scripts/run/run-step.ts +2 -9
  61. package/scripts/run/run-stop-notify.ts +1 -1
  62. package/scripts/run/run-stranded-detect.ts +3 -3
  63. package/scripts/run/run-stranded.ts +25 -17
  64. package/scripts/run/wake/run-wake-cli.ts +7 -9
  65. package/scripts/run/wake/run-wake-describe.ts +3 -5
  66. package/scripts/run/wake/run-wake-driver.ts +3 -3
  67. package/scripts/run/wake/run-wake-loop.ts +4 -21
  68. package/scripts/run/wake/run-wake-restart.ts +1 -1
  69. package/scripts/run/wake/run-wake.ts +6 -7
  70. package/scripts/backlog/backlog-idle.ts +0 -88
@@ -28,7 +28,7 @@ const STOP_RECOVERY =
28
28
  // nothing readable there is no run this can honestly say stopped.
29
29
  function plan(read: CarryRead, reason: string | undefined): StopNotice | undefined {
30
30
  if (reason === undefined || reason.length === 0) return undefined
31
- if (read.kind !== 'carried' && read.kind !== 'expired') return undefined
31
+ if (read.kind !== 'carried') return undefined
32
32
 
33
33
  return { reason }
34
34
  }
@@ -18,7 +18,7 @@ import {
18
18
  //
19
19
  // **Cheap and local.** Every read is a file on disk — the carry record, the wake record — and a process
20
20
  // liveness probe; there is no network call, so unlike the stall detector this makes every read every
21
- // time. The gate is the carry record itself: nothing carried, or a record that is expired or unreadable,
21
+ // time. The gate is the carry record itself: nothing carried, or a record that is unreadable,
22
22
  // returns before the wake record is even read.
23
23
 
24
24
  // The recovery line names the command that hands the run to a fresh supervisor — the same one the pure
@@ -74,8 +74,8 @@ const DEFAULT_PORTS: DetectPorts = {
74
74
  notify_stranded: real_notify_stranded,
75
75
  }
76
76
 
77
- // The three facts, assembled behind the carry-record gate. A non-`carried` read — nothing carried, a
78
- // spent bound, or an unreadable record — is `undefined`: there is no live budget to be stranded, so the
77
+ // The three facts, assembled behind the carry-record gate. A non-`carried` read — nothing carried, or
78
+ // an unreadable record — is `undefined`: there is no live budget to be stranded, so the
79
79
  // wake record is never read and the judge is never asked.
80
80
  async function gather(ports: DetectPorts): Promise<StrandedInput | undefined> {
81
81
  const targets = await ports.resolve_targets()
@@ -2,10 +2,10 @@ import { process_identity } from '#scripts/josh/process-identity'
2
2
  import { run_carry, type CarryRead, type RunCarry } from '#scripts/run/carry/run-carry'
3
3
  import type { RunWake } from '#scripts/run/wake/run-wake'
4
4
 
5
- // The mechanical judge of a *stranded* run: a handed-off budget whose cutting session died, with no
6
- // successor claiming it and no supervisor watching, so lanes run on with nobody driving. Three facts,
7
- // read and never weighed, are what say that state has been reached: the budget was handed off, its
8
- // owner is gone, and no supervisor is alive to wake a successor.
5
+ // The mechanical judge of a *stranded* run: a budget whose owning session died — at a cut it handed
6
+ // off, or with no hand-off at all — with no successor holding it and no supervisor watching, so lanes
7
+ // run on with nobody driving. Three facts, read and never weighed, are what say that state has been
8
+ // reached: the record names a driver, that owner is gone, and no supervisor is alive to wake a successor.
9
9
  //
10
10
  // **Pure by design, exactly as `backlog-stalled.ts` is.** The carry read, the owner's liveness and the
11
11
  // supervisor's are handed in, so the four acceptance cases — all three holding, a live owner, a claimed
@@ -72,19 +72,27 @@ function supervisor_liveness(
72
72
  return is_same ? LIVE : GONE
73
73
  }
74
74
 
75
- // **The whole of the judgement, in four reads.** The carry record must be `carried` — an `expired`,
76
- // `none` or `unreadable` read is never a strand, because there is either no budget to strand or no way
77
- // to trust what the record says. It must be handed off and *not* yet adopted — `adopt_carry` spends
78
- // `is_handed_off` the instant a successor claims it, so a claimed successor reads `false` here. The owner
79
- // must be gone — a live owner is a session still spending the budget, the ordinary in-flight state. And
80
- // the supervisor must be `gone` — `live` or `unknown` both count as something that could still wake a
81
- // successor, so only a proven-absent supervisor completes the strand.
75
+ // Whether the record names a driver whose absence can be proven: a hand-off waiting for its successor,
76
+ // or an owner process. A record with neither was begun by nobody this machine can probe, and an
77
+ // unprovable owner is never a strand.
78
+ function had_driver(carry: RunCarry): boolean {
79
+ return carry.is_handed_off === true || carry.owner_pid !== undefined
80
+ }
81
+
82
+ // **The whole of the judgement, in four reads.** The carry record must be `carried` — a `none` or
83
+ // `unreadable` read is never a strand, because there is either no run to strand or no way to trust
84
+ // what the record says. The owner must be gone — a live owner is a session still spending the budget,
85
+ // the ordinary in-flight state, and a successor that claimed a hand-off is that live owner, since
86
+ // `adopt_carry` rewrites the owner as it spends `is_handed_off`. The record must name the driver that
87
+ // is gone (`had_driver`). And the supervisor must be `gone` — `live` or `unknown` both count as
88
+ // something that could still wake a successor, so only a proven-absent supervisor completes the strand.
89
+ //
90
+ // **A hand-off is not required.** An owner that died between its claim and the next cut — the
91
+ // supervisor killed mid-drive, an interactive parent's terminal closed — leaves a record no cut handed
92
+ // off, and with no supervisor nothing drives it either. `run-wake.ts` → `decide_not_handed_off`
93
+ // recovers exactly that record, so the recovery the notification names holds for it too.
82
94
  function is_stranded(input: StrandedInput): boolean {
83
- if (
84
- input.read.kind !== 'carried' ||
85
- input.read.carry.is_handed_off !== true ||
86
- input.is_owner_live
87
- ) {
95
+ if (input.read.kind !== 'carried' || input.is_owner_live || !had_driver(input.read.carry)) {
88
96
  return false
89
97
  }
90
98
 
@@ -94,7 +102,7 @@ function is_stranded(input: StrandedInput): boolean {
94
102
  // The one line a person reads in the notification and on the stream: which run is stranded, and why. The
95
103
  // carry record's own summary carries the counts; this frames it as the strand it is.
96
104
  function describe(carry: RunCarry): string {
97
- return `Run stranded — ${run_carry.describe_carry(carry)}. The owner is gone, no cut successor claimed the budget, and no supervisor is watching.`
105
+ return `Run stranded — ${run_carry.describe_carry(carry)}. The owner is gone, no successor holds the budget, and no supervisor is watching.`
98
106
  }
99
107
 
100
108
  function verdict_of(is_run_stranded: boolean): StrandedVerdict {
@@ -3,6 +3,7 @@ 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'
@@ -34,7 +35,6 @@ const FAILURE_EXIT_CODE = 1
34
35
  const ARGV_OFFSET = 2
35
36
  const ONE_GROUP = 1
36
37
  const MS_PER_SECOND = 1000
37
- const DEFAULT_INTERVAL_SECONDS = 60
38
38
  // Whole and positive. A zero interval is a busy loop against the temp directory, not a faster
39
39
  // supervisor.
40
40
  const INTERVAL_PATTERN = /^[1-9]\d*$/u
@@ -71,17 +71,15 @@ const WARNING_RECOVERY = `Check the wake command, then restart with \`pnpm josh
71
71
  // `stopped` is a person's own `--stop` or a supervisor being superseded, and a warning channel that
72
72
  // fired on those is one that stops being read.
73
73
  //
74
- // **`expired` and `unreadable` warn**, because both end the supervisor while a carry record is still
75
- // sitting there handed off, so the run would otherwise be left asleep with nothing anywhere saying so.
74
+ // **`unreadable` warns**, because it ends the supervisor while a carry record is still sitting there
75
+ // handed off, so the run would otherwise be left asleep with nothing anywhere saying so.
76
76
  // **Every reason is listed, and the silent two are listed as `undefined`.** A partial map would let a
77
- // sixth stop reason compile straight past this and reach nobody, which is the defect class this
77
+ // fifth stop reason compile straight past this and reach nobody, which is the defect class this
78
78
  // section exists to close.
79
79
  const STOP_BODIES: Record<WakeStopReason, string | undefined> = {
80
80
  ended: undefined,
81
81
  stopped: undefined,
82
82
  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
83
  unreadable:
86
84
  'The carry record could not be read, so the supervisor stopped rather than guess at it.',
87
85
  }
@@ -118,7 +116,7 @@ function note_to_stderr(note: string): void {
118
116
  }
119
117
 
120
118
  function to_interval_ms(value: string | boolean | undefined): number | undefined {
121
- if (typeof value !== 'string') return DEFAULT_INTERVAL_SECONDS * MS_PER_SECOND
119
+ if (typeof value !== 'string') return backlog_budget.OFFER_INTERVAL_MS
122
120
  if (!INTERVAL_PATTERN.test(value)) return undefined
123
121
 
124
122
  const seconds = Number(value)
@@ -221,9 +219,9 @@ async function warn_of_stop(stop: LoopStop, body: string, context: WakeContext):
221
219
  })
222
220
  }
223
221
 
224
- // **Only a failed wake exits non-zero; `expired` and `unreadable` warn but exit zero.** The
222
+ // **Only a failed wake exits non-zero; `unreadable` warns but exits zero.** The
225
223
  // verdict-and-exit-code table in `docs/josh-commands-automation.md` is a contract callers branch on,
226
- // and what those two need is the notification, not a different exit code.
224
+ // and what that one needs is the notification, not a different exit code.
227
225
  function exit_code_of(reason: WakeStopReason): number {
228
226
  return reason === run_wake_loop.FAILED_REASON ? FAILURE_EXIT_CODE : SUCCESS_EXIT_CODE
229
227
  }
@@ -32,7 +32,7 @@ interface WakeContext {
32
32
  }
33
33
 
34
34
  function carry_cuts(read: CarryRead): string {
35
- if (read.kind === 'carried' || read.kind === 'expired') return String(read.carry.cuts)
35
+ if (read.kind === 'carried') return String(read.carry.cuts)
36
36
 
37
37
  return UNKNOWN_CUTS
38
38
  }
@@ -70,15 +70,13 @@ function deadline(marked_at: string | undefined, limit_ms: number): string {
70
70
  }
71
71
 
72
72
  // 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 short of the carry record's whole-run expiry.
73
+ // budget, which has no deadline of its own.
74
74
  function owner_wait(wake: RunWake, carry: RunCarry): string {
75
75
  if (carry.owner_pid === wake.pid) {
76
76
  return 'waiting: the driver holds the carry record and is running'
77
77
  }
78
78
 
79
- const expiry = deadline(carry.started_at, run_carry.CARRY_MAX_AGE_MS)
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}`
79
+ return `waiting: process ${String(carry.owner_pid)} holds the carry record; the driver starts at its next cut or once it exits`
82
80
  }
83
81
 
84
82
  const WAIT_LINES: Partial<Record<WakeDecision['kind'], (wake: RunWake) => string>> = {
@@ -159,9 +159,9 @@ function is_mechanical(result: JoshResult): boolean {
159
159
  )
160
160
  }
161
161
 
162
- // A record that expired or was removed during the pause answers `undefined`: its stale verdict must not
163
- // reach a session, so the supervisor's own next pass re-reads the record and ends the run as `expired`
164
- // or `stopped`; a rerun would only fail on it and report the run as `failed`.
162
+ // A record that was removed during the pause answers `undefined`: its stale verdict must not reach a
163
+ // session, so the supervisor's own next pass re-reads the record and ends the run as `ended` or
164
+ // `stopped`; a rerun would only fail on it and report the run as `failed`.
165
165
  async function run_driver(
166
166
  target: string,
167
167
  args: ReadonlyArray<string>,
@@ -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(next_interval(run_wake.read_own_wake(target), interval_ms, ports.now()))
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 `expired` nor gone.
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`, `expired` (the whole-run bound) and `unreadable` stop. A hand-off is set by `run:carry --cut`,
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
- // An empty backlog still needs one session to finish the run, so a deferral is bounded by the
27
- // `backlogrun` idle watch's own default.
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 and backs off the polling interval.
52
+ // Start of one unbroken idle stretch: bounds the deferral.
53
53
  idle_since?: string | undefined
54
54
  }
55
55
 
56
- type WakeStopReason = 'ended' | 'expired' | 'unreadable' | 'failed' | 'stopped'
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' | 'expired' | 'unreadable', WakeStopReason> = {
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 }