Skip to content

Commit 6cfaac9

Browse files
authored
Merge pull request #1477 from kaizen403/fix/boulder-agent-tracking
fix: track agent in boulder state to fix session continuation (fixes #927)
2 parents 77e99d8 + 38b40bc commit 6cfaac9

File tree

8 files changed

+234
-15
lines changed

8 files changed

+234
-15
lines changed

src/features/boulder-state/storage.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,5 +246,33 @@ describe("boulder-state", () => {
246246
expect(state.plan_name).toBe("auth-refactor")
247247
expect(state.started_at).toBeDefined()
248248
})
249+
250+
test("should include agent field when provided", () => {
251+
//#given - plan path, session id, and agent type
252+
const planPath = "/path/to/feature.md"
253+
const sessionId = "ses-xyz789"
254+
const agent = "atlas"
255+
256+
//#when - createBoulderState is called with agent
257+
const state = createBoulderState(planPath, sessionId, agent)
258+
259+
//#then - state should include the agent field
260+
expect(state.agent).toBe("atlas")
261+
expect(state.active_plan).toBe(planPath)
262+
expect(state.session_ids).toEqual([sessionId])
263+
expect(state.plan_name).toBe("feature")
264+
})
265+
266+
test("should allow agent to be undefined", () => {
267+
//#given - plan path and session id without agent
268+
const planPath = "/path/to/legacy.md"
269+
const sessionId = "ses-legacy"
270+
271+
//#when - createBoulderState is called without agent
272+
const state = createBoulderState(planPath, sessionId)
273+
274+
//#then - state should not have agent field (backward compatible)
275+
expect(state.agent).toBeUndefined()
276+
})
249277
})
250278
})

