/ tasks / DreamTask / DreamTask.ts
DreamTask.ts
  1  // Background task entry for auto-dream (memory consolidation subagent).
  2  // Makes the otherwise-invisible forked agent visible in the footer pill and
  3  // Shift+Down dialog. The dream agent itself is unchanged — this is pure UI
  4  // surfacing via the existing task registry.
  5  
  6  import { rollbackConsolidationLock } from '../../services/autoDream/consolidationLock.js'
  7  import type { SetAppState, Task, TaskStateBase } from '../../Task.js'
  8  import { createTaskStateBase, generateTaskId } from '../../Task.js'
  9  import { registerTask, updateTaskState } from '../../utils/task/framework.js'
 10  
 11  // Keep only the N most recent turns for live display.
 12  const MAX_TURNS = 30
 13  
 14  // A single assistant turn from the dream agent, tool uses collapsed to a count.
 15  export type DreamTurn = {
 16    text: string
 17    toolUseCount: number
 18  }
 19  
 20  // No phase detection — the dream prompt has a 4-stage structure
 21  // (orient/gather/consolidate/prune) but we don't parse it. Just flip from
 22  // 'starting' to 'updating' when the first Edit/Write tool_use lands.
 23  export type DreamPhase = 'starting' | 'updating'
 24  
 25  export type DreamTaskState = TaskStateBase & {
 26    type: 'dream'
 27    phase: DreamPhase
 28    sessionsReviewing: number
 29    /**
 30     * Paths observed in Edit/Write tool_use blocks via onMessage. This is an
 31     * INCOMPLETE reflection of what the dream agent actually changed — it misses
 32     * any bash-mediated writes and only captures the tool calls we pattern-match.
 33     * Treat as "at least these were touched", not "only these were touched".
 34     */
 35    filesTouched: string[]
 36    /** Assistant text responses, tool uses collapsed. Prompt is NOT included. */
 37    turns: DreamTurn[]
 38    abortController?: AbortController
 39    /** Stashed so kill can rewind the lock mtime (same path as fork-failure). */
 40    priorMtime: number
 41  }
 42  
 43  export function isDreamTask(task: unknown): task is DreamTaskState {
 44    return (
 45      typeof task === 'object' &&
 46      task !== null &&
 47      'type' in task &&
 48      task.type === 'dream'
 49    )
 50  }
 51  
 52  export function registerDreamTask(
 53    setAppState: SetAppState,
 54    opts: {
 55      sessionsReviewing: number
 56      priorMtime: number
 57      abortController: AbortController
 58    },
 59  ): string {
 60    const id = generateTaskId('dream')
 61    const task: DreamTaskState = {
 62      ...createTaskStateBase(id, 'dream', 'dreaming'),
 63      type: 'dream',
 64      status: 'running',
 65      phase: 'starting',
 66      sessionsReviewing: opts.sessionsReviewing,
 67      filesTouched: [],
 68      turns: [],
 69      abortController: opts.abortController,
 70      priorMtime: opts.priorMtime,
 71    }
 72    registerTask(task, setAppState)
 73    return id
 74  }
 75  
 76  export function addDreamTurn(
 77    taskId: string,
 78    turn: DreamTurn,
 79    touchedPaths: string[],
 80    setAppState: SetAppState,
 81  ): void {
 82    updateTaskState<DreamTaskState>(taskId, setAppState, task => {
 83      const seen = new Set(task.filesTouched)
 84      const newTouched = touchedPaths.filter(p => !seen.has(p) && seen.add(p))
 85      // Skip the update entirely if the turn is empty AND nothing new was
 86      // touched. Avoids re-rendering on pure no-ops.
 87      if (
 88        turn.text === '' &&
 89        turn.toolUseCount === 0 &&
 90        newTouched.length === 0
 91      ) {
 92        return task
 93      }
 94      return {
 95        ...task,
 96        phase: newTouched.length > 0 ? 'updating' : task.phase,
 97        filesTouched:
 98          newTouched.length > 0
 99            ? [...task.filesTouched, ...newTouched]
100            : task.filesTouched,
101        turns: task.turns.slice(-(MAX_TURNS - 1)).concat(turn),
102      }
103    })
104  }
105  
106  export function completeDreamTask(
107    taskId: string,
108    setAppState: SetAppState,
109  ): void {
110    // notified: true immediately — dream has no model-facing notification path
111    // (it's UI-only), and eviction requires terminal + notified. The inline
112    // appendSystemMessage completion note IS the user surface.
113    updateTaskState<DreamTaskState>(taskId, setAppState, task => ({
114      ...task,
115      status: 'completed',
116      endTime: Date.now(),
117      notified: true,
118      abortController: undefined,
119    }))
120  }
121  
122  export function failDreamTask(taskId: string, setAppState: SetAppState): void {
123    updateTaskState<DreamTaskState>(taskId, setAppState, task => ({
124      ...task,
125      status: 'failed',
126      endTime: Date.now(),
127      notified: true,
128      abortController: undefined,
129    }))
130  }
131  
132  export const DreamTask: Task = {
133    name: 'DreamTask',
134    type: 'dream',
135  
136    async kill(taskId, setAppState) {
137      let priorMtime: number | undefined
138      updateTaskState<DreamTaskState>(taskId, setAppState, task => {
139        if (task.status !== 'running') return task
140        task.abortController?.abort()
141        priorMtime = task.priorMtime
142        return {
143          ...task,
144          status: 'killed',
145          endTime: Date.now(),
146          notified: true,
147          abortController: undefined,
148        }
149      })
150      // Rewind the lock mtime so the next session can retry. Same path as the
151      // fork-failure catch in autoDream.ts. If updateTaskState was a no-op
152      // (already terminal), priorMtime stays undefined and we skip.
153      if (priorMtime !== undefined) {
154        await rollbackConsolidationLock(priorMtime)
155      }
156    },
157  }