Skip to content

Agent runs and skills registry

How a skill goes from a file on disk (SKILL.md) to a running, budget-gated, resumable agent process, on either the Claude or Codex backend. This page complements dispatch, claim-lease and heartbeats, which covers the claim mechanics generically; here the focus is backend selection, the skills registry itself, and completion/failover.

flowchart TD
    SYNC["cmd/skill-sync:\nSkills.Sync -> SyncScoped"] --> SCAN["ScanSkillDir:\nparse each SKILL.md"]
    SCAN --> SKILLTBL[("skills table\n(name, scope)\norphan=true if missing")]
    SYNC --> PROJSYNC["syncProjectAgents:\nper-project agents_path\nscope=project-slug"]
    PROJSYNC --> SKILLTBL

    REQ["POST /runs/agent\nor POST /skills/{name}/run"] --> GATE{"GateForProject:\nunknown/orphan/\nnot executable?"}
    SKILLTBL --> GATE
    GATE -->|"404/409/400"| REJECT["rejected"]
    GATE -->|ok| CAP{"resolveClaudeCapBackend:\nClaude capped/stale?"}
    CAP -->|yes| CODEXPRE["enqueue with\nBackend=codex"]
    CAP -->|no| CLAUDEPRE["enqueue with\nBackend=claude (default)"]

    CODEXPRE --> ENQ["AgentRunStore.Enqueue"]
    CLAUDEPRE --> ENQ
    ENQ --> QROW[("agent_runs\nstatus=queued")]

    QROW --> CLAIM["Runner: RunClaimLanes\n-> claimLane -> ProcessAgentRun"]
    CLAIM --> BUDGET{"Claude + not forced:\nBudgetGate.Allow?"}
    BUDGET -->|"over budget"| RELEASE["Release: status=queued\ndaemon paused until reset"]
    BUDGET -->|ok, or codex, or forced| EXEC["backend branch"]

    EXEC -->|codex| CODEXRUN["buildCodexPrompt ->\ncodex exec --json\n(ChatGPT subscription billing)"]
    EXEC -->|claude| CLAUDERUN["claude -p\n--session-id / --resume\nup to 3 attempts, backoff on 429"]

    CODEXRUN --> MIDRUN["mid-run: skill may invoke\nthe Skill tool using\nstored SKILL.md content"]
    CLAUDERUN --> MIDRUN
    MIDRUN --> HBLOOP["heartbeat + milestone\nposts (see dispatch page)"]

    HBLOOP --> RETURN["process returns"]
    RETURN --> COMPLETE["POST /runs/agent/{id}/complete"]
    COMPLETE --> XCHECK{"output_kind=tasks AND\nclaimed done but no PR?"}
    XCHECK -->|yes| DOWNGRADE["downgrade to failed"]
    XCHECK -->|no| STATUS{"final status"}

    DOWNGRADE --> STATUS
    STATUS -->|done| ARTIFACT[("agent_artifacts row\n(unless output_kind=tasks)")]
    STATUS -->|"failed, backend=claude,\nisUsageCap"| CODEXFALLBACK["RequeueCodexFallbackOnce:\nauto-enqueue Codex retry"]
    CODEXFALLBACK --> QROW

    ARTIFACT --> USAGE["AgentRunUsage ->\nSubscriptionUsageStore.RollingUsage\n(7-day per-backend spend)"]
    USAGE --> FAILOVER{"failoverDecision:\nover threshold?"}
    FAILOVER -->|yes| CROSSFAIL["cross-backend failover\nfor future dispatch"]

    QROW --> REAPSTALE["ReapStale:\nno heartbeat 5m"]
    REAPSTALE -->|"attempts left"| REQUEUE["status=queued,\nsession_id preserved"]
    REQUEUE -.->|"resume: --resume <session>"| CLAUDERUN
    REAPSTALE -->|exhausted| FAILEXH["status=failed"]

Node annotations

Skills.Sync -> SyncScoped

cmd/skill-sync/main.go:main (host cron on pve-claude-code) calls Skills.Sync -> internal/hub/skills.go:SyncScoped (L217), which runs ScanSkillDir (L160) over ~/.claude/skills (global scope), parsing each SKILL.md's frontmatter (parseSkillManifest, parseSkillExec, parseSkillTelegram, L95-142) and upserting into the skills table keyed (name, scope). Anything missing from a scan is marked orphan=true, never deleted.

syncProjectAgents

cmd/skill-sync/main.go:84 repeats the same scan per project, reading each project's agents_path from project_registry, stamped scope=<project-slug> -- this is how project-scoped skills coexist with the global catalog in the same table. Table: skills (migrations/0040_skills.sql).

