@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.
Files changed (88) hide show
  1. package/.claude/skills/workflow-commands/backlogrun-park.md +2 -0
  2. package/.claude/skills/workflow-commands/backlogrun-steps.md +9 -7
  3. package/.claude/skills/workflow-commands/backlogrun.md +2 -2
  4. package/.claude/skills/workflow-commands/fullrun-steps.md +1 -1
  5. package/.claude/skills/workflow-commands/halfrun.md +1 -1
  6. package/.claude/skills/workflow-commands/kickoff.md +12 -13
  7. package/.claude/skills/workflow-commands/observation-filing.md +1 -1
  8. package/.claude/skills/workflow-commands/prerequisite.md +6 -0
  9. package/.claude/skills/workflow-commands/retrospective.md +5 -5
  10. package/.claude/skills/workflow-commands/split-assessment.md +2 -2
  11. package/README.md +15 -17
  12. package/dist/hooks/batch-guard.js +6 -6
  13. package/dist/hooks/{chunk-3LE6VLSR.js → chunk-3C4XRYG6.js} +18 -5
  14. package/dist/hooks/{chunk-JXQBK323.js → chunk-5C7L6FRC.js} +131 -104
  15. package/dist/hooks/{chunk-EMB55BDF.js → chunk-D3NJXN5M.js} +2 -2
  16. package/dist/hooks/{chunk-7KMN5QB5.js → chunk-EF5GQ6WS.js} +3 -3
  17. package/dist/hooks/{chunk-2G5F2YCD.js → chunk-LBFYEOXJ.js} +3 -3
  18. package/dist/hooks/{chunk-XER6SSIB.js → chunk-MBJYQPEK.js} +16 -6
  19. package/dist/hooks/{chunk-QL6QTPWN.js → chunk-NO4AALP5.js} +3 -3
  20. package/dist/hooks/{chunk-4BHUPFKF.js → chunk-OLMO72ON.js} +1 -1
  21. package/dist/hooks/{chunk-MSYSUSY6.js → chunk-SHYUQOIS.js} +5 -5
  22. package/dist/hooks/{chunk-5SJSSCJG.js → chunk-UT7YC3N2.js} +1 -1
  23. package/dist/hooks/{chunk-MU4LJFIV.js → chunk-W5ZKGTSY.js} +3 -3
  24. package/dist/hooks/{chunk-YZXYOAZ3.js → chunk-Y3GKIKGW.js} +11 -5
  25. package/dist/hooks/{chunk-YW452ZAX.js → chunk-Z4XI7CZS.js} +2 -2
  26. package/dist/hooks/codex-hook-adapter.js +13 -13
  27. package/dist/hooks/duplicate-read-guard.js +7 -7
  28. package/dist/hooks/format-edited.js +3 -3
  29. package/dist/hooks/inputs.json +241 -240
  30. package/dist/hooks/investigation-guard.js +7 -7
  31. package/dist/hooks/pretool-guard.js +12 -12
  32. package/dist/hooks/session-lang.js +3 -3
  33. package/dist/hooks/stop-guard.js +6 -6
  34. package/dist/josh.js +33 -10
  35. package/package.json +1 -1
  36. package/prompts/collaboration-workflow/issue-template.md +10 -8
  37. package/prompts/review.md +8 -4
  38. package/scripts/auto-ok/auto-ok-judge-cli.ts +125 -0
  39. package/scripts/auto-ok/auto-ok-judge-read.ts +95 -0
  40. package/scripts/auto-ok/auto-ok-judge-write.ts +109 -0
  41. package/scripts/auto-ok/auto-ok-judge.ts +67 -0
  42. package/scripts/backlog/backlog-drive-cli.ts +14 -0
  43. package/scripts/backlog/backlog-drive.ts +22 -3
  44. package/scripts/document/document-byte-budget.ts +1 -1
  45. package/scripts/fresh-machine/fresh-machine-patterns.ts +60 -15
  46. package/scripts/gate/project-checks.ts +5 -2
  47. package/scripts/gh/git-gh-issue-rest.ts +1 -0
  48. package/scripts/gh/git-gh-issue.ts +9 -0
  49. package/scripts/init/init-actions.ts +3 -3
  50. package/scripts/init/init-ai-copy.ts +1 -1
  51. package/scripts/init/init-basic.ts +1 -1
  52. package/scripts/init/init-bootstrap.ts +6 -2
  53. package/scripts/init/init-dry-run-report.ts +150 -0
  54. package/scripts/init/init-dry-run.ts +189 -0
  55. package/scripts/init/init-install.ts +8 -1
  56. package/scripts/init/init.ts +57 -19
  57. package/scripts/init/kit-setup-state.ts +11 -17
  58. package/scripts/init/own-formatter.ts +21 -0
  59. package/scripts/init/project-profile.ts +25 -6
  60. package/scripts/init/start-dry-run.ts +70 -0
  61. package/scripts/init/start-plan.ts +8 -3
  62. package/scripts/init/start-setup-pr.ts +13 -24
  63. package/scripts/init/start-steps.ts +16 -1
  64. package/scripts/init/start.ts +38 -18
  65. package/scripts/init/working-tree.ts +116 -0
  66. package/scripts/issue/issue-auto-ok.ts +68 -99
  67. package/scripts/issue/issue-file-cli.ts +17 -17
  68. package/scripts/issue/issue-file.ts +1 -6
  69. package/scripts/issue/issue-labels.ts +6 -2
  70. package/scripts/issue/issue-release-cli.ts +69 -23
  71. package/scripts/issue/issue-release.ts +3 -1
  72. package/scripts/josh/josh-commands-ai.ts +11 -0
  73. package/scripts/josh/josh-commands-issue.ts +3 -3
  74. package/scripts/josh/josh-commands-project.ts +2 -2
  75. package/scripts/josh/process-identity.ts +32 -6
  76. package/scripts/lane/lane-broker-server.ts +69 -0
  77. package/scripts/lane/lane-session-broker.ts +37 -14
  78. package/scripts/observations/observation-ledger-migrate.ts +3 -15
  79. package/scripts/release/release-issue-close.ts +133 -0
  80. package/scripts/release/release-publish.ts +12 -1
  81. package/scripts/repo/repository-labels.ts +11 -1
  82. package/scripts/review/comment-ranges.ts +199 -0
  83. package/scripts/review/review-brief-cli.ts +22 -5
  84. package/scripts/review/review-code-tree.ts +119 -0
  85. package/scripts/review/review-round2-cli.ts +16 -5
  86. package/scripts/review/review-round2.ts +30 -10
  87. package/scripts/review/review-stamps.ts +22 -1
  88. 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 linked_line(release: ReleaseIssue, issue_number: number, target: string): string {
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 `release: ${release_cite} (${how}) is blocked by ${session_cite.issue(issue_number, undefined, target)}`
115
+ return `${session_cite.issue(release.issue_number, undefined, target)} (${how})`
99
116
  }
