Note

任务生命周期:前台执行、超时转后台与完成通知

How PseudoClaude runs child agents in the foreground or as detached background tasks with status snapshots, cancellation, continuation, and TUI notifications.

委派决定了子 Agent 拿到什么,Task 层决定它活多久、谁等待结果,以及完成后如何重新进入主对话。前台执行适合短任务,直接把最终文本作为 Agent Tool Result 返回;后台执行必须脱离当前 ToolCall 的等待,又要保留可查询、可取消和可继续的状态。

PseudoClaude 没有为两者实现两套 Runner。前台直接消费 RunToCompletion,后台则把同一个 Runner、Conversation 和 Prompt 交给 task.Manager 的 goroutine,并通过 Snapshot 和 DoneEvent 暴露状态。

RunToCompletion 把事件流收敛成结果

Runner 原本输出 Agent Event channel。前台与后台都通过同一适配器收集最终文本、Token、Tool 数和停止原因:

func (r Runner) RunToCompletion(
    ctx context.Context,
    in RunToCompletionInput,
) CompletionResult {
    req := in.Request
    if in.TaskText != "" {
        req.UserText = in.TaskText
    }
    events := r.Run(ctx, req)
    var result CompletionResult
    for event := range events {
        if in.Events != nil {
            in.Events <- event
        }
        switch event.Type {
        case EventAssistantText:
            result.Text = event.Text
        case EventUsage:
            if event.Usage != nil {
                result.Usage.InputTokens += event.Usage.InputTokens
                result.Usage.OutputTokens += event.Usage.OutputTokens
                result.Usage.TotalTokens += event.Usage.TotalTokens
                result.Usage.CacheWrite += event.Usage.CacheWrite
                result.Usage.CacheRead += event.Usage.CacheRead
            }
        case EventToolCallDone:
            if event.ToolCall != nil {
                result.ToolCount++
                result.LastTool = event.ToolCall.Name
            }
        case EventStop:
            if event.Stop != nil {
                result.Stop = *event.Stop
            }
        }
    }
    return result
}

它不另造执行循环,只把 ReAct Event 聚合为调用方需要的状态。若 Provider 多次发 EventAssistantText,结果保存最后一段完整 Assistant Text;Usage 则逐轮累加。

StopOnMaxTurn 字段当前没有在函数体使用。最大轮数仍由 Runner Config 或 Sub.MaxTurns 控制,不能把这个输入字段描述成已经生效的停止开关。

前台直接等待,超时后重新提交后台任务

Defined 子 Agent 默认走 runForeground:

func runForeground(
    ctx context.Context,
    runner Runner,
    conv *conversation.Conversation,
    prompt string,
    timeout time.Duration,
) CompletionResult {
    if timeout > 0 {
        runCtx, cancel := context.WithTimeout(ctx, timeout)
        defer cancel()
        return runner.RunToCompletion(runCtx, RunToCompletionInput{
            Request:  Request{Conversation: conv},
            TaskText: prompt,
        })
    }
    return runner.RunToCompletion(ctx, RunToCompletionInput{
        Request:  Request{Conversation: conv},
        TaskText: prompt,
    })
}

没有 ForegroundTimeout 时,它与父 ToolCall 共用 Context。设置超时后,子 Run 收到 Deadline;若停止原因是 Canceled,Agent Tool 将同一个 Runner 和 Conversation 交给后台:

result := runForeground(
    ctx, runner, childConv, args.Prompt,
    t.Background.ForegroundTimeout,
)
if result.Stop.Reason == StopCanceled &&
    t.Background.ForegroundTimeout > 0 {
    return t.launch(
        ctx, args, def, runner, childConv, false,
        "timed_out_to_background",
    )
}
return completionToolResult(def.Name, result)

这样短任务同步返回,长任务不会无限占住当前 Agent Tool。当前判断只看 StopCanceled,无法区分 ForegroundTimeout、用户取消或上游 Context 取消;只要 timeout 配置大于 0,这些取消都可能触发新的后台任务。

此外,前台 Run 已经把 Prompt 和部分事件写入 childConv,后台 defaultRun 又会用同一个 Prompt 调用同一 Conversation。超时发生在中途时,任务说明可能再次作为 User 消息出现。Worktree 路径还会清理第一次运行的 worktree,再为后台 Prepare 创建新的 worktree。这些是超时续跑当前采用“重启任务”而不是“迁移 goroutine”的代价。

