merge(dev): resolve latest sync-task conflict for delegated fallback PR

Sync the PR branch with the latest dev branch and resolve the remaining conflict in sync-task.test.ts while preserving both the new upstream poll-recovery coverage and this branch's delegated bootstrap cleanup and isolation coverage. Re-verified the affected delegated fallback suites and typecheck after the merge resolution.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
tw-yshuang
2026-05-12 00:37:01 +08:00
113 changed files with 6674 additions and 492 deletions
@@ -107,7 +107,8 @@ describe("BackgroundManager.cancelTask cleanup", () => {
expect(cancelled).toBe(true)
expect(getPendingByParent(manager).get(task.parentSessionId)).toBeUndefined()
runScheduledCleanup(manager, task.id)
expect(manager.getTask(task.id)).toBeUndefined()
expect(getTaskMap(manager).has(task.id)).toBe(false)
expect(manager.getTask(task.id)?.sessionId).toBe(task.sessionId)
})
test("#given a running task #when cancelTask called with skipNotification=false #then task is also eventually removed", async () => {
@@ -131,7 +132,8 @@ describe("BackgroundManager.cancelTask cleanup", () => {
// then
expect(cancelled).toBe(true)
runScheduledCleanup(manager, task.id)
expect(manager.getTask(task.id)).toBeUndefined()
expect(getTaskMap(manager).has(task.id)).toBe(false)
expect(manager.getTask(task.id)?.sessionId).toBe(task.sessionId)
})
test("#given a running task #when cancelTask called with skipNotification=true #then concurrency slot is freed and pending tasks can start", async () => {
@@ -5248,6 +5248,67 @@ describe("BackgroundManager.handleEvent - session.error", () => {
manager.shutdown()
})
test("completes task on session.status idle after todo-continuation finishes", async () => {
//#given
const sessionID = "ses-status-idle-after-todo-continuation"
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
messages: async () => ({
data: [
{
info: { role: "assistant" },
parts: [{ type: "text", text: "final verified result" }],
},
],
}),
todo: async () => ({ data: [] }),
},
}
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
stubNotifyParentSession(manager)
mockVerifySessionExists(manager, true)
const task = createMockTask({
id: "task-status-idle-after-todo-continuation",
sessionId: sessionID,
parentSessionId: "parent-session",
parentMessageId: "msg-status-idle",
description: "task that finished after todo-continuation",
agent: "explore",
status: "running",
startedAt: new Date(Date.now() - (MIN_IDLE_TIME_MS + 10)),
})
getTaskMap(manager).set(task.id, task)
manager.handleEvent({
type: "todo.updated",
properties: {
sessionID,
todos: [{ id: "todo-1", content: "compile result", status: "completed", priority: "high" }],
},
})
//#when
manager.handleEvent({
type: "session.status",
properties: {
sessionID,
status: { type: "idle" },
},
})
await flushBackgroundNotifications()
//#then
expect(task.status).toBe("completed")
expect(task.completedAt).toBeDefined()
manager.shutdown()
})
test("retry path releases current concurrency slot and prefers current provider in fallback entry", async () => {
//#given
const manager = createBackgroundManager()
@@ -6216,6 +6277,68 @@ describe("BackgroundManager regression fixes - resume and aborted notification",
manager.shutdown()
})
test("should keep completed task retrievable after scheduled removal", () => {
//#given
const manager = createBackgroundManager()
const task: BackgroundTask = {
id: "task-archive-regression",
sessionId: "session-archive-regression",
parentSessionId: "parent-session",
parentMessageId: "msg-1",
description: "archive regression",
prompt: "test",
agent: "explore",
status: "completed",
startedAt: new Date(),
completedAt: new Date(),
}
getTaskMap(manager).set(task.id, task)
//#when
;(cast<{ removeTask: (task: BackgroundTask) => void }>(manager)).removeTask(task)
//#then
expect(getTaskMap(manager).has(task.id)).toBe(false)
const archivedTask = manager.getTask(task.id)
expect(archivedTask?.sessionId).toBe(task.sessionId)
expect(archivedTask?.prompt).toBe("[redacted]")
expect(archivedTask?.startedAt).toEqual(task.startedAt)
manager.shutdown()
})
test("should cap completed task archive size at 100 entries", () => {
//#given
const manager = createBackgroundManager()
//#when
for (let index = 0; index < 120; index += 1) {
const task: BackgroundTask = {
id: `task-archive-${index}`,
sessionId: `session-archive-${index}`,
parentSessionId: "parent-session",
parentMessageId: "msg-1",
description: "archive cap regression",
prompt: `sensitive-${index}`,
agent: "explore",
status: "completed",
startedAt: new Date(),
completedAt: new Date(),
}
;(cast<{ removeTask: (task: BackgroundTask) => void }>(manager)).removeTask(task)
}
//#then
const archive = cast<Map<string, unknown>>(Reflect.get(manager, "completedTaskArchive"))
expect(archive.size).toBe(100)
expect(archive.has("task-archive-0")).toBe(false)
expect(archive.has("task-archive-19")).toBe(false)
expect(archive.has("task-archive-20")).toBe(true)
expect(archive.has("task-archive-119")).toBe(true)
manager.shutdown()
})
})
describe("BackgroundManager - tool permission spread order", () => {
+148 -35
View File
@@ -103,12 +103,14 @@ type ParentWakePromptContext = {
tools?: Record<string, boolean>
}
type PendingParentWake = {
promptContext: ParentWakePromptContext
notifications: string[]
}
type SessionStatusInfo = { type?: string }
const BACKGROUND_PARENT_WAKE_PROMPT = `<system-reminder>
[BACKGROUND TASK NOTIFICATION READY]
A background task notification was already added to this session. Continue from that notification.
</system-reminder>`
const PENDING_PARENT_WAKE_RETRY_MS = 1_000
interface MessagePartInfo {
id?: string
@@ -192,6 +194,7 @@ export interface SubagentSessionCreatedEvent {
export type OnSubagentSessionCreated = (event: SubagentSessionCreatedEvent) => Promise<void>
const MAX_TASK_REMOVAL_RESCHEDULES = 6
const MAX_COMPLETED_TASK_ARCHIVE_SIZE = 100
export interface BackgroundManagerConfig {
pluginContext: PluginInput
@@ -226,10 +229,12 @@ export class BackgroundManager {
private queuesByKey: Map<string, QueueItem[]> = new Map()
private processingKeys: Set<string> = new Set()
private completionTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
private completedTaskArchive: Map<string, BackgroundTask> = new Map()
private completedTaskSummaries: Map<string, BackgroundTaskNotificationTask[]> = new Map()
private idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
private notificationQueueByParent: Map<string, Promise<void>> = new Map()
private pendingParentWakes: Map<string, ParentWakePromptContext> = new Map()
private pendingParentWakes: Map<string, PendingParentWake> = new Map()
private pendingParentWakeTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
private observedOutputSessions: Set<string> = new Set()
private observedIncompleteTodosBySession: Map<string, boolean> = new Map()
private rootDescendantCounts: Map<string, number>
@@ -357,6 +362,7 @@ export class BackgroundManager {
}
private addTask(task: BackgroundTask): void {
this.completedTaskArchive.delete(task.id)
this.tasks.set(task.id, task)
if (!task.parentSessionId) {
return
@@ -368,10 +374,47 @@ export class BackgroundManager {
}
private removeTask(task: BackgroundTask): void {
this.archiveCompletedTask(task)
this.tasks.delete(task.id)
this.removeTaskFromParentIndex(task.id, task.parentSessionId)
}
private archiveCompletedTask(task: BackgroundTask): void {
if (!task.sessionId) {
return
}
if (task.status === "running" || task.status === "pending") {
return
}
const archivedTask: BackgroundTask = {
id: task.id,
parentSessionId: task.parentSessionId,
parentMessageId: task.parentMessageId,
description: task.description,
prompt: "[redacted]",
agent: task.agent,
sessionId: task.sessionId,
status: task.status,
queuedAt: task.queuedAt,
startedAt: task.startedAt,
completedAt: task.completedAt,
model: task.model,
error: task.error,
category: task.category,
}
this.completedTaskArchive.set(task.id, archivedTask)
if (this.completedTaskArchive.size <= MAX_COMPLETED_TASK_ARCHIVE_SIZE) {
return
}
const oldestTaskID = this.completedTaskArchive.keys().next().value
if (typeof oldestTaskID === "string") {
this.completedTaskArchive.delete(oldestTaskID)
}
}
private updateTaskParent(task: BackgroundTask, parentSessionID: string): void {
if (task.parentSessionId === parentSessionID) {
return
@@ -849,7 +892,7 @@ The fallback retry session is now created and can be inspected directly.
}
getTask(id: string): BackgroundTask | undefined {
return this.tasks.get(id)
return this.tasks.get(id) ?? this.completedTaskArchive.get(id)
}
getTasksByParentSession(sessionID: string): BackgroundTask[] {
@@ -1507,7 +1550,14 @@ The fallback retry session is now created and can be inspected directly.
if (event.type === "session.status") {
const sessionID = props?.sessionID as string | undefined
const status = props?.status as { type?: string; message?: string } | undefined
if (!sessionID || status?.type !== "retry") return
if (!sessionID || !status?.type) return
if (status.type === "idle") {
this.handleEvent({ type: "session.idle", properties: { sessionID } })
return
}
if (status.type !== "retry") return
const resolved = this.resolveTaskAttemptBySession(sessionID)
if (!resolved?.isCurrent) return
@@ -2202,34 +2252,40 @@ The task was re-queued on a fallback model after a retryable failure.
}
const shouldDeferReply = shouldReply && await this.isSessionActive(task.parentSessionId)
try {
await this.client.session.promptAsync({
path: { id: task.parentSessionId },
body: {
noReply: shouldDeferReply || !shouldReply,
...parentPromptContext,
parts: [createInternalAgentTextPart(notification)],
},
})
if (shouldDeferReply) {
this.pendingParentWakes.set(task.parentSessionId, parentPromptContext)
}
log("[background-agent] Sent notification to parent session:", {
if (shouldDeferReply) {
this.queuePendingParentWake(task.parentSessionId, notification, parentPromptContext)
log("[background-agent] Deferred notification until parent session is idle:", {
taskId: task.id,
allComplete,
isTaskFailure,
noReply: shouldDeferReply || !shouldReply,
deferredReply: shouldDeferReply,
})
} catch (error) {
if (isAbortedSessionError(error)) {
log("[background-agent] Parent session aborted while sending notification; continuing cleanup:", {
taskId: task.id,
parentSessionID: task.parentSessionId,
} else {
try {
await this.client.session.promptAsync({
path: { id: task.parentSessionId },
body: {
noReply: !shouldReply,
...parentPromptContext,
parts: [createInternalAgentTextPart(notification)],
},
})
this.queuePendingNotification(task.parentSessionId, notification)
} else {
log("[background-agent] Failed to send notification:", error)
log("[background-agent] Sent notification to parent session:", {
taskId: task.id,
allComplete,
isTaskFailure,
noReply: !shouldReply,
deferredReply: false,
})
} catch (error) {
if (isAbortedSessionError(error)) {
log("[background-agent] Parent session aborted while sending notification; continuing cleanup:", {
taskId: task.id,
parentSessionID: task.parentSessionId,
})
this.queuePendingNotification(task.parentSessionId, notification)
} else {
log("[background-agent] Failed to send notification:", error)
}
}
}
} else {
@@ -2274,37 +2330,89 @@ The task was re-queued on a fallback model after a retryable failure.
}
}
private queuePendingParentWake(
sessionID: string,
notification: string,
promptContext: ParentWakePromptContext,
): void {
const pendingWake = this.pendingParentWakes.get(sessionID)
if (pendingWake) {
pendingWake.notifications.push(notification)
pendingWake.promptContext = promptContext
} else {
this.pendingParentWakes.set(sessionID, {
promptContext,
notifications: [notification],
})
}
this.schedulePendingParentWakeFlush(sessionID)
}
private async flushPendingParentWake(sessionID: string): Promise<void> {
const wakeContext = this.pendingParentWakes.get(sessionID)
if (!wakeContext) return
const pendingWake = this.pendingParentWakes.get(sessionID)
if (!pendingWake) {
this.clearPendingParentWakeTimer(sessionID)
return
}
if (await this.isSessionActive(sessionID)) {
this.schedulePendingParentWakeFlush(sessionID)
return
}
this.pendingParentWakes.delete(sessionID)
this.clearPendingParentWakeTimer(sessionID)
await settleAfterSessionIdle()
if (await this.isSessionActive(sessionID)) {
this.pendingParentWakes.set(sessionID, wakeContext)
this.pendingParentWakes.set(sessionID, pendingWake)
this.schedulePendingParentWakeFlush(sessionID)
return
}
const notificationContent = pendingWake.notifications.join("\n\n")
try {
await this.client.session.promptAsync({
path: { id: sessionID },
body: {
noReply: false,
...wakeContext,
parts: [createInternalAgentTextPart(BACKGROUND_PARENT_WAKE_PROMPT)],
...pendingWake.promptContext,
parts: [createInternalAgentTextPart(notificationContent)],
},
})
log("[background-agent] Sent deferred parent wake:", { sessionID })
} catch (error) {
this.queuePendingNotification(sessionID, notificationContent)
log("[background-agent] Failed to send deferred parent wake:", { sessionID, error })
}
}
private schedulePendingParentWakeFlush(sessionID: string): void {
if (this.pendingParentWakeTimers.has(sessionID)) {
return
}
const timer = setTimeout(() => {
this.pendingParentWakeTimers.delete(sessionID)
void this.enqueueNotificationForParent(sessionID, () => this.flushPendingParentWake(sessionID)).catch((error) => {
log("[background-agent] Failed to retry pending parent wake:", { sessionID, error })
})
}, PENDING_PARENT_WAKE_RETRY_MS)
this.pendingParentWakeTimers.set(sessionID, timer)
}
private clearPendingParentWakeTimer(sessionID: string): void {
const timer = this.pendingParentWakeTimers.get(sessionID)
if (!timer) {
return
}
clearTimeout(timer)
this.pendingParentWakeTimers.delete(sessionID)
}
private pruneStaleTasksAndNotifications(allStatuses?: SessionStatusMap): void {
pruneStaleTasksAndNotifications({
tasks: this.tasks,
@@ -2618,6 +2726,11 @@ The task was re-queued on a fallback model after a retryable failure.
}
this.idleDeferralTimers.clear()
for (const timer of this.pendingParentWakeTimers.values()) {
clearTimeout(timer)
}
this.pendingParentWakeTimers.clear()
for (const sessionID of trackedSessionIDs) {
subagentSessions.delete(sessionID)
this.cleanupDelegatedSessionContext(sessionID)
@@ -54,6 +54,7 @@ function createManager(enableParentSessionNotifications: boolean): {
function createManager(
enableParentSessionNotifications: boolean,
sessionStatuses?: Record<string, { type: string }>,
promptAsyncImpl?: (call: PromptAsyncCall) => Promise<unknown>,
): {
manager: BackgroundManager
promptAsyncCalls: PromptAsyncCall[]
@@ -66,6 +67,9 @@ function createManager(
prompt: async () => ({}),
promptAsync: async (call: PromptAsyncCall) => {
promptAsyncCalls.push(call)
if (promptAsyncImpl) {
return promptAsyncImpl(call)
}
return {}
},
abort: async () => ({}),
@@ -142,6 +146,10 @@ function getPendingByParent(manager: BackgroundManager): Map<string, Set<string>
return Reflect.get(manager, "pendingByParent") as Map<string, Set<string>>
}
function getPendingNotifications(manager: BackgroundManager): Map<string, string[]> {
return Reflect.get(manager, "pendingNotifications") as Map<string, string[]>
}
function getCompletionTimers(manager: BackgroundManager): Map<string, ReturnType<typeof setTimeout>> {
return Reflect.get(manager, "completionTimers") as Map<string, ReturnType<typeof setTimeout>>
}
@@ -155,6 +163,10 @@ function waitForDeferredWake(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 180))
}
function waitForDeferredWakeRetry(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 1_180))
}
function getRequiredTimer(manager: BackgroundManager, taskID: string): ReturnType<typeof setTimeout> {
const timer = getCompletionTimers(manager).get(taskID)
expect(timer).toBeDefined()
@@ -208,7 +220,7 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => {
})
})
describe("#given 2 tasks for same parent and both completed", () => {
describe("#given background tasks for same parent", () => {
test("#when the second completion notification is sent #then ALL BACKGROUND TASKS COMPLETE notification still works correctly", async () => {
// given
const { manager, promptAsyncCalls } = createManager(true)
@@ -260,12 +272,10 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => {
await notifyParentSessionForTest(manager, task)
// then
expect(promptAsyncCalls).toHaveLength(1)
expect(promptAsyncCalls[0]?.body.noReply).toBe(true)
expect(JSON.stringify(promptAsyncCalls[0]?.body.parts)).toContain("ALL BACKGROUND TASKS COMPLETE")
expect(promptAsyncCalls).toHaveLength(0)
})
test("#when deferred parent session becomes idle #then wake prompt is sent once without duplicating the notification", async () => {
test("#when deferred parent session becomes idle #then completion notification wakes the parent without a pointer reminder", async () => {
// given
const sessionStatuses: Record<string, { type: string }> = {
"parent-1": { type: "busy" },
@@ -283,12 +293,63 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => {
await waitForDeferredWake()
// then
expect(promptAsyncCalls).toHaveLength(2)
expect(promptAsyncCalls[0]?.body.noReply).toBe(true)
expect(promptAsyncCalls[1]?.body.noReply).toBe(false)
const wakePayload = JSON.stringify(promptAsyncCalls[1]?.body.parts)
expect(wakePayload).toContain("BACKGROUND TASK NOTIFICATION READY")
expect(wakePayload).not.toContain("ALL BACKGROUND TASKS COMPLETE")
expect(promptAsyncCalls).toHaveLength(1)
expect(promptAsyncCalls[0]?.body.noReply).toBe(false)
const wakePayload = JSON.stringify(promptAsyncCalls[0]?.body.parts)
expect(wakePayload).toContain("ALL BACKGROUND TASKS COMPLETE")
expect(wakePayload).not.toContain("BACKGROUND TASK NOTIFICATION READY")
})
test("#when a single background task finishes during a stale busy parent status #then completion notification is retried after the parent becomes idle", async () => {
// given
const sessionStatuses: Record<string, { type: string }> = {
"parent-1": { type: "busy" },
}
const { manager, promptAsyncCalls } = createManager(true, sessionStatuses)
managerUnderTest = manager
const task = createTask({ id: "task-a", parentSessionId: "parent-1", description: "task A", status: "completed", completedAt: new Date("2026-03-11T00:01:00.000Z") })
getTasks(manager).set(task.id, task)
getPendingByParent(manager).set(task.parentSessionId, new Set([task.id]))
// when
await notifyParentSessionForTest(manager, task)
sessionStatuses["parent-1"] = { type: "idle" }
await waitForDeferredWakeRetry()
// then
expect(promptAsyncCalls).toHaveLength(1)
expect(promptAsyncCalls[0]?.body.noReply).toBe(false)
const wakePayload = JSON.stringify(promptAsyncCalls[0]?.body.parts)
expect(wakePayload).toContain("ALL BACKGROUND TASKS COMPLETE")
expect(wakePayload).not.toContain("BACKGROUND TASK NOTIFICATION READY")
})
test("#when deferred completion notification send fails #then notification is queued for the next user message", async () => {
// given
const sessionStatuses: Record<string, { type: string }> = {
"parent-1": { type: "busy" },
}
const promptError = new Error("promptAsync failed")
const { manager, promptAsyncCalls } = createManager(true, sessionStatuses, async () => {
throw promptError
})
managerUnderTest = manager
const task = createTask({ id: "task-a", parentSessionId: "parent-1", description: "task A", status: "completed", completedAt: new Date("2026-03-11T00:01:00.000Z") })
getTasks(manager).set(task.id, task)
getPendingByParent(manager).set(task.parentSessionId, new Set([task.id]))
await notifyParentSessionForTest(manager, task)
// when
sessionStatuses["parent-1"] = { type: "idle" }
manager.handleEvent({ type: "session.idle", properties: { sessionID: "parent-1" } })
await waitForDeferredWake()
// then
expect(promptAsyncCalls).toHaveLength(1)
const queuedNotifications = getPendingNotifications(manager).get("parent-1") ?? []
expect(queuedNotifications).toHaveLength(1)
expect(queuedNotifications[0]).toContain("ALL BACKGROUND TASKS COMPLETE")
expect(queuedNotifications[0]).not.toContain("BACKGROUND TASK NOTIFICATION READY")
})
})
@@ -0,0 +1,32 @@
import { describe, expect, it } from "bun:test"
import { formatDurationHuman } from "./format-duration"
describe("formatDurationHuman", () => {
it("returns 0s for 0ms", () => {
expect(formatDurationHuman(0)).toBe("0s")
})
it("returns 0s for 999ms", () => {
expect(formatDurationHuman(999)).toBe("0s")
})
it("returns 1s for 1000ms", () => {
expect(formatDurationHuman(1000)).toBe("1s")
})
it("returns 1m 0s for 60_000ms", () => {
expect(formatDurationHuman(60_000)).toBe("1m 0s")
})
it("returns 1h 0m 0s for 3_600_000ms", () => {
expect(formatDurationHuman(3_600_000)).toBe("1h 0m 0s")
})
it("returns 1h 2m 3s for 3_723_456ms", () => {
expect(formatDurationHuman(3_723_456)).toBe("1h 2m 3s")
})
it("returns 24h 0m 0s for 86_400_000ms", () => {
expect(formatDurationHuman(86_400_000)).toBe("24h 0m 0s")
})
})
@@ -0,0 +1,16 @@
export function formatDurationHuman(milliseconds: number): string {
const totalSeconds = Math.max(0, Math.floor(milliseconds / 1000))
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
if (hours > 0) {
return `${hours}h ${minutes}m ${seconds}s`
}
if (minutes > 0) {
return `${minutes}m ${seconds}s`
}
return `${seconds}s`
}
+1
View File
@@ -2,3 +2,4 @@ export * from "./types"
export * from "./constants"
export * from "./storage"
export * from "./top-level-task"
export * from "./format-duration"
+260 -1
View File
@@ -3,17 +3,31 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { dirname, join } from "node:path"
import { tmpdir } from "node:os"
import {
addBoulderWork,
appendSessionIdForWork,
completeBoulder,
endTaskTimer,
getActiveWorks,
getBoulderWorks,
readBoulderState,
writeBoulderState,
appendSessionId,
clearBoulderState,
getWorkById,
getWorkByPlanName,
getWorkForSession,
getWorkResumeOptions,
getPlanProgress,
getPlanName,
createBoulderState,
findPrometheusPlans,
getTaskSessionState,
resolveBoulderPlanPath,
resolveBoulderPlanPathForWork,
selectActiveWork,
startTaskTimer,
upsertTaskSessionState,
upsertTaskSessionStateForWork,
} from "./storage"
import type { BoulderState } from "./types"
import { readCurrentTopLevelTask } from "./top-level-task"
@@ -39,6 +53,31 @@ describe("boulder-state", () => {
})
describe("readBoulderState", () => {
test("should preserve legacy boulder.json fields during round-trip", () => {
// given
const boulderFile = join(SISYPHUS_DIR, "boulder.json")
const legacyRawState = {
active_plan: "/path/to/legacy-plan.md",
started_at: "2026-01-01T00:00:00.000Z",
session_ids: ["legacy-session"],
plan_name: "legacy-plan",
}
writeFileSync(boulderFile, JSON.stringify(legacyRawState, null, 2), "utf-8")
// when
const state = readBoulderState(TEST_DIR)
expect(state).not.toBeNull()
const writeSucceeded = writeBoulderState(TEST_DIR, state!)
const roundTripState = readBoulderState(TEST_DIR)
// then
expect(writeSucceeded).toBe(true)
expect(roundTripState?.active_plan).toBe(legacyRawState.active_plan)
expect(roundTripState?.started_at).toBe(legacyRawState.started_at)
expect(roundTripState?.session_ids).toEqual(legacyRawState.session_ids)
expect(roundTripState?.plan_name).toBe(legacyRawState.plan_name)
})
test("should return null when no boulder.json exists", () => {
// given - no boulder.json file
// when
@@ -387,6 +426,225 @@ describe("boulder-state", () => {
})
})
describe("multi-work helpers", () => {
test("should add second work and keep both active works", () => {
// given
const firstState = createBoulderState(
join(TEST_DIR, ".sisyphus/plans/plan-a.md"),
"session-a",
"atlas",
"/worktree-a",
)
writeBoulderState(TEST_DIR, firstState)
const firstWorkId = firstState.active_work_id
// when
const updatedState = addBoulderWork(TEST_DIR, {
planPath: join(TEST_DIR, ".sisyphus/plans/plan-b.md"),
sessionId: "session-b",
agent: "atlas",
worktreePath: "/worktree-b",
})
// then
expect(updatedState).not.toBeNull()
const works = updatedState?.works ?? {}
expect(Object.keys(works).length).toBe(2)
expect(firstWorkId).toBeDefined()
expect(works[firstWorkId!]).toBeDefined()
expect(updatedState?.active_plan).toContain("plan-b.md")
expect(getActiveWorks(TEST_DIR).length).toBe(2)
})
test("should resolve work for session using updated_at tie-break", () => {
// given
const baseState = createBoulderState(
join(TEST_DIR, ".sisyphus/plans/plan-a.md"),
"session-a",
)
writeBoulderState(TEST_DIR, baseState)
const stateWithSecond = addBoulderWork(TEST_DIR, {
planPath: join(TEST_DIR, ".sisyphus/plans/plan-b.md"),
sessionId: "session-b",
})
expect(stateWithSecond).not.toBeNull()
const workIds = Object.keys(stateWithSecond!.works ?? {})
expect(workIds.length).toBe(2)
const firstWorkId = workIds.find((workId) => (stateWithSecond!.works?.[workId]?.plan_name ?? "") === "plan-a")!
const secondWorkId = workIds.find((workId) => (stateWithSecond!.works?.[workId]?.plan_name ?? "") === "plan-b")!
appendSessionIdForWork(TEST_DIR, secondWorkId, "session-a", "appended")
appendSessionIdForWork(TEST_DIR, firstWorkId, "session-a", "appended")
// when
const resolvedWork = getWorkForSession(TEST_DIR, "session-a")
// then
expect(resolvedWork?.work_id).toBe(firstWorkId)
})
test("should support selecting active work and read helpers", () => {
// given
const initialState = createBoulderState(join(TEST_DIR, ".sisyphus/plans/plan-a.md"), "session-a")
writeBoulderState(TEST_DIR, initialState)
const added = addBoulderWork(TEST_DIR, {
planPath: join(TEST_DIR, ".sisyphus/plans/plan-b.md"),
sessionId: "session-b",
worktreePath: "/tmp/worktree-b",
})
expect(added).not.toBeNull()
const firstWork = getWorkByPlanName(TEST_DIR, "plan-a")
expect(firstWork).not.toBeNull()
// when
const selected = selectActiveWork(TEST_DIR, firstWork!.work_id)
const selectedById = getWorkById(TEST_DIR, firstWork!.work_id)
const byPlanNameWithWorktree = getWorkByPlanName(TEST_DIR, "plan-b", { worktreePath: "/tmp/worktree-b" })
const byPlanPath = resolveBoulderPlanPathForWork(TEST_DIR, firstWork!)
const resumeOptions = getWorkResumeOptions(TEST_DIR)
const worksFromState = getBoulderWorks(selected!)
// then
expect(selected?.active_work_id).toBe(firstWork!.work_id)
expect(selectedById?.work_id).toBe(firstWork!.work_id)
expect(byPlanNameWithWorktree?.plan_name).toBe("plan-b")
expect(byPlanPath.endsWith("plan-a.md")).toBe(true)
expect(resumeOptions.length).toBe(2)
expect(worksFromState.length).toBe(2)
})
test("should upsert task session for specific work and keep first started_at", () => {
// given
const initialState = createBoulderState(join(TEST_DIR, ".sisyphus/plans/plan-a.md"), "session-a")
writeBoulderState(TEST_DIR, initialState)
const workId = initialState.active_work_id!
upsertTaskSessionStateForWork(TEST_DIR, workId, {
taskKey: "todo:1",
taskLabel: "1",
taskTitle: "task one",
sessionId: "task-session-a",
})
const seededState = readBoulderState(TEST_DIR)!
seededState.works![workId]!.task_sessions!["todo:1"]!.started_at = "2026-01-01T00:00:00.000Z"
writeBoulderState(TEST_DIR, seededState)
// when
const updated = upsertTaskSessionStateForWork(TEST_DIR, workId, {
taskKey: "todo:1",
taskLabel: "1",
taskTitle: "task one",
sessionId: "task-session-b",
})
// then
expect(updated).not.toBeNull()
const taskSession = updated?.works?.[workId]?.task_sessions?.["todo:1"]
expect(taskSession?.session_id).toBe("task-session-b")
expect(taskSession?.started_at).toBe("2026-01-01T00:00:00.000Z")
})
})
describe("task timer and completion helpers", () => {
test("should keep started_at stable when starting timer repeatedly", () => {
// given
const initialState = createBoulderState(join(TEST_DIR, ".sisyphus/plans/plan-a.md"), "session-a")
writeBoulderState(TEST_DIR, initialState)
const workId = initialState.active_work_id!
// when
startTaskTimer(TEST_DIR, workId, {
taskKey: "todo:1",
taskLabel: "1",
taskTitle: "task one",
sessionId: "session-a",
startedAt: "2026-01-01T00:00:00.000Z",
})
startTaskTimer(TEST_DIR, workId, {
taskKey: "todo:1",
taskLabel: "1",
taskTitle: "task one",
sessionId: "session-a",
startedAt: "2026-01-02T00:00:00.000Z",
})
// then
const taskSession = readBoulderState(TEST_DIR)?.works?.[workId]?.task_sessions?.["todo:1"]
expect(taskSession?.started_at).toBe("2026-01-01T00:00:00.000Z")
expect(taskSession?.status).toBe("running")
})
test("should compute elapsed_ms when ending task timer", () => {
// given
const initialState = createBoulderState(join(TEST_DIR, ".sisyphus/plans/plan-a.md"), "session-a")
writeBoulderState(TEST_DIR, initialState)
const workId = initialState.active_work_id!
startTaskTimer(TEST_DIR, workId, {
taskKey: "todo:1",
taskLabel: "1",
taskTitle: "task one",
sessionId: "session-a",
startedAt: "2026-01-01T00:00:00.000Z",
})
// when
const endedState = endTaskTimer(TEST_DIR, workId, "todo:1", "2026-01-01T00:00:01.500Z")
// then
const taskSession = endedState?.works?.[workId]?.task_sessions?.["todo:1"]
expect(taskSession?.ended_at).toBe("2026-01-01T00:00:01.500Z")
expect(taskSession?.elapsed_ms).toBe(1500)
expect(taskSession?.status).toBe("completed")
})
test("should complete one work and keep other work untouched", () => {
// given
const initialState = createBoulderState(join(TEST_DIR, ".sisyphus/plans/plan-a.md"), "session-a")
writeBoulderState(TEST_DIR, initialState)
const firstWorkId = initialState.active_work_id!
const withSecond = addBoulderWork(TEST_DIR, {
planPath: join(TEST_DIR, ".sisyphus/plans/plan-b.md"),
sessionId: "session-b",
})
const secondWorkId = Object.keys(withSecond!.works!).find((workId) => workId !== firstWorkId)!
// when
const completedState = completeBoulder(TEST_DIR, firstWorkId, "2026-01-01T01:00:00.000Z")
// then
expect(completedState?.works?.[firstWorkId]?.status).toBe("completed")
expect(completedState?.works?.[firstWorkId]?.ended_at).toBe("2026-01-01T01:00:00.000Z")
expect(completedState?.works?.[firstWorkId]?.elapsed_ms).toBe(
Date.parse("2026-01-01T01:00:00.000Z") - Date.parse(completedState!.works![firstWorkId]!.started_at),
)
expect(completedState?.works?.[secondWorkId]?.status).not.toBe("completed")
expect(existsSync(join(SISYPHUS_DIR, "boulder.json"))).toBe(true)
})
test("should keep first completion timing when completeBoulder is called repeatedly", () => {
// given
const initialState = createBoulderState(
join(TEST_DIR, ".sisyphus/plans/plan-idempotent.md"),
"session-a",
)
writeBoulderState(TEST_DIR, initialState)
const workId = initialState.active_work_id!
// when
const firstCompletedState = completeBoulder(TEST_DIR, workId, "2026-01-01T00:01:00Z")
const secondCompletedState = completeBoulder(TEST_DIR, workId, "2026-01-01T01:00:00Z")
// then
expect(firstCompletedState?.works?.[workId]?.ended_at).toBe("2026-01-01T00:01:00Z")
expect(secondCompletedState?.works?.[workId]?.ended_at).toBe("2026-01-01T00:01:00Z")
expect(secondCompletedState?.works?.[workId]?.elapsed_ms).toBe(
Date.parse("2026-01-01T00:01:00Z") - Date.parse(secondCompletedState!.works![workId]!.started_at),
)
})
})
describe("readCurrentTopLevelTask", () => {
test("should return the first unchecked top-level task in TODOs", () => {
// given - plan with nested and top-level unchecked tasks
@@ -630,7 +888,8 @@ describe("boulder-state", () => {
const progress = getPlanProgress("/non/existent/file.md")
// then
expect(progress.total).toBe(0)
expect(progress.isComplete).toBe(true)
expect(progress.completed).toBe(0)
expect(progress.isComplete).toBe(false)
})
test("should support asterisk bullet top-level tasks", () => {
+615 -7
View File
@@ -6,11 +6,103 @@
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "node:fs"
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"
import type { BoulderState, PlanProgress, TaskSessionState } from "./types"
import type {
BoulderSessionOrigin,
BoulderState,
BoulderWorkResumeOption,
BoulderWorkState,
BoulderWorkStatus,
PlanProgress,
TaskSessionState,
} from "./types"
import { BOULDER_DIR, BOULDER_FILE, PROMETHEUS_PLANS_DIR } from "./constants"
const RESERVED_KEYS = new Set(["__proto__", "prototype", "constructor"])
function nowIsoString(): string {
return new Date().toISOString()
}
function parseIsoToMs(value: string | undefined): number | null {
if (!value) {
return null
}
const parsed = Date.parse(value)
return Number.isNaN(parsed) ? null : parsed
}
function getElapsedMs(startedAt: string | undefined, endedAt: string | undefined): number | undefined {
const startedMs = parseIsoToMs(startedAt)
const endedMs = parseIsoToMs(endedAt)
if (startedMs === null || endedMs === null) {
return undefined
}
return endedMs - startedMs
}
function isValidWorkStatus(status: unknown): status is BoulderWorkStatus {
return status === "active" || status === "completed" || status === "paused" || status === "abandoned"
}
function buildWorkFromMirror(state: BoulderState): BoulderWorkState {
const planName = state.plan_name ?? getPlanName(state.active_plan)
const workId = `${planName}-legacy`
return {
work_id: workId,
active_plan: state.active_plan,
plan_name: planName,
status: state.status,
started_at: state.started_at,
ended_at: state.ended_at,
elapsed_ms: state.elapsed_ms,
updated_at: state.updated_at,
session_ids: Array.isArray(state.session_ids) ? [...state.session_ids] : [],
session_origins: state.session_origins,
agent: state.agent,
worktree_path: state.worktree_path,
task_sessions: state.task_sessions,
}
}
function projectWorkToMirror(state: BoulderState, work: BoulderWorkState): void {
state.active_plan = work.active_plan
state.plan_name = work.plan_name
state.status = work.status
state.started_at = work.started_at
state.ended_at = work.ended_at
state.elapsed_ms = work.elapsed_ms
state.updated_at = work.updated_at
state.session_ids = [...work.session_ids]
state.session_origins = work.session_origins ? { ...work.session_origins } : {}
state.agent = work.agent
state.worktree_path = work.worktree_path
state.task_sessions = work.task_sessions ? { ...work.task_sessions } : {}
}
function selectMirrorWork(state: BoulderState): BoulderWorkState | null {
const works = getBoulderWorks(state)
if (works.length === 0) {
return null
}
if (state.active_work_id) {
const matched = works.find((work) => work.work_id === state.active_work_id)
if (matched) {
return matched
}
}
const sorted = [...works].sort((left, right) => {
const leftMs = parseIsoToMs(left.updated_at ?? left.started_at) ?? 0
const rightMs = parseIsoToMs(right.updated_at ?? right.started_at) ?? 0
return rightMs - leftMs
})
return sorted[0] ?? null
}
export function getBoulderFilePath(directory: string): string {
return join(directory, BOULDER_DIR, BOULDER_FILE)
}
@@ -80,7 +172,15 @@ export function readBoulderState(directory: string): BoulderState | null {
if (!parsed.task_sessions || typeof parsed.task_sessions !== "object" || Array.isArray(parsed.task_sessions)) {
parsed.task_sessions = {}
}
return parsed as BoulderState
const state = parsed as BoulderState
const mirrorWork = selectMirrorWork(state)
if (mirrorWork) {
state.active_work_id = mirrorWork.work_id
projectWorkToMirror(state, mirrorWork)
}
return state
} catch {
return null
}
@@ -95,7 +195,33 @@ export function writeBoulderState(directory: string, state: BoulderState): boole
mkdirSync(dir, { recursive: true })
}
writeFileSync(filePath, JSON.stringify(state, null, 2), "utf-8")
const stateToWrite: BoulderState = { ...state }
if (stateToWrite.works && stateToWrite.active_work_id) {
const activeWork = stateToWrite.works[stateToWrite.active_work_id]
if (activeWork) {
const nextActiveWork: BoulderWorkState = {
...activeWork,
active_plan: stateToWrite.active_plan,
plan_name: stateToWrite.plan_name,
status: stateToWrite.status,
started_at: stateToWrite.started_at,
ended_at: stateToWrite.ended_at,
elapsed_ms: stateToWrite.elapsed_ms,
updated_at: stateToWrite.updated_at,
session_ids: [...stateToWrite.session_ids],
session_origins: stateToWrite.session_origins ? { ...stateToWrite.session_origins } : {},
agent: stateToWrite.agent,
worktree_path: stateToWrite.worktree_path,
task_sessions: stateToWrite.task_sessions ? { ...stateToWrite.task_sessions } : {},
}
stateToWrite.works = {
...stateToWrite.works,
[stateToWrite.active_work_id]: nextActiveWork,
}
}
}
writeFileSync(filePath, JSON.stringify(stateToWrite, null, 2), "utf-8")
return true
} catch {
return false
@@ -107,6 +233,11 @@ export function appendSessionId(
sessionId: string,
origin: "direct" | "appended" = "direct",
): BoulderState | null {
const activeWorkId = readBoulderState(directory)?.active_work_id
if (activeWorkId) {
return appendSessionIdForWork(directory, activeWorkId, sessionId, origin)
}
const state = readBoulderState(directory)
if (!state) return null
@@ -156,6 +287,14 @@ export function clearBoulderState(directory: string): boolean {
export function getTaskSessionState(directory: string, taskKey: string): TaskSessionState | null {
const state = readBoulderState(directory)
if (state?.active_work_id) {
const work = state.works?.[state.active_work_id]
const taskSession = work?.task_sessions?.[taskKey]
if (taskSession) {
return taskSession
}
}
if (!state?.task_sessions) {
return null
}
@@ -174,6 +313,11 @@ export function upsertTaskSessionState(
category?: string
},
): BoulderState | null {
const stateForWork = readBoulderState(directory)
if (stateForWork?.active_work_id) {
return upsertTaskSessionStateForWork(directory, stateForWork.active_work_id, input)
}
const state = readBoulderState(directory)
if (!state) {
return null
@@ -251,7 +395,7 @@ type ProgressSection = "todo" | "final-wave" | "other"
*/
export function getPlanProgress(planPath: string): PlanProgress {
if (!existsSync(planPath)) {
return { total: 0, completed: 0, isComplete: true }
return { total: 0, completed: 0, isComplete: false }
}
try {
@@ -272,7 +416,7 @@ export function getPlanProgress(planPath: string): PlanProgress {
// Simple plan: count all top-level checkboxes anywhere
return getSimplePlanProgress(content)
} catch {
return { total: 0, completed: 0, isComplete: true }
return { total: 0, completed: 0, isComplete: false }
}
}
@@ -355,15 +499,479 @@ export function createBoulderState(
agent?: string,
worktreePath?: string,
): BoulderState {
return {
const startedAt = nowIsoString()
const workId = generateWorkId(getPlanName(planPath))
const work: BoulderWorkState = {
work_id: workId,
active_plan: planPath,
started_at: new Date().toISOString(),
plan_name: getPlanName(planPath),
status: "active",
started_at: startedAt,
updated_at: startedAt,
session_ids: [sessionId],
session_origins: {
[sessionId]: "direct",
},
...(agent !== undefined ? { agent } : {}),
...(worktreePath !== undefined ? { worktree_path: worktreePath } : {}),
task_sessions: {},
}
return {
schema_version: 2,
active_work_id: workId,
works: {
[workId]: work,
},
active_plan: planPath,
started_at: startedAt,
status: "active",
updated_at: startedAt,
session_ids: [sessionId],
session_origins: {
[sessionId]: "direct",
},
plan_name: getPlanName(planPath),
task_sessions: {},
...(agent !== undefined ? { agent } : {}),
...(worktreePath !== undefined ? { worktree_path: worktreePath } : {}),
}
}
export function generateWorkId(planName: string): string {
const slug = planName
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
const randomHex = Math.floor(Math.random() * 0xffffffff)
.toString(16)
.padStart(8, "0")
const safeSlug = slug.length > 0 ? slug : "work"
return `${safeSlug}-${randomHex}`
}
export function getBoulderWorks(state: BoulderState): BoulderWorkState[] {
if (state.works && typeof state.works === "object") {
return Object.values(state.works)
}
if (!state.active_plan || !state.plan_name || !state.started_at) {
return []
}
return [buildWorkFromMirror(state)]
}
export function getActiveWorks(directory: string): BoulderWorkState[] {
const state = readBoulderState(directory)
if (!state) {
return []
}
return getBoulderWorks(state).filter((work) => work.status !== "completed" && work.status !== "abandoned")
}
export function getWorkById(directory: string, workId: string): BoulderWorkState | null {
const state = readBoulderState(directory)
if (!state) {
return null
}
return getBoulderWorks(state).find((work) => work.work_id === workId) ?? null
}
export function getWorkByPlanName(
directory: string,
planName: string,
options?: { worktreePath?: string },
): BoulderWorkState | null {
const state = readBoulderState(directory)
if (!state) {
return null
}
const worktreePath = options?.worktreePath
return getBoulderWorks(state).find((work) => {
if (work.plan_name !== planName) {
return false
}
if (!worktreePath) {
return true
}
return work.worktree_path === worktreePath
}) ?? null
}
export function getWorkForSession(directory: string, sessionId: string): BoulderWorkState | null {
const state = readBoulderState(directory)
if (!state) {
return null
}
const works = getBoulderWorks(state)
.filter((work) => work.session_ids.includes(sessionId))
.sort((left, right) => {
const leftMs = parseIsoToMs(left.updated_at ?? left.started_at) ?? 0
const rightMs = parseIsoToMs(right.updated_at ?? right.started_at) ?? 0
return rightMs - leftMs
})
if (works.length > 0) {
return works[0] ?? null
}
if (state.session_ids.includes(sessionId)) {
return buildWorkFromMirror(state)
}
return null
}
export function resolveBoulderPlanPathForWork(
directory: string,
work: Pick<BoulderWorkState, "active_plan" | "worktree_path">,
): string {
return resolveBoulderPlanPath(directory, work)
}
export function getWorkResumeOptions(directory: string): BoulderWorkResumeOption[] {
const state = readBoulderState(directory)
if (!state) {
return []
}
return getActiveWorks(directory).map((work) => {
const progress = getPlanProgress(resolveBoulderPlanPathForWork(directory, work))
return {
work_id: work.work_id,
plan_name: work.plan_name,
active_plan: work.active_plan,
worktree_path: work.worktree_path,
status: work.status && isValidWorkStatus(work.status) ? work.status : "active",
started_at: work.started_at,
updated_at: work.updated_at ?? work.started_at,
ended_at: work.ended_at,
elapsed_ms: work.elapsed_ms,
session_count: work.session_ids.length,
progress,
is_current_mirror: state.active_work_id === work.work_id,
}
})
}
export function selectActiveWork(directory: string, workId: string): BoulderState | null {
const state = readBoulderState(directory)
if (!state) {
return null
}
const works = getBoulderWorks(state)
const nextWork = works.find((work) => work.work_id === workId)
if (!nextWork) {
return null
}
const nextState: BoulderState = {
...state,
schema_version: 2,
active_work_id: workId,
works: state.works ?? Object.fromEntries(works.map((work) => [work.work_id, work])),
}
projectWorkToMirror(nextState, nextWork)
if (!writeBoulderState(directory, nextState)) {
return null
}
return nextState
}
export function addBoulderWork(
directory: string,
input: {
planPath: string
sessionId: string
agent?: string
worktreePath?: string
startedAt?: string
},
): BoulderState | null {
const state = readBoulderState(directory)
if (!state) {
return null
}
const workId = generateWorkId(getPlanName(input.planPath))
const startedAt = input.startedAt ?? nowIsoString()
const nextWork: BoulderWorkState = {
work_id: workId,
active_plan: input.planPath,
plan_name: getPlanName(input.planPath),
status: "active",
started_at: startedAt,
updated_at: startedAt,
session_ids: [input.sessionId],
session_origins: {
[input.sessionId]: "direct",
},
...(input.agent !== undefined ? { agent: input.agent } : {}),
...(input.worktreePath !== undefined ? { worktree_path: input.worktreePath } : {}),
task_sessions: {},
}
const works = getBoulderWorks(state)
const nextWorks: Record<string, BoulderWorkState> = {
...Object.fromEntries(works.map((work) => [work.work_id, work])),
[workId]: nextWork,
}
const nextState: BoulderState = {
...state,
schema_version: 2,
works: nextWorks,
active_work_id: workId,
}
projectWorkToMirror(nextState, nextWork)
if (!writeBoulderState(directory, nextState)) {
return null
}
return nextState
}
export function appendSessionIdForWork(
directory: string,
workId: string,
sessionId: string,
origin: BoulderSessionOrigin = "direct",
): BoulderState | null {
const state = readBoulderState(directory)
if (!state) {
return null
}
const works = getBoulderWorks(state)
const targetWork = works.find((work) => work.work_id === workId)
if (!targetWork) {
return null
}
const sessionIds = targetWork.session_ids.includes(sessionId)
? [...targetWork.session_ids]
: [...targetWork.session_ids, sessionId]
const sessionOrigins = {
...(targetWork.session_origins ?? {}),
[sessionId]: origin,
}
const updatedWork: BoulderWorkState = {
...targetWork,
session_ids: sessionIds,
session_origins: sessionOrigins,
updated_at: nowIsoString(),
}
const nextWorks = {
...Object.fromEntries(works.map((work) => [work.work_id, work])),
[workId]: updatedWork,
}
const nextState: BoulderState = {
...state,
schema_version: 2,
works: nextWorks,
}
if (state.active_work_id === workId) {
projectWorkToMirror(nextState, updatedWork)
}
if (!writeBoulderState(directory, nextState)) {
return null
}
return nextState
}
export function upsertTaskSessionStateForWork(
directory: string,
workId: string,
input: {
taskKey: string
taskLabel: string
taskTitle: string
sessionId: string
agent?: string
category?: string
},
): BoulderState | null {
if (RESERVED_KEYS.has(input.taskKey)) {
return null
}
const state = readBoulderState(directory)
if (!state) {
return null
}
const works = getBoulderWorks(state)
const targetWork = works.find((work) => work.work_id === workId)
if (!targetWork) {
return null
}
const previousTaskSession = targetWork.task_sessions?.[input.taskKey]
const nextTaskSession: TaskSessionState = {
task_key: input.taskKey,
task_label: input.taskLabel,
task_title: input.taskTitle,
session_id: input.sessionId,
...(input.agent !== undefined ? { agent: input.agent } : {}),
...(input.category !== undefined ? { category: input.category } : {}),
...(previousTaskSession?.started_at !== undefined ? { started_at: previousTaskSession.started_at } : {}),
...(previousTaskSession?.ended_at !== undefined ? { ended_at: previousTaskSession.ended_at } : {}),
...(previousTaskSession?.elapsed_ms !== undefined ? { elapsed_ms: previousTaskSession.elapsed_ms } : {}),
...(previousTaskSession?.status !== undefined ? { status: previousTaskSession.status } : {}),
updated_at: nowIsoString(),
}
const nextWork: BoulderWorkState = {
...targetWork,
task_sessions: {
...(targetWork.task_sessions ?? {}),
[input.taskKey]: nextTaskSession,
},
updated_at: nowIsoString(),
}
const nextWorks = {
...Object.fromEntries(works.map((work) => [work.work_id, work])),
[workId]: nextWork,
}
const nextState: BoulderState = {
...state,
schema_version: 2,
works: nextWorks,
}
if (state.active_work_id === workId) {
projectWorkToMirror(nextState, nextWork)
}
if (!writeBoulderState(directory, nextState)) {
return null
}
return nextState
}
export function startTaskTimer(
directory: string,
workId: string,
input: {
taskKey: string
taskLabel: string
taskTitle: string
sessionId: string
agent?: string
category?: string
startedAt?: string
},
): BoulderState | null {
const nextState = upsertTaskSessionStateForWork(directory, workId, input)
if (!nextState) {
return null
}
const work = nextState.works?.[workId]
const taskSession = work?.task_sessions?.[input.taskKey]
if (!work || !taskSession) {
return null
}
const startedAt = taskSession.started_at ?? input.startedAt ?? nowIsoString()
taskSession.started_at = startedAt
taskSession.status = "running"
taskSession.updated_at = nowIsoString()
work.updated_at = nowIsoString()
if (!writeBoulderState(directory, nextState)) {
return null
}
return nextState
}
export function endTaskTimer(
directory: string,
workId: string,
taskKey: string,
endedAt?: string,
): BoulderState | null {
const state = readBoulderState(directory)
if (!state) {
return null
}
const work = state.works?.[workId] ?? getBoulderWorks(state).find((candidate) => candidate.work_id === workId)
if (!work?.task_sessions?.[taskKey]) {
return null
}
const taskSession = work.task_sessions[taskKey]
const endAt = endedAt ?? nowIsoString()
taskSession.ended_at = endAt
taskSession.elapsed_ms = getElapsedMs(taskSession.started_at, endAt)
taskSession.status = "completed"
taskSession.updated_at = nowIsoString()
work.updated_at = nowIsoString()
if (state.active_work_id === workId) {
projectWorkToMirror(state, work)
}
if (!writeBoulderState(directory, state)) {
return null
}
return state
}
export function completeBoulder(directory: string, workId?: string, endedAt?: string): BoulderState | null {
const state = readBoulderState(directory)
if (!state) {
return null
}
const targetWorkId = workId ?? state.active_work_id
if (!targetWorkId) {
return null
}
const work = state.works?.[targetWorkId] ?? getBoulderWorks(state).find((candidate) => candidate.work_id === targetWorkId)
if (!work) {
return null
}
if (work.status === "completed" && work.ended_at !== undefined && work.elapsed_ms !== undefined) {
return state
}
const endAt = endedAt ?? nowIsoString()
work.ended_at = endAt
work.elapsed_ms = getElapsedMs(work.started_at, endAt)
work.status = "completed"
work.updated_at = nowIsoString()
if (state.active_work_id === targetWorkId) {
projectWorkToMirror(state, work)
}
if (!writeBoulderState(directory, state)) {
return null
}
return state
}
+78
View File
@@ -0,0 +1,78 @@
import { describe, expect, test } from "bun:test"
import type {
BoulderSessionOrigin,
BoulderState,
BoulderTaskStatus,
BoulderWorkResumeOption,
BoulderWorkState,
BoulderWorkStatus,
PlanProgress,
TaskSessionState,
} from "./types"
describe("boulder-state types", () => {
test("keeps legacy BoulderState assignable while allowing v2 fields", () => {
// given
const legacyState: BoulderState = {
active_plan: "/tmp/plan.md",
started_at: "2026-01-01T00:00:00.000Z",
session_ids: ["ses_1"],
plan_name: "plan",
}
// when
const hasLegacyShape = legacyState.active_plan.length > 0
// then
expect(hasLegacyShape).toBe(true)
})
test("supports multi-work and timer fields", () => {
// given
const taskStatus: BoulderTaskStatus = "running"
const workStatus: BoulderWorkStatus = "active"
const origin: BoulderSessionOrigin = "direct"
const taskSession: TaskSessionState = {
task_key: "todo:1",
task_label: "1",
task_title: "Do work",
session_id: "ses_task",
started_at: "2026-01-01T00:00:00.000Z",
ended_at: "2026-01-01T00:00:01.000Z",
elapsed_ms: 1000,
status: taskStatus,
updated_at: "2026-01-01T00:00:01.000Z",
}
const work: BoulderWorkState = {
work_id: "plan-abc12345",
active_plan: "/tmp/plan.md",
plan_name: "plan",
status: workStatus,
started_at: "2026-01-01T00:00:00.000Z",
session_ids: ["ses_1"],
session_origins: { ses_1: origin },
task_sessions: { "todo:1": taskSession },
}
const progress: PlanProgress = { total: 2, completed: 1, isComplete: false }
const resumeOption: BoulderWorkResumeOption = {
work_id: work.work_id,
plan_name: work.plan_name,
active_plan: work.active_plan,
status: "paused",
started_at: work.started_at,
updated_at: "2026-01-01T00:00:02.000Z",
session_count: 1,
progress,
is_current_mirror: false,
}
// when
const combined = { taskSession, work, resumeOption }
// then
expect(combined.resumeOption.progress.total).toBe(2)
})
})
+46
View File
@@ -6,10 +6,17 @@
*/
export interface BoulderState {
schema_version?: 2
active_work_id?: string
works?: Record<string, BoulderWorkState>
/** Absolute path to the active plan file */
active_plan: string
/** ISO timestamp when work started */
started_at: string
ended_at?: string
elapsed_ms?: number
status?: BoulderWorkStatus
updated_at?: string
/** Session IDs that have worked on this plan */
session_ids: string[]
session_origins?: Record<string, "direct" | "appended">
@@ -23,6 +30,26 @@ export interface BoulderState {
task_sessions?: Record<string, TaskSessionState>
}
export type BoulderSessionOrigin = "direct" | "appended"
export type BoulderWorkStatus = "active" | "completed" | "paused" | "abandoned"
export type BoulderTaskStatus = "running" | "completed" | "cancelled"
export interface BoulderWorkState {
work_id: string
active_plan: string
plan_name: string
status?: BoulderWorkStatus
started_at: string
ended_at?: string
elapsed_ms?: number
updated_at?: string
session_ids: string[]
session_origins?: Record<string, BoulderSessionOrigin>
agent?: string
worktree_path?: string
task_sessions?: Record<string, TaskSessionState>
}
export interface PlanProgress {
/** Total number of checkboxes */
total: number
@@ -45,10 +72,29 @@ export interface TaskSessionState {
agent?: string
/** Category associated with the task session, when known */
category?: string
started_at?: string
ended_at?: string
elapsed_ms?: number
status?: BoulderTaskStatus
/** Last update timestamp */
updated_at: string
}
export interface BoulderWorkResumeOption {
work_id: string
plan_name: string
active_plan: string
worktree_path?: string
status: BoulderWorkStatus
started_at: string
updated_at: string
ended_at?: string
elapsed_ms?: number
session_count: number
progress: PlanProgress
is_current_mirror: boolean
}
export interface TopLevelTaskRef {
/** Stable identifier for the current top-level plan task */
key: string
@@ -16,9 +16,13 @@ export const START_WORK_TEMPLATE = `You are starting a Sisyphus work session.
2. **Check for active boulder state**: Read \`.sisyphus/boulder.json\` if it exists
3. **Decision logic**:
- If \`.sisyphus/boulder.json\` exists AND plan is NOT complete (has unchecked boxes):
- **APPEND** current session to session_ids
- Continue work on existing plan
- If multiple active works are listed in your context:
- This means boulder.json has more than one work with status: \`active\` or \`paused\`
- Use the Question tool to ask the user which plan to resume
- Resume by running \`/start-work {plan-name}\` for the selected plan
- If the user says "start a new plan", continue with cold-start auto-selection logic
- If exactly one active work is listed and the user did not name a plan:
- Auto-resume that single active work
- If no active plan OR plan is complete:
- List available plan files
- If ONE plan: auto-select it
@@ -7,6 +7,7 @@ import { removeTeamLayout } from "../team-layout-tmux/layout"
import { unregisterTeamSessionsByTeam } from "../team-session-registry"
import { loadRuntimeState, transitionRuntimeState } from "../team-state-store/store"
import type { TeamRunCreateError } from "./create"
import { unregisterTeamRunForSessionCleanup } from "./session-team-run-registry"
type SpawnedMemberResource = {
taskId?: string
@@ -72,6 +73,7 @@ export async function cleanupTeamRunResources(args: {
})
unregisterTeamSessionsByTeam(args.teamRunId)
unregisterTeamRunForSessionCleanup(args.teamRunId)
return cleanupReport
}
@@ -1,6 +1,6 @@
/// <reference types="bun-types" />
import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"
import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
import { access, mkdtemp, readdir, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
@@ -14,6 +14,10 @@ import { BackgroundManager } from "../../background-agent/manager"
import { loadRuntimeState } from "../team-state-store/store"
import { clearTeamSessionRegistry, lookupTeamSession } from "../team-session-registry"
import type { TeamSpec } from "../types"
import {
clearSessionTeamRunCleanupRegistry,
getSessionCreatedTeamRunIds,
} from "./session-cleanup"
const resolveMemberMock = mock(async (member: TeamSpec["members"][number]) => ({
agentToUse: `${member.name}-agent`,
@@ -92,9 +96,15 @@ describe("createTeamRun", () => {
beforeEach(() => {
resolveMemberMock.mockClear()
clearTeamSessionRegistry()
clearSessionTeamRunCleanupRegistry()
})
afterEach(() => {
clearSessionTeamRunCleanupRegistry()
})
afterAll(async () => {
clearSessionTeamRunCleanupRegistry()
await Promise.all(temporaryDirectories.splice(0).map(async (directoryPath) => rm(directoryPath, { recursive: true, force: true })))
})
@@ -117,6 +127,19 @@ describe("createTeamRun", () => {
expect((launchMock.mock.calls as Array<[LaunchInput]>).every(([input]) => input.suppressTmuxSpawn === true)).toBe(true)
})
test("#given a new team runtime #when createTeamRun succeeds #then it registers the run for session cleanup", async () => {
// given
const baseDir = await mkdtemp(path.join(tmpdir(), "team-runtime-session-cleanup-"))
temporaryDirectories.push(baseDir)
const { manager } = createManager(baseDir, async () => ({ id: "task-1", sessionId: "session-1", status: "running" } as BackgroundTask))
// when
const runtimeState = await createTeamRun(createSpec(1), "lead-session", createContext(baseDir, manager), createConfig(baseDir), manager)
// then
expect(getSessionCreatedTeamRunIds()).toEqual([runtimeState.teamRunId])
})
test("registers a member session as soon as launch reports the real sessionId", async () => {
// given
const baseDir = await mkdtemp(path.join(tmpdir(), "team-runtime-session-lineage-"))
@@ -230,6 +253,7 @@ describe("createTeamRun", () => {
}
expect((cancelTaskMock.mock.calls as Array<[string]>).map(([taskId]) => taskId)).toEqual(["task-3", "task-2", "task-1"])
expect((await loadSingleRuntimeState(baseDir)).status).toBe("failed")
expect(getSessionCreatedTeamRunIds()).toEqual([])
})
test("removes all created worktrees when spawn fails after worktree creation", async () => {
@@ -17,6 +17,7 @@ import { buildTeammateCommunicationAddendum } from "../member-guidance"
import { resolveMember } from "./resolve-member"
import { shouldReuseCallerLeadSession } from "../resolve-caller-team-lead"
import { sweepStaleTeamSessions } from "../team-layout-tmux/sweep-stale-team-sessions"
import { registerTeamRunForSessionCleanup } from "./session-team-run-registry"
const SESSION_ID_POLL_MS = 25
@@ -129,6 +130,7 @@ export async function createTeamRun(
await ensureBaseDirs(baseDir)
const reusesCallerLeadSession = shouldReuseCallerLeadSession(spec, options?.callerAgentTypeId)
let runtimeState = await createRuntimeState(spec, leadSessionId, await resolveSpecSource(spec, ctx, config), config)
registerTeamRunForSessionCleanup(runtimeState.teamRunId)
if (reusesCallerLeadSession && spec.leadAgentId) {
const callerLeadSubagentType = options?.callerAgentTypeId
registerTeamSession(leadSessionId, {
@@ -9,6 +9,7 @@ import { unregisterTeamSessionsByTeam } from "../team-session-registry"
import { listActiveTeams, loadRuntimeState, saveRuntimeState, transitionRuntimeState } from "../team-state-store/store"
import type { RuntimeState } from "../types"
import { DELETABLE_MEMBER_STATUSES, removeWorktrees } from "./shutdown-helpers"
import { unregisterTeamRunForSessionCleanup } from "./session-team-run-registry"
export type DeleteTeamDeps = {
canVisualize: typeof canVisualize
@@ -139,6 +140,7 @@ export async function deleteTeam(
await removeWorktrees([getRuntimeStateDir(resolveBaseDir(config), teamRunId)])
unregisterTeamSessionsByTeam(teamRunId)
unregisterTeamRunForSessionCleanup(teamRunId)
const activeTeams = await listActiveTeams(config)
sweepStaleTeamSessions(new Set(activeTeams.map((team) => team.teamRunId))).catch(() => {})
@@ -0,0 +1,57 @@
/// <reference types="bun-types" />
import { afterEach, describe, expect, mock, test } from "bun:test"
import { TeamModeConfigSchema } from "../../../config/schema/team-mode"
import type { BackgroundManager } from "../../background-agent/manager"
import type { TmuxSessionManager } from "../../tmux-subagent/manager"
import type { deleteTeam } from "./delete-team"
import {
cleanupSessionTeamRuns,
clearSessionTeamRunCleanupRegistry,
getSessionCreatedTeamRunIds,
registerTeamRunForSessionCleanup,
} from "./session-cleanup"
describe("session team cleanup", () => {
afterEach(() => {
clearSessionTeamRunCleanupRegistry()
mock.restore()
})
test("#given team runs created in this process #when session cleanup runs #then it force deletes them with the tmux visualizer manager", async () => {
// given
const config = TeamModeConfigSchema.parse({ enabled: true, tmux_visualization: true })
const tmuxMgr = { getServerUrl: () => "http://127.0.0.1:4096" } as TmuxSessionManager
const bgMgr = { cancelTask: mock(async () => true) } as BackgroundManager
const deleteTeamMock = mock(async () => ({
removedLayout: true,
removedWorktrees: [],
})) as typeof deleteTeam
registerTeamRunForSessionCleanup("team-run-a")
registerTeamRunForSessionCleanup("team-run-b")
// when
const report = await cleanupSessionTeamRuns({
config,
tmuxMgr,
bgMgr,
deps: {
deleteTeam: deleteTeamMock,
log: mock(() => {}),
},
})
// then
expect(deleteTeamMock).toHaveBeenCalledTimes(2)
expect(deleteTeamMock).toHaveBeenNthCalledWith(1, "team-run-a", config, tmuxMgr, bgMgr, { force: true })
expect(deleteTeamMock).toHaveBeenNthCalledWith(2, "team-run-b", config, tmuxMgr, bgMgr, { force: true })
expect(report).toEqual({
cleanedTeamRunIds: ["team-run-a", "team-run-b"],
removedLayoutTeamRunIds: ["team-run-a", "team-run-b"],
errors: [],
})
expect(getSessionCreatedTeamRunIds()).toEqual([])
})
})
@@ -0,0 +1,71 @@
import type { TeamModeConfig } from "../../../config/schema/team-mode"
import { log } from "../../../shared/logger"
import type { BackgroundManager } from "../../background-agent/manager"
import type { TmuxSessionManager } from "../../tmux-subagent/manager"
import { deleteTeam } from "./delete-team"
import {
getSessionCreatedTeamRunIds,
unregisterTeamRunForSessionCleanup,
} from "./session-team-run-registry"
export {
clearSessionTeamRunCleanupRegistry,
getSessionCreatedTeamRunIds,
registerTeamRunForSessionCleanup,
unregisterTeamRunForSessionCleanup,
} from "./session-team-run-registry"
export type SessionTeamCleanupReport = {
cleanedTeamRunIds: string[]
removedLayoutTeamRunIds: string[]
errors: string[]
}
export type SessionTeamCleanupDeps = {
deleteTeam: typeof deleteTeam
log: typeof log
}
const defaultSessionTeamCleanupDeps: SessionTeamCleanupDeps = {
deleteTeam,
log,
}
function normalizeError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error))
}
export async function cleanupSessionTeamRuns(args: {
config: TeamModeConfig
tmuxMgr?: TmuxSessionManager
bgMgr?: BackgroundManager
deps?: SessionTeamCleanupDeps
}): Promise<SessionTeamCleanupReport> {
const deps = args.deps ?? defaultSessionTeamCleanupDeps
const report: SessionTeamCleanupReport = {
cleanedTeamRunIds: [],
removedLayoutTeamRunIds: [],
errors: [],
}
for (const teamRunId of getSessionCreatedTeamRunIds()) {
try {
const result = await deps.deleteTeam(teamRunId, args.config, args.tmuxMgr, args.bgMgr, { force: true })
report.cleanedTeamRunIds.push(teamRunId)
if (result.removedLayout) {
report.removedLayoutTeamRunIds.push(teamRunId)
}
} catch (error) {
const normalizedError = normalizeError(error)
report.errors.push(`${teamRunId}: ${normalizedError.message}`)
deps.log("session team cleanup failed", {
teamRunId,
error: normalizedError.message,
})
} finally {
unregisterTeamRunForSessionCleanup(teamRunId)
}
}
return report
}
@@ -0,0 +1,17 @@
const sessionCreatedTeamRunIds = new Set<string>()
export function registerTeamRunForSessionCleanup(teamRunId: string): void {
sessionCreatedTeamRunIds.add(teamRunId)
}
export function unregisterTeamRunForSessionCleanup(teamRunId: string): void {
sessionCreatedTeamRunIds.delete(teamRunId)
}
export function getSessionCreatedTeamRunIds(): string[] {
return Array.from(sessionCreatedTeamRunIds)
}
export function clearSessionTeamRunCleanupRegistry(): void {
sessionCreatedTeamRunIds.clear()
}
@@ -15,6 +15,11 @@ import {
readInboxMessages,
updateMemberStatuses,
} from "./shutdown-test-fixtures"
import {
clearSessionTeamRunCleanupRegistry,
getSessionCreatedTeamRunIds,
registerTeamRunForSessionCleanup,
} from "./session-cleanup"
const { approveShutdown, deleteTeam, rejectShutdown, requestShutdownOfMember } = await import("./shutdown")
@@ -25,6 +30,7 @@ describe("team-runtime shutdown", () => {
await Promise.all(temporaryDirectories.splice(0).map(async (directoryPath) => {
await rm(directoryPath, { recursive: true, force: true })
}))
clearSessionTeamRunCleanupRegistry()
mock.restore()
})
@@ -161,6 +167,23 @@ describe("team-runtime shutdown", () => {
)
})
test("#given a team run is tracked for session cleanup #when deleteTeam succeeds #then it unregisters the run", async () => {
// given
const fixture = await createFixture()
temporaryDirectories.push(fixture.baseDir)
registerTeamRunForSessionCleanup(fixture.teamRunId)
await updateMemberStatuses(fixture.teamRunId, fixture.config, {
"member-a": "shutdown_approved",
"member-b": "shutdown_approved",
})
// when
await deleteTeam(fixture.teamRunId, fixture.config)
// then
expect(getSessionCreatedTeamRunIds()).toEqual([])
})
test("deletes team even with active members when force=true", async () => {
// given
const fixture = await createFixture()