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
425 lines
13 KiB
Go
425 lines
13 KiB
Go
package workspaceissues
|
|
|
|
import (
|
|
"math"
|
|
"slices"
|
|
"strings"
|
|
)
|
|
|
|
// MaxWorkspaceParallelRuns is the shared admission ceiling for Issue task
|
|
// Runs, regardless of whether the generic dispatcher or Tutti execution
|
|
// admits them.
|
|
const MaxWorkspaceParallelRuns = 4
|
|
|
|
const (
|
|
DefaultReasoningIntensity = 50
|
|
DefaultOrchestrationIntensity = 50
|
|
DefaultQuotaWaterlinePercent = 10
|
|
minimumAutoTokenBudget = int64(32_000)
|
|
maximumAutoTokenBudget = int64(2_000_000)
|
|
)
|
|
|
|
func NormalizeStatus(raw string) (Status, bool) {
|
|
switch Status(strings.ToLower(strings.TrimSpace(raw))) {
|
|
case StatusNotStarted, StatusRunning, StatusPendingAcceptance, StatusCompleted, StatusFailed, StatusCanceled:
|
|
return Status(strings.ToLower(strings.TrimSpace(raw))), true
|
|
default:
|
|
return "", false
|
|
}
|
|
}
|
|
|
|
func NormalizeRunCompletionStatus(raw string) (Status, bool) {
|
|
switch Status(strings.ToLower(strings.TrimSpace(raw))) {
|
|
case StatusCompleted, StatusFailed, StatusCanceled:
|
|
return Status(strings.ToLower(strings.TrimSpace(raw))), true
|
|
default:
|
|
return "", false
|
|
}
|
|
}
|
|
|
|
func NormalizePriority(raw string) Priority {
|
|
switch Priority(strings.ToLower(strings.TrimSpace(raw))) {
|
|
case PriorityHigh, PriorityMedium, PriorityLow:
|
|
return Priority(strings.ToLower(strings.TrimSpace(raw)))
|
|
default:
|
|
return PriorityMedium
|
|
}
|
|
}
|
|
|
|
func NormalizePlanningSource(raw string) (PlanningSource, bool) {
|
|
switch PlanningSource(strings.ToLower(strings.TrimSpace(raw))) {
|
|
case PlanningSourceManual, PlanningSourceTuttiModePlan, PlanningSourceTraditionalPlan:
|
|
return PlanningSource(strings.ToLower(strings.TrimSpace(raw))), true
|
|
default:
|
|
return "", false
|
|
}
|
|
}
|
|
|
|
func DefaultExecutionProfile() ExecutionProfile {
|
|
return ExecutionProfile{
|
|
ReasoningIntensity: DefaultReasoningIntensity,
|
|
OrchestrationIntensity: DefaultOrchestrationIntensity,
|
|
}
|
|
}
|
|
|
|
func NormalizeExecutionProfile(profile ExecutionProfile) (ExecutionProfile, bool) {
|
|
if profile.ReasoningIntensity < 0 || profile.ReasoningIntensity > 100 ||
|
|
profile.OrchestrationIntensity < 0 || profile.OrchestrationIntensity > 100 {
|
|
return ExecutionProfile{}, false
|
|
}
|
|
return profile, true
|
|
}
|
|
|
|
func DefaultBudget() Budget {
|
|
return Budget{
|
|
Mode: BudgetModeAuto,
|
|
QuotaWaterlinePercent: DefaultQuotaWaterlinePercent,
|
|
Status: BudgetStatusActive,
|
|
}
|
|
}
|
|
|
|
func NormalizeBudget(value Budget) (Budget, bool) {
|
|
value.Mode = BudgetMode(strings.ToLower(strings.TrimSpace(string(value.Mode))))
|
|
if value.Mode == "" {
|
|
value.Mode = BudgetModeAuto
|
|
}
|
|
if value.Status == "" {
|
|
value.Status = BudgetStatusActive
|
|
} else {
|
|
value.Status = BudgetStatus(strings.ToLower(strings.TrimSpace(string(value.Status))))
|
|
}
|
|
if value.TokenLimit < 0 || value.ConsumedTokens < 0 || !finitePercentage(value.QuotaWaterlinePercent) ||
|
|
!finiteNumber(value.RemainingQuotaPercent) ||
|
|
value.HasRemainingQuota && !finitePercentage(value.RemainingQuotaPercent) {
|
|
return Budget{}, false
|
|
}
|
|
switch value.Mode {
|
|
case BudgetModeAuto:
|
|
// The compiler owns the effective limit in auto mode.
|
|
value.TokenLimit = maxInt64(value.TokenLimit, 0)
|
|
case BudgetModeFixed:
|
|
if value.TokenLimit <= 0 {
|
|
return Budget{}, false
|
|
}
|
|
default:
|
|
return Budget{}, false
|
|
}
|
|
switch value.Status {
|
|
case BudgetStatusActive, BudgetStatusSoftLimited:
|
|
default:
|
|
return Budget{}, false
|
|
}
|
|
return value, true
|
|
}
|
|
|
|
func finitePercentage(value float64) bool {
|
|
return finiteNumber(value) && value >= 0 && value <= 100
|
|
}
|
|
|
|
func finiteNumber(value float64) bool {
|
|
return !math.IsNaN(value) && !math.IsInf(value, 0)
|
|
}
|
|
|
|
func CompileAutoTokenBudget(taskCount int, profile ExecutionProfile) int64 {
|
|
if taskCount < 1 {
|
|
taskCount = 1
|
|
}
|
|
compiled := int64(16_000) + int64(taskCount)*CompileEstimatedRunTokenBudget(profile)
|
|
if compiled < minimumAutoTokenBudget {
|
|
return minimumAutoTokenBudget
|
|
}
|
|
if compiled > maximumAutoTokenBudget {
|
|
return maximumAutoTokenBudget
|
|
}
|
|
return compiled
|
|
}
|
|
|
|
// CompileEstimatedRunTokenBudget returns the conservative allowance reserved
|
|
// before one automatic Issue task is dispatched. Keeping this compiler aligned
|
|
// with CompileAutoTokenBudget lets lowering the Issue intensities make a
|
|
// previously blocked remaining task fit without silently increasing a fixed
|
|
// token limit.
|
|
func CompileEstimatedRunTokenBudget(profile ExecutionProfile) int64 {
|
|
return 24_000 + int64(profile.ReasoningIntensity)*320 + int64(profile.OrchestrationIntensity)*160
|
|
}
|
|
|
|
// IssueBudgetAllowsNextAutomaticRun applies the pre-dispatch soft gate. It
|
|
// reserves an estimated allowance instead of waiting until actual usage has
|
|
// already crossed the limit, which makes the documented lower-intensity
|
|
// recovery path meaningful.
|
|
func IssueBudgetAllowsNextAutomaticRun(issue Issue) bool {
|
|
if issue.Budget.Status != BudgetStatusActive {
|
|
return false
|
|
}
|
|
if issue.Budget.HasRemainingQuota && issue.Budget.RemainingQuotaPercent <= issue.Budget.QuotaWaterlinePercent {
|
|
return false
|
|
}
|
|
if issue.Budget.TokenLimit <= 0 {
|
|
return true
|
|
}
|
|
remaining := issue.Budget.TokenLimit - issue.Budget.ConsumedTokens
|
|
return remaining >= CompileEstimatedRunTokenBudget(issue.ExecutionProfile)
|
|
}
|
|
|
|
// IssueAutomaticBudgetSlots reserves one estimated allowance for every
|
|
// already-active Run before admitting more work for the same Issue.
|
|
func IssueAutomaticBudgetSlots(issue Issue, activeRunCount int) int {
|
|
if !IssueBudgetAllowsNextAutomaticRun(issue) {
|
|
return 0
|
|
}
|
|
if issue.Budget.TokenLimit <= 0 {
|
|
return MaxWorkspaceParallelRuns
|
|
}
|
|
allowance := CompileEstimatedRunTokenBudget(issue.ExecutionProfile)
|
|
remaining := issue.Budget.TokenLimit - issue.Budget.ConsumedTokens
|
|
if allowance <= 0 || remaining < allowance {
|
|
return 0
|
|
}
|
|
slots := int(remaining/allowance) - activeRunCount
|
|
if slots < 0 {
|
|
return 0
|
|
}
|
|
return slots
|
|
}
|
|
|
|
// IssueAutomaticRunAdmissionSlots is the shared concurrency and budget policy
|
|
// for every automatic Issue Run admission path. Persistence adapters supply
|
|
// authoritative counts, then compare-and-set the selected Runs atomically.
|
|
func IssueAutomaticRunAdmissionSlots(issue Issue, workspaceActiveRunCount int, issueActiveRunCount int) int {
|
|
workspaceSlots := MaxWorkspaceParallelRuns - workspaceActiveRunCount
|
|
if workspaceSlots < 0 {
|
|
workspaceSlots = 0
|
|
}
|
|
return min(workspaceSlots, IssueAutomaticBudgetSlots(issue, issueActiveRunCount))
|
|
}
|
|
|
|
type TaskRunBlocker string
|
|
|
|
const (
|
|
TaskRunBlockerSuperseded TaskRunBlocker = "task_superseded"
|
|
TaskRunBlockerNotStarted TaskRunBlocker = "task_not_started"
|
|
TaskRunBlockerMissingAgentTarget TaskRunBlocker = "missing_agent_target"
|
|
TaskRunBlockerDependencyUnsatisfied TaskRunBlocker = "dependency_unsatisfied"
|
|
)
|
|
|
|
// IssueTaskRunBlocker is the shared task/dependency predicate used by generic
|
|
// dispatch, source-Agent exact-set scheduling, and execution diagnostics.
|
|
func IssueTaskRunBlocker(task Task, tasksByID map[string]Task) TaskRunBlocker {
|
|
if task.IsSuperseded() {
|
|
return TaskRunBlockerSuperseded
|
|
}
|
|
if task.Status != StatusNotStarted {
|
|
return TaskRunBlockerNotStarted
|
|
}
|
|
if strings.TrimSpace(task.AgentTargetID) == "" {
|
|
return TaskRunBlockerMissingAgentTarget
|
|
}
|
|
for _, dependencyID := range task.DependencyTaskIDs {
|
|
dependency, ok := tasksByID[dependencyID]
|
|
if !ok || dependency.IsSuperseded() ||
|
|
dependency.Status != StatusCompleted ||
|
|
dependency.AcceptanceState != AcceptanceUserAccepted {
|
|
return TaskRunBlockerDependencyUnsatisfied
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// IssueTaskEligibleForRun preserves the boolean admission interface for
|
|
// existing dispatch callers while diagnostics retain the exact blocker.
|
|
func IssueTaskEligibleForRun(task Task, tasksByID map[string]Task) bool {
|
|
return IssueTaskRunBlocker(task, tasksByID) == ""
|
|
}
|
|
|
|
// ProjectReviewedSettlementTaskForRun applies the acceptance fact that an
|
|
// exact-set schedule commits when it advances from a task_settled checkpoint.
|
|
// The returned copy is for admission/readiness evaluation only.
|
|
func ProjectReviewedSettlementTaskForRun(task Task) Task {
|
|
if task.Status == StatusPendingAcceptance {
|
|
task.Status = StatusCompleted
|
|
task.AcceptanceState = AcceptanceUserAccepted
|
|
}
|
|
return task
|
|
}
|
|
|
|
func IssueRunClientSubmitID(runID string) string {
|
|
runID = strings.TrimSpace(runID)
|
|
if runID == "" {
|
|
return ""
|
|
}
|
|
return "issue-run:" + runID
|
|
}
|
|
|
|
// CompileAutoTokenBudgetWithHistory blends the deterministic scale/intensity
|
|
// compiler with the observed total usage of comparable completed tasks. The
|
|
// deterministic result remains the fallback and contributes half the result,
|
|
// so one unusual historical run cannot wholly redefine a new Issue budget.
|
|
func CompileAutoTokenBudgetWithHistory(taskCount int, profile ExecutionProfile, historicalTotalTokens int64) int64 {
|
|
compiled := CompileAutoTokenBudget(taskCount, profile)
|
|
if historicalTotalTokens <= 0 {
|
|
return compiled
|
|
}
|
|
historicalWithHeadroom := historicalTotalTokens + historicalTotalTokens/4
|
|
if historicalWithHeadroom < minimumAutoTokenBudget {
|
|
historicalWithHeadroom = minimumAutoTokenBudget
|
|
}
|
|
if historicalWithHeadroom > maximumAutoTokenBudget {
|
|
historicalWithHeadroom = maximumAutoTokenBudget
|
|
}
|
|
blended := compiled/2 + historicalWithHeadroom/2
|
|
if blended < minimumAutoTokenBudget {
|
|
return minimumAutoTokenBudget
|
|
}
|
|
if blended > maximumAutoTokenBudget {
|
|
return maximumAutoTokenBudget
|
|
}
|
|
return blended
|
|
}
|
|
|
|
func NormalizeAcceptanceState(raw string) (AcceptanceState, bool) {
|
|
switch AcceptanceState(strings.ToLower(strings.TrimSpace(raw))) {
|
|
case AcceptanceAgentClaimed, AcceptanceAutoChecked, AcceptanceUserAccepted:
|
|
return AcceptanceState(strings.ToLower(strings.TrimSpace(raw))), true
|
|
default:
|
|
return "", false
|
|
}
|
|
}
|
|
|
|
func CanTransitionAcceptance(current AcceptanceState, next AcceptanceState) bool {
|
|
if current == next {
|
|
return true
|
|
}
|
|
switch current {
|
|
case AcceptanceAgentClaimed:
|
|
return next == AcceptanceAutoChecked || next == AcceptanceUserAccepted
|
|
case AcceptanceAutoChecked:
|
|
return next == AcceptanceUserAccepted
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func NormalizeDependencyTaskIDs(values []string) []string {
|
|
result := make([]string, 0, len(values))
|
|
seen := make(map[string]struct{}, len(values))
|
|
for _, value := range values {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
continue
|
|
}
|
|
if _, exists := seen[value]; exists {
|
|
continue
|
|
}
|
|
seen[value] = struct{}{}
|
|
result = append(result, value)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func ValidateTaskDependencyGraph(tasks []Task) bool {
|
|
byID := make(map[string]Task, len(tasks))
|
|
for _, task := range tasks {
|
|
if task.TaskID == "" {
|
|
return false
|
|
}
|
|
byID[task.TaskID] = task
|
|
}
|
|
visiting := make(map[string]bool, len(tasks))
|
|
visited := make(map[string]bool, len(tasks))
|
|
var visit func(string) bool
|
|
visit = func(taskID string) bool {
|
|
if visiting[taskID] {
|
|
return false
|
|
}
|
|
if visited[taskID] {
|
|
return true
|
|
}
|
|
task, exists := byID[taskID]
|
|
if !exists {
|
|
return false
|
|
}
|
|
visiting[taskID] = true
|
|
for _, dependencyID := range task.DependencyTaskIDs {
|
|
if dependencyID == taskID || !visit(dependencyID) {
|
|
return false
|
|
}
|
|
}
|
|
visiting[taskID] = false
|
|
visited[taskID] = true
|
|
return true
|
|
}
|
|
ids := make([]string, 0, len(byID))
|
|
for taskID := range byID {
|
|
ids = append(ids, taskID)
|
|
}
|
|
slices.Sort(ids)
|
|
for _, taskID := range ids {
|
|
if !visit(taskID) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func TaskStatusForCompletedRun(status Status) Status {
|
|
if status == StatusCompleted {
|
|
return StatusPendingAcceptance
|
|
}
|
|
return status
|
|
}
|
|
|
|
func ProjectIssueStatus(counts StatusCounts) Status {
|
|
switch {
|
|
case counts.Running > 0:
|
|
return StatusRunning
|
|
case counts.Failed > 0:
|
|
return StatusFailed
|
|
case counts.All == 0 || counts.NotStarted == counts.All:
|
|
return StatusNotStarted
|
|
case counts.All > 0 && counts.Completed == counts.All:
|
|
return StatusCompleted
|
|
case counts.All > 0 && counts.PendingAcceptance > 0 && counts.NotStarted == 0 && counts.Running == 0 && counts.Failed == 0:
|
|
return StatusPendingAcceptance
|
|
case counts.All > 0 && counts.Canceled == counts.All:
|
|
return StatusCanceled
|
|
default:
|
|
return StatusRunning
|
|
}
|
|
}
|
|
|
|
func NormalizeContextRefParentKind(raw string) (ContextRefParentKind, bool) {
|
|
switch ContextRefParentKind(strings.ToLower(strings.TrimSpace(raw))) {
|
|
case ContextRefParentIssue, ContextRefParentTask:
|
|
return ContextRefParentKind(strings.ToLower(strings.TrimSpace(raw))), true
|
|
default:
|
|
return "", false
|
|
}
|
|
}
|
|
|
|
func TrimSearchText(content string) string {
|
|
return strings.TrimSpace(content)
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, value := range values {
|
|
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
|
return trimmed
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func NormalizeTokenUsage(value TokenUsage) (TokenUsage, bool) {
|
|
if value.InputTokens < 0 || value.OutputTokens < 0 || value.CacheReadTokens < 0 || value.CacheWriteTokens < 0 {
|
|
return TokenUsage{}, false
|
|
}
|
|
return value, true
|
|
}
|
|
|
|
func maxInt(left int, right int) int {
|
|
if left > right {
|
|
return left
|
|
}
|
|
return right
|
|
}
|