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:
@@ -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", () => {
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
@@ -2,3 +2,4 @@ export * from "./types"
|
||||
export * from "./constants"
|
||||
export * from "./storage"
|
||||
export * from "./top-level-task"
|
||||
export * from "./format-duration"
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user