Files
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

215 lines
7.2 KiB
Go

package workspace
import (
"context"
"encoding/json"
"log/slog"
"strings"
canonical "github.com/tutti-os/tutti/packages/agent/store-sqlite/canonical"
workspaceissues "github.com/tutti-os/tutti/packages/workspace/issues"
)
// ObserveAgentSessionState settles Issue runs from the authoritative Agent
// turn lifecycle. Issue Agents are not required to call the Issue Manager CLI
// themselves, so a normal completed turn cannot be mistaken for a vanished
// session by the fallback reconciler.
func (c *IssueExecutionCoordinator) ObserveAgentSessionState(ctx context.Context, input canonical.ReportSessionStateInput, _ canonical.ReportSessionStateReply) {
status, ok := issueRunStatusFromSessionState(input.State)
if !ok || c == nil || c.Issues == nil || c.Issues.Store == nil {
return
}
usage := issueRunUsageFromRuntimeContext(input.State.RuntimeContext)
remainingQuota, hasRemainingQuota := issueRunRemainingQuota(input.State.RuntimeContext)
settledTurnID := ""
if input.State.Turn != nil {
settledTurnID = strings.TrimSpace(input.State.Turn.TurnID)
}
errorMessage := ""
if status != workspaceissues.StatusCompleted {
errorMessage = strings.TrimSpace(input.State.LastError)
}
c.ObserveRunSettlement(ctx, IssueRunSettlement{
WorkspaceID: input.WorkspaceID,
AgentSessionID: input.AgentSessionID,
TurnID: settledTurnID,
Status: status,
ErrorMessage: errorMessage,
Usage: usage,
RemainingQuotaPercent: remainingQuota,
HasRemainingQuotaPercent: hasRemainingQuota,
})
}
func (c *IssueExecutionCoordinator) ObserveRunSettlement(ctx context.Context, settlement IssueRunSettlement) {
if c == nil || c.Issues == nil || c.Issues.Store == nil {
return
}
workspaceID := strings.TrimSpace(settlement.WorkspaceID)
agentSessionID := strings.TrimSpace(settlement.AgentSessionID)
if workspaceID == "" || agentSessionID == "" {
return
}
runs, err := c.Issues.domainService().ListRunningRuns(ctx, workspaceID, defaultIssueRunReconcileLimit)
if err != nil {
slog.Warn("list running Issue runs for Agent settlement failed",
"event", "workspace_issue.agent_settlement_list_failed",
"workspace_id", workspaceID,
"agent_session_id", agentSessionID,
"error", err,
)
return
}
settledTurnID := strings.TrimSpace(settlement.TurnID)
for _, run := range runs {
if strings.TrimSpace(run.AgentSessionID) != agentSessionID {
continue
}
// A delegate session can settle turns that are not the run's own
// brief (a human interjecting in its conversation, queued guidance).
// Match the settled turn against the run's initiating submit so only
// the run's own terminal fact completes it. An unresolved identity is
// not evidence of settlement: fail closed and let reconciliation retry.
if c.SettlementReader == nil || settledTurnID == "" {
c.Issues.enqueueWorkspaceRunReconcile(workspaceID)
continue
}
clientSubmitID, identityErr := c.Issues.issueRunClientSubmitID(ctx, run)
if identityErr != nil {
c.Issues.enqueueWorkspaceRunReconcile(workspaceID)
continue
}
canonicalSettlement, found, resolveErr := c.SettlementReader.ReadRunSettlement(
ctx, workspaceID, agentSessionID, clientSubmitID,
)
if resolveErr != nil || !found || strings.TrimSpace(canonicalSettlement.TurnID) == "" {
c.Issues.enqueueWorkspaceRunReconcile(workspaceID)
continue
}
if strings.TrimSpace(canonicalSettlement.TurnID) != settledTurnID {
continue
}
settlement.Status = canonicalSettlement.Status
if canonicalSettlement.ErrorMessage != "" {
settlement.ErrorMessage = canonicalSettlement.ErrorMessage
}
if _, err := c.Issues.CompleteRun(ctx, workspaceID, run.IssueID, run.TaskID, run.RunID, CompleteIssueManagerRunInput{
Status: string(settlement.Status),
ErrorMessage: settlement.ErrorMessage,
Usage: settlement.Usage,
RemainingQuotaPercent: settlement.RemainingQuotaPercent,
HasRemainingQuotaPercent: settlement.HasRemainingQuotaPercent,
}); err != nil {
slog.Warn("settle Issue run from Agent state failed",
"event", "workspace_issue.agent_settlement_failed",
"workspace_id", workspaceID,
"issue_id", run.IssueID,
"task_id", run.TaskID,
"run_id", run.RunID,
"agent_session_id", agentSessionID,
"error", err,
)
}
}
}
func issueRunStatusFromSessionState(state canonical.WorkspaceAgentSessionStateUpdate) (workspaceissues.Status, bool) {
outcome := ""
if lifecycle := state.TurnLifecycle; lifecycle != nil && strings.TrimSpace(lifecycle.Phase) == "settled" && lifecycle.Outcome != nil {
outcome = strings.TrimSpace(*lifecycle.Outcome)
}
if turn := state.Turn; turn != nil && strings.TrimSpace(turn.Phase) == "settled" && strings.TrimSpace(turn.Outcome) != "" {
outcome = strings.TrimSpace(turn.Outcome)
}
switch outcome {
case "completed":
return workspaceissues.StatusCompleted, true
case "canceled":
return workspaceissues.StatusCanceled, true
default:
if outcome != "" {
return workspaceissues.StatusFailed, true
}
return "", false
}
}
func issueRunUsageFromRuntimeContext(runtimeContext map[string]any) workspaceissues.TokenUsage {
usage, _ := runtimeContext["usage"].(map[string]any)
if last, ok := usage["last"].(map[string]any); ok {
usage = last
}
return workspaceissues.TokenUsage{
InputTokens: issueRunInt64(usage, "inputTokens", "input_tokens"),
OutputTokens: issueRunInt64(usage, "outputTokens", "output_tokens"),
CacheReadTokens: issueRunInt64(usage, "cacheReadTokens", "cache_read_tokens", "cachedInputTokens", "cached_input_tokens", "cacheReadInputTokens", "cache_read_input_tokens"),
CacheWriteTokens: issueRunInt64(usage, "cacheWriteTokens", "cache_write_tokens", "cacheCreationInputTokens", "cache_creation_input_tokens"),
}
}
func issueRunRemainingQuota(runtimeContext map[string]any) (float64, bool) {
usage, _ := runtimeContext["usage"].(map[string]any)
remaining := float64(0)
found := false
consider := func(quota map[string]any) {
value, ok := issueRunFloat64(quota["percentRemaining"])
if !ok || value < 0 || value > 100 {
return
}
if !found || value < remaining {
remaining = value
found = true
}
}
switch quotas := usage["quotas"].(type) {
case []any:
for _, raw := range quotas {
quota, _ := raw.(map[string]any)
consider(quota)
}
case []map[string]any:
for _, quota := range quotas {
consider(quota)
}
}
return remaining, found
}
func issueRunInt64(payload map[string]any, keys ...string) int64 {
for _, key := range keys {
switch value := payload[key].(type) {
case int:
return maxInt64(int64(value), 0)
case int64:
return maxInt64(value, 0)
case uint64:
if value <= uint64(^uint64(0)>>1) {
return int64(value)
}
case float64:
return maxInt64(int64(value), 0)
case json.Number:
if parsed, err := value.Int64(); err == nil {
return maxInt64(parsed, 0)
}
}
}
return 0
}
func issueRunFloat64(value any) (float64, bool) {
switch typed := value.(type) {
case float64:
return typed, true
case int:
return float64(typed), true
case int64:
return float64(typed), true
case json.Number:
parsed, err := typed.Float64()
return parsed, err == nil
default:
return 0, false
}
}