@joshuafolkken/kit 1.1379.0 → 1.1387.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/workflow-commands/backlogrun-progress.md +2 -3
- package/.claude/skills/workflow-commands/backlogrun-steps.md +2 -4
- package/.claude/skills/workflow-commands/retrospective.md +1 -2
- package/dist/hooks/batch-guard.js +6 -6
- package/dist/hooks/{chunk-RW4XMF47.js → chunk-2YSW3CXH.js} +133 -234
- package/dist/hooks/{chunk-4RWRP3FF.js → chunk-DCY2GWHC.js} +5 -5
- package/dist/hooks/{chunk-DHSKBAQ2.js → chunk-EAQEGME7.js} +1 -1
- package/dist/hooks/{chunk-E7LZL5AN.js → chunk-EBSHN25W.js} +3 -3
- package/dist/hooks/{chunk-HGA4EG5S.js → chunk-GJ7JPIHN.js} +15 -15
- package/dist/hooks/{chunk-TG2AB3W6.js → chunk-GNDJ7NJ7.js} +88 -501
- package/dist/hooks/{chunk-546X4PX3.js → chunk-IYVOLN6A.js} +3 -3
- package/dist/hooks/{chunk-NBEAGPS4.js → chunk-J3KSS7II.js} +1 -1
- package/dist/hooks/{chunk-7ZM464OW.js → chunk-LMWBRJIP.js} +3 -3
- package/dist/hooks/{chunk-TORRSNDJ.js → chunk-SL4GQ2XP.js} +3 -3
- package/dist/hooks/{chunk-PV3FWDSQ.js → chunk-STDSEPWU.js} +2 -2
- package/dist/hooks/{chunk-YSDAH73S.js → chunk-U32VSRNG.js} +420 -4
- package/dist/hooks/{chunk-QARPP36N.js → chunk-WNFJLBXI.js} +3 -3
- package/dist/hooks/codex-hook-adapter.js +13 -13
- package/dist/hooks/duplicate-read-guard.js +7 -7
- package/dist/hooks/format-edited.js +3 -3
- package/dist/hooks/inputs.json +213 -212
- package/dist/hooks/investigation-guard.js +7 -7
- package/dist/hooks/pretool-guard.js +12 -12
- package/dist/hooks/session-lang.js +65 -9
- package/dist/hooks/stop-guard.js +104 -32
- package/dist/josh.js +2 -2
- package/package.json +1 -1
- package/prompts/collaboration-workflow/upstream-interrupt.md +1 -1
- package/scripts/backlog/backlog-budget-cli.ts +2 -20
- package/scripts/backlog/backlog-budget.ts +72 -109
- package/scripts/backlog/backlog-drive-cli.ts +12 -7
- package/scripts/backlog/backlog-drive-launch.ts +2 -0
- package/scripts/backlog/backlog-drive-owner.ts +72 -5
- package/scripts/backlog/backlog-drive.ts +5 -2
- package/scripts/backlog/backlog-offer-cli.ts +1 -15
- package/scripts/backlog/backlogrun-launch.ts +56 -5
- package/scripts/document/document-byte-budget.ts +1 -1
- package/scripts/epic/epic-candidate-confirm.ts +1 -1
- package/scripts/epic/epic-cross-repo.ts +4 -4
- package/scripts/epic/epic-next.ts +1 -1
- package/scripts/hooks/stop-guard.ts +20 -9
- package/scripts/init/start-plan.ts +15 -4
- package/scripts/init/start-steps.ts +31 -3
- package/scripts/init/start.ts +20 -13
- package/scripts/josh/josh-commands-ai.ts +1 -1
- package/scripts/josh/josh-commands-project.ts +1 -1
- package/scripts/lane/lane-cli.ts +1 -3
- package/scripts/run/board/run-board-header.ts +137 -88
- package/scripts/run/board/run-board-labels.ts +18 -17
- package/scripts/run/board/run-board-machine.ts +21 -8
- package/scripts/run/board/run-board-read.ts +9 -2
- package/scripts/run/board/run-board-render.ts +1 -5
- package/scripts/run/board/run-board-setup.ts +73 -0
- package/scripts/run/board/run-board-span.ts +49 -8
- package/scripts/run/board/run-board-status.ts +10 -23
- package/scripts/run/board/run-board-tick.ts +11 -2
- package/scripts/run/carry/run-carry-added.ts +1 -1
- package/scripts/run/carry/run-carry-change.ts +1 -1
- package/scripts/run/carry/run-carry-cli.ts +36 -60
- package/scripts/run/carry/run-carry-ended.ts +10 -2
- package/scripts/run/carry/run-carry.ts +38 -68
- package/scripts/run/cut/run-cut.ts +4 -4
- package/scripts/run/event/run-event-render.ts +1 -1
- package/scripts/run/event/run-event-scope.ts +4 -8
- package/scripts/run/event/run-event-setup.ts +47 -0
- package/scripts/run/event/run-event-stream-emit.ts +0 -25
- package/scripts/run/event/run-event-stream.ts +41 -28
- package/scripts/run/merge/run-merge-collect.ts +1 -1
- package/scripts/run/merge/run-merge-steps.ts +1 -1
- package/scripts/run/progress/run-progress-read.ts +1 -1
- package/scripts/run/run-headless.ts +2 -2
- package/scripts/run/run-status-cli.ts +1 -5
- package/scripts/run/run-step.ts +2 -9
- package/scripts/run/run-stop-notify.ts +1 -1
- package/scripts/run/run-stranded-detect.ts +61 -15
- package/scripts/run/run-stranded.ts +25 -17
- package/scripts/run/wake/run-wake-cli.ts +58 -15
- package/scripts/run/wake/run-wake-describe.ts +5 -5
- package/scripts/run/wake/run-wake-driver.ts +13 -4
- package/scripts/run/wake/run-wake-handoff.ts +1 -1
- package/scripts/run/wake/run-wake-loop.ts +4 -21
- package/scripts/run/wake/run-wake-restart.ts +1 -1
- package/scripts/run/wake/run-wake.ts +6 -7
- package/scripts/backlog/backlog-idle.ts +0 -88
|
@@ -50,12 +50,12 @@ const { PRE_GATE_PHASE, IMPLEMENTATION_PHASE } = agent_role_profile
|
|
|
50
50
|
// `pnpm josh cost --cut` hand-off uses (`backlogrun-progress.md` → "The hand-off"). The shared
|
|
51
51
|
// threshold prevents the scheduler and worker boundaries from drifting apart.
|
|
52
52
|
const IMPLEMENTATION_CONTEXT_THRESHOLD = CONTEXT_CUT_THRESHOLD
|
|
53
|
-
// **The
|
|
54
|
-
//
|
|
53
|
+
// **The bound is `backlog-budget.ts`'s idle wait, imported rather than restated**, so the eight hours
|
|
54
|
+
// stay written once. A resume is expected within seconds of the cut, so this age is only a backstop
|
|
55
55
|
// against a fresh process that never started; an expired record is cleared and replaced by the next
|
|
56
56
|
// cut rather than blocking it.
|
|
57
|
-
const CUT_MAX_AGE_MS = backlog_budget.
|
|
58
|
-
const CUT_MAX_AGE_HOURS = backlog_budget.
|
|
57
|
+
const CUT_MAX_AGE_MS = backlog_budget.DEFAULT_IDLE_MS
|
|
58
|
+
const CUT_MAX_AGE_HOURS = backlog_budget.IDLE_WAIT_HOURS
|
|
59
59
|
|
|
60
60
|
const END_COMMAND = 'pnpm josh run:cut --end'
|
|
61
61
|
const READ_COMMAND = 'pnpm josh run:cut --json'
|
|
@@ -39,12 +39,12 @@ const KIND_LABELS: Readonly<Record<EventKind, KindLabel>> = {
|
|
|
39
39
|
heartbeat: { ja: '生存確認', en: 'heartbeat' },
|
|
40
40
|
'ship-launch': { ja: '出荷を開始', en: 'ship launched' },
|
|
41
41
|
'ship-stop': { ja: '出荷が停止', en: 'ship stopped' },
|
|
42
|
-
idle: { ja: '新着待ち', en: 'idle watch' },
|
|
43
42
|
filed: { ja: '起票', en: 'filed' },
|
|
44
43
|
note: { ja: '意見', en: 'note' },
|
|
45
44
|
'lane-phase': { ja: 'レーンの段階', en: 'lane phase' },
|
|
46
45
|
add: { ja: '割り込み', en: 'added' },
|
|
47
46
|
'lane-limit': { ja: 'レーン上限', en: 'lane limit' },
|
|
47
|
+
setup: { ja: '準備', en: 'setup' },
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
const LABELS_BY_KIND: ReadonlyMap<string, KindLabel> = new Map(Object.entries(KIND_LABELS))
|
|
@@ -55,16 +55,12 @@ function scoped_events(
|
|
|
55
55
|
return events.filter((event) => is_within(event, floor))
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
// The scope the run's own carry record implies. A record that reads at all names the invocation's start,
|
|
59
|
-
//
|
|
60
|
-
// so rounding it to unknown would withhold the scope from exactly the long runs that most need one. Only an
|
|
61
|
-
// absent or unreadable record leaves the scope undetermined.
|
|
58
|
+
// The scope the run's own carry record implies. A record that reads at all names the invocation's start,
|
|
59
|
+
// however long ago that was; only an absent or unreadable record leaves the scope undetermined.
|
|
62
60
|
function scope_of(read: CarryRead): EventScope {
|
|
63
|
-
if (read.kind
|
|
64
|
-
return { kind: SINCE_SCOPE, started_at: read.carry.started_at }
|
|
65
|
-
}
|
|
61
|
+
if (read.kind !== 'carried') return UNKNOWN_EVENT_SCOPE
|
|
66
62
|
|
|
67
|
-
return
|
|
63
|
+
return { kind: SINCE_SCOPE, started_at: read.carry.started_at }
|
|
68
64
|
}
|
|
69
65
|
|
|
70
66
|
// The newest event within scope, or `undefined` when the scope could not be determined or nothing falls
|
|
@@ -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 }
|
|
@@ -70,30 +70,6 @@ async function emit_once(kind: string, text: string): Promise<boolean> {
|
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
/**
|
|
74
|
-
* Append one event unless the newest event of this kind already carries this text — for a marker whose
|
|
75
|
-
* value, not its presence, is the news. The idle window is re-derived on every
|
|
76
|
-
* offer of a watching loop; only a window that moved is worth a line. Returns whether it appended.
|
|
77
|
-
*/
|
|
78
|
-
async function emit_changed(kind: string, text: string): Promise<boolean> {
|
|
79
|
-
try {
|
|
80
|
-
const target = await stream_target()
|
|
81
|
-
|
|
82
|
-
if (target === undefined) return false
|
|
83
|
-
|
|
84
|
-
const newest = run_event_stream.read_events(target).findLast((event) => event.kind === kind)
|
|
85
|
-
|
|
86
|
-
if (newest?.text === text) return false
|
|
87
|
-
|
|
88
|
-
return run_event_stream.append(target, kind, text, now_iso()).appended
|
|
89
|
-
} catch (error) {
|
|
90
|
-
// Best-effort: a failed append is dropped rather than raised into the loop's work.
|
|
91
|
-
error_text.trace_swallowed('run_event_stream_emit.emit_changed', error)
|
|
92
|
-
|
|
93
|
-
return false
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
73
|
// The events of the invocation now running: the stream outlives every invocation, so a marker a previous
|
|
98
74
|
// run left — a stall that ended with no dispatch after it — must not hold back this run's first one. An
|
|
99
75
|
// undetermined scope (no carry record) keeps the whole stream, the side that under-notifies rather than
|
|
@@ -187,7 +163,6 @@ const run_event_stream_emit = {
|
|
|
187
163
|
all_events,
|
|
188
164
|
current_events,
|
|
189
165
|
emit,
|
|
190
|
-
emit_changed,
|
|
191
166
|
emit_heartbeat,
|
|
192
167
|
emit_once,
|
|
193
168
|
emit_once_since,
|
|
@@ -29,7 +29,7 @@ const EVENT_PREFIX = 'josh-run-events-'
|
|
|
29
29
|
// every reader — a person, an editor — that it is a line stream rather than a single document, the same
|
|
30
30
|
// distinction the ambient `.log` draws.
|
|
31
31
|
const EVENT_SUFFIX = '.jsonl'
|
|
32
|
-
// A run's session-facing events over
|
|
32
|
+
// A run's session-facing events over one unattended night: a plan, a launch/PR/review-round/merge
|
|
33
33
|
// per child across a few dozen children, plus parks, cuts and stops. Five hundred keeps the whole of a
|
|
34
34
|
// large `backlogrun` and still bounds an unattended run — past it the oldest events roll off.
|
|
35
35
|
const EVENT_CAP = 500
|
|
@@ -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.
|
|
@@ -106,11 +107,6 @@ const EVENT_KIND = {
|
|
|
106
107
|
// The position that hands control back: `run:step` prints the command that
|
|
107
108
|
// shows the stopped report, so the agent fixes it and relaunches rather than rebuilding the context.
|
|
108
109
|
SHIP_STOP: 'ship-stop',
|
|
109
|
-
// The idle watch's window — when the run began waiting on an empty backlog and when that wait ends
|
|
110
|
-
// `backlog:offer` writes it whenever the window changes, so `run:board` reads
|
|
111
|
-
// the deadline rather than guessing it. It is a trace: `emit_once`'s drain dedup and `run:step`'s
|
|
112
|
-
// position both read past it, so a watching loop never re-marks the drain it is waiting after.
|
|
113
|
-
IDLE: 'idle',
|
|
114
110
|
// An Issue the run filed on its way, written by `issue:file`, so a run started from the command line
|
|
115
111
|
// still surfaces what it found — `run:board`'s findings section reads it.
|
|
116
112
|
FILED: 'filed',
|
|
@@ -129,6 +125,11 @@ const EVENT_KIND = {
|
|
|
129
125
|
// arrival probe reads it as a wake: the lanes a raise frees are new room for a pool its baseline
|
|
130
126
|
// already holds. A trace: it says the run gained room, not where the run is.
|
|
131
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',
|
|
132
133
|
} as const
|
|
133
134
|
|
|
134
135
|
type EventKind = (typeof EVENT_KIND)[keyof typeof EVENT_KIND]
|
|
@@ -141,22 +142,23 @@ const EVENT_KINDS: ReadonlyArray<string> = Object.values(EVENT_KIND)
|
|
|
141
142
|
const TRACE_KINDS: ReadonlySet<string> = new Set([
|
|
142
143
|
EVENT_KIND.SHIP_STAGE,
|
|
143
144
|
EVENT_KIND.HEARTBEAT,
|
|
144
|
-
EVENT_KIND.IDLE,
|
|
145
145
|
EVENT_KIND.FILED,
|
|
146
146
|
EVENT_KIND.NOTE,
|
|
147
147
|
EVENT_KIND.LANE_PHASE,
|
|
148
148
|
EVENT_KIND.ADD,
|
|
149
149
|
EVENT_KIND.LANE_LIMIT,
|
|
150
|
+
EVENT_KIND.SETUP,
|
|
150
151
|
])
|
|
151
152
|
|
|
152
153
|
// The trace kinds that say only that the run was alive, so the bound rolls them off first. A `filed` or
|
|
153
154
|
// `note` line is a trace to the position readers but is the board's record of what the run found, so
|
|
154
|
-
// it rolls off only with the positions. A ship-stage `start`
|
|
155
|
-
//
|
|
155
|
+
// it rolls off only with the positions. A ship-stage `start` is disposable but goes last of them:
|
|
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.
|
|
156
158
|
const DISPOSABLE_KINDS: ReadonlySet<string> = new Set([
|
|
157
159
|
EVENT_KIND.SHIP_STAGE,
|
|
158
160
|
EVENT_KIND.HEARTBEAT,
|
|
159
|
-
EVENT_KIND.
|
|
161
|
+
EVENT_KIND.SETUP,
|
|
160
162
|
])
|
|
161
163
|
|
|
162
164
|
const event_schema = z.object({
|
|
@@ -200,9 +202,10 @@ function parse_line(line: string): RunEvent | undefined {
|
|
|
200
202
|
return event_schema.safeParse(json_value.parse_or_undefined(line)).data
|
|
201
203
|
}
|
|
202
204
|
|
|
203
|
-
// Every
|
|
204
|
-
// fail-quiet direction `stamp_file.read_stamp_text` takes for a planted or
|
|
205
|
-
|
|
205
|
+
// Every line on the stream that parses, oldest first, whatever its kind. An absent or unreadable file
|
|
206
|
+
// is an empty stream, the same fail-quiet direction `stamp_file.read_stamp_text` takes for a planted or
|
|
207
|
+
// unowned file.
|
|
208
|
+
function read_lines(target: string): ReadonlyArray<RunEvent> {
|
|
206
209
|
const raw = stamp_file.read_stamp_text(target)
|
|
207
210
|
|
|
208
211
|
if (raw === undefined) return []
|
|
@@ -215,6 +218,13 @@ function read_events(target: string): ReadonlyArray<RunEvent> {
|
|
|
215
218
|
.filter(is_present)
|
|
216
219
|
}
|
|
217
220
|
|
|
221
|
+
// Every event on the stream, oldest first. A line whose kind the enumeration no longer names — one a
|
|
222
|
+
// previous version wrote — reads as no event, so no reader takes it for a position or for the run's
|
|
223
|
+
// newest activity.
|
|
224
|
+
function read_events(target: string): ReadonlyArray<RunEvent> {
|
|
225
|
+
return read_lines(target).filter((event) => is_event_kind(event.kind))
|
|
226
|
+
}
|
|
227
|
+
|
|
218
228
|
function last_position(events: ReadonlyArray<RunEvent>): number {
|
|
219
229
|
return events[events.length - POSITION_INCREMENT]?.pos ?? FIRST_POSITION
|
|
220
230
|
}
|
|
@@ -243,21 +253,23 @@ function is_stage_start(event: RunEvent): boolean {
|
|
|
243
253
|
return event.text.split(WORD_SEPARATOR)[STAGE_PHASE_INDEX] === run_ship_stage.PHASE.START
|
|
244
254
|
}
|
|
245
255
|
|
|
246
|
-
//
|
|
247
|
-
// `
|
|
248
|
-
function
|
|
249
|
-
|
|
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])])
|
|
250
262
|
}
|
|
251
263
|
|
|
252
264
|
// The lines the bound may drop, in the order it drops them: the ones that draw nothing first, then the
|
|
253
265
|
// ones `run:board` draws from, each group oldest first.
|
|
254
266
|
function droppable(events: ReadonlyArray<RunEvent>): ReadonlyArray<RunEvent> {
|
|
255
|
-
const kept =
|
|
267
|
+
const kept = drawn_lines(events)
|
|
256
268
|
const disposable = events.filter((event) => DISPOSABLE_KINDS.has(event.kind) && !kept.has(event))
|
|
257
269
|
|
|
258
270
|
return [
|
|
259
|
-
...disposable.filter((event) => !
|
|
260
|
-
...disposable.filter((event) =>
|
|
271
|
+
...disposable.filter((event) => !is_stage_start(event)),
|
|
272
|
+
...disposable.filter((event) => is_stage_start(event)),
|
|
261
273
|
]
|
|
262
274
|
}
|
|
263
275
|
|
|
@@ -269,11 +281,11 @@ function droppable(events: ReadonlyArray<RunEvent>): ReadonlyArray<RunEvent> {
|
|
|
269
281
|
//
|
|
270
282
|
// **Each issue's newest ship-stage is not disposable**: it is the only record `run:board` draws a lane's
|
|
271
283
|
// review-and-later phase from, and on a stream full of positions it would otherwise roll off in its own
|
|
272
|
-
// 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.
|
|
273
286
|
//
|
|
274
287
|
// **A ship-stage `start` outlasts the other disposable lines**: each one is a phase on the lane's track,
|
|
275
|
-
// so it is kept past the `done` lines and the heartbeats.
|
|
276
|
-
// so a run's newest wait is not the first line a stream of starts and positions gives up.
|
|
288
|
+
// so it is kept past the `done` lines and the heartbeats.
|
|
277
289
|
//
|
|
278
290
|
// **The kept line carries the history**: on a stream the positions fill, every append still rolls off
|
|
279
291
|
// the issue's previous stage line, so the newest one lists every stage its
|
|
@@ -293,11 +305,12 @@ function serialize(events: ReadonlyArray<RunEvent>): string {
|
|
|
293
305
|
}
|
|
294
306
|
|
|
295
307
|
// The read-modify-write, run only while the stream's lock is held. `replace_text_stamp` publishes the
|
|
296
|
-
// rewrite with a rename, so a reader never sees the stream absent or half-written.
|
|
308
|
+
// rewrite with a rename, so a reader never sees the stream absent or half-written. The position counts
|
|
309
|
+
// on from every line the file holds, so a line of a retired kind — dropped by this rewrite — never
|
|
310
|
+
// hands its position to a new event.
|
|
297
311
|
function append_held(target: string, event: Omit<RunEvent, 'pos'>): number {
|
|
298
|
-
const
|
|
299
|
-
const
|
|
300
|
-
const events = bounded([...existing, { pos: position, ...event }])
|
|
312
|
+
const position = last_position(read_lines(target)) + POSITION_INCREMENT
|
|
313
|
+
const events = bounded([...read_events(target), { pos: position, ...event }])
|
|
301
314
|
|
|
302
315
|
stamp_file.replace_text_stamp(target, serialize(events))
|
|
303
316
|
|
|
@@ -93,7 +93,7 @@ async function collect_carried(carry: RunCarry): Promise<void> {
|
|
|
93
93
|
async function collect_merged(directory: string): Promise<void> {
|
|
94
94
|
const read = run_carry.read_carry(run_carry.carry_path(directory))
|
|
95
95
|
|
|
96
|
-
if (read.kind !== 'carried'
|
|
96
|
+
if (read.kind !== 'carried') return
|
|
97
97
|
|
|
98
98
|
try {
|
|
99
99
|
await collect_carried(read.carry)
|
|
@@ -102,7 +102,7 @@ async function read_record(): Promise<{ target: string; carry: RunCarry } | unde
|
|
|
102
102
|
const target = run_carry.carry_path(directory)
|
|
103
103
|
const read = run_carry.read_carry(target)
|
|
104
104
|
|
|
105
|
-
if (read.kind !== 'carried'
|
|
105
|
+
if (read.kind !== 'carried') return undefined
|
|
106
106
|
|
|
107
107
|
return { target, carry: read.carry }
|
|
108
108
|
}
|
|
@@ -123,7 +123,7 @@ function is_hold_present(read: HoldRead): boolean {
|
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
function is_carry_present(read: CarryRead): boolean {
|
|
126
|
-
return read.kind === 'carried'
|
|
126
|
+
return read.kind === 'carried'
|
|
127
127
|
}
|
|
128
128
|
|
|
129
129
|
function hold_present(worktree: string | undefined): boolean {
|
|
@@ -44,8 +44,8 @@ function is_cut_capped(carry: RunCarry, source: EnvironmentSource = process.env)
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
// Only a live, un-handed-off record binds the session to its lanes. After `run:carry --cut` the next
|
|
47
|
-
// successor drives them, and after `--end` (`none`)
|
|
48
|
-
//
|
|
47
|
+
// successor drives them, and after `--end` (`none`) there is no run left to drive — ending the turn is
|
|
48
|
+
// correct in both. An unreadable record fails open, as every stop
|
|
49
49
|
// rule does (`stop-guard.ts`).
|
|
50
50
|
function is_driving(read: CarryRead | undefined): boolean {
|
|
51
51
|
return read?.kind === 'carried' && read.carry.is_handed_off !== true
|
|
@@ -25,7 +25,6 @@ const USAGE = 'Usage: josh run:status <issue-number> [--repo <owner/repo>]'
|
|
|
25
25
|
const CARRY_NONE_NOTE = 'no run recorded here'
|
|
26
26
|
const CARRY_UNREADABLE_NOTE = '(run record not bundled: unreadable)'
|
|
27
27
|
const CARRY_SUMMARY_NONE = 'none'
|
|
28
|
-
const CARRY_SUMMARY_EXPIRED = 'expired'
|
|
29
28
|
const CARRY_SUMMARY_UNREADABLE = 'unreadable'
|
|
30
29
|
const MERGED_SUFFIX = ' merged'
|
|
31
30
|
|
|
@@ -94,16 +93,13 @@ function failure_note(issue_number: string, kind: string): string {
|
|
|
94
93
|
|
|
95
94
|
function carry_summary(carry: CarryRead): string {
|
|
96
95
|
if (carry.kind === 'carried') return `${String(carry.carry.merged)}${MERGED_SUFFIX}`
|
|
97
|
-
if (carry.kind === 'expired') return CARRY_SUMMARY_EXPIRED
|
|
98
96
|
if (carry.kind === 'unreadable') return CARRY_SUMMARY_UNREADABLE
|
|
99
97
|
|
|
100
98
|
return CARRY_SUMMARY_NONE
|
|
101
99
|
}
|
|
102
100
|
|
|
103
101
|
function carry_detail(carry: CarryRead): string {
|
|
104
|
-
if (carry.kind === 'carried'
|
|
105
|
-
return run_carry.describe_carry(carry.carry)
|
|
106
|
-
}
|
|
102
|
+
if (carry.kind === 'carried') return run_carry.describe_carry(carry.carry)
|
|
107
103
|
|
|
108
104
|
if (carry.kind === 'unreadable') return CARRY_UNREADABLE_NOTE
|
|
109
105
|
|
package/scripts/run/run-step.ts
CHANGED
|
@@ -46,11 +46,6 @@ const VOCABULARY: ReadonlyArray<string> = [
|
|
|
46
46
|
UNKNOWN,
|
|
47
47
|
]
|
|
48
48
|
|
|
49
|
-
// The whole-run bound is spent, and carrying the budget or discarding it is a person's call — the one
|
|
50
|
-
// Tier-B point this machine surfaces rather than choosing.
|
|
51
|
-
const EXPIRED_DECISION =
|
|
52
|
-
'decide: whole-run budget spent — resume with `pnpm josh run:carry --resume` | discard with `pnpm josh run:carry --end`'
|
|
53
|
-
|
|
54
49
|
// The one step a stopped run still owes before it ends: the end-of-run retrospective.
|
|
55
50
|
// It reads the run's own measurements and files the improvements worth carrying into the next run;
|
|
56
51
|
// the command is kit-only, so it means nothing — and is never printed — in a consumer project.
|
|
@@ -288,12 +283,11 @@ function is_handed_off_parent(input: CarryFacts): boolean {
|
|
|
288
283
|
return input.is_handed_off && input.carry_kind === 'carried' && !input.is_lane_child
|
|
289
284
|
}
|
|
290
285
|
|
|
291
|
-
// The carry record's terminal answers: an unreadable record leaves the position unknowable, a
|
|
292
|
-
// run is its successor's
|
|
286
|
+
// The carry record's terminal answers: an unreadable record leaves the position unknowable, and a
|
|
287
|
+
// handed-off run is its successor's.
|
|
293
288
|
function carry_terminal_action(input: CarryFacts): StepAction | undefined {
|
|
294
289
|
if (input.carry_kind === 'unreadable') return verdict(UNKNOWN)
|
|
295
290
|
if (is_handed_off_parent(input)) return verdict(STOP)
|
|
296
|
-
if (input.carry_kind === 'expired') return { kind: 'decide', line: EXPIRED_DECISION }
|
|
297
291
|
|
|
298
292
|
return undefined
|
|
299
293
|
}
|
|
@@ -371,7 +365,6 @@ function next_action(input: StepInput): StepAction {
|
|
|
371
365
|
|
|
372
366
|
const run_step = {
|
|
373
367
|
ALREADY_DONE,
|
|
374
|
-
EXPIRED_DECISION,
|
|
375
368
|
HAND_OFF_COMMAND,
|
|
376
369
|
HUMAN_REVIEW,
|
|
377
370
|
IMPLEMENT,
|
|
@@ -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'
|
|
31
|
+
if (read.kind !== 'carried') return undefined
|
|
32
32
|
|
|
33
33
|
return { reason }
|
|
34
34
|
}
|
|
@@ -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'
|
|
@@ -18,7 +19,7 @@ import {
|
|
|
18
19
|
//
|
|
19
20
|
// **Cheap and local.** Every read is a file on disk — the carry record, the wake record — and a process
|
|
20
21
|
// 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
|
|
22
|
+
// time. The gate is the carry record itself: nothing carried, or a record that is unreadable,
|
|
22
23
|
// returns before the wake record is even read.
|
|
23
24
|
|
|
24
25
|
// The recovery line names the command that hands the run to a fresh supervisor — the same one the pure
|
|
@@ -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,20 +80,17 @@ 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,
|
|
74
87
|
notify_stranded: real_notify_stranded,
|
|
75
88
|
}
|
|
76
89
|
|
|
77
|
-
// The three facts, assembled behind the carry-record gate. A non-`carried` read — nothing carried,
|
|
78
|
-
//
|
|
90
|
+
// The three facts, assembled behind the carry-record gate. A non-`carried` read — nothing carried, or
|
|
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 }
|
|
@@ -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
|
|
6
|
-
// successor
|
|
7
|
-
// read and never weighed, are what say that state has been
|
|
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
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
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 {
|