@joshuafolkken/kit 1.1435.0 → 1.1453.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-park.md +2 -0
- package/.claude/skills/workflow-commands/backlogrun-steps.md +9 -7
- package/.claude/skills/workflow-commands/backlogrun.md +2 -2
- package/.claude/skills/workflow-commands/fullrun-steps.md +1 -1
- package/.claude/skills/workflow-commands/halfrun.md +1 -1
- package/.claude/skills/workflow-commands/kickoff.md +12 -13
- package/.claude/skills/workflow-commands/observation-filing.md +1 -1
- package/.claude/skills/workflow-commands/prerequisite.md +6 -0
- package/.claude/skills/workflow-commands/retrospective.md +5 -5
- package/.claude/skills/workflow-commands/split-assessment.md +2 -2
- package/README.md +15 -17
- package/dist/hooks/batch-guard.js +6 -6
- package/dist/hooks/{chunk-3LE6VLSR.js → chunk-3C4XRYG6.js} +18 -5
- package/dist/hooks/{chunk-JXQBK323.js → chunk-5C7L6FRC.js} +131 -104
- package/dist/hooks/{chunk-EMB55BDF.js → chunk-D3NJXN5M.js} +2 -2
- package/dist/hooks/{chunk-7KMN5QB5.js → chunk-EF5GQ6WS.js} +3 -3
- package/dist/hooks/{chunk-2G5F2YCD.js → chunk-LBFYEOXJ.js} +3 -3
- package/dist/hooks/{chunk-XER6SSIB.js → chunk-MBJYQPEK.js} +16 -6
- package/dist/hooks/{chunk-QL6QTPWN.js → chunk-NO4AALP5.js} +3 -3
- package/dist/hooks/{chunk-4BHUPFKF.js → chunk-OLMO72ON.js} +1 -1
- package/dist/hooks/{chunk-MSYSUSY6.js → chunk-SHYUQOIS.js} +5 -5
- package/dist/hooks/{chunk-5SJSSCJG.js → chunk-UT7YC3N2.js} +1 -1
- package/dist/hooks/{chunk-MU4LJFIV.js → chunk-W5ZKGTSY.js} +3 -3
- package/dist/hooks/{chunk-YZXYOAZ3.js → chunk-Y3GKIKGW.js} +11 -5
- package/dist/hooks/{chunk-YW452ZAX.js → chunk-Z4XI7CZS.js} +2 -2
- 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 +241 -240
- package/dist/hooks/investigation-guard.js +7 -7
- package/dist/hooks/pretool-guard.js +12 -12
- package/dist/hooks/session-lang.js +3 -3
- package/dist/hooks/stop-guard.js +6 -6
- package/dist/josh.js +33 -10
- package/package.json +1 -1
- package/prompts/collaboration-workflow/issue-template.md +10 -8
- package/prompts/review.md +8 -4
- package/scripts/auto-ok/auto-ok-judge-cli.ts +125 -0
- package/scripts/auto-ok/auto-ok-judge-read.ts +95 -0
- package/scripts/auto-ok/auto-ok-judge-write.ts +109 -0
- package/scripts/auto-ok/auto-ok-judge.ts +67 -0
- package/scripts/backlog/backlog-drive-cli.ts +14 -0
- package/scripts/backlog/backlog-drive.ts +22 -3
- package/scripts/document/document-byte-budget.ts +1 -1
- package/scripts/fresh-machine/fresh-machine-patterns.ts +60 -15
- package/scripts/gate/project-checks.ts +5 -2
- package/scripts/gh/git-gh-issue-rest.ts +1 -0
- package/scripts/gh/git-gh-issue.ts +9 -0
- package/scripts/init/init-actions.ts +3 -3
- package/scripts/init/init-ai-copy.ts +1 -1
- package/scripts/init/init-basic.ts +1 -1
- package/scripts/init/init-bootstrap.ts +6 -2
- package/scripts/init/init-dry-run-report.ts +150 -0
- package/scripts/init/init-dry-run.ts +189 -0
- package/scripts/init/init-install.ts +8 -1
- package/scripts/init/init.ts +57 -19
- package/scripts/init/kit-setup-state.ts +11 -17
- package/scripts/init/own-formatter.ts +21 -0
- package/scripts/init/project-profile.ts +25 -6
- package/scripts/init/start-dry-run.ts +70 -0
- package/scripts/init/start-plan.ts +8 -3
- package/scripts/init/start-setup-pr.ts +13 -24
- package/scripts/init/start-steps.ts +16 -1
- package/scripts/init/start.ts +38 -18
- package/scripts/init/working-tree.ts +116 -0
- package/scripts/issue/issue-auto-ok.ts +68 -99
- package/scripts/issue/issue-file-cli.ts +17 -17
- package/scripts/issue/issue-file.ts +1 -6
- package/scripts/issue/issue-labels.ts +6 -2
- package/scripts/issue/issue-release-cli.ts +69 -23
- package/scripts/issue/issue-release.ts +3 -1
- package/scripts/josh/josh-commands-ai.ts +11 -0
- package/scripts/josh/josh-commands-issue.ts +3 -3
- package/scripts/josh/josh-commands-project.ts +2 -2
- package/scripts/josh/process-identity.ts +32 -6
- package/scripts/lane/lane-broker-server.ts +69 -0
- package/scripts/lane/lane-session-broker.ts +37 -14
- package/scripts/observations/observation-ledger-migrate.ts +3 -15
- package/scripts/release/release-issue-close.ts +133 -0
- package/scripts/release/release-publish.ts +12 -1
- package/scripts/repo/repository-labels.ts +11 -1
- package/scripts/review/comment-ranges.ts +199 -0
- package/scripts/review/review-brief-cli.ts +22 -5
- package/scripts/review/review-code-tree.ts +119 -0
- package/scripts/review/review-round2-cli.ts +16 -5
- package/scripts/review/review-round2.ts +30 -10
- package/scripts/review/review-stamps.ts +22 -1
- package/scripts/uninstall/distributed-files.ts +1 -1
|
@@ -18,16 +18,21 @@ import { session_cite } from './session-cite'
|
|
|
18
18
|
// --release` calls the same `link` once its Issue exists, so a new and an existing Issue are linked
|
|
19
19
|
// by one body of code.
|
|
20
20
|
//
|
|
21
|
+
// `--wait` points the edge the other way: `<N>` is blocked by the release Issue, so a run that needs
|
|
22
|
+
// the published version records a dependency rather than a `needs-decision` park, and the backlog
|
|
23
|
+
// offers `<N>` again once a person closes the release Issue (joshuafolkken/kit#3824).
|
|
24
|
+
//
|
|
21
25
|
// The relation is written through `issue_add_blocked_by`, which addresses the repository `gh`
|
|
22
26
|
// resolves — the current one, or the target a cross-repository `issue:file` has pointed `GH_REPO` at.
|
|
23
27
|
|
|
24
28
|
const SUCCESS_EXIT_CODE = 0
|
|
25
29
|
const FAILURE_EXIT_CODE = 1
|
|
26
30
|
const ARGV_OFFSET = 2
|
|
27
|
-
const USAGE = 'Usage: josh issue:release <issue-number>'
|
|
31
|
+
const USAGE = 'Usage: josh issue:release <issue-number> [--wait]'
|
|
28
32
|
const NONE = 'none'
|
|
29
33
|
const UNKNOWN_REPO_MESSAGE =
|
|
30
34
|
'Could not read this repository from `git remote` — check `gh auth status`.'
|
|
35
|
+
const OPTIONS = { wait: { type: 'boolean' } } as const
|
|
31
36
|
|
|
32
37
|
interface ReleaseIssue {
|
|
33
38
|
issue_number: number
|
|
@@ -35,7 +40,20 @@ interface ReleaseIssue {
|
|
|
35
40
|
is_filed: boolean
|
|
36
41
|
}
|
|
37
42
|
|
|
43
|
+
interface ReleaseRequest {
|
|
44
|
+
issue_number: number
|
|
45
|
+
// `<N>` waits on the release Issue, rather than blocking it.
|
|
46
|
+
is_wait: boolean
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The two ends of one `blocked_by` edge.
|
|
50
|
+
interface Edge {
|
|
51
|
+
blocked: number
|
|
52
|
+
blocker: number
|
|
53
|
+
}
|
|
54
|
+
|
|
38
55
|
// `undefined` when the listing is unreadable, `NONE` when it reads and names no open release Issue.
|
|
56
|
+
// `pnpm josh release` asks the same question to close the Issue it has just published.
|
|
39
57
|
async function find_open(target: string): Promise<number | typeof NONE | undefined> {
|
|
40
58
|
const outcome = await git_gh_issue_list.issue_list({
|
|
41
59
|
json_fields: 'number',
|
|
@@ -91,41 +109,59 @@ async function release_of(target: string): Promise<ReleaseIssue | undefined> {
|
|
|
91
109
|
return filed === undefined ? undefined : { issue_number: filed, is_filed: true }
|
|
92
110
|
}
|
|
93
111
|
|
|
94
|
-
function
|
|
112
|
+
function release_label_of(release: ReleaseIssue, target: string): string {
|
|
95
113
|
const how = release.is_filed ? 'filed' : 'open'
|
|
96
|
-
const release_cite = session_cite.issue(release.issue_number, undefined, target)
|
|
97
114
|
|
|
98
|
-
return
|
|
115
|
+
return `${session_cite.issue(release.issue_number, undefined, target)} (${how})`
|
|
99
116
|
}
|
|
100
117
|
|
|
101
|
-
function
|
|
102
|
-
const
|
|
118
|
+
function linked_line(release: ReleaseIssue, request: ReleaseRequest, target: string): string {
|
|
119
|
+
const release_label = release_label_of(release, target)
|
|
120
|
+
const issue_label = session_cite.issue(request.issue_number, undefined, target)
|
|
103
121
|
|
|
104
|
-
return
|
|
122
|
+
return request.is_wait
|
|
123
|
+
? `release: ${issue_label} waits on ${release_label}`
|
|
124
|
+
: `release: ${release_label} is blocked by ${issue_label}`
|
|
105
125
|
}
|
|
106
126
|
|
|
107
|
-
|
|
108
|
-
|
|
127
|
+
function unlinked_line(edge: Edge, target: string): string {
|
|
128
|
+
const blocker = session_cite.issue(edge.blocker, undefined, target)
|
|
129
|
+
|
|
130
|
+
return `✖ ${blocker} could not be recorded as a blocker of ${session_cite.issue(edge.blocked, undefined, target)}`
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function edge_of(release: ReleaseIssue, request: ReleaseRequest): Edge {
|
|
134
|
+
return request.is_wait
|
|
135
|
+
? { blocked: request.issue_number, blocker: release.issue_number }
|
|
136
|
+
: { blocked: release.issue_number, blocker: request.issue_number }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Whether the edge between Issue `<N>` of `target` and the release Issue is now recorded. Every
|
|
140
|
+
// outcome is printed.
|
|
141
|
+
async function record(request: ReleaseRequest, target: string): Promise<boolean> {
|
|
109
142
|
const release = await release_of(target)
|
|
110
143
|
|
|
111
144
|
if (release === undefined) return false
|
|
112
|
-
const
|
|
113
|
-
const is_linked = await git_gh_issue_write.issue_add_blocked_by(
|
|
145
|
+
const edge = edge_of(release, request)
|
|
146
|
+
const is_linked = await git_gh_issue_write.issue_add_blocked_by(
|
|
147
|
+
String(edge.blocked),
|
|
148
|
+
String(edge.blocker),
|
|
149
|
+
)
|
|
114
150
|
|
|
115
|
-
if (is_linked) console.info(linked_line(release,
|
|
116
|
-
else console.error(unlinked_line(
|
|
151
|
+
if (is_linked) console.info(linked_line(release, request, target))
|
|
152
|
+
else console.error(unlinked_line(edge, target))
|
|
117
153
|
|
|
118
154
|
return is_linked
|
|
119
155
|
}
|
|
120
156
|
|
|
121
|
-
//
|
|
122
|
-
function
|
|
123
|
-
return
|
|
157
|
+
// Whether Issue `issue_number` of `target` now blocks the release Issue.
|
|
158
|
+
async function link(issue_number: number, target: string): Promise<boolean> {
|
|
159
|
+
return await record({ issue_number, is_wait: false }, target)
|
|
124
160
|
}
|
|
125
161
|
|
|
126
162
|
// The number is the one positional: a missing, malformed or second one refuses the whole call.
|
|
127
|
-
function issue_number_of(
|
|
128
|
-
const [issue_number, ...rest] =
|
|
163
|
+
function issue_number_of(positionals: ReadonlyArray<string>): number | undefined {
|
|
164
|
+
const [issue_number, ...rest] = positionals
|
|
129
165
|
const is_single = rest.length === 0 && issue_number !== undefined
|
|
130
166
|
|
|
131
167
|
return is_single && issue_number_shape.ISSUE_NUMBER_PATTERN.test(issue_number)
|
|
@@ -133,10 +169,20 @@ function issue_number_of(argv: ReadonlyArray<string>): number | undefined {
|
|
|
133
169
|
: undefined
|
|
134
170
|
}
|
|
135
171
|
|
|
172
|
+
// An unknown flag parses to no request at all, so it refuses the call like a missing number.
|
|
173
|
+
function request_of(argv: ReadonlyArray<string>): ReleaseRequest | undefined {
|
|
174
|
+
const parsed = cli_flags.arguments_of(argv, OPTIONS)
|
|
175
|
+
const issue_number = issue_number_of(parsed?.positionals ?? [])
|
|
176
|
+
|
|
177
|
+
if (parsed === undefined || issue_number === undefined) return undefined
|
|
178
|
+
|
|
179
|
+
return { issue_number, is_wait: parsed.values.wait === true }
|
|
180
|
+
}
|
|
181
|
+
|
|
136
182
|
async function run(argv: ReadonlyArray<string>): Promise<number> {
|
|
137
|
-
const
|
|
183
|
+
const request = request_of(argv)
|
|
138
184
|
|
|
139
|
-
if (
|
|
185
|
+
if (request === undefined) {
|
|
140
186
|
console.error(USAGE)
|
|
141
187
|
|
|
142
188
|
return FAILURE_EXIT_CODE
|
|
@@ -150,7 +196,7 @@ async function run(argv: ReadonlyArray<string>): Promise<number> {
|
|
|
150
196
|
return FAILURE_EXIT_CODE
|
|
151
197
|
}
|
|
152
198
|
|
|
153
|
-
return (await
|
|
199
|
+
return (await record(request, current)) ? SUCCESS_EXIT_CODE : FAILURE_EXIT_CODE
|
|
154
200
|
}
|
|
155
201
|
|
|
156
202
|
// `process.exitCode` rather than `process.exit()`, so the report on standard output drains first.
|
|
@@ -158,8 +204,8 @@ async function main(argv: ReadonlyArray<string>): Promise<void> {
|
|
|
158
204
|
process.exitCode = await run(argv)
|
|
159
205
|
}
|
|
160
206
|
|
|
161
|
-
const issue_release_cli = { link, run }
|
|
207
|
+
const issue_release_cli = { find_open, link, run }
|
|
162
208
|
|
|
163
209
|
if (process.argv[1] === fileURLToPath(import.meta.url)) await main(process.argv.slice(ARGV_OFFSET))
|
|
164
210
|
|
|
165
|
-
export { issue_release_cli }
|
|
211
|
+
export { issue_release_cli, NONE }
|
|
@@ -42,7 +42,9 @@ function body_of(repo: string): string {
|
|
|
42
42
|
'',
|
|
43
43
|
'Every Issue this one is blocked by changes something a consumer can use only once it is published. Once they have merged, a person runs `pnpm josh release` in the primary checkout.',
|
|
44
44
|
'',
|
|
45
|
-
'
|
|
45
|
+
'`pnpm josh release` closes this Issue once that release is published: the next blocker files a new one, so an open release Issue names only unreleased work.',
|
|
46
|
+
'',
|
|
47
|
+
'Every Issue this one blocks needs the published version (`pnpm josh issue:release <N> --wait`); closing this Issue hands each of them back to the backlog.',
|
|
46
48
|
'',
|
|
47
49
|
'`auto-ok` is never applied to this Issue by a run; a person may apply it.',
|
|
48
50
|
'',
|
|
@@ -61,6 +61,17 @@ const AI_COMMANDS: Record<string, CommandEntry> = {
|
|
|
61
61
|
category: 'AI tools',
|
|
62
62
|
reference: ['[--exclude <n>[,<n>...]] [--label <name>]', 'automation', ['network']],
|
|
63
63
|
},
|
|
64
|
+
'auto-ok:judge': {
|
|
65
|
+
script: 'scripts/auto-ok/auto-ok-judge-cli.ts',
|
|
66
|
+
description:
|
|
67
|
+
'List the open issues backlogrun owes an auto-ok judgement, or record one (--apply / --decline)',
|
|
68
|
+
category: 'AI tools',
|
|
69
|
+
reference: [
|
|
70
|
+
'[--apply <n> (--lane | --solo) | --decline <n> --reason <text>]',
|
|
71
|
+
'automation',
|
|
72
|
+
['network'],
|
|
73
|
+
],
|
|
74
|
+
},
|
|
64
75
|
...BACKLOG_COMMANDS,
|
|
65
76
|
cost: {
|
|
66
77
|
script: 'scripts/cost-runtime/cost-cli.ts',
|
|
@@ -75,7 +75,7 @@ const ISSUE_COMMANDS: Record<string, CommandEntry> = {
|
|
|
75
75
|
'File an Issue with every filing step: lint, Origin, fold, WIP cap, duplicate scout, labels, then epic:bundle',
|
|
76
76
|
category: 'AI tools',
|
|
77
77
|
reference: [
|
|
78
|
-
'<title> --body-file <path> --depth <0|1|2> [--route <route>] [--label <name>] [--repo <owner/repo>] [--distinct <N,…>] [--over-cap] [--no-auto-ok] [--
|
|
78
|
+
'<title> --body-file <path> --depth <0|1|2> [--route <route>] [--label <name>] [--repo <owner/repo>] [--distinct <N,…>] [--over-cap] [--no-auto-ok] [--release]',
|
|
79
79
|
'automation',
|
|
80
80
|
['network'],
|
|
81
81
|
],
|
|
@@ -83,9 +83,9 @@ const ISSUE_COMMANDS: Record<string, CommandEntry> = {
|
|
|
83
83
|
'issue:release': {
|
|
84
84
|
script: 'scripts/issue/issue-release-cli.ts',
|
|
85
85
|
description:
|
|
86
|
-
"Link an Issue to the repository's open release Issue as its blocker, filing one when none is open",
|
|
86
|
+
"Link an Issue to the repository's open release Issue as its blocker (or, with --wait, as waiting on it), filing one when none is open",
|
|
87
87
|
category: 'AI tools',
|
|
88
|
-
reference: ['<issue>', 'automation', ['network']],
|
|
88
|
+
reference: ['<issue> [--wait]', 'automation', ['network']],
|
|
89
89
|
},
|
|
90
90
|
}
|
|
91
91
|
/* eslint-enable @typescript-eslint/naming-convention */
|
|
@@ -17,7 +17,7 @@ const PROJECT_COMMANDS: Record<string, CommandEntry> = {
|
|
|
17
17
|
description: 'Set a project up for the GitHub Issue workflow, from git init to the setup PR',
|
|
18
18
|
category: 'Project',
|
|
19
19
|
reference: [
|
|
20
|
-
`${PROFILE_ARGUMENTS} [--yes] [--github] [--public] [--init-command <command>] [--push-preflight-command <command>]`,
|
|
20
|
+
`${PROFILE_ARGUMENTS} [--yes] [--github] [--public] [--dry-run] [--init-command <command>] [--push-preflight-command <command>]`,
|
|
21
21
|
'developer',
|
|
22
22
|
['files', 'git', 'network', 'processes'],
|
|
23
23
|
],
|
|
@@ -52,7 +52,7 @@ const PROJECT_COMMANDS: Record<string, CommandEntry> = {
|
|
|
52
52
|
description: 'Initialize config in a new project',
|
|
53
53
|
category: 'Project',
|
|
54
54
|
reference: [
|
|
55
|
-
`${PROFILE_ARGUMENTS} [--no-install]`,
|
|
55
|
+
`${PROFILE_ARGUMENTS} [--no-install] [--dry-run]`,
|
|
56
56
|
'developer',
|
|
57
57
|
['files', 'network', 'processes'],
|
|
58
58
|
],
|
|
@@ -52,6 +52,7 @@ const PROBE_ENVIRONMENT = { TZ: 'UTC', LC_ALL: 'C' }
|
|
|
52
52
|
// `process.kill(pid, 0)` runs every permission check and delivers nothing, so it is the standard
|
|
53
53
|
// liveness probe: it throws `ESRCH` where the process is gone.
|
|
54
54
|
const LIVENESS_SIGNAL = 0
|
|
55
|
+
const NO_SUCH_PROCESS = 'ESRCH'
|
|
55
56
|
// Below this a pid is not a process: `0` addresses the caller's own process group and anything
|
|
56
57
|
// negative addresses another group, so a truncated or hand-edited record carrying either would
|
|
57
58
|
// otherwise answer "alive" forever.
|
|
@@ -79,18 +80,28 @@ interface StartProbes {
|
|
|
79
80
|
read_ps_start: (pid: number) => string | undefined
|
|
80
81
|
}
|
|
81
82
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
83
|
+
// The code the liveness probe was refused with, or nothing where the caller may signal the process.
|
|
84
|
+
function signal_refusal(pid: number): string | undefined {
|
|
85
85
|
try {
|
|
86
86
|
process.kill(pid, LIVENESS_SIGNAL)
|
|
87
87
|
|
|
88
|
-
return
|
|
89
|
-
} catch {
|
|
90
|
-
return
|
|
88
|
+
return undefined
|
|
89
|
+
} catch (error) {
|
|
90
|
+
return (error as NodeJS.ErrnoException).code ?? NO_SUCH_PROCESS
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
function is_live_pid(pid: number): boolean {
|
|
95
|
+
return pid >= LOWEST_REAL_PID && signal_refusal(pid) === undefined
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// **Whether a pid names any process at all, for a reader that may not be allowed to signal it.** Only
|
|
99
|
+
// `ESRCH` proves the process gone: `EPERM` is the kernel refusing a signal to a process it has just
|
|
100
|
+
// found — one under another user, or outside a sandbox that may only signal its own — so it is present.
|
|
101
|
+
function is_present_pid(pid: number): boolean {
|
|
102
|
+
return pid >= LOWEST_REAL_PID && signal_refusal(pid) !== NO_SUCH_PROCESS
|
|
103
|
+
}
|
|
104
|
+
|
|
94
105
|
// Linux exposes field 22 as the process start time in clock ticks since boot. The command name in
|
|
95
106
|
// field 2 may itself contain spaces or `)`, so parsing starts after its final closing parenthesis;
|
|
96
107
|
// `state` is then index 0 and `starttime` index 19. A direct file read avoids process enumeration and
|
|
@@ -315,6 +326,19 @@ function is_same_process(
|
|
|
315
326
|
return matches_recorded_start(pid, recorded_start, read)
|
|
316
327
|
}
|
|
317
328
|
|
|
329
|
+
// `is_same_process` for a reader that may not be allowed to signal the writer (`is_present_pid`): a
|
|
330
|
+
// refused signal still compares the start time, so a reissued pid separates from its writer here too.
|
|
331
|
+
function is_present_process(
|
|
332
|
+
pid: number,
|
|
333
|
+
recorded_start: string | undefined,
|
|
334
|
+
read: (pid: number) => string | undefined = read_start,
|
|
335
|
+
): boolean | undefined {
|
|
336
|
+
if (!is_present_pid(pid)) return false
|
|
337
|
+
if (recorded_start === undefined) return undefined
|
|
338
|
+
|
|
339
|
+
return matches_recorded_start(pid, recorded_start, read)
|
|
340
|
+
}
|
|
341
|
+
|
|
318
342
|
// **Whether a record is the caller's own, which is a different question from whether its writer is
|
|
319
343
|
// alive**. `is_same_process` asks about the recorded process; this asks
|
|
320
344
|
// whether that process *is this one*. A supervisor that has been replaced reads its successor's
|
|
@@ -339,6 +363,8 @@ const process_identity = {
|
|
|
339
363
|
is_live_beacon,
|
|
340
364
|
is_live_pid,
|
|
341
365
|
is_own_process,
|
|
366
|
+
is_present_pid,
|
|
367
|
+
is_present_process,
|
|
342
368
|
is_same_process,
|
|
343
369
|
open_beacon,
|
|
344
370
|
own_fields,
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { process_identity } from '#scripts/josh/process-identity'
|
|
3
|
+
import { stamp_file } from '#scripts/josh/stamp-file'
|
|
4
|
+
import { json_value } from '#scripts/lib/json-value'
|
|
5
|
+
import { z } from 'zod'
|
|
6
|
+
|
|
7
|
+
// Which process serves a lane's brokered sessions, recorded beside the response it answers in.
|
|
8
|
+
//
|
|
9
|
+
// **A server is live while its process is, never while its event loop happens to answer.** One
|
|
10
|
+
// supervisor serves every Claude lane beside its drive passes, so any synchronous call in it — a
|
|
11
|
+
// `spawnSync` of an agent CLI's `--version` alone may take 10 s — stalled the timer-written heartbeat
|
|
12
|
+
// past its limit and failed every review then in flight (joshuafolkken/kit#3840).
|
|
13
|
+
//
|
|
14
|
+
// **A record of its own rather than fields on the response**, because a ship branched before this
|
|
15
|
+
// record existed parses the response strictly and reads any new field as no server at all; the
|
|
16
|
+
// response keeps the shape and heartbeat that ship reads, and this record names the writer by
|
|
17
|
+
// `process_identity.own_fields()`, so a pid the operating system reissued is not taken for it.
|
|
18
|
+
|
|
19
|
+
const SERVER_SUFFIX = '.server'
|
|
20
|
+
const server_schema = z
|
|
21
|
+
.object({
|
|
22
|
+
session: z.uuid(),
|
|
23
|
+
pid: z.number().int().positive(),
|
|
24
|
+
process_start: z.string().optional(),
|
|
25
|
+
})
|
|
26
|
+
.strict()
|
|
27
|
+
type BrokerServer = z.infer<typeof server_schema>
|
|
28
|
+
|
|
29
|
+
function server_path(response: string): string {
|
|
30
|
+
return `${response}${SERVER_SUFFIX}`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function write(response: string, session: string): void {
|
|
34
|
+
stamp_file.replace_stamp(server_path(response), { session, ...process_identity.own_fields() })
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function remove(response: string): void {
|
|
38
|
+
stamp_file.remove_stamp(server_path(response))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Only the record of the session the response names: one left by an earlier server is no answer.
|
|
42
|
+
function read(response: string, session: string): BrokerServer | undefined {
|
|
43
|
+
try {
|
|
44
|
+
const server = json_value.parse_with(readFileSync(server_path(response), 'utf8'), server_schema)
|
|
45
|
+
|
|
46
|
+
return server?.session === session ? server : undefined
|
|
47
|
+
} catch {
|
|
48
|
+
return undefined
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// **The identity is checked when a ship first finds the server, the pid while it waits.** A response
|
|
53
|
+
// a killed supervisor left behind is met on that first look, where a reissued pid must not read as
|
|
54
|
+
// it; the wait polls every 100 ms, where a start-time probe would spawn `ps` on each. Both read
|
|
55
|
+
// through `is_present_*`, because a Codex lane's ship probes from inside its sandbox, where the kernel
|
|
56
|
+
// answers `EPERM` for a supervisor it has found. A start time that cannot be read is no proof of a
|
|
57
|
+
// dead server, so it does not fail the review.
|
|
58
|
+
function is_serving_process(server: BrokerServer): boolean {
|
|
59
|
+
return process_identity.is_present_process(server.pid, server.process_start) !== false
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function is_running(server: BrokerServer): boolean {
|
|
63
|
+
return process_identity.is_present_pid(server.pid)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const lane_broker_server = { is_running, is_serving_process, read, remove, server_path, write }
|
|
67
|
+
|
|
68
|
+
export type { BrokerServer }
|
|
69
|
+
export { lane_broker_server }
|
|
@@ -12,6 +12,7 @@ import { detached_launch } from '#scripts/run/detached-launch'
|
|
|
12
12
|
import { run_ship_review } from '#scripts/run/ship/run-ship-review'
|
|
13
13
|
import { run_ship_stage } from '#scripts/run/ship/run-ship-stage'
|
|
14
14
|
import { z } from 'zod'
|
|
15
|
+
import { lane_broker_server, type BrokerServer } from './lane-broker-server'
|
|
15
16
|
import { lane_child_invocation, type ShipStop } from './lane-child-invocation'
|
|
16
17
|
import { lane_child_marker } from './lane-child-marker'
|
|
17
18
|
import type { LaneInfo } from './lane-registry'
|
|
@@ -32,8 +33,10 @@ const FINDINGS_PREFIX = 'josh-ship-review-findings-'
|
|
|
32
33
|
const LOG_PREFIX = 'josh-ship-review-log-'
|
|
33
34
|
const CACHE_DIRECTORY = ['node_modules', '.cache']
|
|
34
35
|
const POLL_MS = 100
|
|
35
|
-
|
|
36
|
-
|
|
36
|
+
// The heartbeat is kept for a ship branched before the server record, and read only where a
|
|
37
|
+
// supervisor started before it left no such record (`lane-broker-server.ts`).
|
|
38
|
+
const LEGACY_HEARTBEAT_MS = 500
|
|
39
|
+
const LEGACY_HEARTBEAT_LIMIT_MS = 3000
|
|
37
40
|
const MINUTE_MS = 60_000
|
|
38
41
|
const REQUEST_LIMIT_MINUTES = 30
|
|
39
42
|
const REQUEST_LIMIT_MS = REQUEST_LIMIT_MINUTES * MINUTE_MS
|
|
@@ -203,6 +206,7 @@ async function serve(
|
|
|
203
206
|
}
|
|
204
207
|
}
|
|
205
208
|
|
|
209
|
+
// The server record is written before the response, so a ship that finds the response finds it too.
|
|
206
210
|
function prepare(lane: LaneInfo): { response: string; current: BrokerResponse } {
|
|
207
211
|
const response = response_path(lane.directory, lane.issue)
|
|
208
212
|
if (response === undefined) throw new Error('review broker has no trusted response location')
|
|
@@ -211,6 +215,7 @@ function prepare(lane: LaneInfo): { response: string; current: BrokerResponse }
|
|
|
211
215
|
|
|
212
216
|
mkdirSync(path.dirname(response), { recursive: true })
|
|
213
217
|
mkdirSync(path.dirname(request_path(lane.directory, lane.issue)), { recursive: true })
|
|
218
|
+
lane_broker_server.write(response, session)
|
|
214
219
|
const current: BrokerResponse = {
|
|
215
220
|
session,
|
|
216
221
|
nonce: '',
|
|
@@ -227,7 +232,7 @@ function start_heartbeat(state: { current: BrokerResponse }, response: string):
|
|
|
227
232
|
const heartbeat = setInterval(() => {
|
|
228
233
|
state.current = { ...state.current, heartbeat_at: Date.now() }
|
|
229
234
|
write_response(response, state.current)
|
|
230
|
-
},
|
|
235
|
+
}, LEGACY_HEARTBEAT_MS)
|
|
231
236
|
|
|
232
237
|
heartbeat.unref()
|
|
233
238
|
|
|
@@ -246,6 +251,7 @@ async function stop_server(
|
|
|
246
251
|
await serving
|
|
247
252
|
clearInterval(heartbeat)
|
|
248
253
|
unlinkSync(response)
|
|
254
|
+
lane_broker_server.remove(response)
|
|
249
255
|
}
|
|
250
256
|
|
|
251
257
|
async function with_server<T>(lane: LaneInfo, run: () => Promise<T>): Promise<T> {
|
|
@@ -261,25 +267,40 @@ async function with_server<T>(lane: LaneInfo, run: () => Promise<T>): Promise<T>
|
|
|
261
267
|
}
|
|
262
268
|
}
|
|
263
269
|
|
|
270
|
+
function is_fresh_heartbeat(heartbeat_at: number): boolean {
|
|
271
|
+
const now = Date.now()
|
|
272
|
+
|
|
273
|
+
return heartbeat_at <= now && heartbeat_at >= now - LEGACY_HEARTBEAT_LIMIT_MS
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Live by the server record (`lane-broker-server.ts`); a response with none was written by a
|
|
277
|
+
// supervisor started before it, read by the heartbeat that supervisor still writes.
|
|
264
278
|
function is_live_response(
|
|
279
|
+
target: string,
|
|
265
280
|
response: BrokerResponse | undefined,
|
|
266
|
-
|
|
281
|
+
is_live: (server: BrokerServer) => boolean,
|
|
267
282
|
): response is BrokerResponse {
|
|
268
|
-
|
|
283
|
+
if (response === undefined) return false
|
|
269
284
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
285
|
+
const server = lane_broker_server.read(target, response.session)
|
|
286
|
+
|
|
287
|
+
return server === undefined ? is_fresh_heartbeat(response.heartbeat_at) : is_live(server)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function session_response(target: string, session: string): BrokerResponse | undefined {
|
|
291
|
+
const response = read_record(target, response_schema)
|
|
292
|
+
|
|
293
|
+
return response?.session === session ? response : undefined
|
|
275
294
|
}
|
|
276
295
|
|
|
277
296
|
function response_verdict(
|
|
278
|
-
|
|
297
|
+
target: string,
|
|
279
298
|
session: string,
|
|
280
299
|
nonce: string,
|
|
281
300
|
): 'wait' | 'success' | 'failure' {
|
|
282
|
-
|
|
301
|
+
const response = session_response(target, session)
|
|
302
|
+
|
|
303
|
+
if (!is_live_response(target, response, lane_broker_server.is_running)) return 'failure'
|
|
283
304
|
if (response.nonce !== nonce) return 'wait'
|
|
284
305
|
|
|
285
306
|
return response.is_success ? 'success' : 'failure'
|
|
@@ -289,7 +310,7 @@ async function wait_response(target: string, session: string, nonce: string): Pr
|
|
|
289
310
|
const deadline = Date.now() + REQUEST_LIMIT_MS
|
|
290
311
|
|
|
291
312
|
while (Date.now() < deadline) {
|
|
292
|
-
const verdict = response_verdict(
|
|
313
|
+
const verdict = response_verdict(target, session, nonce)
|
|
293
314
|
if (verdict !== 'wait') return verdict === 'success'
|
|
294
315
|
|
|
295
316
|
// eslint-disable-next-line no-await-in-loop -- polling: each read waits on the state the previous one saw
|
|
@@ -302,7 +323,9 @@ async function wait_response(target: string, session: string, nonce: string): Pr
|
|
|
302
323
|
function live_response(target: string): BrokerResponse | undefined {
|
|
303
324
|
const response = read_record(target, response_schema)
|
|
304
325
|
|
|
305
|
-
return is_live_response(
|
|
326
|
+
return is_live_response(target, response, lane_broker_server.is_serving_process)
|
|
327
|
+
? response
|
|
328
|
+
: undefined
|
|
306
329
|
}
|
|
307
330
|
|
|
308
331
|
// Whether a server answers for this lane now — what a Claude lane's ship asks before it hands a session
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
rmSync,
|
|
9
9
|
} from 'node:fs'
|
|
10
10
|
import path from 'node:path'
|
|
11
|
+
import { process_identity } from '#scripts/josh/process-identity'
|
|
11
12
|
import { file_reader } from '#scripts/lib/read-file'
|
|
12
13
|
import {
|
|
13
14
|
LEDGER_FILE_EXTENSION,
|
|
@@ -38,8 +39,6 @@ import { observation_ledger_line } from './observation-ledger-line'
|
|
|
38
39
|
// migration with no await point cannot be interleaved with another write in the same process.
|
|
39
40
|
|
|
40
41
|
const NEWLINE = '\n'
|
|
41
|
-
const NO_SIGNAL = 0
|
|
42
|
-
const NO_SUCH_PROCESS = 'ESRCH'
|
|
43
42
|
|
|
44
43
|
function claim_path(legacy: string): string {
|
|
45
44
|
return `${legacy}.${String(process.pid)}${MIGRATION_CLAIM_SUFFIX}`
|
|
@@ -55,18 +54,6 @@ function claim(from: string, to: string): boolean {
|
|
|
55
54
|
}
|
|
56
55
|
}
|
|
57
56
|
|
|
58
|
-
// Only `ESRCH` means the process is gone; `EPERM` means it runs under another user, and its claim is
|
|
59
|
-
// still its own.
|
|
60
|
-
function is_alive(pid: number): boolean {
|
|
61
|
-
try {
|
|
62
|
-
process.kill(pid, NO_SIGNAL)
|
|
63
|
-
|
|
64
|
-
return true
|
|
65
|
-
} catch (error) {
|
|
66
|
-
return (error as NodeJS.ErrnoException).code !== NO_SUCH_PROCESS
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
57
|
// A claim is named `<old ledger>.<pid>.migrating`, so the pid is what sits between the two.
|
|
71
58
|
function is_stale_claim(name: string, legacy_name: string): boolean {
|
|
72
59
|
const prefix = `${legacy_name}.`
|
|
@@ -75,7 +62,8 @@ function is_stale_claim(name: string, legacy_name: string): boolean {
|
|
|
75
62
|
|
|
76
63
|
const pid = Number(name.slice(prefix.length, -MIGRATION_CLAIM_SUFFIX.length))
|
|
77
64
|
|
|
78
|
-
|
|
65
|
+
// A process under another user still holds its claim (`process_identity.is_present_pid`).
|
|
66
|
+
return Number.isSafeInteger(pid) && !process_identity.is_present_pid(pid)
|
|
79
67
|
}
|
|
80
68
|
|
|
81
69
|
function stale_claims(legacy: string): ReadonlyArray<string> {
|