GateForProject

internal/hub/skills.go:GateForProject (L531) -> gateByScope (L547), called from handleSkillRun (skills_run_api.go:22) before any run is enqueued: 404 for an unknown skill, 409 for orphan/deprecated, 400 if not marked executable.

resolveClaudeCapBackend

handleSkillRun checks the cached claudeCapSignal: if Claude is capped or the signal is stale, the run is pre-enqueued with Backend: "codex" instead of the default "claude" (ARGUS-448 pre-routing) -- a decision made before the run ever reaches a claim loop.

AgentRunStore.Enqueue

internal/hub/agent_runs.go:Enqueue (L216). Validates skill/prompt/ output_kind/model/backend, inserts a queued row (migrations/0042_agent_runs.sql plus many additive migrations for backend/model/session/schedule/mcp_endpoint/progress_trail/context_bundle fields).

Runner claims / BudgetGate

cmd/agent-runner/main.go:main -> RunClaimLanes (agent_runner.go:262) -> claimLane (L291) -> ProcessAgentRun (L141). A non-forced, non-codex run is checked against BudgetGate.Allow (L172); over budget -> Release back to queued and the daemon pauses (PauseUntil, L330). The cap signal itself is pushed by the runner's own ticker, RunClaudeCapHeartbeat (L101) -> POST /runner/claude-cap -> RunnerClaudeCapSignal.Update (runner_claude_cap.go:127,49), read back on the hub side for both claim filtering and dispatch decisions.

Backend branch

internal/hub/agent_runner.go:DefaultResearchRunner (L656). codex: builds a Codex-native prompt (codex_backend.go:buildCodexPrompt, L124), execs codex exec --json (runCodexExec/execCodex, L179), parses the JSONL stream (parseCodexResult, L62) -- no OPENAI_API_KEY is inherited, so billing hits the ChatGPT subscription rather than the API. claude: execs claude -p (runClaudeResearch, L902) with --session-id/ --resume for the ARGUS-308 resume breadcrumb, up to 3 attempts with backoff on transient 429s, a typed *UsageCapError on a real usage cap (isUsageCap, L997).

Mid-run skill invocation

The running headless claude -p process may itself invoke the "Skill" tool as part of its allow-list (DefaultResearchTools, L650), using the stored SKILL.md content (Skills.Prompt, skills.go:282) as its instructions -- this is how a nested skill call surfaces inside an already-running agent run, not a separate agent_runs row.

POST /runs/agent/{id}/complete

agent_runs_api.go:handleAgentComplete (L362), the largest branch point: validates status, cross-checks an hub-build "done" claim against actual task progress (downgrading to failed if no PR was opened, L400), stores the report as an agent_artifacts row (unless output_kind=tasks), files audit findings for output_kind=tasks, and on failed + backend=claude + isUsageCap calls RequeueCodexFallbackOnce (agent_runs.go:326) to auto-enqueue a Codex retry of the same work.

AgentRunStore.Complete

agent_runs.go:368. running -> done|failed, records AgentRunUsage, closes the ledger row, pushes a notification (gated by notifyPolicy), and posts the outcome back to the requesting chat if there was one.

ReapStale / resume

agent_runs.go:482. Sweeps running rows with no heartbeat past the staleness window, requeuing (attempts++, session_id preserved) or failing at maxAttempts. A resumed attempt (Attempts>0 && SessionID!="") sets spawn.ResumeSessionID so the next execution runs claude -p --resume <session> instead of starting cold.

SubscriptionUsageStore.RollingUsage / failoverDecision

internal/hub/subscription_usage.go:RollingUsage (L109). A 7-day per-backend spend aggregate (Codex token counts converted to a dollar proxy) compared against subscription_thresholds to drive cross-backend failover (failoverDecision, L175) -- separate from the live per-run Claude-cap gate above; this one shapes future dispatch/enqueue decisions, not the current run.

Key tables and endpoints

  • agent_runs (0042 + ~15 additive migrations), agent_artifacts (0043), skills (0040), agent_sessions (0077), channel_skills (0044).
  • POST /runs/agent, POST /runs/agent/claim, POST /runs/agent/{id}/progress, POST /runs/agent/{id}/session, POST /runs/agent/{id}/release, POST /runs/agent/{id}/complete, GET /runs/agent/forced, GET /runs/agent/{id}.json, POST /skills/{name}/run, POST /runner/claude-cap, GET /runner/claude-cap, POST /agents/heartbeat, GET /agents.json.