@joshuafolkken/kit 1.1383.0 → 1.1387.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/hooks/batch-guard.js +6 -6
  2. package/dist/hooks/{chunk-4MGHBTOM.js → chunk-2YSW3CXH.js} +8 -2
  3. package/dist/hooks/{chunk-KCF2YE7O.js → chunk-DCY2GWHC.js} +5 -5
  4. package/dist/hooks/{chunk-KC6LUZZ2.js → chunk-EAQEGME7.js} +1 -1
  5. package/dist/hooks/{chunk-LC56R53A.js → chunk-EBSHN25W.js} +3 -3
  6. package/dist/hooks/{chunk-6MAW7BCM.js → chunk-GJ7JPIHN.js} +14 -14
  7. package/dist/hooks/{chunk-6AC4C2IO.js → chunk-GNDJ7NJ7.js} +59 -462
  8. package/dist/hooks/{chunk-LOJTBU5V.js → chunk-IYVOLN6A.js} +3 -3
  9. package/dist/hooks/{chunk-Y5R47QHU.js → chunk-J3KSS7II.js} +1 -1
  10. package/dist/hooks/{chunk-RYP3TSUJ.js → chunk-LMWBRJIP.js} +3 -3
  11. package/dist/hooks/{chunk-TT742SIO.js → chunk-SL4GQ2XP.js} +2 -2
  12. package/dist/hooks/{chunk-YKUITFT7.js → chunk-STDSEPWU.js} +2 -2
  13. package/dist/hooks/{chunk-2X2ART5G.js → chunk-U32VSRNG.js} +420 -4
  14. package/dist/hooks/{chunk-7VSZ2N4B.js → chunk-WNFJLBXI.js} +3 -3
  15. package/dist/hooks/codex-hook-adapter.js +13 -13
  16. package/dist/hooks/duplicate-read-guard.js +7 -7
  17. package/dist/hooks/format-edited.js +3 -3
  18. package/dist/hooks/inputs.json +208 -206
  19. package/dist/hooks/investigation-guard.js +7 -7
  20. package/dist/hooks/pretool-guard.js +12 -12
  21. package/dist/hooks/session-lang.js +65 -9
  22. package/dist/hooks/stop-guard.js +91 -23
  23. package/dist/josh.js +1 -1
  24. package/package.json +1 -1
  25. package/scripts/backlog/backlog-drive-cli.ts +2 -4
  26. package/scripts/backlog/backlog-drive-launch.ts +2 -0
  27. package/scripts/backlog/backlog-drive-owner.ts +72 -5
  28. package/scripts/backlog/backlogrun-launch.ts +56 -5
  29. package/scripts/document/document-byte-budget.ts +1 -1
  30. package/scripts/init/start-plan.ts +15 -4
  31. package/scripts/init/start-steps.ts +31 -3
  32. package/scripts/init/start.ts +20 -13
  33. package/scripts/josh/josh-commands-project.ts +1 -1
  34. package/scripts/run/board/run-board-header.ts +39 -13
  35. package/scripts/run/board/run-board-read.ts +9 -2
  36. package/scripts/run/board/run-board-setup.ts +73 -0
  37. package/scripts/run/board/run-board-span.ts +49 -7
  38. package/scripts/run/board/run-board-status.ts +10 -6
  39. package/scripts/run/board/run-board-tick.ts +10 -2
  40. package/scripts/run/carry/run-carry-added.ts +1 -1
  41. package/scripts/run/carry/run-carry-change.ts +1 -1
  42. package/scripts/run/carry/run-carry-cli.ts +37 -7
  43. package/scripts/run/carry/run-carry-ended.ts +9 -1
  44. package/scripts/run/carry/run-carry.ts +16 -0
  45. package/scripts/run/event/run-event-render.ts +1 -0
  46. package/scripts/run/event/run-event-setup.ts +47 -0
  47. package/scripts/run/event/run-event-stream.ts +26 -5
  48. package/scripts/run/run-stranded-detect.ts +58 -12
  49. package/scripts/run/wake/run-wake-cli.ts +51 -6
  50. package/scripts/run/wake/run-wake-describe.ts +2 -0
  51. package/scripts/run/wake/run-wake-driver.ts +10 -1
  52. package/scripts/run/wake/run-wake-handoff.ts +1 -1
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env tsx
2
2
  import { fileURLToPath } from 'node:url'
