@joshuafolkken/kit 1.1128.0 → 1.1136.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/agents/investigator.md +17 -0
- package/.claude/settings.json +1 -0
- package/.claude/skills/workflow-commands/backlogrun-progress.md +2 -2
- package/.claude/skills/workflow-commands/delegation.md +5 -1
- package/SECURITY.md +1 -0
- package/dist/hooks/batch-guard.js +4 -4
- package/dist/hooks/{chunk-77J7IEQM.js → chunk-FO2I7UT7.js} +1 -1
- package/dist/hooks/{chunk-4JZDP5X5.js → chunk-GDU26K3J.js} +9 -1
- package/dist/hooks/{chunk-QVLT52OV.js → chunk-HANSKHES.js} +2 -2
- package/dist/hooks/{chunk-5SD2YM7Z.js → chunk-HBKZLITF.js} +4 -4
- package/dist/hooks/{chunk-AIOIOMPX.js → chunk-JKKTVDAG.js} +128 -61
- package/dist/hooks/{chunk-M7QKEKWO.js → chunk-KBXZLMRH.js} +9 -28
- package/dist/hooks/{chunk-XYUIQOW3.js → chunk-KOKOAV6D.js} +2 -2
- package/dist/hooks/{chunk-NXYMNPUQ.js → chunk-LJBLGHEH.js} +59 -19
- package/dist/hooks/{chunk-MKVGBQPA.js → chunk-NFZGFGOR.js} +1 -1
- package/dist/hooks/{chunk-7HVB7SZG.js → chunk-RFWWUI5F.js} +4 -4
- 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 +199 -198
- package/dist/hooks/investigation-guard.js +5 -5
- package/dist/hooks/pretool-guard.js +9 -9
- package/dist/hooks/stop-guard.js +3 -3
- package/dist/josh.js +8 -1
- package/package.json +6 -4
- package/scripts/delegation/delegation-policy.ts +9 -1
- package/scripts/delegation/investigation-guard.ts +1 -1
- package/scripts/delegation/investigation-reads.ts +105 -19
- package/scripts/document/capped-print.ts +74 -0
- package/scripts/document/document-section-cli.ts +4 -3
- package/scripts/gate/scoped-green.ts +25 -14
- package/scripts/init/claude-kit-only-settings.ts +29 -0
- package/scripts/init/init-copy-content.ts +8 -2
- package/scripts/init/initial-commit.ts +24 -0
- package/scripts/init/start-exec.ts +16 -3
- package/scripts/init/start-steps.ts +8 -12
- package/scripts/issue/issue-read-cli.ts +1 -0
- package/scripts/issue/issue-report-failures.ts +6 -2
- package/scripts/issue/issue-state-cli.ts +1 -1
- package/scripts/josh/josh-commands-lane.ts +1 -1
- package/scripts/josh/josh-commands-project.ts +7 -0
- package/scripts/lane/lane-await.ts +46 -2
- package/scripts/lane/lane-cli.ts +11 -13
- package/scripts/release/github-release-environment.ts +1 -0
- package/scripts/release/github-release.ts +103 -17
- package/scripts/run/run-carry-cli.ts +3 -1
- package/scripts/run/run-carry-conversation.ts +94 -0
- package/scripts/run/run-carry-reclaim.ts +37 -0
- package/scripts/run/run-carry.ts +21 -5
- package/scripts/run/run-merge-steps.ts +4 -1
- package/scripts/test/test-related.ts +1 -6
- package/scripts/test/test-unit-guard.ts +21 -7
- package/scripts/time-runtime/time-spans.ts +4 -0
- package/templates/workflows/github-release.yml +13 -25
|
@@ -188,7 +188,7 @@ function print_states(reports: ReadonlyArray<IssueReport>): void {
|
|
|
188
188
|
const should_attribute = reports.length > 1
|
|
189
189
|
const blocks = reports.flatMap((report) => state_blocks(report, should_attribute))
|
|
190
190
|
|
|
191
|
-
issue_report_failures.print_blocks(blocks, BLOCK_SEPARATOR)
|
|
191
|
+
issue_report_failures.print_blocks(blocks, BLOCK_SEPARATOR, 'issue:state')
|
|
192
192
|
}
|
|
193
193
|
|
|
194
194
|
async function run(argv: ReadonlyArray<string>): Promise<number> {
|
|
@@ -59,7 +59,7 @@ const LANE_COMMANDS: Record<string, CommandEntry> = {
|
|
|
59
59
|
description:
|
|
60
60
|
'Block until any of the named in-flight lane children completes; prints which one finished',
|
|
61
61
|
category: 'AI tools',
|
|
62
|
-
reference: ['<issue> [<issue>...]', 'automation', ['processes']],
|
|
62
|
+
reference: ['<issue> [<issue>...] [--owner <pid>]', 'automation', ['files', 'processes']],
|
|
63
63
|
default_script_arguments: ['await'],
|
|
64
64
|
},
|
|
65
65
|
'lane:launch': {
|
|
@@ -22,6 +22,13 @@ const PROJECT_COMMANDS: Record<string, CommandEntry> = {
|
|
|
22
22
|
['files', 'git', 'network', 'processes'],
|
|
23
23
|
],
|
|
24
24
|
},
|
|
25
|
+
'dogfood:commit': {
|
|
26
|
+
script: 'scripts/dogfood/dogfood-commit-cli.ts',
|
|
27
|
+
description: "Make the first commit of a dogfood run's own kit-test-* project",
|
|
28
|
+
category: 'Project',
|
|
29
|
+
reference: ['<dir>', 'automation', ['files', 'git']],
|
|
30
|
+
is_kit_only: true,
|
|
31
|
+
},
|
|
25
32
|
init: {
|
|
26
33
|
script: 'scripts/init/init.ts',
|
|
27
34
|
description: 'Initialize config in a new project',
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process'
|
|
2
2
|
import { setTimeout as sleep } from 'node:timers/promises'
|
|
3
|
+
import { parseArgs } from 'node:util'
|
|
3
4
|
import { run_liveness } from '#scripts/run/run-liveness'
|
|
4
5
|
import { lane_child_invocation } from './lane-child-invocation'
|
|
5
6
|
import { lane_handoff } from './lane-handoff'
|
|
@@ -33,6 +34,16 @@ interface AwaitState {
|
|
|
33
34
|
is_settled?: boolean
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
interface AwaitArguments {
|
|
38
|
+
issues: ReadonlyArray<string>
|
|
39
|
+
owner: number | undefined
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface RawAwaitArguments {
|
|
43
|
+
positionals: Array<string>
|
|
44
|
+
values: { owner?: string | undefined }
|
|
45
|
+
}
|
|
46
|
+
|
|
36
47
|
// Injectable for testing -- the CLI never overrides these.
|
|
37
48
|
interface AwaitOptions {
|
|
38
49
|
is_running?: (issue: string) => boolean
|
|
@@ -67,6 +78,39 @@ async function is_settled_default(issue: string): Promise<boolean> {
|
|
|
67
78
|
return (await run_liveness.read_child_settled(issue)) === true
|
|
68
79
|
}
|
|
69
80
|
|
|
81
|
+
// An unknown flag or a flag without its value is `undefined`, which is usage.
|
|
82
|
+
function read_await_args(rest: ReadonlyArray<string>): RawAwaitArguments | undefined {
|
|
83
|
+
try {
|
|
84
|
+
return parseArgs({
|
|
85
|
+
args: [...rest],
|
|
86
|
+
allowPositionals: true,
|
|
87
|
+
strict: true,
|
|
88
|
+
options: { owner: { type: 'string' } },
|
|
89
|
+
})
|
|
90
|
+
} catch {
|
|
91
|
+
return undefined
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Every positional is an issue number, at least one is given, and `--owner` is a pid.
|
|
96
|
+
function is_valid(raw: RawAwaitArguments): boolean {
|
|
97
|
+
const { owner } = raw.values
|
|
98
|
+
const numbers = owner === undefined ? raw.positionals : [...raw.positionals, owner]
|
|
99
|
+
|
|
100
|
+
return raw.positionals.length > 0 && numbers.every((value) => ISSUE_PATTERN.test(value))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// The issues to wait on and the waiting session's pid, or `undefined` for anything else.
|
|
104
|
+
function parse_arguments(rest: ReadonlyArray<string>): AwaitArguments | undefined {
|
|
105
|
+
const raw = read_await_args(rest)
|
|
106
|
+
|
|
107
|
+
if (raw === undefined || !is_valid(raw)) return undefined
|
|
108
|
+
|
|
109
|
+
const { owner } = raw.values
|
|
110
|
+
|
|
111
|
+
return { issues: raw.positionals, owner: owner === undefined ? undefined : Number(owner) }
|
|
112
|
+
}
|
|
113
|
+
|
|
70
114
|
function make_initial_state(is_settled: boolean): AwaitState {
|
|
71
115
|
return { appeared: false, disappeared_at: undefined, first_polled_at: undefined, is_settled }
|
|
72
116
|
}
|
|
@@ -187,13 +231,13 @@ async function wait_for_any(
|
|
|
187
231
|
}
|
|
188
232
|
|
|
189
233
|
const lane_await = {
|
|
190
|
-
ISSUE_PATTERN,
|
|
191
234
|
NEVER_APPEARED_TIMEOUT_MS,
|
|
192
235
|
RECONFIRM_MS,
|
|
193
236
|
check_issue,
|
|
194
237
|
is_process_running_default,
|
|
238
|
+
parse_arguments,
|
|
195
239
|
wait_for_any,
|
|
196
240
|
}
|
|
197
241
|
|
|
198
|
-
export type { AwaitState, CheckConfig }
|
|
242
|
+
export type { AwaitArguments, AwaitState, CheckConfig }
|
|
199
243
|
export { lane_await }
|
package/scripts/lane/lane-cli.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { OpenIssueData } from '#scripts/git/schemas'
|
|
|
6
6
|
import { issue_cite, type IssueCiter } from '#scripts/issue/issue-cite'
|
|
7
7
|
import { error_text } from '#scripts/lib/error-message'
|
|
8
8
|
import { run_carry } from '#scripts/run/run-carry'
|
|
9
|
+
import { run_carry_reclaim } from '#scripts/run/run-carry-reclaim'
|
|
9
10
|
import { lane_await } from './lane-await'
|
|
10
11
|
import { lane_close, type CloseOutcome, type SweepOutcome } from './lane-close'
|
|
11
12
|
import { lane_dispatch, type DispatchOutcome } from './lane-dispatch'
|
|
@@ -40,7 +41,7 @@ const USAGE = [
|
|
|
40
41
|
' josh lane:prune',
|
|
41
42
|
' josh lane:output <issue-number> [<path>]',
|
|
42
43
|
' josh lane:dispatch <issue-number>',
|
|
43
|
-
' josh lane:await <issue-number> ...',
|
|
44
|
+
' josh lane:await <issue-number> ... [--owner <pid>]',
|
|
44
45
|
].join('\n')
|
|
45
46
|
|
|
46
47
|
type Handler = (rest: ReadonlyArray<string>) => Promise<number>
|
|
@@ -356,22 +357,19 @@ async function dispatch_command(rest: ReadonlyArray<string>): Promise<number> {
|
|
|
356
357
|
return await report_dispatch(await lane_dispatch.dispatch_child(issue), issue)
|
|
357
358
|
}
|
|
358
359
|
|
|
359
|
-
// Each valid argument is an issue number; any non-number stops parsing and triggers usage.
|
|
360
|
-
function parse_issues(rest: ReadonlyArray<string>): ReadonlyArray<string> | undefined {
|
|
361
|
-
if (rest.length === 0) return undefined
|
|
362
|
-
const issues = rest.filter((value) => lane_await.ISSUE_PATTERN.test(value))
|
|
363
|
-
|
|
364
|
-
return issues.length === rest.length ? issues : undefined
|
|
365
|
-
}
|
|
366
|
-
|
|
367
360
|
// The completed issue number goes to standard output so `N=$(pnpm josh lane:await ...)` captures
|
|
368
|
-
// which child finished without parsing stderr.
|
|
361
|
+
// which child finished without parsing stderr. `--owner` reclaims the carry record for a resumed
|
|
362
|
+
// conversation before the wait (`run-carry-reclaim.ts`).
|
|
369
363
|
async function await_command(rest: ReadonlyArray<string>): Promise<number> {
|
|
370
|
-
const
|
|
364
|
+
const parsed = lane_await.parse_arguments(rest)
|
|
371
365
|
|
|
372
|
-
if (
|
|
366
|
+
if (parsed === undefined) return report_usage()
|
|
367
|
+
|
|
368
|
+
if (parsed.owner !== undefined) {
|
|
369
|
+
await run_carry_reclaim.reclaim_carry(run_carry.owner_of(parsed.owner))
|
|
370
|
+
}
|
|
373
371
|
|
|
374
|
-
const completed = await lane_await.wait_for_any(issues)
|
|
372
|
+
const completed = await lane_await.wait_for_any(parsed.issues)
|
|
375
373
|
|
|
376
374
|
console.info(completed)
|
|
377
375
|
|
|
@@ -37,6 +37,7 @@ function read_release_input(environment: Environment): ReleaseInput {
|
|
|
37
37
|
start_tag: optional(environment['RELEASE_START_TAG']),
|
|
38
38
|
workflow: optional(environment['RELEASE_WORKFLOW']),
|
|
39
39
|
jobs: job_names(environment['RELEASE_JOBS']),
|
|
40
|
+
await_publish: environment['RELEASE_AWAIT_PUBLISH'] === 'true',
|
|
40
41
|
},
|
|
41
42
|
}
|
|
42
43
|
}
|
|
@@ -15,7 +15,12 @@ const POLL_INTERVAL_MS = 30_000
|
|
|
15
15
|
const MAX_WAIT_ATTEMPTS = 120
|
|
16
16
|
const RELEASE_SCHEMA = z.object({ tag_name: z.string().min(1) })
|
|
17
17
|
const NOTES_SCHEMA = z.object({ name: z.string().min(1), body: z.string().min(1) })
|
|
18
|
-
const RUN_SCHEMA = z.object({
|
|
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
|
+
})
|
|
19
24
|
const JOB_SCHEMA = z.object({
|
|
20
25
|
name: z.string(),
|
|
21
26
|
status: z.string(),
|
|
@@ -26,15 +31,19 @@ const JOBS_SCHEMA = z.object({ jobs: z.array(JOB_SCHEMA) })
|
|
|
26
31
|
|
|
27
32
|
type ReleaseRequest = (url: string, init: RequestInit) => Promise<Response>
|
|
28
33
|
type Wait = () => Promise<void>
|
|
34
|
+
type PollState = 'ready' | 'failed' | 'pending'
|
|
29
35
|
|
|
30
36
|
// What differs between repositories. `start_tag` is the last tag that predates automatic releases;
|
|
31
37
|
// without it the latest existing release is the floor. `workflow` names the publish workflow whose
|
|
32
38
|
// failed run lets a lower tag be skipped, and `jobs` the jobs in it that count (empty: every job).
|
|
39
|
+
// `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.
|
|
33
41
|
interface ReleaseSettings {
|
|
34
42
|
repository: string
|
|
35
43
|
start_tag?: string | undefined
|
|
36
44
|
workflow?: string | undefined
|
|
37
45
|
jobs?: ReadonlyArray<string> | undefined
|
|
46
|
+
await_publish?: boolean | undefined
|
|
38
47
|
}
|
|
39
48
|
|
|
40
49
|
interface PublishOptions extends ReleaseSettings {
|
|
@@ -56,7 +65,14 @@ interface Ladder {
|
|
|
56
65
|
tags: ReadonlyArray<string>
|
|
57
66
|
floor: string | undefined
|
|
58
67
|
fallback?: () => Promise<string>
|
|
68
|
+
budget: Budget
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// One allowance shared by every wait a release makes: waiting for its own publication and then for
|
|
72
|
+
// lower tags' releases must end inside the job's `timeout-minutes` together, not each on its own.
|
|
73
|
+
interface Budget {
|
|
59
74
|
wait: Wait
|
|
75
|
+
remaining: number
|
|
60
76
|
}
|
|
61
77
|
|
|
62
78
|
function request_options(token: string, method: string, body?: object): RequestInit {
|
|
@@ -130,13 +146,22 @@ function is_failed_job(job: z.infer<typeof JOB_SCHEMA>, names: ReadonlyArray<str
|
|
|
130
146
|
return names.some((name) => job.name === name || job.name.startsWith(`${name} /`))
|
|
131
147
|
}
|
|
132
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
|
+
|
|
133
161
|
async function failed_publication(client: Client, tag: string): Promise<boolean> {
|
|
134
162
|
if (client.workflow === undefined) return false
|
|
135
163
|
|
|
136
|
-
const
|
|
137
|
-
const runs_response = await api_get(client, `/actions/workflows/${workflow}/runs?per_page=100`)
|
|
138
|
-
const runs = RUNS_SCHEMA.parse(await read_response(runs_response)).workflow_runs
|
|
139
|
-
const run = runs.find((item) => item.display_title === `Publish ${tag}`)
|
|
164
|
+
const run = await publish_run(client, client.workflow, tag)
|
|
140
165
|
if (!run) return false
|
|
141
166
|
|
|
142
167
|
const jobs_response = await api_get(client, `/actions/runs/${String(run.id)}/jobs?per_page=100`)
|
|
@@ -149,24 +174,72 @@ async function default_wait(): Promise<void> {
|
|
|
149
174
|
await setTimeout(POLL_INTERVAL_MS)
|
|
150
175
|
}
|
|
151
176
|
|
|
152
|
-
async function release_state(client: Client, tag: string): Promise<
|
|
177
|
+
async function release_state(client: Client, tag: string): Promise<PollState> {
|
|
153
178
|
if (await existing_release(client, tag)) return 'ready'
|
|
154
179
|
if (await failed_publication(client, tag)) return 'failed'
|
|
155
180
|
|
|
156
181
|
return 'pending'
|
|
157
182
|
}
|
|
158
183
|
|
|
159
|
-
|
|
160
|
-
|
|
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
|
+
|
|
194
|
+
return run.conclusion === 'success' ? 'ready' : 'failed'
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function poll(
|
|
198
|
+
read: () => Promise<PollState>,
|
|
199
|
+
budget: Budget,
|
|
200
|
+
subject: string,
|
|
201
|
+
): Promise<boolean> {
|
|
202
|
+
while (budget.remaining > 0) {
|
|
161
203
|
// eslint-disable-next-line no-await-in-loop -- polling: each read waits on the state the previous one saw
|
|
162
|
-
const state = await
|
|
204
|
+
const state = await read()
|
|
163
205
|
if (state !== 'pending') return state === 'ready'
|
|
164
206
|
|
|
207
|
+
budget.remaining -= 1
|
|
165
208
|
// eslint-disable-next-line no-await-in-loop -- polling: each read waits on the state the previous one saw
|
|
166
|
-
await wait()
|
|
209
|
+
await budget.wait()
|
|
167
210
|
}
|
|
168
211
|
|
|
169
|
-
throw new Error(`Timed out waiting for
|
|
212
|
+
throw new Error(`Timed out waiting for ${subject}`)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function wait_for_release(client: Client, tag: string, budget: Budget): Promise<boolean> {
|
|
216
|
+
return await poll(
|
|
217
|
+
async () => await release_state(client, tag),
|
|
218
|
+
budget,
|
|
219
|
+
`previous GitHub Release ${tag}`,
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function wait_for_publication(client: Client, tag: string, budget: Budget): Promise<boolean> {
|
|
224
|
+
const { workflow } = client
|
|
225
|
+
if (workflow === undefined) throw new Error('Waiting for the publication needs RELEASE_WORKFLOW')
|
|
226
|
+
|
|
227
|
+
return await poll(
|
|
228
|
+
async () => await publication_state(client, workflow, tag),
|
|
229
|
+
budget,
|
|
230
|
+
`Publish ${tag}`,
|
|
231
|
+
)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function is_publication_ready(
|
|
235
|
+
client: Client,
|
|
236
|
+
tag: string,
|
|
237
|
+
options: PublishOptions,
|
|
238
|
+
budget: Budget,
|
|
239
|
+
): Promise<boolean> {
|
|
240
|
+
if (options.await_publish !== true) return true
|
|
241
|
+
|
|
242
|
+
return await wait_for_publication(client, tag, budget)
|
|
170
243
|
}
|
|
171
244
|
|
|
172
245
|
async function ladder_end(ladder: Ladder): Promise<string | undefined> {
|
|
@@ -184,7 +257,7 @@ async function baseline_tag(
|
|
|
184
257
|
if (ladder.floor === undefined) return prior
|
|
185
258
|
if (!prior) return await ladder_end(ladder)
|
|
186
259
|
|
|
187
|
-
return (await wait_for_release(client, prior, ladder.
|
|
260
|
+
return (await wait_for_release(client, prior, ladder.budget))
|
|
188
261
|
? prior
|
|
189
262
|
: await baseline_tag(client, prior, ladder)
|
|
190
263
|
}
|
|
@@ -195,16 +268,19 @@ function assert_older(previous: string | undefined, tag: string): void {
|
|
|
195
268
|
}
|
|
196
269
|
}
|
|
197
270
|
|
|
198
|
-
async function build_ladder(
|
|
271
|
+
async function build_ladder(
|
|
272
|
+
client: Client,
|
|
273
|
+
options: PublishOptions,
|
|
274
|
+
budget: Budget,
|
|
275
|
+
): Promise<Ladder> {
|
|
199
276
|
const tags = options.tags ?? []
|
|
200
|
-
const wait = options.wait ?? default_wait
|
|
201
277
|
const { start_tag } = options
|
|
202
278
|
|
|
203
279
|
if (start_tag !== undefined) {
|
|
204
|
-
return { tags,
|
|
280
|
+
return { tags, budget, floor: start_tag, fallback: async () => await previous_tag(client) }
|
|
205
281
|
}
|
|
206
282
|
|
|
207
|
-
return { tags,
|
|
283
|
+
return { tags, budget, floor: await latest_release(client) }
|
|
208
284
|
}
|
|
209
285
|
|
|
210
286
|
async function generate_notes(
|
|
@@ -252,6 +328,10 @@ function create_client(request: ReleaseRequest, token: string, settings: Release
|
|
|
252
328
|
}
|
|
253
329
|
}
|
|
254
330
|
|
|
331
|
+
function create_budget(options: PublishOptions): Budget {
|
|
332
|
+
return { wait: options.wait ?? default_wait, remaining: MAX_WAIT_ATTEMPTS }
|
|
333
|
+
}
|
|
334
|
+
|
|
255
335
|
async function publish(
|
|
256
336
|
request: ReleaseRequest,
|
|
257
337
|
token: string,
|
|
@@ -263,7 +343,13 @@ async function publish(
|
|
|
263
343
|
const client = create_client(request, token, options)
|
|
264
344
|
if (await existing_release(client, tag)) return 'already-published'
|
|
265
345
|
|
|
266
|
-
const
|
|
346
|
+
const budget = create_budget(options)
|
|
347
|
+
|
|
348
|
+
if (!(await is_publication_ready(client, tag, options, budget))) {
|
|
349
|
+
return `skipped ${tag}: Publish ${tag} did not succeed`
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const previous = await baseline_tag(client, tag, await build_ladder(client, options, budget))
|
|
267
353
|
|
|
268
354
|
assert_older(previous, tag)
|
|
269
355
|
await create_release(client, tag, previous)
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
type RunCarry,
|
|
11
11
|
} from './run-carry'
|
|
12
12
|
import { run_carry_args, type CountRequest, type Request } from './run-carry-args'
|
|
13
|
+
import { run_carry_conversation } from './run-carry-conversation'
|
|
13
14
|
import { run_carry_stash } from './run-carry-stash'
|
|
14
15
|
import { run_event_stream } from './run-event-stream'
|
|
15
16
|
import { run_event_stream_emit } from './run-event-stream-emit'
|
|
@@ -331,7 +332,8 @@ async function count(
|
|
|
331
332
|
|
|
332
333
|
if (blocked !== undefined) return blocked
|
|
333
334
|
|
|
334
|
-
const
|
|
335
|
+
const owned = run_carry_conversation.reclaim_owner(read.carry, request.owner)
|
|
336
|
+
const carry = run_carry.apply_change(target, owned, request.change)
|
|
335
337
|
|
|
336
338
|
await record_retrospective(request)
|
|
337
339
|
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { existsSync, statSync } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { cost_transcript } from '#scripts/cost-runtime/cost-transcript'
|
|
4
|
+
import { own_session } from '#scripts/cost-runtime/own-session'
|
|
5
|
+
import type { CarryOwner, RunCarry } from './run-carry'
|
|
6
|
+
|
|
7
|
+
// **A conversation outlives its process, so an owner named by a pid alone dies too early**
|
|
8
|
+
// (joshuafolkken/kit#3137). The carry record names the agent process spending the budget, and that is
|
|
9
|
+
// right while the process lives — but an interactive conversation is not its process. On 2026-10-04 a
|
|
10
|
+
// VS Code extension-host restart ended the session's process at 15:17:34 UTC, and the same
|
|
11
|
+
// conversation resumed eighteen seconds later in a new process (`--resume=<id>`), writing on into the
|
|
12
|
+
// same transcript. `run:wake` read the old pid as dead, recovered the record as a crash, and dispatched
|
|
13
|
+
// a second lane child for the issue the conversation was still running.
|
|
14
|
+
//
|
|
15
|
+
// **The transcript is what persists across the restart, so it is the second half of the identity.**
|
|
16
|
+
// Every resume path — the extension's `--resume=<id>`, a person's `claude -c` — appends to the one
|
|
17
|
+
// file named by the session id, and nothing else writes it. So a record also names its owner's
|
|
18
|
+
// transcript, and a dead pid whose transcript was written inside the quiet window below is a
|
|
19
|
+
// conversation still being continued rather than a crash. Matching the transcript is also how the
|
|
20
|
+
// resumed conversation is recognized as the record's own owner, so it can keep counting.
|
|
21
|
+
//
|
|
22
|
+
// **A hand-off ends the conversation's claim.** A record `--cut` handed off is waited on by the pid
|
|
23
|
+
// alone, because the cutting conversation is meant to end there — counted by its transcript, the
|
|
24
|
+
// supervisor would hold every cut for the whole window after the cutting process had already exited.
|
|
25
|
+
|
|
26
|
+
// How long after its last write a conversation whose process has gone still counts as continuing. It
|
|
27
|
+
// bridges a restart and a resume — measured at eighteen seconds — with room for a person reopening a
|
|
28
|
+
// window, and it is the order of the wake grace window, so a genuine crash is recovered no later than
|
|
29
|
+
// a lost wake would be retried.
|
|
30
|
+
const CONVERSATION_QUIET_MS = 600_000
|
|
31
|
+
|
|
32
|
+
type Environment = Readonly<Record<string, string | undefined>>
|
|
33
|
+
|
|
34
|
+
// The calling session's own transcript, or `undefined` outside a session or when its file cannot be
|
|
35
|
+
// found. **The id comes from the environment, which a detached supervisor never inherits**
|
|
36
|
+
// (`agent-session-environment.ts` strips it), so a record the supervisor adopts names no transcript.
|
|
37
|
+
function own_transcript(
|
|
38
|
+
environment: Environment = process.env,
|
|
39
|
+
directories: ReadonlyArray<string> = cost_transcript.transcript_directories(process.cwd()),
|
|
40
|
+
): string | undefined {
|
|
41
|
+
const session_id = own_session.session_id_of(environment)
|
|
42
|
+
|
|
43
|
+
if (session_id === undefined) return undefined
|
|
44
|
+
|
|
45
|
+
const name = `${session_id}${cost_transcript.TRANSCRIPT_EXTENSION}`
|
|
46
|
+
|
|
47
|
+
return directories.map((directory) => path.join(directory, name)).find((file) => existsSync(file))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function modified_ms(transcript: string): number | undefined {
|
|
51
|
+
try {
|
|
52
|
+
return statSync(transcript).mtimeMs
|
|
53
|
+
} catch {
|
|
54
|
+
return undefined
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function is_recent(modified: number | undefined, now: Date): boolean {
|
|
59
|
+
return modified !== undefined && now.getTime() - modified <= CONVERSATION_QUIET_MS
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function is_conversation_live(
|
|
63
|
+
carry: RunCarry,
|
|
64
|
+
now: Date = new Date(),
|
|
65
|
+
read_modified: (transcript: string) => number | undefined = modified_ms,
|
|
66
|
+
): boolean {
|
|
67
|
+
if (carry.is_handed_off === true || carry.owner_transcript === undefined) return false
|
|
68
|
+
|
|
69
|
+
return is_recent(read_modified(carry.owner_transcript), now)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function is_same_conversation(carry: RunCarry, owner: CarryOwner): boolean {
|
|
73
|
+
return owner.transcript !== undefined && owner.transcript === carry.owner_transcript
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The record with its owner moved to the caller's process when the caller is the same conversation in
|
|
77
|
+
// a new one, so the record goes on naming a live process once the conversation falls quiet. A record a
|
|
78
|
+
// cut handed off is the successor's to take, never the cutting conversation's to take back.
|
|
79
|
+
function reclaim_owner(carry: RunCarry, owner: CarryOwner): RunCarry {
|
|
80
|
+
if (carry.is_handed_off === true || !is_same_conversation(carry, owner)) return carry
|
|
81
|
+
if (owner.pid === carry.owner_pid) return carry
|
|
82
|
+
|
|
83
|
+
return { ...carry, owner_pid: owner.pid, owner_start: owner.start }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const run_carry_conversation = {
|
|
87
|
+
CONVERSATION_QUIET_MS,
|
|
88
|
+
own_transcript,
|
|
89
|
+
is_conversation_live,
|
|
90
|
+
is_same_conversation,
|
|
91
|
+
reclaim_owner,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export { run_carry_conversation }
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { stamp_file } from '#scripts/josh/stamp-file'
|
|
2
|
+
import { run_carry, type CarryOwner } from './run-carry'
|
|
3
|
+
import { run_carry_conversation } from './run-carry-conversation'
|
|
4
|
+
|
|
5
|
+
// **A resumed conversation has to take its record back before it falls quiet** (joshuafolkken/kit#3137).
|
|
6
|
+
// A count moves the owner to the conversation's new process, but a parent waiting on a lane child
|
|
7
|
+
// counts nothing — `lane:await` blocks in the background and the transcript stays unwritten — so a
|
|
8
|
+
// restart followed by a long wait outlasts the quiet window with the record still naming the dead
|
|
9
|
+
// process, and `run:wake` recovers it as a crash. The wait itself is therefore where the parent
|
|
10
|
+
// declares its owner: `lane:await --owner "$PPID"` reclaims the record before it starts waiting.
|
|
11
|
+
|
|
12
|
+
// Writes the record back under the caller's process when the caller is the record's own conversation
|
|
13
|
+
// in a new one; answers whether it did.
|
|
14
|
+
function reclaim_at(target: string, owner: CarryOwner): boolean {
|
|
15
|
+
const read = run_carry.read_carry(target)
|
|
16
|
+
|
|
17
|
+
if (read.kind !== 'carried') return false
|
|
18
|
+
|
|
19
|
+
const reclaimed = run_carry_conversation.reclaim_owner(read.carry, owner)
|
|
20
|
+
|
|
21
|
+
if (reclaimed === read.carry) return false
|
|
22
|
+
|
|
23
|
+
// Replaced atomically: a `run:wake` reading between an unlink and a write would see no record.
|
|
24
|
+
stamp_file.replace_stamp(target, reclaimed)
|
|
25
|
+
|
|
26
|
+
return true
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function reclaim_carry(owner: CarryOwner): Promise<boolean> {
|
|
30
|
+
const directory = await run_carry.repository_directory()
|
|
31
|
+
|
|
32
|
+
return directory !== undefined && reclaim_at(run_carry.carry_path(directory), owner)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const run_carry_reclaim = { reclaim_at, reclaim_carry }
|
|
36
|
+
|
|
37
|
+
export { run_carry_reclaim }
|
package/scripts/run/run-carry.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { process_identity } from '#scripts/josh/process-identity'
|
|
|
4
4
|
import { stamp_file } from '#scripts/josh/stamp-file'
|
|
5
5
|
import { z } from 'zod'
|
|
6
6
|
import { run_carry_change } from './run-carry-change'
|
|
7
|
+
import { run_carry_conversation } from './run-carry-conversation'
|
|
7
8
|
import { run_invocation } from './run-invocation'
|
|
8
9
|
|
|
9
10
|
// joshuafolkken/kit#1714: a `backlogrun` declares a budget — `--max`, `--idle` and the 8-hour
|
|
@@ -106,6 +107,9 @@ interface RunCarry {
|
|
|
106
107
|
// them apart would be asserting a distinction nothing here can observe.
|
|
107
108
|
owner_pid?: number | undefined
|
|
108
109
|
owner_start?: string | undefined
|
|
110
|
+
// The owner conversation's transcript, which outlives a restart of its process
|
|
111
|
+
// (`run-carry-conversation.ts`, joshuafolkken/kit#3137). Optional for the same reason as the pid.
|
|
112
|
+
owner_transcript?: string | undefined
|
|
109
113
|
// Set by `--cut`, and by nothing else. A crash never reaches `--cut`, which is what makes a
|
|
110
114
|
// declared cut the only standing record carried without a person deciding.
|
|
111
115
|
is_handed_off?: boolean | undefined
|
|
@@ -129,10 +133,12 @@ interface RunCarry {
|
|
|
129
133
|
}
|
|
130
134
|
|
|
131
135
|
// The identity of a process, as `process-identity.ts` keeps it: the pid plus an opaque start-time
|
|
132
|
-
// token, so a reissued pid is never mistaken for the process that wrote the record
|
|
136
|
+
// token, so a reissued pid is never mistaken for the process that wrote the record — and the
|
|
137
|
+
// transcript of the conversation that process runs, which outlives it (`run-carry-conversation.ts`).
|
|
133
138
|
interface CarryOwner {
|
|
134
139
|
pid?: number | undefined
|
|
135
140
|
start?: string | undefined
|
|
141
|
+
transcript?: string | undefined
|
|
136
142
|
}
|
|
137
143
|
|
|
138
144
|
// **`resume` is the only one of the four that establishes anything.** The other three are refusals,
|
|
@@ -201,6 +207,7 @@ const run_carry_schema = z.object({
|
|
|
201
207
|
// which is the not-provably-live answer rather than a live one.
|
|
202
208
|
owner_pid: z.number().optional(),
|
|
203
209
|
owner_start: z.string().optional(),
|
|
210
|
+
owner_transcript: z.string().optional(),
|
|
204
211
|
is_handed_off: z.boolean().optional(),
|
|
205
212
|
done: z.array(z.number()).optional(),
|
|
206
213
|
// Optional, so a record written before the retrospective existed still parses (joshuafolkken/kit#2328).
|
|
@@ -256,12 +263,14 @@ function read_carry(target: string, now: Date = new Date()): CarryRead {
|
|
|
256
263
|
|
|
257
264
|
// The identity to record for a pid the caller declared. `read_start` is what makes the pair an
|
|
258
265
|
// identity rather than a number; injected in tests so the sandbox branch is exercised without
|
|
259
|
-
// changing the machine's process capabilities.
|
|
266
|
+
// changing the machine's process capabilities. The transcript is the calling session's own — the
|
|
267
|
+
// declared pid is that session's process (`--owner "$PPID"`).
|
|
260
268
|
function owner_of(
|
|
261
269
|
pid: number,
|
|
262
270
|
read: (owner_pid: number) => string | undefined = process_identity.read_start,
|
|
271
|
+
transcript: string | undefined = run_carry_conversation.own_transcript(),
|
|
263
272
|
): CarryOwner {
|
|
264
|
-
return { pid, start: read(pid) }
|
|
273
|
+
return { pid, start: read(pid), transcript }
|
|
265
274
|
}
|
|
266
275
|
|
|
267
276
|
function fresh_carry(invocation: string, owner: CarryOwner, now: Date): RunCarry {
|
|
@@ -275,6 +284,7 @@ function fresh_carry(invocation: string, owner: CarryOwner, now: Date): RunCarry
|
|
|
275
284
|
outages: NO_INCREMENT,
|
|
276
285
|
owner_pid: owner.pid,
|
|
277
286
|
owner_start: owner.start,
|
|
287
|
+
owner_transcript: owner.transcript,
|
|
278
288
|
}
|
|
279
289
|
}
|
|
280
290
|
|
|
@@ -329,6 +339,7 @@ function adopt_carry(
|
|
|
329
339
|
...carry,
|
|
330
340
|
owner_pid: owner.pid,
|
|
331
341
|
owner_start: owner.start,
|
|
342
|
+
owner_transcript: owner.transcript,
|
|
332
343
|
is_handed_off: false,
|
|
333
344
|
}
|
|
334
345
|
|
|
@@ -340,11 +351,16 @@ function adopt_carry(
|
|
|
340
351
|
// A live pid with no readable start token is conservatively held rather than replaced. The command
|
|
341
352
|
// cannot prove its generation inside that sandbox, but a false `busy` stops for a person while a
|
|
342
353
|
// false `standing` can put two parents on one budget. A record with no pid still resolves false.
|
|
343
|
-
|
|
344
|
-
|
|
354
|
+
// A gone process whose conversation is still being continued is live (`run-carry-conversation.ts`).
|
|
355
|
+
function is_owner_live(carry: RunCarry, now: Date = new Date()): boolean {
|
|
356
|
+
if (process_identity.is_same_process(carry.owner_pid, carry.owner_start) !== false) return true
|
|
357
|
+
|
|
358
|
+
return run_carry_conversation.is_conversation_live(carry, now)
|
|
345
359
|
}
|
|
346
360
|
|
|
347
361
|
function is_owned_by(carry: RunCarry, owner: CarryOwner): boolean {
|
|
362
|
+
if (run_carry_conversation.is_same_conversation(carry, owner)) return true
|
|
363
|
+
|
|
348
364
|
return carry.owner_pid === owner.pid && carry.owner_start === owner.start
|
|
349
365
|
}
|
|
350
366
|
|
|
@@ -10,6 +10,7 @@ import { lane_reap } from '#scripts/lane/lane-reap'
|
|
|
10
10
|
import { lane_registry, type LaneInfo } from '#scripts/lane/lane-registry'
|
|
11
11
|
import { lane_relaunch } from '#scripts/lane/lane-relaunch'
|
|
12
12
|
import { run_carry, type CarryChange, type CarryOwner, type RunCarry } from './run-carry'
|
|
13
|
+
import { run_carry_conversation } from './run-carry-conversation'
|
|
13
14
|
import { run_cut, type RunCut } from './run-cut'
|
|
14
15
|
import { run_merge } from './run-merge'
|
|
15
16
|
|
|
@@ -105,7 +106,9 @@ async function apply_carry(
|
|
|
105
106
|
return { kind: 'refused', carry: record.carry }
|
|
106
107
|
}
|
|
107
108
|
|
|
108
|
-
|
|
109
|
+
const owned = run_carry_conversation.reclaim_owner(record.carry, ctx.owner)
|
|
110
|
+
|
|
111
|
+
return { kind: 'applied', carry: run_carry.apply_change(record.target, owned, change) }
|
|
109
112
|
}
|
|
110
113
|
|
|
111
114
|
// The carry record when this session may not act on it — the same ownership guard `apply_carry` asks —
|
|
@@ -26,12 +26,7 @@ import { test_unit_guard } from './test-unit-guard'
|
|
|
26
26
|
function conclusive_tree(before: GateTree | undefined): GateTree | undefined {
|
|
27
27
|
if (before === undefined) return undefined
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
test_unit_guard.is_vitest_installed(process.cwd()),
|
|
31
|
-
test_unit_guard.has_unit_tests(process.cwd()),
|
|
32
|
-
)
|
|
33
|
-
|
|
34
|
-
return action === 'run' ? before : undefined
|
|
29
|
+
return test_unit_guard.guard_action(process.cwd()) === 'run' ? before : undefined
|
|
35
30
|
}
|
|
36
31
|
|
|
37
32
|
async function run_related_tests(command_arguments: ReadonlyArray<string>): Promise<number> {
|