100
117
 
101
- function unlinked_line(issue_number: number, blocked: string, target: string): string {
102
- const blocker = session_cite.issue(issue_number, undefined, target)
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 `✖ ${blocker} could not be recorded as a blocker of ${session_cite.issue(blocked, undefined, target)}`
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
- // Whether Issue `issue_number` of `target` now blocks the release Issue. Every outcome is printed.
108
- async function link(issue_number: number, target: string): Promise<boolean> {
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 blocked = String(release.issue_number)
113
- const is_linked = await git_gh_issue_write.issue_add_blocked_by(blocked, String(issue_number))
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, issue_number, target))
116
- else console.error(unlinked_line(issue_number, blocked, target))
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
- // An unknown flag parses to no positionals, so it refuses the call like a missing number.
122
- function positionals_of(argv: ReadonlyArray<string>): ReadonlyArray<string> {
123
- return cli_flags.arguments_of(argv, {})?.positionals ?? []
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(argv: ReadonlyArray<string>): number | undefined {
128
- const [issue_number, ...rest] = positionals_of(argv)
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 issue_number = issue_number_of(argv)
183
+ const request = request_of(argv)
138
184
 
139
- if (issue_number === undefined) {
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 link(issue_number, current)) ? SUCCESS_EXIT_CODE : FAILURE_EXIT_CODE
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
- 'Close this Issue once that release is published: the next blocker files a new one, so an open release Issue names only unreleased work.',
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] [--requested] [--release]',
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
- function is_live_pid(pid: number): boolean {
83
- if (pid < LOWEST_REAL_PID) return false
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 true
89
- } catch {
90
- return false
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
- const HEARTBEAT_MS = 500
36
- const HEARTBEAT_LIMIT_MS = 3000
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
- }, HEARTBEAT_MS)
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
- session: string,
281
+ is_live: (server: BrokerServer) => boolean,
267
282
  ): response is BrokerResponse {
268
- const now = Date.now()
283
+ if (response === undefined) return false
269
284
 
270
- return (
271
- response?.session === session &&
272
- response.heartbeat_at <= now &&
273
- response.heartbeat_at >= now - HEARTBEAT_LIMIT_MS
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
- response: BrokerResponse | undefined,
297
+ target: string,
279
298
  session: string,
280
299
  nonce: string,
281
300
  ): 'wait' | 'success' | 'failure' {
282
- if (!is_live_response(response, session)) return 'failure'
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(read_record(target, response_schema), session, nonce)
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(response, response?.session ?? '') ? response : undefined
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
- return Number.isSafeInteger(pid) && !is_alive(pid)
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> {