3
+ import { backlogrun_launch } from '#scripts/backlog/backlogrun-launch'
3
4
  import { cost_cli } from '#scripts/cost-runtime/cost-cli'
4
5
  import { cost_verdict } from '#scripts/cost-runtime/cost-verdict'
6
+ import { run_event_setup } from '#scripts/run/event/run-event-setup'
5
7
  import { run_event_stream } from '#scripts/run/event/run-event-stream'
6
8
  import { run_event_stream_emit } from '#scripts/run/event/run-event-stream-emit'
7
9
  import { run_merge_collect } from '#scripts/run/merge/run-merge-collect'
@@ -151,25 +153,47 @@ function report_claim(
151
153
  return report(CLAIM_VERDICTS[claim], carry, is_json, FAILURE_EXIT_CODE)
152
154
  }
153
155
 
154
- function start_fresh(target: string, request: CarryClaimRequest, is_json: boolean): number {
155
- const carry = run_carry.begin_carry(target, request.invocation, request.owner)
156
+ // When the run began: the moment `josh backlogrun` launched this owner, so the clock `run:board` was
157
+ // already counting from does not restart at the claim; `undefined` — now — for every other run.
158
+ async function launch_start(request: CarryClaimRequest): Promise<Date | undefined> {
159
+ const directory = await run_carry.repository_directory()
160
+
161
+ if (directory === undefined) return undefined
162
+
163
+ return backlogrun_launch.started_for(directory, request.owner.pid)
164
+ }
165
+
166
+ // A run that began is planning, which is what its board says until the supervisor takes it over.
167
+ async function start_fresh(
168
+ target: string,
169
+ request: CarryClaimRequest,
170
+ is_json: boolean,
171
+ ): Promise<number> {
172
+ const started = await launch_start(request)
173
+ const carry = run_carry.begin_carry(target, request.invocation, request.owner, started)
156
174
 
157
175
  if (carry === undefined) return report_lost(is_json)
158
176
 
177
+ await run_event_setup.emit(run_event_setup.STAGE.PLANNING)
178
+
159
179
  return report_carry(BEGAN_VERDICT, carry, is_json)
160
180
  }
161
181
 
162
182
  // **A standing record is never replaced, and never resumed into by something else.** Replacing one
163
183
  // would restart the budget the cut exists to carry, and resuming into one would put two parents on a
164
184
  // single budget. Its age changes none of that: a record is replaced by nothing but a person's `--end`.
165
- function begin(target: string, request: CarryClaimRequest, is_json: boolean): number {
185
+ async function begin(
186
+ target: string,
187
+ request: CarryClaimRequest,
188
+ is_json: boolean,
189
+ ): Promise<number> {
166
190
  const read = run_carry.read_carry(target)
167
191
 
168
192
  if (read.kind === 'unreadable') return report_unreadable(is_json)
169
193
 
170
194
  if (read.kind === 'carried') return report_claim(target, read.carry, request, is_json)
171
195
 
172
- return start_fresh(target, request, is_json)
196
+ return await start_fresh(target, request, is_json)
173
197
  }
174
198
 
175
199
  function report_nothing_to_resume(is_json: boolean): number {
@@ -203,10 +227,16 @@ function report_over(is_json: boolean): number {
203
227
  return report(OVER_VERDICT, undefined, is_json, FAILURE_EXIT_CODE)
204
228
  }
205
229
 
206
- function claim_record(target: string, request: CarryClaimRequest, is_json: boolean): number {
230
+ async function claim_record(
231
+ target: string,
232
+ request: CarryClaimRequest,
233
+ is_json: boolean,
234
+ ): Promise<number> {
207
235
  if (cost_cli.session_verdict() === cost_verdict.OVER_VERDICT) return report_over(is_json)
208
236
 
209
- return request.is_adoption ? adopt(target, request, is_json) : begin(target, request, is_json)
237
+ if (request.is_adoption) return adopt(target, request, is_json)
238
+
239
+ return await begin(target, request, is_json)
210
240
  }
211
241
 
212
242
  // The record is no longer this session's to advance — handed off at a cut, or taken over by a live
@@ -347,7 +377,7 @@ async function act(
347
377
  request: Exclude<Request, { kind: 'end' }>,
348
378
  is_json: boolean,
349
379
  ): Promise<number> {
350
- if (request.kind === 'claim') return claim_record(target, request.claim, is_json)
380
+ if (request.kind === 'claim') return await claim_record(target, request.claim, is_json)
351
381
 
352
382
  const read = run_carry.read_carry(target)
353
383
 
@@ -79,7 +79,15 @@ function read_ended(target: string): EndedRun | undefined {
79
79
  return raw === undefined ? undefined : parse_ended(raw)
80
80
  }
81
81
 
82
- const run_carry_ended = { ended_path, read_ended, record_ended }
82
+ // Whether the run that began at `started_at` is the one `--end` last ended — what tells a record that is
83
+ // gone because its run ended from one that only failed to read.
84
+ function has_ended(target: string, started_at: string | undefined): boolean {
85
+ const ended = read_ended(target)
86
+
87
+ return ended !== undefined && ended.started_at === started_at
88
+ }
89
+
90
+ const run_carry_ended = { ended_path, has_ended, read_ended, record_ended }
83
91
 
84
92
  export { run_carry_ended }
85
93
  export type { EndedRun }
@@ -336,6 +336,21 @@ function is_foreign_live_owner(carry: RunCarry, owner: CarryOwner): boolean {
336
336
  return is_owner_live(carry) && !is_owned_by(carry, owner)
337
337
  }
338
338
 
339
+ // Whether a process other than the caller still holds the record. **By process alone, never by
340
+ // conversation**: the wake supervisor inherits the environment of the session that started it, so a
341
+ // conversation match would read that live session's record as the supervisor's own. The driver adopts
342
+ // the record for the supervisor's process (`run-wake-driver.ts` → `claim_record`), so without this a
343
+ // supervisor reads itself as a session at work.
344
+ function is_held_by_another(
345
+ carry: RunCarry,
346
+ is_live: (carry: RunCarry) => boolean = is_owner_live,
347
+ ): boolean {
348
+ const caller = owner_of(process.pid)
349
+ const is_own = carry.owner_pid === caller.pid && carry.owner_start === caller.start
350
+
351
+ return !is_own && is_live(carry)
352
+ }
353
+
339
354
  function is_handed_off_to(carry: RunCarry, invocation: string): boolean {
340
355
  return carry.is_handed_off === true && carry.invocation === invocation
341
356
  }
@@ -504,6 +519,7 @@ const run_carry = {
504
519
  fresh_carry,
505
520
  is_count_refused,
506
521
  is_foreign_live_owner,
522
+ is_held_by_another,
507
523
  is_owner_live,
508
524
  lost_message,
509
525
  nothing_to_resume_message,
@@ -44,6 +44,7 @@ const KIND_LABELS: Readonly<Record<EventKind, KindLabel>> = {
44
44
  'lane-phase': { ja: 'レーンの段階', en: 'lane phase' },
45
45
  add: { ja: '割り込み', en: 'added' },
46
46
  'lane-limit': { ja: 'レーン上限', en: 'lane limit' },
47
+ setup: { ja: '準備', en: 'setup' },
47
48
  }
48
49
 
49
50
  const LABELS_BY_KIND: ReadonlyMap<string, KindLabel> = new Map(Object.entries(KIND_LABELS))
@@ -0,0 +1,47 @@
1
+ import { issue_cite } from '#scripts/issue/issue-cite'
2
+ import { error_text } from '#scripts/lib/error-message'
3
+ import { run_event_stream } from './run-event-stream'
4
+ import { run_event_stream_emit } from './run-event-stream-emit'
5
+
6
+ // The stages a run passes between `josh backlogrun` and its first child, each written to the stream as
7
+ // one `setup` event by the step that enters it. **The text is the stage's own words**: `run:board` draws
8
+ // it as written, so the message a person reads and the `💓` beside it come from one event and cannot
9
+ // disagree. Only a lane opening names an issue, which is how a reader tells it from the stages before it.
10
+
11
+ const STAGE = {
12
+ // `josh backlogrun` started the parent session, which is reading its procedure.
13
+ STARTING: 'starting session',
14
+ // `run:carry --begin` claimed the run, and the parent is planning it.
15
+ PLANNING: 'planning',
16
+ // The supervisor took the run over from the parent and is asking for the first offer.
17
+ HANDING_OFF: 'handing off',
18
+ } as const
19
+
20
+ const OPENING = 'opening lane'
21
+
22
+ // The driver is about to open a lane for `issue` — for every child, not only the first.
23
+ function opening_lane(issue: string): string {
24
+ return `${OPENING} ${issue_cite.plain(issue)}`
25
+ }
26
+
27
+ // Best-effort, as every append to this stream is: a stage that could not be written is a board that
28
+ // says less, never a run that failed.
29
+ async function emit(text: string): Promise<void> {
30
+ await run_event_stream_emit.emit(run_event_stream.EVENT_KIND.SETUP, text)
31
+ }
32
+
33
+ // The same append for a caller that already holds the repository's common git directory and the moment
34
+ // it wants the stage dated — the launcher, whose record and first stage carry one time.
35
+ function emit_in(repository: string, text: string, at: Date): void {
36
+ try {
37
+ const target = run_event_stream.target_of(repository)
38
+
39
+ run_event_stream.append(target, run_event_stream.EVENT_KIND.SETUP, text, at.toISOString())
40
+ } catch (error) {
41
+ error_text.trace_swallowed('run_event_setup.emit_in', error)
42
+ }
43
+ }
44
+
45
+ const run_event_setup = { STAGE, emit, emit_in, opening_lane }
46
+
47
+ export { run_event_setup }
@@ -83,7 +83,8 @@ const EVENT_KIND = {
83
83
  // stalled is pointed straight at dispatching the work rather than left waiting.
84
84
  STALL: 'stall',
85
85
  // The run's own driver is gone: the cut handed the budget off, the owner has died, and no supervisor
86
- // is watching. Emitted once per strand episode by the stop-time detector,
86
+ // is watching. Emitted once per strand episode by the stop-time detector — or by the `run:wake`
87
+ // supervisor as it fails, leaves that state behind and has warned of it —
87
88
  // exactly as `STALL` is — the newest-event dedup keeps a run polled every stop from notifying more
88
89
  // than once, and a terminal reader following the stream sees the line. The strand is the step before
89
90
  // the stall: a stalled run still has a driver that could dispatch, a stranded one has none.
@@ -124,6 +125,11 @@ const EVENT_KIND = {
124
125
  // arrival probe reads it as a wake: the lanes a raise frees are new room for a pool its baseline
125
126
  // already holds. A trace: it says the run gained room, not where the run is.
126
127
  LANE_LIMIT: 'lane-limit',
128
+ // One stage of the run's start — the session starting, the plan, the hand-off to the supervisor, a
129
+ // lane opening (`run-event-setup.ts`). `run:board` draws it until the first child launches, so the
130
+ // minutes before one read as work rather than as a run that has stopped. A trace: it says the run is
131
+ // getting ready, not where the run is.
132
+ SETUP: 'setup',
127
133
  } as const
128
134
 
129
135
  type EventKind = (typeof EVENT_KIND)[keyof typeof EVENT_KIND]
@@ -141,13 +147,19 @@ const TRACE_KINDS: ReadonlySet<string> = new Set([
141
147
  EVENT_KIND.LANE_PHASE,
142
148
  EVENT_KIND.ADD,
143
149
  EVENT_KIND.LANE_LIMIT,
150
+ EVENT_KIND.SETUP,
144
151
  ])
145
152
 
146
153
  // The trace kinds that say only that the run was alive, so the bound rolls them off first. A `filed` or
147
154
  // `note` line is a trace to the position readers but is the board's record of what the run found, so
148
155
  // it rolls off only with the positions. A ship-stage `start` is disposable but goes last of them:
149
- // `run:board` draws from that line.
150
- const DISPOSABLE_KINDS: ReadonlySet<string> = new Set([EVENT_KIND.SHIP_STAGE, EVENT_KIND.HEARTBEAT])
156
+ // `run:board` draws from that line. A `setup` line is read only until the child it prepares launches,
157
+ // so every one but the newest goes.
158
+ const DISPOSABLE_KINDS: ReadonlySet<string> = new Set([
159
+ EVENT_KIND.SHIP_STAGE,
160
+ EVENT_KIND.HEARTBEAT,
161
+ EVENT_KIND.SETUP,
162
+ ])
151
163
 
152
164
  const event_schema = z.object({
153
165
  pos: z.number(),
@@ -241,10 +253,18 @@ function is_stage_start(event: RunEvent): boolean {
241
253
  return event.text.split(WORD_SEPARATOR)[STAGE_PHASE_INDEX] === run_ship_stage.PHASE.START
242
254
  }
243
255
 
256
+ // The disposable lines the bound never drops: each issue's newest ship-stage and the run's newest
257
+ // `setup` stage, the ones `run:board` draws from now.
258
+ function drawn_lines(events: ReadonlyArray<RunEvent>): ReadonlySet<RunEvent> {
259
+ const setup = events.findLast((event) => event.kind === EVENT_KIND.SETUP)
260
+
261
+ return new Set([...newest_stages(events), ...(setup === undefined ? [] : [setup])])
262
+ }
263
+
244
264
  // The lines the bound may drop, in the order it drops them: the ones that draw nothing first, then the
245
265
  // ones `run:board` draws from, each group oldest first.
246
266
  function droppable(events: ReadonlyArray<RunEvent>): ReadonlyArray<RunEvent> {
247
- const kept = newest_stages(events)
267
+ const kept = drawn_lines(events)
248
268
  const disposable = events.filter((event) => DISPOSABLE_KINDS.has(event.kind) && !kept.has(event))
249
269
 
250
270
  return [
@@ -261,7 +281,8 @@ function droppable(events: ReadonlyArray<RunEvent>): ReadonlyArray<RunEvent> {
261
281
  //
262
282
  // **Each issue's newest ship-stage is not disposable**: it is the only record `run:board` draws a lane's
263
283
  // review-and-later phase from, and on a stream full of positions it would otherwise roll off in its own
264
- // append.
284
+ // append. **The newest `setup` stage is kept for the same reason**: it is the stage `run:board` draws
285
+ // until the first child launches, and a run that starts on a full stream would otherwise show none.
265
286
  //
266
287
  // **A ship-stage `start` outlasts the other disposable lines**: each one is a phase on the lane's track,
267
288
  // so it is kept past the `done` lines and the heartbeats.
@@ -1,5 +1,6 @@
1
1
  import { telegram_notify } from '#scripts/notify/telegram-notify'
2
2
  import { run_carry, type CarryRead, type RunCarry } from '#scripts/run/carry/run-carry'
3
+ import { run_carry_ended } from '#scripts/run/carry/run-carry-ended'
3
4
  import { run_event_stream } from '#scripts/run/event/run-event-stream'
4
5
  import { run_event_stream_emit } from '#scripts/run/event/run-event-stream-emit'
5
6
  import { run_wake, type RunWake } from '#scripts/run/wake/run-wake'
@@ -27,13 +28,14 @@ import {
27
28
  // so recovery is another actor's.
28
29
  const STRAND_RECOVERY = `Restart supervision with \`${run_stranded.RECOVERY_COMMAND}\` — it wakes a fresh successor to claim the budget; the cutting session must not resume it itself.`
29
30
 
30
- // The seams a test replaces: the two record targets, the two record reads, the owner and supervisor
31
- // liveness probes, and the two side effects. The defaults are the real readers; a test hands fakes so
31
+ // The seams a test replaces: the two record targets, the two record reads, the ended-run read, the owner
32
+ // and supervisor liveness probes, and the two side effects. The defaults are the real readers; a test hands fakes so
32
33
  // the gather and report logic run without a git tree, a process table or a network.
33
34
  interface DetectPorts {
34
35
  resolve_targets: () => Promise<RecordTargets | undefined>
35
36
  read_carry: (target: string) => CarryRead
36
37
  is_owner_live: (carry: RunCarry) => boolean
38
+ has_ended: (target: string, started_at: string) => boolean
37
39
  read_wake: (target: string) => RunWake | undefined
38
40
  supervisor_liveness: (wake: RunWake | undefined) => SupervisorLiveness
39
41
  emit_stranded: (text: string) => Promise<boolean>
@@ -45,15 +47,25 @@ interface RecordTargets {
45
47
  wake: string
46
48
  }
47
49
 
50
+ // The run a supervisor was watching: its carry record, where `run:carry --end` records the run it
51
+ // removes, and the `started_at` the supervisor read when it took the run on.
52
+ interface WatchedRun {
53
+ carry_target: string
54
+ ended_target: string
55
+ started_at: string
56
+ }
57
+
58
+ function targets_of(directory: string): RecordTargets {
59
+ return { carry: run_carry.carry_path(directory), wake: run_wake.wake_path(directory) }
60
+ }
61
+
48
62
  // Both records key on the common git directory, the same one `run-carry.ts` and `run-wake.ts` resolve,
49
63
  // so a strand read from any lane reads the one run's records. `undefined` outside a repository: no git
50
64
  // directory to key on, so no run to be stranded.
51
65
  async function real_resolve_targets(): Promise<RecordTargets | undefined> {
52
66
  const directory = await run_carry.repository_directory()
53
67
 
54
- if (directory === undefined) return undefined
55
-
56
- return { carry: run_carry.carry_path(directory), wake: run_wake.wake_path(directory) }
68
+ return directory === undefined ? undefined : targets_of(directory)
57
69
  }
58
70
 
59
71
  async function real_emit_stranded(text: string): Promise<boolean> {
@@ -68,6 +80,7 @@ const DEFAULT_PORTS: DetectPorts = {
68
80
  resolve_targets: real_resolve_targets,
69
81
  read_carry: (target) => run_carry.read_carry(target),
70
82
  is_owner_live: run_carry.is_owner_live,
83
+ has_ended: run_carry_ended.has_ended,
71
84
  read_wake: (target) => run_wake.read_wake(target),
72
85
  supervisor_liveness: (wake) => run_stranded.supervisor_liveness(wake),
73
86
  emit_stranded: real_emit_stranded,
@@ -77,11 +90,7 @@ const DEFAULT_PORTS: DetectPorts = {
77
90
  // The three facts, assembled behind the carry-record gate. A non-`carried` read — nothing carried, or
78
91
  // an unreadable record — is `undefined`: there is no live budget to be stranded, so the
79
92
  // wake record is never read and the judge is never asked.
80
- async function gather(ports: DetectPorts): Promise<StrandedInput | undefined> {
81
- const targets = await ports.resolve_targets()
82
-
83
- if (targets === undefined) return undefined
84
-
93
+ function input_at(ports: DetectPorts, targets: RecordTargets): StrandedInput | undefined {
85
94
  const read = ports.read_carry(targets.carry)
86
95
 
87
96
  if (read.kind !== 'carried') return undefined
@@ -93,6 +102,12 @@ async function gather(ports: DetectPorts): Promise<StrandedInput | undefined> {
93
102
  }
94
103
  }
95
104
 
105
+ async function gather(ports: DetectPorts): Promise<StrandedInput | undefined> {
106
+ const targets = await ports.resolve_targets()
107
+
108
+ return targets === undefined ? undefined : input_at(ports, targets)
109
+ }
110
+
96
111
  // The marker and the notification, both once per strand episode. `emit_stranded` refuses a second while
97
112
  // the strand event is still the newest, and its `false` is what holds the notification back too — so a
98
113
  // strand polled every stop reaches the person once, not once a turn.
@@ -128,7 +143,38 @@ async function run_stranded_check(): Promise<void> {
128
143
  }
129
144
  }
130
145
 
131
- const run_stranded_detect = { detect_and_report, run_stranded_check }
146
+ // The same judgement for a reader that draws the run rather than reports on it: `run:board` asks it
147
+ // every local read, so a run nobody is driving is not drawn as one still waiting for work.
148
+ function is_stranded_at(directory: string, ports: DetectPorts = DEFAULT_PORTS): boolean {
149
+ const input = input_at(ports, targets_of(directory))
150
+
151
+ return input !== undefined && run_stranded.is_stranded(input)
152
+ }
153
+
154
+ // The judgement a supervisor asks of the run it is about to stop watching. The asker is that
155
+ // supervisor on its way out, so it is taken as gone, and so is an owner that is the asker itself
156
+ // (`run_carry.is_held_by_another`) — that owner is about to be gone. A
157
+ // record another session holds is not left stranded. **A record that does not read is left stranded
158
+ // unless `run:carry --end` ended this very run**: the read that failed the driver fails here too, and
159
+ // that run still has its budget and nobody watching it.
160
+ function is_left_stranded(run: WatchedRun, ports: DetectPorts = DEFAULT_PORTS): boolean {
161
+ const read = ports.read_carry(run.carry_target)
162
+
163
+ if (read.kind !== 'carried') return !ports.has_ended(run.ended_target, run.started_at)
164
+
165
+ return run_stranded.is_stranded({
166
+ read,
167
+ is_owner_live: run_carry.is_held_by_another(read.carry, ports.is_owner_live),
168
+ supervisor: run_stranded.GONE,
169
+ })
170
+ }
171
+
172
+ const run_stranded_detect = {
173
+ detect_and_report,
174
+ is_left_stranded,
175
+ is_stranded_at,
176
+ run_stranded_check,
177
+ }
132
178
 
133
179
  export { run_stranded_detect }
134
- export type { DetectPorts, RecordTargets }
180
+ export type { DetectPorts, RecordTargets, WatchedRun }
@@ -8,8 +8,11 @@ import { cli_flags } from '#scripts/lib/cli-flags'
8
8
  import { poll } from '#scripts/lib/poll'
9
9
  import { telegram_notify } from '#scripts/notify/telegram-notify'
10
10
  import { run_carry, type CarryRead } from '#scripts/run/carry/run-carry'
11
+ import { run_carry_ended } from '#scripts/run/carry/run-carry-ended'
11
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'
12
14
  import { run_headless } from '#scripts/run/run-headless'
15
+ import { run_stranded_detect, type WatchedRun } from '#scripts/run/run-stranded-detect'
13
16
  import { run_wake, type RunWake, type WakeStopReason, type WakeTidyResult } from './run-wake'
14
17
  import { run_wake_describe, type WakeContext } from './run-wake-describe'
15
18
  import { run_wake_driver } from './run-wake-driver'
@@ -145,6 +148,7 @@ async function resolve_context(): Promise<WakeContext | undefined> {
145
148
 
146
149
  return {
147
150
  carry_target: run_carry.carry_path(directory),
151
+ ended_target: run_carry_ended.ended_path(directory),
148
152
  wake_target: run_wake.wake_path(directory),
149
153
  log_target,
150
154
  event_target: run_event_stream.target_of(directory),
@@ -180,10 +184,13 @@ function wake_session(
180
184
  return run_wake_session.launch({ argv: built.argv, ...target, profile }, note_to_stderr)
181
185
  }
182
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.
183
190
  function ports_for(context: WakeContext, profile: AgentProfile): LoopPorts {
184
191
  return {
185
192
  read_carry: () => run_carry.read_carry(context.carry_target),
186
- is_owner_live: (read) => read.kind === 'carried' && run_carry.is_owner_live(read.carry),
193
+ is_owner_live: (read) => read.kind === 'carried' && run_carry.is_held_by_another(read.carry),
187
194
  has_work: async () => true,
188
195
  new_session_id: () => randomUUID(),
189
196
  hand_off: () => {
@@ -211,14 +218,45 @@ function warning_body(stop: LoopStop, body: string, context: WakeContext): strin
211
218
  return [body, log_hint(context), stop.note].filter(Boolean).join('\n')
212
219
  }
213
220
 
214
- async function warn_of_stop(stop: LoopStop, body: string, context: WakeContext): Promise<void> {
215
- await telegram_notify.warn({
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({
216
224
  issue_title: WARNING_TITLE,
217
225
  body: warning_body(stop, body, context),
218
226
  recovery: WARNING_RECOVERY,
219
227
  })
220
228
  }
221
229
 
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
+
222
260
  // **Only a failed wake exits non-zero; `unreadable` warns but exits zero.** The
223
261
  // verdict-and-exit-code table in `docs/josh-commands-automation.md` is a contract callers branch on,
224
262
  // and what that one needs is the notification, not a different exit code.
@@ -234,13 +272,17 @@ function stop_body(reason: WakeStopReason): string | undefined {
234
272
  return STOP_BODIES[reason]
235
273
  }
236
274
 
237
- async function finish(stop: LoopStop, context: WakeContext): Promise<number> {
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> {
238
278
  const body = stop_body(stop.reason)
239
279
 
240
280
  if (body === undefined) return report(stop.reason)
241
281
 
242
282
  console.error(stop.note ?? body)
243
- 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)
244
286
 
245
287
  return report(stop.reason, exit_code_of(stop.reason))
246
288
  }
@@ -336,7 +378,7 @@ async function loop(context: WakeContext, interval_ms: number): Promise<number>
336
378
 
337
379
  tidy_up(context.wake_target)
338
380
 
339
- return await finish(stop, context)
381
+ return await finish(stop, context, read.carry.started_at)
340
382
  }
341
383
 
342
384
  // **The verdict tracks whether the supervisor is actually running, not merely whether a record is
@@ -419,6 +461,9 @@ const run_wake_cli = {
419
461
  SUPERVISING_VERDICT,
420
462
  UNKNOWN_VERDICT,
421
463
  USAGE,
464
+ finish,
465
+ ports_for,
466
+ record_failure,
422
467
  run,
423
468
  stop_body,
424
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.
@@ -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
- if (current.carry.is_handed_off !== true && run_carry.is_owner_live(current.carry)) return false
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
  }
@@ -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.write_stamp(target, { ...read.carry, is_handed_off: true })
24
+ stamp_file.replace_stamp(target, { ...read.carry, is_handed_off: true })
25
25
  }
26
26
 
27
27
  const run_wake_handoff = { hand_off }