Launch 创建脱离父请求的 Task Context

后台入口先保存完整执行对象,再启动 goroutine:

func (m *Manager) Launch(
    ctx context.Context,
    in LaunchInput,
) (string, error) {
    if in.Conversation == nil {
        in.Conversation = &conversation.Conversation{}
    }
    taskCtx, cancel := context.WithCancel(context.Background())
    id := in.ID
    if id == "" {
        id = m.idSource()
    }
    task := &BackgroundTask{
        ID:           id,
        Name:         in.Name,
        Type:         in.Type,
        Fork:         in.Fork,
        Status:       StatusRunning,
        Prompt:       in.Prompt,
        StartedAt:    time.Now(),
        Cancel:       cancel,
        Runner:       in.Runner,
        Conversation: in.Conversation,
        OnFinish:     in.OnFinish,
        LastActivity: "started",
    }
    // 名称去重并写入 tasks/byName
    go m.runTask(taskCtx, id, in.Prompt, in.Prepare, in.OnFinish)
    return id, nil
}

这里故意不用传入的 ctx 创建 child Context,而是从 context.Background() 开始。父 Agent 本轮结束或原 ToolCall 返回后,后台任务继续存在;只有 TaskStop 保存的 CancelFunc 能主动取消它。

这也意味着进程退出时没有持久化或优雅等待。Task map、Runner 和 Conversation 都只在内存中,重启 PseudoClaude 后普通后台 Task 不会恢复。Options.AutoTimeout 被存入 Manager,但当前没有读取点,所以后台任务也没有自动超时。

Prepare 和 Cleanup 把隔离资源包进生命周期

后台 worktree 不能在 Launch 返回 task ID 前同步创建,否则创建慢时仍会阻塞主 Agent。Task goroutine 因此支持 Prepare/Cleanup:

if prepare != nil {
    nextRunner, nextPrompt, nextCleanup, err :=
        prepare(ctx, runner, prompt)
    if err != nil {
        snap := m.finish(
            id, StatusFailed, "", err.Error(),
            agent.CompletionResult{
                Stop: agent.Stop{
                    Reason:  agent.StopStreamError,
                    Message: err.Error(),
                },
            },
        )
        m.callOnFinish(onFinish, FinishEvent{
            TaskID: id, Snapshot: snap,
        })
        return
    }
    runner = nextRunner
    prompt = nextPrompt
    cleanup = nextCleanup
}
result := m.run(ctx, runner, conv, prompt)
if cleanup != nil {
    result.Text = cleanup(context.Background(), result.Text)
}

Prepare 可以异步创建 worktree、修改 Runner CWD 并在 Prompt 前加入隔离说明;Cleanup 在独立 Background Context 中执行保守清理,并能把保留目录附到最终结果。Prepare 失败直接记为 Failed,真实 Runner 不会启动。

Manager 为每个 Launch 启动独立 goroutine,多个 Prepare 可以同时执行。资源并发安全必须由具体服务负责,例如 Worktree Manager 用 creating map 防止同名并发创建。

状态从 Agent Stop 映射到 Task Snapshot

后台状态只有五种:

const (
    StatusRunning   Status = "running"
    StatusCompleted Status = "completed"
    StatusFailed    Status = "failed"
    StatusCancelled Status = "cancelled"
    StatusMaxTurns  Status = "max_turns"
)

func statusFromStop(reason agent.StopReason) Status {
    switch reason {
    case agent.StopCompleted:
        return StatusCompleted
    case agent.StopMaxIterations:
        return StatusMaxTurns
    case agent.StopCanceled:
        return StatusCancelled
    default:
        return StatusFailed
    }
}

finish 在锁内写入 Result、Error、EndedAt、Usage、ToolCount 和 LastActivity,再生成值拷贝 Snapshot。goroutine panic 由 defer recover 转成 Failed,不会让整个 TUI 崩溃。

Stop 会先把状态改成 Cancelled、调用 CancelFunc 并立即 publishDone;运行 goroutine稍后退出时还会再次 finish 和 publishDone。TUI 可能收到同一任务的多次 DoneEvent,但每次都重新读取最终 Snapshot,而不是信任 Event 携带状态。

完成通知采用非阻塞广播

Manager 的公共 Done channel 和每个 Subscriber 都有 buffer,发布时不等待消费者:

