Skip to content

Commit bff219c

Browse files
alari76claude
andauthored
feat: template fixes, child transcript endpoint, org-aware repo discovery (Joe 5/5) (#505)
* feat: realtime blocked-child notifications for the orchestrator When a child session blocks on a tool approval or question, the parent orchestrator now gets an immediate push notification with the requestId and the exact API call to respond, instead of the child silently dying at timeout. Adds a 'blocked' child status, stops auto-denying pending prompts when the last client leaves an agent session, and replaces the headless-agent fast-deny with the normal prompt flow (5-min timeout remains the backstop). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: persistent notification outbox with replay when the orchestrator returns Notifications to the orchestrator (Agent Joe) were silently dropped when its Claude process was not running — terminal child-session events and monitor alerts vanished, so Joe woke up with no idea what happened while it was down. - New OrchestratorOutbox: JSON-file-backed queue (survives restarts, capped at 200 items) with a 60s background flusher that replays queued items as a single digest message once the orchestrator session is alive again. Flush is gated on the rate-limit circuit breaker and clears the queue before sending to avoid double-delivery. - sendOrchestratorNotification now enqueues to the outbox when the parent is unreachable and returns true (the outbox owns delivery from that point); false only when queueing itself fails. - OrchestratorMonitor.deliverToOrchestrator enqueues instead of dropping when the orchestrator process is down. - Flusher wired up at server boot in ws-server.ts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: pausable child timeouts, broader allowlist, worktree status in spawn response Blocked children used to burn their whole timeout waiting on an approval the orchestrator never saw — with PR1's realtime blocked notifications in place, the timeout now pauses while a child waits for an answer. - Working-time clock pauses when a child blocks on an approval/question and resumes (with the remaining budget) once the prompt is answered; a separate 30-min blocked-time cap still terminates abandoned children. - DEFAULT_TIMEOUT_MS raised from 10 to 30 minutes; spawn API now accepts and validates timeoutMs (1 min – 4 h), documented in Joe's CLAUDE.md. - AGENT_CHILD_ALLOWED_TOOLS broadened: python3, pytest, sed, rg, jq, mkdir, cp, mv, touch (rm/sudo/docker still require approval). - ChildSession now reports worktree status ('active'/'failed'/'none') and worktreePath so the orchestrator knows when isolation failed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: verify child completion against ground truth instead of transcript sniffing Completion checks used to grep the child transcript for keywords like "pull request" — false-positive on mentions, false-negative on terse output — and treated >100 chars of output as success on process exit. - isFinalStepMissing asks the real systems: gh pr list --head <branch> for 'pr' policy, git ls-remote --heads origin <branch> for 'merge' (run in the child's worktree, injectable exec for tests). Transcript sniffing remains only as a fallback when the command itself fails. - Children are nudged once when the final step is missing; if it still doesn't land, the terminal notification carries a "Completion not verified" note so the orchestrator knows to follow up. - Exit-path status now derives from ground truth, not output length; removed the dead supersededMsgs tracking. - Joe's CLAUDE.md template: drop the "poll children every 30 min" cron example — the server pushes blocked/terminal notifications and owns completion verification now. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: template fixes + version refresh, child transcript endpoint, org-aware repo discovery (Joe 5/5) - Fix broken backtick escaping in CLAUDE_MD_TEMPLATE so seeded files render real code fences, and stamp the template with a version comment; CLAUDE.md is now system-managed and refreshed automatically when the template version bumps (PROFILE.md/REPOS.md remain seed-once user memory). - Add GET /api/orchestrator/children/:id/transcript so the orchestrator can read the tail of a child's output (e.g. after "Completion not verified") without attaching to the session. - Make repo discovery in the monitor recurse one level so org-style layouts (REPOS_ROOT/org/repo) are picked up alongside flat ones. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent cac3feb commit bff219c

6 files changed

Lines changed: 297 additions & 34 deletions

server/orchestrator-manager.test.ts

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ import {
3333
isOrchestratorSession,
3434
ensureOrchestratorRunning,
3535
getOrchestratorSessionId,
36+
readTemplateVersion,
37+
CLAUDE_MD_TEMPLATE_VERSION,
3638
} from './orchestrator-manager.js'
3739

3840
function fakeSessionManager(existingSession?: any) {
@@ -97,15 +99,56 @@ describe('ensureOrchestratorDir', () => {
9799
expect(mockWriteFileSync).toHaveBeenCalledTimes(3)
98100
})
99101

100-
it('does not overwrite existing files', () => {
101-
// All paths exist
102+
it('does not overwrite existing files when CLAUDE.md is current', () => {
103+
// All paths exist and CLAUDE.md carries the current template version
102104
mockExistsSync.mockReturnValue(true)
105+
mockReadFileSync.mockReturnValue(
106+
`<!-- codekin-template-version: ${CLAUDE_MD_TEMPLATE_VERSION} -->\n# custom`,
107+
)
103108

104109
ensureOrchestratorDir()
105110

106111
expect(mockMkdirSync).not.toHaveBeenCalled()
107112
expect(mockWriteFileSync).not.toHaveBeenCalled()
108113
})
114+
115+
it('refreshes CLAUDE.md when its template version is stale, leaving seed files alone', () => {
116+
mockExistsSync.mockReturnValue(true)
117+
// Unstamped (pre-versioning) CLAUDE.md → version 0 → stale
118+
mockReadFileSync.mockReturnValue('# Agent — old template without a stamp')
119+
120+
ensureOrchestratorDir()
121+
122+
const writtenPaths = mockWriteFileSync.mock.calls.map((c: any[]) => c[0])
123+
expect(writtenPaths).toEqual(['/tmp/test-data/orchestrator/CLAUDE.md'])
124+
const written = mockWriteFileSync.mock.calls[0][1] as string
125+
expect(written).toContain(`<!-- codekin-template-version: ${CLAUDE_MD_TEMPLATE_VERSION} -->`)
126+
})
127+
})
128+
129+
describe('readTemplateVersion', () => {
130+
it('returns 0 when the file does not exist', () => {
131+
mockExistsSync.mockReturnValue(false)
132+
expect(readTemplateVersion('/tmp/none/CLAUDE.md')).toBe(0)
133+
})
134+
135+
it('returns 0 when the file has no version stamp', () => {
136+
mockExistsSync.mockReturnValue(true)
137+
mockReadFileSync.mockReturnValue('# no stamp here')
138+
expect(readTemplateVersion('/tmp/x/CLAUDE.md')).toBe(0)
139+
})
140+
141+
it('parses the stamped version', () => {
142+
mockExistsSync.mockReturnValue(true)
143+
mockReadFileSync.mockReturnValue('<!-- codekin-template-version: 7 -->\n# hi')
144+
expect(readTemplateVersion('/tmp/x/CLAUDE.md')).toBe(7)
145+
})
146+
147+
it('returns 0 when reading throws', () => {
148+
mockExistsSync.mockReturnValue(true)
149+
mockReadFileSync.mockImplementation(() => { throw new Error('EACCES') })
150+
expect(readTemplateVersion('/tmp/x/CLAUDE.md')).toBe(0)
151+
})
109152
})
110153

111154
describe('getOrCreateOrchestratorId', () => {

server/orchestrator-manager.ts

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,17 @@ Agent ${AGENT_DISPLAY_NAME} tracks repositories you work with in Codekin.
3535
(none yet — ${AGENT_DISPLAY_NAME} will populate this as you work)
3636
`
3737

38-
const CLAUDE_MD_TEMPLATE = `# Agent ${AGENT_DISPLAY_NAME} — Codekin Orchestrator
38+
/**
39+
* Bump this whenever CLAUDE_MD_TEMPLATE changes. Already-seeded CLAUDE.md
40+
* files carrying an older (or no) version stamp are refreshed on boot —
41+
* without this, installs keep running on stale orchestrator instructions
42+
* forever. CLAUDE.md is system-managed; user memory lives in PROFILE.md,
43+
* REPOS.md and journal/, which are never overwritten.
44+
*/
45+
export const CLAUDE_MD_TEMPLATE_VERSION = 2
46+
47+
const CLAUDE_MD_TEMPLATE = `<!-- codekin-template-version: ${CLAUDE_MD_TEMPLATE_VERSION} -->
48+
# Agent ${AGENT_DISPLAY_NAME} — Codekin Orchestrator
3949
4050
You are ${AGENT_DISPLAY_NAME}, a calm and friendly ops manager inside Codekin.
4151
You help users keep their repositories healthy, their workflows running
@@ -174,6 +184,12 @@ curl -s "http://localhost:$CODEKIN_PORT/api/orchestrator/children" \\
174184
# Get specific child session
175185
curl -s "http://localhost:$CODEKIN_PORT/api/orchestrator/children/SESSION_ID" \\
176186
-H "Authorization: Bearer $CODEKIN_AUTH_TOKEN"
187+
188+
# Read the tail of a child's transcript (what Claude actually output).
189+
# Useful when a child stops with "Completion not verified" or gets stuck.
190+
# ?limit caps the returned characters (default 5000, max 50000).
191+
curl -s "http://localhost:$CODEKIN_PORT/api/orchestrator/children/SESSION_ID/transcript?limit=10000" \\
192+
-H "Authorization: Bearer $CODEKIN_AUTH_TOKEN"
177193
\`\`\`
178194
179195
## Scheduling Reminders & Recurring Tasks
@@ -214,31 +230,31 @@ inspect the worktree and finish it or respawn.
214230
Sessions can get stuck waiting for tool approvals or user answers. You can
215231
discover and unblock them:
216232
217-
\\\`\\\`\\\`bash
233+
\`\`\`bash
218234
# List all sessions with pending prompts
219235
curl -s "http://localhost:$CODEKIN_PORT/api/orchestrator/sessions/pending-prompts" \\
220236
-H "Authorization: Bearer $CODEKIN_AUTH_TOKEN"
221-
\\\`\\\`\\\`
237+
\`\`\`
222238
223-
Returns sessions with their pending prompts, including the \\\`requestId\\\`,
224-
\\\`toolName\\\`, and \\\`promptType\\\` ("permission" or "question").
239+
Returns sessions with their pending prompts, including the \`requestId\`,
240+
\`toolName\`, and \`promptType\` ("permission" or "question").
225241
226242
### Giving Approvals to Stuck Sessions
227243
If a child session is blocked on a tool approval and you're confident it's
228244
safe, you can approve it directly:
229245
230-
\\\`\\\`\\\`bash
246+
\`\`\`bash
231247
curl -s -X POST "http://localhost:$CODEKIN_PORT/api/orchestrator/sessions/SESSION_ID/respond" \\
232248
-H "Authorization: Bearer $CODEKIN_AUTH_TOKEN" \\
233249
-H "Content-Type: application/json" \\
234250
-d '{"requestId": "REQUEST_ID", "value": "allow"}'
235-
\\\`\\\`\\\`
251+
\`\`\`
236252
237-
Values: \\\`"allow"\\\`, \\\`"deny"\\\`, \\\`"always_allow"\\\`, or free text for question prompts.
253+
Values: \`"allow"\`, \`"deny"\`, \`"always_allow"\`, or free text for question prompts.
238254
239255
**Guidelines for giving approvals:**
240256
- Only approve tools you understand — if unsure, ask the user
241-
- Prefer \\\`"allow"\\\` over \\\`"always_allow"\\\` for child sessions
257+
- Prefer \`"allow"\` over \`"always_allow"\` for child sessions
242258
- Never approve destructive commands (rm -rf, git push --force, DROP TABLE)
243259
without user confirmation
244260
- For question prompts, provide a reasonable answer or ask the user
@@ -299,9 +315,9 @@ Users can manage trust directly in chat:
299315
4. Read skill-profile.json for guidance style adaptation
300316
5. Check for new audit reports that may have landed
301317
6. Check for decisions pending outcome assessment
302-
7. **Re-establish cron jobs** — cron jobs do not survive session restarts, so always re-create your standard recurring checks on startup:
318+
7. **Re-establish cron jobs** — cron jobs do not survive session restarts, so re-create your scheduled work on startup:
303319
- Report check: \`cron: "3 9 * * *"\`, \`prompt: "Check for new audit reports across all managed repos and triage any new findings"\`
304-
- Child session monitor: \`cron: "*/30 * * * *"\`, \`prompt: "Check child session status and unblock any stuck sessions"\`
320+
- Do NOT create a child-session polling cron — the server pushes blocked/terminal notifications to you in realtime.
305321
8. Greet the user with a brief, friendly status update
306322
307323
### Greeting Guidelines
@@ -321,15 +337,32 @@ export function ensureOrchestratorDir(): void {
321337
const journalDir = join(ORCHESTRATOR_DIR, 'journal')
322338
if (!existsSync(journalDir)) mkdirSync(journalDir, { recursive: true })
323339

324-
// Seed files only if they don't exist (preserve user edits)
340+
// Seed memory files only if they don't exist (preserve user edits)
325341
const seeds: [string, string][] = [
326342
[join(ORCHESTRATOR_DIR, 'PROFILE.md'), PROFILE_TEMPLATE],
327343
[join(ORCHESTRATOR_DIR, 'REPOS.md'), REPOS_TEMPLATE],
328-
[join(ORCHESTRATOR_DIR, 'CLAUDE.md'), CLAUDE_MD_TEMPLATE],
329344
]
330345
for (const [path, content] of seeds) {
331346
if (!existsSync(path)) writeFileSync(path, content, 'utf-8')
332347
}
348+
349+
// CLAUDE.md is system-managed: refresh it whenever the embedded template
350+
// version is older than the current one (or missing entirely).
351+
const claudeMdPath = join(ORCHESTRATOR_DIR, 'CLAUDE.md')
352+
if (readTemplateVersion(claudeMdPath) < CLAUDE_MD_TEMPLATE_VERSION) {
353+
writeFileSync(claudeMdPath, CLAUDE_MD_TEMPLATE, 'utf-8')
354+
}
355+
}
356+
357+
/** Parse the template version stamp from a seeded CLAUDE.md; 0 when absent. */
358+
export function readTemplateVersion(path: string): number {
359+
try {
360+
if (!existsSync(path)) return 0
361+
const match = /<!-- codekin-template-version: (\d+) -->/.exec(readFileSync(path, 'utf-8'))
362+
return match ? parseInt(match[1], 10) : 0
363+
} catch {
364+
return 0
365+
}
333366
}
334367

335368
/** Get or create a stable session UUID that persists across restarts. */

server/orchestrator-monitor.test.ts

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
/** Tests for the passive-repo notifier predicate — see #issue: alerts were
22
* firing for repos with zero enabled workflows, recommending de-scheduling
33
* workflows that didn't exist. */
4-
import { describe, it, expect } from 'vitest'
5-
import { hasEnabledWorkflowForRepo } from './orchestrator-monitor.js'
4+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
5+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs'
6+
import { tmpdir } from 'os'
7+
import { join } from 'path'
8+
import { hasEnabledWorkflowForRepo, discoverRepoPathsUnder } from './orchestrator-monitor.js'
69
import type { ReviewRepoConfig } from './workflow-config.js'
710

811
const make = (overrides: Partial<ReviewRepoConfig>): ReviewRepoConfig => ({
@@ -46,3 +49,55 @@ describe('hasEnabledWorkflowForRepo', () => {
4649
expect(hasEnabledWorkflowForRepo(repoPath, entries)).toBe(false)
4750
})
4851
})
52+
53+
describe('discoverRepoPathsUnder', () => {
54+
let root: string
55+
56+
beforeEach(() => {
57+
root = mkdtempSync(join(tmpdir(), 'codekin-discover-'))
58+
})
59+
60+
afterEach(() => {
61+
rmSync(root, { recursive: true, force: true })
62+
})
63+
64+
function makeRepo(...segments: string[]): string {
65+
const repo = join(root, ...segments)
66+
mkdirSync(join(repo, '.git'), { recursive: true })
67+
return repo
68+
}
69+
70+
it('returns [] when the root does not exist', () => {
71+
expect(discoverRepoPathsUnder(join(root, 'missing'))).toEqual([])
72+
})
73+
74+
it('finds flat repos directly under the root', () => {
75+
const a = makeRepo('repo-a')
76+
const b = makeRepo('repo-b')
77+
expect(discoverRepoPathsUnder(root).sort()).toEqual([a, b].sort())
78+
})
79+
80+
it('finds org-style repos one level down (root/org/repo)', () => {
81+
const flat = makeRepo('flat-repo')
82+
const nested = makeRepo('my-org', 'nested-repo')
83+
expect(discoverRepoPathsUnder(root).sort()).toEqual([flat, nested].sort())
84+
})
85+
86+
it('does not recurse past depth 2', () => {
87+
makeRepo('a', 'b', 'too-deep')
88+
expect(discoverRepoPathsUnder(root)).toEqual([])
89+
})
90+
91+
it('does not descend into repos looking for nested repos', () => {
92+
const outer = makeRepo('outer')
93+
mkdirSync(join(outer, 'vendor', 'inner', '.git'), { recursive: true })
94+
expect(discoverRepoPathsUnder(root)).toEqual([outer])
95+
})
96+
97+
it('ignores plain files at both levels', () => {
98+
writeFileSync(join(root, 'README.md'), 'hi')
99+
mkdirSync(join(root, 'org'))
100+
writeFileSync(join(root, 'org', 'notes.txt'), 'hi')
101+
expect(discoverRepoPathsUnder(root)).toEqual([])
102+
})
103+
})

server/orchestrator-monitor.ts

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -293,29 +293,46 @@ export class OrchestratorMonitor {
293293
notification.delivered = true
294294
}
295295

296-
/** Discover repo paths from REPOS_ROOT. */
296+
/** Discover repo paths from REPOS_ROOT (see discoverRepoPathsUnder). */
297297
private discoverRepoPaths(): string[] {
298-
if (!existsSync(REPOS_ROOT)) return []
299-
try {
300-
return readdirSync(REPOS_ROOT)
301-
.map(name => join(REPOS_ROOT, name))
302-
.filter(p => {
303-
try {
304-
return statSync(p).isDirectory() && existsSync(join(p, '.git'))
305-
} catch {
306-
return false
307-
}
308-
})
309-
} catch {
310-
return []
311-
}
298+
return discoverRepoPathsUnder(REPOS_ROOT)
312299
}
313300
}
314301

315302
// ---------------------------------------------------------------------------
316303
// Helpers
317304
// ---------------------------------------------------------------------------
318305

306+
/**
307+
* Discover git repositories under a root directory, recursing one level into
308+
* non-repo directories so org-style layouts (root/org/repo) are picked up
309+
* alongside flat ones (root/repo). Unreadable entries are skipped.
310+
*/
311+
export function discoverRepoPathsUnder(root: string): string[] {
312+
if (!existsSync(root)) return []
313+
const repos: string[] = []
314+
const scan = (dir: string, depth: number): void => {
315+
let entries: string[]
316+
try {
317+
entries = readdirSync(dir)
318+
} catch {
319+
return
320+
}
321+
for (const name of entries) {
322+
const p = join(dir, name)
323+
try {
324+
if (!statSync(p).isDirectory()) continue
325+
if (existsSync(join(p, '.git'))) repos.push(p)
326+
else if (depth < 2) scan(p, depth + 1)
327+
} catch {
328+
// unreadable entry — skip
329+
}
330+
}
331+
}
332+
scan(root, 1)
333+
return repos
334+
}
335+
319336
/**
320337
* Suppress passive-repo alerts unless at least one workflow is enabled for the
321338
* repo — there's nothing to "de-schedule" otherwise, so the nag is noise.

0 commit comments

Comments
 (0)