1622 lines
63 KiB
Go
1622 lines
63 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
runtimeapi "github.com/kandev/kandev/internal/agent/runtime"
|
|
"github.com/kandev/kandev/internal/agent/runtime/routingerr"
|
|
"github.com/kandev/kandev/internal/agentctl/types/streams"
|
|
commoncosts "github.com/kandev/kandev/internal/common/costs"
|
|
"github.com/kandev/kandev/internal/events"
|
|
"github.com/kandev/kandev/internal/events/bus"
|
|
"github.com/kandev/kandev/internal/office/costs"
|
|
"github.com/kandev/kandev/internal/office/models"
|
|
"github.com/kandev/kandev/internal/office/repository/sqlite"
|
|
"github.com/kandev/kandev/internal/office/shared"
|
|
"github.com/kandev/kandev/internal/runs/commentkeys"
|
|
"github.com/kandev/kandev/internal/runs/dedupkeys"
|
|
runsservice "github.com/kandev/kandev/internal/runs/service"
|
|
"github.com/kandev/kandev/internal/workflow/engine"
|
|
)
|
|
|
|
// dispatchEngineTrigger invokes the configured WorkflowEngineDispatcher.
|
|
// After Phase 4 the engine is the only routing path for the four
|
|
// task-scoped triggers; there is no legacy fallback. When no dispatcher
|
|
// is wired (e.g. tests that don't need engine-driven runs) the trigger
|
|
// is silently dropped — the test fixture is responsible for queuing
|
|
// runs directly.
|
|
//
|
|
// ErrEngineNoSession is logged at debug and treated as a non-error: the
|
|
// task hasn't started a session yet, so there's nothing for the engine
|
|
// to evaluate. The bootstrap path (handleTaskUpdated /
|
|
// onMovedToInProgress) is what kicks off the very first session.
|
|
func (s *Service) dispatchEngineTrigger(
|
|
ctx context.Context, taskID string, trigger engine.Trigger, payload any, opID string,
|
|
) error {
|
|
if s.engineDispatcher == nil {
|
|
return nil
|
|
}
|
|
if err := s.engineDispatcher.HandleTrigger(ctx, taskID, trigger, payload, opID); err != nil {
|
|
if errors.Is(err, shared.ErrEngineNoSession) {
|
|
s.logger.Debug("engine trigger skipped: no active session",
|
|
zap.String("task_id", taskID),
|
|
zap.String("trigger", string(trigger)))
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// dispatchEngineTriggerForRecovery dispatches a level-triggered wake through
|
|
// the workflow engine and reports whether the engine accepted the trigger.
|
|
// A missing session is a normal no-op: recording a receipt in that case would
|
|
// suppress a later delivery after the session is created.
|
|
//
|
|
// The production dispatcher exposes HandleTriggerHandled so this path can
|
|
// distinguish a missing session from a valid no-action step. Small test
|
|
// dispatchers only implement WorkflowEngineDispatcher, so they use the
|
|
// existing error-only contract and count a successful call as accepted.
|
|
func (s *Service) dispatchEngineTriggerForRecovery(
|
|
ctx context.Context, taskID string, trigger engine.Trigger, payload any, opID string,
|
|
) (bool, error) {
|
|
if s.engineDispatcher == nil {
|
|
return false, nil
|
|
}
|
|
|
|
type handledDispatcher interface {
|
|
HandleTriggerHandled(
|
|
context.Context, string, engine.Trigger, any, string,
|
|
) (bool, error)
|
|
}
|
|
if d, ok := s.engineDispatcher.(handledDispatcher); ok {
|
|
_, err := d.HandleTriggerHandled(ctx, taskID, trigger, payload, opID)
|
|
if errors.Is(err, shared.ErrEngineNoSession) {
|
|
s.logger.Debug("engine recovery trigger skipped: no active session",
|
|
zap.String("task_id", taskID),
|
|
zap.String("trigger", string(trigger)))
|
|
return false, nil
|
|
}
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
if err := s.dispatchEngineTrigger(ctx, taskID, trigger, payload, opID); err != nil {
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// TaskMovedData represents the payload of a task.moved event.
|
|
type TaskMovedData struct {
|
|
TaskID string `json:"task_id"`
|
|
WorkspaceID string `json:"workspace_id"`
|
|
FromStepID string `json:"from_step_id"`
|
|
ToStepID string `json:"to_step_id"`
|
|
ToStepName string `json:"to_step_name"`
|
|
FromStepName string `json:"from_step_name"`
|
|
AssigneeAgentProfileID string `json:"assignee_agent_profile_id"`
|
|
ParentID string `json:"parent_id"`
|
|
SessionID string `json:"session_id"`
|
|
}
|
|
|
|
// TaskUpdatedData represents the payload of a task.created / task.updated
|
|
// event. AssignmentGeneration is carried on the extra map by
|
|
// publishTaskEventWithExtra's caller at task-creation time (never re-read);
|
|
// nil means the publishing event predates this field or is not a creation.
|
|
type TaskUpdatedData struct {
|
|
TaskID string `json:"task_id"`
|
|
WorkspaceID string `json:"workspace_id"`
|
|
AssigneeAgentProfileID string `json:"assignee_agent_profile_id"`
|
|
Title string `json:"title"`
|
|
AssignmentGeneration *int64 `json:"assignment_generation"`
|
|
}
|
|
|
|
// CommentPostedData represents a comment event payload.
|
|
type CommentPostedData struct {
|
|
TaskID string `json:"task_id"`
|
|
CommentID string `json:"comment_id"`
|
|
AuthorID string `json:"author_id"`
|
|
AuthorType string `json:"author_type"`
|
|
AssigneeAgentProfileID string `json:"assignee_agent_profile_id"`
|
|
EngineDispatched string `json:"engine_dispatched"`
|
|
Source string `json:"source"`
|
|
}
|
|
|
|
// ApprovalResolvedData represents an approval resolved event payload.
|
|
type ApprovalResolvedData struct {
|
|
ApprovalID string `json:"approval_id"`
|
|
Status string `json:"status"`
|
|
DecisionNote string `json:"decision_note"`
|
|
RequestedByAgentProfileID string `json:"requested_by_agent_profile_id"`
|
|
Type string `json:"type"`
|
|
}
|
|
|
|
// AgentLifecycleData is the subset of agent lifecycle event data needed by office.
|
|
//
|
|
// AgentID is the underlying agent type (for example, "claude-acp"). It is
|
|
// not an Office agent identity and must not be used to look up runs or
|
|
// continuation summaries.
|
|
//
|
|
// AgentProfileID is the office agent instance's own identity
|
|
// (lifecycle.AgentEventPayload's "agent_profile_id", sourced from
|
|
// AgentExecution.officeProfileID()). runs.agent_profile_id is keyed on this
|
|
// same identity, so taskless fallback resolution and summary writes use this
|
|
// field, not AgentID.
|
|
type AgentLifecycleData struct {
|
|
AgentExecutionID string `json:"agent_execution_id"`
|
|
TaskID string `json:"task_id"`
|
|
RunID string `json:"run_id"`
|
|
RunSessionID string `json:"run_session_id"`
|
|
RunAttempt int `json:"run_attempt"`
|
|
WorkspaceID string `json:"workspace_id"`
|
|
OwnerKind string `json:"owner_kind"`
|
|
AgentID string `json:"agent_id"`
|
|
AgentProfileID string `json:"agent_profile_id"`
|
|
SessionID string `json:"session_id"`
|
|
TurnID string `json:"turn_id,omitempty"`
|
|
ErrorMessage string `json:"error_message"`
|
|
ProviderError *streams.ProviderError `json:"provider_error,omitempty"`
|
|
PromptGeneration uint64 `json:"prompt_generation,omitempty"`
|
|
EvidenceKnown bool `json:"evidence_known,omitempty"`
|
|
OutputObserved bool `json:"output_observed,omitempty"`
|
|
EffectObserved bool `json:"effect_observed,omitempty"`
|
|
ProviderDiagnosticCandidate bool `json:"provider_diagnostic_candidate,omitempty"`
|
|
ProviderDiagnosticText string `json:"provider_diagnostic_text,omitempty"`
|
|
}
|
|
|
|
// resolveLifecycleRun resolves the exact claimed run named by a lifecycle
|
|
// event. The task-and-agent fallback keeps compatibility with older events
|
|
// that predate run_id, while every current Office launch carries the exact
|
|
// identity and avoids predecessor/successor races.
|
|
func (s *Service) resolveLifecycleRun(ctx context.Context, data AgentLifecycleData) (*models.Run, error) {
|
|
if data.RunID != "" {
|
|
run, err := s.repo.GetClaimedRunByID(ctx, data.RunID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if data.AgentProfileID != "" && run.AgentProfileID != data.AgentProfileID {
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
if data.TaskID != "" && taskIDFromRunPayload(run.Payload) != data.TaskID {
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
if data.SessionID != "" && run.SessionID != "" && data.SessionID != run.SessionID {
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
if data.RunSessionID != "" {
|
|
session, sessionErr := s.repo.GetRunSession(ctx, data.RunSessionID)
|
|
if sessionErr != nil {
|
|
return nil, sessionErr
|
|
}
|
|
if session == nil || session.RunID != run.ID || session.Attempt != data.RunAttempt ||
|
|
session.AgentProfileID != run.AgentProfileID ||
|
|
(session.State != models.RunSessionStatePreparing && session.State != models.RunSessionStateRunning &&
|
|
session.State != models.RunSessionStateFinished) {
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
if data.AgentExecutionID != "" && session.ExecutionID != "" && session.ExecutionID != data.AgentExecutionID {
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
}
|
|
return run, nil
|
|
}
|
|
if data.TaskID != "" {
|
|
return s.repo.GetClaimedRunByTaskAndAgent(ctx, data.TaskID, data.AgentProfileID)
|
|
}
|
|
agentProfileID := data.AgentProfileID
|
|
if agentProfileID == "" {
|
|
// Legacy taskless events used AgentID for the Office identity.
|
|
agentProfileID = data.AgentID
|
|
}
|
|
return s.repo.GetClaimedTasklessRunForAgent(ctx, agentProfileID)
|
|
}
|
|
|
|
// exactRunSessionEvent reports whether the lifecycle event carries the
|
|
// immutable run-attempt identity. A missing claimed run for such an event is
|
|
// a stale predecessor signal and must not clear a successor's working state.
|
|
func exactRunSessionEvent(data *AgentLifecycleData) bool {
|
|
return data != nil && data.RunID != "" && data.RunSessionID != ""
|
|
}
|
|
|
|
type PromptUsageData struct {
|
|
AgentExecutionID string `json:"agent_execution_id,omitempty"`
|
|
TaskID string `json:"task_id"`
|
|
SessionID string `json:"session_id"`
|
|
RunSessionID string `json:"run_session_id,omitempty"`
|
|
RunAttempt int `json:"run_attempt,omitempty"`
|
|
WorkspaceID string `json:"workspace_id,omitempty"`
|
|
AgentID string `json:"agent_id"`
|
|
AgentProfileID string `json:"agent_profile_id,omitempty"`
|
|
AgentType string `json:"agent_type"`
|
|
Model string `json:"model"`
|
|
Provider string `json:"provider"`
|
|
Usage UsageTokens `json:"usage"`
|
|
TurnID string `json:"turn_id,omitempty"`
|
|
UsageEventID string `json:"usage_event_id,omitempty"`
|
|
}
|
|
|
|
// UsageTokens mirrors streams.PromptUsage on the wire. All counts are int64
|
|
// to match the stream type and to handle workspaces that accumulate over a
|
|
// million tokens. ProviderReportedCostSubcents is forwarded from claude-acp's
|
|
// usage_update.cost.amount (USD float * 10000); when
|
|
// ProviderReportedCostPresent is true (including an explicit zero), the
|
|
// subscriber uses it directly and skips the models.dev lookup. The legacy
|
|
// positive-value check remains in the resolver for older events. Estimated is
|
|
// true when the token counts are not authoritative for a complete turn: the
|
|
// adapter synthesised them (codex-acp cumulative-delta inference), or the
|
|
// provider returned a frame that covers only part of the turn.
|
|
// OutputTokensPresent distinguishes an observed zero from a missing
|
|
// output-token sample. The pointer is nil only for legacy events that
|
|
// predate this field, so the subscriber can apply the old inference rule to
|
|
// those events without overriding an explicit false from a new event.
|
|
type UsageTokens struct {
|
|
InputTokens int64 `json:"input_tokens"`
|
|
OutputTokens int64 `json:"output_tokens"`
|
|
OutputTokensPresent *bool `json:"output_tokens_present,omitempty"`
|
|
CachedReadTokens int64 `json:"cached_read_tokens"`
|
|
CachedWriteTokens int64 `json:"cached_write_tokens"`
|
|
ThoughtTokens int64 `json:"thought_tokens,omitempty"`
|
|
TotalTokens int64 `json:"total_tokens,omitempty"`
|
|
ProviderReportedCostSubcents int64 `json:"provider_reported_cost_subcents,omitempty"`
|
|
ProviderReportedCostPresent bool `json:"provider_reported_cost_present,omitempty"`
|
|
Estimated bool `json:"estimated,omitempty"`
|
|
}
|
|
|
|
// RegisterEventSubscribers subscribes to system events and queues runs.
|
|
// High-frequency global events (AgentCompleted, AgentFailed, PromptUsage)
|
|
// run in goroutines to avoid blocking the synchronous event bus publisher.
|
|
// Call SetSyncHandlers(true) before this method in tests that need
|
|
// deterministic handler completion.
|
|
func (s *Service) RegisterEventSubscribers(eb bus.EventBus) error {
|
|
s.eb = eb
|
|
|
|
// maybeAsync wraps the handler in a goroutine unless syncHandlers is set.
|
|
maybeAsync := func(h bus.EventHandler) bus.EventHandler {
|
|
if s.syncHandlers {
|
|
return h
|
|
}
|
|
return func(_ context.Context, event *bus.Event) error {
|
|
go func() { _ = h(context.Background(), event) }()
|
|
return nil
|
|
}
|
|
}
|
|
|
|
subs := []struct {
|
|
subject string
|
|
handler bus.EventHandler
|
|
}{
|
|
{events.TaskCreated, s.handleTaskCreated},
|
|
{events.TaskUpdated, s.handleTaskUpdated},
|
|
{events.TaskMoved, s.handleTaskMoved},
|
|
{events.OfficeApprovalResolved, s.handleApprovalResolved},
|
|
{events.OfficeCommentCreated, s.handleCommentCreated},
|
|
{events.AgentCompleted, maybeAsync(s.handleAgentCompleted)},
|
|
{events.AgentReady, maybeAsync(s.handleTasklessAgentReady)},
|
|
// AgentStopped fires when StopAgent is called (e.g. office
|
|
// fire-and-forget turn-complete teardown). Same handler — both
|
|
// signal "the agent is no longer running on this task and the
|
|
// claimed run should be finished so the next run for the
|
|
// same agent can be claimed". Without this, comments / status
|
|
// changes / mentions queue runs that never fire because the
|
|
// previous run stays in 'claimed' state forever.
|
|
{events.AgentStopped, maybeAsync(s.handleAgentCompleted)},
|
|
{events.AgentFailed, maybeAsync(s.handleAgentFailed)},
|
|
{events.BuildSessionPromptUsageWildcardSubject(), maybeAsync(s.handlePromptUsage)},
|
|
{events.BuildAgentStreamWildcardSubject(), maybeAsync(s.handleAgentStreamUsage)},
|
|
{events.AgentTurnMessageSaved, maybeAsync(s.handleAgentTurnMessageSaved)},
|
|
}
|
|
for _, sub := range subs {
|
|
if _, err := eb.Subscribe(sub.subject, sub.handler); err != nil {
|
|
return fmt.Errorf("subscribe %s: %w", sub.subject, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AgentTurnMessageData is the payload of an agent.turn.message_saved event.
|
|
type AgentTurnMessageData struct {
|
|
TaskID string `json:"task_id"`
|
|
SessionID string `json:"session_id"`
|
|
TurnID string `json:"turn_id"`
|
|
AgentText string `json:"agent_text"`
|
|
AgentID string `json:"agent_id"`
|
|
AgentProfileID string `json:"agent_profile_id"`
|
|
}
|
|
|
|
// handleAgentTurnMessageSaved auto-bridges an agent session response to a
|
|
// task comment so it appears in the office chat thread.
|
|
// Only runs for office tasks (tasks that have an assignee agent instance).
|
|
// Deduplicates: if a comment with source="session" already exists for this
|
|
// task, no new comment is created.
|
|
func (s *Service) handleAgentTurnMessageSaved(ctx context.Context, event *bus.Event) error {
|
|
data, err := decodeEventData[AgentTurnMessageData](event)
|
|
if err != nil || data.TaskID == "" {
|
|
return nil
|
|
}
|
|
|
|
// Only bridge for office tasks (those with an assignee agent instance).
|
|
fields, err := s.repo.GetTaskExecutionFields(ctx, data.TaskID)
|
|
if err != nil || fields.AssigneeAgentProfileID == "" {
|
|
return nil
|
|
}
|
|
|
|
// For streaming agents, Data.Text is empty because chunks were already
|
|
// drained. Fall back to the last agent message saved in session messages.
|
|
agentText := data.AgentText
|
|
if agentText == "" && data.TurnID != "" && s.taskWorkspace != nil {
|
|
if lastMsg, msgErr := s.taskWorkspace.GetLastAgentMessageForTurn(ctx, data.TurnID); msgErr == nil && lastMsg != "" {
|
|
agentText = lastMsg
|
|
}
|
|
}
|
|
if agentText == "" && data.TurnID == "" && data.SessionID != "" && s.taskWorkspace != nil {
|
|
if lastMsg, msgErr := s.taskWorkspace.GetLastAgentMessage(ctx, data.SessionID); msgErr == nil && lastMsg != "" {
|
|
agentText = lastMsg
|
|
}
|
|
}
|
|
if agentText == "" {
|
|
return nil
|
|
}
|
|
|
|
// Per-turn dedup: skip only if a session-source comment with this
|
|
// exact body already exists for the task. Office sessions are
|
|
// reused across turns (same DB session_id, same ACP session id), so
|
|
// a per-task dedup would suppress every turn after the first. Body
|
|
// equality is a stable per-turn signal because each turn yields a
|
|
// fresh agent response.
|
|
exists, err := s.repo.HasCommentWithSourceAndBody(ctx, data.TaskID, "session", agentText)
|
|
if err != nil {
|
|
s.logger.Warn("hasCommentWithSourceAndBody check failed",
|
|
zap.String("task_id", data.TaskID), zap.Error(err))
|
|
}
|
|
if exists {
|
|
return nil
|
|
}
|
|
|
|
// Attribute to the agent that actually ran the turn, not the task's
|
|
// assignee — the two diverge for a reviewer/approver turn. The event
|
|
// carries the acting agent's own office identity directly
|
|
// (execution.officeProfileID(), captured at launch before step/routing
|
|
// overrides mutate the profile). The session-row lookup only reflects
|
|
// the acting agent when features.officeSessionIdentity is on — off by
|
|
// default in every shipped profile, it stores the assignee for every
|
|
// participant's session — so it is kept only as a fallback for events
|
|
// published before this field existed. Final fallback is the assignee,
|
|
// mirroring handlePromptUsage's log-and-continue fallback below.
|
|
authorID := fields.AssigneeAgentProfileID
|
|
if data.AgentProfileID != "" {
|
|
authorID = data.AgentProfileID
|
|
} else if id, lookupErr := s.repo.GetSessionAgentProfileID(ctx, data.TaskID, data.SessionID); lookupErr != nil {
|
|
s.logger.Warn("session agent profile lookup failed",
|
|
zap.String("task_id", data.TaskID),
|
|
zap.String("session_id", data.SessionID),
|
|
zap.Error(lookupErr))
|
|
} else if id != "" {
|
|
authorID = id
|
|
}
|
|
|
|
comment := &models.TaskComment{
|
|
TaskID: data.TaskID,
|
|
AuthorType: "agent",
|
|
AuthorID: authorID,
|
|
Body: agentText,
|
|
Source: "session",
|
|
}
|
|
if cErr := s.repo.CreateTaskComment(ctx, comment); cErr != nil {
|
|
s.logger.Error("failed to create agent session comment",
|
|
zap.String("task_id", data.TaskID), zap.Error(cErr))
|
|
return cErr
|
|
}
|
|
s.publishCommentCreated(ctx, comment)
|
|
// Successful turn → reset the acting agent's consecutive-failure
|
|
// counter. A bridged comment is the only place we know a turn
|
|
// produced real output.
|
|
s.RecordAgentSuccess(ctx, authorID)
|
|
// Lifecycle: each successful turn under an in-flight run lands a
|
|
// "step" event so the run detail page's Events log gets a row per
|
|
// turn. Resolve the run from the currently-claimed run for this
|
|
// task; bridged comments outside a run (orphaned messages, etc.)
|
|
// don't get attributed.
|
|
if runID := s.ResolveRunForTask(ctx, data.TaskID); runID != "" {
|
|
s.AppendRunEvent(ctx, runID, "step", "info", map[string]interface{}{
|
|
"task_id": data.TaskID,
|
|
"session_id": data.SessionID,
|
|
"comment_id": comment.ID,
|
|
"chars": len(agentText),
|
|
})
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) handleAgentCompleted(ctx context.Context, event *bus.Event) error {
|
|
data, err := decodeEventData[AgentLifecycleData](event)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
// Taskless completion: heartbeat or lightweight-routine fires that
|
|
// don't carry a task_id. Lightweight routines are already created
|
|
// today (wakeup/dispatcher.go's createFreshRun runs on every
|
|
// coordinator heartbeat fire), but the scheduler cannot yet launch a
|
|
// taskless run (WO-35: SchedulerIntegration.launchAgent fails it
|
|
// instead), so no agent ever completes one and no production caller
|
|
// emits this event yet. Once a taskless launch seam lands (tracked
|
|
// as a follow-up feature, not part of WO-35), this branch is what
|
|
// will finish those runs.
|
|
if data.TaskID == "" {
|
|
return s.handleTasklessAgentCompleted(ctx, data)
|
|
}
|
|
run, err := s.resolveLifecycleRun(ctx, *data)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
if exactRunSessionEvent(data) {
|
|
return nil
|
|
}
|
|
// No claimed run resolves for this event: it already finished
|
|
// via another path, arrived late/duplicated, or a cancellation
|
|
// marked the run terminal before this event landed. There is no
|
|
// run left to reach stampRunFinished, but the agent may still
|
|
// be sitting at "working" from the launch that produced it.
|
|
// Scoped to data.RunID so a stale/duplicate event for this
|
|
// finished run can't clobber a successor run's live status.
|
|
s.clearAgentWorking(ctx, data.AgentProfileID, data.RunID)
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
// resolveLifecycleRun prefers the immutable run ID from the event and
|
|
// falls back to task plus agent only for legacy events. Releasing here
|
|
// (rather than unconditionally inside transitionRunTerminal) is safe —
|
|
// see transitionRunTerminal's doc comment.
|
|
//
|
|
// Finish before releasing: if FinishRun's DB update fails, the checkout
|
|
// must stay held so ReapStaleCheckouts (not a same-agent race) is what
|
|
// eventually reclaims it, instead of releasing a lock for a run that
|
|
// never actually reached a terminal state.
|
|
wrote, err := s.FinishRun(ctx, run.ID, RunOutcomeProcessed)
|
|
if err != nil {
|
|
s.clearAgentWorking(ctx, run.AgentProfileID, run.ID)
|
|
return err
|
|
}
|
|
if !wrote {
|
|
// The run reached a terminal state through another writer (e.g. a
|
|
// concurrent cancel) between resolveLifecycleRun's read and this
|
|
// write — it never actually held the checkout from this call's
|
|
// point of view, so releasing it here would steal a live lock the
|
|
// same way an unconditional release would (Review round 3, R3-1).
|
|
// The agent may still be stuck "working" from the launch though.
|
|
// The completion side effects below (timeline event, routing
|
|
// health, output summary, review-decision warning) must not run
|
|
// either: this run did not actually complete from this call's
|
|
// point of view, so persisting them would attribute another
|
|
// writer's outcome (e.g. a cancel) with this one's evidence
|
|
// (CodeRabbit, PR fixup round 2).
|
|
s.clearAgentWorking(ctx, run.AgentProfileID, run.ID)
|
|
return nil
|
|
}
|
|
// Lifecycle: terminal "complete" event for the run detail Events log.
|
|
s.AppendRunEvent(ctx, run.ID, "complete", "info", map[string]interface{}{
|
|
"task_id": data.TaskID,
|
|
"session_id": data.SessionID,
|
|
})
|
|
s.markRoutingSuccess(ctx, run)
|
|
s.recordRunOutputSummary(ctx, run, *data)
|
|
s.warnIfReviewDecisionMissing(ctx, run)
|
|
s.releaseTaskCheckoutForRun(ctx, run)
|
|
s.stampRunFinished(ctx, run)
|
|
return nil
|
|
}
|
|
|
|
// warnIfReviewDecisionMissing flags a review or approval run that finished
|
|
// without the agent ever recording a workflow decision. A reviewer can
|
|
// post a full critique and reject the work in a comment, but if that comment
|
|
// never becomes a recorded decision the workflow engine has nothing to act
|
|
// on and the task strands in its current step forever. This does not fix
|
|
// the missing decision — it only surfaces it as a warn-level run event so
|
|
// the stall is visible instead of silent.
|
|
func (s *Service) warnIfReviewDecisionMissing(ctx context.Context, run *models.Run) {
|
|
if run == nil {
|
|
return
|
|
}
|
|
parsed := ParseRunPayload(run.Payload)
|
|
taskID, stepID, stageType := s.resolveReviewStage(ctx, run.Reason, parsed)
|
|
if !isReviewOrApprovalStage(stageType) {
|
|
return
|
|
}
|
|
if taskID == "" || stepID == "" {
|
|
return
|
|
}
|
|
has, err := s.repo.HasActiveStepDecision(ctx, taskID, stepID, run.AgentProfileID)
|
|
if err != nil {
|
|
s.logger.Warn("failed to check step decision presence",
|
|
zap.String("task_id", taskID),
|
|
zap.String("run_id", run.ID),
|
|
zap.Error(err))
|
|
return
|
|
}
|
|
if has {
|
|
return
|
|
}
|
|
s.logger.Warn("review/approval run finished without a recorded step decision",
|
|
zap.String("task_id", taskID),
|
|
zap.String("run_id", run.ID),
|
|
zap.String("stage_type", stageType),
|
|
zap.String("step_id", stepID),
|
|
zap.String("agent_profile_id", run.AgentProfileID))
|
|
s.AppendRunEvent(ctx, run.ID, "decision.missing", string(models.RunEventLevelWarn), map[string]interface{}{
|
|
"task_id": taskID,
|
|
"stage_type": stageType,
|
|
"step_id": stepID,
|
|
})
|
|
}
|
|
|
|
// markRoutingSuccess delegates to the routing dispatcher (when wired)
|
|
// so the resolved provider's health scopes flip back to healthy.
|
|
func (s *Service) markRoutingSuccess(ctx context.Context, run *models.Run) {
|
|
rd := s.routingDispatcher
|
|
if rd == nil || run == nil {
|
|
return
|
|
}
|
|
if run.ResolvedProviderID == nil || *run.ResolvedProviderID == "" {
|
|
return
|
|
}
|
|
agent, err := s.GetAgentFromConfig(ctx, run.AgentProfileID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
rd.MarkRunSuccessHealth(ctx, run, agent)
|
|
}
|
|
|
|
// runEventFieldAgentID is the run-event payload key for an agent id.
|
|
// Named to avoid a duplicate-literal lint failure — "agent_id" also
|
|
// appears as a JSON struct tag elsewhere in this file, and those two
|
|
// uses are otherwise unrelated to each other.
|
|
const runEventFieldAgentID = "agent_id"
|
|
|
|
// handleTasklessAgentCompleted attributes a taskless run completion,
|
|
// finishes the run, and refreshes the per-agent, per-scope continuation
|
|
// summary so the next fire has bridge context. The
|
|
// summary is built deterministically from the run's result_json,
|
|
// workspace activity, and the prior summary — see the office/summary
|
|
// package.
|
|
//
|
|
// Best-effort: any failure in the summary build path is logged but
|
|
// does NOT fail the completion event. We always at least call
|
|
// FinishRun so the run row reaches a terminal state.
|
|
func (s *Service) handleTasklessAgentCompleted(
|
|
ctx context.Context, data *AgentLifecycleData,
|
|
) error {
|
|
if data == nil || (data.AgentID == "" && data.AgentProfileID == "" && data.RunID == "") {
|
|
return nil
|
|
}
|
|
run, err := s.resolveLifecycleRun(ctx, *data)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
if exactRunSessionEvent(data) {
|
|
return nil
|
|
}
|
|
// Same reasoning as handleAgentCompleted's and handleAgentFailed's
|
|
// ErrNoRows exits: no claimed run resolves, so nothing reaches
|
|
// this function's own clear below, but the agent may still be
|
|
// "working" from the launch. Scoped to data.RunID for the same
|
|
// reason.
|
|
agentProfileID := data.AgentProfileID
|
|
if agentProfileID == "" {
|
|
agentProfileID = data.AgentID
|
|
}
|
|
s.clearAgentWorking(ctx, agentProfileID, data.RunID)
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
if err := s.finishRunSession(ctx, data, models.RunSessionStateFinished, ""); err != nil {
|
|
return err
|
|
}
|
|
// run came from GetClaimedTasklessRunForAgent: it is the run that
|
|
// actually launched. Taskless runs typically carry no task_id, so this
|
|
// is a no-op in the common case, but call it for the same reason as
|
|
// handleAgentCompleted — see transitionRunTerminal's doc comment.
|
|
//
|
|
// Finish before releasing, same as handleAgentCompleted: a failed
|
|
// FinishRun must not still give up the checkout.
|
|
wrote, err := s.FinishRun(ctx, run.ID, RunOutcomeProcessed)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !wrote {
|
|
// Already terminal via another writer — see handleAgentCompleted's
|
|
// matching branch (Review round 3, R3-1). The completion side
|
|
// effects below must not run either: this run did not actually
|
|
// complete from this call's point of view, so recording them
|
|
// (timeline event, continuation summary, output summary) would
|
|
// attribute another writer's outcome with this one's evidence
|
|
// (CodeRabbit, PR fixup round 2).
|
|
return nil
|
|
}
|
|
s.AppendRunEvent(ctx, run.ID, "complete", "info", map[string]interface{}{
|
|
runEventFieldAgentID: data.AgentID,
|
|
"session_id": data.SessionID,
|
|
})
|
|
s.refreshContinuationSummary(ctx, run, run.AgentProfileID)
|
|
s.recordRunOutputSummary(ctx, run, *data)
|
|
s.releaseTaskCheckoutForRun(ctx, run)
|
|
s.stampRunFinished(ctx, run)
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) finishRunSession(
|
|
ctx context.Context, data *AgentLifecycleData, state models.RunSessionState, message string,
|
|
) error {
|
|
if data == nil || data.RunSessionID == "" {
|
|
return nil
|
|
}
|
|
session, err := s.repo.GetRunSession(ctx, data.RunSessionID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if session == nil {
|
|
return sql.ErrNoRows
|
|
}
|
|
if session.State == state {
|
|
return nil
|
|
}
|
|
if session.State != models.RunSessionStatePreparing && session.State != models.RunSessionStateRunning {
|
|
return sql.ErrNoRows
|
|
}
|
|
wrote, err := s.repo.FinishRunSession(ctx, data.RunSessionID, state, message)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if wrote {
|
|
return nil
|
|
}
|
|
latest, err := s.repo.GetRunSession(ctx, data.RunSessionID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if latest != nil && latest.State == state {
|
|
return nil
|
|
}
|
|
return sql.ErrNoRows
|
|
}
|
|
|
|
// refreshContinuationSummary rebuilds the continuation summary for the
|
|
// given agent and upserts it under run.ContinuationScope — the scope key
|
|
// models.ContinuationScopeForRun computed once, at run-creation time, and
|
|
// persisted onto the row (see runs/repository/sqlite.CreateRun). Errors
|
|
// are logged at warn — the prior row stays intact (last-good wins) and
|
|
// the run completion proceeds.
|
|
//
|
|
// This deliberately reads the persisted field rather than recomputing it:
|
|
// run here comes from resolveLifecycleRun's fresh DB fetch, which can
|
|
// observe a context_snapshot a routine wakeup coalesced into this run
|
|
// after it was claimed (MarkWakeupRequestCoalesced patches only
|
|
// context_snapshot). Recomputing against that drifted snapshot could
|
|
// disagree with the scope the claim-time scheduler used when it read the
|
|
// prior continuation summary into the prompt — the persisted column is
|
|
// the single source of truth both sides read.
|
|
func (s *Service) refreshContinuationSummary(
|
|
ctx context.Context, run *models.Run, agentProfileID string,
|
|
) {
|
|
if s.repo == nil || run == nil || agentProfileID == "" {
|
|
return
|
|
}
|
|
scope := run.ContinuationScope
|
|
inputs, err := summaryLoadInputs(ctx, s.repo, run, agentProfileID, scope)
|
|
if err != nil {
|
|
s.logger.Warn("continuation-summary load inputs failed",
|
|
zap.String("run_id", run.ID), zap.Error(err))
|
|
return
|
|
}
|
|
body := summaryBuild(inputs)
|
|
upsertErr := s.repo.UpsertContinuationSummary(ctx, sqlite.AgentContinuationSummary{
|
|
AgentProfileID: agentProfileID,
|
|
Scope: scope,
|
|
Content: body,
|
|
ContentTokens: approxTokenCount(body),
|
|
UpdatedByRunID: run.ID,
|
|
})
|
|
if upsertErr != nil {
|
|
s.logger.Warn("continuation-summary upsert failed",
|
|
zap.String("run_id", run.ID), zap.Error(upsertErr))
|
|
}
|
|
}
|
|
|
|
// approxTokenCount is the same crude 4-chars-per-token approximation
|
|
// used elsewhere in the codebase. Good enough for budget-line logging
|
|
// — the summary is capped at 8 KB so the absolute number is small.
|
|
func approxTokenCount(s string) int {
|
|
return (len(s) + 3) / 4
|
|
}
|
|
|
|
// recordRunOutputSummary persists the agent's final message as the run's
|
|
// output_summary. Best-effort — same contract as refreshContinuationSummary
|
|
// above: any failure is logged at warn and never fails the completion
|
|
// event, since the run must still reach a terminal state either way.
|
|
//
|
|
// The lookup uses the exact turn when a completion event carries one.
|
|
// Legacy events use messages created at or after run.ClaimedAt because Office
|
|
// task-bound sessions are reused across runs. A nil ClaimedAt falls back to
|
|
// the zero time, matching the old unscoped behavior.
|
|
//
|
|
// failure_reason is deliberately left "" on this write.
|
|
// UpdateRunOutputSummary is output_summary's only writer, so this never
|
|
// clobbers a value nothing else sets today; giving failure_reason real
|
|
// semantics (skipped / checkout-contended / failed) is card b5cf0858's
|
|
// territory, not this one's.
|
|
func (s *Service) recordRunOutputSummary(ctx context.Context, run *models.Run, data AgentLifecycleData) {
|
|
if s.repo == nil || run == nil {
|
|
return
|
|
}
|
|
summary, err := s.lookupFinalAgentMessage(ctx, run, data)
|
|
if err != nil {
|
|
s.logger.Warn("run output summary: final agent message lookup failed",
|
|
zap.String("run_id", run.ID), zap.Error(err))
|
|
return
|
|
}
|
|
if summary == "" {
|
|
return
|
|
}
|
|
if updateErr := s.repo.UpdateRunOutputSummary(ctx, run.ID, summary, ""); updateErr != nil {
|
|
s.logger.Warn("run output summary: update failed",
|
|
zap.String("run_id", run.ID), zap.Error(updateErr))
|
|
}
|
|
}
|
|
|
|
func (s *Service) lookupFinalAgentMessage(
|
|
ctx context.Context, run *models.Run, data AgentLifecycleData,
|
|
) (string, error) {
|
|
if data.TurnID != "" && s.taskWorkspace != nil {
|
|
message, err := s.taskWorkspace.GetLastAgentMessageForTurn(ctx, data.TurnID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return truncateRunOutputSummary(message), nil
|
|
}
|
|
if data.SessionID == "" {
|
|
return "", nil
|
|
}
|
|
var since time.Time
|
|
if run.ClaimedAt != nil {
|
|
since = *run.ClaimedAt
|
|
}
|
|
return s.repo.GetFinalAgentMessage(ctx, data.SessionID, since)
|
|
}
|
|
|
|
const maxRunOutputSummaryChars = 500
|
|
|
|
func truncateRunOutputSummary(content string) string {
|
|
runes := []rune(content)
|
|
if len(runes) <= maxRunOutputSummaryChars {
|
|
return content
|
|
}
|
|
return string(runes[:maxRunOutputSummaryChars])
|
|
}
|
|
|
|
func (s *Service) handleAgentFailed(ctx context.Context, event *bus.Event) error {
|
|
data, err := decodeEventData[AgentLifecycleData](event)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if data.TaskID == "" {
|
|
return s.handleTasklessAgentFailed(ctx, data)
|
|
}
|
|
run, err := s.resolveLifecycleRun(ctx, *data)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
if exactRunSessionEvent(data) {
|
|
return nil
|
|
}
|
|
// Same reasoning as handleAgentCompleted's ErrNoRows exit: no
|
|
// claimed run resolves, so nothing reaches HandleAgentFailure's
|
|
// clear, but the agent may still be "working" from the launch.
|
|
// Scoped to data.RunID for the same reason.
|
|
s.clearAgentWorking(ctx, data.AgentProfileID, data.RunID)
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
// Lifecycle: terminal "error" event for the run detail Events log.
|
|
s.AppendRunEvent(ctx, run.ID, "error", "error", map[string]interface{}{
|
|
"task_id": data.TaskID,
|
|
"session_id": data.SessionID,
|
|
"error_message": data.ErrorMessage,
|
|
})
|
|
// Clear before routing can make the run claimable again. This prevents
|
|
// cleanup from this attempt from matching a relaunch that reuses its run ID.
|
|
s.clearAgentWorking(ctx, run.AgentProfileID, run.ID)
|
|
if s.tryPostStartFallback(ctx, run, data.ErrorMessage, data.ProviderError) {
|
|
return nil
|
|
}
|
|
// Office failure path: terminal for every error except a classified-
|
|
// transient failure, which HandleAgentFailure itself retries a
|
|
// bounded number of times before it counts toward auto-pause
|
|
// (AC-OFFICE-RUNTIME-001.11). The rate-limit-retry path behind
|
|
// HandleRunFailure is a separate, pre-launch tier; we deliberately
|
|
// do NOT call into it here. See docs/specs/office/requirements/runtime.md.
|
|
errMsg := enrichModelFailureMessage(run, data.ErrorMessage)
|
|
wrote, err := s.HandleAgentFailure(ctx, run, errMsg, data.AgentID, data.ProviderError, AgentFailureEvidence{
|
|
RunID: data.RunID,
|
|
SessionID: data.SessionID,
|
|
AgentExecutionID: data.AgentExecutionID,
|
|
PromptGeneration: data.PromptGeneration,
|
|
EvidenceKnown: data.EvidenceKnown,
|
|
OutputObserved: data.OutputObserved,
|
|
EffectObserved: data.EffectObserved,
|
|
ProviderDiagnosticCandidate: data.ProviderDiagnosticCandidate,
|
|
ProviderDiagnosticText: data.ProviderDiagnosticText,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !wrote {
|
|
// Already terminal via another writer — waking the CEO agent
|
|
// over a run that never actually failed would be a false alarm
|
|
// (Review round 3, R3-1).
|
|
return nil
|
|
}
|
|
s.dispatchAgentErrorTrigger(ctx, run, data.TaskID, data.SessionID, errMsg)
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) handleTasklessAgentFailed(
|
|
ctx context.Context, data *AgentLifecycleData,
|
|
) error {
|
|
if data == nil || (data.AgentID == "" && data.AgentProfileID == "" && data.RunID == "") {
|
|
return nil
|
|
}
|
|
run, err := s.resolveLifecycleRun(ctx, *data)
|
|
if err != nil {
|
|
return s.handleTasklessAgentLookupError(ctx, data, err)
|
|
}
|
|
s.AppendRunEvent(ctx, run.ID, "error", "error", map[string]interface{}{
|
|
"session_id": data.SessionID,
|
|
"run_session_id": data.RunSessionID,
|
|
"error_message": data.ErrorMessage,
|
|
})
|
|
s.clearAgentWorking(ctx, run.AgentProfileID, run.ID)
|
|
if err := s.finishRunSession(ctx, data, models.RunSessionStateFailed, data.ErrorMessage); err != nil {
|
|
return err
|
|
}
|
|
if s.tryPostStartFallback(ctx, run, data.ErrorMessage, data.ProviderError) {
|
|
return nil
|
|
}
|
|
wrote, err := s.HandleAgentFailure(
|
|
ctx,
|
|
run,
|
|
enrichModelFailureMessage(run, data.ErrorMessage),
|
|
data.AgentID,
|
|
data.ProviderError,
|
|
AgentFailureEvidence{
|
|
RunID: data.RunID,
|
|
SessionID: data.SessionID,
|
|
AgentExecutionID: data.AgentExecutionID,
|
|
PromptGeneration: data.PromptGeneration,
|
|
EvidenceKnown: data.EvidenceKnown,
|
|
OutputObserved: data.OutputObserved,
|
|
EffectObserved: data.EffectObserved,
|
|
ProviderDiagnosticCandidate: data.ProviderDiagnosticCandidate,
|
|
ProviderDiagnosticText: data.ProviderDiagnosticText,
|
|
},
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if wrote {
|
|
s.publishRunProcessedForWorkspace(ctx, run.ID, RunStatusFailed, run, data.WorkspaceID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) handleTasklessAgentLookupError(
|
|
ctx context.Context,
|
|
data *AgentLifecycleData,
|
|
err error,
|
|
) error {
|
|
if !errors.Is(err, sql.ErrNoRows) {
|
|
return err
|
|
}
|
|
if exactRunSessionEvent(data) {
|
|
return nil
|
|
}
|
|
agentProfileID := data.AgentProfileID
|
|
if agentProfileID == "" {
|
|
agentProfileID = data.AgentID
|
|
}
|
|
s.clearAgentWorking(ctx, agentProfileID, data.RunID)
|
|
return nil
|
|
}
|
|
|
|
// dispatchAgentErrorTrigger wakes the workspace CEO agent (WO-05) after a
|
|
// terminal agent-session failure. This is Path A in the office failure
|
|
// model; Path B (HandleRunFailure -> escalateFailure, pre-launch retry
|
|
// exhaustion) queues its own CEO run directly and never reaches this
|
|
// dispatcher, so the two mechanisms cannot double-fire for one failure.
|
|
// A dispatch error is logged rather than returned: it must not mask the
|
|
// failure bookkeeping HandleAgentFailure already committed.
|
|
func (s *Service) dispatchAgentErrorTrigger(
|
|
ctx context.Context, run *models.Run, taskID, sessionID, errMsg string,
|
|
) {
|
|
key := fmt.Sprintf("agent_error:%s", run.ID)
|
|
if err := s.dispatchEngineTrigger(ctx, taskID, engine.TriggerOnAgentError,
|
|
engine.OnAgentErrorPayload{
|
|
FailedAgentID: run.AgentProfileID,
|
|
FailedSessionID: sessionID,
|
|
ErrorMessage: errMsg,
|
|
}, key); err != nil {
|
|
s.logger.Error("engine trigger on_agent_error failed",
|
|
zap.String("task_id", taskID),
|
|
zap.String("run_id", run.ID),
|
|
zap.Error(err))
|
|
}
|
|
}
|
|
|
|
// enrichModelFailureMessage prepends the actionable "change the model" copy
|
|
// when a run fails with a provider/model availability error in strict mode
|
|
// (no auto-fallback toggle, no fallback model — the routing dispatcher
|
|
// escalates instead of re-dispatching). The raw agent error is preserved
|
|
// after the hint so the run detail and chat still show the original cause.
|
|
func enrichModelFailureMessage(run *models.Run, message string) string {
|
|
if run == nil || run.ResolvedModel == nil || *run.ResolvedModel == "" || message == "" {
|
|
return message
|
|
}
|
|
provider := ""
|
|
if run.ResolvedProviderID != nil {
|
|
provider = *run.ResolvedProviderID
|
|
}
|
|
classified := routingerr.Classify(routingerr.Input{
|
|
Phase: routingerr.PhaseStreaming,
|
|
ProviderID: provider,
|
|
Stderr: message,
|
|
})
|
|
if !routingerr.IsAvailabilityCode(classified.Code) {
|
|
return message
|
|
}
|
|
return routingerr.ModelUnavailableMessage(*run.ResolvedModel) + "\n\n" + message
|
|
}
|
|
|
|
// tryPostStartFallback delegates to the routing dispatcher when one is
|
|
// wired and the run was launched via routing. Returns true when the
|
|
// dispatcher requeued the run; the caller should NOT escalate.
|
|
func (s *Service) tryPostStartFallback(
|
|
ctx context.Context, run *models.Run, errorMessage string,
|
|
providerError *streams.ProviderError,
|
|
) bool {
|
|
rd := s.routingDispatcher
|
|
if rd == nil {
|
|
return false
|
|
}
|
|
if run.ResolvedProviderID == nil || *run.ResolvedProviderID == "" {
|
|
return false
|
|
}
|
|
agent, err := s.GetAgentFromConfig(ctx, run.AgentProfileID)
|
|
if err != nil {
|
|
s.logger.Warn("post-start fallback: agent lookup failed",
|
|
zap.String("run_id", run.ID), zap.Error(err))
|
|
return false
|
|
}
|
|
handled, err := rd.HandlePostStartFailure(ctx, run, agent, errorMessage, providerError)
|
|
if err != nil {
|
|
s.logger.Warn("post-start fallback failed",
|
|
zap.String("run_id", run.ID), zap.Error(err))
|
|
return false
|
|
}
|
|
return handled
|
|
}
|
|
|
|
// handlePromptUsage records a cost event from a session/prompt usage
|
|
// update. Cost resolution follows the two-layer order from
|
|
// docs/specs/office/requirements/costs.md, and CostSource on the row records which
|
|
// layer actually produced the dollar amount (see resolveCostForUsage in
|
|
// prompt_usage_cost.go — distinct from Estimated, a usage-authority flag):
|
|
//
|
|
// 1. Provider-reported cost (Layer A) — claude-acp emits exact USD per
|
|
// turn on usage_update.cost.amount; the adapter forwards this as
|
|
// ProviderReportedCostSubcents plus a presence bit. When present (even
|
|
// when zero), the row is recorded verbatim and pricing lookup is skipped.
|
|
// This is the only accurate
|
|
// path for claude-acp, whose model identifiers are logical aliases
|
|
// (default / sonnet / haiku) with no real-name mapping.
|
|
// 2. models.dev (Layer B) — when tokens are reported but no cost,
|
|
// normalize the model id and look up pricing. On miss the row
|
|
// records cost_subcents=0 with cost_source=unpriced; Estimated tracks
|
|
// data.Usage.Estimated verbatim on this path, not hardcoded true.
|
|
//
|
|
// buildCostEvent (prompt_usage_cost.go) also records the cache read/write
|
|
// split when the usage frame reports cache data, and turn_id /
|
|
// usage_event_id threaded from the publish site. This function only inserts
|
|
// the office_cost_events row; it no longer increments the task_sessions
|
|
// rollup columns (docs/specs/task-cost-ledger/spec.md AC-21):
|
|
// internal/task/usage's writer is now the sole writer of those columns
|
|
// (AC-10), inserting task_usage_events and incrementing task_sessions
|
|
// atomically in its own transaction (internal/task/repository/sqlite's
|
|
// insertUsageEventAndRollup). Before this change, Office incremented the
|
|
// same columns here too, so an Office-enabled install (the dev/e2e default)
|
|
// double-counted every turn. Any applicable budget policy is evaluated
|
|
// after the insert. Estimated rows count toward budget totals at face
|
|
// value. Every early return and the insert path record a writer-health
|
|
// metric (cost_metrics.go) so a silently-stopped writer is observable.
|
|
func (s *Service) handlePromptUsage(ctx context.Context, event *bus.Event) error {
|
|
data, err := decodeEventData[PromptUsageData](event)
|
|
if err != nil {
|
|
s.recordCostEventDropped(costDropReasonDecodeError, "")
|
|
return nil
|
|
}
|
|
var fields *sqlite.TaskExecutionFields
|
|
switch {
|
|
case data.TaskID == "" && data.RunSessionID != "":
|
|
fields, err = s.tasklessUsageFields(ctx, data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if fields == nil {
|
|
s.recordCostEventDropped(costDropReasonMissingIDs, data.TaskID)
|
|
return nil
|
|
}
|
|
|
|
case data.TaskID == "" || data.SessionID == "":
|
|
s.recordCostEventDropped(costDropReasonMissingIDs, data.TaskID)
|
|
return nil
|
|
default:
|
|
fields, err = s.repo.GetTaskExecutionFields(ctx, data.TaskID)
|
|
if err != nil {
|
|
s.recordCostEventDropped(costDropReasonTaskFieldsError, data.TaskID)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
sessionAgentProfileID := data.AgentProfileID
|
|
if sessionAgentProfileID == "" && data.TaskID != "" {
|
|
id, lookupErr := s.repo.GetSessionAgentProfileID(ctx, data.TaskID, data.SessionID)
|
|
if lookupErr != nil {
|
|
// Best-effort attribution fallback: log and continue with an
|
|
// unattributed row rather than dropping the cost event over a
|
|
// failed lookup, which would reproduce the exact symptom this
|
|
// fallback exists to fix.
|
|
s.logger.Warn("session agent profile lookup failed",
|
|
zap.String("task_id", data.TaskID),
|
|
zap.String("session_id", data.SessionID),
|
|
zap.Error(lookupErr))
|
|
} else {
|
|
sessionAgentProfileID = id
|
|
}
|
|
}
|
|
|
|
resolution := s.resolveCostForUsage(ctx, *data)
|
|
provider := resolveProvider(*data)
|
|
costEvent := buildCostEvent(
|
|
*data, fields, s.projectIDForTask(ctx, data.TaskID), provider, resolution, sessionAgentProfileID,
|
|
)
|
|
|
|
if err := s.repo.CreateCostEvent(ctx, costEvent); err != nil {
|
|
if errors.Is(err, sqlite.ErrDuplicateUsageEvent) {
|
|
s.recordCostEventDropped(costDropReasonDuplicate, data.TaskID)
|
|
return nil
|
|
}
|
|
s.recordCostEventDropped(costDropReasonInsertError, data.TaskID)
|
|
return err
|
|
}
|
|
s.recordCostEventWritten(string(resolution.source), provider)
|
|
s.publishCostRecorded(ctx, fields.WorkspaceID, costEvent)
|
|
|
|
if fields.WorkspaceID != "" {
|
|
if err := s.CheckBudget(
|
|
ctx, fields.WorkspaceID, costEvent.AgentProfileID, costEvent.ProjectID,
|
|
); err != nil {
|
|
s.logger.Warn("post-event budget check failed",
|
|
zap.String("task_id", data.TaskID), zap.Error(err))
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// handleAgentStreamUsage bridges direct shared-runtime usage into the Office
|
|
// cost ledger. Taskless executions do not have an orchestrator task session,
|
|
// so the owner fields on the stream payload provide exact attribution.
|
|
func (s *Service) handleAgentStreamUsage(ctx context.Context, event *bus.Event) error {
|
|
payload, err := decodeEventData[runtimeapi.AgentStreamEventPayload](event)
|
|
if err != nil || payload.OwnerKind != runtimeapi.ExecutionOwnerRun || payload.RunSessionID == "" || payload.Data == nil || payload.Data.Usage == nil {
|
|
return nil
|
|
}
|
|
usage := payload.Data.Usage
|
|
outputPresent := usage.OutputTokensPresent
|
|
return s.handlePromptUsage(ctx, bus.NewEvent(events.SessionPromptUsageUpdated, "office-service", PromptUsageData{
|
|
AgentExecutionID: payload.ExecutionID,
|
|
SessionID: payload.RunSessionID,
|
|
RunSessionID: payload.RunSessionID,
|
|
RunAttempt: payload.RunAttempt,
|
|
WorkspaceID: payload.WorkspaceID,
|
|
AgentID: payload.AgentType,
|
|
AgentProfileID: payload.AgentProfileID,
|
|
AgentType: payload.AgentType,
|
|
Model: payload.Data.CurrentModelID,
|
|
TurnID: payload.Data.TurnID,
|
|
UsageEventID: fmt.Sprintf("run:%s:%d", payload.ExecutionID, payload.Data.PromptGeneration),
|
|
Usage: UsageTokens{
|
|
InputTokens: usage.InputTokens,
|
|
OutputTokens: usage.OutputTokens,
|
|
OutputTokensPresent: &outputPresent,
|
|
CachedReadTokens: usage.CachedReadTokens,
|
|
CachedWriteTokens: usage.CachedWriteTokens,
|
|
ThoughtTokens: usage.ThoughtTokens,
|
|
TotalTokens: usage.TotalTokens,
|
|
ProviderReportedCostSubcents: usage.ProviderReportedCostSubcents,
|
|
ProviderReportedCostPresent: usage.ProviderReportedCostPresent,
|
|
Estimated: usage.Estimated,
|
|
},
|
|
}))
|
|
}
|
|
|
|
// publishCostRecorded emits the durable cost write as an Office notification.
|
|
// The database insert is authoritative; a publish failure is logged and does
|
|
// not turn a successful cost write into a failed prompt-usage event.
|
|
func (s *Service) publishCostRecorded(ctx context.Context, workspaceID string, costEvent *models.CostEvent) {
|
|
if s.eb == nil || workspaceID == "" || costEvent == nil {
|
|
return
|
|
}
|
|
data := map[string]interface{}{
|
|
"workspace_id": workspaceID,
|
|
"task_id": costEvent.TaskID,
|
|
"session_id": costEvent.SessionID,
|
|
"agent_profile_id": costEvent.AgentProfileID,
|
|
"project_id": costEvent.ProjectID,
|
|
"model": costEvent.Model,
|
|
"provider": costEvent.Provider,
|
|
"cost_subcents": costEvent.CostSubcents,
|
|
}
|
|
event := bus.NewEvent(events.OfficeCostRecorded, "office-service", data)
|
|
if err := s.eb.Publish(ctx, events.OfficeCostRecorded, event); err != nil {
|
|
s.logger.Debug("publish cost recorded event failed",
|
|
zap.String("task_id", costEvent.TaskID), zap.Error(err))
|
|
}
|
|
}
|
|
|
|
// resolveProvider derives the provider id for the cost row. AgentType
|
|
// (the CLI engine slug — claude-acp, codex-acp, ...) is the most reliable
|
|
// source; AgentID is checked as a fallback for legacy bus events that
|
|
// publish the CLI name there. ProviderForModel handles canonical model
|
|
// prefixes (gpt-*, gemini-*) for CLIs that surface real model names.
|
|
// The explicit `provider` field is honoured last.
|
|
func resolveProvider(data PromptUsageData) string {
|
|
if p := providerFromCLI(data.AgentType); p != "" {
|
|
return p
|
|
}
|
|
if p := providerFromCLI(data.AgentID); p != "" {
|
|
return p
|
|
}
|
|
if p := costs.ProviderForModel(data.Model); p != "" {
|
|
return p
|
|
}
|
|
return data.Provider
|
|
}
|
|
|
|
// providerFromCLI maps the upstream CLI id (the agent_id stream field)
|
|
// to a provider name. Used because claude-acp emits logical model
|
|
// aliases (sonnet / haiku) that can't be matched on prefix. Delegates to
|
|
// internal/common/costs, the single source of truth shared with the task
|
|
// usage ledger writer (docs/specs/task-cost-ledger/spec.md).
|
|
func providerFromCLI(cli string) string {
|
|
return commoncosts.ProviderFromCLI(cli)
|
|
}
|
|
|
|
func (s *Service) projectIDForTask(ctx context.Context, taskID string) string {
|
|
projectID, err := s.repo.GetTaskProjectID(ctx, taskID)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return projectID
|
|
}
|
|
|
|
// handleTaskCreated fires a task_assigned run when a newly-created task
|
|
// already has a runner. The task.created payload may not carry the
|
|
// projected assignee, so this falls back to the stored runner row.
|
|
func (s *Service) handleTaskCreated(ctx context.Context, event *bus.Event) error {
|
|
data, err := decodeEventData[TaskUpdatedData](event)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return s.queueTaskAssignedRun(ctx, data.TaskID, data.AssigneeAgentProfileID, data.AssignmentGeneration, true)
|
|
}
|
|
|
|
// handleTaskUpdated fires a task_assigned run when an agent is assigned. No
|
|
// production update path can put a new agent into the runner seat (see
|
|
// office/repository/sqlite/tasks.go's UpdateTaskAssignee vs the four inert
|
|
// syncRunnerInTx writers), so this stays subscribed as a redelivery and
|
|
// defensive path rather than a live assignment occurrence.
|
|
func (s *Service) handleTaskUpdated(ctx context.Context, event *bus.Event) error {
|
|
data, err := decodeEventData[TaskUpdatedData](event)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return s.queueTaskAssignedRun(ctx, data.TaskID, data.AssigneeAgentProfileID, data.AssignmentGeneration, false)
|
|
}
|
|
|
|
// queueTaskAssignedRun fires a task_assigned run for the given occurrence.
|
|
//
|
|
// When fallbackToStoredRunner is set and the event carried no assignee, the
|
|
// agent is recovered through a fresh post-commit GetTaskExecutionFields read
|
|
// (task.created's payload can omit it) while the generation, if any, still
|
|
// describes the payload's own occurrence — not necessarily this freshly-read
|
|
// agent's. Task event publication is asynchronous (a per-task FIFO drainer),
|
|
// so a reassignment can commit between the event being built and this
|
|
// handler running, and the two halves would then name different occurrences.
|
|
// Forcing keyless whenever the fallback actually recovers an agent this way
|
|
// avoids mis-keying at the cost of a possible duplicate wake, which
|
|
// AC-003.2 already accepts.
|
|
func (s *Service) queueTaskAssignedRun(
|
|
ctx context.Context,
|
|
taskID string,
|
|
agentProfileID string,
|
|
assignmentGeneration *int64,
|
|
fallbackToStoredRunner bool,
|
|
) error {
|
|
if taskID == "" {
|
|
return nil
|
|
}
|
|
fields, err := s.repo.GetTaskExecutionFields(ctx, taskID)
|
|
if err != nil {
|
|
if errors.Is(err, sqlite.ErrTaskNotFound) {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("get task execution fields for assignment: %w", err)
|
|
}
|
|
if fields == nil || !fields.IsFromOffice {
|
|
return nil
|
|
}
|
|
fellBackToStoredRunner := false
|
|
if agentProfileID == "" && fallbackToStoredRunner {
|
|
agentProfileID = fields.AssigneeAgentProfileID
|
|
fellBackToStoredRunner = agentProfileID != ""
|
|
}
|
|
if agentProfileID == "" {
|
|
return nil
|
|
}
|
|
payload := mustJSON(map[string]string{"task_id": taskID})
|
|
var key string
|
|
if fellBackToStoredRunner || assignmentGeneration == nil {
|
|
runsservice.ReportKeylessEnqueue(RunReasonTaskAssigned, runsservice.KeylessCauseUnresolved, "event_missing_generation")
|
|
} else {
|
|
key = dedupkeys.AssignmentKey(taskID, agentProfileID, *assignmentGeneration)
|
|
}
|
|
_, err = s.QueueRun(ctx, agentProfileID, RunReasonTaskAssigned, payload, key)
|
|
return err
|
|
}
|
|
|
|
// handleTaskMoved keeps the legacy named-step activity fallback and queues
|
|
// downstream blocker / children-completed runs when a task enters a terminal
|
|
// step. Canonical task-state activity is written by the task service
|
|
// before task.state_changed is published, so the workflow move path is durable
|
|
// before any WebSocket refetch can run. Stage progression itself is owned by
|
|
// the workflow engine (the orchestrator subscribes to TaskMoved and fires
|
|
// on_exit / on_enter).
|
|
func (s *Service) handleTaskMoved(ctx context.Context, event *bus.Event) error {
|
|
data, err := decodeEventData[TaskMovedData](event)
|
|
if err != nil || data.AssigneeAgentProfileID == "" {
|
|
return nil
|
|
}
|
|
|
|
// Log the step change to the activity log. The originating run is
|
|
// resolved from the currently-claimed run for this task so the
|
|
// activity row joins back to it on the run detail page's Tasks
|
|
// Touched surface.
|
|
if data.WorkspaceID != "" && data.FromStepName != data.ToStepName {
|
|
runID := s.ResolveRunForTask(ctx, data.TaskID)
|
|
s.LogActivityWithRun(ctx, data.WorkspaceID, "system", "orchestrator",
|
|
"task_status_changed", "task", data.TaskID,
|
|
fmt.Sprintf(`{"new_status":%q,"old_status":%q}`, data.ToStepName, data.FromStepName),
|
|
runID, data.SessionID)
|
|
}
|
|
|
|
if categorizeStep(data.ToStepName) == stepCategoryDone &&
|
|
categorizeStep(data.FromStepName) != stepCategoryDone {
|
|
return s.finalizeDone(ctx, data)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// finalizeDone resolves blockers, notifies parents, and closes out a
|
|
// linked routine run when a task enters a terminal step. The blocker
|
|
// and parent side-effects route through the engine via
|
|
// dispatchEngineTrigger (on_blocker_resolved / on_children_completed);
|
|
// the routine sync is a direct call since it's a simple status write,
|
|
// not an engine trigger.
|
|
func (s *Service) finalizeDone(ctx context.Context, data *TaskMovedData) error {
|
|
if s.routineRunSyncer != nil {
|
|
terminal := "done"
|
|
if strings.EqualFold(data.ToStepName, "cancelled") {
|
|
terminal = "cancelled"
|
|
}
|
|
if err := s.routineRunSyncer.SyncRunStatus(ctx, data.TaskID, terminal); err != nil {
|
|
s.logger.Warn("sync routine run status", zap.Error(err))
|
|
}
|
|
}
|
|
if err := s.queueBlockersResolvedRuns(ctx, data.TaskID); err != nil {
|
|
s.logger.Error("blocker resolution runs failed", zap.Error(err))
|
|
}
|
|
if data.ParentID != "" {
|
|
if err := s.queueChildrenCompletedRun(ctx, data.ParentID); err != nil {
|
|
// Warn, not Error: ParentWakeReconciler is the documented
|
|
// recovery path for a parent whose wake didn't get queued
|
|
// here (including a transient GetChildSetKey failure), so
|
|
// this is expected to self-heal rather than page anyone.
|
|
s.logger.Warn("children completed run failed", zap.Error(err))
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// queueBlockersResolvedRuns checks tasks blocked by the completed task and
|
|
// queues runs for those whose blockers are all resolved.
|
|
func (s *Service) queueBlockersResolvedRuns(ctx context.Context, completedTaskID string) error {
|
|
blockedTaskIDs, err := s.repo.ListTasksBlockedBy(ctx, completedTaskID)
|
|
if err != nil {
|
|
return fmt.Errorf("list blocked tasks: %w", err)
|
|
}
|
|
for _, blockedID := range blockedTaskIDs {
|
|
if err := s.resolveAndWakeIfUnblocked(ctx, blockedID, completedTaskID); err != nil {
|
|
s.logger.Error("resolve blocker run failed",
|
|
zap.String("blocked_task", blockedID), zap.Error(err))
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) resolveAndWakeIfUnblocked(ctx context.Context, blockedTaskID, resolvedBlockerID string) error {
|
|
blockers, err := s.repo.ListTaskBlockers(ctx, blockedTaskID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
blockerIDs := make([]string, 0, len(blockers))
|
|
for _, b := range blockers {
|
|
blockerIDs = append(blockerIDs, b.BlockerTaskID)
|
|
if b.BlockerTaskID == resolvedBlockerID {
|
|
continue
|
|
}
|
|
done, err := s.repo.IsTaskInTerminalStep(ctx, b.BlockerTaskID)
|
|
if err != nil || !done {
|
|
return err // still blocked
|
|
}
|
|
}
|
|
if len(blockerIDs) == 0 {
|
|
return nil
|
|
}
|
|
key := fmt.Sprintf("blockers_resolved:%s:%s", blockedTaskID, dedupkeys.BlockerDigest(blockerIDs))
|
|
return s.dispatchEngineTrigger(ctx, blockedTaskID, engine.TriggerOnBlockerResolved,
|
|
engine.OnBlockerResolvedPayload{
|
|
ResolvedBlockerIDs: []string{resolvedBlockerID},
|
|
}, key)
|
|
}
|
|
|
|
// lookupChildPRLinks resolves the PR URLs (one or more, multi-repo capable)
|
|
// for each child task id. Returns an empty map when the office service has
|
|
// no PR lister wired or when the lookup errors — PR enrichment is
|
|
// best-effort, never blocking the parent's wakeup.
|
|
func (s *Service) lookupChildPRLinks(
|
|
ctx context.Context, children []sqlite.ChildSummary,
|
|
) map[string][]string {
|
|
out := make(map[string][]string, len(children))
|
|
if s.taskPRs == nil || len(children) == 0 {
|
|
return out
|
|
}
|
|
taskIDs := make([]string, 0, len(children))
|
|
for _, c := range children {
|
|
if c.TaskID != "" {
|
|
taskIDs = append(taskIDs, c.TaskID)
|
|
}
|
|
}
|
|
if len(taskIDs) == 0 {
|
|
return out
|
|
}
|
|
prsByTask, err := s.taskPRs.ListTaskPRsByTaskIDs(ctx, taskIDs)
|
|
if err != nil {
|
|
s.logger.Warn("list child task PRs failed",
|
|
zap.Int("children", len(taskIDs)), zap.Error(err))
|
|
return out
|
|
}
|
|
for taskID, links := range prsByTask {
|
|
urls := make([]string, 0, len(links))
|
|
for _, l := range links {
|
|
if l.URL != "" {
|
|
urls = append(urls, l.URL)
|
|
}
|
|
}
|
|
if len(urls) > 0 {
|
|
out[taskID] = urls
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// queueChildrenCompletedRun checks if all children of a parent are terminal
|
|
// and, if so, dispatches an on_children_completed trigger to the engine.
|
|
func (s *Service) queueChildrenCompletedRun(ctx context.Context, parentID string) error {
|
|
allDone, err := s.repo.AreAllChildrenTerminal(ctx, parentID)
|
|
if err != nil || !allDone {
|
|
return err
|
|
}
|
|
|
|
waveKey, waveString, ok := resolveWaveIdentity(ctx, s.repo, parentID, s.logger)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
// Derived the same way as ParentWakeReconciler's recovery dispatch
|
|
// (wakeOperationID) so both producers land on the identical operation
|
|
// id for the same parent + child set + generation. That shared id is
|
|
// what lets idx_run_idempotency actually dedupe the pair when the
|
|
// reconciler races this edge-triggered path for the same completion
|
|
// wave.
|
|
childSetKey, generation, err := s.repo.GetChildSetKeyAndGeneration(ctx, parentID)
|
|
if err != nil {
|
|
return fmt.Errorf("get child set key: %w", err)
|
|
}
|
|
|
|
// No child summaries are assembled here. The prompt path derives the
|
|
// child list at assembly time from the parent's current children, so a
|
|
// summary read at this point would pay for data that is discarded and
|
|
// would make the wake's content depend on which producer won the race.
|
|
key := wakeOperationID(parentID, childSetKey, generation)
|
|
return s.dispatchEngineTrigger(ctx, parentID, engine.TriggerOnChildrenCompleted,
|
|
engine.OnChildrenCompletedPayload{
|
|
WaveKey: waveKey,
|
|
WaveString: waveString,
|
|
}, key)
|
|
}
|
|
|
|
// handleCommentCreated loads the comment and relays it to external channels.
|
|
func (s *Service) handleCommentCreated(ctx context.Context, event *bus.Event) error {
|
|
data, err := decodeEventData[CommentPostedData](event)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if err := s.queueCommentRun(ctx, *data); err != nil {
|
|
s.logger.Error("queue comment run failed",
|
|
zap.String("task_id", data.TaskID),
|
|
zap.String("comment_id", data.CommentID),
|
|
zap.Error(err))
|
|
}
|
|
comment, err := s.repo.GetTaskComment(ctx, data.CommentID)
|
|
if err != nil {
|
|
s.logger.Error("load comment for relay failed",
|
|
zap.String("comment_id", data.CommentID), zap.Error(err))
|
|
return nil
|
|
}
|
|
if err := s.relay.RelayComment(ctx, comment); err != nil {
|
|
s.logger.Error("relay comment failed",
|
|
zap.String("comment_id", data.CommentID), zap.Error(err))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) queueCommentRun(ctx context.Context, data CommentPostedData) error {
|
|
if data.TaskID == "" || data.CommentID == "" {
|
|
return nil
|
|
}
|
|
if data.EngineDispatched == commentkeys.EngineDispatchedValue {
|
|
return nil
|
|
}
|
|
// A session-bridged comment mirrors a turn that already ran; it must
|
|
// never itself queue a new run, whichever agent it's attributed to.
|
|
if data.Source == "session" {
|
|
return nil
|
|
}
|
|
// Self-comment short-circuit: if the agent that wrote the comment is
|
|
// also the task's primary participant we don't want to queue a run.
|
|
// The engine's queue_run resolver enforces the same rule, but
|
|
// short-circuiting here saves an engine evaluation on every agent
|
|
// reply.
|
|
fields, err := s.repo.GetTaskExecutionFields(ctx, data.TaskID)
|
|
if err == nil && fields != nil &&
|
|
data.AuthorType == participantTypeAgent && fields.AssigneeAgentProfileID == data.AuthorID {
|
|
return nil
|
|
}
|
|
key := commentkeys.TaskComment(data.CommentID)
|
|
return s.dispatchEngineTrigger(ctx, data.TaskID, engine.TriggerOnComment,
|
|
engine.OnCommentPayload{
|
|
CommentID: data.CommentID,
|
|
AuthorID: data.AuthorID,
|
|
}, key)
|
|
}
|
|
|
|
// handleApprovalResolved dispatches an on_approval_resolved trigger to
|
|
// the workflow engine. The engine path is the only path after Phase 4 —
|
|
// no legacy fallback — so an approval with no associated task or no
|
|
// active session is dropped (with a debug log via dispatchEngineTrigger).
|
|
func (s *Service) handleApprovalResolved(ctx context.Context, event *bus.Event) error {
|
|
data, err := decodeEventData[ApprovalResolvedData](event)
|
|
if err != nil || data.RequestedByAgentProfileID == "" {
|
|
return nil
|
|
}
|
|
taskID := s.resolveApprovalTaskID(ctx, data.ApprovalID)
|
|
if taskID == "" {
|
|
s.logger.Debug("approval_resolved: no associated task; dropping",
|
|
zap.String("approval_id", data.ApprovalID))
|
|
return nil
|
|
}
|
|
key := fmt.Sprintf("approval_resolved:%s", data.ApprovalID)
|
|
return s.dispatchEngineTrigger(ctx, taskID, engine.TriggerOnApprovalResolved,
|
|
engine.OnApprovalResolvedPayload{
|
|
ApprovalID: data.ApprovalID,
|
|
Status: data.Status,
|
|
Note: data.DecisionNote,
|
|
}, key)
|
|
}
|
|
|
|
// resolveApprovalTaskID returns the task id associated with an approval by
|
|
// reading the JSON-encoded `task_id` field from the approval's payload.
|
|
// Returns empty if the approval has no task or the lookup/decode fails.
|
|
// Used by the engine-driven path which requires a task id to load
|
|
// workflow state.
|
|
func (s *Service) resolveApprovalTaskID(ctx context.Context, approvalID string) string {
|
|
if approvalID == "" {
|
|
return ""
|
|
}
|
|
a, err := s.repo.GetApproval(ctx, approvalID)
|
|
if err != nil || a == nil || a.Payload == "" {
|
|
return ""
|
|
}
|
|
var raw map[string]any
|
|
if err := json.Unmarshal([]byte(a.Payload), &raw); err != nil {
|
|
return ""
|
|
}
|
|
if id, ok := raw["task_id"].(string); ok {
|
|
return id
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// Step categories for task.moved events.
|
|
type stepCategory int
|
|
|
|
const (
|
|
stepCategoryUnknown stepCategory = iota
|
|
stepCategoryInProgress
|
|
stepCategoryDone
|
|
stepCategoryInReview
|
|
)
|
|
|
|
// categorizeStep maps step names to categories.
|
|
func categorizeStep(name string) stepCategory {
|
|
switch name {
|
|
case "In Progress", "in_progress":
|
|
return stepCategoryInProgress
|
|
case "Done", "done", "Cancelled", "cancelled":
|
|
return stepCategoryDone
|
|
case "In Review", "in_review":
|
|
return stepCategoryInReview
|
|
default:
|
|
return stepCategoryUnknown
|
|
}
|
|
}
|
|
|
|
// decodeEventData extracts typed data from an event.
|
|
func decodeEventData[T any](event *bus.Event) (*T, error) {
|
|
b, err := json.Marshal(event.Data)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var data T
|
|
if err := json.Unmarshal(b, &data); err != nil {
|
|
return nil, err
|
|
}
|
|
return &data, nil
|
|
}
|
|
|
|
// mustJSON marshals v to JSON string, returning "{}" on error.
|
|
func mustJSON(v interface{}) string {
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
return "{}"
|
|
}
|
|
return string(b)
|
|
}
|