-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathTaskManager.ts
More file actions
878 lines (768 loc) · 31.9 KB
/
Copy pathTaskManager.ts
File metadata and controls
878 lines (768 loc) · 31.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
import EventEmitter from "events"
import type {
TaskState,
TaskLifecycle,
HistoryItem,
ToolName,
TokenUsage,
ToolUsage,
TaskCompletedInfo,
TaskAbortedInfo,
ManagedTask,
ManagedTaskNotification,
TaskManagerEvents,
} from "@shofer/types"
import { ShoferEventName, isTerminalLifecycle, IDLE_TASK_STATE } from "@shofer/types"
import type { Task } from "@shofer/core"
import { taskLog } from "@shofer/core"
import type { ShoferProvider } from "../../core/webview/ShoferProvider"
import { incTaskCreated, incTaskCompleted, incTaskErrored } from "@shofer/core"
// The TaskManager TYPE surface (`ManagedTask`, `ManagedTaskNotification`,
// `TaskManagerEvents`) now lives in `@shofer/types` so `@shofer/core`'s
// `TaskManagerLike` and the orchestration tools can be import-clean. Re-exported
// here so existing `./TaskManager`/`../services/task-manager` consumers are
// unchanged. The CONCRETE class below stays in `src` (ShoferProvider-coupled).
export type { ManagedTask, ManagedTaskNotification, TaskManagerEvents } from "@shofer/types"
/**
* TaskManager handles multiple concurrent tasks.
*
* The manager is the single authority for task lifecycle/rating state. It:
* 1. Listens to `Task` events and translates them into `ManagedTask.state`.
* 2. Persists state changes through to `HistoryItem.taskState` (single
* writer — no other component is allowed to write `taskState`).
* 3. Hydrates from the persisted history at startup so that re-visiting a
* task after a restart shows the correct icon immediately.
*/
export class TaskManager extends EventEmitter<TaskManagerEvents> {
private activeTasks: Map<string, Task> = new Map()
private focusedTaskId: string | null = null
private notifications: ManagedTaskNotification[] = []
private managedTasks: Map<string, ManagedTask> = new Map()
/**
* Set to true once `ensureRestored()` is called. Gates
* `assertRestored()` — early-bird code paths (a hot-constructor
* constructor, etc.) must not throw before the async history store
* settles.
*/
private restored = false
/**
* Set to true once `restoreManagedTasks()` seeds `managedTasks` from
* persisted history. Kept separate from `restored` so
* `ensureRestored()` (which flips `restored`) does NOT prevent the
* later `restoreManagedTasks()` call from actually seeding.
*/
private seeded = false
private providerRef: WeakRef<ShoferProvider>
/**
* Per-task monotonic version counter for the latest-wins persist strategy.
* Incremented on every `setState()` call for the task. Each
* `enqueuePersist()` captures the current version; when the async I/O
* completes, the captured version is compared against the live counter.
* If they differ, the write is stale and is silently dropped.
*
* This handles three race conditions:
* 1. Fire-and-forget `completed+rating` write from `attempt_completion`
* landing AFTER a subsequent `running` write — the `running` write
* incremented the counter, so the stale `completed` write is skipped.
* 2. Process restart between enqueue and write — the counter is lost, so
* no stale write survives (restore loads from disk directly).
* 3. Slow I/O — even if a write takes seconds to complete, it never
* retroactively overwrites a newer state.
*/
private persistVersions: Map<string, number> = new Map()
/**
* Per-task persist promise chains. Each new `enqueuePersist` call
* chains onto the previous promise for the same task, so writes are
* serialized — the second write always starts after the first completes.
* Combined with `persistVersions` (latest-wins), this ensures that the
* last `setState` call for any task always ends up on disk.
*/
private persistChains: Map<string, Promise<void>> = new Map()
constructor(provider: ShoferProvider) {
super()
this.providerRef = new WeakRef(provider)
}
// ────────────────────────────── State helpers ──────────────────────────────
/**
* Build a new TaskState. Use this rather than instantiating literals so
* the lifecycle/rating invariant (rating only valid when completed) is
* enforced in one place.
*/
private static makeState(lifecycle: TaskLifecycle, rating?: TaskState["rating"]): TaskState {
if (lifecycle === "completed" && rating) return { lifecycle, rating }
return { lifecycle }
}
private static statesEqual(a: TaskState | undefined, b: TaskState | undefined): boolean {
if (a === b) return true
if (!a || !b) return false
return a.lifecycle === b.lifecycle && a.rating === b.rating
}
// ────────────────────────────── Lifecycle ──────────────────────────────
async createManagedTask(name: string | undefined, task: Task): Promise<ManagedTask> {
const managedTask: ManagedTask = {
id: task.taskId,
name: name || "New Task",
taskId: task.taskId,
rootTaskId: task.rootTaskId,
workspace: task.cwd || "",
createdAt: Date.now(),
lastActiveAt: Date.now(),
state: TaskManager.makeState("running"),
activeTimeMs: 0,
_runningSince: Date.now(),
}
this.managedTasks.set(managedTask.id, managedTask)
this.activeTasks.set(managedTask.id, task)
this.setupManagedTaskEventListeners(task)
await this.focusTask(managedTask.id)
this.emit("tasks:updated", this.getManagedTasks())
this._emitTaskCreatedMetric(task)
return managedTask
}
/**
* Register an existing Task as a background managed task.
*
* `restoreManagedTasks` MUST have been called at least once before this
* method is reached for any rehydrated task — otherwise the heuristic
* fallback below would mis-classify resumed tasks as freshly running.
* The provider enforces this ordering via `initializeTaskHistoryStore`.
*/
registerBackgroundTask(task: Task, name?: string): void {
this.assertRestored("registerBackgroundTask")
const existingActive = this.activeTasks.get(task.taskId)
if (existingActive) {
this.cleanupTaskEventListeners(existingActive)
this.activeTasks.set(task.taskId, task)
this.setupManagedTaskEventListeners(task)
this._emitTaskCreatedMetric(task)
return
}
const existing = this.managedTasks.get(task.taskId)
const taskText = task.shoferMessages.find((m) => m.type === "say" && m.say === "text")?.text || ""
const autoName =
name || (taskText ? taskText.slice(0, 50).trim() + (taskText.length > 50 ? "..." : "") : "New Task")
// Fallback only fires for genuinely new tasks (no persisted history).
// For rehydrated tasks `existing` was seeded by restoreManagedTasks.
const state = existing?.state ?? TaskManager.makeState(task.abandoned || task.abort ? "idle" : "running")
const isActive = TaskManager.isActive(state.lifecycle)
const managedTask: ManagedTask = {
id: task.taskId,
name: existing?.name ?? autoName,
taskId: task.taskId,
rootTaskId: task.rootTaskId,
workspace: task.cwd || "",
createdAt: existing?.createdAt ?? Date.now(),
lastActiveAt: Date.now(),
state,
activeTimeMs: existing?.activeTimeMs ?? 0,
_runningSince: isActive ? Date.now() : 0,
}
this.managedTasks.set(managedTask.id, managedTask)
this.activeTasks.set(managedTask.id, task)
this.setupManagedTaskEventListeners(task)
this.emit("tasks:updated", this.getManagedTasks())
}
private cleanupTaskEventListeners(task: Task): void {
const cleanupSymbol = Symbol.for("taskManager.cleanup")
const cleanup = (task as any)[cleanupSymbol]
if (typeof cleanup === "function") {
cleanup()
delete (task as any)[cleanupSymbol]
}
}
updateTaskInstance(targetTaskId: string, newTask: Task): void {
const managedTask = this.managedTasks.get(targetTaskId)
if (!managedTask) {
return
}
const oldTask = this.activeTasks.get(targetTaskId)
if (oldTask) {
this.cleanupTaskEventListeners(oldTask)
}
this.activeTasks.set(targetTaskId, newTask)
this.setupManagedTaskEventListeners(newTask)
}
async deleteManagedTask(targetTaskId: string): Promise<void> {
const task = this.activeTasks.get(targetTaskId)
if (task) {
this.cleanupTaskEventListeners(task)
await task.abortTask(true).catch(() => {})
this.activeTasks.delete(targetTaskId)
}
this.managedTasks.delete(targetTaskId)
this.notifications = this.notifications.filter((n) => n.targetTaskId !== targetTaskId)
if (this.focusedTaskId === targetTaskId) {
this.focusedTaskId = null
}
this.emit("tasks:updated", this.getManagedTasks())
}
// ────────────────────────────── Focus Management ──────────────────────────────
async focusTask(targetTaskId: string): Promise<void> {
const managedTask = this.managedTasks.get(targetTaskId)
if (!managedTask) {
throw new Error(`Task ${targetTaskId} not found`)
}
if (this.focusedTaskId) {
const prevManagedTask = this.managedTasks.get(this.focusedTaskId)
if (prevManagedTask) {
prevManagedTask.lastActiveAt = Date.now()
}
}
this.focusedTaskId = targetTaskId
managedTask.lastActiveAt = Date.now()
this.notifications = this.notifications.filter((n) => n.targetTaskId !== targetTaskId)
this.emit("managedTask:state-changed", targetTaskId, managedTask.state)
this.emit("tasks:updated", this.getManagedTasks())
}
getFocusedTask(): ManagedTask | null {
if (!this.focusedTaskId) {
return null
}
return this.managedTasks.get(this.focusedTaskId) || null
}
getFocusedTaskId(): string | null {
return this.focusedTaskId
}
/**
* Clear the focused task if it currently matches `targetTaskId`.
*
* Called when a task is removed from the visible UI stack without being
* aborted (parallel-task switch via the pencil/+ button). Without this,
* the popped task keeps satisfying the `getFocusedTaskId() === taskId`
* branch in `Task.addToShoferMessages` and continues streaming
* `shoferMessageAppended` deltas to the webview, which re-mounts
* ChatView for the supposedly-backgrounded task.
*/
clearFocusIfMatches(targetTaskId: string): void {
if (this.focusedTaskId === targetTaskId) {
this.focusedTaskId = null
}
}
// ────────────────────────────── Execution Control ──────────────────────────────
async startManagedTask(targetTaskId: string): Promise<void> {
const managedTask = this.managedTasks.get(targetTaskId)
if (!managedTask) {
throw new Error(`Task ${targetTaskId} not found`)
}
const task = this.activeTasks.get(targetTaskId)
if (!task) {
throw new Error(`Task for managed task ${targetTaskId} not found`)
}
this.setState(targetTaskId, TaskManager.makeState("running"))
}
async pauseManagedTask(targetTaskId: string): Promise<void> {
const managedTask = this.managedTasks.get(targetTaskId)
if (!managedTask) {
throw new Error(`Task ${targetTaskId} not found`)
}
const task = this.activeTasks.get(targetTaskId)
if (task) {
task.cancelCurrentRequest()
await task.abortTask(false).catch(() => {})
}
this.setState(targetTaskId, TaskManager.makeState("paused"))
}
async stopManagedTask(targetTaskId: string): Promise<void> {
const managedTask = this.managedTasks.get(targetTaskId)
if (!managedTask) {
throw new Error(`Task ${targetTaskId} not found`)
}
const task = this.activeTasks.get(targetTaskId)
if (task) {
await task.abortTask(true).catch(() => {})
}
this.setState(targetTaskId, IDLE_TASK_STATE)
}
// ────────────────────────────── Queries ──────────────────────────────
/**
* "Active" = the task is doing work: running its own loop, or blocked waiting
* on its mailbox (`wait`) or on an async MCP call (`wait_for_mcp_call`).
* Idle, waiting_input (waiting on the user), paused, and terminal states are
* NOT active. `activeTimeMs` accumulates active wall-clock time and excludes
* only idle-equivalent time — matching the Stats "runtime" pie.
*/
private static isActive(lifecycle: TaskState["lifecycle"]): boolean {
return lifecycle === "running" || lifecycle === "waiting"
}
/**
* Live wall-clock active time for a task, in ms: the stored `activeTimeMs`
* plus, when the task is currently active (running or waiting), the
* in-progress interval since `_runningSince`. Pure — never mutates timing state.
*/
private static liveActiveTimeMs(m: ManagedTask, now: number): number {
return TaskManager.isActive(m.state.lifecycle) && m._runningSince > 0
? m.activeTimeMs + (now - m._runningSince)
: m.activeTimeMs
}
/**
* Return all managed tasks sorted by most-recently-active first.
*
* Pure query — it never mutates internal timing state (this used to
* accumulate `activeTimeMs` in place, making every incidental read advance
* the persistence clock). For a currently-running task the returned snapshot
* carries a *live* `activeTimeMs` computed into a shallow copy, so callers
* see up-to-the-millisecond time with no side effects; non-running tasks are
* returned by reference (no allocation). The stored accumulation is advanced
* by `setState` when a task leaves `running`, and `persistState` writes the
* live value so a persist mid-run records the correct wall-clock time.
*/
getManagedTasks(): ManagedTask[] {
const now = Date.now()
return Array.from(this.managedTasks.values())
.map((m) =>
TaskManager.isActive(m.state.lifecycle) && m._runningSince > 0
? { ...m, activeTimeMs: TaskManager.liveActiveTimeMs(m, now) }
: m,
)
.sort((a, b) => b.lastActiveAt - a.lastActiveAt)
}
/**
* Count of non-terminal, non-idle managed tasks (running or waiting).
* Used as the live concurrency count for the parallel-task limit.
*/
countActiveTasks(): number {
let count = 0
for (const m of this.managedTasks.values()) {
if (TaskManager.isActive(m.state.lifecycle)) {
count++
}
}
return count
}
getActiveManagedTasks(): ManagedTask[] {
return this.getManagedTasks().filter((s) => this.activeTasks.has(s.id))
}
getBackgroundTasks(): ManagedTask[] {
return this.getActiveManagedTasks().filter((s) => s.id !== this.focusedTaskId)
}
getTaskState(targetTaskId: string): TaskState | undefined {
return this.managedTasks.get(targetTaskId)?.state
}
getManagedTask(targetTaskId: string): ManagedTask | undefined {
return this.managedTasks.get(targetTaskId)
}
getManagedTaskInstance(targetTaskId: string): Task | undefined {
return this.activeTasks.get(targetTaskId)
}
removeManagedTaskInstance(targetTaskId: string): void {
const task = this.activeTasks.get(targetTaskId)
if (task) {
this.cleanupTaskEventListeners(task)
}
this.activeTasks.delete(targetTaskId)
}
// ────────────────────────────── Notifications ──────────────────────────────
getNotifications(): ManagedTaskNotification[] {
return [...this.notifications]
}
clearTaskNotification(targetTaskId: string): void {
this.notifications = this.notifications.filter((n) => n.targetTaskId !== targetTaskId)
}
private addNotification(notification: ManagedTaskNotification): void {
const existing = this.notifications.find(
(n) => n.targetTaskId === notification.targetTaskId && n.type === notification.type,
)
if (!existing) {
this.notifications.push(notification)
this.emit("managedTask:needs-input", notification)
}
}
// ────────────────────────────── State Mutation (single writer) ──────────────────────────────
/**
* Set the in-memory state and persist it through to the HistoryItem.
*
* This is the ONLY method that writes `HistoryItem.taskState`. No other
* component should set `taskState` directly — they must go through
* TaskManager (typically via events).
*/
setState(targetTaskId: string, state: TaskState): void {
const managedTask = this.managedTasks.get(targetTaskId)
if (!managedTask) {
return
}
const prevState = managedTask.state
if (TaskManager.statesEqual(prevState, state)) return
// Accumulate wall-clock time spent active (running or waiting) when leaving it.
if (TaskManager.isActive(prevState.lifecycle) && managedTask._runningSince > 0) {
managedTask.activeTimeMs += Date.now() - managedTask._runningSince
managedTask._runningSince = 0
}
managedTask.state = state
// Start timing when entering an active (running or waiting) state.
if (TaskManager.isActive(state.lifecycle)) {
managedTask._runningSince = Date.now()
}
// Bump the per-task version counter so any in-flight persist for an
// older state will detect that it's stale and silently drop.
const version = (this.persistVersions.get(targetTaskId) ?? 0) + 1
this.persistVersions.set(targetTaskId, version)
this.emit("managedTask:state-changed", targetTaskId, state)
this.emit("tasks:updated", this.getManagedTasks())
this.enqueuePersist(targetTaskId, state, version)
}
/**
* Fire-and-forget persist with latest-wins semantics.
*
* Each call captures a monotonic version from `persistVersions`. When the
* async I/O completes, the captured version is compared against the live
* counter — if another `setState` fired in the meantime, this write is
* stale and is silently dropped.
*
* Combined with the chaining from `enqueuePersist`, this ensures:
* - Writes for the same task never race (chaining guarantees serial order).
* - Stale writes that complete after a newer `setState` are discarded
* (version check).
* - A slow in-flight write does not block the process from shutting down
* with the latest state on disk — the live `managedTask.state` is the
* source of truth, and `setState` bumps the counter synchronously.
*/
private persistState(targetTaskId: string, state: TaskState, capturedVersion: number): Promise<void> {
// Fast-path: skip the entire I/O trip if the version is already stale
// before we even start. This catches the case where two setState calls
// happen synchronously before the first I/O begins.
{
const currentVersion = this.persistVersions.get(targetTaskId)
if (currentVersion !== undefined && currentVersion !== capturedVersion) {
taskLog.debug(
`[TaskManager] Skipping stale persist for ${targetTaskId}: version=${capturedVersion}, current=${currentVersion}, state=${state.lifecycle}`,
)
return Promise.resolve()
}
}
const provider = this.providerRef.deref()
if (!provider) return Promise.resolve()
return (async () => {
try {
const existing = provider.taskHistoryStore.get(targetTaskId)
if (!existing) return
if (TaskManager.statesEqual(existing.taskState, state)) return
// Re-check version AFTER the I/O completes (read + equality
// check). If a newer setState bumped the counter while we were
// waiting, this write is stale and must be silently dropped.
// Without this post-I/O re-check, a slow completed+rating
// persist from attempt_completion completes after a synchronous
// running write in cancelAndProcessQueuedMessages and overwrites
// the running state on disk.
const currentVersion = this.persistVersions.get(targetTaskId)
if (currentVersion !== undefined && currentVersion !== capturedVersion) {
taskLog.debug(
`[TaskManager] Dropping stale persist after I/O for ${targetTaskId}: v${capturedVersion} ${state.lifecycle} (current=v${currentVersion})`,
)
return
}
taskLog.debug(
`[TaskManager] Persisting state for ${targetTaskId}: v${capturedVersion} ${state.lifecycle}${state.rating ? ":" + state.rating : ""}`,
)
// Persist the live active time (stored accumulation + any in-progress
// running interval) so a persist that fires mid-run records correct
// wall-clock time. For leave-running transitions `setState` has
// already folded the interval into `activeTimeMs` and zeroed
// `_runningSince`, so this is identical to the stored value there.
const target = this.managedTasks.get(targetTaskId)
const activeTimeMs = target ? TaskManager.liveActiveTimeMs(target, Date.now()) : undefined
await provider.updateTaskHistory({
id: targetTaskId,
taskState: state,
activeTimeMs,
} as HistoryItem)
} catch (err) {
taskLog.error(
`[TaskManager] Failed to persist taskState for ${targetTaskId}:`,
err instanceof Error ? err.message : String(err),
)
}
})()
}
/**
* Enqueue a persist, chaining onto the previous one for this task.
* The version ensures that only the latest write actually hits disk.
*/
private enqueuePersist(targetTaskId: string, state: TaskState, version: number): void {
const prev = this.persistChains.get(targetTaskId) ?? Promise.resolve()
const next = prev.then(() => this.persistState(targetTaskId, state, version))
// Prune the chain once it settles so the map doesn't grow unbounded.
next.finally(() => {
if (this.persistChains.get(targetTaskId) === next) {
this.persistChains.delete(targetTaskId)
}
})
this.persistChains.set(targetTaskId, next)
taskLog.debug(
`[TaskManager] Enqueued persist for ${targetTaskId}: v${version} ${state.lifecycle}${state.rating ? ":" + state.rating : ""}`,
)
}
/**
* Await any in-flight persist for the given task.
*
* Lets a caller guarantee that the latest `setState` has been flushed to
* disk before proceeding — e.g. `Task.cancelAndProcessQueuedMessages`
* awaits this after emitting `TaskActive` (which synchronously routes
* through `setState(running)`) so the restarted loop cannot lose a race
* with a slower `completed` write. This is the single-writer-respecting
* alternative to writing `taskState` directly from the task.
*/
async waitForPendingPersist(targetTaskId: string): Promise<void> {
const chain = this.persistChains.get(targetTaskId)
if (chain) {
await chain.catch(() => {})
}
}
renameManagedTask(targetTaskId: string, name: string): void {
const managedTask = this.managedTasks.get(targetTaskId)
if (managedTask) {
managedTask.name = name
this.emit("tasks:updated", this.getManagedTasks())
}
}
// ────────────────────────────── Task Event Handling ──────────────────────────────
/**
* Translate Task events into `ManagedTask.state` updates.
*
* Each Task event is self-contained — `TaskCompleted` carries the rating,
* `TaskAborted` carries the abort reason — so the manager never needs to
* read state back from disk to interpret an event.
*/
private setupManagedTaskEventListeners(task: Task): void {
const targetTaskId = task.taskId
const onStarted = () => {
this.setState(targetTaskId, TaskManager.makeState("running"))
}
const onInteractive = (taskId: string) => {
if (taskId !== targetTaskId) return
this.setState(targetTaskId, TaskManager.makeState("waiting_input"))
// Suppress the needs_input notification for a question a child FORWARDED
// to its parent: it already has a live agent audience (the request is in
// the parent's mailbox, and the parent answers it with `reply`), so the
// human is not the one being waited on. They can still open the child and
// answer it there — first answer wins — but they are not interrupted.
if (task.parentTaskId && task.forwardedQuestion) {
return
}
if (this.focusedTaskId !== targetTaskId) {
this.addNotification({
targetTaskId,
type: "needs_input",
message: "Task needs your approval to continue",
timestamp: Date.now(),
})
}
}
const onActive = (taskId: string) => {
if (taskId !== targetTaskId) return
this.setState(targetTaskId, TaskManager.makeState("running"))
}
const onIdle = (taskId: string) => {
if (taskId !== targetTaskId) return
this.setState(targetTaskId, IDLE_TASK_STATE)
}
const onComplete = (
taskId: string,
_tokenUsage: TokenUsage,
_toolUsage: ToolUsage,
info: TaskCompletedInfo,
) => {
if (taskId !== targetTaskId) return
this.setState(targetTaskId, TaskManager.makeState("completed", info.rating))
this._emitTaskCompletedMetric(targetTaskId, info.rating)
this.emit("managedTask:completed", targetTaskId)
}
const onToolError = (taskId: string, _tool: ToolName, error: string) => {
if (taskId !== targetTaskId) return
this.emit("managedTask:tool-error", targetTaskId, error)
}
const onAborted = (info: TaskAbortedInfo) => {
// Reason-driven mapping — no peeking at current state.
switch (info.reason) {
case "completed":
case "error":
// Terminal outcome was already set by the originating event;
// the abort here is just cleanup.
return
case "user":
case "abandoned": {
// Don't override an already-terminal state. When a parent
// task completes and calls abortBackgroundChildren(), it
// calls abortTask(false) on every child — including ones
// that already reached a terminal outcome (completed via
// attempt_completion, or error). A child that completed
// before the parent's abort must NOT be downgraded to
// "paused", or the UI shows a completed task as paused.
const currentState = this.getTaskState(targetTaskId)
if (currentState && isTerminalLifecycle(currentState.lifecycle)) {
return
}
this.setState(targetTaskId, TaskManager.makeState("paused"))
return
}
}
}
const onTaskError = (taskId: string, errorType: string) => {
if (taskId !== targetTaskId) return
this.setState(targetTaskId, TaskManager.makeState("error"))
this._emitTaskErroredMetric(targetTaskId, errorType)
}
task.on(ShoferEventName.TaskStarted, onStarted)
task.on(ShoferEventName.TaskInteractive, onInteractive)
task.on(ShoferEventName.TaskActive, onActive)
task.on(ShoferEventName.TaskIdle, onIdle)
task.on(ShoferEventName.TaskCompleted, onComplete)
task.on(ShoferEventName.TaskToolFailed, onToolError)
task.on(ShoferEventName.TaskAborted, onAborted)
task.on(ShoferEventName.TaskError, onTaskError)
const cleanup = () => {
task.off(ShoferEventName.TaskStarted, onStarted)
task.off(ShoferEventName.TaskInteractive, onInteractive)
task.off(ShoferEventName.TaskActive, onActive)
task.off(ShoferEventName.TaskIdle, onIdle)
task.off(ShoferEventName.TaskCompleted, onComplete)
task.off(ShoferEventName.TaskToolFailed, onToolError)
task.off(ShoferEventName.TaskAborted, onAborted)
task.off(ShoferEventName.TaskError, onTaskError)
}
const cleanupSymbol = Symbol.for("taskManager.cleanup")
;(task as any)[cleanupSymbol] = cleanup
}
// ────────────────────────────── HistoryItem Integration ──────────────────────────────
managedTaskToHistoryItem(
managedTask: ManagedTask,
task: string,
tokensIn: number,
tokensOut: number,
totalCost: number,
): HistoryItem {
return {
id: managedTask.taskId,
number: 0,
ts: managedTask.createdAt,
task,
tokensIn,
tokensOut,
totalCost,
workspace: managedTask.workspace,
name: managedTask.name,
lastActiveTs: managedTask.lastActiveAt,
taskState: managedTask.state,
}
}
// ────────────────────────────── Cleanup ──────────────────────────────
async dispose(): Promise<void> {
for (const [_targetTaskId, task] of this.activeTasks) {
this.cleanupTaskEventListeners(task)
await task.abortTask(true).catch(() => {})
}
this.activeTasks.clear()
this.managedTasks.clear()
this.notifications = []
this.focusedTaskId = null
this.removeAllListeners()
}
// ────────────────────────────── Restore ordering ──────────────────────────────
/**
* Seed `managedTasks` from persisted history. MUST be called once before
* any task is registered so the in-memory map can supply correct state for
* rehydrated tasks. Calling more than once is a no-op after the first call.
*
* Already-registered tasks are never overwritten — a task that was
* registerBackgroundTask'd before restoreManagedTasks settles (e.g. from
* a hot constructor path) keeps its live state and is not
* clobbered by the on-disk snapshot.
*/
async restoreManagedTasks(historyItems: HistoryItem[]): Promise<void> {
if (this.seeded) return
this.seeded = true
this.restored = true
for (const item of historyItems) {
if (!item.id) continue
// Guard: never overwrite a Task that was already registered via
// registerBackgroundTask() (which may carry a more-recent state).
if (this.managedTasks.has(item.id)) continue
const restoredState = TaskManager.sanitizeRestoredState(item.taskState)
const managedTask: ManagedTask = {
id: item.id,
name:
item.name ||
(item.task
? item.task.slice(0, 50).trim() + (item.task.length > 50 ? "..." : "")
: `Task ${item.number}`),
taskId: item.id,
rootTaskId: item.rootTaskId,
workspace: item.workspace || "",
createdAt: item.ts,
lastActiveAt: item.lastActiveTs || item.ts,
state: restoredState,
activeTimeMs: item.activeTimeMs ?? 0,
_runningSince: 0,
}
this.managedTasks.set(managedTask.id, managedTask)
}
this.emit("tasks:updated", this.getManagedTasks())
}
/**
* Mark the manager as restored without loading any history items.
*
* This is the early-bird counterpart to `restoreManagedTasks`: called
* before the async `initializeTaskHistoryStore` settles so that early
* `registerBackgroundTask` calls (e.g. from a task spawning an
* agent child in the hot constructor path) don't throw.
*
* Uses the `restored` flag (gated by `assertRestored()`) only — does
* NOT touch `seeded`, so the later `restoreManagedTasks()` call will
* still seed `managedTasks` from persisted history.
*/
public ensureRestored(): void {
this.restored = true
}
private assertRestored(method: string): void {
if (!this.restored) {
throw new Error(
`[TaskManager] ${method}() called before restoreManagedTasks(). ` +
`The provider must call restoreManagedTasks() during startup.`,
)
}
}
/**
* Resolve an in-memory state from a persisted snapshot.
*
* Transient lifecycles (`running`, `waiting_input`, `waiting`) imply
* in-flight work, which cannot survive a restart — downgrade them to
* `idle`. Terminal lifecycles (`completed` with rating, `error`, `paused`)
* are preserved.
*/
private static sanitizeRestoredState(state: TaskState | undefined): TaskState {
if (!state) return IDLE_TASK_STATE
if (state.lifecycle === "running" || state.lifecycle === "waiting_input" || state.lifecycle === "waiting") {
taskLog.debug(`[TaskManager] sanitizeRestoredState: downgrading ${state.lifecycle} → idle`)
return IDLE_TASK_STATE
}
if (isTerminalLifecycle(state.lifecycle) || state.lifecycle === "idle") {
taskLog.debug(
`[TaskManager] sanitizeRestoredState: preserving ${state.lifecycle}${state.rating ? ":" + state.rating : ""}`,
)
return state
}
return IDLE_TASK_STATE
}
// ──────────────────────── Metric helpers ────────────────────────
private _getTaskMode(taskId: string): string {
const task = this.activeTasks.get(taskId)
if (!task) return "unknown"
try {
return task.taskMode
} catch {
return "unknown"
}
}
private _emitTaskCreatedMetric(task: Task): void {
try {
incTaskCreated(task.taskMode)
} catch {
incTaskCreated("unknown")
}
}
private _emitTaskCompletedMetric(taskId: string, rating: string): void {
incTaskCompleted(this._getTaskMode(taskId), rating)
}
private _emitTaskErroredMetric(taskId: string, errorType: string): void {
incTaskErrored(this._getTaskMode(taskId), errorType)
}
}