func (m *Manager) publishDone(id string) {
    m.mu.RLock()
    subscribers := append([]chan DoneEvent(nil), m.subscribers...)
    m.mu.RUnlock()
    event := DoneEvent{TaskID: id}
    select {
    case m.done <- event:
    default:
    }
    for _, ch := range subscribers {
        select {
        case ch <- event:
        default:
        }
    }
}

这保证后台完成不会因为 TUI 忙而卡住,代价是 buffer 满时事件会静默丢弃。Subscriber 没有取消订阅或关闭机制;TUI 每处理一次 Done 后会再次调用 SubscribeDone(),从源码看会不断向 Manager 追加 channel,而不是复用最初订阅。这会让长期会话累积无人读取的 subscriber,并把后续事件写入它们的 buffer。

TUI 收到 task ID 后调用 Get,生成最多 2,000 字符的 <task-notification>,先放进 pending reminders;下一次模型请求再消费。通知不是自动把子 Agent 的整个 Conversation 合并回父 Conversation,只提供状态和截断结果。

命名任务可以在完成后继续

Task Manager 为非空 Name 保存 byName 映射。SendMessage 只允许继续 Completed 或 MaxTurns 等非运行、非失败、非取消状态:

func (m *Manager) SendMessage(
    ctx context.Context,
    name, message string,
) (string, error) {
    m.mu.RLock()
    id, ok := m.byName[name]
    task := m.tasks[id]
    if !ok || task == nil {
        m.mu.RUnlock()
        return "", fmt.Errorf("task name %q not found", name)
    }
    if task.Status == StatusRunning {
        m.mu.RUnlock()
        return "", fmt.Errorf("task name %q is still running", name)
    }
    if task.Status == StatusCancelled || task.Status == StatusFailed {
        m.mu.RUnlock()
        return "", fmt.Errorf(
            "task name %q cannot be continued from status %s",
            name, task.Status,
        )
    }
    in := LaunchInput{
        Name:         name,
        Type:         task.Type,
        Fork:         task.Fork,
        Prompt:       message,
        Runner:       task.Runner,
        Conversation: task.Conversation,
        OnFinish:     task.OnFinish,
    }
    m.mu.RUnlock()
    return m.Launch(ctx, in)
}

继续运行复用原 Runner 和 Conversation,但创建新 task ID,并让 Name 指向新任务。它不是向仍在运行的 goroutine 注入消息;正在运行时会返回错误。Team Mailbox 对空闲成员的唤醒也建立在这个机制上。

完整流程

Defined 子 Agent
  -> 默认前台 RunToCompletion
  -> 完成:文本直接作为 Agent Tool Result
  -> ForegroundTimeout/Canceled:重新 Launch 后台任务

Fork 或显式 background
  -> Task Manager 保存 Runner + Conversation + CancelFunc
  -> 从 context.Background 创建独立生命周期
  -> goroutine Prepare -> RunToCompletion -> Cleanup
  -> StopReason 映射 Task Status
  -> 保存 Snapshot
  -> 非阻塞 publish DoneEvent
  -> TUI Get Snapshot,生成 pending task reminder
  -> 命名且已结束:SendMessage 复用 Conversation 创建新 Task

测试验证了什么

Completion 测试覆盖最终文本、Usage、Tool 数和 MaxTurns;AgentTool 测试覆盖显式后台与前台超时转后台;Task Manager 测试覆盖父 Context 取消不影响后台任务、并发 Prepare、Cleanup、panic、Stop、多 Subscriber、预分配 ID 和继续命名任务;TUI 测试确认 DoneEvent 被格式化为下一轮 Reminder。

现有测试没有覆盖 subscriber 长期累积、buffer 满后的事件丢失、Stop 双通知、AutoTimeout 生效,或超时转后台时 Conversation 中的重复 Prompt。当前后台任务也没有跨进程恢复能力。

小结

PseudoClaude 用同一个 Runner 支持同步与异步执行:前台把 Event 聚合成 Tool Result,后台把执行对象放进独立 Task Context,再以 Snapshot 和 DoneEvent 回到主 TUI。命名任务还可以复用原 Conversation 继续一轮。

下一篇进入比普通后台 Task 更持久的协作模型:Team Lead 如何创建成员、保存身份与任务,并让每个成员在独立工作区完成后向 Lead 回报。