@joshuafolkken/kit 1.1229.0 → 1.1240.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-lanes.md +1 -1
- package/.claude/skills/workflow-commands/backlogrun-progress.md +2 -2
- package/.claude/skills/workflow-commands/followup.md +5 -0
- package/.claude/skills/workflow-commands/kickoff.md +1 -0
- package/.claude/skills/workflow-commands/latest-gate.md +1 -1
- package/.github/workflows/publish.yml +2 -0
- package/README.md +30 -31
- package/dist/hooks/batch-guard.js +4 -4
- package/dist/hooks/{chunk-JUDKDCDP.js → chunk-4TZVB44A.js} +8 -8
- package/dist/hooks/{chunk-RNWHN5BP.js → chunk-F3ELQ2AF.js} +3 -3
- package/dist/hooks/{chunk-VC3PZPZO.js → chunk-GPTFMK5E.js} +1 -1
- package/dist/hooks/{chunk-FIVDHQGT.js → chunk-KHGB5S3Y.js} +1 -1
- package/dist/hooks/{chunk-NV2JRZZO.js → chunk-KHX4U3BG.js} +17 -2
- package/dist/hooks/{chunk-F56MFS4B.js → chunk-KRBERBBI.js} +3 -3
- package/dist/hooks/{chunk-TQNPDZNH.js → chunk-QVSWU24O.js} +4 -4
- package/dist/hooks/{chunk-55HHG5PX.js → chunk-WK2XSHSI.js} +284 -63
- package/dist/hooks/{chunk-5VMKCCC4.js → chunk-XZYKEM6J.js} +2 -2
- package/dist/hooks/{chunk-6HJJXRBX.js → chunk-ZQGD4GBY.js} +1 -1
- package/dist/hooks/codex-hook-adapter.js +10 -10
- package/dist/hooks/duplicate-read-guard.js +5 -5
- package/dist/hooks/format-edited.js +2 -2
- package/dist/hooks/inputs.json +172 -170
- package/dist/hooks/investigation-guard.js +5 -5
- package/dist/hooks/pretool-guard.js +9 -9
- package/dist/hooks/stop-guard.js +3 -17
- package/dist/josh.js +279 -63
- package/package.json +1 -1
- package/prompts/collaboration-workflow/issue-template.md +1 -0
- package/scripts/gate/core-admission.ts +162 -0
- package/scripts/gate/core-budget.ts +121 -67
- package/scripts/gate/gate-plan.ts +35 -2
- package/scripts/gate/gate-report.ts +28 -9
- package/scripts/gate/machine-capacity.ts +175 -0
- package/scripts/gate/verification-gate.ts +7 -2
- package/scripts/init/init.ts +5 -1
- package/scripts/init/package-manager-pin.ts +35 -0
- package/scripts/issue/issue-auto-ok.ts +31 -6
- package/scripts/issue/issue-file-cli.ts +31 -8
- package/scripts/issue/issue-file.ts +5 -0
- package/scripts/issue/issue-labels.ts +13 -0
- package/scripts/issue/issue-release-cli.ts +155 -0
- package/scripts/issue/issue-release.ts +58 -0
- package/scripts/josh/josh-command-types.ts +3 -0
- package/scripts/josh/josh-commands-development.ts +8 -0
- package/scripts/josh/josh-commands-issue.ts +8 -1
- package/scripts/josh/josh-commands-lane.ts +2 -1
- package/scripts/josh/josh-commands-maintenance.ts +2 -2
- package/scripts/josh/josh-logic.ts +4 -3
- package/scripts/lane/lane-cli.ts +9 -1
- package/scripts/lane/lane-leftover.ts +43 -29
- package/scripts/lane/lane-open.ts +5 -5
- package/scripts/lane/lane-paths.ts +13 -0
- package/scripts/lane/lane-registry.ts +11 -0
- package/scripts/lane/lane-stray.ts +140 -0
- package/scripts/release/github-release-environment.ts +1 -0
- package/scripts/release/github-release-runs.ts +115 -0
- package/scripts/release/github-release.ts +13 -67
- package/scripts/run/cut/run-cut-cli.ts +16 -3
- package/scripts/run/event/run-event-render.ts +1 -0
- package/scripts/run/event/run-event-stream.ts +4 -0
- package/scripts/run/run-step.ts +5 -2
- package/scripts/run/wake/run-wake-cli.ts +2 -3
- package/scripts/run/wake/run-wake-describe.ts +56 -5
- package/scripts/run/wake/run-wake-loop.ts +5 -13
- package/scripts/run/wake/run-wake.ts +15 -64
- package/scripts/version/latest-corepack.ts +109 -78
- package/templates/workflows/github-release.yml +4 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
// A job skipped by its `if:` (or neutral) belongs to a run that succeeded; counting it would skip a
|
|
4
|
+
// lower tag whose release is still on its way.
|
|
5
|
+
const FAILED_CONCLUSIONS = new Set(['failure', 'cancelled', 'timed_out', 'startup_failure'])
|
|
6
|
+
const RELEASE_RUN_TITLE = 'GitHub Release '
|
|
7
|
+
const RUN_SCHEMA = z.object({
|
|
8
|
+
id: z.number(),
|
|
9
|
+
display_title: z.string(),
|
|
10
|
+
status: z.string().optional(),
|
|
11
|
+
conclusion: z.string().nullable().optional(),
|
|
12
|
+
})
|
|
13
|
+
const JOB_SCHEMA = z.object({
|
|
14
|
+
name: z.string(),
|
|
15
|
+
status: z.string(),
|
|
16
|
+
conclusion: z.string().nullable(),
|
|
17
|
+
})
|
|
18
|
+
const RUNS_SCHEMA = z.object({ workflow_runs: z.array(RUN_SCHEMA) })
|
|
19
|
+
const JOBS_SCHEMA = z.object({ jobs: z.array(JOB_SCHEMA) })
|
|
20
|
+
|
|
21
|
+
type Run = z.infer<typeof RUN_SCHEMA>
|
|
22
|
+
type PollState = 'ready' | 'failed' | 'pending'
|
|
23
|
+
|
|
24
|
+
// `read` answers a GET on a repository API path. `workflow` is the publish workflow and `jobs` the
|
|
25
|
+
// jobs in it whose failure counts (empty: every job). `release_run_workflow` is the workflow that
|
|
26
|
+
// creates a tag's release — the publish workflow itself when its run creates the release.
|
|
27
|
+
interface RunSource {
|
|
28
|
+
read: (path: string) => Promise<unknown>
|
|
29
|
+
workflow: string | undefined
|
|
30
|
+
jobs: ReadonlyArray<string>
|
|
31
|
+
release_run_workflow: string | undefined
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function is_failed_job(job: z.infer<typeof JOB_SCHEMA>, names: ReadonlyArray<string>): boolean {
|
|
35
|
+
if (job.status !== 'completed' || !FAILED_CONCLUSIONS.has(job.conclusion ?? '')) return false
|
|
36
|
+
if (names.length === 0) return true
|
|
37
|
+
|
|
38
|
+
return names.some((name) => job.name === name || job.name.startsWith(`${name} /`))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function list_runs(source: RunSource, workflow_name: string): Promise<ReadonlyArray<Run>> {
|
|
42
|
+
const workflow = encodeURIComponent(workflow_name)
|
|
43
|
+
|
|
44
|
+
return RUNS_SCHEMA.parse(await source.read(`/actions/workflows/${workflow}/runs?per_page=100`))
|
|
45
|
+
.workflow_runs
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function find_run(
|
|
49
|
+
source: RunSource,
|
|
50
|
+
workflow_name: string,
|
|
51
|
+
title: string,
|
|
52
|
+
): Promise<Run | undefined> {
|
|
53
|
+
const runs = await list_runs(source, workflow_name)
|
|
54
|
+
|
|
55
|
+
return runs.find((item) => item.display_title === title)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function has_failed_job(source: RunSource, run_id: number): Promise<boolean> {
|
|
59
|
+
const { jobs } = JOBS_SCHEMA.parse(
|
|
60
|
+
await source.read(`/actions/runs/${String(run_id)}/jobs?per_page=100`),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
return jobs.some((job) => is_failed_job(job, source.jobs))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// A run started before its title named the tag may be at work on any tag.
|
|
67
|
+
function may_release(run: Run, tag: string): boolean {
|
|
68
|
+
if (run.status === 'completed') return false
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
run.display_title === `${RELEASE_RUN_TITLE}${tag}` ||
|
|
72
|
+
!run.display_title.startsWith(RELEASE_RUN_TITLE)
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Unset, nothing tells that a release is over, so the lower tag is waited for.
|
|
77
|
+
async function is_release_over(source: RunSource, tag: string): Promise<boolean> {
|
|
78
|
+
const workflow = source.release_run_workflow
|
|
79
|
+
if (workflow === undefined) return false
|
|
80
|
+
if (workflow === source.workflow) return true
|
|
81
|
+
|
|
82
|
+
const runs = await list_runs(source, workflow)
|
|
83
|
+
|
|
84
|
+
return runs.every((run) => !may_release(run, tag))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// A lower tag whose publication failed, or finished while no release run is left at work on it,
|
|
88
|
+
// never gains a release: a tag cut before joshuafolkken/kit#3138 was fixed would hold every later one.
|
|
89
|
+
async function is_release_lost(source: RunSource, tag: string): Promise<boolean> {
|
|
90
|
+
if (source.workflow === undefined) return false
|
|
91
|
+
|
|
92
|
+
const run = await find_run(source, source.workflow, `Publish ${tag}`)
|
|
93
|
+
if (!run) return false
|
|
94
|
+
if (await has_failed_job(source, run.id)) return true
|
|
95
|
+
|
|
96
|
+
return run.status === 'completed' && (await is_release_over(source, tag))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// A run not listed yet is pending too: the announcement that starts this release also starts the
|
|
100
|
+
// publication, so the release can ask before GitHub has queued that run.
|
|
101
|
+
async function publication_state(
|
|
102
|
+
source: RunSource,
|
|
103
|
+
workflow: string,
|
|
104
|
+
tag: string,
|
|
105
|
+
): Promise<PollState> {
|
|
106
|
+
const run = await find_run(source, workflow, `Publish ${tag}`)
|
|
107
|
+
if (run?.status !== 'completed') return 'pending'
|
|
108
|
+
|
|
109
|
+
return run.conclusion === 'success' ? 'ready' : 'failed'
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const github_release_runs = { is_release_lost, publication_state }
|
|
113
|
+
|
|
114
|
+
export type { PollState, RunSource }
|
|
115
|
+
export { github_release_runs }
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { setTimeout } from 'node:timers/promises'
|
|
2
2
|
import semver from 'semver'
|
|
3
3
|
import { z } from 'zod'
|
|
4
|
+
import { github_release_runs, type PollState, type RunSource } from './github-release-runs'
|
|
4
5
|
|
|
5
6
|
const API_ROOT = 'https://api.github.com/repos'
|
|
6
7
|
const LATEST_PATH = '/releases/latest'
|
|
@@ -8,42 +9,26 @@ const REPOSITORY_PATTERN = /^[\w.-]+\/[\w.-]+$/u
|
|
|
8
9
|
const TAG_PATTERN = /^v\d+\.\d+\.\d+(?:[.-][0-9A-Za-z.-]+)?$/u
|
|
9
10
|
const REQUEST_TIMEOUT_MS = 30_000
|
|
10
11
|
const NOT_FOUND_STATUS = 404
|
|
11
|
-
// A job skipped by its `if:` (or neutral) belongs to a run that succeeded; counting it would skip a
|
|
12
|
-
// lower tag whose release is still on its way.
|
|
13
|
-
const FAILED_CONCLUSIONS = new Set(['failure', 'cancelled', 'timed_out', 'startup_failure'])
|
|
14
12
|
const POLL_INTERVAL_MS = 30_000
|
|
15
13
|
const MAX_WAIT_ATTEMPTS = 120
|
|
16
14
|
const RELEASE_SCHEMA = z.object({ tag_name: z.string().min(1) })
|
|
17
15
|
const NOTES_SCHEMA = z.object({ name: z.string().min(1), body: z.string().min(1) })
|
|
18
|
-
const RUN_SCHEMA = z.object({
|
|
19
|
-
id: z.number(),
|
|
20
|
-
display_title: z.string(),
|
|
21
|
-
status: z.string().optional(),
|
|
22
|
-
conclusion: z.string().nullable().optional(),
|
|
23
|
-
})
|
|
24
|
-
const JOB_SCHEMA = z.object({
|
|
25
|
-
name: z.string(),
|
|
26
|
-
status: z.string(),
|
|
27
|
-
conclusion: z.string().nullable(),
|
|
28
|
-
})
|
|
29
|
-
const RUNS_SCHEMA = z.object({ workflow_runs: z.array(RUN_SCHEMA) })
|
|
30
|
-
const JOBS_SCHEMA = z.object({ jobs: z.array(JOB_SCHEMA) })
|
|
31
|
-
|
|
32
16
|
type ReleaseRequest = (url: string, init: RequestInit) => Promise<Response>
|
|
33
17
|
type Wait = () => Promise<void>
|
|
34
|
-
type PollState = 'ready' | 'failed' | 'pending'
|
|
35
18
|
|
|
36
19
|
// What differs between repositories. `start_tag` is the last tag that predates automatic releases;
|
|
37
20
|
// without it the latest existing release is the floor. `workflow` names the publish workflow whose
|
|
38
21
|
// failed run lets a lower tag be skipped, and `jobs` the jobs in it that count (empty: every job).
|
|
39
22
|
// `await_publish` holds the tag's own release until its run in that workflow has succeeded, for a
|
|
40
|
-
// release started beside the publication rather than from inside it.
|
|
23
|
+
// release started beside the publication rather than from inside it. `release_run_workflow` names
|
|
24
|
+
// the workflow whose run creates a tag's release, so a lower tag left without one can be told lost.
|
|
41
25
|
interface ReleaseSettings {
|
|
42
26
|
repository: string
|
|
43
27
|
start_tag?: string | undefined
|
|
44
28
|
workflow?: string | undefined
|
|
45
29
|
jobs?: ReadonlyArray<string> | undefined
|
|
46
30
|
await_publish?: boolean | undefined
|
|
31
|
+
release_run_workflow?: string | undefined
|
|
47
32
|
}
|
|
48
33
|
|
|
49
34
|
interface PublishOptions extends ReleaseSettings {
|
|
@@ -51,12 +36,10 @@ interface PublishOptions extends ReleaseSettings {
|
|
|
51
36
|
wait?: Wait
|
|
52
37
|
}
|
|
53
38
|
|
|
54
|
-
interface Client {
|
|
39
|
+
interface Client extends Omit<RunSource, 'read'> {
|
|
55
40
|
request: ReleaseRequest
|
|
56
41
|
token: string
|
|
57
42
|
url: string
|
|
58
|
-
workflow: string | undefined
|
|
59
|
-
jobs: ReadonlyArray<string>
|
|
60
43
|
}
|
|
61
44
|
|
|
62
45
|
// `fallback` answers when no tag sits between the floor and the target; without one the floor itself
|
|
@@ -139,59 +122,21 @@ function predecessor(
|
|
|
139
122
|
.toSorted((left, right) => semver.compare(right, left))[0]
|
|
140
123
|
}
|
|
141
124
|
|
|
142
|
-
function
|
|
143
|
-
|
|
144
|
-
if (names.length === 0) return true
|
|
145
|
-
|
|
146
|
-
return names.some((name) => job.name === name || job.name.startsWith(`${name} /`))
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
async function publish_run(
|
|
150
|
-
client: Client,
|
|
151
|
-
workflow_name: string,
|
|
152
|
-
tag: string,
|
|
153
|
-
): Promise<z.infer<typeof RUN_SCHEMA> | undefined> {
|
|
154
|
-
const workflow = encodeURIComponent(workflow_name)
|
|
155
|
-
const runs_response = await api_get(client, `/actions/workflows/${workflow}/runs?per_page=100`)
|
|
156
|
-
const runs = RUNS_SCHEMA.parse(await read_response(runs_response)).workflow_runs
|
|
157
|
-
|
|
158
|
-
return runs.find((item) => item.display_title === `Publish ${tag}`)
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
async function failed_publication(client: Client, tag: string): Promise<boolean> {
|
|
162
|
-
if (client.workflow === undefined) return false
|
|
163
|
-
|
|
164
|
-
const run = await publish_run(client, client.workflow, tag)
|
|
165
|
-
if (!run) return false
|
|
166
|
-
|
|
167
|
-
const jobs_response = await api_get(client, `/actions/runs/${String(run.id)}/jobs?per_page=100`)
|
|
168
|
-
const { jobs } = JOBS_SCHEMA.parse(await read_response(jobs_response))
|
|
169
|
-
|
|
170
|
-
return jobs.some((job) => is_failed_job(job, client.jobs))
|
|
125
|
+
function run_source(client: Client): RunSource {
|
|
126
|
+
return { ...client, read: async (path) => await read_response(await api_get(client, path)) }
|
|
171
127
|
}
|
|
172
128
|
|
|
173
129
|
async function default_wait(): Promise<void> {
|
|
174
130
|
await setTimeout(POLL_INTERVAL_MS)
|
|
175
131
|
}
|
|
176
132
|
|
|
133
|
+
// The lost state is read before the release: a run completes only after creating the release, so a
|
|
134
|
+
// run that completes between the two reads still shows its release to the second one.
|
|
177
135
|
async function release_state(client: Client, tag: string): Promise<PollState> {
|
|
136
|
+
const is_lost = await github_release_runs.is_release_lost(run_source(client), tag)
|
|
178
137
|
if (await existing_release(client, tag)) return 'ready'
|
|
179
|
-
if (await failed_publication(client, tag)) return 'failed'
|
|
180
|
-
|
|
181
|
-
return 'pending'
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
// A run not listed yet is pending too: the announcement that starts this release also starts the
|
|
185
|
-
// publication, so the release can ask before GitHub has queued that run.
|
|
186
|
-
async function publication_state(
|
|
187
|
-
client: Client,
|
|
188
|
-
workflow: string,
|
|
189
|
-
tag: string,
|
|
190
|
-
): Promise<PollState> {
|
|
191
|
-
const run = await publish_run(client, workflow, tag)
|
|
192
|
-
if (run?.status !== 'completed') return 'pending'
|
|
193
138
|
|
|
194
|
-
return
|
|
139
|
+
return is_lost ? 'failed' : 'pending'
|
|
195
140
|
}
|
|
196
141
|
|
|
197
142
|
async function poll(
|
|
@@ -225,7 +170,7 @@ async function wait_for_publication(client: Client, tag: string, budget: Budget)
|
|
|
225
170
|
if (workflow === undefined) throw new Error('Waiting for the publication needs RELEASE_WORKFLOW')
|
|
226
171
|
|
|
227
172
|
return await poll(
|
|
228
|
-
async () => await publication_state(client, workflow, tag),
|
|
173
|
+
async () => await github_release_runs.publication_state(run_source(client), workflow, tag),
|
|
229
174
|
budget,
|
|
230
175
|
`Publish ${tag}`,
|
|
231
176
|
)
|
|
@@ -325,6 +270,7 @@ function create_client(request: ReleaseRequest, token: string, settings: Release
|
|
|
325
270
|
url: `${API_ROOT}/${settings.repository}`,
|
|
326
271
|
workflow: settings.workflow,
|
|
327
272
|
jobs: settings.jobs ?? [],
|
|
273
|
+
release_run_workflow: settings.release_run_workflow,
|
|
328
274
|
}
|
|
329
275
|
}
|
|
330
276
|
|
|
@@ -290,14 +290,27 @@ function announce_handoff(cut_record: RunCut): void {
|
|
|
290
290
|
}
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
-
|
|
293
|
+
// **An implementation resume appends a `resume` event** (joshuafolkken/kit#3375). It clears the record
|
|
294
|
+
// the `cut` event stood for, so the stream is what moves `run:step` back to implementation; a pre-gate
|
|
295
|
+
// resume keeps its record and appends nothing. Best-effort, as the cut's own append is.
|
|
296
|
+
async function emit_resume_event(issue: string, verdict: string): Promise<void> {
|
|
297
|
+
if (verdict !== RESUME_IMPL_VERDICT) return
|
|
298
|
+
|
|
299
|
+
await run_event_stream_emit.emit(run_event_stream.EVENT_KIND.RESUME, `#${issue} resumed`)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function adopt(target: string, cut_record: RunCut): Promise<number> {
|
|
294
303
|
const taken = take_over(target, cut_record)
|
|
295
304
|
|
|
296
305
|
if (taken === undefined) return report_busy(cut_record)
|
|
297
306
|
|
|
298
307
|
announce_handoff(cut_record)
|
|
299
308
|
|
|
300
|
-
|
|
309
|
+
const verdict = resume_verdict_for(cut_record)
|
|
310
|
+
|
|
311
|
+
await emit_resume_event(cut_record.issue, verdict)
|
|
312
|
+
|
|
313
|
+
return report(verdict, SUCCESS_EXIT_CODE)
|
|
301
314
|
}
|
|
302
315
|
|
|
303
316
|
// A relaunched lane child and a fresh session both measure `under`, so only the session that took an
|
|
@@ -329,7 +342,7 @@ async function verify_and_adopt(
|
|
|
329
342
|
|
|
330
343
|
if (is_over_threshold_resume(cut_record)) return report_over()
|
|
331
344
|
|
|
332
|
-
return adopt(target, cut_record)
|
|
345
|
+
return await adopt(target, cut_record)
|
|
333
346
|
}
|
|
334
347
|
|
|
335
348
|
async function resume(target: string, issue: string): Promise<number> {
|
|
@@ -28,6 +28,7 @@ const KIND_LABELS: Readonly<Record<EventKind, KindLabel>> = {
|
|
|
28
28
|
split: { ja: '分割', en: 'split' },
|
|
29
29
|
outage: { ja: '障害', en: 'outage' },
|
|
30
30
|
cut: { ja: 'セッション交代', en: 'session cut' },
|
|
31
|
+
resume: { ja: '実装を再開', en: 'implementation resumed' },
|
|
31
32
|
drain: { ja: 'バックログが空', en: 'backlog drained' },
|
|
32
33
|
stop: { ja: '停止', en: 'stopped' },
|
|
33
34
|
'pr-opened': { ja: 'PR 作成', en: 'PR opened' },
|
|
@@ -48,6 +48,10 @@ const EVENT_KIND = {
|
|
|
48
48
|
SPLIT: 'split',
|
|
49
49
|
OUTAGE: 'outage',
|
|
50
50
|
CUT: 'cut',
|
|
51
|
+
// A fresh session adopted an implementation cut — `run:cut --resume` answered `resume-impl`
|
|
52
|
+
// (joshuafolkken/kit#3375). The resume clears the cut record, so without this the `cut` stayed the
|
|
53
|
+
// newest position and `run:step` kept answering the resume command it had just run.
|
|
54
|
+
RESUME: 'resume',
|
|
51
55
|
// The backlog emptied while nothing of the run's own was in flight — the drain
|
|
52
56
|
// (joshuafolkken/kit#2335). It marks the position at which the end-of-run retrospective is owed,
|
|
53
57
|
// *before* the idle watch opens, so `run:step` fires the retrospective at the drain rather than after
|
package/scripts/run/run-step.ts
CHANGED
|
@@ -236,9 +236,12 @@ function event_action(event: string, issue_number: string, is_lane_child: boolea
|
|
|
236
236
|
}
|
|
237
237
|
|
|
238
238
|
// A run that has planned but emitted nothing else is still at its pre-implementation position, so a
|
|
239
|
-
// `plan` reads the same as an empty stream.
|
|
239
|
+
// `plan` reads the same as an empty stream. A resume from an implementation cut is back at the same
|
|
240
|
+
// position (joshuafolkken/kit#3375): read as the `cut` it follows, it pointed at the spent resume again.
|
|
241
|
+
const IMPLEMENTATION_POSITIONS: ReadonlySet<string> = new Set([KIND.PLAN, KIND.RESUME])
|
|
242
|
+
|
|
240
243
|
function is_pre_implementation(last_event: string | undefined): boolean {
|
|
241
|
-
return last_event === undefined || last_event
|
|
244
|
+
return last_event === undefined || IMPLEMENTATION_POSITIONS.has(last_event)
|
|
242
245
|
}
|
|
243
246
|
|
|
244
247
|
// **No cut at the setup→implementation boundary** (joshuafolkken/kit#2489). A lane child's context is
|
|
@@ -74,9 +74,8 @@ const WARNING_RECOVERY = `Check the wake command, then restart with \`pnpm josh
|
|
|
74
74
|
//
|
|
75
75
|
// **`expired` and `unreadable` were silent until joshuafolkken/kit#1746, and that was the defect
|
|
76
76
|
// rather than the design.** Both end the supervisor while a carry record is still sitting there handed
|
|
77
|
-
// off, so the run is left asleep with nothing anywhere saying so —
|
|
78
|
-
//
|
|
79
|
-
// then only the `failed` reason was wired to the notification.
|
|
77
|
+
// off, so the run is left asleep with nothing anywhere saying so — a silent overnight failure, while
|
|
78
|
+
// only the `failed` reason was wired to the notification.
|
|
80
79
|
// **Every reason is listed, and the silent two are listed as `undefined`.** A partial map would let a
|
|
81
80
|
// sixth stop reason compile straight past this and reach nobody, which is the defect class this
|
|
82
81
|
// section exists to close.
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { agent_role_profile } from '#scripts/agent/agent-role-profile'
|
|
2
2
|
import { gh_spawn } from '#scripts/gh/gh-spawn'
|
|
3
3
|
import { issue_citation } from '#scripts/rules/issue-citation'
|
|
4
|
-
import { run_carry, type CarryRead } from '#scripts/run/carry/run-carry'
|
|
4
|
+
import { run_carry, type CarryRead, type RunCarry } from '#scripts/run/carry/run-carry'
|
|
5
5
|
import { run_event_stream } from '#scripts/run/event/run-event-stream'
|
|
6
6
|
import { run_liveness } from '#scripts/run/run-liveness'
|
|
7
|
-
import { run_wake, type RunWake } from './run-wake'
|
|
7
|
+
import { run_wake, type RunWake, type WakeDecision, type WakeDecisionInput } from './run-wake'
|
|
8
8
|
|
|
9
9
|
// The `--list` presentation of a wake record (joshuafolkken/kit#2407 split it out of `run-wake-cli.ts`,
|
|
10
10
|
// which had reached its line limit). What the command *does* stays in the CLI; how a supervisor's
|
|
@@ -13,6 +13,7 @@ import { run_wake, type RunWake } from './run-wake'
|
|
|
13
13
|
const UNKNOWN_CUTS = 'an unreadable number of'
|
|
14
14
|
const REPO_LOOKUP_TIMEOUT_MS = 10_000
|
|
15
15
|
const STOP_COMMAND = 'pnpm josh run:wake --stop'
|
|
16
|
+
const UNREADABLE_TIME = 'an unreadable time'
|
|
16
17
|
|
|
17
18
|
// The three paths every verb needs: the carry record it reads, its own record, and the work tree a
|
|
18
19
|
// woken session runs in. The work tree is the common git directory's parent, which is the *primary*
|
|
@@ -49,6 +50,55 @@ function outstanding_line(wake: RunWake): string | undefined {
|
|
|
49
50
|
return `${String(wake.attempts)} launch(es) outstanding for the current cut, none claimed yet`
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
// **Why the supervisor is not launching, and until when** (joshuafolkken/kit#3363). A supervisor that
|
|
54
|
+
// waits writes nothing to its log, so before this `--list` showed a stalled run and a waiting one
|
|
55
|
+
// alike. The state is `run_wake.decide`'s own answer rather than a second reading of the record: an
|
|
56
|
+
// `idle_since` mark is the last pass having found no work, so it is fed back in as `has_work: false`.
|
|
57
|
+
function wait_input(wake: RunWake, read: CarryRead, now: Date): WakeDecisionInput {
|
|
58
|
+
return {
|
|
59
|
+
read,
|
|
60
|
+
woke_at: wake.woke_at,
|
|
61
|
+
attempts: wake.attempts ?? 0,
|
|
62
|
+
is_owner_live: read.kind === 'carried' && run_carry.is_owner_live(read.carry, now),
|
|
63
|
+
has_work: wake.idle_since === undefined ? undefined : false,
|
|
64
|
+
idle_since: wake.idle_since,
|
|
65
|
+
now,
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function deadline(marked_at: string | undefined, limit_ms: number): string {
|
|
70
|
+
const marked = Date.parse(marked_at ?? '')
|
|
71
|
+
|
|
72
|
+
return Number.isNaN(marked) ? UNREADABLE_TIME : new Date(marked + limit_ms).toISOString()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// A live owner that is this supervisor is its own driver at work; any other is a session spending the
|
|
76
|
+
// budget, which has no deadline of its own short of the carry record's whole-run expiry.
|
|
77
|
+
function owner_wait(wake: RunWake, carry: RunCarry): string {
|
|
78
|
+
if (carry.owner_pid === wake.pid) {
|
|
79
|
+
return 'waiting: the driver holds the carry record and is running'
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const expiry = deadline(carry.started_at, run_carry.CARRY_MAX_AGE_MS)
|
|
83
|
+
|
|
84
|
+
return `waiting: process ${String(carry.owner_pid)} holds the carry record; the driver starts at its next cut or once it exits, and the record expires at ${expiry}`
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const WAIT_LINES: Partial<Record<WakeDecision['kind'], (wake: RunWake) => string>> = {
|
|
88
|
+
pending: (wake) =>
|
|
89
|
+
`waiting: the woken session has until ${deadline(wake.woke_at, run_wake.WAKE_GRACE_MS)} to claim the carry record`,
|
|
90
|
+
idle: (wake) =>
|
|
91
|
+
`waiting: no runnable work; the driver starts anyway at ${deadline(wake.idle_since, run_wake.IDLE_CEILING_MS)}`,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function wait_line(wake: RunWake, read: CarryRead, now: Date): string | undefined {
|
|
95
|
+
const { kind } = run_wake.decide(wait_input(wake, read, now))
|
|
96
|
+
|
|
97
|
+
if (kind === 'wait' && read.kind === 'carried') return owner_wait(wake, read.carry)
|
|
98
|
+
|
|
99
|
+
return WAIT_LINES[kind]?.(wake)
|
|
100
|
+
}
|
|
101
|
+
|
|
52
102
|
// The run's stream, read here because a headless parent's progress reaches its own transcript alone —
|
|
53
103
|
// after a cut, `--list` is the person's one window onto it (joshuafolkken/kit#1910). Two lines: the
|
|
54
104
|
// newest event (the degenerate last-event read of the stream the watch pane follows, so `--list` and
|
|
@@ -73,16 +123,17 @@ function stream_lines(context: WakeContext): Array<string> {
|
|
|
73
123
|
// **The outstanding line beside it is what makes a shortfall readable**: the count is observed at a
|
|
74
124
|
// poll, so it lags a claim by up to one interval, and launches still outstanding are what say whether
|
|
75
125
|
// a missing wake is one not yet seen or one that never arrived.
|
|
76
|
-
function describe_wake(wake: RunWake, context: WakeContext): string {
|
|
126
|
+
function describe_wake(wake: RunWake, context: WakeContext, now: Date = new Date()): string {
|
|
77
127
|
const live = run_wake.is_supervisor_live(wake) ? 'running' : 'not running'
|
|
78
|
-
const
|
|
128
|
+
const read = run_carry.read_carry(context.carry_target)
|
|
79
129
|
|
|
80
130
|
return [
|
|
81
131
|
`invocation: ${wake.invocation}`,
|
|
82
132
|
`profile: ${wake.profile === undefined ? 'unrecorded' : agent_role_profile.describe(wake.profile)}`,
|
|
83
133
|
`supervisor: process ${String(wake.pid)} (${live}), watching since ${wake.started_at}`,
|
|
84
|
-
`woke ${String(wake.woke)} session(s) across ${
|
|
134
|
+
`woke ${String(wake.woke)} session(s) across ${carry_cuts(read)} cut(s)`,
|
|
85
135
|
outstanding_line(wake),
|
|
136
|
+
wait_line(wake, read, now),
|
|
86
137
|
run_liveness.describe_agent_state(context.log_target),
|
|
87
138
|
`output: ${context.log_target}`,
|
|
88
139
|
// How progress is seen without asking after the cut: the run's stream, followed from here
|
|
@@ -35,9 +35,8 @@ interface LoopPorts {
|
|
|
35
35
|
// The carry record, re-read every pass rather than cached: it is written by the parent loop of a
|
|
36
36
|
// different session, which is the whole point of reading it here.
|
|
37
37
|
read_carry: () => CarryRead
|
|
38
|
-
// Whether the process named on the carry record is still running
|
|
39
|
-
//
|
|
40
|
-
// out the second — `run-wake.ts` → `WakeDecisionInput.is_owner_live`.
|
|
38
|
+
// Whether the process named on the carry record is still running, which tells a session at work
|
|
39
|
+
// from a crashed one — `run-wake.ts` → `WakeDecisionInput.is_owner_live`.
|
|
41
40
|
is_owner_live: (read: CarryRead) => boolean
|
|
42
41
|
// A compatibility probe for the wake decision. Production lets the resident driver inspect work,
|
|
43
42
|
// including an empty idle watch, without starting an AI session.
|
|
@@ -110,17 +109,11 @@ async function wake_step(wake: RunWake, ports: LoopPorts): Promise<StepOutcome>
|
|
|
110
109
|
// A `wait` means the woken session has claimed the carry record, so the wake mark is cleared and the
|
|
111
110
|
// next cut starts the grace window afresh — **and it is the only pass on which `woke` may grow**
|
|
112
111
|
// (joshuafolkken/kit#1746), because arriving here is the supervisor observing that a record it was
|
|
113
|
-
// waiting on has been taken over. A `
|
|
114
|
-
//
|
|
115
|
-
// that predecessor does. A `pending` keeps what is there, because the window it is measured from is
|
|
116
|
-
// still open.
|
|
112
|
+
// waiting on has been taken over. A `pending` keeps what is there, because the window it is measured
|
|
113
|
+
// from is still open.
|
|
117
114
|
function continue_step(wake: RunWake, decision: WakeDecision, ports: LoopPorts): StepOutcome {
|
|
118
115
|
if (decision.kind === 'wait') return { kind: 'continue', wake: run_wake.count_claim(wake) }
|
|
119
116
|
|
|
120
|
-
if (decision.kind === 'hold') {
|
|
121
|
-
return { kind: 'continue', wake: run_wake.mark_wait(wake, ports.now()) }
|
|
122
|
-
}
|
|
123
|
-
|
|
124
117
|
if (decision.kind === 'idle') {
|
|
125
118
|
return { kind: 'continue', wake: run_wake.mark_idle(wake, ports.now()) }
|
|
126
119
|
}
|
|
@@ -146,7 +139,6 @@ function decision_input(wake: RunWake, ports: LoopPorts): WakeDecisionInput {
|
|
|
146
139
|
woke_at: wake.woke_at,
|
|
147
140
|
attempts: wake.attempts ?? NO_ATTEMPTS,
|
|
148
141
|
is_owner_live: ports.is_owner_live(read),
|
|
149
|
-
held_at: wake.held_at,
|
|
150
142
|
has_work: undefined,
|
|
151
143
|
idle_since: wake.idle_since,
|
|
152
144
|
now: ports.now(),
|
|
@@ -155,7 +147,7 @@ function decision_input(wake: RunWake, ports: LoopPorts): WakeDecisionInput {
|
|
|
155
147
|
|
|
156
148
|
// **Work is asked about only where the answer could change the decision** (joshuafolkken/kit#2417):
|
|
157
149
|
// on a pass the record alone would launch from. Every other pass — the in-flight wait, the pending
|
|
158
|
-
// grace window
|
|
150
|
+
// grace window — decides from the record as before and costs no backlog read.
|
|
159
151
|
async function decide_pass(wake: RunWake, ports: LoopPorts): Promise<WakeDecision> {
|
|
160
152
|
const input = decision_input(wake, ports)
|
|
161
153
|
const decision = run_wake.decide(input)
|