@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.
- package/dist/hooks/batch-guard.js +6 -6
- package/dist/hooks/{chunk-4MGHBTOM.js → chunk-2YSW3CXH.js} +8 -2
- package/dist/hooks/{chunk-KCF2YE7O.js → chunk-DCY2GWHC.js} +5 -5
- package/dist/hooks/{chunk-KC6LUZZ2.js → chunk-EAQEGME7.js} +1 -1
- package/dist/hooks/{chunk-LC56R53A.js → chunk-EBSHN25W.js} +3 -3
- package/dist/hooks/{chunk-6MAW7BCM.js → chunk-GJ7JPIHN.js} +14 -14
- package/dist/hooks/{chunk-6AC4C2IO.js → chunk-GNDJ7NJ7.js} +59 -462
- package/dist/hooks/{chunk-LOJTBU5V.js → chunk-IYVOLN6A.js} +3 -3
- package/dist/hooks/{chunk-Y5R47QHU.js → chunk-J3KSS7II.js} +1 -1
- package/dist/hooks/{chunk-RYP3TSUJ.js → chunk-LMWBRJIP.js} +3 -3
- package/dist/hooks/{chunk-TT742SIO.js → chunk-SL4GQ2XP.js} +2 -2
- package/dist/hooks/{chunk-YKUITFT7.js → chunk-STDSEPWU.js} +2 -2
- package/dist/hooks/{chunk-2X2ART5G.js → chunk-U32VSRNG.js} +420 -4
- package/dist/hooks/{chunk-7VSZ2N4B.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 +208 -206
- 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 +91 -23
- package/dist/josh.js +1 -1
- package/package.json +1 -1
- package/scripts/backlog/backlog-drive-cli.ts +2 -4
- package/scripts/backlog/backlog-drive-launch.ts +2 -0
- package/scripts/backlog/backlog-drive-owner.ts +72 -5
- package/scripts/backlog/backlogrun-launch.ts +56 -5
- package/scripts/document/document-byte-budget.ts +1 -1
- 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-project.ts +1 -1
- package/scripts/run/board/run-board-header.ts +39 -13
- package/scripts/run/board/run-board-read.ts +9 -2
- package/scripts/run/board/run-board-setup.ts +73 -0
- package/scripts/run/board/run-board-span.ts +49 -7
- package/scripts/run/board/run-board-status.ts +10 -6
- package/scripts/run/board/run-board-tick.ts +10 -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 +37 -7
- package/scripts/run/carry/run-carry-ended.ts +9 -1
- package/scripts/run/carry/run-carry.ts +16 -0
- package/scripts/run/event/run-event-render.ts +1 -0
- package/scripts/run/event/run-event-setup.ts +47 -0
- package/scripts/run/event/run-event-stream.ts +26 -5
- package/scripts/run/run-stranded-detect.ts +58 -12
- package/scripts/run/wake/run-wake-cli.ts +51 -6
- package/scripts/run/wake/run-wake-describe.ts +2 -0
- package/scripts/run/wake/run-wake-driver.ts +10 -1
- 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
|
-
|
|
155
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
24
|
+
stamp_file.replace_stamp(target, { ...read.carry, is_handed_off: true })
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
const run_wake_handoff = { hand_off }
|