src/features/boulder-state/storage.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,12 +139,14 @@ export function getPlanName(planPath: string): string {
139139
*/
140140
export function createBoulderState(
141141
planPath: string,
142-
sessionId: string
142+
sessionId: string,
143+
agent?: string
143144
): BoulderState {
144145
return {
145146
active_plan: planPath,
146147
started_at: new Date().toISOString(),
147148
session_ids: [sessionId],
148149
plan_name: getPlanName(planPath),
150+
...(agent !== undefined ? { agent } : {}),
149151
}
150152
}

src/features/boulder-state/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ export interface BoulderState {
1414
session_ids: string[]
1515
/** Plan name derived from filename */
1616
plan_name: string
17+
/** Agent type to use when resuming (e.g., 'atlas') */
18+
agent?: string
1719
}
1820

1921
export interface PlanProgress {

src/hooks/atlas/index.test.ts

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -858,8 +858,8 @@ describe("atlas hook", () => {
858858
expect(callArgs.body.parts[0].text).toContain("2 remaining")
859859
})
860860

861-
test("should not inject when last agent is not Atlas", async () => {
862-
// given - boulder state with incomplete plan, but last agent is NOT Atlas
861+
test("should not inject when last agent does not match boulder agent", async () => {
862+
// given - boulder state with incomplete plan, but last agent does NOT match
863863
const planPath = join(TEST_DIR, "test-plan.md")
864864
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
865865

@@ -868,10 +868,11 @@ describe("atlas hook", () => {
868868
started_at: "2026-01-02T10:00:00Z",
869869
session_ids: [MAIN_SESSION_ID],
870870
plan_name: "test-plan",
871+
agent: "atlas",
871872
}
872873
writeBoulderState(TEST_DIR, state)
873874

874-
// given - last agent is NOT Atlas
875+
// given - last agent is NOT the boulder agent
875876
cleanupMessageStorage(MAIN_SESSION_ID)
876877
setupMessageStorage(MAIN_SESSION_ID, "sisyphus")
877878

@@ -886,10 +887,44 @@ describe("atlas hook", () => {
886887
},
887888
})
888889

889-
// then - should NOT call prompt because agent is not Atlas
890+
// then - should NOT call prompt because agent does not match
890891
expect(mockInput._promptMock).not.toHaveBeenCalled()
891892
})
892893

894+
test("should inject when last agent matches boulder agent even if non-Atlas", async () => {
895+
// given - boulder state expects sisyphus and last agent is sisyphus
896+
const planPath = join(TEST_DIR, "test-plan.md")
897+
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
898+
899+
const state: BoulderState = {
900+
active_plan: planPath,
901+
started_at: "2026-01-02T10:00:00Z",
902+
session_ids: [MAIN_SESSION_ID],
903+
plan_name: "test-plan",
904+
agent: "sisyphus",
905+
}
906+
writeBoulderState(TEST_DIR, state)
907+
908+
cleanupMessageStorage(MAIN_SESSION_ID)
909+
setupMessageStorage(MAIN_SESSION_ID, "sisyphus")
910+
911+
const mockInput = createMockPluginInput()
912+
const hook = createAtlasHook(mockInput)
913+
914+
// when
915+
await hook.handler({
916+
event: {
917+
type: "session.idle",
918+
properties: { sessionID: MAIN_SESSION_ID },
919+
},
920+
})
921+
922+
// then - should call prompt for sisyphus
923+
expect(mockInput._promptMock).toHaveBeenCalled()
924+
const callArgs = mockInput._promptMock.mock.calls[0][0]
925+
expect(callArgs.body.agent).toBe("sisyphus")
926+
})
927+
893928
test("should debounce rapid continuation injections (prevent infinite loop)", async () => {
894929
// given - boulder state with incomplete plan
895930
const planPath = join(TEST_DIR, "test-plan.md")

src/hooks/atlas/index.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@ function isSisyphusPath(filePath: string): boolean {
2626

2727
const WRITE_EDIT_TOOLS = ["Write", "Edit", "write", "edit"]
2828

29+
function getLastAgentFromSession(sessionID: string): string | null {
30+
const messageDir = getMessageDir(sessionID)
31+
if (!messageDir) return null
32+
const nearest = findNearestMessageWithFields(messageDir)
33+
return nearest?.agent?.toLowerCase() ?? null
34+
}
35+
2936
const DIRECT_WORK_REMINDER = `
3037
3138
---
@@ -431,7 +438,7 @@ export function createAtlasHook(
431438
return state
432439
}
433440

434-
async function injectContinuation(sessionID: string, planName: string, remaining: number, total: number): Promise<void> {
441+
async function injectContinuation(sessionID: string, planName: string, remaining: number, total: number, agent?: string): Promise<void> {
435442
const hasRunningBgTasks = backgroundManager
436443
? backgroundManager.getTasksByParentSession(sessionID).some(t => t.status === "running")
437444
: false
@@ -477,7 +484,7 @@ export function createAtlasHook(
477484
await ctx.client.session.prompt({
478485
path: { id: sessionID },
479486
body: {
480-
agent: "atlas",
487+
agent: agent ?? "atlas",
481488
...(model !== undefined ? { model } : {}),
482489
parts: [{ type: "text", text: prompt }],
483490
},
@@ -549,8 +556,14 @@ export function createAtlasHook(
549556
return
550557
}
551558

552-
if (!isCallerOrchestrator(sessionID)) {
553-
log(`[${HOOK_NAME}] Skipped: last agent is not Atlas`, { sessionID })
559+
const requiredAgent = (boulderState.agent ?? "atlas").toLowerCase()
560+
const lastAgent = getLastAgentFromSession(sessionID)
561+
if (!lastAgent || lastAgent !== requiredAgent) {
562+
log(`[${HOOK_NAME}] Skipped: last agent does not match boulder agent`, {
563+
sessionID,
564+
lastAgent: lastAgent ?? "unknown",
565+
requiredAgent,
566+
})
554567
return
555568
}
556569

@@ -568,7 +581,7 @@ export function createAtlasHook(
568581

569582
state.lastContinuationInjectedAt = now
570583
const remaining = progress.total - progress.completed
571-
injectContinuation(sessionID, boulderState.plan_name, remaining, progress.total)
584+
injectContinuation(sessionID, boulderState.plan_name, remaining, progress.total, boulderState.agent)
572585
return
573586
}
574587

src/hooks/prometheus-md-only/index.test.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,121 @@ describe("prometheus-md-only", () => {
352352
})
353353
})
354354

355+
describe("boulder state priority over message files (fixes #927)", () => {
356+
const BOULDER_DIR = join(tmpdir(), `boulder-test-${randomUUID()}`)
357+
const BOULDER_FILE = join(BOULDER_DIR, ".sisyphus", "boulder.json")
358+
359+
beforeEach(() => {
360+
mkdirSync(join(BOULDER_DIR, ".sisyphus"), { recursive: true })
361+
})
362+
363+
afterEach(() => {
364+
rmSync(BOULDER_DIR, { recursive: true, force: true })
365+
})
366+
367+
//#given session was started with prometheus (first message), but /start-work set boulder agent to atlas
368+
//#when user types "continue" after interruption (memory cleared, falls back to message files)
369+
//#then should use boulder state agent (atlas), not message file agent (prometheus)
370+
test("should prioritize boulder agent over message file agent", async () => {
371+
// given - prometheus in message files (from /plan)
372+
setupMessageStorage(TEST_SESSION_ID, "prometheus")
373+
374+
// given - atlas in boulder state (from /start-work)
375+
writeFileSync(BOULDER_FILE, JSON.stringify({
376+
active_plan: "/test/plan.md",
377+
started_at: new Date().toISOString(),
378+
session_ids: [TEST_SESSION_ID],
379+
plan_name: "test-plan",
380+
agent: "atlas"
381+
}))
382+
383+
const hook = createPrometheusMdOnlyHook({
384+
client: {},
385+
directory: BOULDER_DIR,
386+
} as never)
387+
388+
const input = {
389+
tool: "Write",
390+
sessionID: TEST_SESSION_ID,
391+
callID: "call-1",
392+
}
393+
const output = {
394+
args: { filePath: "/path/to/code.ts" },
395+
}
396+
397+
// when / then - should NOT block because boulder says atlas, not prometheus
398+
await expect(
399+
hook["tool.execute.before"](input, output)
400+
).resolves.toBeUndefined()
401+
})
402+
403+
test("should use prometheus from boulder state when set", async () => {
404+
// given - atlas in message files (from some other agent)
405+
setupMessageStorage(TEST_SESSION_ID, "atlas")
406+
407+
// given - prometheus in boulder state (edge case, but should honor it)
408+
writeFileSync(BOULDER_FILE, JSON.stringify({
409+
active_plan: "/test/plan.md",
410+
started_at: new Date().toISOString(),
411+
session_ids: [TEST_SESSION_ID],
412+
plan_name: "test-plan",
413+
agent: "prometheus"
414+
}))
415+
416+
const hook = createPrometheusMdOnlyHook({
417+
client: {},
418+
directory: BOULDER_DIR,
419+
} as never)
420+
421+
const input = {
422+
tool: "Write",
423+
sessionID: TEST_SESSION_ID,
424+
callID: "call-1",
425+
}
426+
const output = {
427+
args: { filePath: "/path/to/code.ts" },
428+
}
429+
430+
// when / then - should block because boulder says prometheus
431+
await expect(
432+
hook["tool.execute.before"](input, output)
433+
).rejects.toThrow("can only write/edit .md files")
434+
})
435+
436+
test("should fall back to message files when session not in boulder", async () => {
437+
// given - prometheus in message files
438+
setupMessageStorage(TEST_SESSION_ID, "prometheus")
439+
440+
// given - boulder state exists but for different session
441+
writeFileSync(BOULDER_FILE, JSON.stringify({
442+
active_plan: "/test/plan.md",
443+
started_at: new Date().toISOString(),
444+
session_ids: ["other-session-id"],
445+
plan_name: "test-plan",
446+
agent: "atlas"
447+
}))
448+
449+
const hook = createPrometheusMdOnlyHook({
450+
client: {},
451+
directory: BOULDER_DIR,
452+
} as never)
453+
454+
const input = {
455+
tool: "Write",
456+
sessionID: TEST_SESSION_ID,
457+
callID: "call-1",
458+
}
459+
const output = {
460+
args: { filePath: "/path/to/code.ts" },
461+
}
462+
463+
// when / then - should block because falls back to message files (prometheus)
464+
await expect(
465+
hook["tool.execute.before"](input, output)
466+
).rejects.toThrow("can only write/edit .md files")
467+
})
468+
})
469+
355470
describe("without message storage", () => {
356471
test("should handle missing session gracefully (no agent found)", async () => {
357472
// given

src/hooks/prometheus-md-only/index.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { join, resolve, relative, isAbsolute } from "node:path"
44
import { HOOK_NAME, PROMETHEUS_AGENT, ALLOWED_EXTENSIONS, ALLOWED_PATH_PREFIX, BLOCKED_TOOLS, PLANNING_CONSULT_WARNING, PROMETHEUS_WORKFLOW_REMINDER } from "./constants"
55
import { findNearestMessageWithFields, findFirstMessageWithAgent, MESSAGE_STORAGE } from "../../features/hook-message-injector"
66
import { getSessionAgent } from "../../features/claude-code-session-state"
7+
import { readBoulderState } from "../../features/boulder-state"
78
import { log } from "../../shared/logger"
89
import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive"
910
import { getAgentDisplayName } from "../../shared/agent-display-names"
@@ -70,8 +71,31 @@ function getAgentFromMessageFiles(sessionID: string): string | undefined {
7071
return findFirstMessageWithAgent(messageDir) ?? findNearestMessageWithFields(messageDir)?.agent
7172
}
7273

73-
function getAgentFromSession(sessionID: string): string | undefined {
74-
return getSessionAgent(sessionID) ?? getAgentFromMessageFiles(sessionID)
74+
/**
75+
* Get the effective agent for the session.
76+
* Priority order:
77+
* 1. In-memory session agent (most recent, set by /start-work)
78+
* 2. Boulder state agent (persisted across restarts, fixes #927)
79+
* 3. Message files (fallback for sessions without boulder state)
80+
*
81+
* This fixes issue #927 where after interruption:
82+
* - In-memory map is cleared (process restart)
83+
* - Message files return "prometheus" (oldest message from /plan)
84+
* - But boulder.json has agent: "atlas" (set by /start-work)
85+
*/
86+
function getAgentFromSession(sessionID: string, directory: string): string | undefined {
87+
// Check in-memory first (current session)
88+
const memoryAgent = getSessionAgent(sessionID)
89+
if (memoryAgent) return memoryAgent
90+
91+
// Check boulder state (persisted across restarts) - fixes #927
92+
const boulderState = readBoulderState(directory)
93+
if (boulderState?.session_ids.includes(sessionID) && boulderState.agent) {
94+
return boulderState.agent
95+
}
96+
97+
// Fallback to message files
98+
return getAgentFromMessageFiles(sessionID)
7599
}
76100

77101
export function createPrometheusMdOnlyHook(ctx: PluginInput) {
@@ -80,7 +104,7 @@ export function createPrometheusMdOnlyHook(ctx: PluginInput) {
80104
input: { tool: string; sessionID: string; callID: string },
81105
output: { args: Record<string, unknown>; message?: string }
82106
): Promise<void> => {
83-
const agentName = getAgentFromSession(input.sessionID)
107+
const agentName = getAgentFromSession(input.sessionID, ctx.directory)
84108

85109
if (agentName !== PROMETHEUS_AGENT) {
86110
return

src/hooks/start-work/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ All ${progress.total} tasks are done. Create a new plan with: /plan "your task"`
102102
if (existingState) {
103103
clearBoulderState(ctx.directory)
104104
}
105-
const newState = createBoulderState(matchedPlan, sessionId)
105+
const newState = createBoulderState(matchedPlan, sessionId, "atlas")
106106
writeBoulderState(ctx.directory, newState)
107107

108108
contextInfo = `
@@ -187,7 +187,7 @@ All ${plans.length} plan(s) are complete. Create a new plan with: /plan "your ta
187187
} else if (incompletePlans.length === 1) {
188188
const planPath = incompletePlans[0]
189189
const progress = getPlanProgress(planPath)
190-
const newState = createBoulderState(planPath, sessionId)
190+
const newState = createBoulderState(planPath, sessionId, "atlas")
191191
writeBoulderState(ctx.directory, newState)
192192

193193
contextInfo += `

0 commit comments

Comments
 (0)