diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..d304ff3 --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,3 @@ +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/.gitignore b/.gitignore index 6684ef9..04aeeb1 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,13 @@ dist/ build/ *.egg-info/ +# Elixir +_build/ +deps/ +.elixir_ls/ +tmp/ +/symphony + # JavaScript and frontend build artifacts node_modules/ npm-debug.log* diff --git a/README.md b/README.md index 6c7b84b..d03e54c 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Caretta Symphony runs many Codex agents from Linear, across many repositories, with a live dashboard for the whole queue. -It is an independent Python implementation of OpenAI's draft [Symphony service specification](https://github.com/openai/symphony/blob/main/SPEC.md). OpenAI defined the core pattern: poll Linear, create an isolated workspace, run a coding agent, and reconcile the issue state. Caretta Symphony keeps that model and adds the operating layer we needed for a real multi-repo product. +It is an independent Elixir implementation of OpenAI's draft [Symphony service specification](https://github.com/openai/symphony/blob/main/SPEC.md). OpenAI defined the core pattern: poll Linear, create an isolated workspace, run a coding agent, and reconcile the issue state. Caretta Symphony keeps that model and adds the operating layer we needed for a real multi-repo product. The core value is control: get the work out of the issue tracker, put each agent in the right repos, and see what all of them are doing while they run. @@ -57,11 +57,17 @@ Raw Linear GraphQL mode is also supported. Agents hand work to a review state. Symphony can move an issue to `Done` only after the required GitHub pull requests are merged into the configured base branch. +While an issue is in a review state, Symphony also polls Linear comments and linked GitHub PR feedback. New human-authored feedback moves the issue to the configured `rework_state` so Codex can continue from the existing workspace. + +### Blocked issue escalation + +When an agent or repository plan cannot continue without human input, Symphony creates or updates one Linear comment headed `## Symphony Blocked Escalation`. It mentions the Linear assignee when the tracker payload includes one; otherwise it uses `tracker.blocked_escalation_mentions`. A later human comment on the issue releases the block so the normal dispatcher can retry. + ## Relationship to OpenAI Symphony OpenAI's `openai/symphony` repository contains a language-agnostic spec and an experimental Elixir implementation. -Caretta Symphony is not an official OpenAI project. It does not include OpenAI's reference implementation code. It implements the public Symphony spec in Python and extends it for: +Caretta Symphony is not an official OpenAI project. It does not include OpenAI's reference implementation code. It implements the public Symphony spec in Elixir and extends it for: - multi-repo product work - repo planning before dispatch @@ -73,7 +79,8 @@ Caretta Symphony is not an official OpenAI project. It does not include OpenAI's ## Install ```bash -python3 -m pip install -e ".[dev]" +mix deps.get +mix escript.build ``` ## Run @@ -89,8 +96,11 @@ tracker: terminal_states: ["Closed", "Cancelled", "Canceled", "Duplicate", "Done"] review_states: ["In Review", "Merging"] handoff_state: In Review + rework_state: Rework done_state: Done merge_base_branch: dev + blocked_escalation_enabled: true + blocked_escalation_mentions: ["@operator"] required_labels: ["codex"] mcp_command: /Applications/Codex.app/Contents/Resources/codex app-server workspace: @@ -107,6 +117,30 @@ codex: networkAccess: true server: port: 8765 +self_healing: + enabled: false + base_branch: main + branch_prefix: codex/self-heal + workspace_root: ./.symphony-self-heal + stale_poll_ms: 120000 + cooldown_ms: 900000 + max_attempts: 3 + validation_commands: + - mix format --check-formatted + - mix test + - mix escript.build + codex: + model: gpt-5.5 + effort: xhigh + approval_policy: never + thread_sandbox: workspace-write + turn_sandbox_policy: + type: workspaceWrite + networkAccess: true + restart: + tmux_session: symphony-elixir + port: 8765 + workflow_path: ./WORKFLOW.md context: coding: enabled: true @@ -152,13 +186,31 @@ Issue: {{ issue.identifier }} - {{ issue.title }} Start Symphony: ```bash -symphony WORKFLOW.md --port 8765 +./symphony WORKFLOW.md --port 8765 ``` Open `http://127.0.0.1:8765` for the dashboard. For a larger anonymized workflow, see [`WORKFLOW.linear-mcp.example.md`](WORKFLOW.linear-mcp.example.md). For macOS background operation, adapt [`launchd/com.symphony.linear-mcp.example.plist`](launchd/com.symphony.linear-mcp.example.plist). +## Self-healing watchdog + +Symphony can run a separate local watchdog when `self_healing.enabled: true` is configured. The watchdog polls `GET /api/v1/state`; when Symphony is unreachable, degraded, or stale, it runs a high-reasoning Codex repair agent in `.symphony-self-heal/worktrees/`, validates the repair, deploys the validated escript artifact locally, restarts the managed tmux session, pushes a `codex/self-heal/...` branch, opens a ready PR into `main`, and requests GitHub auto-merge without bypassing branch protection. + +Local deployment intentionally comes from the validated artifact, not from `main`. The PR is the audit and sync path back to GitHub. + +Useful commands: + +```bash +./symphony WORKFLOW.md --watchdog +./symphony WORKFLOW.md --self-heal-once --reason "manual diagnosis" +./symphony WORKFLOW.md --restart-managed +``` + +On macOS, `scripts/symphony-managed.sh` and `launchd/com.caretta.symphony.watchdog.plist` provide a launcher path that goes through `/bin/zsh -lc`, which avoids direct launchd escript startup failures. + +The managed launcher resolves the executable in this order: `SYMPHONY_EXECUTABLE`, the validated self-heal deployment at `.symphony-self-heal/deploy/current/symphony`, then the local `./symphony` build artifact. It fails before invoking escript if none of those paths is executable, so generated build artifacts are never assumed to exist after checkout or cleanup. + ## Status API When `server.port` is set, Symphony exposes: @@ -173,13 +225,14 @@ The status surface is unauthenticated. Keep `server.host` bound to `127.0.0.1` u ## Testing ```bash -python3 -m pytest +mix test ``` ## Project layout -- `symphony/` - service implementation -- `tests/` - pytest coverage +- `lib/symphony/` - service implementation +- `test/` - ExUnit coverage +- `mix.exs` - Elixir project metadata and escript configuration - `docs/IMPLEMENTATION.md` - implementation notes and conformance summary - `WORKFLOW.linear-mcp.example.md` - anonymized multi-repo Linear MCP workflow - `launchd/` - example macOS launch agent diff --git a/WORKFLOW.linear-mcp.example.md b/WORKFLOW.linear-mcp.example.md index bba5535..662bd77 100644 --- a/WORKFLOW.linear-mcp.example.md +++ b/WORKFLOW.linear-mcp.example.md @@ -6,8 +6,11 @@ tracker: terminal_states: ["Closed", "Cancelled", "Canceled", "Duplicate", "Done"] review_states: ["In Review", "Merging"] handoff_state: In Review + rework_state: Rework done_state: Done merge_base_branch: dev + blocked_escalation_enabled: true + blocked_escalation_mentions: ["@operator"] required_labels: ["codex"] mcp_command: /Applications/Codex.app/Contents/Resources/codex app-server mcp_server: codex_apps @@ -27,6 +30,31 @@ codex: networkAccess: true server: port: 8765 +self_healing: + enabled: false + base_branch: main + branch_prefix: codex/self-heal + workspace_root: ./.symphony-self-heal + stale_poll_ms: 120000 + cooldown_ms: 900000 + max_attempts: 3 + validation_commands: + - mix format --check-formatted + - mix test + - mix escript.build + codex: + command: /Applications/Codex.app/Contents/Resources/codex app-server + model: gpt-5.5 + effort: xhigh + approval_policy: never + thread_sandbox: workspace-write + turn_sandbox_policy: + type: workspaceWrite + networkAccess: true + restart: + tmux_session: symphony-elixir + port: 8765 + workflow_path: ./WORKFLOW.md context: coding: enabled: true @@ -61,12 +89,12 @@ repositories: local_path: /opt/symphony/example-repos/client/desktop-runtime remote_url: https://github.com/ExampleOrg/desktop-runtime.git aliases: ["desktop-runtime", "electron shell", "overlay", "live workflow", "local transcription", "runtime orchestrator"] - description: Desktop shell and live-session runtime; local capture, transcript batching, in-app suggestions, and host-side provider calls. + description: Desktop shell and live in-call runtime; local capture, transcript batching, in-app suggestions, and host-side provider calls. Do not choose this for saved-call history pages, post-call detail tabs, or follow-up email drafts unless the issue explicitly says desktop overlay or live runtime. - slug: ExampleOrg/web-console local_path: /opt/symphony/example-repos/product/web-console remote_url: https://github.com/ExampleOrg/web-console.git - aliases: ["web-console", "web app", "Next.js", "onboarding", "settings", "history", "calendar", "CRM", "in-app assistant"] - description: Customer-facing web console, authenticated routes, calendar/CRM settings, history views, folders, and browser-side gateway proxy. + aliases: ["web-console", "web app", "Next.js", "onboarding", "settings", "history", "history tab", "post-call", "saved call", "call details", "follow-up email", "email draft", "calendar", "CRM", "in-app assistant"] + description: Customer-facing web console, authenticated routes, calendar/CRM settings, saved-call history views, post-call detail tabs, follow-up email drafts/templates, folders, and browser-side gateway proxy. - slug: ExampleOrg/shared-contracts local_path: /opt/symphony/example-repos/libs/shared-contracts aliases: ["shared-contracts", "shared schema", "shared types", "API contracts"] @@ -157,6 +185,16 @@ Symphony only releases the issue when it leaves the configured active states. Yo - Do not post separate completion summary comments. - Final assistant message should report completed actions and blockers only. Do not ask the human to do routine follow-up work. +## Credentialed And Data Operations + +- You run under the same macOS user context as Symphony. Before declaring missing non-GitHub auth, inspect configured local auth and secret sources without printing secret values: + - `which supabase && supabase projects list` + - `which aws && aws sts get-caller-identity` + - local repo `.env*` files, Vercel env, AWS Secrets Manager/SSM names, Supabase project links, and connected MCP tools when relevant. +- Never paste secret values into Linear, PRs, terminal summaries, or final messages. Load credentials into the command environment or an untracked temporary file only when required for the operation. +- For Supabase/Postgres data migrations, a PR or migration script alone is not completion. Record dry-run output and either apply output or a concrete verified reason the data operation must not be run. +- If the issue explicitly asks to move, copy, backfill, delete, or repair production rows or cloud resources, do not move it to `In Review` just because code was written. Move it to `In Review` only after the operation has been executed and verified, or after the requester explicitly converts the issue to a code-only preparatory task. + ## State Routing - `Backlog`: out of scope. Do not modify the issue. Stop. @@ -175,20 +213,21 @@ Symphony only releases the issue when it leaves the configured active states. Yo - add or refine the implementation plan, - mirror any issue-provided validation/test-plan items as required checklist items, - record a compact environment stamp with host, absolute workspace path, and short commit SHA when available. -4. Reproduce or inspect the current behavior enough to make the fix target explicit, then implement the requested change. +4. Reproduce or inspect the current behavior enough to make the fix target explicit, then implement the requested change. For credentialed data or cloud operations, prove the access path first using configured CLIs, local env files, or secret stores, then run the required dry-run/apply or read-only verification without exposing secrets. 5. Run validation appropriate to the changed surface. Treat issue-provided validation instructions as mandatory. 6. Commit and push only the Symphony-prepared branch recorded in `.symphony-workspace.json` when changes are ready. Never push an inherited source checkout branch; if the current branch differs from the expected branch, stop and report the mismatch. Open or update the PR and attach/link the PR to the Linear issue. Prefer Linear attachments/links; use the workpad only if attachments are unavailable. 7. Before handoff, sweep existing PR feedback and checks: - address or explicitly respond to actionable comments, - confirm checks/validation are green or document a real external blocker, - refresh the workpad so plan, acceptance criteria, validation, commit, and PR status match reality. -8. Move the issue to `In Review` only after the handoff bar below is satisfied. If blocked by missing non-GitHub auth, permissions, or required tooling, document the blocker in the workpad and move to `In Review` with a concise unblock note. `Done` is reserved for Symphony's merge gate after every required PR has merged into `dev`. +8. Move the issue to `In Review` only after the handoff bar below is satisfied. If blocked by missing non-GitHub auth, permissions, or required tooling after checking the configured local CLIs/env/secret stores, document the blocker in the workpad, leave the issue active, and report the blocker in the final message. `Done` is reserved for Symphony's merge gate after every required PR has merged into `dev`. ## Handoff Bar Before `In Review` - Workpad exists and is current. - Implementation is complete for the issue scope. - Required validation/test-plan items are complete and recorded. +- For credentialed data or cloud operations, the requested operation is executed and verified, with dry-run/apply output or read-only verification recorded. A code-only helper script is not enough unless the requester explicitly asked only for a helper script. - Symphony-prepared branch is pushed and PR is linked on the issue. - PR feedback has been swept; no known actionable comments remain unaddressed. - PR checks are passing, or any failure is documented as an external blocker that cannot be resolved in-session. diff --git a/WORKFLOW.md b/WORKFLOW.md new file mode 100644 index 0000000..5fc1f1e --- /dev/null +++ b/WORKFLOW.md @@ -0,0 +1,287 @@ +--- +tracker: + kind: linear_mcp + team: Caretta + active_states: ["Todo", "In Progress", "Rework", "Merging"] + terminal_states: ["Closed", "Cancelled", "Canceled", "Duplicate", "Done"] + review_states: ["In Review", "Merging"] + handoff_state: In Review + rework_state: Rework + done_state: Done + merge_base_branch: dev + blocked_escalation_enabled: true + blocked_escalation_mentions: ["@Omar"] + required_labels: ["codex"] + mcp_command: /Applications/Codex.app/Contents/Resources/codex app-server + mcp_server: codex_apps +workspace: + root: ./.symphony-workspaces +agent: + max_concurrent_agents: 5 + max_turns: 20 + max_retry_backoff_ms: 300000 +codex: + command: /Applications/Codex.app/Contents/Resources/codex app-server + effort: medium + approval_policy: never + thread_sandbox: workspace-write + turn_sandbox_policy: + type: workspaceWrite + networkAccess: true +server: + port: 8765 +self_healing: + enabled: true + base_branch: main + branch_prefix: codex/self-heal + workspace_root: ./.symphony-self-heal + stale_poll_ms: 120000 + cooldown_ms: 900000 + max_attempts: 3 + validation_commands: + - mix format --check-formatted + - mix test + - mix escript.build + codex: + command: /Applications/Codex.app/Contents/Resources/codex app-server + model: gpt-5.5 + effort: xhigh + approval_policy: never + thread_sandbox: workspace-write + turn_sandbox_policy: + type: workspaceWrite + networkAccess: true + restart: + tmux_session: symphony-elixir + port: 8765 + workflow_path: ./WORKFLOW.md +context: + coding: + enabled: true + classifier: llm + classification_fallback: inject + classifier_effort: low + classification_timeout_ms: 120000 + skill_paths: + - /Users/omarelamin/.codex/skills/caretta-architecture + label_triggers: ["codex"] + keyword_triggers: ["implement", "fix", "bug", "feature", "integration", "UI", "API", "provider", "repo", "code", "web search", "transcription", "chat", "documents", "knowledge base"] + max_chars: 50000 +dashboard: + summaries: + enabled: true + update_interval_ms: 30000 + timeout_ms: 120000 + max_events: 60 + max_chars: 14000 + effort: low +repositories: + enabled: true + planner: llm + fallback: rules + block_on_needs_human: true + quarantine_on_mismatch: true + clone_timeout_ms: 300000 + base_branch: main + branch_prefix: Symphony + known: + - slug: CarettaAI/Project-N + local_path: /Users/omarelamin/Documents/Caretta/repos/caretta-app/Project-N + remote_url: https://github.com/CarettaAI/Project-N.git + base_branch: dev + aliases: ["Project-N", "desktop", "electron", "smart notch", "overlay", "local transcription", "project-n-lambdas"] + description: Desktop app and live in-call runtime; Electron shell, meeting detection, local capture, transcription hooks, CRM bridge, package work, and desktop packaging. Do not choose this for saved-call history pages, post-call detail tabs, or follow-up email drafts unless the issue explicitly says desktop overlay or live runtime. + - slug: CarettaAI/caretta-webapp + local_path: /Users/omarelamin/Documents/Caretta/repos/caretta-app/caretta-webapp + remote_url: https://github.com/CarettaAI/caretta-webapp.git + base_branch: dev + aliases: ["caretta-webapp", "webapp", "web app", "Next.js", "onboarding", "settings", "history", "history tab", "post-call", "saved call", "call details", "follow-up email", "email draft", "calendar", "CRM"] + description: Customer-facing web app, authenticated routes, product UI, onboarding, settings, saved-call history, post-call detail tabs, follow-up email drafts/templates, calendar, CRM, and browser API routes. + - slug: CarettaAI/caretta-app-shared + local_path: /Users/omarelamin/Documents/Caretta/repos/caretta-app/caretta-app-shared + base_branch: dev + aliases: ["caretta-app-shared", "shared contracts", "shared types", "schemas"] + description: Shared contracts, schemas, helpers, and app types used by desktop and web surfaces. + - slug: CarettaAI/caretta-slack + local_path: /Users/omarelamin/Documents/Caretta/repos/caretta-slack + remote_url: https://github.com/CarettaAI/caretta-slack.git + base_branch: dev + aliases: ["caretta-slack", "Slack", "Telegram", "messaging", "home tab", "bot", "OAuth"] + description: Slack, Telegram, messaging integration lambdas, install flows, bot behavior, and integration infrastructure. + - slug: CarettaAI/chat-engine + local_path: /Users/omarelamin/Documents/Caretta/repos/chat-engine + remote_url: https://github.com/CarettaAI/chat-engine.git + base_branch: dev + aliases: ["chat-engine", "chat runtime", "assistant", "automations", "async agent"] + description: Chat runtime, assistant behavior, async agents, automations, prompt composition, and assistant routes. + - slug: CarettaAI/kb-service + local_path: /Users/omarelamin/Documents/Caretta/repos/kb-service + remote_url: https://github.com/CarettaAI/kb-service.git + base_branch: dev + aliases: ["kb-service", "knowledge service", "knowledge ingestion", "vector sync", "RAG"] + description: Knowledge ingestion, retrieval, vector sync, transcript FAQ extraction, and organization knowledge upserts. + - slug: CarettaAI/doc-to-context + local_path: /Users/omarelamin/Documents/Caretta/repos/doc-to-context + remote_url: https://github.com/CarettaAI/doc-to-context.git + base_branch: dev + aliases: ["doc-to-context", "documents", "document packs", "splitting", "context"] + description: Document conversion, splitting, extraction, context packaging, and knowledge-base tooling. + - slug: CarettaAI/llm-gateway-tensorzero + local_path: /Users/omarelamin/Documents/Caretta/repos/llm-gateway-tensorzero + remote_url: https://github.com/CarettaAI/llm-gateway-tensorzero.git + base_branch: dev + aliases: ["llm-gateway-tensorzero", "llm-gateway", "LLM gateway", "TensorZero", "model gateway", "prompts", "schemas", "gateway"] + description: TensorZero-backed model gateway configuration, prompt schemas, provider routing, experiments, and infrastructure. + - slug: CarettaAI/asr-service + local_path: /Users/omarelamin/Documents/Caretta/repos/asr-service + remote_url: https://github.com/CarettaAI/asr-service.git + base_branch: dev + aliases: ["asr-service", "ASR", "speech recognition", "hosted recognition"] + description: Hosted speech recognition and ASR service behavior. + - slug: CarettaAI/caretta-dashboard + local_path: /Users/omarelamin/Documents/Caretta/repos/caretta-dashboard + remote_url: https://github.com/CarettaAI/caretta-dashboard.git + base_branch: dev + aliases: ["caretta-dashboard", "dashboard", "reporting", "admin"] + description: Internal dashboard, reporting, and operational product surfaces. + - slug: CarettaAI/caretta-metrics + local_path: /Users/omarelamin/Documents/Caretta/repos/caretta-metrics + remote_url: https://github.com/CarettaAI/caretta-metrics.git + base_branch: dev + aliases: ["caretta-metrics", "metrics", "QA sampling"] + description: Svelte metrics app for explicit metrics UI and QA sampling work. + - slug: CarettaAI/yc-launch-lp + local_path: /Users/omarelamin/Documents/Caretta/repos/yc-launch-lp + remote_url: https://github.com/CarettaAI/yc-launch-lp.git + base_branch: dev + aliases: ["yc-launch-lp", "YC launch", "landing page"] + description: YC launch and marketing landing page work. + - slug: RubyBit/aec3-rs + local_path: /Users/omarelamin/Documents/Caretta/repos/aec3-rs + remote_url: https://github.com/RubyBit/aec3-rs.git + aliases: ["aec3-rs", "AEC", "audio echo cancellation"] + description: Audio echo cancellation library work when explicitly named. + - slug: TheBoredTeam/boring.notch + local_path: /Users/omarelamin/Documents/Caretta/repos/boring.notch + remote_url: https://github.com/TheBoredTeam/boring.notch.git + base_branch: dev + aliases: ["boring.notch", "notch"] + description: Boring Notch app work when explicitly named. + - slug: openclaw/openclaw + local_path: /Users/omarelamin/Documents/Caretta/repos/openclaw + remote_url: https://github.com/openclaw/openclaw.git + aliases: ["openclaw"] + description: OpenClaw work when explicitly named. + - slug: tensorzero/tensorzero + local_path: /Users/omarelamin/Documents/Caretta/repos/tensorzero + remote_url: https://github.com/tensorzero/tensorzero.git + aliases: ["tensorzero"] + description: TensorZero upstream work when explicitly named. +--- +You are working on a Linear issue in an unattended Symphony run. + +Issue: {{ issue.identifier }} - {{ issue.title }} +URL: {{ issue.url }} +Priority: {{ issue.priority }} +State: {{ issue.state }} +Attempt: {{ attempt }} + +Continuation context: + +- If `Attempt` is populated, this is a retry/continuation because the issue was still in an active state. +- Resume from the current workspace and Linear workpad state. Do not restart from scratch. +- Do not end the turn while the issue is still `Todo`, `In Progress`, `Rework`, or `Merging` unless a true external blocker remains. + +Description: +{{ issue.description }} + +## Delivery Contract + +Symphony only releases the issue when it leaves the configured active states. Your work is not delivered until you update Linear and move the issue to the correct handoff state. + +- Use the Linear MCP connector/tools available in Codex to read and update the issue. +- If a Linear MCP write asks for approval, approve it for the session and continue. +- Keep exactly one persistent Linear comment headed `## Codex Workpad`; create it if missing and update that same comment in place. +- Use the workpad for plan, acceptance criteria, validation results, PR/commit status, blockers, and final handoff notes. +- Do not post separate completion summary comments. +- Final assistant message should report completed actions and blockers only. Do not ask the human to do routine follow-up work. + +## Credentialed And Data Operations + +- You run under the same macOS user context as Symphony. Before declaring missing non-GitHub auth, inspect configured local auth and secret sources without printing secret values: + - `which supabase && supabase projects list` + - `which aws && aws sts get-caller-identity` + - local repo `.env*` files, Vercel env, AWS Secrets Manager/SSM names, Supabase project links, and connected MCP tools when relevant. +- Never paste secret values into Linear, PRs, terminal summaries, or final messages. Load credentials into the command environment or an untracked temporary file only when required for the operation. +- For Supabase/Postgres data migrations, a PR or migration script alone is not completion. Record dry-run output and either apply output or a concrete verified reason the data operation must not be run. +- If the issue explicitly asks to move, copy, backfill, delete, or repair production rows or cloud resources, do not move it to `In Review` just because code was written. Move it to `In Review` only after the operation has been executed and verified, or after the requester explicitly converts the issue to a code-only preparatory task. + +## State Routing + +- `Backlog`: out of scope. Do not modify the issue. Stop. +- `Todo`: immediately move the issue to `In Progress`, create/update the workpad, then execute the task. +- `In Progress`: continue from the existing workpad and workspace. +- `Rework`: read all issue and PR feedback, update the workpad with the rework plan, address feedback, revalidate, push, and return to `In Review`. +- `Merging`: follow the repository's merge/land instructions. After the PR is merged, update the workpad; Symphony's merge gate moves the issue to `Done`. +- `Done`: terminal. Do nothing. + +## Execution Flow + +1. Fetch the current Linear issue by `{{ issue.identifier }}` and confirm its state, labels, description, comments, and links. +2. Follow the injected Symphony repo plan. Start in the primary repo, use secondary repos only when the plan allows it, and do not edit read-only context repos. +3. Reconcile the `## Codex Workpad` before editing code: + - check off completed work, + - add or refine the implementation plan, + - mirror any issue-provided validation/test-plan items as required checklist items, + - record a compact environment stamp with host, absolute workspace path, and short commit SHA when available. +4. Reproduce or inspect the current behavior enough to make the fix target explicit, then implement the requested change. For credentialed data or cloud operations, prove the access path first using configured CLIs, local env files, or secret stores, then run the required dry-run/apply or read-only verification without exposing secrets. +5. Run validation appropriate to the changed surface. Treat issue-provided validation instructions as mandatory. +6. Commit and push only the Symphony-prepared branch recorded in `.symphony-workspace.json` when changes are ready. Never push an inherited source checkout branch; if the current branch differs from the expected branch, stop and report the mismatch. Open or update the PR and attach/link the PR to the Linear issue. Prefer Linear attachments/links; use the workpad only if attachments are unavailable. +7. Before handoff, sweep existing PR feedback and checks: + - address or explicitly respond to actionable comments, + - confirm checks/validation are green or document a real external blocker, + - refresh the workpad so plan, acceptance criteria, validation, commit, and PR status match reality. +8. Move the issue to `In Review` only after the handoff bar below is satisfied. If blocked by missing non-GitHub auth, permissions, or required tooling after checking the configured local CLIs/env/secret stores, document the blocker in the workpad, leave the issue active, and report the blocker in the final message. `Done` is reserved for Symphony's merge gate after every required PR has merged into `dev`. + +## Handoff Bar Before `In Review` + +- Workpad exists and is current. +- Implementation is complete for the issue scope. +- Required validation/test-plan items are complete and recorded. +- For credentialed data or cloud operations, the requested operation is executed and verified, with dry-run/apply output or read-only verification recorded. A code-only helper script is not enough unless the requester explicitly asked only for a helper script. +- Symphony-prepared branch is pushed and PR is linked on the issue. +- PR feedback has been swept; no known actionable comments remain unaddressed. +- PR checks are passing, or any failure is documented as an external blocker that cannot be resolved in-session. + +## Workpad Template + +Keep this structure and edit it in place: + +````md +## Codex Workpad + +```text +:@ +``` + +### Plan + +- [ ] 1. Parent task + - [ ] 1.1 Child task + +### Acceptance Criteria + +- [ ] Criterion + +### Validation + +- [ ] `` - result + +### Notes + +- + +### Confusions + +- +```` diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index f3e382b..a70b241 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -11,23 +11,32 @@ Implemented: - Linear reader operations for candidates, terminal-state cleanup, and running-state reconciliation - OAuth-backed Linear MCP reader extension via Codex app-server (`tracker.kind: linear_mcp`) - review-state reconciliation that moves Linear issues to `Done` only after required GitHub PRs are merged into the configured base branch +- review-state feedback polling that moves issues to `tracker.rework_state` when new human Linear comments or GitHub PR feedback appear +- blocked issue escalation comments that mention the Linear assignee or `tracker.blocked_escalation_mentions`, then release the block after a human response - required label gating via `tracker.required_labels` - deterministic sanitized workspaces below `workspace.root` - workspace hooks with timeout handling and spec-defined fatal/best-effort behavior -- strict Liquid prompt rendering with `issue` and `attempt` -- Codex app-server subprocess integration using JSON-RPC JSONL over stdio +- strict prompt variable rendering with `issue` and `attempt` +- Codex app-server subprocess integration using JSON-RPC JSONL over stdio from Elixir ports - continuation turns on the same thread during a worker lifetime - retry queue with continuation retries and exponential failure backoff - reconciliation that cancels terminal/non-active/stalled runs - startup terminal workspace cleanup - structured key=value logging - optional HTTP status and refresh API +- self-healing watchdog CLI that detects unreachable, degraded, or stale local Symphony state +- isolated self-heal worktrees under `.symphony-self-heal/worktrees/` +- high-reasoning Codex repair prompt with validation, local artifact deployment, tmux restart, PR creation, and best-effort auto-merge Implementation-defined choices: - existing non-directory workspace paths fail safely - secrets are validated by presence only and are not logged -- no durable database is used; retry/running state is in-memory +- no durable database is used; retry/running state is in-memory with a JSON runtime snapshot under the workspace root +- review feedback fingerprints are persisted in the JSON runtime snapshot so existing review feedback is baselined across restarts +- local self-heal deployment uses the validated artifact immediately; merging to `main` is the audit/sync path and is not required before local restart +- self-heal worktrees are based on the current local checkout state, including local commits and the current working-tree patch, so the managed local runtime can be ahead of `main` +- self-heal never direct-pushes to `main` and never requests an admin merge; branch protection can leave the PR open as the expected blocker - `tracker.kind: linear_mcp` uses Codex app-server's `mcpServer/tool/call` gateway and the configured `tracker.mcp_server`/`tracker.mcp_command` - `linear_mcp` uses the issue identifier as the internal issue ID because the Linear connector list output does not expose the Linear GraphQL UUID - `linear_graphql` dynamic tool calls are handled if the agent app-server asks for them, but dynamic tool advertisement is not enabled because the generated schema for the installed Codex app-server version does not expose `dynamicTools` on `thread/start` diff --git a/launchd/com.caretta.symphony.local.plist b/launchd/com.caretta.symphony.local.plist new file mode 100644 index 0000000..00d0132 --- /dev/null +++ b/launchd/com.caretta.symphony.local.plist @@ -0,0 +1,37 @@ + + + + + Label + com.caretta.symphony.local + + WorkingDirectory + /Users/omarelamin/Documents/Codex/2026-04-28/implement-symphony-according-to-the-following + + ProgramArguments + + /bin/zsh + -lc + cd /Users/omarelamin/Documents/Codex/2026-04-28/implement-symphony-according-to-the-following && exec ./scripts/symphony-managed.sh run + + + RunAtLoad + + + KeepAlive + + + EnvironmentVariables + + PATH + /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin + + + StandardOutPath + /var/tmp/caretta-symphony.out.log + + StandardErrorPath + /var/tmp/caretta-symphony.err.log + + diff --git a/launchd/com.caretta.symphony.watchdog.plist b/launchd/com.caretta.symphony.watchdog.plist new file mode 100644 index 0000000..c7cffb3 --- /dev/null +++ b/launchd/com.caretta.symphony.watchdog.plist @@ -0,0 +1,37 @@ + + + + + Label + com.caretta.symphony.watchdog + + WorkingDirectory + /Users/omarelamin/Documents/Codex/2026-04-28/implement-symphony-according-to-the-following + + ProgramArguments + + /bin/zsh + -lc + cd /Users/omarelamin/Documents/Codex/2026-04-28/implement-symphony-according-to-the-following && exec ./scripts/symphony-managed.sh watchdog + + + RunAtLoad + + + KeepAlive + + + EnvironmentVariables + + PATH + /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin + + + StandardOutPath + /var/tmp/caretta-symphony-watchdog.out.log + + StandardErrorPath + /var/tmp/caretta-symphony-watchdog.err.log + + diff --git a/launchd/com.symphony.linear-mcp.example.plist b/launchd/com.symphony.linear-mcp.example.plist index 00d2358..a58b580 100644 --- a/launchd/com.symphony.linear-mcp.example.plist +++ b/launchd/com.symphony.linear-mcp.example.plist @@ -11,9 +11,7 @@ ProgramArguments - /usr/local/bin/python3 - -m - symphony + /opt/symphony/runner/symphony /opt/symphony/config/WORKFLOW.linear-mcp.example.md diff --git a/lib/symphony.ex b/lib/symphony.ex new file mode 100644 index 0000000..36eb185 --- /dev/null +++ b/lib/symphony.ex @@ -0,0 +1,14 @@ +defmodule Symphony do + @moduledoc """ + Caretta Symphony orchestrates Codex app-server workers from Linear issues. + + This Elixir implementation keeps the same service boundaries as the previous + implementation: workflow/config parsing, Linear adapters, workspace + preparation, Codex JSONL sessions, review reconciliation, and a small status + server. + """ + + @version "0.1.0" + + def version, do: @version +end diff --git a/lib/symphony/agent_runner.ex b/lib/symphony/agent_runner.ex new file mode 100644 index 0000000..db09886 --- /dev/null +++ b/lib/symphony/agent_runner.ex @@ -0,0 +1,509 @@ +defmodule Symphony.AgentRunner do + @moduledoc false + + alias Symphony.CodexClient + alias Symphony.CodingContext + alias Symphony.Config.ConfigManager + alias Symphony.Config.TrackerConfig + alias Symphony.Models.RepoPlan + alias Symphony.Models.Issue + alias Symphony.RepoPlanner + alias Symphony.Templating + alias Symphony.Utils + alias Symphony.Workspace.Manager, as: WorkspaceManager + + defmodule AgentRunResult do + defstruct issue_id: nil, + issue_identifier: nil, + normal: false, + reason: "normal", + retryable: true, + blocked: false, + workspace_path: nil, + repo_plan: nil + end + + def agent_reported_linear_delivery_blocker?(text) do + normalized = String.downcase(text || "") + + blocker = + Enum.any?( + [ + "rejected", + "could not update", + "can't update", + "couldn't update", + "could not create", + "can't create", + "couldn't create" + ], + &String.contains?(normalized, &1) + ) + + completed = + Enum.any?( + [ + "completed:", + "completed actions", + "validation passed", + "validation previously completed", + "pr is open", + "pr #", + "pull request", + "branch clean", + "committed and pushed", + "already committed and pushed" + ], + &String.contains?(normalized, &1) + ) + + String.contains?(normalized, "linear") and blocker and completed and + not agent_reported_incomplete_or_blocked?(text) + end + + def agent_reported_incomplete_or_blocked?(text) do + normalized = String.downcase(text || "") + + direct_negative = + Enum.any?( + [ + "blocked; no code changed", + "blocked; no files changed", + "no code changed", + "no files changed", + "no implementation changes were possible", + "no implementation changes were made", + "no validation was run because no implementation", + "could not implement", + "couldn't implement", + "unable to implement" + ], + &String.contains?(normalized, &1) + ) + + blocked_by_guardrail = + String.contains?(normalized, "blocked") and + Enum.any?( + [ + "edit_allowed: false", + "read-only", + "read only", + "guardrail", + "wrong repo", + "repo plan", + "does not contain the target ui", + "does not contain the relevant ui", + "does not contain the target surface" + ], + &String.contains?(normalized, &1) + ) + + direct_negative or blocked_by_guardrail + end + + def agent_reported_unresolved_external_blocker?(text) do + normalized = String.downcase(text || "") + + linear_delivery_only? = + agent_reported_linear_delivery_blocker?(text) and + String.contains?(normalized, "linear") and + Enum.any?( + [ + "workpad", + "state", + "moving", + "move", + "in review", + "comment" + ], + &String.contains?(normalized, &1) + ) and + not Enum.any?( + [ + "data migration was not run", + "migration was not run", + "not applied", + "not executed", + "dry-run only", + "production data", + "supabase", + "postgres", + "database" + ], + &String.contains?(normalized, &1) + ) + + (not linear_delivery_only? and + Enum.any?( + [ + "missing postgres url", + "missing database url", + "missing db url", + "missing database_url", + "missing credentials", + "missing credential", + "no production credentials", + "credentials are missing", + "not applied", + "not executed", + "data migration was not run", + "migration was not run", + "dry-run only", + "operator must run", + "someone must run" + ], + &String.contains?(normalized, &1) + )) or + (String.contains?(normalized, "blocked") and + Enum.any?( + [ + "credential", + "credentials", + "supabase", + "postgres", + "database", + "aws", + "permission", + "permissions" + ], + &String.contains?(normalized, &1) + )) or + (String.contains?(normalized, "requires") and + Enum.any?( + [ + "privileged postgres", + "production credential", + "database credential", + "supabase_transaction_pooler_url", + "supabase_db_url", + "service role" + ], + &String.contains?(normalized, &1) + )) + end + + def existing_workpad_comment_id(comments) do + Enum.find_value(comments, fn comment -> + body = + comment["body"] || comment[:body] || comment["text"] || comment[:text] || + comment["content"] || comment[:content] || "" + + if is_binary(body) and String.contains?(body, "## Codex Workpad") do + id = comment["id"] || comment[:id] + if id, do: to_string(id) + end + end) + end + + def fallback_workpad_body(%Issue{} = issue, agent_message_text, workspace_path) do + timestamp = Utils.isoformat_z(Utils.now_utc()) + summary = agent_message_text |> to_string() |> String.trim() |> Utils.truncate(5000) + + """ + ## Codex Workpad + + ```text + #{workspace_path} + ``` + + ### Plan + + - [x] Agent completed implementation work. + - [x] Agent attempted Linear workpad/state handoff. + - [x] Symphony applied tracker-owned delivery fallback after the in-agent Linear write was rejected. + + ### Acceptance Criteria + + - [x] #{issue.identifier} final agent handoff captured below. + + ### Validation + + - [x] See final agent handoff below. + + ### Notes + + - #{timestamp}: Symphony fallback created this workpad because the agent reported that Linear MCP writes were rejected inside the Codex turn. + + #### Final Agent Handoff + + ```text + #{summary} + ``` + + ### Confusions + + - In-agent Linear MCP writes were rejected; Symphony used tracker-owned Linear MCP writes for delivery. + """ + end + + def new(config_manager, tracker), do: %{config_manager: config_manager, tracker: tracker} + + def run_issue( + %{config_manager: config_manager, tracker: tracker}, + %Issue{} = issue, + attempt, + on_event + ) do + {_manager, workflow, config} = ConfigManager.current(config_manager) + workspace_manager = WorkspaceManager.new(config.workspace, config.hooks) + workspace = WorkspaceManager.create_for_issue(workspace_manager, issue.identifier) + + try do + emit = fn event -> on_event.(issue.id, event) end + first_prompt = Templating.render_prompt(workflow.prompt_template, issue, attempt) + + classification = + CodingContext.classify_coding_issue(issue, config.context.coding, + codex_config: config.codex, + workspace_path: workspace.path + ) + + repo_plan = + RepoPlanner.plan_repositories( + issue, + config.repositories, + config.context.coding, + classification, + codex_config: config.codex, + workspace_path: workspace.path + ) + + workspace = + if repo_plan do + emit.(%{ + "event" => "repo_plan_created", + "repo_plan" => RepoPlan.to_map(repo_plan), + "repo_plan_needs_human" => repo_plan.needs_human, + "repo_plan_human_reason" => repo_plan.human_reason + }) + + if repo_plan.needs_human and config.repositories.block_on_needs_human do + throw({:blocked, repo_plan}) + end + + prepared = + WorkspaceManager.materialize_repo_plan( + workspace_manager, + workspace, + repo_plan, + config.repositories + ) + + emit.(%{ + "event" => "repo_workspace_prepared", + "repo_plan" => RepoPlan.to_map(repo_plan), + "workspace_path" => prepared.path, + "primary_repo_path" => prepared.primary_repo_path + }) + + prepared + else + workspace + end + + WorkspaceManager.before_run(workspace_manager, workspace.path) + + first_prompt = + first_prompt + |> CodingContext.augment_prompt_with_coding_context(issue, config.context.coding, + codex_config: config.codex, + workspace_path: workspace.path, + classification: classification, + on_event: emit + ) + |> RepoPlanner.apply_repo_plan_to_prompt(repo_plan, workspace.path) + + session = + CodexClient.start_session(config.codex, workspace.path, + tracker_config: config.tracker, + on_event: emit + ) + + try do + run_turn_loop(session, tracker, issue, first_prompt, config, workspace.path, repo_plan) + after + CodexClient.stop_session(session) + end + rescue + error in Symphony.Error -> + %AgentRunResult{ + issue_id: issue.id, + issue_identifier: issue.identifier, + normal: false, + reason: to_string(error.code), + workspace_path: workspace.path + } + + _error -> + %AgentRunResult{ + issue_id: issue.id, + issue_identifier: issue.identifier, + normal: false, + reason: "unhandled_agent_error", + workspace_path: workspace.path + } + catch + {:blocked, %RepoPlan{} = repo_plan} -> + %AgentRunResult{ + issue_id: issue.id, + issue_identifier: issue.identifier, + normal: false, + reason: "repo_plan_needs_human", + retryable: false, + blocked: true, + workspace_path: workspace.path, + repo_plan: repo_plan + } + after + WorkspaceManager.after_run(workspace_manager, workspace.path) + end + end + + defp run_turn_loop(session, tracker, issue, first_prompt, config, workspace_path, repo_plan) do + Enum.reduce_while(1..config.agent.max_turns, {issue, session}, fn turn_number, + {current_issue, session} -> + prompt = + if turn_number == 1 do + first_prompt + else + Templating.continuation_prompt(current_issue, turn_number, config.agent.max_turns) + end + + {turn_result, session} = CodexClient.run_turn(session, prompt, capture_agent_text: true) + refreshed = call_tracker(tracker, :fetch_issue_states_by_ids, [[issue.id]]) + current_issue = List.first(refreshed) || current_issue + + {current_issue, state} = + if MapSet.member?( + TrackerConfig.active_state_set(config.tracker), + Utils.normalize_state(current_issue.state) + ) and + try_delivery_fallback(tracker, current_issue, turn_result.agent_message_text, + workspace_path: workspace_path, + handoff_state: config.tracker.handoff_state + ) do + {%{current_issue | state: config.tracker.handoff_state}, + Utils.normalize_state(config.tracker.handoff_state)} + else + {current_issue, Utils.normalize_state(current_issue.state)} + end + + cond do + agent_reported_incomplete_or_blocked?(turn_result.agent_message_text) -> + {:halt, + %AgentRunResult{ + issue_id: issue.id, + issue_identifier: issue.identifier, + normal: false, + reason: "agent_reported_blocked_or_incomplete", + retryable: false, + blocked: true, + workspace_path: workspace_path, + repo_plan: repo_plan + }} + + agent_reported_unresolved_external_blocker?(turn_result.agent_message_text) -> + {:halt, + %AgentRunResult{ + issue_id: issue.id, + issue_identifier: issue.identifier, + normal: false, + reason: "unresolved_external_blocker", + retryable: false, + blocked: true, + workspace_path: workspace_path, + repo_plan: repo_plan + }} + + !MapSet.member?(TrackerConfig.active_state_set(config.tracker), state) -> + {:halt, + %AgentRunResult{ + issue_id: issue.id, + issue_identifier: issue.identifier, + normal: true, + reason: "issue_left_active_state", + retryable: false, + workspace_path: workspace_path, + repo_plan: repo_plan + }} + + turn_number >= config.agent.max_turns -> + {:halt, + %AgentRunResult{ + issue_id: issue.id, + issue_identifier: issue.identifier, + normal: true, + reason: "max_turns_reached", + retryable: true, + workspace_path: workspace_path, + repo_plan: repo_plan + }} + + true -> + {:cont, {current_issue, session}} + end + end) + |> case do + {%Issue{}, _session} -> + %AgentRunResult{ + issue_id: issue.id, + issue_identifier: issue.identifier, + normal: true, + workspace_path: workspace_path, + repo_plan: repo_plan + } + + %AgentRunResult{} = result -> + result + end + end + + def try_delivery_fallback(tracker, %Issue{} = issue, agent_message_text, opts) do + if agent_reported_linear_delivery_blocker?(agent_message_text) and + not agent_reported_unresolved_external_blocker?(agent_message_text) and + tracker_supports_writes?(tracker) do + comments = call_tracker(tracker, :list_issue_comments, [issue.identifier]) + comment_id = existing_workpad_comment_id(comments) + + body = + fallback_workpad_body(issue, agent_message_text, Keyword.fetch!(opts, :workspace_path)) + + call_tracker(tracker, :save_issue_comment, [ + issue.identifier, + body, + [comment_id: comment_id] + ]) + + call_tracker(tracker, :save_issue_state, [ + issue.identifier, + Keyword.fetch!(opts, :handoff_state) + ]) + + true + else + false + end + rescue + _ -> false + end + + defp tracker_supports_writes?(%{__struct__: _module}), do: true + + defp tracker_supports_writes?(tracker) when is_map(tracker) do + Enum.all?( + [:list_issue_comments, :save_issue_comment, :save_issue_state], + &Map.has_key?(tracker, &1) + ) + end + + defp tracker_supports_writes?(_), do: true + + defp call_tracker(%{__struct__: module} = tracker, function, args), + do: apply(module, function, [tracker | args]) + + defp call_tracker(tracker, function, args) when is_atom(tracker), + do: apply(tracker, function, args) + + defp call_tracker(tracker, function, args) when is_map(tracker), + do: apply(Map.fetch!(tracker, function), args) +end diff --git a/lib/symphony/cli.ex b/lib/symphony/cli.ex new file mode 100644 index 0000000..0685a19 --- /dev/null +++ b/lib/symphony/cli.ex @@ -0,0 +1,137 @@ +defmodule Symphony.CLI do + @moduledoc false + + alias Symphony.Config.ConfigManager + alias Symphony.HTTPServer + alias Symphony.Orchestrator + alias Symphony.SelfHeal + alias Symphony.Watchdog + + def main(argv) do + argv + |> run() + |> System.halt() + end + + def run(argv) do + {opts, args, _invalid} = + OptionParser.parse(argv, + switches: [ + port: :integer, + log_level: :string, + once: :boolean, + watchdog: :boolean, + self_heal_once: :boolean, + restart_managed: :boolean, + reason: :string, + help: :boolean + ], + aliases: [p: :port, h: :help] + ) + + workflow_path = List.first(args) + + if Keyword.get(opts, :help, false) do + IO.puts(usage()) + 0 + else + try do + manager = ConfigManager.new(workflow_path) + {manager, _workflow, config} = ConfigManager.load_startup(manager) + port = Keyword.get(opts, :port) || config.server.port + + cond do + Keyword.get(opts, :watchdog, false) -> + Watchdog.run(manager) + + Keyword.get(opts, :self_heal_once, false) -> + result = + SelfHeal.run_once(config, + reason: Keyword.get(opts, :reason) || "manual self-heal", + force: true + ) + + IO.puts(Jason.encode!(SelfHeal.result_to_map(result), pretty: true)) + if result.status == :ok, do: 0, else: 1 + + Keyword.get(opts, :restart_managed, false) -> + case SelfHeal.restart_managed(config) do + {:ok, results} -> + IO.puts( + Jason.encode!(%{"status" => "ok", "commands" => command_results(results)}, + pretty: true + ) + ) + + 0 + + {:error, results} -> + IO.puts( + Jason.encode!(%{"status" => "error", "commands" => command_results(results)}, + pretty: true + ) + ) + + 1 + end + + Keyword.get(opts, :once, false) -> + orchestrator = + manager + |> Orchestrator.new() + |> Orchestrator.startup_terminal_workspace_cleanup() + |> Orchestrator.tick() + + server = + if port do + HTTPServer.start(orchestrator, host: config.server.host, port: port) + end + + if server, do: HTTPServer.stop(server) + 0 + + true -> + {:ok, orchestrator} = Orchestrator.start_link(manager) + + server = + if port do + HTTPServer.start(orchestrator, host: config.server.host, port: port) + end + + IO.puts( + "Symphony running#{if server, do: " on http://#{server.host}:#{server.bound_port}", else: ""}. Press Ctrl+C to stop." + ) + + Process.sleep(:infinity) + 0 + end + rescue + error in Symphony.Error -> + IO.puts(:stderr, Exception.message(error)) + 1 + end + end + end + + defp command_results(results) do + Enum.map(results, fn result -> + %{ + "command" => result.command, + "status" => result.status, + "output" => result.output + } + end) + end + + defp usage do + """ + Usage: + symphony [WORKFLOW.md] [--port PORT] + symphony [WORKFLOW.md] --once + symphony [WORKFLOW.md] --watchdog + symphony [WORKFLOW.md] --self-heal-once --reason "reason" + symphony [WORKFLOW.md] --restart-managed + """ + |> String.trim() + end +end diff --git a/lib/symphony/codex_client.ex b/lib/symphony/codex_client.ex new file mode 100644 index 0000000..9e5a015 --- /dev/null +++ b/lib/symphony/codex_client.ex @@ -0,0 +1,635 @@ +defmodule Symphony.CodexClient do + @moduledoc false + + alias Symphony.Config.{CodexConfig, TrackerConfig} + alias Symphony.Error + alias Symphony.Tracker.LinearClient + alias Symphony.Utils + + defmodule TurnResult do + defstruct thread_id: nil, turn_id: nil, status: nil, agent_message_text: "" + end + + defmodule Session do + defstruct [ + :config, + :workspace_path, + :tracker_config, + :on_event, + :port, + :os_pid, + :thread_id, + next_id: 1, + buffer: "", + agent_text_capture: nil + ] + end + + def start_session(%CodexConfig{} = config, workspace_path, opts \\ []) do + workspace_path = Path.expand(workspace_path) + + unless File.dir?(workspace_path) do + raise Error, + code: :invalid_workspace_cwd, + message: "workspace cwd does not exist: #{workspace_path}" + end + + port = open_command_port(config.command, workspace_path) + os_pid = port_os_pid(port) + + session = %Session{ + config: config, + workspace_path: workspace_path, + tracker_config: Keyword.get(opts, :tracker_config), + on_event: Keyword.get(opts, :on_event, fn _event -> :ok end), + port: port, + os_pid: os_pid + } + + emit(session, %{"event" => "app_server_started", "codex_app_server_pid" => to_string(os_pid)}) + + try do + {_, session} = + request( + session, + "initialize", + %{ + "clientInfo" => %{ + "name" => "symphony_runner", + "title" => "Symphony Runner", + "version" => Symphony.version() + }, + "capabilities" => %{"experimentalApi" => true} + }, + config.read_timeout_ms + ) + + session = notify(session, "initialized", %{}) + + thread_params = + %{ + "cwd" => workspace_path, + "approvalPolicy" => config.approval_policy, + "sandbox" => config.thread_sandbox, + "serviceName" => "symphony_runner", + "sessionStartSource" => "startup" + } + |> put_if("model", config.model) + |> put_if("personality", config.personality) + + {response, session} = + request(session, "thread/start", thread_params, config.read_timeout_ms) + + thread_id = get_in(response, ["thread", "id"]) + + unless thread_id do + raise Error, + code: :response_error, + message: "thread/start response did not include thread.id" + end + + %{session | thread_id: to_string(thread_id)} + rescue + error -> + stop_session(session) + reraise error, __STACKTRACE__ + end + end + + def stop_session(nil), do: :ok + + def stop_session(%Session{port: nil}), do: :ok + + def stop_session(%Session{} = session) do + if Port.info(session.port) do + Port.close(session.port) + end + + kill_os_pid(session.os_pid) + + emit(session, %{ + "event" => "app_server_stopped", + "codex_app_server_pid" => to_string(session.os_pid), + "returncode" => nil + }) + + :ok + catch + _, _ -> :ok + end + + defp kill_os_pid(nil), do: :ok + + defp kill_os_pid(pid) do + pid = to_string(pid) + System.cmd("kill", ["-TERM", pid], stderr_to_stdout: true) + Process.sleep(100) + {_out, status} = System.cmd("ps", ["-p", pid], stderr_to_stdout: true) + if status == 0, do: System.cmd("kill", ["-KILL", pid], stderr_to_stdout: true) + :ok + rescue + _ -> :ok + end + + def run_turn(%Session{thread_id: nil}) do + raise Error, code: :response_error, message: "thread has not been started" + end + + def run_turn(%Session{} = session, prompt, opts \\ []) do + config = session.config + + params = + %{ + "threadId" => session.thread_id, + "input" => [%{"type" => "text", "text" => prompt}], + "cwd" => session.workspace_path, + "approvalPolicy" => config.approval_policy, + "sandboxPolicy" => turn_sandbox_policy(session) + } + |> put_if("model", config.model) + |> put_if("effort", config.effort) + |> put_if("summary", config.summary) + |> put_if("personality", config.personality) + + {response, session} = request(session, "turn/start", params, config.read_timeout_ms) + turn_id = get_in(response, ["turn", "id"]) + + unless turn_id do + raise Error, code: :response_error, message: "turn/start response did not include turn.id" + end + + session_id = "#{session.thread_id}-#{turn_id}" + + emit(session, %{ + "event" => "session_started", + "thread_id" => session.thread_id, + "turn_id" => to_string(turn_id), + "session_id" => session_id, + "codex_app_server_pid" => to_string(session.os_pid) + }) + + capture? = Keyword.get(opts, :capture_agent_text, false) + session = %{session | agent_text_capture: if(capture?, do: [], else: nil)} + {status, session} = wait_for_turn(session, to_string(turn_id)) + agent_text = Enum.join(session.agent_text_capture || [], "") + + {%TurnResult{ + thread_id: session.thread_id, + turn_id: to_string(turn_id), + status: status, + agent_message_text: agent_text + }, %{session | agent_text_capture: nil}} + end + + defp open_command_port(command, cwd) do + bash = System.find_executable("bash") || "/bin/bash" + + Port.open({:spawn_executable, bash}, [ + :binary, + :exit_status, + :use_stdio, + {:args, ["-lc", "exec " <> command]}, + {:cd, cwd} + ]) + end + + defp port_os_pid(port) do + case Port.info(port, :os_pid) do + {:os_pid, pid} -> pid + _ -> nil + end + end + + defp request(%Session{} = session, method, params, timeout_ms) do + request_id = session.next_id + session = %{session | next_id: request_id + 1} + + session = + send_message(session, %{"method" => method, "id" => request_id, "params" => params || %{}}) + + receive_response(session, request_id, timeout_ms) + end + + defp notify(%Session{} = session, method, params) do + send_message(session, %{"method" => method, "params" => params || %{}}) + end + + defp receive_response(session, request_id, timeout_ms) do + {msg, session} = read_message(session, timeout_ms) + + cond do + Map.get(msg, "id") == request_id and !Map.has_key?(msg, "method") -> + if Map.has_key?(msg, "error") do + raise Error, code: :response_error, message: Jason.encode!(msg["error"]) + end + + {Map.get(msg, "result", %{}), session} + + Map.has_key?(msg, "method") and Map.has_key?(msg, "id") -> + session = handle_server_request(session, msg) + receive_response(session, request_id, timeout_ms) + + Map.has_key?(msg, "method") -> + session = handle_notification(session, msg) + receive_response(session, request_id, timeout_ms) + + true -> + receive_response(session, request_id, timeout_ms) + end + end + + defp wait_for_turn(%Session{} = session, turn_id) do + deadline = System.monotonic_time(:millisecond) + session.config.turn_timeout_ms + do_wait_for_turn(session, turn_id, deadline) + end + + defp do_wait_for_turn(session, turn_id, deadline) do + remaining = deadline - System.monotonic_time(:millisecond) + + if remaining <= 0 do + raise Error, + code: :turn_timeout, + message: "turn timed out after #{session.config.turn_timeout_ms} ms" + end + + {msg, session} = read_message(session, remaining) + + cond do + Map.has_key?(msg, "method") and Map.has_key?(msg, "id") -> + session = handle_server_request(session, msg) + do_wait_for_turn(session, turn_id, deadline) + + !Map.has_key?(msg, "method") -> + do_wait_for_turn(session, turn_id, deadline) + + true -> + session = handle_notification(session, msg) + + if msg["method"] == "turn/completed" do + turn = get_in(msg, ["params", "turn"]) || %{} + completed_id = turn["id"] + + if completed_id && to_string(completed_id) != turn_id do + do_wait_for_turn(session, turn_id, deadline) + else + case to_string(turn["status"] || "") do + "completed" -> + {"completed", session} + + "interrupted" -> + raise Error, code: :turn_cancelled, message: "turn was interrupted" + + _ -> + raise Error, + code: :turn_failed, + message: + Utils.truncate( + Jason.encode!(turn["error"] || msg["params"]["error"] || %{}), + 1000 + ) + end + end + else + do_wait_for_turn(session, turn_id, deadline) + end + end + end + + defp send_message(%Session{} = session, message) do + unless Port.info(session.port) do + raise Error, code: :port_exit, message: "app-server stdin is closed" + end + + Port.command(session.port, Jason.encode!(message) <> "\n") + session + end + + defp read_message(%Session{} = session, timeout_ms) do + case next_line(session.buffer) do + {line, rest} -> + {decode_line(line), %{session | buffer: rest}} + + :none -> + receive do + {port, {:data, data}} when port == session.port -> + if byte_size(data) > Utils.jsonl_read_limit_bytes() do + raise Error, + code: :response_error, + message: "app-server JSONL message exceeded reader limit" + end + + read_message(%{session | buffer: session.buffer <> data}, timeout_ms) + + {port, {:exit_status, status}} when port == session.port -> + raise Error, code: :port_exit, message: "app-server exited before response: #{status}" + after + max(timeout_ms, 1) -> + raise Error, + code: :response_timeout, + message: "timed out waiting for app-server response after #{timeout_ms} ms" + end + end + end + + defp next_line(buffer) do + case :binary.match(buffer, "\n") do + {index, 1} -> + <> = buffer + {String.trim_trailing(line, "\r"), rest} + + :nomatch -> + :none + end + end + + defp decode_line(line) do + case Jason.decode(line) do + {:ok, %{} = msg} -> + msg + + {:ok, _} -> + raise Error, code: :response_error, message: "app-server message is not an object" + + {:error, reason} -> + raise Error, + code: :response_error, + message: "malformed app-server JSON: #{inspect(reason)}" + end + end + + defp handle_notification(%Session{} = session, msg) do + method = to_string(msg["method"]) + params = if is_map(msg["params"]), do: msg["params"], else: %{} + turn = if is_map(params["turn"]), do: params["turn"], else: %{} + thread_id = params["threadId"] || params["thread_id"] + turn_id = params["turnId"] || params["turn_id"] || turn["id"] + + event = + %{ + "event" => event_name(method), + "method" => method, + "payload" => params, + "codex_app_server_pid" => to_string(session.os_pid), + "message" => summarize(method, params) + } + |> put_if("thread_id", thread_id) + |> put_if("turn_id", turn_id) + + event = + if thread_id && turn_id do + Map.put(event, "session_id", "#{thread_id}-#{turn_id}") + else + event + end + + event = + if method == "thread/tokenUsage/updated" do + total = get_in(params, ["tokenUsage", "total"]) || %{} + + Map.put(event, "usage_absolute", %{ + "input_tokens" => total["inputTokens"], + "output_tokens" => total["outputTokens"], + "total_tokens" => total["totalTokens"] + }) + else + event + end + + event = + if method in ["account/rateLimits/updated", "account/rateLimitsUpdated"] do + Map.put(event, "rate_limits", params) + else + event + end + + agent_text = agent_text_from_notification(method, params, session.agent_text_capture) + + session = + if agent_text && session.agent_text_capture, + do: %{session | agent_text_capture: session.agent_text_capture ++ [agent_text]}, + else: session + + emit(session, event) + session + end + + defp handle_server_request(%Session{} = session, msg) do + request_id = msg["id"] + method = to_string(msg["method"]) + params = if is_map(msg["params"]), do: msg["params"], else: %{} + + cond do + method in ["item/commandExecution/requestApproval", "item/fileChange/requestApproval"] -> + session = + send_message(session, %{ + "id" => request_id, + "result" => %{"decision" => "acceptForSession"} + }) + + emit(session, %{ + "event" => "approval_auto_approved", + "method" => method, + "payload" => params + }) + + session + + method == "item/tool/requestUserInput" -> + auto_answer_tool_user_input(session, request_id, method, params) + + method == "item/tool/call" -> + result = handle_dynamic_tool(session, params) + send_message(session, %{"id" => request_id, "result" => result}) + + true -> + send_message(session, %{ + "id" => request_id, + "error" => %{"code" => -32601, "message" => "unsupported server request: #{method}"} + }) + end + end + + defp auto_answer_tool_user_input(session, request_id, method, params) do + cond do + answers = Utils.tool_request_user_input_approval_answers(params) -> + session = + send_message(session, %{"id" => request_id, "result" => %{"answers" => answers}}) + + emit(session, %{ + "event" => "approval_auto_approved", + "method" => method, + "payload" => params, + "decision" => "Approve this Session" + }) + + session + + answers = Utils.tool_request_user_input_unavailable_answers(params) -> + session = + send_message(session, %{"id" => request_id, "result" => %{"answers" => answers}}) + + emit(session, %{ + "event" => "tool_input_auto_answered", + "method" => method, + "payload" => params, + "answer" => Utils.non_interactive_tool_input_answer() + }) + + session + + true -> + session = send_message(session, %{"id" => request_id, "result" => %{"answers" => %{}}}) + + emit(session, %{"event" => "turn_input_required", "method" => method, "payload" => params}) + + raise Error, code: :turn_input_required, message: "app-server requested user input" + end + end + + defp handle_dynamic_tool( + %Session{ + tracker_config: %TrackerConfig{kind: "linear", api_key: api_key} = tracker_config + }, + params + ) + when is_binary(api_key) do + tool = params["tool"] || params["name"] + + if tool != "linear_graphql" do + tool_text(false, %{ + "error" => %{"code" => "unsupported_tool", "message" => "unsupported tool: #{tool}"} + }) + else + arguments = params["arguments"] + {query, variables} = graphql_arguments(arguments) + + cond do + !is_binary(query) or String.trim(query) == "" -> + tool_text(false, %{ + "error" => %{ + "code" => "invalid_input", + "message" => "query must be a non-empty string" + } + }) + + !is_map(variables) -> + tool_text(false, %{ + "error" => %{"code" => "invalid_input", "message" => "variables must be an object"} + }) + + looks_like_multiple_graphql_operations?(query) -> + tool_text(false, %{ + "error" => %{ + "code" => "invalid_input", + "message" => "query must contain exactly one operation" + } + }) + + true -> + try do + body = + LinearClient.execute_graphql_once( + struct(LinearClient, config: tracker_config), + query, + variables + ) + + tool_text(true, body) + rescue + error -> + tool_text(false, %{ + "error" => %{"code" => "linear_graphql", "message" => Exception.message(error)} + }) + end + end + end + end + + defp handle_dynamic_tool(_session, _params) do + tool_text(false, %{ + "error" => %{"code" => "missing_auth", "message" => "Linear auth is not configured"} + }) + end + + defp graphql_arguments(arguments) when is_binary(arguments), do: {arguments, %{}} + + defp graphql_arguments(arguments) when is_map(arguments), + do: {arguments["query"], arguments["variables"] || %{}} + + defp graphql_arguments(_), do: {nil, nil} + + defp tool_text(success, payload) do + output = Jason.encode!(payload) + + %{ + "success" => success, + "output" => output, + "contentItems" => [%{"type" => "inputText", "text" => output}] + } + end + + defp emit(%Session{} = session, event) do + event = Map.put_new(event, "timestamp", Utils.now_utc()) + session.on_event.(event) + :ok + end + + defp event_name("turn/completed"), do: "turn_completed" + defp event_name("turn/started"), do: "turn_started" + defp event_name("item/tool/requestUserInput"), do: "turn_input_required" + defp event_name(method), do: String.replace(method, "/", "_") + + defp summarize("item/agentMessage/delta", params), + do: Utils.truncate(to_string(params["delta"] || params["text"] || ""), 500) + + defp summarize(method, params) do + item = params["item"] + + cond do + is_map(item) and item["type"] == "agentMessage" -> + Utils.truncate(to_string(item["text"] || ""), 500) + + is_map(item) and item["type"] == "commandExecution" -> + Utils.truncate("command=#{item["command"]} status=#{item["status"]}", 500) + + is_map(item) -> + Utils.truncate("item_type=#{item["type"]} status=#{item["status"]}", 500) + + method == "turn/completed" -> + "status=#{get_in(params, ["turn", "status"])}" + + true -> + Utils.truncate(Jason.encode!(params), 500) + end + end + + defp agent_text_from_notification("item/agentMessage/delta", params, _capture), + do: to_string(params["delta"] || params["text"] || "") + + defp agent_text_from_notification(_method, _params, _capture), do: nil + + defp turn_sandbox_policy(%Session{ + config: %CodexConfig{turn_sandbox_policy: nil}, + workspace_path: workspace_path + }) do + %{"type" => "workspaceWrite", "writableRoots" => [workspace_path], "networkAccess" => true} + end + + defp turn_sandbox_policy(%Session{config: %CodexConfig{turn_sandbox_policy: policy}}), + do: policy + + defp put_if(map, _key, nil), do: map + defp put_if(map, key, value), do: Map.put(map, key, value) + + defp looks_like_multiple_graphql_operations?(query) do + cleaned = + query + |> String.split("\n") + |> Enum.map(&(&1 |> String.split("#", parts: 2) |> hd())) + |> Enum.join(" ") + + Enum.reduce(["query", "mutation", "subscription"], 0, fn word, count -> + count + length(String.split(cleaned, word)) - 1 + end) > 1 + end +end diff --git a/lib/symphony/coding_context.ex b/lib/symphony/coding_context.ex new file mode 100644 index 0000000..ef69134 --- /dev/null +++ b/lib/symphony/coding_context.ex @@ -0,0 +1,296 @@ +defmodule Symphony.CodingContext do + @moduledoc false + + alias Symphony.CodexClient + alias Symphony.Config.{CodexConfig, CodingContextConfig} + alias Symphony.Models.Issue + alias Symphony.Utils + + defmodule CodingClassification do + defstruct is_coding_task: false, source: nil, confidence: nil, reason: nil + end + + def is_coding_issue(%Issue{} = issue, %CodingContextConfig{} = config), + do: rules_classify(issue, config) + + def classify_coding_issue(%Issue{} = issue, %CodingContextConfig{} = config, opts \\ []) do + cond do + !config.enabled -> + %CodingClassification{is_coding_task: false, source: "disabled"} + + config.classifier == "always" -> + %CodingClassification{ + is_coding_task: true, + source: "always", + confidence: 1.0, + reason: "Configured to always inject coding context." + } + + config.classifier == "rules" -> + %CodingClassification{is_coding_task: rules_classify(issue, config), source: "rules"} + + true -> + codex_config = Keyword.get(opts, :codex_config) + workspace_path = Keyword.get(opts, :workspace_path) + + if is_nil(codex_config) or is_nil(workspace_path) do + fallback_classification( + issue, + config, + "LLM classifier unavailable: missing codex config or workspace." + ) + else + try do + classify_with_llm(issue, config, codex_config, workspace_path) + rescue + error -> + fallback_classification( + issue, + config, + "LLM classifier failed: #{Exception.message(error)}" + ) + end + end + end + end + + def augment_prompt_with_coding_context( + prompt, + %Issue{} = issue, + %CodingContextConfig{} = config, + opts \\ [] + ) do + classification = + Keyword.get_lazy(opts, :classification, fn -> + classify_coding_issue(issue, config, + codex_config: Keyword.get(opts, :codex_config), + workspace_path: Keyword.get(opts, :workspace_path) + ) + end) + + if on_event = Keyword.get(opts, :on_event) do + on_event.(%{ + "event" => "coding_context_classified", + "coding_context_injected" => classification.is_coding_task, + "classification_source" => classification.source, + "classification_confidence" => classification.confidence, + "classification_reason" => classification.reason + }) + end + + if classification.is_coding_task do + context = load_coding_context(config) + + if String.trim(context) == "" do + prompt + else + """ + + This Linear issue is classified as a coding task. Read this context before choosing a repo or editing files. If this context conflicts with older assumptions, this context and the current Linear issue text win. + + Classification source: #{classification.source} + Classification reason: #{classification.reason || "(none)"} + + #{context} + + + #{prompt} + """ + |> String.trim() + end + else + prompt + end + end + + def load_coding_context(%CodingContextConfig{} = config) do + {chunks, _remaining} = + config.skill_paths + |> Enum.flat_map(&context_files/1) + |> Enum.reduce({[], config.max_chars}, fn file_path, {chunks, remaining} -> + if remaining <= 0 do + {chunks, remaining} + else + case File.read(file_path) do + {:ok, text} -> + chunk = "## #{file_path}\n#{String.trim(text)}\n" + + chunk = + if String.length(chunk) > remaining do + String.slice(chunk, 0, remaining) + |> String.trim_trailing() + |> Kernel.<>("\n[truncated]\n") + else + chunk + end + + {chunks ++ [chunk], remaining - String.length(chunk)} + + _ -> + {chunks, remaining} + end + end + end) + + chunks |> Enum.join("\n") |> String.trim() + end + + defp rules_classify(%Issue{} = issue, %CodingContextConfig{} = config) do + issue_labels = issue.labels |> Enum.map(&String.downcase/1) |> MapSet.new() + + cond do + MapSet.size(CodingContextConfig.label_trigger_set(config)) > 0 and + !MapSet.disjoint?(CodingContextConfig.label_trigger_set(config), issue_labels) -> + true + + true -> + haystack = "#{issue.title}\n#{issue.description || ""}" |> String.downcase() + Enum.any?(config.keyword_triggers, &String.contains?(haystack, String.downcase(&1))) + end + end + + defp fallback_classification(issue, config, reason) do + case config.classification_fallback do + "inject" -> + %CodingClassification{ + is_coding_task: true, + source: "fallback:inject", + confidence: 0.0, + reason: reason + } + + "skip" -> + %CodingClassification{ + is_coding_task: false, + source: "fallback:skip", + confidence: 0.0, + reason: reason + } + + _ -> + %CodingClassification{ + is_coding_task: rules_classify(issue, config), + source: "fallback:rules", + confidence: 0.0, + reason: reason + } + end + end + + defp classify_with_llm(issue, config, %CodexConfig{} = codex_config, workspace_path) do + classifier_codex_config = %{ + codex_config + | model: config.classifier_model || codex_config.model, + effort: config.classifier_effort, + turn_timeout_ms: config.classification_timeout_ms, + summary: nil, + personality: nil + } + + session = + CodexClient.start_session(classifier_codex_config, workspace_path, + tracker_config: nil, + on_event: fn _ -> :ok end + ) + + try do + {result, session} = + CodexClient.run_turn(session, classification_prompt(issue), capture_agent_text: true) + + CodexClient.stop_session(session) + data = parse_classifier_json(result.agent_message_text) + needed = data["coding_context_needed"] || data["is_coding_task"] + + unless is_boolean(needed) do + raise ArgumentError, "classifier JSON missing boolean coding_context_needed" + end + + confidence = + case Utils.to_float(data["confidence"]) do + nil -> nil + value -> value |> max(0.0) |> min(1.0) + end + + reason = if data["reason"], do: Utils.truncate(data["reason"], 500) + + %CodingClassification{ + is_coding_task: needed, + source: "llm", + confidence: confidence, + reason: reason + } + after + CodexClient.stop_session(session) + end + end + + defp classification_prompt(%Issue{} = issue) do + issue_json = Jason.encode!(Issue.to_template_data(issue)) + + """ + You are a classifier for Symphony, a background coding agent runner. + Decide whether the current Linear issue is a coding/repository task that should receive architecture and repo-map context before the agent edits files. + + Return only a single JSON object, no markdown, no prose, no tool calls. + Schema: + { + "coding_context_needed": boolean, + "confidence": number, + "reason": "short reason" + } + + Use true for tasks that likely require code, config, scripts, repository changes, debugging, tests, provider integration, product implementation, or repo selection. + Use false for pure Linear/project-management actions, status checks, tagging, prioritization, or discussion with no likely repo changes. + If ambiguous, choose true. False negatives are more harmful than extra context. + + Linear issue JSON: + #{issue_json} + """ + end + + defp parse_classifier_json(text) do + stripped = String.trim(text || "") + if stripped == "", do: raise(ArgumentError, "classifier returned empty text") + + case Jason.decode(stripped) do + {:ok, %{} = value} -> + value + + _ -> + stripped |> extract_json_object() |> Jason.decode!() + end + end + + defp extract_json_object(text) do + start = :binary.match(text, "{") + finish = :binary.matches(text, "}") |> List.last() + + case {start, finish} do + {{start_index, 1}, {end_index, 1}} when end_index > start_index -> + binary_part(text, start_index, end_index - start_index + 1) + + _ -> + raise ArgumentError, "classifier output did not contain a JSON object" + end + end + + defp context_files(path) do + cond do + File.regular?(path) -> + [path] + + File.dir?(path) -> + skill_file = Path.join(path, "SKILL.md") + references = Path.join(path, "references") + files = if File.regular?(skill_file), do: [skill_file], else: [] + + files ++ + if File.dir?(references), + do: references |> Path.join("*.md") |> Path.wildcard() |> Enum.sort(), + else: [] + + true -> + [] + end + end +end diff --git a/lib/symphony/config.ex b/lib/symphony/config.ex new file mode 100644 index 0000000..b248812 --- /dev/null +++ b/lib/symphony/config.ex @@ -0,0 +1,965 @@ +defmodule Symphony.Config do + @moduledoc false + + alias Symphony.Error + alias Symphony.Models.WorkflowDefinition + alias Symphony.Utils + alias Symphony.Workflow + + defmodule TrackerConfig do + defstruct kind: nil, + endpoint: nil, + api_key: nil, + project_slug: nil, + team: nil, + mcp_command: "codex app-server", + mcp_server: "codex_apps", + active_states: ["Todo", "In Progress"], + terminal_states: ["Closed", "Cancelled", "Canceled", "Duplicate", "Done"], + review_states: ["In Review", "Merging"], + required_labels: [], + handoff_state: "In Review", + rework_state: "Rework", + done_state: "Done", + merge_base_branch: "dev", + blocked_escalation_enabled: true, + blocked_escalation_mentions: [] + + def active_state_set(config), do: state_set(config.active_states) + def terminal_state_set(config), do: state_set(config.terminal_states) + def review_state_set(config), do: state_set(config.review_states) + + def required_label_set(config) do + config.required_labels + |> Enum.map(&(to_string(&1) |> String.trim() |> String.downcase())) + |> Enum.reject(&(&1 == "")) + |> MapSet.new() + end + + defp state_set(states), do: states |> Enum.map(&Utils.normalize_state/1) |> MapSet.new() + end + + defmodule PollingConfig do + defstruct interval_ms: 30_000 + end + + defmodule WorkspaceConfig do + defstruct root: nil + end + + defmodule HooksConfig do + defstruct after_create: nil, + before_run: nil, + after_run: nil, + before_remove: nil, + timeout_ms: 60_000 + end + + defmodule AgentConfig do + defstruct max_concurrent_agents: 10, + max_turns: 20, + max_retry_backoff_ms: 300_000, + max_concurrent_agents_by_state: %{} + end + + defmodule CodexConfig do + defstruct command: "codex app-server", + approval_policy: "never", + thread_sandbox: "workspace-write", + turn_sandbox_policy: nil, + turn_timeout_ms: 3_600_000, + read_timeout_ms: 5_000, + stall_timeout_ms: 300_000, + model: nil, + effort: nil, + summary: nil, + personality: nil + end + + defmodule ServerConfig do + defstruct port: nil, host: "127.0.0.1" + end + + defmodule CodingContextConfig do + defstruct enabled: false, + classifier: "rules", + classification_fallback: "inject", + classifier_model: nil, + classifier_effort: "low", + classification_timeout_ms: 120_000, + skill_paths: [], + label_triggers: [], + keyword_triggers: [], + max_chars: 40_000 + + def label_trigger_set(config) do + config.label_triggers + |> Enum.map(&(String.trim(&1) |> String.downcase())) + |> Enum.reject(&(&1 == "")) + |> MapSet.new() + end + end + + defmodule ContextConfig do + defstruct coding: %CodingContextConfig{} + end + + defmodule DashboardConfig do + defstruct summaries_enabled: false, + summary_update_interval_ms: 45_000, + summary_timeout_ms: 120_000, + summary_max_events: 60, + summary_max_chars: 14_000, + summary_model: nil, + summary_effort: "low" + end + + defmodule RepositoryConfig do + defstruct slug: nil, + local_path: nil, + remote_url: nil, + aliases: [], + description: nil, + base_branch: nil + + def path_name(%__MODULE__{slug: slug}) do + slug |> to_string() |> String.split("/") |> List.last() + end + + def to_prompt_data(%__MODULE__{} = repo) do + %{ + "slug" => repo.slug, + "local_path" => repo.local_path, + "remote_url" => repo.remote_url, + "aliases" => repo.aliases, + "description" => repo.description, + "base_branch" => repo.base_branch + } + end + end + + defmodule RepositoryPlanningConfig do + defstruct enabled: false, + planner: "rules", + plan_model: nil, + plan_effort: "low", + plan_timeout_ms: 120_000, + fallback: "rules", + block_on_needs_human: true, + quarantine_on_mismatch: true, + clone_timeout_ms: 300_000, + base_branch: "dev", + branch_prefix: "Symphony", + repositories: [] + + def repository_by_slug(config) do + Map.new(config.repositories, &{&1.slug, &1}) + end + end + + defmodule SelfHealingConfig do + defstruct enabled: false, + base_branch: "main", + branch_prefix: "codex/self-heal", + workspace_root: nil, + stale_poll_ms: 120_000, + cooldown_ms: 900_000, + max_attempts: 3, + validation_commands: [ + "mix format --check-formatted", + "mix test", + "mix escript.build" + ], + repair_codex: %CodexConfig{ + approval_policy: "never", + thread_sandbox: "workspace-write", + turn_sandbox_policy: %{"type" => "workspaceWrite", "networkAccess" => true}, + model: "gpt-5.5", + effort: "xhigh" + }, + tmux_session: "symphony-elixir", + restart_port: 8765, + restart_workflow_path: nil + end + + defmodule ServiceConfig do + defstruct workflow_path: nil, + tracker: %TrackerConfig{}, + polling: %PollingConfig{}, + workspace: %WorkspaceConfig{}, + hooks: %HooksConfig{}, + agent: %AgentConfig{}, + codex: %CodexConfig{}, + server: %ServerConfig{}, + context: %ContextConfig{}, + dashboard: %DashboardConfig{}, + repositories: %RepositoryPlanningConfig{}, + self_healing: %SelfHealingConfig{} + end + + defmodule ConfigManager do + defstruct workflow_path: nil, environ: %{}, workflow: nil, config: nil, last_reload_error: nil + + def new(workflow_path \\ nil, opts \\ []) do + %__MODULE__{ + workflow_path: Workflow.resolve_workflow_path(workflow_path), + environ: Keyword.get(opts, :environ, System.get_env()) + } + end + + def load_startup(%__MODULE__{} = manager) do + workflow = Workflow.load_workflow(manager.workflow_path) + config = Symphony.Config.resolve_config(workflow, manager.environ) + Symphony.Config.validate_dispatch_config!(config) + {%{manager | workflow: workflow, config: config, last_reload_error: nil}, workflow, config} + end + + def current(%__MODULE__{workflow: nil} = manager), do: load_startup(manager) + def current(%__MODULE__{} = manager), do: {manager, manager.workflow, manager.config} + + def reload_if_changed(%__MODULE__{workflow: nil} = manager) do + {manager, _workflow, _config} = load_startup(manager) + {manager, true} + end + + def reload_if_changed(%__MODULE__{} = manager) do + current_mtime = Workflow.mtime(manager.workflow_path) + changed? = current_mtime != manager.workflow.mtime_ns + + try do + workflow = Workflow.load_workflow(manager.workflow_path) + config = Symphony.Config.resolve_config(workflow, manager.environ) + Symphony.Config.validate_dispatch_config!(config) + {%{manager | workflow: workflow, config: config, last_reload_error: nil}, changed?} + rescue + error in Error -> + {%{manager | last_reload_error: error}, false} + end + end + + def validate_for_dispatch!(%__MODULE__{} = manager) do + {manager, _changed} = reload_if_changed(manager) + + if manager.last_reload_error do + raise Error, + code: :workflow_reload_invalid, + message: Exception.message(manager.last_reload_error), + cause: manager.last_reload_error + end + + {_manager, _workflow, config} = current(manager) + Symphony.Config.validate_dispatch_config!(config) + manager + end + end + + def resolve_config(%WorkflowDefinition{} = workflow, environ \\ System.get_env()) do + raw = workflow.config + workflow_dir = Path.dirname(workflow.path) + + tracker_raw = section(raw, "tracker") + kind = string_or_nil(get(tracker_raw, "kind")) + + endpoint = + get(tracker_raw, "endpoint") || if(kind == "linear", do: "https://api.linear.app/graphql") + + api_key = resolve_env_reference(get(tracker_raw, "api_key"), environ) + + tracker = %TrackerConfig{ + kind: kind, + endpoint: string_or_nil(endpoint), + api_key: string_or_nil(api_key), + project_slug: string_or_nil(get(tracker_raw, "project_slug")), + team: string_or_nil(get(tracker_raw, "team")), + mcp_command: to_string(get(tracker_raw, "mcp_command", "codex app-server")), + mcp_server: to_string(get(tracker_raw, "mcp_server", "codex_apps")), + active_states: + string_list( + get(tracker_raw, "active_states"), + ["Todo", "In Progress"], + "tracker.active_states" + ), + terminal_states: + string_list( + get(tracker_raw, "terminal_states"), + ["Closed", "Cancelled", "Canceled", "Duplicate", "Done"], + "tracker.terminal_states" + ), + review_states: + string_list( + get(tracker_raw, "review_states"), + ["In Review", "Merging"], + "tracker.review_states" + ), + required_labels: + string_list(get(tracker_raw, "required_labels"), [], "tracker.required_labels"), + handoff_state: to_string(get(tracker_raw, "handoff_state", "In Review")), + rework_state: to_string(get(tracker_raw, "rework_state", "Rework")), + done_state: to_string(get(tracker_raw, "done_state", "Done")), + merge_base_branch: to_string(get(tracker_raw, "merge_base_branch", "dev")), + blocked_escalation_enabled: + bool_value( + get(tracker_raw, "blocked_escalation_enabled"), + true, + "tracker.blocked_escalation_enabled" + ), + blocked_escalation_mentions: + string_list( + get(tracker_raw, "blocked_escalation_mentions"), + [], + "tracker.blocked_escalation_mentions" + ) + } + + polling_raw = section(raw, "polling") + + polling = %PollingConfig{ + interval_ms: + int_value(get(polling_raw, "interval_ms"), 30_000, "polling.interval_ms", positive: true) + } + + workspace_raw = section(raw, "workspace") + + workspace = %WorkspaceConfig{ + root: + resolve_path(get(workspace_raw, "root"), + default: Path.join(System.tmp_dir!(), "symphony_workspaces"), + workflow_dir: workflow_dir, + environ: environ + ) + } + + hooks_raw = section(raw, "hooks") + + hooks = %HooksConfig{ + after_create: get(hooks_raw, "after_create"), + before_run: get(hooks_raw, "before_run"), + after_run: get(hooks_raw, "after_run"), + before_remove: get(hooks_raw, "before_remove"), + timeout_ms: + int_value(get(hooks_raw, "timeout_ms"), 60_000, "hooks.timeout_ms", positive: true) + } + + agent_raw = section(raw, "agent") + + agent = %AgentConfig{ + max_concurrent_agents: + int_value(get(agent_raw, "max_concurrent_agents"), 10, "agent.max_concurrent_agents", + positive: true + ), + max_turns: int_value(get(agent_raw, "max_turns"), 20, "agent.max_turns", positive: true), + max_retry_backoff_ms: + int_value(get(agent_raw, "max_retry_backoff_ms"), 300_000, "agent.max_retry_backoff_ms", + positive: true + ), + max_concurrent_agents_by_state: + state_limits(get(agent_raw, "max_concurrent_agents_by_state")) + } + + codex_raw = section(raw, "codex") + + codex = %CodexConfig{ + command: to_string(get(codex_raw, "command", "codex app-server")), + approval_policy: get(codex_raw, "approval_policy", "never"), + thread_sandbox: get(codex_raw, "thread_sandbox", "workspace-write"), + turn_sandbox_policy: get(codex_raw, "turn_sandbox_policy"), + turn_timeout_ms: + int_value(get(codex_raw, "turn_timeout_ms"), 3_600_000, "codex.turn_timeout_ms", + positive: true + ), + read_timeout_ms: + int_value(get(codex_raw, "read_timeout_ms"), 5_000, "codex.read_timeout_ms", + positive: true + ), + stall_timeout_ms: + int_value(get(codex_raw, "stall_timeout_ms"), 300_000, "codex.stall_timeout_ms"), + model: string_or_nil(get(codex_raw, "model")), + effort: string_or_nil(get(codex_raw, "effort")), + summary: string_or_nil(get(codex_raw, "summary")), + personality: string_or_nil(get(codex_raw, "personality")) + } + + server_raw = section(raw, "server") + port = get(server_raw, "port") + + server = %ServerConfig{ + port: if(port == nil, do: nil, else: int_value(port, 0, "server.port", minimum: 0)), + host: to_string(get(server_raw, "host", "127.0.0.1")) + } + + context_raw = section(raw, "context") + coding_raw = section(context_raw, "coding") + + coding = %CodingContextConfig{ + enabled: bool_value(get(coding_raw, "enabled"), false, "context.coding.enabled"), + classifier: + get(coding_raw, "classifier", "rules") + |> to_string() + |> String.trim() + |> String.downcase(), + classification_fallback: + get(coding_raw, "classification_fallback", "inject") + |> to_string() + |> String.trim() + |> String.downcase(), + classifier_model: string_or_nil(get(coding_raw, "classifier_model")), + classifier_effort: string_or_nil(get(coding_raw, "classifier_effort")) || "low", + classification_timeout_ms: + int_value( + get(coding_raw, "classification_timeout_ms"), + 120_000, + "context.coding.classification_timeout_ms", + positive: true + ), + skill_paths: + path_list(get(coding_raw, "skill_paths"), + workflow_dir: workflow_dir, + environ: environ, + field_name: "context.coding.skill_paths" + ), + label_triggers: + string_list(get(coding_raw, "label_triggers"), [], "context.coding.label_triggers"), + keyword_triggers: + string_list(get(coding_raw, "keyword_triggers"), [], "context.coding.keyword_triggers"), + max_chars: + int_value(get(coding_raw, "max_chars"), 40_000, "context.coding.max_chars", + positive: true + ) + } + + dashboard_raw = section(raw, "dashboard") + summaries_raw = section(dashboard_raw, "summaries") + + dashboard = %DashboardConfig{ + summaries_enabled: + bool_value(get(summaries_raw, "enabled"), false, "dashboard.summaries.enabled"), + summary_update_interval_ms: + int_value( + get(summaries_raw, "update_interval_ms"), + 45_000, + "dashboard.summaries.update_interval_ms", + positive: true + ), + summary_timeout_ms: + int_value(get(summaries_raw, "timeout_ms"), 120_000, "dashboard.summaries.timeout_ms", + positive: true + ), + summary_max_events: + int_value(get(summaries_raw, "max_events"), 60, "dashboard.summaries.max_events", + positive: true + ), + summary_max_chars: + int_value(get(summaries_raw, "max_chars"), 14_000, "dashboard.summaries.max_chars", + positive: true + ), + summary_model: string_or_nil(get(summaries_raw, "model")), + summary_effort: string_or_nil(get(summaries_raw, "effort")) || "low" + } + + repositories_raw = section(raw, "repositories") + + repositories = %RepositoryPlanningConfig{ + enabled: bool_value(get(repositories_raw, "enabled"), false, "repositories.enabled"), + planner: + get(repositories_raw, "planner", "rules") + |> to_string() + |> String.trim() + |> String.downcase(), + plan_model: string_or_nil(get(repositories_raw, "model")), + plan_effort: string_or_nil(get(repositories_raw, "effort")) || "low", + plan_timeout_ms: + int_value(get(repositories_raw, "timeout_ms"), 120_000, "repositories.timeout_ms", + positive: true + ), + fallback: + get(repositories_raw, "fallback", "rules") + |> to_string() + |> String.trim() + |> String.downcase(), + block_on_needs_human: + bool_value( + get(repositories_raw, "block_on_needs_human"), + true, + "repositories.block_on_needs_human" + ), + quarantine_on_mismatch: + bool_value( + get(repositories_raw, "quarantine_on_mismatch"), + true, + "repositories.quarantine_on_mismatch" + ), + clone_timeout_ms: + int_value( + get(repositories_raw, "clone_timeout_ms"), + 300_000, + "repositories.clone_timeout_ms", + positive: true + ), + base_branch: clean_default(get(repositories_raw, "base_branch"), "dev"), + branch_prefix: + get(repositories_raw, "branch_prefix", "Symphony") + |> to_string() + |> String.trim("/") + |> clean_default("Symphony"), + repositories: + repository_list(get(repositories_raw, "known"), + workflow_dir: workflow_dir, + environ: environ + ) + } + + self_healing_raw = section(raw, "self_healing") + self_healing_codex_raw = section(self_healing_raw, "codex") + self_healing_restart_raw = section(self_healing_raw, "restart") + + self_healing_repair_codex = %CodexConfig{ + command: to_string(get(self_healing_codex_raw, "command", codex.command)), + approval_policy: to_string(get(self_healing_codex_raw, "approval_policy", "never")), + thread_sandbox: to_string(get(self_healing_codex_raw, "thread_sandbox", "workspace-write")), + turn_sandbox_policy: + get(self_healing_codex_raw, "turn_sandbox_policy") || + %{"type" => "workspaceWrite", "networkAccess" => true}, + turn_timeout_ms: + int_value( + get(self_healing_codex_raw, "turn_timeout_ms"), + codex.turn_timeout_ms, + "self_healing.codex.turn_timeout_ms", + positive: true + ), + read_timeout_ms: + int_value( + get(self_healing_codex_raw, "read_timeout_ms"), + codex.read_timeout_ms, + "self_healing.codex.read_timeout_ms", + positive: true + ), + stall_timeout_ms: + int_value( + get(self_healing_codex_raw, "stall_timeout_ms"), + codex.stall_timeout_ms, + "self_healing.codex.stall_timeout_ms" + ), + model: string_or_nil(get(self_healing_codex_raw, "model")) || "gpt-5.5", + effort: string_or_nil(get(self_healing_codex_raw, "effort")) || "xhigh", + summary: string_or_nil(get(self_healing_codex_raw, "summary")), + personality: string_or_nil(get(self_healing_codex_raw, "personality")) + } + + self_healing = %SelfHealingConfig{ + enabled: bool_value(get(self_healing_raw, "enabled"), false, "self_healing.enabled"), + base_branch: clean_default(get(self_healing_raw, "base_branch"), "main"), + branch_prefix: + get(self_healing_raw, "branch_prefix", "codex/self-heal") + |> to_string() + |> String.trim("/") + |> clean_default("codex/self-heal"), + workspace_root: + resolve_path(get(self_healing_raw, "workspace_root"), + default: Path.join(workflow_dir, ".symphony-self-heal"), + workflow_dir: workflow_dir, + environ: environ + ), + stale_poll_ms: + int_value( + get(self_healing_raw, "stale_poll_ms"), + 120_000, + "self_healing.stale_poll_ms", + positive: true + ), + cooldown_ms: + int_value( + get(self_healing_raw, "cooldown_ms"), + 900_000, + "self_healing.cooldown_ms", + positive: true + ), + max_attempts: + int_value( + get(self_healing_raw, "max_attempts"), + 3, + "self_healing.max_attempts", + positive: true + ), + validation_commands: + string_list( + get(self_healing_raw, "validation_commands"), + [ + "mix format --check-formatted", + "mix test", + "mix escript.build" + ], + "self_healing.validation_commands" + ), + repair_codex: self_healing_repair_codex, + tmux_session: + clean_default(get(self_healing_restart_raw, "tmux_session"), "symphony-elixir"), + restart_port: + int_value( + get(self_healing_restart_raw, "port"), + server.port || 8765, + "self_healing.restart.port", + minimum: 0 + ), + restart_workflow_path: + resolve_path(get(self_healing_restart_raw, "workflow_path"), + default: workflow.path, + workflow_dir: workflow_dir, + environ: environ + ) + } + + %ServiceConfig{ + workflow_path: workflow.path, + tracker: tracker, + polling: polling, + workspace: workspace, + hooks: hooks, + agent: agent, + codex: codex, + server: server, + context: %ContextConfig{coding: coding}, + dashboard: dashboard, + repositories: repositories, + self_healing: self_healing + } + end + + def validate_dispatch_config!(%ServiceConfig{} = config) do + cond do + config.tracker.kind not in ["linear", "linear_mcp"] -> + raise Error, + code: :unsupported_tracker_kind, + message: "tracker.kind must be 'linear' or 'linear_mcp'" + + config.tracker.kind == "linear" and !truthy_string?(config.tracker.api_key) -> + raise Error, + code: :missing_tracker_api_key, + message: "tracker.api_key is required after $VAR resolution" + + config.tracker.kind == "linear" and !truthy_string?(config.tracker.project_slug) -> + raise Error, + code: :missing_tracker_project_slug, + message: "tracker.project_slug is required for raw Linear GraphQL" + + config.tracker.kind == "linear_mcp" and !truthy_string?(config.tracker.project_slug) and + !truthy_string?(config.tracker.team) -> + raise Error, + code: :missing_tracker_scope, + message: "tracker.team or tracker.project_slug is required for Linear MCP" + + String.trim(config.codex.command || "") == "" -> + raise Error, + code: :missing_codex_command, + message: "codex.command must be present and non-empty" + + config.tracker.kind == "linear_mcp" and String.trim(config.tracker.mcp_command || "") == "" -> + raise Error, + code: :missing_tracker_mcp_command, + message: "tracker.mcp_command must be present and non-empty" + + true -> + validate_coding_context!(config.context.coding) + validate_repositories!(config.repositories) + validate_self_healing!(config.self_healing) + :ok + end + end + + defp validate_self_healing!(%SelfHealingConfig{enabled: false}), do: :ok + + defp validate_self_healing!(%SelfHealingConfig{} = config) do + cond do + String.trim(config.repair_codex.command || "") == "" -> + raise Error, + code: :missing_self_healing_codex_command, + message: "self_healing.codex.command must be present and non-empty" + + config.validation_commands == [] -> + raise Error, + code: :missing_self_healing_validation_commands, + message: "self_healing.validation_commands must contain at least one command" + + true -> + :ok + end + end + + defp validate_coding_context!(%CodingContextConfig{enabled: false}), do: :ok + + defp validate_coding_context!(%CodingContextConfig{} = config) do + if config.classifier not in ["rules", "llm", "always"] do + raise Error, + code: :invalid_coding_context_classifier, + message: "context.coding.classifier must be one of: rules, llm, always" + end + + if config.classification_fallback not in ["rules", "inject", "skip"] do + raise Error, + code: :invalid_coding_context_fallback, + message: "context.coding.classification_fallback must be one of: rules, inject, skip" + end + + if config.skill_paths == [] do + raise Error, + code: :missing_coding_context_skills, + message: "context.coding.skill_paths must be present when coding context is enabled" + end + + Enum.each(config.skill_paths, fn path -> + unless File.exists?(path) do + raise Error, + code: :missing_coding_context_skill, + message: "context coding skill path does not exist: #{path}" + end + end) + end + + defp validate_repositories!(%RepositoryPlanningConfig{enabled: false}), do: :ok + + defp validate_repositories!(%RepositoryPlanningConfig{} = config) do + if config.planner not in ["rules", "llm"] do + raise Error, + code: :invalid_repository_planner, + message: "repositories.planner must be one of: rules, llm" + end + + if config.fallback not in ["rules", "block"] do + raise Error, + code: :invalid_repository_planner_fallback, + message: "repositories.fallback must be one of: rules, block" + end + + if config.repositories == [] do + raise Error, + code: :missing_known_repositories, + message: + "repositories.known must contain at least one repository when repositories.enabled is true" + end + + config.repositories + |> Enum.reduce(MapSet.new(), fn repo, seen -> + key = String.downcase(repo.slug) + + if MapSet.member?(seen, key) do + raise Error, + code: :duplicate_known_repository, + message: "duplicate repository slug: #{repo.slug}" + end + + if repo.local_path && !File.exists?(repo.local_path) do + raise Error, + code: :missing_repository_local_path, + message: "repository local_path does not exist: #{repo.local_path}" + end + + if is_nil(repo.local_path) and !truthy_string?(repo.remote_url) do + raise Error, + code: :repository_missing_source, + message: "repository must define local_path or remote_url: #{repo.slug}" + end + + MapSet.put(seen, key) + end) + + :ok + end + + defp section(raw, key) do + value = get(raw, key, %{}) || %{} + + unless is_map(value) do + raise Error, code: :config_invalid_section, message: "#{key} must be an object" + end + + value + end + + def get(map, key, default \\ nil) + + def get(map, key, default) when is_map(map) do + cond do + Map.has_key?(map, key) -> + Map.get(map, key) + + is_binary(key) -> + case existing_atom(key) do + nil -> default + atom -> Map.get(map, atom, default) + end + + true -> + default + end + end + + def get(_, _, default), do: default + + defp existing_atom(value) do + String.to_existing_atom(value) + rescue + ArgumentError -> nil + end + + defp resolve_env_reference(value, environ) when is_binary(value) do + if String.starts_with?(value, "$") and String.length(value) > 1 and + Regex.match?(~r/^\$[A-Za-z0-9_]+$/, value) do + Map.get(environ, String.trim_leading(value, "$")) |> blank_to_nil() + else + value + end + end + + defp resolve_env_reference(value, _environ), do: value + + defp resolve_path(value, opts) do + default = Keyword.fetch!(opts, :default) + workflow_dir = Keyword.fetch!(opts, :workflow_dir) + environ = Keyword.fetch!(opts, :environ) + resolved = if is_nil(value), do: default, else: resolve_env_reference(value, environ) + path = resolved |> to_string() |> Path.expand(workflow_dir) + Path.expand(path) + end + + defp string_list(nil, default, _field_name), do: default + + defp string_list(value, _default, field_name) when is_list(value) do + if Enum.all?(value, &is_binary/1) do + value + else + raise Error, code: :config_invalid_value, message: "#{field_name} must be a list of strings" + end + end + + defp string_list(_value, _default, field_name) do + raise Error, code: :config_invalid_value, message: "#{field_name} must be a list of strings" + end + + defp int_value(value, default, field_name, opts \\ []) do + result = + cond do + is_nil(value) -> + default + + is_boolean(value) -> + raise Error, code: :config_invalid_value, message: "#{field_name} must be an integer" + + is_integer(value) -> + value + + true -> + Utils.to_int(value) || + raise(Error, code: :config_invalid_value, message: "#{field_name} must be an integer") + end + + if Keyword.get(opts, :positive, false) and result <= 0 do + raise Error, code: :config_invalid_value, message: "#{field_name} must be positive" + end + + minimum = Keyword.get(opts, :minimum) + + if minimum && result < minimum do + raise Error, code: :config_invalid_value, message: "#{field_name} must be >= #{minimum}" + end + + result + end + + defp bool_value(nil, default, _field_name), do: default + defp bool_value(value, _default, _field_name) when is_boolean(value), do: value + + defp bool_value(_value, _default, field_name) do + raise Error, code: :config_invalid_value, message: "#{field_name} must be a boolean" + end + + defp state_limits(value) when is_map(value) do + Enum.reduce(value, %{}, fn {key, raw_limit}, acc -> + case Utils.to_int(raw_limit) do + limit when is_integer(limit) and limit > 0 -> + Map.put(acc, Utils.normalize_state(key), limit) + + _ -> + acc + end + end) + end + + defp state_limits(_), do: %{} + + defp path_list(value, opts) do + field_name = Keyword.fetch!(opts, :field_name) + + value + |> string_list([], field_name) + |> Enum.map( + &resolve_path(&1, + default: Keyword.fetch!(opts, :workflow_dir), + workflow_dir: Keyword.fetch!(opts, :workflow_dir), + environ: Keyword.fetch!(opts, :environ) + ) + ) + end + + defp repository_list(nil, _opts), do: [] + + defp repository_list(value, opts) when is_list(value) do + Enum.with_index(value) + |> Enum.map(fn {item, index} -> + unless is_map(item) do + raise Error, + code: :config_invalid_value, + message: "repositories.known[#{index}] must be an object" + end + + slug = item |> get("slug") |> string_or_nil() + + unless truthy_string?(slug) do + raise Error, + code: :config_invalid_value, + message: "repositories.known[#{index}].slug must be a non-empty string" + end + + local_path = + if get(item, "local_path") do + resolve_path(get(item, "local_path"), + default: Keyword.fetch!(opts, :workflow_dir), + workflow_dir: Keyword.fetch!(opts, :workflow_dir), + environ: Keyword.fetch!(opts, :environ) + ) + end + + %RepositoryConfig{ + slug: String.trim(slug), + local_path: local_path, + remote_url: clean_nil(get(item, "remote_url")), + aliases: string_list(get(item, "aliases"), [], "repositories.known[#{index}].aliases"), + description: clean_nil(get(item, "description")), + base_branch: clean_nil(get(item, "base_branch")) + } + end) + end + + defp repository_list(_value, _opts) do + raise Error, + code: :config_invalid_value, + message: "repositories.known must be a list of objects" + end + + defp string_or_nil(nil), do: nil + defp string_or_nil(value), do: to_string(value) + defp blank_to_nil(nil), do: nil + defp blank_to_nil(""), do: nil + defp blank_to_nil(value), do: value + + defp clean_nil(nil), do: nil + + defp clean_nil(value) do + text = String.trim(to_string(value)) + if text == "", do: nil, else: text + end + + defp clean_default(value, default) do + clean_nil(value) || default + end + + defp truthy_string?(value), do: is_binary(value) and String.trim(value) != "" +end diff --git a/lib/symphony/dashboard_summary.ex b/lib/symphony/dashboard_summary.ex new file mode 100644 index 0000000..02d9748 --- /dev/null +++ b/lib/symphony/dashboard_summary.ex @@ -0,0 +1,144 @@ +defmodule Symphony.DashboardSummary do + @moduledoc false + + alias Symphony.CodexClient + alias Symphony.Config.{CodexConfig, DashboardConfig} + alias Symphony.Models.Issue + alias Symphony.Utils + + defstruct summary: nil, + current_step: nil, + needs_human: false, + human_reason: nil, + risk: "unknown", + confidence: nil + + def summarize_activity(opts) do + %Issue{} = issue = Keyword.fetch!(opts, :issue) + activity = Keyword.fetch!(opts, :activity) + previous_summary = Keyword.get(opts, :previous_summary) + %CodexConfig{} = codex_config = Keyword.fetch!(opts, :codex_config) + %DashboardConfig{} = dashboard_config = Keyword.fetch!(opts, :dashboard_config) + workspace_path = Keyword.fetch!(opts, :workspace_path) + + summary_config = %{ + codex_config + | model: dashboard_config.summary_model || codex_config.model, + effort: dashboard_config.summary_effort, + turn_timeout_ms: dashboard_config.summary_timeout_ms, + summary: nil, + personality: nil + } + + session = + CodexClient.start_session(summary_config, workspace_path, + tracker_config: nil, + on_event: fn _event -> :ok end + ) + + try do + {result, _session} = + CodexClient.run_turn( + session, + summary_prompt(issue, activity, previous_summary, dashboard_config), + capture_agent_text: true + ) + + data = parse_summary_json(result.agent_message_text) + + %__MODULE__{ + summary: + Utils.truncate( + to_string(data["summary"] || "No substantive activity has been summarized yet."), + 800 + ), + current_step: Utils.truncate(to_string(data["current_step"] || "Unknown."), 300), + needs_human: !!data["needs_human"], + human_reason: + if(data["human_reason"], do: Utils.truncate(to_string(data["human_reason"]), 500)), + risk: normalize_risk(data["risk"]), + confidence: normalize_confidence(data["confidence"]) + } + after + CodexClient.stop_session(session) + end + end + + def normalize_risk(value) do + risk = value |> to_string() |> String.trim() |> String.downcase() + if risk in ["low", "medium", "high", "unknown"], do: risk, else: "unknown" + end + + def normalize_confidence(value) do + case Utils.to_float(value) do + nil -> nil + float -> float |> max(0.0) |> min(1.0) + end + end + + defp summary_prompt(%Issue{} = issue, activity, previous_summary, %DashboardConfig{} = config) do + payload = %{ + "issue" => Issue.to_template_data(issue), + "previous_summary" => previous_summary, + "recent_activity" => activity + } + + payload_json = + payload |> Jason.encode!(pretty: false) |> Utils.truncate(config.summary_max_chars) + + """ + You summarize a running background coding agent for a human dashboard. + Use only the visible activity events. Do not claim completion unless the events show it. Flag human attention if the agent appears blocked, confused, repeatedly failing, using the wrong repo, waiting for credentials/decisions, asking for input, or operating with high uncertainty. Also flag human attention if the issue reads like product/runtime/UI work but the activity shows the agent working mostly in unrelated infrastructure, gateway, prompt/config, or deployment files. + + Return only one JSON object, no markdown and no prose. + Schema: + { + "summary": "1-2 sentence present-tense summary of what the agent is doing", + "current_step": "short phrase for the current/next step", + "needs_human": boolean, + "human_reason": "why a human should step in, or null", + "risk": "low|medium|high|unknown", + "confidence": number + } + + Dashboard input JSON: + #{payload_json} + """ + end + + defp parse_summary_json(text) do + stripped = String.trim(to_string(text || "")) + + if stripped == "" do + raise ArgumentError, message: "summary model returned empty text" + end + + value = + case Jason.decode(stripped) do + {:ok, decoded} -> + decoded + + {:error, _reason} -> + stripped |> extract_json_object() |> Jason.decode!() + end + + unless is_map(value) do + raise ArgumentError, message: "summary JSON is not an object" + end + + value + end + + defp extract_json_object(text) do + start = :binary.match(text, "{") + finish = :binary.matches(text, "}") |> List.last() + + case {start, finish} do + {{start_index, 1}, {end_index, 1}} when end_index > start_index -> + binary_part(text, start_index, end_index - start_index + 1) + + _ -> + raise ArgumentError, message: "summary output did not contain a JSON object" + end + end +end diff --git a/lib/symphony/error.ex b/lib/symphony/error.ex new file mode 100644 index 0000000..3da066d --- /dev/null +++ b/lib/symphony/error.ex @@ -0,0 +1,18 @@ +defmodule Symphony.Error do + @moduledoc "Stable machine-readable Symphony exception." + + defexception [:code, :message, :cause] + + @impl true + def exception(opts) do + code = Keyword.fetch!(opts, :code) + message = Keyword.get(opts, :message, to_string(code)) + cause = Keyword.get(opts, :cause) + %__MODULE__{code: code, message: message, cause: cause} + end + + @impl true + def message(%__MODULE__{code: code, message: message}) do + "#{code}: #{message}" + end +end diff --git a/lib/symphony/http_server.ex b/lib/symphony/http_server.ex new file mode 100644 index 0000000..71a718d --- /dev/null +++ b/lib/symphony/http_server.ex @@ -0,0 +1,702 @@ +defmodule Symphony.HTTPServer do + @moduledoc false + + alias Symphony.Orchestrator + alias Symphony.Utils + + @agent_read_timeout_ms 250 + @fallback_state_path ".symphony-workspaces/.symphony-state.json" + + defstruct orchestrator: nil, + host: "127.0.0.1", + port: 0, + listen_socket: nil, + bound_port: nil, + acceptor: nil + + def start(orchestrator, opts \\ []) do + host = Keyword.get(opts, :host, "127.0.0.1") + port = Keyword.get(opts, :port, 0) + {:ok, ip} = host |> to_charlist() |> :inet.parse_address() + + {:ok, socket} = + :gen_tcp.listen(port, [:binary, packet: :raw, active: false, reuseaddr: true, ip: ip]) + + {:ok, bound_port} = :inet.port(socket) + + server = %__MODULE__{ + orchestrator: orchestrator, + host: host, + port: port, + listen_socket: socket, + bound_port: bound_port + } + + acceptor = spawn_link(fn -> accept_loop(server) end) + %{server | acceptor: acceptor} + end + + def stop(%__MODULE__{} = server) do + if server.acceptor, do: Process.exit(server.acceptor, :normal) + if server.listen_socket, do: :gen_tcp.close(server.listen_socket) + :ok + end + + defp accept_loop(server) do + case :gen_tcp.accept(server.listen_socket) do + {:ok, socket} -> + spawn(fn -> handle(socket, server.orchestrator) end) + accept_loop(server) + + {:error, :closed} -> + :ok + end + end + + defp handle(socket, orchestrator) do + with {:ok, request_line} <- :gen_tcp.recv(socket, 0, 5_000), + [method, target | _] <- + request_line |> String.split("\r\n", parts: 2) |> hd() |> String.split(" ", parts: 3) do + drain_headers(socket) + route(socket, method, URI.parse(target).path || "/", orchestrator) + else + _ -> + send_json(socket, 500, %{ + "error" => %{"code" => "internal_error", "message" => "invalid request"} + }) + end + after + :gen_tcp.close(socket) + end + + defp drain_headers(socket) do + case :gen_tcp.recv(socket, 0, 1_000) do + {:ok, data} -> + unless String.contains?(data, "\r\n\r\n"), do: drain_headers(socket) + + _ -> + :ok + end + end + + defp route(socket, "GET", "/", orchestrator) do + snapshot = snapshot(orchestrator) + send_html(socket, 200, dashboard_html(snapshot)) + end + + defp route(socket, "GET", "/api/v1/state", orchestrator), + do: send_json(socket, 200, snapshot(orchestrator)) + + defp route(socket, "POST", "/api/v1/refresh", orchestrator) do + coalesced = + if is_pid(orchestrator) do + Orchestrator.request_refresh(orchestrator, @agent_read_timeout_ms) + else + false + end + + send_json(socket, 202, %{ + "queued" => true, + "coalesced" => coalesced, + "requested_at" => Utils.isoformat_z(Utils.now_utc()), + "operations" => ["poll", "reconcile"] + }) + catch + :exit, _ -> + send_json(socket, 202, %{ + "queued" => true, + "coalesced" => true, + "requested_at" => Utils.isoformat_z(Utils.now_utc()), + "operations" => ["poll", "reconcile"] + }) + end + + defp route(socket, "GET", "/api/v1/" <> issue_identifier, orchestrator) do + case issue_snapshot(orchestrator, URI.decode(issue_identifier)) do + nil -> + send_json(socket, 404, %{ + "error" => %{ + "code" => "issue_not_found", + "message" => "issue is not known: #{issue_identifier}" + } + }) + + detail -> + send_json(socket, 200, detail) + end + end + + defp route(socket, _method, _path, _orchestrator), + do: + send_json(socket, 404, %{ + "error" => %{"code" => "not_found", "message" => "route not found"} + }) + + defp snapshot(source) when is_pid(source) do + Orchestrator.cached_snapshot(source) || + Orchestrator.snapshot(source, @agent_read_timeout_ms) + catch + :exit, _ -> persisted_snapshot() + end + + defp snapshot(source), do: Orchestrator.snapshot(source) + + defp issue_snapshot(source, issue_identifier) when is_pid(source) do + Orchestrator.cached_issue_snapshot(source, issue_identifier) || + Orchestrator.issue_snapshot(source, issue_identifier, @agent_read_timeout_ms) + catch + :exit, _ -> persisted_issue_snapshot(issue_identifier) + end + + defp issue_snapshot(source, issue_identifier), + do: Orchestrator.issue_snapshot(source, issue_identifier) + + defp send_json(socket, status, payload), + do: send_response(socket, status, "application/json; charset=utf-8", Jason.encode!(payload)) + + defp send_html(socket, status, body), + do: send_response(socket, status, "text/html; charset=utf-8", body) + + defp send_response(socket, status, content_type, body) do + reason = + %{200 => "OK", 202 => "Accepted", 404 => "Not Found", 500 => "Internal Server Error"}[ + status + ] || "OK" + + response = [ + "HTTP/1.1 #{status} #{reason}\r\n", + "Content-Type: #{content_type}\r\n", + "Content-Length: #{byte_size(body)}\r\n", + "Connection: close\r\n\r\n", + body + ] + + :gen_tcp.send(socket, response) + end + + defp persisted_snapshot do + case persisted_payload() do + {:ok, payload} -> fallback_snapshot(payload) + :error -> empty_snapshot() + end + end + + defp persisted_issue_snapshot(issue_identifier) do + case persisted_payload() do + {:ok, payload} -> + issue_identifier = to_string(issue_identifier) + + Enum.find_value(payload["completed"] || [], fn entry -> + issue = if is_map(entry), do: entry["issue"] || %{}, else: %{} + + if issue["identifier"] == issue_identifier do + fallback_completed_entry(entry) + end + end) + + :error -> + nil + end + end + + defp persisted_payload do + with {:ok, body} <- @fallback_state_path |> Path.expand(File.cwd!()) |> File.read(), + {:ok, payload} when is_map(payload) <- Jason.decode(body) do + {:ok, payload} + else + _ -> :error + end + end + + defp fallback_snapshot(payload) do + retrying = Enum.map(payload["retry_attempts"] || [], &fallback_retry_entry/1) + continuing_count = Enum.count(retrying, &(&1["kind"] == "continuation")) + retrying_count = length(retrying) - continuing_count + blocked = Enum.map(payload["blocked"] || [], &fallback_blocked_entry/1) + completed = Enum.map(payload["completed"] || [], &fallback_completed_entry/1) + service = if is_map(payload["service"]), do: payload["service"], else: %{} + + %{ + "generated_at" => Utils.isoformat_z(Utils.now_utc()), + "service" => %{ + "status" => "busy", + "startup_completed_at" => service["startup_completed_at"], + "last_poll_started_at" => service["last_poll_started_at"], + "last_poll_completed_at" => service["last_poll_completed_at"], + "last_poll_error" => service["last_poll_error"], + "last_candidate_count" => service["last_candidate_count"], + "poll_interval_ms" => service["poll_interval_ms"], + "max_concurrent_agents" => service["max_concurrent_agents"], + "snapshot_source" => "persisted_state" + }, + "counts" => %{ + "running" => 0, + "continuing" => continuing_count, + "retrying" => retrying_count, + "queued" => length(retrying), + "blocked" => length(blocked), + "completed" => length(completed) + }, + "running" => [], + "retrying" => retrying, + "blocked" => blocked, + "completed" => completed, + "codex_totals" => payload["codex_totals"] || %{}, + "rate_limits" => payload["rate_limits"] + } + end + + defp empty_snapshot do + %{ + "generated_at" => Utils.isoformat_z(Utils.now_utc()), + "service" => %{"status" => "starting", "snapshot_source" => "empty"}, + "counts" => %{ + "running" => 0, + "continuing" => 0, + "retrying" => 0, + "queued" => 0, + "blocked" => 0, + "completed" => 0 + }, + "running" => [], + "retrying" => [], + "blocked" => [], + "completed" => [], + "codex_totals" => %{}, + "rate_limits" => nil + } + end + + defp fallback_retry_entry(entry) when is_map(entry) do + kind = if entry["error"], do: "retry", else: "continuation" + + %{ + "issue_id" => entry["issue_id"], + "issue_identifier" => entry["issue_identifier"] || entry["identifier"], + "kind" => kind, + "status" => if(kind == "continuation", do: "continuing", else: "retrying"), + "attempt" => entry["attempt"], + "due_at" => entry["due_at"] || entry["due_at_wall"], + "due_in_seconds" => due_in_seconds(entry["due_at"] || entry["due_at_wall"]), + "error" => entry["error"] + } + end + + defp fallback_retry_entry(_entry), do: %{} + + defp fallback_blocked_entry(entry) when is_map(entry) do + issue = entry["issue"] || %{} + + %{ + "issue_id" => issue["id"], + "issue_identifier" => issue["identifier"], + "title" => issue["title"], + "url" => issue["url"], + "state" => issue["state"], + "labels" => issue["labels"] || [], + "blocked_at" => entry["blocked_at"], + "reason" => entry["reason"], + "workspace" => %{"path" => entry["workspace_path"]}, + "repo_plan" => entry["repo_plan"] + } + end + + defp fallback_blocked_entry(_entry), do: %{} + + defp fallback_completed_entry(entry) when is_map(entry) do + issue = entry["issue"] || %{} + + %{ + "issue_id" => issue["id"], + "issue_identifier" => issue["identifier"], + "title" => issue["title"], + "url" => issue["url"], + "state" => issue["state"], + "labels" => issue["labels"] || [], + "completed_at" => entry["completed_at"], + "reason" => entry["reason"], + "workspace" => %{"path" => entry["workspace_path"]}, + "repo_plan" => entry["repo_plan"], + "summary" => entry["summary"] || %{}, + "tokens" => entry["tokens"] || %{}, + "activity" => entry["recent_activity"] || entry["activity"] || [] + } + end + + defp fallback_completed_entry(_entry), do: %{} + + defp due_in_seconds(nil), do: nil + + defp due_in_seconds(value) do + case Utils.parse_datetime(value) do + %DateTime{} = due_at -> max(DateTime.diff(due_at, Utils.now_utc(), :second), 0) + _ -> nil + end + end + + defp dashboard_html(snapshot) do + """ + + + + + + Symphony + + + +
+
+

Symphony

+
Generated at #{html_escape(snapshot["generated_at"])}
+
+
+ + State JSON +
+
+
+
+
+

Running Agents

+
+
+
+

Continuing / Retrying

+
+
+
+

Blocked

+
+
+
+

Completed

+
+
+
+ + + + """ + end + + defp html_escape(value) do + value + |> to_string() + |> String.replace("&", "&") + |> String.replace("<", "<") + |> String.replace(">", ">") + |> String.replace("\"", """) + |> String.replace("'", "'") + end +end diff --git a/lib/symphony/logging.ex b/lib/symphony/logging.ex new file mode 100644 index 0000000..f1a6d3e --- /dev/null +++ b/lib/symphony/logging.ex @@ -0,0 +1,11 @@ +defmodule Symphony.Logging do + @moduledoc false + + require Logger + + alias Symphony.Utils + + def log_event(level, event, fields \\ []) do + Logger.log(level, Utils.key_value_message(event, fields)) + end +end diff --git a/lib/symphony/models.ex b/lib/symphony/models.ex new file mode 100644 index 0000000..32872ba --- /dev/null +++ b/lib/symphony/models.ex @@ -0,0 +1,303 @@ +defmodule Symphony.Models do + @moduledoc false + + alias Symphony.Utils + + defmodule BlockerRef do + defstruct id: nil, identifier: nil, state: nil + + def to_map(%__MODULE__{} = blocker) do + %{"id" => blocker.id, "identifier" => blocker.identifier, "state" => blocker.state} + end + end + + defmodule IssueAttachment do + defstruct id: nil, title: nil, subtitle: nil, url: nil + + def to_map(%__MODULE__{} = attachment) do + %{ + "id" => attachment.id, + "title" => attachment.title, + "subtitle" => attachment.subtitle, + "url" => attachment.url + } + end + end + + defmodule IssueAssignee do + defstruct id: nil, name: nil, display_name: nil, email: nil, url: nil, mention: nil + + def to_map(%__MODULE__{} = assignee) do + %{ + "id" => assignee.id, + "name" => assignee.name, + "display_name" => assignee.display_name, + "email" => assignee.email, + "url" => assignee.url, + "mention" => assignee.mention + } + end + end + + defmodule Issue do + defstruct [ + :id, + :identifier, + :title, + description: nil, + priority: nil, + state: "", + branch_name: nil, + url: nil, + assignee: nil, + labels: [], + attachments: [], + blocked_by: [], + created_at: nil, + updated_at: nil + ] + + def to_template_data(%__MODULE__{} = issue) do + %{ + "id" => issue.id, + "identifier" => issue.identifier, + "title" => issue.title, + "description" => issue.description, + "priority" => issue.priority, + "state" => issue.state, + "branch_name" => issue.branch_name, + "url" => issue.url, + "assignee" => if(issue.assignee, do: IssueAssignee.to_map(issue.assignee), else: nil), + "labels" => issue.labels, + "attachments" => Enum.map(issue.attachments, &IssueAttachment.to_map/1), + "blocked_by" => Enum.map(issue.blocked_by, &BlockerRef.to_map/1), + "created_at" => Utils.isoformat_z(issue.created_at), + "updated_at" => Utils.isoformat_z(issue.updated_at) + } + end + end + + defmodule WorkflowDefinition do + defstruct config: %{}, prompt_template: "", path: nil, mtime_ns: nil + end + + defmodule Workspace do + defstruct path: nil, + workspace_key: nil, + created_now: false, + repo_plan: nil, + primary_repo_path: nil + end + + defmodule RepoPlanItem do + defstruct slug: nil, role: nil, reason: nil, path_name: nil, edit_allowed: true + + def to_map(%__MODULE__{} = item) do + %{ + "slug" => item.slug, + "role" => item.role, + "reason" => item.reason, + "path_name" => item.path_name, + "edit_allowed" => item.edit_allowed + } + end + end + + defmodule RepoPlan do + defstruct [ + :issue_identifier, + :coding_task, + :planner, + :source, + primary_repo: nil, + secondary_repos: [], + read_only_context_repos: [], + confidence: nil, + needs_human: false, + human_reason: nil, + notes: nil, + created_at: nil + ] + + def all_repos(%__MODULE__{} = plan) do + [plan.primary_repo] + |> Enum.reject(&is_nil/1) + |> Kernel.++(plan.secondary_repos) + |> Kernel.++(plan.read_only_context_repos) + end + + def edit_allowed_slugs(%__MODULE__{} = plan) do + plan + |> all_repos() + |> Enum.filter(&(&1.edit_allowed and &1.role != "read_only_context")) + |> Enum.map(& &1.slug) + |> MapSet.new() + end + + def to_map(%__MODULE__{} = plan) do + %{ + "issue_identifier" => plan.issue_identifier, + "coding_task" => plan.coding_task, + "planner" => plan.planner, + "source" => plan.source, + "primary_repo" => + if(plan.primary_repo, do: RepoPlanItem.to_map(plan.primary_repo), else: nil), + "secondary_repos" => Enum.map(plan.secondary_repos, &RepoPlanItem.to_map/1), + "read_only_context_repos" => + Enum.map(plan.read_only_context_repos, &RepoPlanItem.to_map/1), + "confidence" => plan.confidence, + "needs_human" => plan.needs_human, + "human_reason" => plan.human_reason, + "notes" => plan.notes, + "created_at" => Utils.isoformat_z(plan.created_at) + } + end + end + + defmodule CodexTotals do + defstruct input_tokens: 0, output_tokens: 0, total_tokens: 0, seconds_running: 0.0 + + def to_map(%__MODULE__{} = totals) do + %{ + "input_tokens" => totals.input_tokens, + "output_tokens" => totals.output_tokens, + "total_tokens" => totals.total_tokens, + "seconds_running" => totals.seconds_running + } + end + end + + defmodule RetryEntry do + defstruct [ + :issue_id, + :identifier, + :attempt, + :due_at_monotonic, + :due_at_wall, + error: nil, + timer_ref: nil + ] + end + + defmodule ReviewFeedbackState do + defstruct issue_id: nil, + identifier: nil, + fingerprint: nil, + latest_feedback_at: nil, + last_triggered_at: nil + + def to_map(%__MODULE__{} = state) do + %{ + "issue_id" => state.issue_id, + "identifier" => state.identifier, + "fingerprint" => state.fingerprint, + "latest_feedback_at" => Utils.isoformat_z(state.latest_feedback_at), + "last_triggered_at" => Utils.isoformat_z(state.last_triggered_at) + } + end + end + + defmodule BlockedEntry do + defstruct issue: nil, + reason: nil, + blocked_at: nil, + workspace_path: nil, + repo_plan: nil, + escalation_comment_id: nil, + escalation_fingerprint: nil, + escalation_at: nil, + escalation_error: nil + end + + defmodule CompletedEntry do + defstruct [ + :issue, + :completed_at, + :reason, + workspace_path: nil, + repo_plan: nil, + duration_seconds: 0.0, + turn_count: 0, + session_id: nil, + thread_id: nil, + turn_id: nil, + codex_input_tokens: 0, + codex_output_tokens: 0, + codex_total_tokens: 0, + summary_text: nil, + summary_current_step: nil, + summary_needs_human: false, + summary_human_reason: nil, + summary_risk: nil, + summary_confidence: nil, + summary_updated_at: nil, + repo_deviations: [], + recent_activity: [] + ] + end + + defmodule RunningEntry do + defstruct [ + :issue, + task: nil, + workspace_path: nil, + started_at: nil, + started_monotonic: nil, + retry_attempt: nil, + session_id: nil, + thread_id: nil, + turn_id: nil, + codex_app_server_pid: nil, + last_codex_event: nil, + last_codex_timestamp: nil, + last_codex_message: nil, + repo_plan: nil, + repo_deviations: [], + recent_activity: [], + activity_revision: 0, + summary_revision: 0, + summary_pending: false, + summary_text: nil, + summary_current_step: nil, + summary_needs_human: false, + summary_human_reason: nil, + summary_risk: nil, + summary_confidence: nil, + summary_updated_at: nil, + summary_error: nil, + summary_source: nil, + last_summary_monotonic: 0.0, + codex_input_tokens: 0, + codex_output_tokens: 0, + codex_total_tokens: 0, + last_reported_input_tokens: 0, + last_reported_output_tokens: 0, + last_reported_total_tokens: 0, + turn_count: 0, + forced_outcome: nil, + forced_error: nil, + cleanup_workspace: false + ] + end + + defmodule RuntimeState do + defstruct [ + :poll_interval_ms, + :max_concurrent_agents, + service_status: "starting", + startup_completed_at: nil, + last_poll_started_at: nil, + last_poll_completed_at: nil, + last_poll_error: nil, + last_candidate_count: nil, + running: %{}, + claimed: MapSet.new(), + retry_attempts: %{}, + review_feedback: %{}, + blocked: %{}, + completed: %{}, + codex_totals: %CodexTotals{}, + codex_rate_limits: nil + ] + end +end diff --git a/lib/symphony/orchestrator.ex b/lib/symphony/orchestrator.ex new file mode 100644 index 0000000..6be63bd --- /dev/null +++ b/lib/symphony/orchestrator.ex @@ -0,0 +1,2847 @@ +defmodule Symphony.Orchestrator do + @moduledoc false + + use GenServer + + alias Symphony.AgentRunner.AgentRunResult + alias Symphony.CodingContext.CodingClassification + alias Symphony.Config.{ConfigManager, ServiceConfig, TrackerConfig} + alias Symphony.DashboardSummary + alias Symphony.Logging + alias Symphony.RepoPlanner + + alias Symphony.Models.{ + BlockedEntry, + BlockerRef, + CodexTotals, + CompletedEntry, + Issue, + IssueAssignee, + IssueAttachment, + RepoPlan, + RepoPlanItem, + ReviewFeedbackState, + RetryEntry, + RunningEntry, + RuntimeState + } + + alias Symphony.Review + alias Symphony.Review.ReviewPullRequestResolver + alias Symphony.Tracker + alias Symphony.Utils + alias Symphony.Workspace.Manager, as: WorkspaceManager + + @continuation_retry_ms 1_000 + @default_call_timeout_ms 5_000 + @persist_coalesce_ms 500 + @review_reconcile_timeout_ms 30_000 + @blocked_escalation_header "## Symphony Blocked Escalation" + @snapshot_cache_table :symphony_orchestrator_snapshot_cache + @state_file_name ".symphony-state.json" + + defstruct config_manager: nil, + state: nil, + review_resolver: nil, + task_supervisor: nil, + refresh_requested: false, + refreshing: false, + refresh_timer_ref: nil, + persist_timer_ref: nil, + review_task: nil, + worker_tasks: %{}, + summary_tasks: %{}, + agent_runner: Symphony.AgentRunner, + dashboard_summary: DashboardSummary, + tracker_factory: &Tracker.make_tracker/1 + + def start_link(%ConfigManager{} = manager, opts \\ []) do + genserver_opts = Keyword.take(opts, [:name]) + GenServer.start_link(__MODULE__, {manager, opts}, genserver_opts) + end + + def request_refresh(pid, timeout \\ @default_call_timeout_ms) when is_pid(pid), + do: GenServer.call(pid, :request_refresh, timeout) + + def stop(pid) when is_pid(pid), do: GenServer.stop(pid, :normal, :infinity) + + @impl true + def init({%ConfigManager{} = manager, opts}) do + Process.flag(:trap_exit, true) + {:ok, task_supervisor} = Task.Supervisor.start_link() + + orchestrator = + manager + |> new(opts) + |> Map.put(:task_supervisor, task_supervisor) + + ensure_snapshot_cache_table!() + + {:ok, orchestrator, {:continue, :startup}} + end + + @impl true + def handle_continue(:startup, orchestrator) do + orchestrator = publish_snapshot_cache(orchestrator) + + orchestrator = + orchestrator + |> startup_terminal_workspace_cleanup() + |> restore_retry_timers() + + {_manager, _workflow, config} = ConfigManager.current(orchestrator.config_manager) + orchestrator = reconcile_blocked_issues(orchestrator, [], config) + + state = %{ + orchestrator.state + | service_status: "running", + startup_completed_at: Utils.now_utc(), + last_poll_error: nil + } + + orchestrator = + orchestrator + |> Map.put(:state, state) + |> persist_state() + |> publish_snapshot_cache() + |> request_refresh_internal() + + Logging.log_event(:info, "service_started", + workflow_path: orchestrator.config_manager.workflow_path + ) + + {:noreply, publish_snapshot_cache(orchestrator)} + end + + @impl true + def handle_call(:request_refresh, _from, orchestrator) do + coalesced = + orchestrator.refresh_requested or orchestrator.refreshing or + not is_nil(orchestrator.refresh_timer_ref) + + {:reply, coalesced, request_refresh_internal(orchestrator)} + end + + def handle_call(:snapshot, _from, orchestrator), + do: {:reply, snapshot(orchestrator), orchestrator} + + def handle_call({:issue_snapshot, issue_identifier}, _from, orchestrator), + do: {:reply, issue_snapshot(orchestrator, issue_identifier), orchestrator} + + @impl true + def handle_cast({:codex_event, issue_id, event}, orchestrator) do + orchestrator = handle_codex_event(orchestrator, issue_id, event) + orchestrator = maybe_schedule_summary(orchestrator, issue_id) + orchestrator = publish_snapshot_cache(orchestrator) + {:noreply, publish_snapshot_cache(orchestrator)} + end + + @impl true + def handle_info(:run_refresh, %{refreshing: true} = orchestrator), + do: {:noreply, %{orchestrator | refresh_requested: true, refresh_timer_ref: nil}} + + def handle_info(:run_refresh, orchestrator) do + orchestrator = + orchestrator + |> Map.put(:refreshing, true) + |> Map.put(:refresh_requested, false) + |> Map.put(:refresh_timer_ref, nil) + |> tick_runtime() + |> Map.put(:refreshing, false) + |> publish_snapshot_cache() + + orchestrator = + if orchestrator.refresh_requested do + orchestrator + |> Map.put(:refresh_requested, false) + |> request_refresh_internal() + else + schedule_next_refresh(orchestrator) + end + + {:noreply, orchestrator} + end + + def handle_info(:persist_state, orchestrator) do + orchestrator = + orchestrator + |> Map.put(:persist_timer_ref, nil) + |> persist_state() + + {:noreply, orchestrator} + end + + def handle_info({:summary_timeout, ref}, orchestrator) when is_reference(ref) do + case Map.pop(orchestrator.summary_tasks, ref) do + {nil, _summary_tasks} -> + {:noreply, orchestrator} + + {meta, summary_tasks} -> + Process.demonitor(ref, [:flush]) + shutdown_task(meta.task) + + reason = "summary timed out after #{meta.timeout_ms} ms" + + orchestrator = + %{orchestrator | summary_tasks: summary_tasks} + |> apply_summary_result(meta.issue_id, meta.activity_revision, {:error, reason}) + |> publish_snapshot_cache() + + {:noreply, orchestrator} + end + end + + def handle_info({:retry_due, issue_id}, orchestrator) do + {_manager, _workflow, config} = ConfigManager.current(orchestrator.config_manager) + tracker = make_tracker(orchestrator, config.tracker) + retry = orchestrator.state.retry_attempts[issue_id] + + orchestrator = + if retry && available_global_slots(orchestrator, config) > 0 do + state = %{ + orchestrator.state + | retry_attempts: Map.delete(orchestrator.state.retry_attempts, issue_id) + } + + orchestrator = %{orchestrator | state: state} + issue = refresh_retry_issue(tracker, retry) + + if MapSet.member?( + TrackerConfig.active_state_set(config.tracker), + Utils.normalize_state(issue.state) + ) and + is_dispatch_eligible(orchestrator, issue, config, ignore_claimed_issue_id: issue_id) do + dispatch_issue_async(orchestrator, issue, tracker, retry.attempt) + else + state = %{ + orchestrator.state + | claimed: MapSet.delete(orchestrator.state.claimed, issue_id) + } + + %{orchestrator | state: state} + |> persist_state() + |> publish_snapshot_cache() + end + else + orchestrator + end + + {:noreply, orchestrator} + end + + def handle_info({ref, %AgentRunResult{} = result}, orchestrator) when is_reference(ref) do + Process.demonitor(ref, [:flush]) + {issue_id, worker_tasks} = Map.pop(orchestrator.worker_tasks, ref) + orchestrator = %{orchestrator | worker_tasks: worker_tasks} + + orchestrator = + if issue_id, do: handle_worker_done(orchestrator, issue_id, result), else: orchestrator + + {:noreply, publish_snapshot_cache(orchestrator)} + end + + def handle_info({ref, {:summary_result, issue_id, activity_revision, result}}, orchestrator) + when is_reference(ref) do + Process.demonitor(ref, [:flush]) + {meta, summary_tasks} = Map.pop(orchestrator.summary_tasks, ref) + if meta && meta.timer_ref, do: Process.cancel_timer(meta.timer_ref) + orchestrator = %{orchestrator | summary_tasks: summary_tasks} + + {:noreply, + orchestrator + |> apply_summary_result(issue_id, activity_revision, result) + |> publish_snapshot_cache()} + end + + def handle_info({ref, {:review_reconcile_result, result}}, orchestrator) + when is_reference(ref) do + case orchestrator.review_task do + %{ref: ^ref} = task_meta -> + Process.demonitor(ref, [:flush]) + if task_meta.timer_ref, do: Process.cancel_timer(task_meta.timer_ref) + log_review_reconcile_result(result) + + orchestrator = + orchestrator + |> Map.put(:review_task, nil) + |> apply_review_reconciliation_result(result) + |> publish_snapshot_cache() + + {:noreply, orchestrator} + + _ -> + {:noreply, orchestrator} + end + end + + def handle_info({:review_reconcile_timeout, ref}, orchestrator) when is_reference(ref) do + case orchestrator.review_task do + %{ref: ^ref, task: task} -> + Process.demonitor(ref, [:flush]) + shutdown_task(task) + + Logging.log_event(:warning, "review_reconcile_timed_out", + timeout_ms: review_reconcile_timeout_ms() + ) + + {:noreply, %{orchestrator | review_task: nil}} + + _ -> + {:noreply, orchestrator} + end + end + + def handle_info({:DOWN, ref, :process, _pid, reason}, orchestrator) do + {issue_id, worker_tasks} = Map.pop(orchestrator.worker_tasks, ref) + {summary_meta, summary_tasks} = Map.pop(orchestrator.summary_tasks, ref) + + cond do + issue_id -> + orchestrator = %{orchestrator | worker_tasks: worker_tasks} + + result = %AgentRunResult{ + issue_id: issue_id, + normal: false, + reason: "worker crashed: #{inspect(reason)}" + } + + {:noreply, + orchestrator |> handle_worker_done(issue_id, result) |> publish_snapshot_cache()} + + summary_meta -> + if summary_meta.timer_ref, do: Process.cancel_timer(summary_meta.timer_ref) + + orchestrator = + %{orchestrator | summary_tasks: summary_tasks} + |> apply_summary_result( + summary_meta.issue_id, + summary_meta.activity_revision, + {:error, "summary worker crashed: #{inspect(reason)}"} + ) + |> publish_snapshot_cache() + + {:noreply, orchestrator} + + orchestrator.review_task && orchestrator.review_task.ref == ref -> + if orchestrator.review_task.timer_ref, + do: Process.cancel_timer(orchestrator.review_task.timer_ref) + + Logging.log_event(:warning, "review_reconcile_task_exited", reason: inspect(reason)) + + {:noreply, %{orchestrator | review_task: nil}} + + true -> + {:noreply, orchestrator} + end + end + + def handle_info({:EXIT, _source, :normal}, orchestrator), do: {:noreply, orchestrator} + + def handle_info({:EXIT, source, reason}, orchestrator) do + Logging.log_event(:warning, "linked_process_exited", + source: inspect(source), + reason: inspect(reason) + ) + + {:noreply, orchestrator} + end + + def handle_info(message, orchestrator) do + Logging.log_event(:debug, "unexpected_orchestrator_message", message: inspect(message)) + {:noreply, orchestrator} + end + + @impl true + def terminate(_reason, orchestrator) do + Enum.each(orchestrator.state.retry_attempts, fn {_issue_id, retry} -> + if retry.timer_ref, do: Process.cancel_timer(retry.timer_ref) + end) + + Enum.each(orchestrator.state.running, fn {_issue_id, entry} -> + shutdown_task(entry.task) + end) + + Enum.each(orchestrator.summary_tasks, fn {ref, meta} -> + if meta.timer_ref, do: Process.cancel_timer(meta.timer_ref) + Process.demonitor(ref, [:flush]) + shutdown_task(meta.task) + end) + + if orchestrator.review_task do + if orchestrator.review_task.timer_ref, + do: Process.cancel_timer(orchestrator.review_task.timer_ref) + + Process.demonitor(orchestrator.review_task.ref, [:flush]) + shutdown_task(orchestrator.review_task.task) + end + + if orchestrator.persist_timer_ref, do: Process.cancel_timer(orchestrator.persist_timer_ref) + delete_snapshot_cache() + persist_state(orchestrator) + :ok + end + + def new(%ConfigManager{} = manager, opts \\ []) do + {manager, _workflow, config} = ConfigManager.current(manager) + + orchestrator = %__MODULE__{ + config_manager: manager, + state: %RuntimeState{ + poll_interval_ms: config.polling.interval_ms, + max_concurrent_agents: config.agent.max_concurrent_agents + }, + review_resolver: Keyword.get(opts, :review_resolver, ReviewPullRequestResolver.new()), + agent_runner: Keyword.get(opts, :agent_runner, Symphony.AgentRunner), + dashboard_summary: Keyword.get(opts, :dashboard_summary, DashboardSummary), + tracker_factory: Keyword.get(opts, :tracker_factory, &Tracker.make_tracker/1) + } + + load_persisted_state(orchestrator) + end + + def sort_for_dispatch(issues) do + Enum.sort_by(issues, fn issue -> + {issue.priority || 999_999, issue.created_at || Utils.now_utc(), issue.identifier} + end) + end + + def tick(%__MODULE__{} = orchestrator) do + {manager, _changed?} = ConfigManager.reload_if_changed(orchestrator.config_manager) + orchestrator = %{orchestrator | config_manager: manager} + + try do + ConfigManager.validate_for_dispatch!(manager) + {_manager, _workflow, config} = ConfigManager.current(manager) + tracker = Tracker.make_tracker(config.tracker) + + orchestrator = %{ + orchestrator + | state: %{ + orchestrator.state + | service_status: "polling", + last_poll_started_at: Utils.now_utc(), + last_poll_error: nil + } + } + + orchestrator = process_due_retries(orchestrator, tracker, config) + candidates = call_tracker(tracker, :fetch_candidate_issues, []) + + orchestrator = + reconcile_blocked_issues(orchestrator, candidates, config, + candidate_snapshot_complete: true + ) + + orchestrator = + candidates + |> sort_for_dispatch() + |> Enum.reduce(orchestrator, fn issue, acc -> + if available_global_slots(acc, config) > 0 and is_dispatch_eligible(acc, issue, config) do + dispatch_issue_sync(acc, issue, tracker) + else + acc + end + end) + + orchestrator = safe_reconcile_review_issues(orchestrator, tracker, config) + + state = %{ + orchestrator.state + | service_status: "running", + last_poll_completed_at: Utils.now_utc(), + last_candidate_count: length(candidates), + last_poll_error: nil + } + + persist_state(%{orchestrator | state: state}) + rescue + error -> + state = %{ + orchestrator.state + | service_status: "degraded", + last_poll_completed_at: Utils.now_utc(), + last_poll_error: Utils.truncate(Exception.message(error), 500) + } + + persist_state(%{orchestrator | state: state}) + end + end + + defp request_refresh_internal(%{refreshing: true} = orchestrator), + do: %{orchestrator | refresh_requested: true} + + defp request_refresh_internal(orchestrator) do + if orchestrator.refresh_timer_ref, do: Process.cancel_timer(orchestrator.refresh_timer_ref) + ref = Process.send_after(self(), :run_refresh, 0) + %{orchestrator | refresh_timer_ref: ref, refresh_requested: false} + end + + defp schedule_next_refresh(orchestrator) do + {_manager, _workflow, config} = ConfigManager.current(orchestrator.config_manager) + if orchestrator.refresh_timer_ref, do: Process.cancel_timer(orchestrator.refresh_timer_ref) + ref = Process.send_after(self(), :run_refresh, config.polling.interval_ms) + %{orchestrator | refresh_timer_ref: ref, refresh_requested: false} + end + + defp tick_runtime(%__MODULE__{} = orchestrator) do + {manager, _changed?} = ConfigManager.reload_if_changed(orchestrator.config_manager) + orchestrator = %{orchestrator | config_manager: manager} + + {_manager, _workflow, config} = ConfigManager.current(manager) + tracker = make_tracker(orchestrator, config.tracker) + + orchestrator = + %{ + orchestrator + | state: %{ + orchestrator.state + | service_status: "polling", + poll_interval_ms: config.polling.interval_ms, + max_concurrent_agents: config.agent.max_concurrent_agents, + last_poll_started_at: Utils.now_utc(), + last_poll_error: nil + } + } + |> reconcile_running_issues(tracker, config) + + try do + ConfigManager.validate_for_dispatch!(manager) + + candidates = call_tracker(tracker, :fetch_candidate_issues, []) + + orchestrator = + reconcile_blocked_issues(orchestrator, candidates, config, + candidate_snapshot_complete: true + ) + + orchestrator = + candidates + |> sort_for_dispatch() + |> Enum.reduce(orchestrator, fn issue, acc -> + if available_global_slots(acc, config) > 0 and is_dispatch_eligible(acc, issue, config) do + dispatch_issue_async(acc, issue, tracker) + else + acc + end + end) + + state = %{ + orchestrator.state + | service_status: "running", + last_poll_completed_at: Utils.now_utc(), + last_candidate_count: length(candidates), + last_poll_error: nil + } + + orchestrator + |> Map.put(:state, state) + |> persist_state() + |> start_review_reconciliation(tracker, config) + rescue + error -> + state = %{ + orchestrator.state + | service_status: "degraded", + last_poll_completed_at: Utils.now_utc(), + last_poll_error: Utils.truncate(Exception.message(error), 500) + } + + Logging.log_event(:error, "poll_failed", reason: Exception.message(error)) + persist_state(%{orchestrator | state: state}) + end + end + + def startup_terminal_workspace_cleanup(%__MODULE__{} = orchestrator) do + {_manager, _workflow, config} = ConfigManager.current(orchestrator.config_manager) + tracker = make_tracker(orchestrator, config.tracker) + + terminal = + try do + call_tracker(tracker, :fetch_issues_by_states, [config.tracker.terminal_states]) + rescue + error -> + Logging.log_event(:warning, "startup_cleanup_failed", reason: Exception.message(error)) + [] + end + + workspace_manager = WorkspaceManager.new(config.workspace, config.hooks) + Enum.each(terminal, &WorkspaceManager.remove_for_identifier(workspace_manager, &1.identifier)) + orchestrator + end + + def reconcile_running_issues(%__MODULE__{} = orchestrator, tracker, %ServiceConfig{} = config) do + orchestrator = reconcile_stalled(orchestrator, config) + running_ids = Map.keys(orchestrator.state.running) + + if running_ids == [] do + orchestrator + else + refreshed = + try do + call_tracker(tracker, :fetch_issue_states_by_ids, [running_ids]) + rescue + error -> + Logging.log_event(:warning, "running_state_refresh_failed", + reason: Exception.message(error) + ) + + [] + end + + refreshed_by_id = Map.new(refreshed, &{&1.id, &1}) + + Enum.reduce(running_ids, orchestrator, fn issue_id, acc -> + case refreshed_by_id[issue_id] do + nil -> + acc + + %Issue{} = issue -> + state = Utils.normalize_state(issue.state) + + cond do + MapSet.member?(TrackerConfig.terminal_state_set(config.tracker), state) -> + terminate_running_issue(acc, issue_id, + cleanup_workspace: true, + retry: false, + reason: "terminal_state" + ) + + !has_required_labels?(issue, config) -> + terminate_running_issue(acc, issue_id, + cleanup_workspace: false, + retry: false, + reason: "required_label_removed" + ) + + MapSet.member?(TrackerConfig.active_state_set(config.tracker), state) -> + update_in(acc.state.running[issue_id].issue, fn _ -> issue end) + + true -> + terminate_running_issue(acc, issue_id, + cleanup_workspace: false, + retry: false, + reason: "non_active_state" + ) + end + end + end) + end + end + + def reconcile_blocked_issues(orchestrator, candidates, config, opts \\ []) + + def reconcile_blocked_issues( + %__MODULE__{} = orchestrator, + candidates, + %ServiceConfig{} = config, + opts + ) + when is_list(candidates) do + candidates_by_id = Map.new(candidates, &{&1.id, &1}) + candidate_snapshot_complete? = Keyword.get(opts, :candidate_snapshot_complete, false) + + tracker = + Keyword.get_lazy(opts, :tracker, fn -> make_tracker(orchestrator, config.tracker) end) + + orchestrator.state.blocked + |> Enum.reduce(orchestrator, fn {issue_id, blocked}, acc -> + current_issue = candidates_by_id[issue_id] + issue = current_issue || blocked.issue + + cond do + candidate_snapshot_complete? and is_nil(current_issue) -> + release_blocked_issue(acc, issue_id, "blocked issue is no longer a candidate") + + !blocked_issue_still_active?(issue, config) -> + release_blocked_issue(acc, issue_id, "blocked issue is no longer dispatchable") + + blocked_issue_has_human_response?(tracker, blocked) -> + release_blocked_issue(acc, issue_id, "human response added after block") + + !blocked_rules_tie?(blocked) -> + maybe_escalate_blocked_issue(acc, tracker, config, blocked) + + true -> + acc = maybe_escalate_blocked_issue(acc, tracker, config, blocked) + rules_config = %{config.repositories | planner: "rules"} + + plan = + RepoPlanner.plan_repositories( + issue, + rules_config, + config.context.coding, + %CodingClassification{is_coding_task: true, source: "blocked_reconcile"} + ) + + if plan && !plan.needs_human do + Logging.log_event(:info, "repo_plan_block_released", + issue_id: issue.id, + issue_identifier: issue.identifier, + primary_repo: plan.primary_repo && plan.primary_repo.slug + ) + + release_blocked_issue(acc, issue_id, "rules repo plan no longer needs human") + else + acc + end + end + end) + end + + def reconcile_blocked_issues(%__MODULE__{} = orchestrator, _candidates, _config, _opts), + do: orchestrator + + defp blocked_rules_tie?(%BlockedEntry{repo_plan: %RepoPlan{} = plan}) do + to_string(plan.source) =~ "rules" and plan.needs_human and + to_string(plan.human_reason) =~ "Rules planner found tied primary repositories" + end + + defp blocked_rules_tie?(_blocked), do: false + + defp blocked_issue_still_active?(%Issue{} = issue, %ServiceConfig{} = config) do + state = Utils.normalize_state(issue.state) + + MapSet.member?(TrackerConfig.active_state_set(config.tracker), state) and + has_required_labels?(issue, config) + end + + defp release_blocked_issue(%__MODULE__{} = orchestrator, issue_id, _reason) do + state = %{ + orchestrator.state + | blocked: Map.delete(orchestrator.state.blocked, issue_id), + claimed: MapSet.delete(orchestrator.state.claimed, issue_id) + } + + %{orchestrator | state: state} + end + + defp maybe_escalate_blocked_issue( + %__MODULE__{} = orchestrator, + tracker, + %ServiceConfig{} = config, + %BlockedEntry{} = blocked + ) do + cond do + !config.tracker.blocked_escalation_enabled -> + orchestrator + + !tracker_supports?(tracker, :save_issue_comment) -> + orchestrator + + true -> + do_escalate_blocked_issue(orchestrator, tracker, config, blocked) + end + end + + defp do_escalate_blocked_issue(orchestrator, tracker, config, blocked) do + body = blocked_escalation_body(blocked, config) + fingerprint = sha256(body) + + if blocked.escalation_comment_id && blocked.escalation_fingerprint == fingerprint do + orchestrator + else + comments = blocked_issue_comments(tracker, blocked.issue) + comment_id = blocked.escalation_comment_id || find_blocked_escalation_comment_id(comments) + + response = + call_tracker( + tracker, + :save_issue_comment, + [blocked.issue.identifier, body, if(comment_id, do: [comment_id: comment_id], else: [])] + ) + + saved_comment_id = comment_id || comment_id_from_response(response) + + updated = %{ + blocked + | escalation_comment_id: saved_comment_id, + escalation_fingerprint: fingerprint, + escalation_at: Utils.now_utc(), + escalation_error: nil + } + + Logging.log_event(:info, "blocked_issue_escalated", + issue_id: blocked.issue.id, + issue_identifier: blocked.issue.identifier, + comment_id: saved_comment_id + ) + + put_blocked_entry(orchestrator, updated) + end + rescue + error -> + reason = Utils.truncate(Exception.message(error), 500) + + Logging.log_event(:warning, "blocked_issue_escalation_failed", + issue_id: blocked.issue && blocked.issue.id, + issue_identifier: blocked.issue && blocked.issue.identifier, + reason: reason + ) + + put_blocked_entry(orchestrator, %{blocked | escalation_error: reason}) + end + + defp blocked_issue_has_human_response?(tracker, %BlockedEntry{} = blocked) do + if tracker_supports?(tracker, :list_issue_comments) and blocked.blocked_at do + tracker + |> blocked_issue_comments(blocked.issue) + |> Review.linear_feedback_items() + |> Review.human_feedback_items() + |> Enum.any?(fn item -> + timestamp = item.updated_at || item.created_at + timestamp && DateTime.compare(timestamp, blocked.blocked_at) == :gt + end) + else + false + end + rescue + _ -> false + end + + defp blocked_issue_comments(tracker, %Issue{} = issue) do + if tracker_supports?(tracker, :list_issue_comments) do + call_tracker(tracker, :list_issue_comments, [issue.identifier]) + else + [] + end + end + + defp blocked_escalation_body(%BlockedEntry{} = blocked, %ServiceConfig{} = config) do + mention = blocked_escalation_mention(blocked.issue, config) + mention_line = if mention, do: "#{mention} ", else: "" + + repo_plan = + case blocked.repo_plan do + %RepoPlan{} = plan -> + """ + Repo plan: + - Primary repo: #{plan.primary_repo && plan.primary_repo.slug} + - Needs human: #{plan.needs_human} + - Human reason: #{plan.human_reason || "n/a"} + - Notes: #{plan.notes || "n/a"} + """ + + _ -> + "Repo plan: n/a" + end + + workspace = + if blocked.workspace_path, + do: "Workspace: `#{blocked.workspace_path}`", + else: "Workspace: n/a" + + """ + #{@blocked_escalation_header} + + #{mention_line}Symphony is blocked and could not resolve this autonomously. + + Reason: + #{blocked.reason} + + #{workspace} + + #{String.trim(repo_plan)} + + Next step: + Add the missing decision, repository mapping, credential, approval, or blocker resolution in this issue. After a human reply, Symphony will release the block and retry the issue on the next poll. + """ + |> String.trim() + end + + defp blocked_escalation_mention(%Issue{} = issue, %ServiceConfig{} = config) do + issue_assignee_mention(issue) || fallback_blocked_escalation_mention(config) + end + + defp fallback_blocked_escalation_mention(%ServiceConfig{} = config) do + config.tracker.blocked_escalation_mentions + |> Enum.map(&(to_string(&1) |> String.trim())) + |> Enum.find(&(&1 != "")) + end + + defp issue_assignee_mention(%Issue{assignee: %IssueAssignee{} = assignee}) do + cond do + !blank?(assignee.mention) -> + assignee.mention + + !blank?(assignee.display_name) -> + "@#{assignee.display_name}" + + !blank?(assignee.name) -> + "@#{assignee.name}" + + true -> + nil + end + end + + defp issue_assignee_mention(_), do: nil + + defp find_blocked_escalation_comment_id(comments) do + Enum.find_value(comments, fn comment -> + body = Utils.map_get(comment, "body") || Utils.map_get(comment, "text") || "" + + if String.contains?(to_string(body), @blocked_escalation_header) do + Utils.map_get(comment, "id") + end + end) + end + + defp comment_id_from_response(response) when is_map(response) do + Utils.map_get(response, "id") || get_in(response, ["comment", "id"]) + end + + defp comment_id_from_response(_), do: nil + + defp put_blocked_entry(%__MODULE__{} = orchestrator, %BlockedEntry{} = blocked) do + state = %{ + orchestrator.state + | blocked: Map.put(orchestrator.state.blocked, blocked.issue.id, blocked) + } + + %{orchestrator | state: state} + end + + defp sha256(value) do + :crypto.hash(:sha256, to_string(value)) + |> Base.encode16(case: :lower) + end + + defp reconcile_stalled(%__MODULE__{} = orchestrator, %ServiceConfig{} = config) do + if config.codex.stall_timeout_ms <= 0 do + orchestrator + else + now = Utils.now_utc() + + orchestrator.state.running + |> Enum.filter(fn {_issue_id, entry} -> + since = entry.last_codex_timestamp || entry.started_at || now + DateTime.diff(now, since, :millisecond) > config.codex.stall_timeout_ms + end) + |> Enum.reduce(orchestrator, fn {issue_id, _entry}, acc -> + terminate_running_issue(acc, issue_id, + cleanup_workspace: false, + retry: true, + reason: "stalled" + ) + end) + end + end + + def terminate_running_issue(%__MODULE__{} = orchestrator, issue_id, opts) do + cleanup_workspace = Keyword.fetch!(opts, :cleanup_workspace) + retry? = Keyword.fetch!(opts, :retry) + reason = Keyword.fetch!(opts, :reason) + + case orchestrator.state.running[issue_id] do + nil -> + orchestrator + + entry -> + entry = %{ + entry + | forced_outcome: if(retry?, do: "retry", else: "release"), + forced_error: reason, + cleanup_workspace: cleanup_workspace + } + + shutdown_task(entry.task) + + state = %{ + orchestrator.state + | running: Map.put(orchestrator.state.running, issue_id, entry) + } + + %{orchestrator | state: state} + end + end + + defp dispatch_issue_async( + %__MODULE__{} = orchestrator, + %Issue{} = issue, + tracker, + attempt \\ nil + ) do + {_manager, _workflow, config} = ConfigManager.current(orchestrator.config_manager) + workspace_manager = WorkspaceManager.new(config.workspace, config.hooks) + server = self() + runner_module = orchestrator.agent_runner + + task = + Task.Supervisor.async_nolink(orchestrator.task_supervisor, fn -> + runner = apply(runner_module, :new, [orchestrator.config_manager, tracker]) + + apply(runner_module, :run_issue, [ + runner, + issue, + attempt, + fn issue_id, event -> GenServer.cast(server, {:codex_event, issue_id, event}) end + ]) + end) + + entry = %RunningEntry{ + issue: issue, + task: task, + workspace_path: + WorkspaceManager.workspace_path_for_identifier(workspace_manager, issue.identifier), + started_at: Utils.now_utc(), + started_monotonic: System.monotonic_time(:millisecond), + retry_attempt: attempt + } + + retry = orchestrator.state.retry_attempts[issue.id] + if retry && retry.timer_ref, do: Process.cancel_timer(retry.timer_ref) + + state = %{ + orchestrator.state + | running: Map.put(orchestrator.state.running, issue.id, entry), + retry_attempts: Map.delete(orchestrator.state.retry_attempts, issue.id), + claimed: MapSet.put(orchestrator.state.claimed, issue.id) + } + + orchestrator = + %{ + orchestrator + | state: state, + worker_tasks: Map.put(orchestrator.worker_tasks, task.ref, issue.id) + } + |> persist_state() + + Logging.log_event(:info, "issue_dispatched", + issue_id: issue.id, + issue_identifier: issue.identifier, + attempt: attempt + ) + + orchestrator + end + + defp schedule_retry_runtime(%__MODULE__{} = orchestrator, %Issue{} = issue, attempt, opts) do + {_manager, _workflow, config} = ConfigManager.current(orchestrator.config_manager) + + delay_ms = + Keyword.get(opts, :delay_ms) || + min(10_000 * trunc(:math.pow(2, max(attempt - 1, 0))), config.agent.max_retry_backoff_ms) + + error = Keyword.get(opts, :error) + timer_ref = Process.send_after(self(), {:retry_due, issue.id}, delay_ms) + + entry = %RetryEntry{ + issue_id: issue.id, + identifier: issue.identifier, + attempt: attempt, + due_at_monotonic: System.monotonic_time(:millisecond) + delay_ms, + due_at_wall: DateTime.add(Utils.now_utc(), delay_ms, :millisecond), + error: error, + timer_ref: timer_ref + } + + old = orchestrator.state.retry_attempts[issue.id] + if old && old.timer_ref, do: Process.cancel_timer(old.timer_ref) + + state = %{ + orchestrator.state + | retry_attempts: Map.put(orchestrator.state.retry_attempts, issue.id, entry), + claimed: MapSet.put(orchestrator.state.claimed, issue.id) + } + + %{orchestrator | state: state} + |> persist_state() + end + + defp restore_retry_timers(%__MODULE__{} = orchestrator) do + now = Utils.now_utc() + + retry_attempts = + Map.new(orchestrator.state.retry_attempts, fn {issue_id, retry} -> + delay_ms = max(DateTime.diff(retry.due_at_wall, now, :millisecond), 0) + timer_ref = Process.send_after(self(), {:retry_due, issue_id}, delay_ms) + + {issue_id, + %{ + retry + | due_at_monotonic: System.monotonic_time(:millisecond) + delay_ms, + timer_ref: timer_ref + }} + end) + + %{orchestrator | state: %{orchestrator.state | retry_attempts: retry_attempts}} + end + + defp maybe_schedule_summary(%__MODULE__{} = orchestrator, issue_id) do + {_manager, _workflow, config} = ConfigManager.current(orchestrator.config_manager) + entry = orchestrator.state.running[issue_id] + + if should_schedule_summary?(entry, config) do + entry = %{entry | summary_pending: true} + + state = %{ + orchestrator.state + | running: Map.put(orchestrator.state.running, issue_id, entry) + } + + orchestrator = %{orchestrator | state: state} |> schedule_persist_state() + summary_module = orchestrator.dashboard_summary + activity_revision = entry.activity_revision + activity = Enum.take(entry.recent_activity, -config.dashboard.summary_max_events) + + task = + Task.Supervisor.async_nolink(orchestrator.task_supervisor, fn -> + result = + try do + {:ok, + apply(summary_module, :summarize_activity, [ + [ + issue: entry.issue, + activity: activity, + previous_summary: entry.summary_text, + codex_config: config.codex, + dashboard_config: config.dashboard, + workspace_path: entry.workspace_path + ] + ])} + rescue + error -> {:error, Utils.truncate(Exception.message(error), 500)} + end + + {:summary_result, issue_id, activity_revision, result} + end) + + timer_ref = + Process.send_after( + self(), + {:summary_timeout, task.ref}, + config.dashboard.summary_timeout_ms + ) + + meta = %{ + issue_id: issue_id, + task: task, + timer_ref: timer_ref, + activity_revision: activity_revision, + timeout_ms: config.dashboard.summary_timeout_ms + } + + %{orchestrator | summary_tasks: Map.put(orchestrator.summary_tasks, task.ref, meta)} + else + orchestrator + end + end + + defp should_schedule_summary?(nil, _config), do: false + + defp should_schedule_summary?(entry, %ServiceConfig{} = config) do + ((config.dashboard.summaries_enabled and !entry.summary_pending and entry.workspace_path) && + entry.activity_revision > entry.summary_revision) and entry.recent_activity != [] and + has_work_signal?(entry.recent_activity) and + (is_nil(entry.summary_text) or + System.monotonic_time(:millisecond) - entry.last_summary_monotonic >= + config.dashboard.summary_update_interval_ms) + end + + defp has_work_signal?(activity) do + Enum.any?(activity, fn item -> + message = to_string(item["message"] || "") + + Enum.any?( + ["Agent said:", "Command ", "File change ", "The agent requested user input"], + &String.starts_with?(message, &1) + ) + end) + end + + defp apply_summary_result(orchestrator, issue_id, activity_revision, result) do + case orchestrator.state.running[issue_id] do + nil -> + orchestrator + + entry -> + entry = + case result do + {:ok, %DashboardSummary{} = summary} -> + %{ + entry + | summary_pending: false, + summary_revision: max(entry.summary_revision, activity_revision), + summary_text: summary.summary, + summary_current_step: summary.current_step, + summary_needs_human: summary.needs_human, + summary_human_reason: summary.human_reason, + summary_risk: summary.risk, + summary_confidence: summary.confidence, + summary_updated_at: Utils.now_utc(), + summary_error: nil, + summary_source: "llm", + last_summary_monotonic: System.monotonic_time(:millisecond) + } + + {:error, reason} -> + %{ + entry + | summary_pending: false, + summary_revision: max(entry.summary_revision, activity_revision), + summary_error: reason, + summary_updated_at: Utils.now_utc(), + summary_source: "llm", + last_summary_monotonic: System.monotonic_time(:millisecond) + } + end + + state = %{ + orchestrator.state + | running: Map.put(orchestrator.state.running, issue_id, entry) + } + + persist_state(%{orchestrator | state: state}) + end + end + + defp make_tracker(%__MODULE__{} = orchestrator, %TrackerConfig{} = config) do + orchestrator.tracker_factory.(config) + end + + defp schedule_retry_for(%__MODULE__{task_supervisor: nil} = orchestrator, issue, attempt, opts), + do: schedule_retry(orchestrator, issue, attempt, opts) + + defp schedule_retry_for(%__MODULE__{} = orchestrator, issue, attempt, opts), + do: schedule_retry_runtime(orchestrator, issue, attempt, opts) + + defp cleanup_workspace(%__MODULE__{} = orchestrator, %Issue{} = issue) do + {_manager, _workflow, config} = ConfigManager.current(orchestrator.config_manager) + + config.workspace + |> WorkspaceManager.new(config.hooks) + |> WorkspaceManager.remove_for_identifier(issue.identifier) + end + + defp shutdown_task(%Task{} = task), do: Task.shutdown(task, :brutal_kill) + defp shutdown_task(_task), do: :ok + + def available_global_slots(%__MODULE__{} = orchestrator, %ServiceConfig{} = config) do + max(config.agent.max_concurrent_agents - map_size(orchestrator.state.running), 0) + end + + def is_dispatch_eligible( + %__MODULE__{} = orchestrator, + %Issue{} = issue, + %ServiceConfig{} = config, + opts \\ [] + ) do + ignore_claimed_issue_id = Keyword.get(opts, :ignore_claimed_issue_id) + state = Utils.normalize_state(issue.state) + + cond do + blank?(issue.id) or blank?(issue.identifier) or blank?(issue.title) or blank?(issue.state) -> + false + + !MapSet.member?(TrackerConfig.active_state_set(config.tracker), state) or + MapSet.member?(TrackerConfig.terminal_state_set(config.tracker), state) -> + false + + !has_required_labels?(issue, config) -> + false + + Map.has_key?(orchestrator.state.running, issue.id) -> + false + + Map.has_key?(orchestrator.state.blocked, issue.id) -> + false + + MapSet.member?(orchestrator.state.claimed, issue.id) and issue.id != ignore_claimed_issue_id -> + false + + available_global_slots(orchestrator, config) <= 0 -> + false + + state_running_count(orchestrator, state) >= + Map.get( + config.agent.max_concurrent_agents_by_state, + state, + config.agent.max_concurrent_agents + ) -> + false + + state == "todo" and + Enum.any?( + issue.blocked_by, + &(not MapSet.member?( + TrackerConfig.terminal_state_set(config.tracker), + Utils.normalize_state(&1.state) + )) + ) -> + false + + true -> + true + end + end + + defp start_review_reconciliation( + %__MODULE__{} = orchestrator, + tracker, + %ServiceConfig{} = config + ) do + cond do + !review_reconcile_supported?(tracker, config) -> + orchestrator + + orchestrator.review_task -> + orchestrator + + is_nil(orchestrator.task_supervisor) -> + safe_reconcile_review_issues(orchestrator, tracker, config) + + true -> + task = + Task.Supervisor.async_nolink(orchestrator.task_supervisor, fn -> + {:review_reconcile_result, run_review_reconciliation(orchestrator, tracker, config)} + end) + + timer_ref = + Process.send_after( + self(), + {:review_reconcile_timeout, task.ref}, + review_reconcile_timeout_ms() + ) + + %{ + orchestrator + | review_task: %{ + ref: task.ref, + task: task, + timer_ref: timer_ref, + started_at: Utils.now_utc() + } + } + end + end + + defp safe_reconcile_review_issues( + %__MODULE__{} = orchestrator, + tracker, + %ServiceConfig{} = config + ) do + if review_reconcile_supported?(tracker, config) do + parent = self() + ref = make_ref() + + {pid, monitor_ref} = + spawn_monitor(fn -> + send(parent, {ref, run_review_reconciliation(orchestrator, tracker, config)}) + end) + + receive do + {^ref, result} -> + Process.demonitor(monitor_ref, [:flush]) + log_review_reconcile_result(result) + apply_review_reconciliation_result(orchestrator, result) + + {:DOWN, ^monitor_ref, :process, ^pid, reason} -> + Logging.log_event(:warning, "review_reconcile_task_exited", reason: inspect(reason)) + orchestrator + after + review_reconcile_timeout_ms() -> + Process.demonitor(monitor_ref, [:flush]) + Process.exit(pid, :kill) + + Logging.log_event(:warning, "review_reconcile_timed_out", + timeout_ms: review_reconcile_timeout_ms() + ) + + orchestrator + end + else + orchestrator + end + end + + defp run_review_reconciliation(orchestrator, tracker, config) do + {:ok, build_review_reconciliation_result(orchestrator, tracker, config)} + rescue + error -> + {:error, Exception.message(error)} + catch + kind, reason -> + {:error, "#{kind}: #{inspect(reason)}"} + end + + defp review_reconcile_supported?(tracker, %ServiceConfig{} = config), + do: config.tracker.review_states != [] and tracker_supports?(tracker, :save_issue_state) + + defp review_reconcile_timeout_ms do + Application.get_env( + :caretta_symphony, + :review_reconcile_timeout_ms, + @review_reconcile_timeout_ms + ) + end + + defp log_review_reconcile_result(:ok), do: :ok + defp log_review_reconcile_result({:ok, _result}), do: :ok + + defp log_review_reconcile_result({:error, reason}) do + Logging.log_event(:warning, "review_reconcile_failed", reason: Utils.truncate(reason, 500)) + end + + def reconcile_review_issues(%__MODULE__{} = orchestrator, tracker, %ServiceConfig{} = config) do + result = {:ok, build_review_reconciliation_result(orchestrator, tracker, config)} + apply_review_reconciliation_result(orchestrator, result) + end + + defp build_review_reconciliation_result( + %__MODULE__{} = orchestrator, + tracker, + %ServiceConfig{} = config + ) do + if config.tracker.review_states == [] or !tracker_supports?(tracker, :save_issue_state) do + %{review_feedback: [], rework_issue_ids: []} + else + review_issues = fetch_review_issues(tracker, config) + refreshed = refresh_review_issues(tracker, review_issues) + workspace_manager = WorkspaceManager.new(config.workspace, config.hooks) + now = Utils.now_utc() + + refreshed + |> dedupe_by_id() + |> Enum.reduce(%{review_feedback: [], rework_issue_ids: []}, fn issue, acc -> + if has_required_labels?(issue, config) do + reconcile_review_issue( + orchestrator, + tracker, + config, + workspace_manager, + issue, + acc, + now + ) + else + acc + end + end) + end + end + + defp fetch_review_issues(tracker, config) do + call_tracker(tracker, :fetch_issues_by_states, [config.tracker.review_states]) + rescue + _ -> [] + end + + defp refresh_review_issues(tracker, review_issues) do + call_tracker(tracker, :fetch_issue_states_by_ids, [Enum.map(review_issues, & &1.id)]) + rescue + _ -> review_issues + end + + defp reconcile_review_issue( + orchestrator, + tracker, + config, + workspace_manager, + issue, + acc, + now + ) do + comments = review_issue_comments(tracker, issue) + + workspace_path = + WorkspaceManager.workspace_path_for_identifier(workspace_manager, issue.identifier) + + result = + evaluate_review(orchestrator.review_resolver, issue, + comments: comments, + workspace_path: workspace_path, + base_branch: config.tracker.merge_base_branch + ) + + snapshot = + review_feedback_snapshot(orchestrator.review_resolver, comments, result.required_prs) + + {feedback_state, changed?} = + next_review_feedback_state(orchestrator, issue, snapshot, now) + + acc = %{acc | review_feedback: acc.review_feedback ++ [feedback_state]} + + cond do + changed? -> + call_tracker(tracker, :save_issue_state, [issue.identifier, config.tracker.rework_state]) + + Logging.log_event(:info, "review_feedback_rework_detected", + issue_id: issue.id, + issue_identifier: issue.identifier, + rework_state: config.tracker.rework_state + ) + + %{acc | rework_issue_ids: acc.rework_issue_ids ++ [issue.id]} + + result.ready -> + call_tracker(tracker, :save_issue_state, [issue.identifier, config.tracker.done_state]) + acc + + true -> + acc + end + end + + defp review_issue_comments(tracker, issue) do + if tracker_supports?(tracker, :list_issue_comments) do + call_tracker(tracker, :list_issue_comments, [issue.identifier]) + else + [] + end + rescue + _ -> [] + end + + defp next_review_feedback_state(orchestrator, issue, snapshot, now) do + previous = orchestrator.state.review_feedback[issue.id] + changed? = previous && previous.fingerprint != snapshot.fingerprint + + state = %ReviewFeedbackState{ + issue_id: issue.id, + identifier: issue.identifier, + fingerprint: snapshot.fingerprint, + latest_feedback_at: snapshot.latest_feedback_at, + last_triggered_at: if(changed?, do: now, else: previous && previous.last_triggered_at) + } + + {state, !!changed?} + end + + defp apply_review_reconciliation_result(orchestrator, {:ok, result}) when is_map(result) do + updates = + result + |> Map.get(:review_feedback, []) + |> Enum.reject(&is_nil/1) + |> Map.new(&{&1.issue_id, &1}) + + rework_issue_ids = Map.get(result, :rework_issue_ids, []) + + if map_size(updates) == 0 and rework_issue_ids == [] do + orchestrator + else + state = %{ + orchestrator.state + | review_feedback: Map.merge(orchestrator.state.review_feedback, updates) + } + + orchestrator = persist_state(%{orchestrator | state: state}) + + if rework_issue_ids == [] or is_nil(orchestrator.task_supervisor) do + orchestrator + else + request_refresh_internal(orchestrator) + end + end + end + + defp apply_review_reconciliation_result(orchestrator, _result), do: orchestrator + + def dispatch_issue_sync(%__MODULE__{} = orchestrator, %Issue{} = issue, tracker, attempt \\ nil) do + {_manager, _workflow, config} = ConfigManager.current(orchestrator.config_manager) + workspace_manager = WorkspaceManager.new(config.workspace, config.hooks) + + entry = %RunningEntry{ + issue: issue, + workspace_path: + WorkspaceManager.workspace_path_for_identifier(workspace_manager, issue.identifier), + started_at: Utils.now_utc(), + started_monotonic: System.monotonic_time(:millisecond), + retry_attempt: attempt + } + + orchestrator = %{ + orchestrator + | state: %{ + orchestrator.state + | running: Map.put(orchestrator.state.running, issue.id, entry), + claimed: MapSet.put(orchestrator.state.claimed, issue.id) + } + } + + {:ok, holder} = Agent.start_link(fn -> orchestrator end) + + result = + Symphony.AgentRunner.new(orchestrator.config_manager, tracker) + |> Symphony.AgentRunner.run_issue(issue, attempt, fn issue_id, event -> + Agent.update(holder, &handle_codex_event(&1, issue_id, event)) + end) + + orchestrator = Agent.get(holder, & &1) + Agent.stop(holder) + handle_worker_done(orchestrator, issue.id, result) + end + + def schedule_retry(%__MODULE__{} = orchestrator, %Issue{} = issue, attempt, opts \\ []) do + {_manager, _workflow, config} = ConfigManager.current(orchestrator.config_manager) + + delay_ms = + Keyword.get(opts, :delay_ms) || + min(10_000 * trunc(:math.pow(2, max(attempt - 1, 0))), config.agent.max_retry_backoff_ms) + + error = Keyword.get(opts, :error) + due_at_monotonic = System.monotonic_time(:millisecond) + delay_ms + + entry = %RetryEntry{ + issue_id: issue.id, + identifier: issue.identifier, + attempt: attempt, + due_at_monotonic: due_at_monotonic, + due_at_wall: DateTime.add(Utils.now_utc(), delay_ms, :millisecond), + error: error + } + + old = orchestrator.state.retry_attempts[issue.id] + if old && old.timer_ref, do: Process.cancel_timer(old.timer_ref) + + state = %{ + orchestrator.state + | retry_attempts: Map.put(orchestrator.state.retry_attempts, issue.id, entry), + claimed: MapSet.put(orchestrator.state.claimed, issue.id) + } + + persist_state(%{orchestrator | state: state}) + end + + def process_due_retries(%__MODULE__{} = orchestrator, tracker, %ServiceConfig{} = config) do + now = System.monotonic_time(:millisecond) + + orchestrator.state.retry_attempts + |> Map.values() + |> Enum.filter(&(&1.due_at_monotonic <= now)) + |> Enum.sort_by(& &1.due_at_monotonic) + |> Enum.reduce(orchestrator, fn retry, acc -> + if available_global_slots(acc, config) > 0 do + state = %{ + acc.state + | retry_attempts: Map.delete(acc.state.retry_attempts, retry.issue_id) + } + + acc = %{acc | state: state} + issue = refresh_retry_issue(tracker, retry) + + if MapSet.member?( + TrackerConfig.active_state_set(config.tracker), + Utils.normalize_state(issue.state) + ) do + dispatch_issue_sync(acc, issue, tracker, retry.attempt) + else + persist_state(acc) + end + else + acc + end + end) + end + + def handle_worker_done(%__MODULE__{} = orchestrator, issue_id, %AgentRunResult{} = result) do + {entry, running} = Map.pop(orchestrator.state.running, issue_id) + + if is_nil(entry) do + orchestrator + else + elapsed = max((System.monotonic_time(:millisecond) - entry.started_monotonic) / 1000, 0) + + state = %{ + orchestrator.state + | running: running, + codex_totals: %{ + orchestrator.state.codex_totals + | seconds_running: orchestrator.state.codex_totals.seconds_running + elapsed + } + } + + orchestrator = %{orchestrator | state: state} + + cond do + entry.forced_outcome == "release" -> + if entry.cleanup_workspace, do: cleanup_workspace(orchestrator, entry.issue) + + state = %{ + orchestrator.state + | claimed: MapSet.delete(orchestrator.state.claimed, issue_id) + } + + persist_state(%{orchestrator | state: state}) + + entry.forced_outcome == "retry" -> + schedule_retry_for(orchestrator, entry.issue, (entry.retry_attempt || 0) + 1, + error: entry.forced_error || "worker cancelled" + ) + + result.normal -> + completed = completed_entry_from_running(entry, result.reason, elapsed) + + state = %{ + orchestrator.state + | completed: Map.put(orchestrator.state.completed, issue_id, completed), + claimed: MapSet.put(orchestrator.state.claimed, issue_id) + } + + orchestrator + |> Map.put(:state, state) + |> persist_state() + |> schedule_retry_for(entry.issue, 1, delay_ms: @continuation_retry_ms, error: nil) + + result.blocked -> + blocked = %BlockedEntry{ + issue: entry.issue, + reason: + if(result.repo_plan && result.repo_plan.human_reason, + do: result.repo_plan.human_reason, + else: result.reason + ), + blocked_at: Utils.now_utc(), + workspace_path: entry.workspace_path, + repo_plan: result.repo_plan + } + + state = %{ + orchestrator.state + | blocked: Map.put(orchestrator.state.blocked, issue_id, blocked), + claimed: MapSet.put(orchestrator.state.claimed, issue_id) + } + + orchestrator = %{orchestrator | state: state} + {_manager, _workflow, config} = ConfigManager.current(orchestrator.config_manager) + tracker = make_tracker(orchestrator, config.tracker) + + orchestrator + |> maybe_escalate_blocked_issue(tracker, config, blocked) + |> persist_state() + + !result.retryable -> + state = %{ + orchestrator.state + | claimed: MapSet.delete(orchestrator.state.claimed, issue_id) + } + + persist_state(%{orchestrator | state: state}) + + true -> + schedule_retry_for(orchestrator, entry.issue, (entry.retry_attempt || 0) + 1, + error: result.reason + ) + end + end + end + + def handle_codex_event(%__MODULE__{} = orchestrator, issue_id, event) do + entry = orchestrator.state.running[issue_id] + + if is_nil(entry) do + orchestrator + else + timestamp = + if match?(%DateTime{}, event["timestamp"]), do: event["timestamp"], else: Utils.now_utc() + + entry = + %{ + entry + | last_codex_event: event["event"], + last_codex_timestamp: timestamp, + last_codex_message: event["message"], + codex_app_server_pid: event["codex_app_server_pid"] || entry.codex_app_server_pid, + thread_id: event["thread_id"] || entry.thread_id, + turn_id: event["turn_id"] || entry.turn_id, + session_id: event["session_id"] || entry.session_id, + turn_count: + entry.turn_count + if(event["event"] == "session_started", do: 1, else: 0), + workspace_path: event["workspace_path"] || entry.workspace_path + } + + entry = + if event["event"] in ["repo_plan_created", "repo_workspace_prepared"] and + is_map(event["repo_plan"]) do + %{entry | repo_plan: repo_plan_from_map(event["repo_plan"])} + else + entry + end + + deviation = repo_deviation_from_event(entry, event) + + entry = + if deviation && deviation not in entry.repo_deviations, + do: %{entry | repo_deviations: Enum.take(entry.repo_deviations ++ [deviation], -20)}, + else: entry + + {entry, totals} = + if is_map(event["usage_absolute"]) do + apply_usage(entry, orchestrator.state.codex_totals, event["usage_absolute"]) + else + {entry, orchestrator.state.codex_totals} + end + + entry = record_activity(entry, event, timestamp) + + state = %{ + orchestrator.state + | running: Map.put(orchestrator.state.running, issue_id, entry), + codex_totals: totals, + codex_rate_limits: + if(is_map(event["rate_limits"]), + do: event["rate_limits"], + else: orchestrator.state.codex_rate_limits + ) + } + + %{orchestrator | state: state} + |> schedule_persist_state() + end + end + + def cached_snapshot(pid) when is_pid(pid) do + case :ets.whereis(@snapshot_cache_table) do + :undefined -> + nil + + table -> + case :ets.lookup(table, pid) do + [{^pid, snapshot}] -> snapshot + _ -> nil + end + end + rescue + ArgumentError -> nil + end + + def cached_issue_snapshot(pid, issue_identifier) when is_pid(pid) do + case cached_snapshot(pid) do + nil -> nil + state -> issue_snapshot_from_state(state, issue_identifier) + end + end + + def snapshot(pid) when is_pid(pid), do: snapshot(pid, @default_call_timeout_ms) + + def snapshot(%__MODULE__{} = orchestrator) do + generated_at = Utils.now_utc() + now_monotonic = System.monotonic_time(:millisecond) + + running = + orchestrator.state.running + |> Map.values() + |> Enum.map(fn entry -> + attention_override = attention_override(entry) + + repo_deviation_reason = + if entry.repo_deviations == [], + do: nil, + else: entry.repo_deviations |> Enum.take(-3) |> Enum.join("; ") + + needs_human = + entry.summary_needs_human or !is_nil(attention_override) or + !is_nil(repo_deviation_reason) + + human_reason = repo_deviation_reason || entry.summary_human_reason || attention_override + + risk = + if(attention_override || repo_deviation_reason, + do: "high", + else: entry.summary_risk || "unknown" + ) + + %{ + "issue_id" => entry.issue.id, + "issue_identifier" => entry.issue.identifier, + "state" => entry.issue.state, + "session_id" => entry.session_id, + "turn_count" => entry.turn_count, + "last_event" => entry.last_codex_event, + "last_message" => entry.last_codex_message, + "started_at" => Utils.isoformat_z(entry.started_at), + "last_event_at" => Utils.isoformat_z(entry.last_codex_timestamp), + "elapsed_seconds" => max((now_monotonic - entry.started_monotonic) / 1000, 0), + "title" => entry.issue.title, + "url" => entry.issue.url, + "priority" => entry.issue.priority, + "labels" => entry.issue.labels, + "updated_at" => Utils.isoformat_z(entry.issue.updated_at), + "workspace" => %{"path" => entry.workspace_path && to_string(entry.workspace_path)}, + "repo_plan" => entry.repo_plan && RepoPlan.to_map(entry.repo_plan), + "repo_deviations" => entry.repo_deviations, + "tokens" => %{ + "input_tokens" => entry.codex_input_tokens, + "output_tokens" => entry.codex_output_tokens, + "total_tokens" => entry.codex_total_tokens + }, + "summary" => %{ + "text" => entry.summary_text, + "current_step" => entry.summary_current_step, + "needs_human" => needs_human, + "human_reason" => human_reason, + "risk" => risk, + "confidence" => entry.summary_confidence, + "updated_at" => Utils.isoformat_z(entry.summary_updated_at), + "pending" => entry.summary_pending, + "stale" => entry.activity_revision > entry.summary_revision, + "error" => entry.summary_error, + "source" => entry.summary_source + }, + "activity" => Enum.take(entry.recent_activity, -12) + } + end) + + retrying = + orchestrator.state.retry_attempts + |> Map.values() + |> Enum.map(fn retry -> + kind = if is_nil(retry.error), do: "continuation", else: "retry" + + %{ + "issue_id" => retry.issue_id, + "issue_identifier" => retry.identifier, + "kind" => kind, + "status" => if(kind == "continuation", do: "continuing", else: "retrying"), + "attempt" => retry.attempt, + "due_at" => Utils.isoformat_z(retry.due_at_wall), + "due_in_seconds" => max((retry.due_at_monotonic - now_monotonic) / 1000, 0), + "error" => retry.error + } + end) + + continuing_count = Enum.count(retrying, &(&1["kind"] == "continuation")) + retrying_count = length(retrying) - continuing_count + + blocked = + orchestrator.state.blocked + |> Map.values() + |> Enum.map(fn blocked -> + %{ + "issue_id" => blocked.issue.id, + "issue_identifier" => blocked.issue.identifier, + "title" => blocked.issue.title, + "url" => blocked.issue.url, + "state" => blocked.issue.state, + "labels" => blocked.issue.labels, + "blocked_at" => Utils.isoformat_z(blocked.blocked_at), + "reason" => blocked.reason, + "escalation" => blocked_escalation_to_public_map(blocked), + "workspace" => %{"path" => blocked.workspace_path && to_string(blocked.workspace_path)}, + "repo_plan" => blocked.repo_plan && RepoPlan.to_map(blocked.repo_plan) + } + end) + + completed = + orchestrator.state.completed + |> Map.values() + |> Enum.sort_by(&DateTime.to_unix(&1.completed_at, :microsecond), :desc) + |> Enum.map(&completed_entry_snapshot/1) + + review_feedback = + orchestrator.state.review_feedback + |> Map.values() + |> Enum.sort_by(& &1.identifier) + |> Enum.map(&ReviewFeedbackState.to_map/1) + + active_runtime = + orchestrator.state.running + |> Map.values() + |> Enum.reduce(0.0, fn entry, acc -> + acc + max((now_monotonic - entry.started_monotonic) / 1000, 0) + end) + + totals = CodexTotals.to_map(orchestrator.state.codex_totals) + totals = Map.update!(totals, "seconds_running", &(&1 + active_runtime)) + + %{ + "generated_at" => Utils.isoformat_z(generated_at), + "service" => %{ + "status" => orchestrator.state.service_status, + "startup_completed_at" => Utils.isoformat_z(orchestrator.state.startup_completed_at), + "last_poll_started_at" => Utils.isoformat_z(orchestrator.state.last_poll_started_at), + "last_poll_completed_at" => Utils.isoformat_z(orchestrator.state.last_poll_completed_at), + "last_poll_error" => orchestrator.state.last_poll_error, + "last_candidate_count" => orchestrator.state.last_candidate_count, + "poll_interval_ms" => orchestrator.state.poll_interval_ms, + "max_concurrent_agents" => orchestrator.state.max_concurrent_agents + }, + "counts" => %{ + "running" => length(running), + "continuing" => continuing_count, + "retrying" => retrying_count, + "queued" => length(retrying), + "blocked" => length(blocked), + "completed" => length(completed), + "review_feedback_tracked" => length(review_feedback) + }, + "running" => running, + "retrying" => retrying, + "blocked" => blocked, + "completed" => completed, + "review_feedback" => review_feedback, + "codex_totals" => totals, + "rate_limits" => orchestrator.state.codex_rate_limits + } + end + + def snapshot(pid, timeout) when is_pid(pid), + do: GenServer.call(pid, :snapshot, timeout) + + def issue_snapshot(pid, issue_identifier) when is_pid(pid), + do: issue_snapshot(pid, issue_identifier, @default_call_timeout_ms) + + def issue_snapshot(%__MODULE__{} = orchestrator, issue_identifier) do + state = snapshot(orchestrator) + issue_snapshot_from_state(state, issue_identifier) + end + + defp issue_snapshot_from_state(state, issue_identifier) do + cond do + item = Enum.find(state["running"], &(&1["issue_identifier"] == issue_identifier)) -> + %{ + "issue_identifier" => issue_identifier, + "issue_id" => item["issue_id"], + "status" => "running", + "workspace" => item["workspace"], + "running" => item + } + + item = Enum.find(state["retrying"], &(&1["issue_identifier"] == issue_identifier)) -> + %{ + "issue_identifier" => issue_identifier, + "issue_id" => item["issue_id"], + "status" => item["status"], + "retry" => item, + "last_error" => item["error"] + } + + item = Enum.find(state["blocked"], &(&1["issue_identifier"] == issue_identifier)) -> + %{ + "issue_identifier" => issue_identifier, + "issue_id" => item["issue_id"], + "status" => "blocked", + "blocked" => item, + "last_error" => item["reason"] + } + + item = Enum.find(state["completed"], &(&1["issue_identifier"] == issue_identifier)) -> + %{ + "issue_identifier" => issue_identifier, + "issue_id" => item["issue_id"], + "status" => "completed", + "completed" => item, + "recent_events" => item["activity"] + } + + true -> + nil + end + end + + def issue_snapshot(pid, issue_identifier, timeout) when is_pid(pid), + do: GenServer.call(pid, {:issue_snapshot, issue_identifier}, timeout) + + def has_required_labels?(%Issue{} = issue, %ServiceConfig{} = config) do + required = TrackerConfig.required_label_set(config.tracker) + issue_labels = issue.labels |> Enum.map(&String.downcase/1) |> MapSet.new() + MapSet.subset?(required, issue_labels) + end + + defp blank?(value), do: is_nil(value) or String.trim(to_string(value)) == "" + + defp state_running_count(orchestrator, state) do + orchestrator.state.running + |> Map.values() + |> Enum.count(&(Utils.normalize_state(&1.issue.state) == state)) + end + + defp tracker_supports?(%{__struct__: module}, function), + do: Enum.any?(1..3, &function_exported?(module, function, &1)) + + defp tracker_supports?(tracker, function) when is_atom(tracker), + do: Enum.any?(1..3, &function_exported?(tracker, function, &1)) + + defp tracker_supports?(tracker, function) when is_map(tracker), + do: Map.has_key?(tracker, function) + + defp tracker_supports?(_tracker, _function), do: true + + defp call_tracker(%{__struct__: module} = tracker, function, args), + do: apply(module, function, [tracker | args]) + + defp call_tracker(tracker, function, args) when is_atom(tracker), + do: apply(tracker, function, args) + + defp call_tracker(tracker, function, args) when is_map(tracker), + do: apply(Map.fetch!(tracker, function), args) + + defp refresh_retry_issue(tracker, %RetryEntry{} = retry) do + case call_tracker(tracker, :fetch_issue_states_by_ids, [[retry.issue_id]]) do + [%Issue{} = issue | _] -> + issue + + _ -> + %Issue{ + id: retry.issue_id, + identifier: retry.identifier, + title: "", + state: "In Progress" + } + end + rescue + _ -> + %Issue{ + id: retry.issue_id, + identifier: retry.identifier, + title: "", + state: "In Progress" + } + end + + defp evaluate_review(%ReviewPullRequestResolver{} = resolver, issue, opts), + do: ReviewPullRequestResolver.evaluate(resolver, issue, opts) + + defp evaluate_review(resolver, issue, opts) when is_map(resolver), + do: resolver.evaluate.(issue, opts) + + defp evaluate_review(resolver, issue, opts) when is_atom(resolver), + do: apply(resolver, :evaluate, [issue, opts]) + + defp review_feedback_snapshot(%ReviewPullRequestResolver{} = resolver, comments, prs), + do: ReviewPullRequestResolver.feedback_snapshot(resolver, comments, prs) + + defp review_feedback_snapshot(resolver, comments, prs) when is_map(resolver) do + if Map.has_key?(resolver, :feedback_snapshot) do + resolver.feedback_snapshot.(comments, prs) + else + Review.feedback_snapshot(comments, []) + end + end + + defp review_feedback_snapshot(resolver, comments, prs) when is_atom(resolver) do + if function_exported?(resolver, :feedback_snapshot, 2) do + apply(resolver, :feedback_snapshot, [comments, prs]) + else + Review.feedback_snapshot(comments, []) + end + end + + defp dedupe_by_id(issues) do + issues + |> Enum.reduce({MapSet.new(), []}, fn issue, {seen, acc} -> + if MapSet.member?(seen, issue.id), + do: {seen, acc}, + else: {MapSet.put(seen, issue.id), acc ++ [issue]} + end) + |> elem(1) + end + + defp completed_entry_from_running(entry, reason, elapsed) do + %CompletedEntry{ + issue: entry.issue, + completed_at: Utils.now_utc(), + reason: reason, + workspace_path: entry.workspace_path, + repo_plan: entry.repo_plan, + duration_seconds: elapsed, + turn_count: entry.turn_count, + session_id: entry.session_id, + thread_id: entry.thread_id, + turn_id: entry.turn_id, + codex_input_tokens: entry.codex_input_tokens, + codex_output_tokens: entry.codex_output_tokens, + codex_total_tokens: entry.codex_total_tokens, + summary_text: entry.summary_text, + summary_current_step: entry.summary_current_step, + summary_needs_human: entry.summary_needs_human, + summary_human_reason: entry.summary_human_reason, + summary_risk: entry.summary_risk, + summary_confidence: entry.summary_confidence, + summary_updated_at: entry.summary_updated_at, + repo_deviations: entry.repo_deviations, + recent_activity: Enum.take(entry.recent_activity, -100) + } + end + + defp apply_usage(entry, totals, usage) do + {entry, totals} = + apply_usage_value( + entry, + totals, + usage["input_tokens"], + :last_reported_input_tokens, + :codex_input_tokens, + :input_tokens + ) + + {entry, totals} = + apply_usage_value( + entry, + totals, + usage["output_tokens"], + :last_reported_output_tokens, + :codex_output_tokens, + :output_tokens + ) + + apply_usage_value( + entry, + totals, + usage["total_tokens"], + :last_reported_total_tokens, + :codex_total_tokens, + :total_tokens + ) + end + + defp apply_usage_value(entry, totals, value, last_field, current_field, total_field) do + case Utils.to_int(value) do + nil -> + {entry, totals} + + reported -> + last_reported = Map.fetch!(entry, last_field) + delta = max(reported - last_reported, 0) + + entry = + entry + |> Map.put(last_field, max(last_reported, reported)) + |> Map.put(current_field, reported) + + totals = Map.update!(totals, total_field, &(&1 + delta)) + {entry, totals} + end + end + + defp record_activity(entry, event, timestamp) do + case activity_message(event) do + nil -> + entry + + message -> + activity = + entry.recent_activity ++ + [ + %{ + "at" => Utils.isoformat_z(timestamp), + "event" => event["event"], + "message" => Utils.truncate(message, 1000) + } + ] + + %{ + entry + | recent_activity: Enum.take(activity, -100), + activity_revision: entry.activity_revision + 1 + } + end + end + + defp activity_message(event) do + name = to_string(event["event"] || "") + + if name in [ + "thread_tokenUsage_updated", + "account_rateLimits_updated", + "account_rateLimitsUpdated", + "mcpServer_startupStatus_updated", + "thread_started", + "thread_status_changed", + "item_agentMessage_delta", + "item_commandExecution_outputDelta" + ] do + nil + else + payload = if is_map(event["payload"]), do: event["payload"], else: %{} + item = if is_map(payload["item"]), do: payload["item"], else: %{} + + cond do + name == "coding_context_classified" -> + "Coding context classified: injected=#{event["coding_context_injected"]}, source=#{event["classification_source"]}, reason=#{event["classification_reason"] || "none"}." + + name == "session_started" -> + "Started Codex turn #{event["turn_id"] || ""}." + + name == "approval_auto_approved" -> + "Auto-approved Codex request: #{get_in(event, ["payload", "command"]) || event["method"] || "approval"}." + + name == "turn_input_required" -> + "The agent requested user input." + + item["type"] in ["reasoning", "userMessage"] -> + nil + + item["type"] == "agentMessage" -> + text = String.trim(to_string(item["text"] || event["message"] || "")) + if text == "", do: nil, else: "Agent said: #{text}" + + item["type"] == "commandExecution" -> + command = String.trim(to_string(item["command"] || "")) + status = String.trim(to_string(item["status"] || "unknown")) + if command == "", do: "Command status: #{status}", else: "Command #{status}: #{command}" + + item["type"] == "fileChange" -> + path = item["path"] || item["filePath"] || item["file"] + status = item["status"] || "updated" + if path, do: "File change #{status}: #{path}", else: "File change #{status}." + + String.trim(to_string(event["message"] || "")) == "" -> + nil + + name == "turn_completed" -> + "Turn completed: #{event["message"]}." + + String.starts_with?(name, "turn_") -> + "#{name}: #{event["message"]}" + + String.starts_with?(name, "item_") and + !String.starts_with?(to_string(event["message"]), "item_type=") -> + "#{name}: #{event["message"]}" + + true -> + event["message"] + end + end + end + + defp attention_override(entry) do + issue_text = "#{entry.issue.title}\n#{entry.issue.description || ""}" |> String.downcase() + + activity_text = + entry.recent_activity + |> Enum.take(-20) + |> Enum.map_join("\n", &to_string(&1["message"] || "")) + |> String.downcase() + + product_runtime_words = [ + "screen capture", + "screenshot", + "slides", + "call", + "live", + "overlay", + "proactive", + "suggest", + "answer", + "browser extension", + "electron", + "integration" + ] + + infra_config_words = [ + "infrastructure/", + "terraform", + ".tf", + "model-gateway", + "gateway", + "schemas/functions", + "system_template.minijinja", + "user_template.minijinja" + ] + + cond do + Enum.any?(product_runtime_words, &String.contains?(issue_text, &1)) and + Enum.any?(infra_config_words, &String.contains?(activity_text, &1)) -> + "Recent activity is focused on infrastructure/gateway/config files while the issue reads like product, runtime, or integration work. Check the repo boundary before letting this continue." + + String.contains?(activity_text, "command failed:") and + length(String.split(activity_text, "command failed:")) - 1 >= 3 -> + "Several recent commands failed; the agent may be stuck or looking in the wrong place." + + true -> + nil + end + end + + defp repo_deviation_from_event(entry, event) do + if entry.repo_plan && entry.workspace_path do + path = file_change_path(event) + + if path do + repo_slug = repo_slug_for_path(entry, path) + + cond do + is_nil(repo_slug) -> + "File change is outside the approved repo plan: #{path}" + + !MapSet.member?(RepoPlan.edit_allowed_slugs(entry.repo_plan), repo_slug) -> + "File change is in an unapproved or read-only repo (#{repo_slug}): #{path}" + + true -> + nil + end + end + end + end + + defp file_change_path(event) do + payload = if is_map(event["payload"]), do: event["payload"], else: %{} + item = if is_map(payload["item"]), do: payload["item"], else: %{} + if item["type"] == "fileChange", do: item["path"] || item["filePath"] || item["file"] + end + + defp repo_slug_for_path(entry, raw_path) do + workspace_path = Path.expand(entry.workspace_path) + + path = + if Path.type(raw_path) == :absolute, + do: Path.expand(raw_path), + else: Path.expand(Path.join(workspace_path, raw_path)) + + relative = safe_relative(path, workspace_path) + + with relative when is_binary(relative) <- relative, + ["repos", repo_dir | _] <- Path.split(relative) do + entry.repo_plan + |> RepoPlan.all_repos() + |> Enum.find_value(fn repo -> if repo.path_name == repo_dir, do: repo.slug end) + else + _ -> nil + end + end + + defp safe_relative(path, root) do + root_parts = Path.split(Path.expand(root)) + path_parts = Path.split(Path.expand(path)) + + if Enum.take(path_parts, length(root_parts)) == root_parts do + path_parts |> Enum.drop(length(root_parts)) |> Path.join() + end + end + + defp state_path(%__MODULE__{} = orchestrator) do + {_manager, _workflow, config} = ConfigManager.current(orchestrator.config_manager) + Path.join(Path.expand(config.workspace.root), @state_file_name) + end + + defp schedule_persist_state(%__MODULE__{task_supervisor: nil} = orchestrator), + do: orchestrator + + defp schedule_persist_state(%__MODULE__{persist_timer_ref: nil} = orchestrator) do + ref = Process.send_after(self(), :persist_state, @persist_coalesce_ms) + %{orchestrator | persist_timer_ref: ref} + end + + defp schedule_persist_state(%__MODULE__{} = orchestrator), do: orchestrator + + defp ensure_snapshot_cache_table! do + case :ets.whereis(@snapshot_cache_table) do + :undefined -> + :ets.new(@snapshot_cache_table, [:named_table, :public, read_concurrency: true]) + + _table -> + @snapshot_cache_table + end + rescue + ArgumentError -> + @snapshot_cache_table + end + + defp publish_snapshot_cache(%__MODULE__{task_supervisor: nil} = orchestrator), + do: orchestrator + + defp publish_snapshot_cache(%__MODULE__{} = orchestrator) do + ensure_snapshot_cache_table!() + + snapshot = + orchestrator + |> snapshot() + |> put_in(["service", "snapshot_source"], "live_cache") + + :ets.insert(@snapshot_cache_table, {self(), snapshot}) + orchestrator + rescue + _ -> orchestrator + end + + defp delete_snapshot_cache do + case :ets.whereis(@snapshot_cache_table) do + :undefined -> :ok + table -> :ets.delete(table, self()) + end + rescue + ArgumentError -> :ok + end + + defp persist_state(%__MODULE__{} = orchestrator) do + path = state_path(orchestrator) + File.mkdir_p!(Path.dirname(path)) + + payload = %{ + "version" => 1, + "updated_at" => Utils.isoformat_z(Utils.now_utc()), + "service" => %{ + "last_poll_completed_at" => Utils.isoformat_z(orchestrator.state.last_poll_completed_at), + "last_poll_error" => orchestrator.state.last_poll_error, + "last_candidate_count" => orchestrator.state.last_candidate_count + }, + "codex_totals" => CodexTotals.to_map(orchestrator.state.codex_totals), + "rate_limits" => orchestrator.state.codex_rate_limits, + "completed" => + orchestrator.state.completed |> Map.values() |> Enum.map(&completed_entry_to_map/1), + "blocked" => + orchestrator.state.blocked |> Map.values() |> Enum.map(&blocked_entry_to_map/1), + "review_feedback" => + orchestrator.state.review_feedback + |> Map.values() + |> Enum.map(&ReviewFeedbackState.to_map/1), + "retry_attempts" => + orchestrator.state.retry_attempts |> Map.values() |> Enum.map(&retry_entry_to_map/1) + } + + tmp = path <> ".tmp" + File.write!(tmp, Jason.encode!(payload, pretty: true)) + File.rename!(tmp, path) + orchestrator + end + + defp load_persisted_state(%__MODULE__{} = orchestrator) do + path = state_path(orchestrator) + + with {:ok, body} <- File.read(path), + {:ok, payload} when is_map(payload) <- Jason.decode(body) do + state = %{ + orchestrator.state + | codex_totals: codex_totals_from_map(payload["codex_totals"]), + codex_rate_limits: + if(is_map(payload["rate_limits"]), do: payload["rate_limits"], else: nil), + completed: + (payload["completed"] || []) + |> Enum.flat_map(fn raw -> + if entry = completed_entry_from_map(raw), do: [{entry.issue.id, entry}], else: [] + end) + |> Map.new(), + blocked: + (payload["blocked"] || []) + |> Enum.flat_map(fn raw -> + if entry = blocked_entry_from_map(raw), do: [{entry.issue.id, entry}], else: [] + end) + |> Map.new(), + review_feedback: + (payload["review_feedback"] || []) + |> Enum.flat_map(fn raw -> + if entry = review_feedback_state_from_map(raw), + do: [{entry.issue_id, entry}], + else: [] + end) + |> Map.new(), + retry_attempts: + (payload["retry_attempts"] || []) + |> Enum.flat_map(fn raw -> + if entry = retry_entry_from_map(raw), do: [{entry.issue_id, entry}], else: [] + end) + |> Map.new() + } + + %{ + orchestrator + | state: %{ + state + | claimed: + state.blocked + |> Map.keys() + |> MapSet.new() + |> MapSet.union(Map.keys(state.retry_attempts) |> MapSet.new()) + } + } + else + _ -> orchestrator + end + end + + defp completed_entry_snapshot(completed) do + %{ + "issue_id" => completed.issue.id, + "issue_identifier" => completed.issue.identifier, + "title" => completed.issue.title, + "url" => completed.issue.url, + "state" => completed.issue.state, + "labels" => completed.issue.labels, + "completed_at" => Utils.isoformat_z(completed.completed_at), + "reason" => completed.reason, + "workspace" => %{"path" => completed.workspace_path && to_string(completed.workspace_path)}, + "repo_plan" => completed.repo_plan && RepoPlan.to_map(completed.repo_plan), + "repo_deviations" => completed.repo_deviations, + "duration_seconds" => completed.duration_seconds, + "turn_count" => completed.turn_count, + "session_id" => completed.session_id, + "thread_id" => completed.thread_id, + "turn_id" => completed.turn_id, + "tokens" => %{ + "input_tokens" => completed.codex_input_tokens, + "output_tokens" => completed.codex_output_tokens, + "total_tokens" => completed.codex_total_tokens + }, + "summary" => %{ + "text" => completed.summary_text, + "current_step" => completed.summary_current_step, + "needs_human" => completed.summary_needs_human, + "human_reason" => completed.summary_human_reason, + "risk" => completed.summary_risk, + "confidence" => completed.summary_confidence, + "updated_at" => Utils.isoformat_z(completed.summary_updated_at) + }, + "activity" => Enum.take(completed.recent_activity, -12) + } + end + + defp retry_entry_to_map(entry), + do: %{ + "issue_id" => entry.issue_id, + "issue_identifier" => entry.identifier, + "attempt" => entry.attempt, + "due_at" => Utils.isoformat_z(entry.due_at_wall), + "error" => entry.error + } + + defp blocked_escalation_to_public_map(%BlockedEntry{} = entry) do + %{ + "comment_id" => entry.escalation_comment_id, + "escalated_at" => Utils.isoformat_z(entry.escalation_at), + "error" => entry.escalation_error + } + end + + defp blocked_entry_to_map(entry), + do: %{ + "issue" => Issue.to_template_data(entry.issue), + "reason" => entry.reason, + "blocked_at" => Utils.isoformat_z(entry.blocked_at), + "workspace_path" => entry.workspace_path && to_string(entry.workspace_path), + "repo_plan" => entry.repo_plan && RepoPlan.to_map(entry.repo_plan), + "escalation_comment_id" => entry.escalation_comment_id, + "escalation_fingerprint" => entry.escalation_fingerprint, + "escalation_at" => Utils.isoformat_z(entry.escalation_at), + "escalation_error" => entry.escalation_error + } + + defp completed_entry_to_map(entry) do + %{ + "issue" => Issue.to_template_data(entry.issue), + "completed_at" => Utils.isoformat_z(entry.completed_at), + "reason" => entry.reason, + "workspace_path" => entry.workspace_path && to_string(entry.workspace_path), + "repo_plan" => entry.repo_plan && RepoPlan.to_map(entry.repo_plan), + "duration_seconds" => entry.duration_seconds, + "turn_count" => entry.turn_count, + "session_id" => entry.session_id, + "thread_id" => entry.thread_id, + "turn_id" => entry.turn_id, + "tokens" => %{ + "input_tokens" => entry.codex_input_tokens, + "output_tokens" => entry.codex_output_tokens, + "total_tokens" => entry.codex_total_tokens + }, + "summary" => %{ + "text" => entry.summary_text, + "current_step" => entry.summary_current_step, + "needs_human" => entry.summary_needs_human, + "human_reason" => entry.summary_human_reason, + "risk" => entry.summary_risk, + "confidence" => entry.summary_confidence, + "updated_at" => Utils.isoformat_z(entry.summary_updated_at) + }, + "repo_deviations" => entry.repo_deviations, + "recent_activity" => Enum.take(entry.recent_activity, -100) + } + end + + defp codex_totals_from_map(value) when is_map(value) do + %CodexTotals{ + input_tokens: Utils.to_int(value["input_tokens"]) || 0, + output_tokens: Utils.to_int(value["output_tokens"]) || 0, + total_tokens: Utils.to_int(value["total_tokens"]) || 0, + seconds_running: Utils.to_float(value["seconds_running"]) || 0.0 + } + end + + defp codex_totals_from_map(_), do: %CodexTotals{} + + defp retry_entry_from_map(value) when is_map(value) do + issue_id = value["issue_id"] + identifier = value["issue_identifier"] || value["identifier"] + due_at_wall = Utils.parse_datetime(value["due_at"] || value["due_at_wall"]) + + if issue_id && identifier && due_at_wall do + delay_ms = max(DateTime.diff(due_at_wall, Utils.now_utc(), :millisecond), 0) + + %RetryEntry{ + issue_id: to_string(issue_id), + identifier: to_string(identifier), + attempt: Utils.to_int(value["attempt"]) || 1, + due_at_monotonic: System.monotonic_time(:millisecond) + delay_ms, + due_at_wall: due_at_wall, + error: value["error"], + timer_ref: nil + } + end + end + + defp retry_entry_from_map(_), do: nil + + defp review_feedback_state_from_map(value) when is_map(value) do + issue_id = value["issue_id"] + identifier = value["identifier"] + fingerprint = value["fingerprint"] + + if issue_id && identifier && fingerprint do + %ReviewFeedbackState{ + issue_id: to_string(issue_id), + identifier: to_string(identifier), + fingerprint: to_string(fingerprint), + latest_feedback_at: Utils.parse_datetime(value["latest_feedback_at"]), + last_triggered_at: Utils.parse_datetime(value["last_triggered_at"]) + } + end + end + + defp review_feedback_state_from_map(_), do: nil + + defp blocked_entry_from_map(value) when is_map(value) do + with %Issue{} = issue <- issue_from_map(value["issue"]), + %DateTime{} = blocked_at <- Utils.parse_datetime(value["blocked_at"]) do + %BlockedEntry{ + issue: issue, + reason: to_string(value["reason"] || ""), + blocked_at: blocked_at, + workspace_path: value["workspace_path"], + repo_plan: if(is_map(value["repo_plan"]), do: repo_plan_from_map(value["repo_plan"])), + escalation_comment_id: value["escalation_comment_id"], + escalation_fingerprint: value["escalation_fingerprint"], + escalation_at: Utils.parse_datetime(value["escalation_at"]), + escalation_error: value["escalation_error"] + } + else + _ -> nil + end + end + + defp blocked_entry_from_map(_), do: nil + + defp completed_entry_from_map(value) when is_map(value) do + with %Issue{} = issue <- issue_from_map(value["issue"]), + %DateTime{} = completed_at <- Utils.parse_datetime(value["completed_at"]) do + tokens = if is_map(value["tokens"]), do: value["tokens"], else: %{} + summary = if is_map(value["summary"]), do: value["summary"], else: %{} + + %CompletedEntry{ + issue: issue, + completed_at: completed_at, + reason: to_string(value["reason"] || ""), + workspace_path: value["workspace_path"], + repo_plan: if(is_map(value["repo_plan"]), do: repo_plan_from_map(value["repo_plan"])), + duration_seconds: Utils.to_float(value["duration_seconds"]) || 0.0, + turn_count: Utils.to_int(value["turn_count"]) || 0, + session_id: value["session_id"], + thread_id: value["thread_id"], + turn_id: value["turn_id"], + codex_input_tokens: Utils.to_int(tokens["input_tokens"]) || 0, + codex_output_tokens: Utils.to_int(tokens["output_tokens"]) || 0, + codex_total_tokens: Utils.to_int(tokens["total_tokens"]) || 0, + summary_text: summary["text"], + summary_current_step: summary["current_step"], + summary_needs_human: !!summary["needs_human"], + summary_human_reason: summary["human_reason"], + summary_risk: summary["risk"], + summary_confidence: Utils.to_float(summary["confidence"]), + summary_updated_at: Utils.parse_datetime(summary["updated_at"]), + repo_deviations: Enum.map(value["repo_deviations"] || [], &to_string/1), + recent_activity: Enum.filter(value["recent_activity"] || [], &is_map/1) + } + else + _ -> nil + end + end + + defp completed_entry_from_map(_), do: nil + + defp issue_from_map(value) when is_map(value) do + issue_id = value["id"] + identifier = value["identifier"] + + if issue_id && identifier do + %Issue{ + id: to_string(issue_id), + identifier: to_string(identifier), + title: to_string(value["title"] || ""), + description: value["description"], + priority: Utils.to_int(value["priority"]), + state: to_string(value["state"] || ""), + branch_name: value["branch_name"], + url: value["url"], + assignee: assignee_from_map(value["assignee"]), + labels: Enum.map(value["labels"] || [], &to_string/1), + attachments: + Enum.flat_map(value["attachments"] || [], fn + raw when is_map(raw) -> + [ + %IssueAttachment{ + id: raw["id"], + title: raw["title"], + subtitle: raw["subtitle"], + url: raw["url"] + } + ] + + _ -> + [] + end), + blocked_by: + Enum.flat_map(value["blocked_by"] || [], fn + raw when is_map(raw) -> + [%BlockerRef{id: raw["id"], identifier: raw["identifier"], state: raw["state"]}] + + _ -> + [] + end), + created_at: Utils.parse_datetime(value["created_at"]), + updated_at: Utils.parse_datetime(value["updated_at"]) + } + end + end + + defp issue_from_map(_), do: nil + + defp assignee_from_map(value) when is_map(value) do + %IssueAssignee{ + id: value["id"], + name: value["name"], + display_name: value["display_name"], + email: value["email"], + url: value["url"], + mention: value["mention"] + } + end + + defp assignee_from_map(_), do: nil + + defp repo_plan_from_map(data) do + item = fn + raw when is_map(raw) -> + slug = to_string(raw["slug"] || "") + + if slug != "", + do: %RepoPlanItem{ + slug: slug, + role: to_string(raw["role"] || ""), + reason: raw["reason"], + path_name: raw["path_name"], + edit_allowed: Map.get(raw, "edit_allowed", true) + } + + _ -> + nil + end + + %RepoPlan{ + issue_identifier: to_string(data["issue_identifier"] || ""), + coding_task: !!data["coding_task"], + planner: to_string(data["planner"] || ""), + source: to_string(data["source"] || ""), + primary_repo: item.(data["primary_repo"]), + secondary_repos: + Enum.flat_map(data["secondary_repos"] || [], fn raw -> + if parsed = item.(raw), do: [parsed], else: [] + end), + read_only_context_repos: + Enum.flat_map(data["read_only_context_repos"] || [], fn raw -> + if parsed = item.(raw), do: [parsed], else: [] + end), + confidence: Utils.to_float(data["confidence"]), + needs_human: !!data["needs_human"], + human_reason: data["human_reason"], + notes: data["notes"], + created_at: Utils.now_utc() + } + end +end diff --git a/lib/symphony/repo_planner.ex b/lib/symphony/repo_planner.ex new file mode 100644 index 0000000..43426bb --- /dev/null +++ b/lib/symphony/repo_planner.ex @@ -0,0 +1,615 @@ +defmodule Symphony.RepoPlanner do + @moduledoc false + + alias Symphony.CodingContext.{CodingClassification} + alias Symphony.CodingContext + alias Symphony.CodexClient + + alias Symphony.Config.{ + CodexConfig, + CodingContextConfig, + RepositoryConfig, + RepositoryPlanningConfig + } + + alias Symphony.Models.{Issue, RepoPlan, RepoPlanItem} + alias Symphony.Utils + + def plan_repositories( + %Issue{} = issue, + %RepositoryPlanningConfig{} = config, + %CodingContextConfig{} = coding_config, + %CodingClassification{} = classification, + opts \\ [] + ) do + cond do + !config.enabled -> + nil + + !classification.is_coding_task -> + %RepoPlan{ + issue_identifier: issue.identifier, + coding_task: false, + planner: config.planner, + source: classification.source, + confidence: classification.confidence, + notes: "Issue was not classified as a coding task.", + created_at: Utils.now_utc() + } + + config.planner == "llm" -> + try do + plan_with_llm(issue, config, coding_config, opts) + rescue + error -> + if config.fallback == "block" do + %RepoPlan{ + issue_identifier: issue.identifier, + coding_task: true, + planner: config.planner, + source: "fallback:block", + needs_human: true, + human_reason: + "Repository planner failed and fallback is block: #{Utils.truncate(Exception.message(error), 300)}", + created_at: Utils.now_utc() + } + else + plan_with_rules(issue, config, "fallback:rules") + end + end + + true -> + plan_with_rules(issue, config, "rules") + end + end + + def apply_repo_plan_to_prompt(prompt, nil, _workspace_path), do: prompt + + def apply_repo_plan_to_prompt(prompt, %RepoPlan{coding_task: false}, _workspace_path), + do: prompt + + def apply_repo_plan_to_prompt(prompt, %RepoPlan{} = repo_plan, workspace_path) do + plan_json = Jason.encode!(RepoPlan.to_map(repo_plan), pretty: true) + git_metadata = workspace_git_metadata(workspace_path) + + git_guardrail = + "Git hygiene guardrail: commit and push only the expected branch recorded for each repo in `.symphony-workspace.json`. Never push an inherited source checkout branch. If `git branch --show-current` differs from the repo's expected branch, stop and report the mismatch. The workspace-local pre-push hook rejects pushes to any other branch.\n" + + git_guardrail = + if git_metadata == [] do + git_guardrail + else + git_guardrail <> "Prepared git branches:\n#{Jason.encode!(git_metadata, pretty: true)}\n" + end + + """ + + Symphony prepared an explicit repository plan for this issue. Treat it as a guardrail, not as proof the implementation is already understood. + + Workspace root: #{workspace_path} + Repositories are checked out under `repos/` inside the workspace root. + Start by inspecting the primary repo. You may read secondary and read-only context repos as needed. Only edit the primary repo and secondary repos whose `edit_allowed` value is true. Do not edit read-only context repos. If the current Linear issue text proves the repo plan is wrong or incomplete, stop and report that instead of patching an unapproved repo. + + #{git_guardrail} + #{plan_json} + + + #{prompt} + """ + |> String.trim() + end + + defp workspace_git_metadata(workspace_path) do + path = Path.join(workspace_path, ".symphony-workspace.json") + + with {:ok, body} <- File.read(path), + {:ok, payload} when is_map(payload) <- Jason.decode(body), + repositories when is_list(repositories) <- payload["repositories"] do + repositories + |> Enum.flat_map(fn + item when is_map(item) -> + if is_map(item["git"]) do + [ + %{ + "slug" => item["slug"], + "path" => item["path"], + "edit_allowed" => item["edit_allowed"], + "expected_branch" => item["git"]["expected_branch"], + "expected_ref" => item["git"]["expected_ref"], + "base_ref" => item["git"]["base_ref"] + } + ] + else + [] + end + + _ -> + [] + end) + else + _ -> [] + end + end + + defp plan_with_llm(issue, config, coding_config, opts) do + %CodexConfig{} = codex_config = Keyword.fetch!(opts, :codex_config) + workspace_path = Keyword.fetch!(opts, :workspace_path) + codex_client = Keyword.get(opts, :codex_client, CodexClient) + + planner_codex_config = %{ + codex_config + | model: config.plan_model || codex_config.model, + effort: config.plan_effort, + turn_timeout_ms: config.plan_timeout_ms, + summary: nil, + personality: nil + } + + session = + apply(codex_client, :start_session, [ + planner_codex_config, + workspace_path, + [tracker_config: nil, on_event: fn _event -> :ok end] + ]) + + try do + {result, _session} = + apply(codex_client, :run_turn, [ + session, + planner_prompt(issue, config, coding_config), + [capture_agent_text: true] + ]) + + result + |> agent_message_text() + |> parse_json_object() + |> normalize_plan(issue, config, planner: "llm", source: "llm") + |> apply_rules_crosscheck(issue, config) + after + apply(codex_client, :stop_session, [session]) + end + end + + defp planner_prompt(issue, config, coding_config) do + payload = %{ + "issue" => Issue.to_template_data(issue), + "repositories" => Enum.map(config.repositories, &RepositoryConfig.to_prompt_data/1), + "coding_context" => + if(coding_config.enabled, + do: coding_config |> CodingContext.load_coding_context() |> Utils.truncate(30_000), + else: "" + ) + } + + """ + You are Symphony's repository planner for a background coding agent. + Pick the repository set the agent is allowed to use for this Linear issue. Many valid tasks span multiple repos, so return a primary repo plus optional secondary/edit repos and read-only context repos. Prefer the runtime/product repo that owns the behavior as primary. Do not choose a gateway/config repo merely because the issue mentions AI, search, prompts, or suggestions if the runtime/UI/provider adapter lives elsewhere. If the issue requests a specific provider/integration, keep that provider direction in the reason. + + Return only one JSON object, no markdown, no prose, and no tool calls. + Schema: + { + "coding_task": true, + "primary_repo": {"slug": "owner/name", "reason": "why this repo is the start"}, + "secondary_repos": [{"slug": "owner/name", "reason": "why it may need edits", "edit_allowed": true}], + "read_only_context_repos": [{"slug": "owner/name", "reason": "why it is useful context"}], + "confidence": 0.0, + "needs_human": false, + "human_reason": null, + "notes": "short operational note" + } + + Rules: + - Use only repository slugs listed in the input catalog. + - If there is no clear primary repo, set needs_human=true and explain. + - If a secondary repo might need edits, include it as secondary_repos with edit_allowed=true. + - If a repo is only background material, include it as read_only_context_repos. + - If the issue is not a coding/repository task, set coding_task=false and leave repo lists empty. + - Route post-call, saved-call, call-history, history-detail, history-tab, recap, follow-up email, and template UI work to the web/customer app repository when one exists. Do not route those issues to a desktop/live-runtime repository unless the issue explicitly names desktop overlay, native capture, transcription, or live in-call behavior. + + Planner input JSON: + #{Jason.encode!(payload)} + """ + end + + defp plan_with_rules(issue, config, source) do + text = + "#{issue.identifier}\n#{issue.title}\n#{issue.description || ""}\n#{Enum.join(issue.labels, " ")}" + |> String.downcase() + + scored = + config.repositories + |> Enum.flat_map(fn repo -> + {score, strong_score, reasons} = score_repo(repo, text) + if score > 0, do: [{score, strong_score, repo, Enum.take(reasons, 6)}], else: [] + end) + |> Enum.sort_by(fn {score, strong_score, repo, _reasons} -> + {-score, -strong_score, repo.slug} + end) + + case scored do + [] -> + %RepoPlan{ + issue_identifier: issue.identifier, + coding_task: true, + planner: config.planner, + source: source, + needs_human: true, + human_reason: "No configured repository matched the issue text.", + confidence: 0.0, + created_at: Utils.now_utc() + } + + [{top_score, top_strong_score, top_repo, top_reasons} | rest] -> + tied = + scored + |> Enum.filter(fn {score, strong_score, _repo, _} -> + score == top_score and strong_score == top_strong_score + end) + |> Enum.map(fn {_, _, repo, _} -> repo.slug end) + + needs_human = length(tied) > 1 + + %RepoPlan{ + issue_identifier: issue.identifier, + coding_task: true, + planner: config.planner, + source: source, + primary_repo: + item(top_repo, "primary", "Rules matched: #{Enum.join(top_reasons, ", ")}"), + secondary_repos: + rest + |> Enum.take(3) + |> Enum.map(fn {_score, _strong_score, repo, reasons} -> + item(repo, "secondary", "Rules also matched: #{Enum.join(reasons, ", ")}") + end), + confidence: min(0.85, max(0.2, top_score / 12)), + needs_human: needs_human, + human_reason: + if(needs_human, + do: "Rules planner found tied primary repositories: #{Enum.join(tied, ", ")}" + ), + created_at: Utils.now_utc() + } + end + end + + defp apply_rules_crosscheck( + %RepoPlan{coding_task: true, primary_repo: %RepoPlanItem{} = llm_primary} = plan, + %Issue{} = issue, + %RepositoryPlanningConfig{} = config + ) do + rules_plan = plan_with_rules(issue, config, "llm:rules_crosscheck") + rules_primary = rules_plan.primary_repo + + cond do + rules_plan.needs_human or is_nil(rules_primary) -> + plan + + rules_primary.slug == llm_primary.slug -> + plan + + not Enum.any?(plan.read_only_context_repos, &(&1.slug == rules_primary.slug)) -> + plan + + true -> + promote_rules_primary(plan, rules_primary, llm_primary) + end + end + + defp apply_rules_crosscheck(plan, _issue, _config), do: plan + + defp promote_rules_primary(%RepoPlan{} = plan, %RepoPlanItem{} = rules_primary, llm_primary) do + promoted = + rules_primary + |> retag_plan_item("primary", true) + |> Map.put( + :reason, + "#{rules_primary.reason}; promoted over LLM primary #{llm_primary.slug} because the LLM marked this rules-matched repo as read-only context." + ) + + demoted = + llm_primary + |> retag_plan_item("read_only_context", false) + |> Map.put( + :reason, + "LLM initially selected this as primary, but rules cross-check promoted #{rules_primary.slug}." + ) + + secondary = + plan.secondary_repos + |> Enum.reject(&(&1.slug in [rules_primary.slug, llm_primary.slug])) + + read_only = + [demoted | plan.read_only_context_repos] + |> Enum.reject(&(&1.slug == rules_primary.slug)) + |> dedupe_items() + + %{ + plan + | primary_repo: promoted, + secondary_repos: secondary, + read_only_context_repos: read_only, + source: "llm+rules_crosscheck", + notes: + [ + plan.notes, + "Rules cross-check promoted #{rules_primary.slug} over #{llm_primary.slug}." + ] + |> Enum.reject(&blank?/1) + |> Enum.join(" ") + } + end + + defp retag_plan_item(%RepoPlanItem{} = item, role, edit_allowed) do + %{item | role: role, edit_allowed: edit_allowed} + end + + defp blank?(value), do: is_nil(value) or String.trim(to_string(value)) == "" + + defp score_repo(%RepositoryConfig{} = repo, text) do + candidates = + [ + {String.downcase(repo.slug), 4}, + {String.downcase(RepositoryConfig.path_name(repo)), 4} + | Enum.map(repo.aliases, &{String.downcase(&1), 3}) + ] + |> Enum.uniq_by(fn {candidate, _weight} -> candidate end) + + {score, strong_score, reasons} = + candidates + |> Enum.reject(fn {candidate, _weight} -> candidate == "" end) + |> Enum.reduce({0, 0, []}, fn {candidate, weight}, {score, strong_score, reasons} -> + if term_matches?(text, candidate) do + {score + weight, strong_score + weight, append_reason(reasons, candidate)} + else + {score, strong_score, reasons} + end + end) + + if repo.description do + repo.description + |> keyword_terms() + |> Enum.reduce({score, strong_score, reasons}, fn word, {score, strong_score, reasons} -> + if term_matches?(text, word), + do: {score + 1, strong_score, append_reason(reasons, word)}, + else: {score, strong_score, reasons} + end) + else + {score, strong_score, reasons} + end + end + + defp term_matches?(_text, ""), do: false + + defp term_matches?(text, term) do + term = String.trim(term) + + if term == "" do + false + else + pattern = "(^|[^[:alnum:]])" <> Regex.escape(term) <> "($|[^[:alnum:]])" + Regex.match?(Regex.compile!(pattern, "u"), text) + end + end + + defp append_reason(reasons, reason) do + if reason in reasons, do: reasons, else: reasons ++ [reason] + end + + defp item(%RepositoryConfig{} = repo, role, reason, opts \\ []) do + edit_allowed = + Keyword.get(opts, :edit_allowed, role != "read_only_context") and + role != "read_only_context" + + %RepoPlanItem{ + slug: repo.slug, + role: role, + reason: reason, + path_name: Utils.sanitize_workspace_key(RepositoryConfig.path_name(repo)), + edit_allowed: edit_allowed + } + end + + defp normalize_plan(data, issue, config, opts) do + known = RepositoryPlanningConfig.repository_by_slug(config) + unknown = [] + + {primary, unknown} = + parse_plan_item(Utils.map_get(data, "primary_repo"), "primary", known, unknown) + + {secondary, unknown} = + data + |> Utils.map_get("secondary_repos") + |> list_value() + |> Enum.reduce({[], unknown}, fn raw, {items, unknown_acc} -> + case parse_plan_item(raw, "secondary", known, unknown_acc) do + {nil, next_unknown} -> {items, next_unknown} + {item, next_unknown} -> {items ++ [item], next_unknown} + end + end) + + {read_only, unknown} = + data + |> Utils.map_get("read_only_context_repos") + |> list_value() + |> Enum.reduce({[], unknown}, fn raw, {items, unknown_acc} -> + case parse_plan_item(raw, "read_only_context", known, unknown_acc) do + {nil, next_unknown} -> {items, next_unknown} + {item, next_unknown} -> {items ++ [item], next_unknown} + end + end) + + secondary = dedupe_items(secondary) + read_only = dedupe_items(read_only) + + secondary = + if primary, + do: Enum.reject(secondary, &(&1.slug == primary.slug)), + else: secondary + + read_only = + if primary, + do: Enum.reject(read_only, &(&1.slug == primary.slug)), + else: read_only + + coding_task = + truthy?(Utils.map_get(data, "coding_task", Utils.map_get(data, "is_coding_task", true))) + + needs_human = truthy?(Utils.map_get(data, "needs_human")) + human_reason = clean_truncated(Utils.map_get(data, "human_reason"), 500) + + {needs_human, human_reason} = + if coding_task and is_nil(primary) do + {true, human_reason || "Repository planner did not return a primary repo."} + else + {needs_human, human_reason} + end + + {needs_human, human_reason} = + if unknown != [] do + suffix = + "Planner returned unknown repositories: #{unknown |> Enum.uniq() |> Enum.sort() |> Enum.join(", ")}." + + {true, + [human_reason, suffix] |> Enum.reject(&is_nil/1) |> Enum.join(" ") |> String.trim()} + else + {needs_human, human_reason} + end + + %RepoPlan{ + issue_identifier: issue.identifier, + coding_task: coding_task, + planner: Keyword.fetch!(opts, :planner), + source: Keyword.fetch!(opts, :source), + primary_repo: primary, + secondary_repos: secondary, + read_only_context_repos: read_only, + confidence: confidence(Utils.map_get(data, "confidence")), + needs_human: needs_human, + human_reason: if(human_reason == "", do: nil, else: human_reason), + notes: clean_truncated(Utils.map_get(data, "notes"), 500), + created_at: Utils.now_utc() + } + end + + defp parse_plan_item(raw, role, known, unknown) when is_map(raw) do + slug = raw |> Utils.map_get("slug") |> to_string() |> String.trim() + + cond do + slug == "" -> + {nil, unknown} + + repo = known[slug] -> + edit_allowed = truthy?(Utils.map_get(raw, "edit_allowed", role != "read_only_context")) + + {item(repo, role, clean_truncated(Utils.map_get(raw, "reason"), 500), + edit_allowed: edit_allowed + ), unknown} + + true -> + {nil, unknown ++ [slug]} + end + end + + defp parse_plan_item(_raw, _role, _known, unknown), do: {nil, unknown} + + defp dedupe_items(items) do + {deduped, _seen} = + Enum.reduce(items, {[], MapSet.new()}, fn item, {acc, seen} -> + if MapSet.member?(seen, item.slug), + do: {acc, seen}, + else: {acc ++ [item], MapSet.put(seen, item.slug)} + end) + + deduped + end + + defp list_value(value) when is_list(value), do: value + defp list_value(_value), do: [] + + defp confidence(value) do + case Utils.to_float(value) do + nil -> nil + float -> float |> max(0.0) |> min(1.0) + end + end + + defp truthy?(false), do: false + defp truthy?(nil), do: false + defp truthy?(_), do: true + + defp clean_truncated(nil, _limit), do: nil + + defp clean_truncated(value, limit) do + text = value |> to_string() |> Utils.truncate(limit) |> String.trim() + if text == "", do: nil, else: text + end + + defp agent_message_text(result) when is_map(result) do + Map.get(result, :agent_message_text) || Map.get(result, "agent_message_text") || "" + end + + defp agent_message_text(_), do: "" + + defp parse_json_object(text) do + stripped = String.trim(to_string(text || "")) + + if stripped == "" do + raise ArgumentError, message: "repo planner returned empty text" + end + + value = + case Jason.decode(stripped) do + {:ok, decoded} -> + decoded + + {:error, _reason} -> + stripped |> extract_json_object() |> Jason.decode!() + end + + unless is_map(value) do + raise ArgumentError, message: "repo planner JSON is not an object" + end + + value + end + + defp extract_json_object(text) do + start = :binary.match(text, "{") + finish = :binary.matches(text, "}") |> List.last() + + case {start, finish} do + {{start_index, 1}, {end_index, 1}} when end_index > start_index -> + binary_part(text, start_index, end_index - start_index + 1) + + _ -> + raise ArgumentError, message: "repo planner output did not contain a JSON object" + end + end + + defp keyword_terms(text) do + stop = + MapSet.new([ + "the", + "and", + "for", + "with", + "from", + "that", + "this", + "repo", + "choose", + "validation", + "local", + "path" + ]) + + text + |> String.downcase() + |> String.replace(~r/[\/-]/, " ") + |> String.split() + |> Enum.map(&String.replace(&1, ~r/[^[:alnum:]]/, "")) + |> Enum.filter(&(String.length(&1) >= 5 and !MapSet.member?(stop, &1))) + |> Enum.take(40) + end +end diff --git a/lib/symphony/review.ex b/lib/symphony/review.ex new file mode 100644 index 0000000..2b3d7c4 --- /dev/null +++ b/lib/symphony/review.ex @@ -0,0 +1,770 @@ +defmodule Symphony.Review do + @moduledoc false + + alias Symphony.Models.Issue + alias Symphony.Utils + + @github_pr_url_re ~r"https://github\.com/([^/\s]+)/([^/\s]+)/pull/(\d+)"i + @owner_repo_pr_re ~r"(? Review.pr_info_from_payload() + end + + def view_pr_ref(%PullRequestRef{} = ref) do + run_gh_json([ + "pr", + "view", + to_string(ref.number), + "--repo", + PullRequestRef.repo_full_name(ref), + "--json", + "number,url,state,mergedAt,baseRefName,headRefName,body" + ]) + |> Review.pr_info_from_payload(fallback_ref: ref) + end + + def list_prs_for_branch(repo_full_name, branch, base_branch) do + payload = + run_gh_json([ + "pr", + "list", + "--repo", + repo_full_name, + "--head", + branch, + "--base", + base_branch, + "--state", + "all", + "--limit", + "20", + "--json", + "number,url,state,mergedAt,baseRefName,headRefName,body" + ]) + + if is_list(payload), + do: payload |> Enum.map(&Review.pr_info_from_payload/1) |> Enum.reject(&is_nil/1), + else: [] + end + + def list_pr_feedback(%PullRequestRef{} = ref) do + repo = PullRequestRef.repo_full_name(ref) + number = ref.number + + [ + {"github_pr_comment", "/repos/#{repo}/issues/#{number}/comments"}, + {"github_pr_review", "/repos/#{repo}/pulls/#{number}/reviews"}, + {"github_pr_review_comment", "/repos/#{repo}/pulls/#{number}/comments"} + ] + |> Enum.flat_map(fn {source, endpoint} -> + endpoint + |> run_gh_pages() + |> Enum.flat_map(fn payload -> + case Review.pr_feedback_item_from_payload(ref, source, payload) do + nil -> [] + item -> [item] + end + end) + end) + end + + defp run_gh_json(args) do + case System.cmd("gh", args, stderr_to_stdout: true) do + {body, 0} -> Jason.decode!(body) + _ -> nil + end + rescue + _ -> nil + end + + defp run_gh_pages(endpoint) do + case System.cmd( + "gh", + [ + "api", + "--method", + "GET", + "--paginate", + "--slurp", + endpoint, + "-F", + "per_page=100" + ], + stderr_to_stdout: true + ) do + {body, 0} -> + body |> Jason.decode!() |> flatten_slurped_pages() + + _ -> + [] + end + rescue + _ -> [] + end + + defp flatten_slurped_pages(pages) when is_list(pages) do + if Enum.all?(pages, &is_list/1), do: List.flatten(pages), else: pages + end + + defp flatten_slurped_pages(_), do: [] + end + + defmodule ReviewPullRequestResolver do + @max_dependency_prs 50 + + defstruct inspector: GhPullRequestInspector + + alias Symphony.Review + alias Symphony.Review.{ReviewMergeResult, PullRequestRef} + + def new(inspector \\ GhPullRequestInspector), do: %__MODULE__{inspector: inspector} + + def evaluate(%__MODULE__{} = resolver, %Issue{} = issue, opts) do + comments = Keyword.get(opts, :comments, []) + workspace_path = Keyword.fetch!(opts, :workspace_path) + base_branch = Keyword.fetch!(opts, :base_branch) + + {required, unresolved, queue} = + (Review.issue_attachment_pr_urls(issue) ++ Review.comment_pr_urls(comments)) + |> Review.dedupe_strings() + |> Enum.reduce({%{}, MapSet.new(), []}, fn url, {required, unresolved, queue} -> + case call_inspector(resolver.inspector, :view_pr_url, [url]) do + nil -> + {required, MapSet.put(unresolved, url), queue} + + pr -> + if Map.has_key?(required, PullRequestRef.canonical(pr.ref)) do + {required, unresolved, queue} + else + {Map.put(required, PullRequestRef.canonical(pr.ref), pr), unresolved, + queue ++ [pr]} + end + end + end) + + metadata = Review.load_workspace_metadata(workspace_path) + + {required, queue} = + metadata + |> Review.workspace_branch_candidates(issue) + |> Enum.reduce({required, queue}, fn {repo_full_name, branch}, {required, queue} -> + resolver.inspector + |> call_inspector(:list_prs_for_branch, [repo_full_name, branch, base_branch]) + |> Enum.reduce({required, queue}, fn pr, {required, queue} -> + key = PullRequestRef.canonical(pr.ref) + + if Map.has_key?(required, key), + do: {required, queue}, + else: {Map.put(required, key, pr), queue ++ [pr]} + end) + end) + + {required, unresolved} = resolve_dependencies(resolver, queue, required, unresolved) + blockers = Review.merge_blockers(Map.values(required), unresolved, base_branch) + + %ReviewMergeResult{ + ready: map_size(required) > 0 and blockers == [], + required_prs: Enum.sort_by(Map.values(required), &PullRequestRef.canonical(&1.ref)), + unresolved_refs: unresolved |> MapSet.to_list() |> Enum.sort(), + blockers: blockers + } + end + + def feedback_snapshot(%__MODULE__{} = resolver, comments, prs) do + pr_feedback = + prs + |> Enum.flat_map(fn + %{ref: %PullRequestRef{} = ref} -> + maybe_call_inspector(resolver.inspector, :list_pr_feedback, [ref], []) + + _ -> + [] + end) + + Review.feedback_snapshot(comments, pr_feedback) + end + + defp resolve_dependencies(resolver, queue, required, unresolved) do + do_resolve_dependencies(resolver, queue, required, unresolved, 0) + end + + defp do_resolve_dependencies(_resolver, [], required, unresolved, _count), + do: {required, unresolved} + + defp do_resolve_dependencies(resolver, [pr | rest], required, unresolved, count) do + if map_size(required) + MapSet.size(unresolved) >= @max_dependency_prs or + count >= @max_dependency_prs do + {required, unresolved} + else + {required, unresolved, queue_additions} = + pr.body + |> Review.dependency_refs() + |> Enum.reduce({required, unresolved, []}, fn ref, {required, unresolved, additions} -> + key = PullRequestRef.canonical(ref) + + cond do + Map.has_key?(required, key) -> + {required, unresolved, additions} + + dependency = call_inspector(resolver.inspector, :view_pr_ref, [ref]) -> + {Map.put(required, key, dependency), unresolved, additions ++ [dependency]} + + true -> + {required, MapSet.put(unresolved, key), additions} + end + end) + + do_resolve_dependencies( + resolver, + rest ++ queue_additions, + required, + unresolved, + count + 1 + ) + end + end + + defp call_inspector(inspector, function, args) when is_atom(inspector), + do: apply(inspector, function, args) + + defp call_inspector(inspector, function, args) when is_map(inspector) do + apply(Map.fetch!(inspector, function), args) + end + + defp call_inspector(inspector, function, args), do: apply(inspector, function, args) + + defp maybe_call_inspector(inspector, function, args, default) when is_atom(inspector) do + if function_exported?(inspector, function, length(args)), + do: apply(inspector, function, args), + else: default + rescue + _ -> default + end + + defp maybe_call_inspector(inspector, function, args, default) when is_map(inspector) do + if Map.has_key?(inspector, function), + do: apply(Map.fetch!(inspector, function), args), + else: default + rescue + _ -> default + end + + defp maybe_call_inspector(inspector, function, args, default) do + apply(inspector, function, args) + rescue + _ -> default + end + end + + def issue_attachment_pr_urls(%Issue{} = issue) do + Enum.flat_map(issue.attachments, fn attachment -> + [attachment.url, attachment.title, attachment.subtitle] + |> Enum.flat_map(&pr_urls(to_string(&1 || ""))) + end) + end + + def comment_pr_urls(comments) do + Enum.flat_map(comments, fn comment -> + body = + comment["body"] || comment[:body] || comment["text"] || comment[:text] || + comment["content"] || comment[:content] || "" + + if is_binary(body) and String.contains?(body, "## Codex Workpad") do + pr_urls(body) + else + [] + end + end) + end + + def feedback_snapshot(linear_comments, pr_feedback_items \\ []) do + items = + (linear_feedback_items(linear_comments) ++ normalize_feedback_items(pr_feedback_items)) + |> human_feedback_items() + |> dedupe_feedback_items() + + %{ + items: items, + fingerprint: feedback_fingerprint(items), + latest_feedback_at: latest_feedback_at(items) + } + end + + def linear_feedback_items(comments) do + comments + |> Enum.flat_map(fn comment -> + case linear_feedback_item(comment) do + nil -> [] + item -> [item] + end + end) + end + + def linear_feedback_item(comment) when is_map(comment) do + body = comment_value(comment, "body") || comment_value(comment, "text") || "" + id = comment_value(comment, "id") + author = author_login(comment) + author_type = author_type(comment) + + %ReviewFeedbackItem{ + source: "linear_comment", + id: if(id, do: "linear_comment:#{id}", else: nil), + author: author, + author_type: author_type, + body: to_string(body || ""), + url: comment_value(comment, "url"), + created_at: + parse_feedback_datetime( + comment_value(comment, "createdAt") || comment_value(comment, "created_at") + ), + updated_at: + parse_feedback_datetime( + comment_value(comment, "updatedAt") || comment_value(comment, "updated_at") || + comment_value(comment, "createdAt") || comment_value(comment, "created_at") + ) + } + end + + def linear_feedback_item(_), do: nil + + def pr_feedback_item_from_payload(%PullRequestRef{} = ref, source, payload) + when is_map(payload) do + body = + payload + |> pr_feedback_body(source) + + id = comment_value(payload, "id") + author = author_login(payload) + author_type = author_type(payload) + + created_at = + parse_feedback_datetime( + comment_value(payload, "created_at") || comment_value(payload, "createdAt") || + comment_value(payload, "submitted_at") || comment_value(payload, "submittedAt") + ) + + updated_at = + parse_feedback_datetime( + comment_value(payload, "updated_at") || comment_value(payload, "updatedAt") || + comment_value(payload, "submitted_at") || comment_value(payload, "submittedAt") || + comment_value(payload, "created_at") || comment_value(payload, "createdAt") + ) + + %ReviewFeedbackItem{ + source: to_string(source), + id: "#{PullRequestRef.canonical(ref)}:#{source}:#{id || body_hash(body)}", + author: author, + author_type: author_type, + body: to_string(body || ""), + url: comment_value(payload, "html_url") || comment_value(payload, "url"), + created_at: created_at, + updated_at: updated_at + } + end + + def pr_feedback_item_from_payload(_, _, _), do: nil + + defp pr_feedback_body(payload, "github_pr_review") do + body = to_string(comment_value(payload, "body") || "") + state = comment_value(payload, "state") |> to_string() |> String.trim() + + if String.trim(body) == "" and state != "" and String.upcase(state) != "APPROVED" do + "Review state: #{state}" + else + body + end + end + + defp pr_feedback_body(payload, _source), do: to_string(comment_value(payload, "body") || "") + + def human_feedback_items(items) do + items + |> normalize_feedback_items() + |> Enum.filter(&human_feedback_item?/1) + end + + def human_feedback_item?(%ReviewFeedbackItem{} = item) do + body = String.trim(to_string(item.body || "")) + + body != "" and not bot_author?(item) and not automation_author?(item) and + not automation_body?(body) + end + + def human_feedback_item?(_), do: false + + def feedback_fingerprint(items) do + tokens = + items + |> normalize_feedback_items() + |> Enum.map(&feedback_token/1) + |> Enum.sort() + + tokens + |> Jason.encode!() + |> sha256() + end + + def latest_feedback_at(items) do + items + |> normalize_feedback_items() + |> Enum.map(&(&1.updated_at || &1.created_at)) + |> Enum.reject(&is_nil/1) + |> Enum.reduce(nil, fn datetime, latest -> + if is_nil(latest) or DateTime.compare(datetime, latest) == :gt, + do: datetime, + else: latest + end) + end + + def pr_urls(text) do + @github_pr_url_re + |> Regex.scan(text) + |> Enum.map(fn [_match, owner, repo, number] -> + "https://github.com/#{owner}/#{repo}/pull/#{number}" + end) + end + + def dependency_refs(text) do + url_refs = + @github_pr_url_re + |> Regex.scan(text || "") + |> Enum.map(fn [_match, owner, repo, number] -> + %PullRequestRef{owner: owner, repo: repo, number: String.to_integer(number)} + end) + + shorthand_refs = + @owner_repo_pr_re + |> Regex.scan(text || "") + |> Enum.map(fn [_match, owner_repo, number] -> + [owner, repo] = String.split(owner_repo, "/", parts: 2) + %PullRequestRef{owner: owner, repo: repo, number: String.to_integer(number)} + end) + + (url_refs ++ shorthand_refs) + |> Map.new(&{PullRequestRef.canonical(&1), &1}) + |> Map.values() + |> Enum.sort_by(&PullRequestRef.canonical/1) + end + + def merge_blockers(prs, unresolved, base_branch) do + unresolved_list = unresolved |> MapSet.to_list() |> Enum.sort() + + blockers = + if unresolved_list == [], + do: [], + else: ["unresolved PR reference(s): #{Enum.join(unresolved_list, ", ")}"] + + prs = Enum.to_list(prs) + + blockers = + if prs == [] and unresolved_list == [], + do: blockers ++ ["no required PRs were found"], + else: blockers + + Enum.reduce(prs, blockers, fn pr, acc -> + cond do + pr.base_ref_name != base_branch -> + acc ++ + [ + "#{PullRequestRef.canonical(pr.ref)} targets #{pr.base_ref_name || "unknown"} instead of #{base_branch}" + ] + + String.upcase(to_string(pr.state || "")) != "MERGED" -> + acc ++ ["#{PullRequestRef.canonical(pr.ref)} is #{pr.state || "unknown"}"] + + true -> + acc + end + end) + end + + def load_workspace_metadata(workspace_path) do + path = Path.join(workspace_path, @workspace_metadata) + + with {:ok, body} <- File.read(path), + {:ok, payload} when is_map(payload) <- Jason.decode(body) do + payload + else + _ -> %{} + end + end + + def workspace_branch_candidates(metadata, issue) do + (metadata["repositories"] || []) + |> Enum.flat_map(fn + repo when is_map(repo) -> + if Map.get(repo, "edit_allowed", true) do + repo_full_name = repo_full_name(repo) + git = if is_map(repo["git"]), do: repo["git"], else: %{} + branch = git["expected_branch"] || git["current_branch"] || issue.branch_name + if repo_full_name && branch, do: [{repo_full_name, to_string(branch)}], else: [] + else + [] + end + + _ -> + [] + end) + |> dedupe_pairs() + end + + def repo_full_name(repo) do + cond do + is_binary(repo["slug"]) and String.contains?(repo["slug"], "/") -> + repo["slug"] + + is_binary(repo["remote_url"]) -> + repo_full_name_from_remote(repo["remote_url"]) + + is_map(repo["git"]) and is_binary(repo["git"]["remote_url"]) -> + repo_full_name_from_remote(repo["git"]["remote_url"]) + + true -> + nil + end + end + + def repo_full_name_from_remote(remote) do + text = + remote + |> to_string() + |> String.trim() + |> then(fn text -> + if String.starts_with?(text, "git@github.com:"), + do: "https://github.com/" <> String.replace_prefix(text, "git@github.com:", ""), + else: text + end) + + case Regex.run(~r"github\.com[:/]([^/\s]+)/([^/\s]+?)(?:\.git)?/?$", text) do + [_match, owner, repo] -> "#{owner}/#{repo}" + _ -> nil + end + end + + def pr_info_from_payload(payload, opts \\ []) + + def pr_info_from_payload(payload, opts) when is_map(payload) do + fallback_ref = Keyword.get(opts, :fallback_ref) + url = to_string(payload["url"] || "") + ref = ref_from_url(url) || fallback_ref + + ref = + if is_nil(ref) and is_integer(payload["number"]) and is_binary(payload["owner"]) and + is_binary(payload["repo"]) do + %PullRequestRef{owner: payload["owner"], repo: payload["repo"], number: payload["number"]} + else + ref + end + + if ref do + %PullRequestInfo{ + ref: ref, + url: + if(url == "", + do: "https://github.com/#{ref.owner}/#{ref.repo}/pull/#{ref.number}", + else: url + ), + state: to_string(payload["state"] || ""), + base_ref_name: maybe_string(payload["baseRefName"]), + merged_at: maybe_string(payload["mergedAt"]), + head_ref_name: maybe_string(payload["headRefName"]), + body: Utils.truncate(to_string(payload["body"] || ""), 50_000) + } + end + end + + def pr_info_from_payload(_, _), do: nil + + def ref_from_url(url) do + case Regex.run(@github_pr_url_re, url) do + [_match, owner, repo, number] -> + %PullRequestRef{owner: owner, repo: repo, number: String.to_integer(number)} + + _ -> + nil + end + end + + def dedupe_strings(values) do + values + |> Enum.reduce({MapSet.new(), []}, fn value, {seen, acc} -> + if MapSet.member?(seen, value), + do: {seen, acc}, + else: {MapSet.put(seen, value), acc ++ [value]} + end) + |> elem(1) + end + + defp dedupe_pairs(values) do + values + |> Enum.reduce({MapSet.new(), []}, fn value, {seen, acc} -> + if MapSet.member?(seen, value), + do: {seen, acc}, + else: {MapSet.put(seen, value), acc ++ [value]} + end) + |> elem(1) + end + + defp maybe_string(nil), do: nil + defp maybe_string(value), do: to_string(value) + + defp normalize_feedback_items(items) do + Enum.flat_map(items || [], fn + %ReviewFeedbackItem{} = item -> [item] + item when is_map(item) -> if parsed = linear_feedback_item(item), do: [parsed], else: [] + _ -> [] + end) + end + + defp dedupe_feedback_items(items) do + items + |> Enum.reduce({MapSet.new(), []}, fn item, {seen, acc} -> + key = feedback_item_id(item) + + if MapSet.member?(seen, key), + do: {seen, acc}, + else: {MapSet.put(seen, key), acc ++ [item]} + end) + |> elem(1) + end + + defp feedback_item_id(%ReviewFeedbackItem{} = item) do + item.id || "#{item.source}:#{item.author}:#{body_hash(item.body)}" + end + + defp feedback_token(%ReviewFeedbackItem{} = item) do + %{ + "source" => to_string(item.source || ""), + "id" => feedback_item_id(item), + "author" => to_string(item.author || ""), + "body_hash" => body_hash(item.body), + "updated_at" => Utils.isoformat_z(item.updated_at || item.created_at) + } + end + + defp body_hash(body), do: sha256(String.trim(to_string(body || ""))) + + defp sha256(value) do + :crypto.hash(:sha256, to_string(value)) + |> Base.encode16(case: :lower) + end + + defp bot_author?(%ReviewFeedbackItem{} = item) do + author = item.author |> to_string() |> String.downcase() + type = item.author_type |> to_string() |> String.downcase() + + type == "bot" or String.contains?(author, "[bot]") or String.ends_with?(author, "-bot") + end + + defp automation_author?(%ReviewFeedbackItem{} = item) do + author = item.author |> to_string() |> String.downcase() + String.contains?(author, "symphony") or String.contains?(author, "codex") + end + + defp automation_body?(body) do + text = String.downcase(to_string(body || "")) + + String.contains?(text, "## codex workpad") or + String.contains?(text, "## symphony blocked escalation") or + String.contains?(text, "symphony fallback created this workpad") + end + + defp author_login(map) do + author = + comment_value(map, "author") || comment_value(map, "user") || comment_value(map, "creator") || + comment_value(map, "createdBy") + + cond do + is_binary(author) -> + author + + is_map(author) -> + comment_value(author, "login") || comment_value(author, "name") || + comment_value(author, "displayName") || comment_value(author, "email") + + true -> + nil + end + end + + defp author_type(map) do + author = + comment_value(map, "author") || comment_value(map, "user") || comment_value(map, "creator") || + comment_value(map, "createdBy") + + cond do + is_map(author) -> + comment_value(author, "type") + + true -> + comment_value(map, "authorType") || comment_value(map, "author_type") || + comment_value(map, "type") + end + end + + defp comment_value(map, key) when is_map(map) do + Map.get(map, key) || Map.get(map, String.to_existing_atom(key)) + rescue + ArgumentError -> Map.get(map, key) + end + + defp parse_feedback_datetime(nil), do: nil + + defp parse_feedback_datetime(%DateTime{} = datetime), do: datetime + + defp parse_feedback_datetime(value), do: Utils.parse_datetime(value) +end diff --git a/lib/symphony/self_heal.ex b/lib/symphony/self_heal.ex new file mode 100644 index 0000000..54a4c49 --- /dev/null +++ b/lib/symphony/self_heal.ex @@ -0,0 +1,613 @@ +defmodule Symphony.SelfHeal do + @moduledoc false + + import Bitwise, only: [band: 2] + + alias Symphony.CodexClient + alias Symphony.Config.{ConfigManager, ServiceConfig, SelfHealingConfig} + alias Symphony.Utils + + defmodule CommandResult do + defstruct command: nil, cwd: nil, output: "", status: 0 + end + + defmodule RunResult do + defstruct [ + :run_id, + :reason, + :branch, + :worktree_path, + :artifact_path, + :pr_url, + :auto_merge_status, + :restart_status, + :evidence_path, + status: :unknown, + attempts: 0, + validation: [], + error: nil + ] + end + + def run_once(manager_or_config, opts \\ []) + + def run_once(%ConfigManager{} = manager, opts) do + {_manager, _workflow, config} = ConfigManager.current(manager) + run_once(config, opts) + end + + def run_once(%ServiceConfig{} = config, opts) do + reason = Keyword.get(opts, :reason) || "manual self-heal" + self_healing = config.self_healing + ensure_root!(self_healing) + + with_lock(self_healing, fn -> + cond do + !self_healing.enabled and !Keyword.get(opts, :force, false) -> + %RunResult{ + run_id: nil, + reason: reason, + status: :skipped, + error: "self-healing is disabled" + } + + cooldown_active?(self_healing) -> + %RunResult{ + run_id: nil, + reason: reason, + status: :skipped, + error: "self-heal cooldown is active" + } + + true -> + do_run_once(config, reason, opts) + end + end) + end + + def restart_managed(manager_or_config, opts \\ []) + + def restart_managed(%ConfigManager{} = manager, opts) do + {_manager, _workflow, config} = ConfigManager.current(manager) + restart_managed(config, opts) + end + + def restart_managed(%ServiceConfig{} = config, opts) do + self_healing = config.self_healing + repo_root = repo_root(config) + + artifact = + Keyword.get(opts, :artifact_path) || + Path.join([self_healing.workspace_root, "deploy", "current", "symphony"]) + + artifact = Path.expand(artifact) + workflow_path = Path.expand(self_healing.restart_workflow_path || config.workflow_path) + session = self_healing.tmux_session + port = self_healing.restart_port + + if executable_artifact?(artifact) do + managed_command = + "cd #{shell(repo_root)} && exec #{shell(artifact)} #{shell(workflow_path)} --port #{port}" + + commands = [ + "tmux has-session -t #{shell(session)} 2>/dev/null && tmux kill-session -t #{shell(session)} || true", + "pids=$(lsof -tiTCP:#{port} -sTCP:LISTEN 2>/dev/null || true); if [ -n \"$pids\" ]; then kill $pids 2>/dev/null || true; fi", + "tmux new-session -d -s #{shell(session)} #{shell(managed_command)}" + ] + + results = Enum.map(commands, &run_shell(&1, repo_root, opts)) + + if Enum.all?(results, &(&1.status == 0)) do + {:ok, results} + else + {:error, results} + end + else + {:error, + [ + %CommandResult{ + command: "preflight managed Symphony artifact", + cwd: repo_root, + output: "managed restart requires an executable Symphony artifact at #{artifact}", + status: 1 + } + ]} + end + end + + def build_repair_prompt(reason, evidence, attempt, max_attempts) do + """ + You are a high-reasoning Codex repair agent for Caretta Symphony. + + Mission: + - Diagnose the root cause of the local Symphony failure. + - Fix the codebase generically with strong engineering practice. + - Do not apply narrow point fixes, log silencing, sleeps, retries, or special-case hacks unless they are part of a principled design. + - Add or improve tests that would have caught the failure or an adjacent failure mode. + - Preserve existing behavior unless changing it is necessary and justified by the root cause. + - Do not print, commit, or expose secrets. + - Do not push, open PRs, merge, restart Symphony, or edit files outside this checkout. The self-heal supervisor owns validation, git, deploy, and GitHub synchronization. + + Attempt #{attempt} of #{max_attempts}. + Trigger reason: + #{reason} + + Evidence: + #{Jason.encode!(evidence, pretty: true)} + + Completion bar: + - Root cause has been addressed at the right abstraction level. + - Relevant tests have been added or updated. + - The repository is ready for `mix format --check-formatted`, `mix test`, and `mix escript.build`. + """ + |> String.trim() + end + + def validation_success?(results), do: Enum.all?(results, &(&1.status == 0)) + + def result_to_map(%RunResult{} = result) do + %{ + "status" => to_string(result.status), + "run_id" => result.run_id, + "reason" => result.reason, + "branch" => result.branch, + "worktree_path" => result.worktree_path, + "artifact_path" => result.artifact_path, + "evidence_path" => result.evidence_path, + "pr_url" => result.pr_url, + "auto_merge_status" => normalize_status(result.auto_merge_status), + "restart_status" => normalize_status(result.restart_status), + "attempts" => result.attempts, + "validation" => encode_results(result.validation || []), + "error" => result.error + } + end + + def branch_name(%SelfHealingConfig{} = config, run_id) do + prefix = config.branch_prefix |> to_string() |> String.trim("/") + "#{prefix}/#{run_id}" + end + + def run_id(reason, now \\ Utils.now_utc()) do + stamp = + now + |> DateTime.shift_zone!("Etc/UTC") + |> DateTime.truncate(:second) + |> DateTime.to_iso8601(:basic) + |> String.replace(~r/[^0-9T]/, "") + + "#{stamp}-#{sanitize_segment(reason)}" + |> String.trim("-") + |> String.slice(0, 80) + |> String.trim("-") + end + + def classify_github_sync(pr_create, auto_merge) do + cond do + pr_create.status != 0 -> + {:error, Utils.truncate(pr_create.output, 1000)} + + auto_merge.status == 0 -> + {:ok, "auto-merge enabled"} + + true -> + {:blocked, Utils.truncate(auto_merge.output, 1000)} + end + end + + defp do_run_once(config, reason, opts) do + self_healing = config.self_healing + run_id = Keyword.get(opts, :run_id) || run_id(reason) + branch = branch_name(self_healing, run_id) + run_dir = Path.join(self_healing.workspace_root, "runs/#{run_id}") + worktree_path = Path.join(self_healing.workspace_root, "worktrees/#{run_id}") + File.mkdir_p!(run_dir) + + evidence = collect_evidence(config, reason, run_dir, opts) + evidence_path = Path.join(run_dir, "evidence.json") + File.write!(evidence_path, Jason.encode!(evidence, pretty: true)) + + with {:ok, _} <- prepare_worktree(config, branch, worktree_path, run_dir, opts), + {:ok, attempts, validation} <- + repair_until_valid(config, reason, evidence, worktree_path, opts), + true <- worktree_has_changes?(worktree_path, opts) || {:error, "repair made no changes"}, + {:ok, commit_sha} <- commit_repair(worktree_path, reason, opts), + {:ok, artifact_path} <- deploy_artifact(config, worktree_path), + restart_status <- restart_managed_status(config, artifact_path, opts), + github <- + sync_github(config, worktree_path, branch, reason, evidence_path, commit_sha, opts) do + write_cooldown(self_healing) + + %RunResult{ + run_id: run_id, + reason: reason, + branch: branch, + worktree_path: worktree_path, + artifact_path: artifact_path, + evidence_path: evidence_path, + status: :ok, + attempts: attempts, + validation: validation, + pr_url: github[:pr_url], + auto_merge_status: github[:auto_merge_status], + restart_status: restart_status + } + else + {:error, error} -> + %RunResult{ + run_id: run_id, + reason: reason, + branch: branch, + worktree_path: worktree_path, + evidence_path: evidence_path, + status: :error, + error: error + } + end + end + + defp collect_evidence(%ServiceConfig{} = config, reason, run_dir, opts) do + root = repo_root(config) + port = config.self_healing.restart_port + diff = run_shell("git diff --binary HEAD", root, opts) + File.write!(Path.join(run_dir, "current-working-tree.patch"), diff.output || "") + + %{ + "reason" => reason, + "captured_at" => Utils.isoformat_z(Utils.now_utc()), + "repo_root" => root, + "workflow_path" => config.workflow_path, + "git_status" => command_output("git status --short", root, opts), + "git_head" => command_output("git rev-parse HEAD", root, opts), + "git_branch" => command_output("git branch --show-current", root, opts), + "git_remote" => command_output("git remote -v", root, opts), + "state" => fetch_state(port), + "process" => + command_output("ps -ax -o pid,ppid,stat,command | grep '[s]ymphony'", root, opts), + "listeners" => command_output("lsof -nP -iTCP:#{port} -sTCP:LISTEN", root, opts), + "stdout_tail" => + command_output( + "tail -n 200 /var/tmp/caretta-symphony.out.log 2>/dev/null || true", + root, + opts + ), + "stderr_tail" => + command_output( + "tail -n 200 /var/tmp/caretta-symphony.err.log 2>/dev/null || true", + root, + opts + ) + } + end + + defp prepare_worktree(config, branch, worktree_path, run_dir, opts) do + root = repo_root(config) + File.rm_rf!(worktree_path) + File.mkdir_p!(Path.dirname(worktree_path)) + + commands = [ + "git fetch origin #{shell(config.self_healing.base_branch)} --quiet || true", + "git worktree add -B #{shell(branch)} #{shell(worktree_path)} HEAD" + ] + + case run_commands(commands, root, opts) do + {:ok, results} -> + patch_path = Path.join(run_dir, "current-working-tree.patch") + + if File.exists?(patch_path) and File.read!(patch_path) |> String.trim() != "" do + apply = run_shell("git apply --3way #{shell(patch_path)}", worktree_path, opts) + + if apply.status == 0, + do: {:ok, results ++ [apply]}, + else: {:error, "failed to apply current working-tree patch: #{apply.output}"} + else + {:ok, results} + end + + {:error, result} -> + {:error, result.output} + end + end + + defp repair_until_valid(config, reason, evidence, worktree_path, opts) do + max_attempts = config.self_healing.max_attempts + repair_fun = Keyword.get(opts, :repair_fun, &run_codex_repair/5) + + Enum.reduce_while(1..max_attempts, {:error, []}, fn attempt, {_status, history} -> + prompt = + build_repair_prompt( + reason, + Map.put(evidence, "validation_history", history), + attempt, + max_attempts + ) + + case repair_fun.(config, worktree_path, prompt, attempt, opts) do + {:ok, _text} -> + validation = validate(worktree_path, config.self_healing.validation_commands, opts) + + history = + history ++ [%{"attempt" => attempt, "validation" => encode_results(validation)}] + + if validation_success?(validation) do + {:halt, {:ok, attempt, validation}} + else + {:cont, {:error, history}} + end + + {:error, reason} -> + {:halt, {:error, "repair agent failed: #{reason}"}} + end + end) + |> case do + {:ok, attempts, validation} -> + {:ok, attempts, validation} + + {:error, history} -> + {:error, "validation failed after #{max_attempts} attempt(s): #{Jason.encode!(history)}"} + end + end + + defp run_codex_repair(config, worktree_path, prompt, _attempt, _opts) do + session = CodexClient.start_session(config.self_healing.repair_codex, worktree_path) + + try do + {result, _session} = CodexClient.run_turn(session, prompt, capture_agent_text: true) + {:ok, result.agent_message_text} + after + CodexClient.stop_session(session) + end + rescue + error -> {:error, Exception.message(error)} + end + + defp validate(worktree_path, commands, opts) do + Enum.map(commands, &run_shell(&1, worktree_path, opts)) + end + + defp worktree_has_changes?(worktree_path, opts) do + result = run_shell("git status --porcelain", worktree_path, opts) + result.status == 0 and String.trim(result.output || "") != "" + end + + defp commit_repair(worktree_path, reason, opts) do + message = "Self-heal Symphony: #{Utils.truncate(reason, 72)}" + + with {:ok, _} <- + run_commands(["git add -A", "git commit -m #{shell(message)}"], worktree_path, opts) do + sha = command_output("git rev-parse HEAD", worktree_path, opts) |> String.trim() + {:ok, sha} + else + {:error, result} -> {:error, "failed to commit repair: #{result.output}"} + end + end + + defp deploy_artifact(config, worktree_path) do + source = Path.join(worktree_path, "symphony") + dest = Path.join([config.self_healing.workspace_root, "deploy", "current", "symphony"]) + + if File.exists?(source) do + File.mkdir_p!(Path.dirname(dest)) + File.cp!(source, dest) + File.chmod!(dest, 0o755) + {:ok, dest} + else + {:error, "validated build did not produce #{source}"} + end + end + + defp restart_managed_status(config, artifact_path, opts) do + case restart_managed(config, Keyword.put(opts, :artifact_path, artifact_path)) do + {:ok, results} -> {:ok, encode_results(results)} + {:error, results} -> {:error, encode_results(results)} + end + end + + defp sync_github(config, worktree_path, branch, reason, evidence_path, commit_sha, opts) do + body_path = Path.join(Path.dirname(evidence_path), "pull-request.md") + + File.write!(body_path, pr_body(reason, evidence_path, commit_sha)) + + push = run_shell("git push -u origin #{shell(branch)}", worktree_path, opts) + + if push.status != 0 do + result = [ + pr_url: nil, + auto_merge_status: {:error, "push failed: #{Utils.truncate(push.output, 1000)}"} + ] + + write_github_sync_result(evidence_path, result) + result + else + title = "Self-heal Symphony: #{Utils.truncate(reason, 80)}" + + create = + run_shell( + "gh pr create --base #{shell(config.self_healing.base_branch)} --head #{shell(branch)} --title #{shell(title)} --body-file #{shell(body_path)}", + worktree_path, + opts + ) + + pr_url = pr_url_from_output(create.output) + + auto_merge = + if pr_url, + do: run_shell("gh pr merge --auto --squash #{shell(pr_url)}", worktree_path, opts), + else: %CommandResult{status: 1, output: "PR creation did not return a URL"} + + {_status, auto_merge_status} = classify_github_sync(create, auto_merge) + + result = [pr_url: pr_url, auto_merge_status: auto_merge_status] + write_github_sync_result(evidence_path, result) + result + end + end + + defp write_github_sync_result(evidence_path, result) do + body = + %{ + "pr_url" => result[:pr_url], + "auto_merge_status" => normalize_status(result[:auto_merge_status]) + } + |> Jason.encode!(pretty: true) + + File.write!(Path.join(Path.dirname(evidence_path), "github-sync.json"), body) + end + + defp pr_body(reason, evidence_path, commit_sha) do + """ + ## Self-Heal Summary + Symphony detected a local failure and produced a validated generic repair. + + Trigger: + #{reason} + + Local validated commit: + #{commit_sha} + + Evidence artifact: + #{evidence_path} + + Validation: + - `mix format --check-formatted` + - `mix test` + - `mix escript.build` + + Notes: + - Local Symphony is allowed to run ahead of `main`. + - This PR is the sync/audit path back to `main`. + - Auto-merge was requested without bypassing branch protection. + """ + end + + defp run_commands(commands, cwd, opts) do + Enum.reduce_while(commands, {:ok, []}, fn command, {:ok, acc} -> + result = run_shell(command, cwd, opts) + if result.status == 0, do: {:cont, {:ok, acc ++ [result]}}, else: {:halt, {:error, result}} + end) + end + + defp run_shell(command, cwd, opts) do + runner = Keyword.get(opts, :runner, &default_runner/3) + runner.(command, cwd, Keyword.get(opts, :env, [])) + end + + defp default_runner(command, cwd, env) do + {output, status} = + System.cmd("bash", ["-lc", command], + cd: cwd, + env: env, + stderr_to_stdout: true + ) + + %CommandResult{command: command, cwd: cwd, output: output, status: status} + rescue + error -> + %CommandResult{command: command, cwd: cwd, output: Exception.message(error), status: 1} + end + + defp command_output(command, cwd, opts) do + result = run_shell(command, cwd, opts) + Utils.truncate(result.output || "", 20_000) + end + + defp fetch_state(port) do + url = String.to_charlist("http://127.0.0.1:#{port}/api/v1/state") + + with {:ok, {{_, 200, _}, _headers, body}} <- + :httpc.request(:get, {url, []}, [{:timeout, 5_000}], body_format: :binary), + {:ok, json} <- Jason.decode(body) do + json + else + error -> %{"error" => inspect(error)} + end + end + + defp with_lock(%SelfHealingConfig{} = config, fun) do + lock_path = Path.join(config.workspace_root, "self-heal.lock") + File.mkdir_p!(Path.dirname(lock_path)) + + case File.open(lock_path, [:write, :exclusive]) do + {:ok, io} -> + try do + IO.write(io, "#{System.os_time(:second)}\n") + File.close(io) + fun.() + after + File.rm(lock_path) + end + + {:error, :eexist} -> + %RunResult{status: :skipped, error: "another self-heal run is active"} + + {:error, reason} -> + %RunResult{status: :error, error: "failed to create self-heal lock: #{inspect(reason)}"} + end + end + + defp cooldown_active?(%SelfHealingConfig{} = config) do + path = cooldown_path(config) + + with {:ok, body} <- File.read(path), + {timestamp, ""} <- Integer.parse(String.trim(body)) do + System.os_time(:millisecond) - timestamp < config.cooldown_ms + else + _ -> false + end + end + + defp write_cooldown(%SelfHealingConfig{} = config) do + File.mkdir_p!(config.workspace_root) + File.write!(cooldown_path(config), Integer.to_string(System.os_time(:millisecond))) + end + + defp cooldown_path(config), do: Path.join(config.workspace_root, "last-success-ms") + + defp ensure_root!(%SelfHealingConfig{} = config), do: File.mkdir_p!(config.workspace_root) + + defp repo_root(%ServiceConfig{} = config), + do: config.workflow_path |> Path.dirname() |> Path.expand() + + defp executable_artifact?(path) do + case File.stat(path) do + {:ok, %File.Stat{type: :regular, mode: mode}} -> band(mode, 0o111) != 0 + _ -> false + end + end + + defp encode_results(results) do + Enum.map(results, fn result -> + %{ + "command" => result.command, + "status" => result.status, + "output" => Utils.truncate(result.output || "", 10_000) + } + end) + end + + defp normalize_status(nil), do: nil + + defp normalize_status({status, message}), + do: %{"status" => to_string(status), "message" => message} + + defp normalize_status(value), do: value + + defp pr_url_from_output(output) do + case Regex.run(~r"https://github\.com/[^\s]+/pull/\d+", output || "") do + [url] -> url + _ -> nil + end + end + + defp sanitize_segment(value) do + value + |> to_string() + |> String.downcase() + |> String.replace(~r/[^a-z0-9._-]+/, "-") + |> String.trim("-") + |> then(fn text -> if text == "", do: "manual", else: text end) + end + + defp shell(value), + do: value |> to_string() |> String.replace("'", "'\"'\"'") |> then(&"'#{&1}'") +end diff --git a/lib/symphony/templating.ex b/lib/symphony/templating.ex new file mode 100644 index 0000000..5f95efd --- /dev/null +++ b/lib/symphony/templating.ex @@ -0,0 +1,75 @@ +defmodule Symphony.Templating do + @moduledoc false + + alias Symphony.Error + alias Symphony.Models.Issue + + @default_prompt "You are working on an issue from Linear." + @var_re ~r/{{\s*([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)\s*}}/ + + def render_prompt(template_text, %Issue{} = issue, attempt \\ nil) do + source = + template_text + |> to_string() + |> String.trim() + |> then(fn text -> if text == "", do: @default_prompt, else: text end) + + data = %{"issue" => Issue.to_template_data(issue), "attempt" => attempt} + + Regex.replace(@var_re, source, fn _match, path -> + case fetch_path(data, String.split(path, ".")) do + {:ok, nil} -> + "" + + {:ok, value} -> + to_string(value) + + :error -> + raise Error, code: :template_render_error, message: "undefined variable: #{path}" + end + end) + |> String.trim() + end + + def continuation_prompt(%Issue{} = issue, turn_number, max_turns) do + issue_data = Issue.to_template_data(issue) + + """ + Continue working on the same Linear issue in this existing Codex thread. + + Continuation turn: #{turn_number} of #{max_turns}. + + Current Linear issue snapshot, authoritative for this turn: + Issue: #{issue.identifier} - #{issue.title} + URL: #{issue.url || "(none)"} + State: #{issue.state || "(unknown)"} + Priority: #{issue.priority || "(none)"} + Labels: #{format_labels(issue.labels)} + Updated at: #{issue_data["updated_at"] || "(unknown)"} + + Description: + #{format_description(issue.description)} + + Do not resend the original task from scratch. Inspect current progress, complete the next needed work, validate the result, and perform the workflow-defined handoff if ready. If this current snapshot differs from earlier assumptions, pause and adapt to the current Linear text before editing more code. Do not choose a repository from sibling issue workspaces; use the injected coding context, repo map, or explicit issue text. + """ + |> String.trim() + end + + defp fetch_path(value, []), do: {:ok, value} + + defp fetch_path(map, [key | rest]) when is_map(map) do + if Map.has_key?(map, key), do: fetch_path(Map.get(map, key), rest), else: :error + end + + defp fetch_path(_value, _path), do: :error + + defp format_labels([]), do: "(none)" + defp format_labels(labels), do: Enum.join(labels, ", ") + + defp format_description(nil), do: "(none)" + + defp format_description(description) do + text = String.trim(description) + if text == "", do: "(none)", else: text + end +end diff --git a/lib/symphony/tracker.ex b/lib/symphony/tracker.ex new file mode 100644 index 0000000..2d75038 --- /dev/null +++ b/lib/symphony/tracker.ex @@ -0,0 +1,922 @@ +defmodule Symphony.Tracker do + @moduledoc false + + alias Symphony.Config.TrackerConfig + alias Symphony.Error + alias Symphony.Models.{BlockerRef, Issue, IssueAssignee, IssueAttachment} + alias Symphony.Utils + + defmodule LinearClient do + @linear_timeout_ms 30_000 + + defstruct config: nil, transport: nil + + alias Symphony.Error + alias Symphony.Models.{BlockerRef, Issue} + alias Symphony.Tracker + alias Symphony.Utils + + def fetch_candidate_issues(%__MODULE__{} = client), + do: fetch_by_states(client, client.config.active_states) + + def fetch_issues_by_states(_client, []), do: [] + + def fetch_issues_by_states(%__MODULE__{} = client, states), + do: fetch_by_states(client, states) + + def fetch_issue_states_by_ids(_client, []), do: [] + + def fetch_issue_states_by_ids(%__MODULE__{} = client, issue_ids) do + query = """ + query SymphonyIssueStates($ids: [ID!]) { + issues(filter: { id: { in: $ids } }, first: 100) { + nodes { + id identifier title description priority branchName url createdAt updatedAt + assignee { id name displayName email } + state { name } + labels { nodes { name } } + attachments { nodes { id title subtitle url } } + inverseRelations { nodes { type issue { id identifier state { name } } } } + } + } + } + """ + + body = graphql(client, query, %{"ids" => issue_ids}) + nodes = get_in(body, ["data", "issues", "nodes"]) + + unless is_list(nodes) do + raise Error, + code: :linear_unknown_payload, + message: "state refresh payload missing issues.nodes" + end + + Enum.map(nodes, &normalize_issue/1) + end + + def execute_graphql_once(%__MODULE__{} = client, query, variables \\ %{}) do + graphql(client, query, variables || %{}) + end + + defp fetch_by_states(%__MODULE__{} = client, states) do + query = """ + query SymphonyIssuesByState($projectSlug: String!, $stateNames: [String!], $first: Int!, $after: String) { + issues( + filter: { + project: { slugId: { eq: $projectSlug } } + state: { name: { in: $stateNames } } + } + first: $first + after: $after + ) { + nodes { + id identifier title description priority branchName url createdAt updatedAt + assignee { id name displayName email } + state { name } + labels { nodes { name } } + attachments { nodes { id title subtitle url } } + inverseRelations { nodes { type issue { id identifier state { name } } } } + } + pageInfo { hasNextPage endCursor } + } + } + """ + + fetch_page(client, query, states, nil, []) + end + + defp fetch_page(client, query, states, after_cursor, acc) do + body = + graphql(client, query, %{ + "projectSlug" => client.config.project_slug, + "stateNames" => states, + "first" => 50, + "after" => after_cursor + }) + + connection = get_in(body, ["data", "issues"]) || %{} + nodes = connection["nodes"] + page_info = connection["pageInfo"] + + unless is_list(nodes) and is_map(page_info) do + raise Error, + code: :linear_unknown_payload, + message: "candidate payload missing issues.nodes/pageInfo" + end + + issues = acc ++ Enum.map(nodes, &normalize_issue/1) + + if page_info["hasNextPage"] do + cursor = page_info["endCursor"] + + unless cursor do + raise Error, + code: :linear_missing_end_cursor, + message: "Linear pagination requested another page without endCursor" + end + + fetch_page(client, query, states, cursor, issues) + else + issues + end + end + + defp graphql(%__MODULE__{transport: transport}, query, variables) + when is_function(transport, 2) do + body = transport.(query, variables) + validate_graphql_body(body) + end + + defp graphql(%__MODULE__{} = client, query, variables) do + unless client.config.endpoint, + do: raise(Error, code: :linear_api_request, message: "Linear endpoint is missing") + + unless client.config.api_key, + do: raise(Error, code: :missing_tracker_api_key, message: "Linear API key is missing") + + request_body = Jason.encode!(%{"query" => query, "variables" => variables}) + + headers = [ + {~c"authorization", to_charlist(client.config.api_key)}, + {~c"content-type", ~c"application/json"}, + {~c"accept", ~c"application/json"} + ] + + request = {to_charlist(client.config.endpoint), headers, ~c"application/json", request_body} + + case :httpc.request(:post, request, [{:timeout, @linear_timeout_ms}], body_format: :binary) do + {:ok, {{_, 200, _}, _headers, body}} -> + body |> Jason.decode!() |> validate_graphql_body() + + {:ok, {{_, status, _}, _headers, _body}} -> + raise Error, code: :linear_api_status, message: "Linear HTTP status #{status}" + + {:error, reason} -> + raise Error, + code: :linear_api_request, + message: "Linear request failed: #{inspect(reason)}" + end + end + + defp validate_graphql_body(body) when is_map(body) do + if body["errors"], + do: raise(Error, code: :linear_graphql_errors, message: "Linear GraphQL returned errors") + + body + end + + defp validate_graphql_body(_), + do: + raise(Error, + code: :linear_unknown_payload, + message: "Linear response is not a JSON object" + ) + + defp normalize_issue(node) when is_map(node) do + state = get_in(node, ["state", "name"]) + + labels = + get_in(node, ["labels", "nodes"]) + |> normalize_label_nodes() + + attachments = Tracker.normalize_attachments(get_in(node, ["attachments", "nodes"])) + + blockers = + node + |> get_in(["inverseRelations", "nodes"]) + |> case do + nodes when is_list(nodes) -> nodes + _ -> [] + end + |> Enum.flat_map(fn + %{"type" => "blocks", "issue" => issue} when is_map(issue) -> + [ + %BlockerRef{ + id: issue["id"], + identifier: issue["identifier"], + state: get_in(issue, ["state", "name"]) + } + ] + + _ -> + [] + end) + + priority = + if is_integer(node["priority"]) and !is_boolean(node["priority"]), do: node["priority"] + + %Issue{ + id: + to_string( + node["id"] || + raise(Error, + code: :linear_unknown_payload, + message: "issue node missing required field id" + ) + ), + identifier: + to_string( + node["identifier"] || + raise(Error, + code: :linear_unknown_payload, + message: "issue node missing required field identifier" + ) + ), + title: + to_string( + node["title"] || + raise(Error, + code: :linear_unknown_payload, + message: "issue node missing required field title" + ) + ), + description: node["description"], + priority: priority, + state: to_string(state || ""), + branch_name: node["branchName"], + url: node["url"], + assignee: Tracker.normalize_assignee(node["assignee"]), + labels: labels, + attachments: attachments, + blocked_by: blockers, + created_at: Utils.parse_datetime(node["createdAt"]), + updated_at: Utils.parse_datetime(node["updatedAt"]) + } + end + + defp normalize_issue(_), + do: raise(Error, code: :linear_unknown_payload, message: "issue node is not an object") + + defp normalize_label_nodes(nodes) when is_list(nodes) do + nodes + |> Enum.flat_map(fn + %{"name" => name} when is_binary(name) -> [String.downcase(name)] + _ -> [] + end) + end + + defp normalize_label_nodes(_), do: [] + end + + defmodule LinearMcpClient do + @linear_page_size 50 + @linear_mcp_tools %{ + list_issues: ["linear_list_issues", "linear mcp server_list_issues"], + get_issue: ["linear_get_issue", "linear mcp server_get_issue"], + list_comments: ["linear_list_comments", "linear mcp server_list_comments"], + save_comment: ["linear_save_comment", "linear mcp server_save_comment"], + save_issue: ["linear_save_issue", "linear mcp server_save_issue"] + } + + defstruct config: nil, gateway: nil + + alias Symphony.Error + alias Symphony.Models.{BlockerRef, Issue} + alias Symphony.Tracker + alias Symphony.Utils + + def fetch_candidate_issues(%__MODULE__{} = client) do + client.config.active_states + |> Enum.flat_map(&list_issues(client, state: &1)) + |> hydrate_todo_blockers(client) + |> dedupe_issues() + end + + def fetch_issues_by_states(_client, []), do: [] + + def fetch_issues_by_states(%__MODULE__{} = client, states) do + states |> Enum.flat_map(&list_issues(client, state: &1)) |> dedupe_issues() + end + + def fetch_issue_states_by_ids(%__MODULE__{} = client, ids) do + Enum.map(ids, fn id -> + client + |> call_gateway(linear_tool(:get_issue), %{"id" => id, "includeRelations" => true}) + |> normalize_issue() + end) + end + + def list_issue_comments(%__MODULE__{} = client, issue_id) do + body = + call_gateway(client, linear_tool(:list_comments), %{ + "issueId" => issue_id, + "limit" => 250, + "orderBy" => "createdAt" + }) + + cond do + is_list(body) -> + Enum.filter(body, &is_map/1) + + is_map(body) and is_list(body["comments"]) -> + Enum.filter(body["comments"], &is_map/1) + + is_map(body) and is_list(body["nodes"]) -> + Enum.filter(body["nodes"], &is_map/1) + + true -> + raise Error, + code: :linear_unknown_payload, + message: "Linear MCP comments payload missing comments list" + end + end + + def save_issue_comment(%__MODULE__{} = client, issue_id, body, opts \\ []) do + args = + if comment_id = Keyword.get(opts, :comment_id) do + %{"body" => body, "id" => comment_id} + else + %{"body" => body, "issueId" => issue_id} + end + + response = call_gateway(client, linear_tool(:save_comment), args) + + unless is_map(response), + do: + raise(Error, + code: :linear_unknown_payload, + message: "Linear MCP save comment response is not an object" + ) + + response + end + + def save_issue_state(%__MODULE__{} = client, issue_id, state) do + response = + call_gateway(client, linear_tool(:save_issue), %{"id" => issue_id, "state" => state}) + + unless is_map(response), + do: + raise(Error, + code: :linear_unknown_payload, + message: "Linear MCP save issue response is not an object" + ) + + response + end + + defp list_issues(client, state: state), do: list_issues_page(client, state, nil, []) + + defp list_issues_page(client, state, cursor, acc) do + args = + %{"limit" => min(@linear_page_size, 250), "state" => state, "includeArchived" => false} + |> put_if("project", client.config.project_slug) + |> put_if("team", client.config.team) + |> put_if("label", List.first(client.config.required_labels)) + |> put_if("cursor", cursor) + + body = call_gateway(client, linear_tool(:list_issues), args) + nodes = body["issues"] + + unless is_list(nodes) do + raise Error, + code: :linear_unknown_payload, + message: "Linear MCP payload missing issues list" + end + + issues = acc ++ Enum.map(nodes, &normalize_issue/1) + + if body["hasNextPage"] do + cursor = body["cursor"] + + unless cursor do + raise Error, + code: :linear_missing_end_cursor, + message: "Linear MCP pagination requested another page without cursor" + end + + list_issues_page(client, state, cursor, issues) + else + issues + end + end + + defp hydrate_todo_blockers(issues, client) do + Enum.map(issues, fn issue -> + if String.downcase(issue.state) == "todo" do + client + |> call_gateway(linear_tool(:get_issue), %{ + "id" => issue.identifier, + "includeRelations" => true + }) + |> normalize_issue() + else + issue + end + end) + end + + defp normalize_issue(node) when is_map(node) do + identifier = to_string(node["id"] || "") + + if identifier == "", + do: + raise(Error, + code: :linear_unknown_payload, + message: "Linear MCP issue payload missing id" + ) + + priority = + cond do + is_map(node["priority"]) and is_integer(node["priority"]["value"]) and + node["priority"]["value"] > 0 -> + node["priority"]["value"] + + is_integer(node["priority"]) and node["priority"] > 0 -> + node["priority"] + + true -> + nil + end + + blocked_by = + get_in(node, ["relations", "blockedBy"]) + |> case do + blockers when is_list(blockers) -> blockers + _ -> [] + end + |> Enum.flat_map(fn + blocker when is_map(blocker) -> + blocker_id = blocker["id"] || blocker["identifier"] + + [ + %BlockerRef{ + id: blocker_id && to_string(blocker_id), + identifier: blocker_id && to_string(blocker_id), + state: blocker["status"] || blocker["state"] + } + ] + + _ -> + [] + end) + + %Issue{ + id: identifier, + identifier: identifier, + title: to_string(node["title"] || ""), + description: node["description"], + priority: priority, + state: to_string(node["status"] || node["state"] || ""), + branch_name: node["gitBranchName"] || node["branchName"], + url: node["url"], + assignee: Tracker.normalize_assignee(node["assignee"] || node["owner"]), + labels: Enum.map(node["labels"] || [], &(to_string(&1) |> String.downcase())), + attachments: Tracker.normalize_attachments(node["attachments"]), + blocked_by: blocked_by, + created_at: Utils.parse_datetime(node["createdAt"]), + updated_at: Utils.parse_datetime(node["updatedAt"]) + } + end + + defp normalize_issue(_), + do: + raise(Error, + code: :linear_unknown_payload, + message: "Linear MCP issue payload is not an object" + ) + + defp call_gateway(%__MODULE__{gateway: gateway}, tool, args) when is_function(gateway, 2), + do: gateway.(primary_tool(tool), args) + + defp call_gateway(%__MODULE__{gateway: gateway}, tool, args) when not is_nil(gateway) do + Symphony.Tracker.CodexMcpGateway.call_tool(gateway, tool, args) + end + + defp call_gateway(%__MODULE__{} = client, tool, args) do + gateway = + struct(Symphony.Tracker.CodexMcpGateway, + command: client.config.mcp_command, + server: client.config.mcp_server + ) + + Symphony.Tracker.CodexMcpGateway.call_tool(gateway, tool, args) + end + + defp linear_tool(name), do: Map.fetch!(@linear_mcp_tools, name) + + defp primary_tool([tool | _]), do: tool + defp primary_tool(tool), do: tool + + defp dedupe_issues(issues) do + issues + |> Enum.reduce({MapSet.new(), []}, fn issue, {seen, acc} -> + if MapSet.member?(seen, issue.id), + do: {seen, acc}, + else: {MapSet.put(seen, issue.id), acc ++ [issue]} + end) + |> elem(1) + end + + defp put_if(map, _key, nil), do: map + defp put_if(map, key, value), do: Map.put(map, key, value) + end + + defmodule CodexMcpGateway do + @gateway_attempts 3 + + defstruct command: "codex app-server", + server: "codex_apps", + cwd: nil, + next_id: 1, + buffer: "", + port: nil + + alias Symphony.CodexClient + alias Symphony.Error + alias Symphony.Utils + + def call_tool(%__MODULE__{} = gateway, tool, arguments) do + tools = normalize_tool_candidates!(tool) + + Enum.reduce_while(1..@gateway_attempts, nil, fn attempt, _last_error -> + try do + {:halt, call_tool_once(gateway, tools, arguments)} + rescue + error in Error -> + if retryable?(error) and attempt < @gateway_attempts do + Process.sleep(min(2 * attempt, 5) * 1000) + {:cont, error} + else + reraise error, __STACKTRACE__ + end + end + end) + end + + defp normalize_tool_candidates!(tool) when is_binary(tool), do: [tool] + + defp normalize_tool_candidates!(tools) when is_list(tools) do + tools + |> Enum.filter(&is_binary/1) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + |> Enum.uniq() + |> case do + [] -> + raise Error, code: :linear_mcp_app_server, message: "MCP tool candidate list is empty" + + normalized -> + normalized + end + end + + defp call_tool_once(gateway, tools, arguments) do + cwd = Path.expand(gateway.cwd || File.cwd!()) + + session = + CodexClient.start_session(%Symphony.Config.CodexConfig{command: gateway.command}, cwd, + on_event: fn _ -> :ok end + ) + + try do + {response, _gateway} = + gateway + |> Map.merge(%{port: session.port, buffer: session.buffer, next_id: session.next_id}) + |> resolve_tool_order(tools) + |> invoke_mcp_candidates(session.thread_id, arguments) + + response + after + CodexClient.stop_session(session) + end + end + + defp resolve_tool_order(gateway, [_single] = tools), do: {tools, gateway} + + defp resolve_tool_order(gateway, tools) do + case list_mcp_tool_names(gateway) do + {available_tools, gateway} when is_list(available_tools) -> + available = MapSet.new(available_tools) + preferred = Enum.filter(tools, &MapSet.member?(available, &1)) + ordered = Enum.uniq(preferred ++ tools) + {ordered, gateway} + + {_, gateway} -> + {tools, gateway} + end + end + + defp invoke_mcp_candidates({tools, gateway}, thread_id, arguments), + do: invoke_mcp_candidates(gateway, thread_id, tools, arguments) + + defp invoke_mcp_candidates(gateway, thread_id, [tool], arguments) do + {response, gateway} = invoke_mcp_request(gateway, thread_id, tool, arguments) + {decode_tool_response(response), gateway} + end + + defp invoke_mcp_candidates(gateway, thread_id, [tool | rest], arguments) do + {response, gateway} = invoke_mcp_request(gateway, thread_id, tool, arguments) + + try do + {decode_tool_response(response), gateway} + rescue + error in Error -> + if unknown_tool_error?(error) do + invoke_mcp_candidates(gateway, thread_id, rest, arguments) + else + reraise error, __STACKTRACE__ + end + end + end + + defp list_mcp_tool_names(gateway) do + request_id = gateway.next_id + gateway = %{gateway | next_id: request_id + 1} + + gateway = + send_message(gateway, %{ + "method" => "mcpServerStatus/list", + "id" => request_id, + "params" => %{"detail" => "toolsAndAuthOnly", "limit" => 100} + }) + + case receive_gateway_response_result(gateway, request_id) do + {{:ok, response}, gateway} -> + {extract_mcp_tool_names(response), gateway} + + {{:error, _error}, gateway} -> + {nil, gateway} + end + rescue + _ -> {nil, gateway} + end + + defp extract_mcp_tool_names(%{"data" => servers}) when is_list(servers) do + servers + |> Enum.flat_map(fn + %{"tools" => tools} when is_map(tools) -> + Enum.flat_map(tools, fn + {key, %{"name" => name}} when is_binary(name) -> [key, name] + {key, _tool} -> [key] + end) + + _ -> + [] + end) + |> Enum.filter(&is_binary/1) + |> Enum.uniq() + end + + defp extract_mcp_tool_names(_), do: [] + + defp invoke_mcp_request(gateway, thread_id, tool, arguments) do + request_id = gateway.next_id + gateway = %{gateway | next_id: request_id + 1} + + send_message(gateway, %{ + "method" => "mcpServer/tool/call", + "id" => request_id, + "params" => %{ + "threadId" => thread_id, + "server" => gateway.server, + "tool" => tool, + "arguments" => arguments + } + }) + + receive_gateway_response(gateway, request_id) + end + + defp receive_gateway_response(gateway, request_id) do + case receive_gateway_response_result(gateway, request_id) do + {{:ok, result}, gateway} -> + {result, gateway} + + {{:error, error}, _gateway} -> + raise Error, code: :linear_mcp_app_server, message: Jason.encode!(error) + end + end + + defp receive_gateway_response_result(gateway, request_id) do + {msg, gateway} = read_message(gateway) + + cond do + msg["id"] == request_id and !Map.has_key?(msg, "method") -> + result = + if msg["error"] do + {:error, msg["error"]} + else + {:ok, msg["result"] || %{}} + end + + {result, gateway} + + Map.has_key?(msg, "method") and Map.has_key?(msg, "id") -> + gateway = handle_gateway_server_request(gateway, msg) + receive_gateway_response_result(gateway, request_id) + + true -> + receive_gateway_response_result(gateway, request_id) + end + end + + defp send_message(gateway, message) do + Port.command(gateway.port, Jason.encode!(message) <> "\n") + gateway + end + + defp read_message(gateway) do + case next_line(gateway.buffer) do + {line, rest} -> + {Jason.decode!(line), %{gateway | buffer: rest}} + + :none -> + receive do + {port, {:data, data}} when port == gateway.port -> + read_message(%{gateway | buffer: gateway.buffer <> data}) + + {port, {:exit_status, status}} when port == gateway.port -> + raise Error, + code: :linear_mcp_app_server, + message: "app-server exited before MCP response: #{status}" + after + 30_000 -> + raise Error, + code: :linear_mcp_app_server, + message: "timed out waiting for app-server MCP response" + end + end + end + + defp next_line(buffer) do + case :binary.match(buffer, "\n") do + {index, 1} -> + <> = buffer + {String.trim_trailing(line, "\r"), rest} + + :nomatch -> + :none + end + end + + defp handle_gateway_server_request(gateway, msg) do + request_id = msg["id"] + method = to_string(msg["method"]) + params = if is_map(msg["params"]), do: msg["params"], else: %{} + + cond do + method in ["item/commandExecution/requestApproval", "item/fileChange/requestApproval"] -> + send_message(gateway, %{ + "id" => request_id, + "result" => %{"decision" => "acceptForSession"} + }) + + method in ["item/tool/requestUserInput", "tool/requestUserInput"] -> + answers = + Utils.tool_request_user_input_approval_answers(params) || + Utils.tool_request_user_input_unavailable_answers(params) + + if answers do + send_message(gateway, %{"id" => request_id, "result" => %{"answers" => answers}}) + else + send_message(gateway, %{ + "id" => request_id, + "error" => %{"code" => -32601, "message" => "unsupported server request: #{method}"} + }) + end + + true -> + send_message(gateway, %{ + "id" => request_id, + "error" => %{"code" => -32601, "message" => "unsupported server request: #{method}"} + }) + end + end + + defp decode_tool_response(response) when is_map(response) do + if response["isError"], + do: raise(Error, code: :linear_mcp_tool_error, message: Jason.encode!(response)) + + content = response["content"] + + unless is_list(content), + do: + raise(Error, + code: :linear_mcp_app_server, + message: "MCP tool response missing content list" + ) + + Enum.find_value(content, fn + %{"type" => "text", "text" => text} when is_binary(text) -> Jason.decode!(text) + _ -> nil + end) || + raise(Error, + code: :linear_mcp_app_server, + message: "MCP tool response did not contain text JSON" + ) + end + + defp decode_tool_response(_), + do: + raise(Error, code: :linear_mcp_app_server, message: "MCP tool response is not an object") + + defp retryable?(%Error{code: :linear_mcp_app_server}), do: true + + defp retryable?(%Error{code: :linear_mcp_tool_error, message: message}), + do: + String.contains?(String.downcase(message), [ + "transport", + "http request failed", + "timed out", + "failed to get client" + ]) + + defp retryable?(_), do: false + + defp unknown_tool_error?(%Error{code: :linear_mcp_tool_error, message: message}) do + message = String.downcase(message) + String.contains?(message, "unknown tool") or String.contains?(message, "tool not found") + end + + defp unknown_tool_error?(_), do: false + end + + def normalize_attachments(value) when is_list(value) do + Enum.flat_map(value, fn + item when is_map(item) -> + [ + %IssueAttachment{ + id: maybe_string(item["id"]), + title: maybe_string(item["title"]), + subtitle: maybe_string(item["subtitle"]), + url: maybe_string(item["url"]) + } + ] + + _ -> + [] + end) + end + + def normalize_attachments(_), do: [] + + def normalize_assignee(nil), do: nil + + def normalize_assignee(value) when is_binary(value) do + trimmed = String.trim(value) + + if trimmed == "", + do: nil, + else: %IssueAssignee{name: trimmed, display_name: trimmed, mention: mention_text(trimmed)} + end + + def normalize_assignee(value) when is_map(value) do + id = maybe_string(map_get(value, "id")) + name = maybe_string(map_get(value, "name") || map_get(value, "username")) + display_name = maybe_string(map_get(value, "displayName") || map_get(value, "display_name")) + email = maybe_string(map_get(value, "email")) + url = maybe_string(map_get(value, "url")) + + mention = + maybe_string(map_get(value, "mention")) || + mention_text( + map_get(value, "handle") || map_get(value, "username") || display_name || name + ) + + if Enum.any?([id, name, display_name, email, url, mention], &(!is_nil(&1))) do + %IssueAssignee{ + id: id, + name: name, + display_name: display_name, + email: email, + url: url, + mention: mention + } + end + end + + def normalize_assignee(_), do: nil + + defp maybe_string(nil), do: nil + defp maybe_string(value), do: to_string(value) + + defp mention_text(nil), do: nil + + defp mention_text(value) do + text = String.trim(to_string(value)) + + cond do + text == "" -> nil + String.starts_with?(text, "@") -> text + true -> "@#{text}" + end + end + + defp map_get(map, key) when is_map(map) do + Map.get(map, key) || Map.get(map, String.to_existing_atom(key)) + rescue + ArgumentError -> nil + end + + def make_tracker(%TrackerConfig{kind: "linear_mcp"} = config), + do: %LinearMcpClient{config: config} + + def make_tracker(%TrackerConfig{kind: "linear"} = config), do: %LinearClient{config: config} + + def make_tracker(%TrackerConfig{} = config) do + raise Error, + code: :unsupported_tracker_kind, + message: "unsupported tracker kind: #{config.kind}" + end +end diff --git a/lib/symphony/utils.ex b/lib/symphony/utils.ex new file mode 100644 index 0000000..206de9d --- /dev/null +++ b/lib/symphony/utils.ex @@ -0,0 +1,235 @@ +defmodule Symphony.Utils do + @moduledoc false + + @workspace_key_re ~r/[^A-Za-z0-9._-]/ + @secret_field_re ~r/(api[_-]?key|token|secret|authorization)/i + + @jsonl_read_limit_bytes 10 * 1024 * 1024 + 1 + @non_interactive_tool_input_answer "This is a non-interactive session. Operator input is unavailable." + + def jsonl_read_limit_bytes, do: @jsonl_read_limit_bytes + def non_interactive_tool_input_answer, do: @non_interactive_tool_input_answer + + def now_utc do + DateTime.utc_now() + end + + def isoformat_z(nil), do: nil + + def isoformat_z(%DateTime{} = value) do + value + |> DateTime.shift_zone!("Etc/UTC") + |> DateTime.truncate(:second) + |> DateTime.to_iso8601() + end + + def isoformat_z(%NaiveDateTime{} = value) do + value + |> DateTime.from_naive!("Etc/UTC") + |> isoformat_z() + end + + def parse_datetime(nil), do: nil + def parse_datetime(""), do: nil + def parse_datetime(%DateTime{} = value), do: value + + def parse_datetime(value) when is_binary(value) do + text = String.trim(value) + + cond do + text == "" -> + nil + + true -> + case DateTime.from_iso8601(text) do + {:ok, datetime, _offset} -> datetime + _ -> parse_naive_datetime(text) + end + end + end + + def parse_datetime(_), do: nil + + defp parse_naive_datetime(text) do + case NaiveDateTime.from_iso8601(String.trim_trailing(text, "Z")) do + {:ok, naive} -> DateTime.from_naive!(naive, "Etc/UTC") + _ -> nil + end + end + + def normalize_state(value) do + value + |> to_string_or_empty() + |> String.trim() + |> String.downcase() + end + + def sanitize_workspace_key(identifier) do + sanitized = + identifier + |> to_string_or_empty() + |> String.replace(@workspace_key_re, "_") + + if sanitized == "", do: "_", else: sanitized + end + + def resolve_under_root(root, child_name) do + root_abs = Path.expand(root) + child = Path.expand(Path.join(root_abs, child_name)) + root_parts = Path.split(root_abs) + child_parts = Path.split(child) + + if Enum.take(child_parts, length(root_parts)) != root_parts do + raise ArgumentError, "path escapes workspace root: #{child}" + end + + child + end + + def truncate(value, limit \\ 4000) + def truncate(nil, _limit), do: "" + + def truncate(value, limit) do + text = to_string(value) + + if String.length(text) <= limit, + do: text, + else: String.slice(text, 0, limit) <> "..." + end + + def redact_field(key, value) do + if Regex.match?(@secret_field_re, to_string(key)), do: "", else: value + end + + def key_value_message(event, fields \\ []) do + field_parts = + Enum.map(fields, fn {key, value} -> + safe = redact_field(key, value) + text = format_field_value(safe) + "#{key}=#{text}" + end) + + Enum.join(["event=#{event}" | field_parts], " ") + end + + defp format_field_value(nil), do: "null" + defp format_field_value(%DateTime{} = value), do: isoformat_z(value) + + defp format_field_value(value) do + text = value |> to_string() |> String.replace("\n", "\\n") + if String.contains?(text, " "), do: inspect(text), else: text + end + + def tool_request_user_input_approval_answers(params) when is_map(params) do + with questions when is_list(questions) <- + Map.get(params, "questions") || Map.get(params, :questions) do + answers = + Enum.reduce_while(questions, %{}, fn question, acc -> + question_id = map_get(question, "id") + answer_label = approval_option_label(map_get(question, "options")) + + cond do + not is_binary(question_id) or question_id == "" -> {:halt, nil} + is_nil(answer_label) -> {:halt, nil} + true -> {:cont, Map.put(acc, question_id, %{"answers" => [answer_label]})} + end + end) + + if map_size(answers || %{}) == 0, do: nil, else: answers + else + _ -> nil + end + end + + def tool_request_user_input_approval_answers(_), do: nil + + def tool_request_user_input_unavailable_answers(params) when is_map(params) do + with questions when is_list(questions) <- + Map.get(params, "questions") || Map.get(params, :questions) do + answers = + Enum.reduce_while(questions, %{}, fn question, acc -> + question_id = map_get(question, "id") + + if is_binary(question_id) and question_id != "" do + {:cont, + Map.put(acc, question_id, %{"answers" => [@non_interactive_tool_input_answer]})} + else + {:halt, nil} + end + end) + + if map_size(answers || %{}) == 0, do: nil, else: answers + else + _ -> nil + end + end + + def tool_request_user_input_unavailable_answers(_), do: nil + + defp approval_option_label(options) when is_list(options) do + labels = + options + |> Enum.map(&map_get(&1, "label")) + |> Enum.filter(&is_binary/1) + + Enum.find(["Approve this Session", "Approve Once"], &(&1 in labels)) || + Enum.find(labels, fn label -> + normalized = label |> String.trim() |> String.downcase() + String.starts_with?(normalized, "approve") or String.starts_with?(normalized, "allow") + end) + end + + defp approval_option_label(_), do: nil + + def map_get(map, key, default \\ nil) + + def map_get(map, key, default) when is_map(map) do + cond do + Map.has_key?(map, key) -> + Map.get(map, key) + + is_binary(key) -> + case existing_atom(key) do + nil -> default + atom -> Map.get(map, atom, default) + end + + true -> + default + end + end + + def map_get(_, _, default), do: default + + defp existing_atom(value) do + String.to_existing_atom(value) + rescue + ArgumentError -> nil + end + + def to_string_or_empty(nil), do: "" + def to_string_or_empty(value), do: to_string(value) + + def to_int(value) + def to_int(value) when is_boolean(value) or is_nil(value), do: nil + def to_int(value) when is_integer(value), do: value + + def to_int(value) do + case Integer.parse(to_string(value)) do + {int, ""} -> int + _ -> nil + end + end + + def to_float(value) + def to_float(value) when is_boolean(value) or is_nil(value), do: nil + def to_float(value) when is_float(value), do: value + def to_float(value) when is_integer(value), do: value / 1 + + def to_float(value) do + case Float.parse(to_string(value)) do + {float, ""} -> float + _ -> nil + end + end +end diff --git a/lib/symphony/watchdog.ex b/lib/symphony/watchdog.ex new file mode 100644 index 0000000..8984041 --- /dev/null +++ b/lib/symphony/watchdog.ex @@ -0,0 +1,185 @@ +defmodule Symphony.Watchdog do + @moduledoc false + + alias Symphony.Config.{ConfigManager, ServiceConfig} + alias Symphony.SelfHeal + alias Symphony.Utils + + def run(manager_or_config, opts \\ []) + + def run(%ConfigManager{} = manager, opts) do + {_manager, _workflow, config} = ConfigManager.current(manager) + + if config.self_healing.enabled do + loop(manager, opts) + else + IO.puts("Symphony watchdog is disabled by self_healing.enabled=false") + 0 + end + end + + def run(%ServiceConfig{} = config, opts) do + if config.self_healing.enabled do + loop_config(config, opts) + else + IO.puts("Symphony watchdog is disabled by self_healing.enabled=false") + 0 + end + end + + def run_once(manager_or_config, opts \\ []) + + def run_once(%ConfigManager{} = manager, opts) do + {_manager, _workflow, config} = ConfigManager.current(manager) + run_once(config, opts) + end + + def run_once(%ServiceConfig{} = config, opts) do + if config.self_healing.enabled do + state = + Keyword.get_lazy(opts, :state, fn -> fetch_state(config.self_healing.restart_port) end) + + now = Keyword.get(opts, :now, Utils.now_utc()) + + case classify_state(state, config, now) do + :healthy -> + {:ok, :healthy} + + {:trigger, reason} -> + self_heal_fun = Keyword.get(opts, :self_heal_fun, &SelfHeal.run_once/2) + + self_heal_opts = + opts + |> Keyword.drop([:state, :now, :self_heal_fun]) + |> Keyword.put(:reason, reason) + + {:triggered, self_heal_fun.(config, self_heal_opts)} + end + else + {:ok, :disabled} + end + end + + def classify_state(snapshot, config, now \\ Utils.now_utc()) + + def classify_state(%{"error" => error}, %ServiceConfig{} = config, _now) do + {:trigger, + "Symphony API is unreachable on port #{config.self_healing.restart_port}: #{error}"} + end + + def classify_state(snapshot, %ServiceConfig{} = config, now) + when is_map(snapshot) do + service = if is_map(snapshot["service"]), do: snapshot["service"], else: %{} + status = service["status"] |> to_string() |> String.downcase() + + cond do + status in ["degraded", "failed", "error"] -> + {:trigger, degraded_reason(service)} + + stale_poll?(service, config.self_healing.stale_poll_ms, now) -> + {:trigger, stale_reason(service, config.self_healing.stale_poll_ms)} + + true -> + :healthy + end + end + + def classify_state(_snapshot, %ServiceConfig{} = config, _now) do + {:trigger, + "Symphony API returned an invalid state payload on port #{config.self_healing.restart_port}"} + end + + def fetch_state(port) do + url = String.to_charlist("http://127.0.0.1:#{port}/api/v1/state") + + with {:ok, {{_, 200, _}, _headers, body}} <- + :httpc.request(:get, {url, []}, [{:timeout, 5_000}], body_format: :binary), + {:ok, json} when is_map(json) <- Jason.decode(body) do + json + else + error -> %{"error" => inspect(error)} + end + end + + defp loop(manager, opts) do + {manager, _changed} = ConfigManager.reload_if_changed(manager) + {_manager, _workflow, config} = ConfigManager.current(manager) + log_once(run_once(config, opts)) + Process.sleep(watchdog_interval(config)) + loop(manager, opts) + rescue + error -> + IO.puts(:stderr, "Symphony watchdog poll failed: #{Exception.message(error)}") + Process.sleep(30_000) + loop(manager, opts) + end + + defp loop_config(config, opts) do + log_once(run_once(config, opts)) + Process.sleep(watchdog_interval(config)) + loop_config(config, opts) + rescue + error -> + IO.puts(:stderr, "Symphony watchdog poll failed: #{Exception.message(error)}") + Process.sleep(30_000) + loop_config(config, opts) + end + + defp log_once({:ok, :healthy}), do: :ok + defp log_once({:ok, :disabled}), do: IO.puts("Symphony watchdog is disabled") + + defp log_once({:triggered, %SelfHeal.RunResult{} = result}) do + IO.puts( + "Symphony watchdog triggered self-heal status=#{result.status} reason=#{inspect(result.reason)}" + ) + end + + defp degraded_reason(service) do + case service["last_poll_error"] do + value when is_binary(value) and value != "" -> + "Symphony is degraded after a poll/reconciliation failure: #{value}" + + _ -> + "Symphony is degraded" + end + end + + defp stale_poll?(service, stale_poll_ms, now) do + last_completed = Utils.parse_datetime(service["last_poll_completed_at"]) + last_started = Utils.parse_datetime(service["last_poll_started_at"]) + startup_completed = Utils.parse_datetime(service["startup_completed_at"]) + + cond do + last_completed -> + age_ms(last_completed, now) > stale_poll_ms + + last_started -> + age_ms(last_started, now) > stale_poll_ms + + startup_completed -> + age_ms(startup_completed, now) > stale_poll_ms + + true -> + false + end + end + + defp stale_reason(service, stale_poll_ms) do + observed_at = + service["last_poll_completed_at"] || + service["last_poll_started_at"] || + service["startup_completed_at"] || + "unknown" + + "Symphony poll state is stale for more than #{stale_poll_ms} ms; last observed poll timestamp=#{observed_at}" + end + + defp age_ms(%DateTime{} = timestamp, %DateTime{} = now), + do: DateTime.diff(now, timestamp, :second) * 1000 + + defp watchdog_interval(%ServiceConfig{} = config) do + config.polling.interval_ms + |> min(config.self_healing.stale_poll_ms) + |> max(1_000) + end +end diff --git a/lib/symphony/workflow.ex b/lib/symphony/workflow.ex new file mode 100644 index 0000000..5e60a22 --- /dev/null +++ b/lib/symphony/workflow.ex @@ -0,0 +1,98 @@ +defmodule Symphony.Workflow do + @moduledoc false + + alias Symphony.Error + alias Symphony.Models.WorkflowDefinition + + def default_workflow_path(cwd \\ File.cwd!()) do + Path.join(cwd, "WORKFLOW.md") + end + + def resolve_workflow_path(path, cwd \\ File.cwd!()) + def resolve_workflow_path(nil, cwd), do: cwd |> default_workflow_path() |> Path.expand() + def resolve_workflow_path(path, _cwd), do: path |> to_string() |> Path.expand() + + def load_workflow(path \\ nil, cwd \\ File.cwd!()) do + workflow_path = resolve_workflow_path(path, cwd) + + raw = + case File.read(workflow_path) do + {:ok, body} -> + body + + {:error, reason} -> + raise Error, + code: :missing_workflow_file, + message: "workflow file cannot be read: #{workflow_path}", + cause: reason + end + + {config, body} = parse_workflow(raw) + + %WorkflowDefinition{ + config: config, + prompt_template: String.trim(body), + path: workflow_path, + mtime_ns: mtime(workflow_path) + } + end + + defp parse_workflow("---" <> _ = raw) do + lines = String.split(raw, ~r/\R/, trim: false) + + closing_index = + lines + |> Enum.drop(1) + |> Enum.find_index(&(String.trim(&1) == "---")) + + if is_nil(closing_index) do + raise Error, + code: :workflow_parse_error, + message: "YAML front matter is missing closing ---" + end + + closing_index = closing_index + 1 + front_matter = lines |> Enum.slice(1, closing_index - 1) |> Enum.join("\n") + body = lines |> Enum.slice((closing_index + 1)..-1//1) |> Enum.join("\n") + + parsed = + if String.trim(front_matter) == "" do + %{} + else + case YamlElixir.read_from_string(front_matter) do + {:ok, value} -> + value + + {:error, reason} -> + raise Error, + code: :workflow_parse_error, + message: "invalid YAML front matter: #{inspect(reason)}" + + value -> + value + end + end + + cond do + is_nil(parsed) -> + {%{}, body} + + is_map(parsed) -> + {parsed, body} + + true -> + raise Error, + code: :workflow_front_matter_not_a_map, + message: "YAML front matter must decode to a map/object" + end + end + + defp parse_workflow(raw), do: {%{}, raw} + + def mtime(path) do + case File.stat(path, time: :nanosecond) do + {:ok, stat} -> stat.mtime + _ -> nil + end + end +end diff --git a/lib/symphony/workspace.ex b/lib/symphony/workspace.ex new file mode 100644 index 0000000..e8bdbe4 --- /dev/null +++ b/lib/symphony/workspace.ex @@ -0,0 +1,670 @@ +defmodule Symphony.Workspace do + @moduledoc false + + alias Symphony.Config.{HooksConfig, RepositoryConfig, RepositoryPlanningConfig, WorkspaceConfig} + alias Symphony.Error + alias Symphony.Logging + alias Symphony.Models.{RepoPlan, RepoPlanItem, Workspace} + alias Symphony.Utils + + defmodule Manager do + defstruct root: nil, hooks: %HooksConfig{} + + def new(%WorkspaceConfig{} = workspace_config, %HooksConfig{} = hooks) do + %__MODULE__{root: Path.expand(workspace_config.root), hooks: hooks} + end + + def workspace_path_for_identifier(%__MODULE__{} = manager, identifier) do + Utils.resolve_under_root(manager.root, Utils.sanitize_workspace_key(identifier)) + end + + def create_for_issue(%__MODULE__{} = manager, identifier) do + workspace_key = Utils.sanitize_workspace_key(identifier) + workspace_path = Utils.resolve_under_root(manager.root, workspace_key) + File.mkdir_p!(manager.root) + + if File.exists?(workspace_path) and !File.dir?(workspace_path) do + raise Error, + code: :workspace_path_not_directory, + message: "workspace path exists and is not a directory: #{workspace_path}" + end + + created_now = !File.exists?(workspace_path) + if created_now, do: File.mkdir!(workspace_path) + + workspace = %Workspace{ + path: workspace_path, + workspace_key: workspace_key, + created_now: created_now + } + + if created_now and manager.hooks.after_create do + run_hook(manager, :after_create, workspace.path, fatal: true) + end + + workspace + end + + def materialize_repo_plan( + %__MODULE__{} = manager, + %Workspace{} = workspace, + %RepoPlan{} = repo_plan, + %RepositoryPlanningConfig{} = config + ) do + if !repo_plan.coding_task or is_nil(repo_plan.primary_repo) do + %{workspace | repo_plan: repo_plan} + else + workspace = + if workspace_requires_quarantine?(manager, workspace.path, repo_plan, config) do + unless config.quarantine_on_mismatch do + raise Error, + code: :workspace_repo_mismatch, + message: + "workspace does not match repo plan and quarantine_on_mismatch is false: #{workspace.path}" + end + + quarantine_workspace(workspace.path) + File.mkdir!(workspace.path) + %{workspace | created_now: true} + else + workspace + end + + repos_dir = Path.join(workspace.path, "repos") + File.mkdir_p!(repos_dir) + repository_by_slug = RepositoryPlanningConfig.repository_by_slug(config) + + repo_metadata = + repo_plan + |> RepoPlan.all_repos() + |> Enum.map(fn planned_repo -> + repo_config = repository_by_slug[planned_repo.slug] + + unless repo_config do + raise Error, + code: :unknown_planned_repository, + message: "repo plan references unknown repo: #{planned_repo.slug}" + end + + path_name = repo_path_name(planned_repo, repo_config) + repo_path = Path.join(repos_dir, path_name) + base_branch = base_branch_name(repo_config.base_branch || config.base_branch) + + expected_branch = + expected_branch_name( + repo_plan.issue_identifier, + planned_repo, + repo_config, + config.branch_prefix + ) + + checkout_metadata = + ensure_repo_checkout(manager, repo_path, repo_config, config.clone_timeout_ms, + base_branch: base_branch, + expected_branch: expected_branch + ) + + %{ + "slug" => planned_repo.slug, + "role" => planned_repo.role, + "edit_allowed" => planned_repo.edit_allowed, + "path_name" => path_name, + "path" => "repos/#{path_name}", + "remote_url" => checkout_metadata["remote_url"], + "git" => checkout_metadata + } + end) + + write_repo_metadata(workspace.path, repo_plan, repo_metadata) + + %{ + workspace + | repo_plan: repo_plan, + primary_repo_path: + repo_path(workspace.path, repo_plan.primary_repo, repository_by_slug) + } + end + end + + def repo_path(workspace_path, %RepoPlanItem{} = repo_item, repository_by_slug) do + repo_config = repository_by_slug[repo_item.slug] + Path.join([workspace_path, "repos", repo_path_name(repo_item, repo_config)]) + end + + def before_run(%__MODULE__{} = manager, workspace_path) do + if manager.hooks.before_run, do: run_hook(manager, :before_run, workspace_path, fatal: true) + end + + def after_run(%__MODULE__{} = manager, workspace_path) do + if manager.hooks.after_run do + try do + run_hook(manager, :after_run, workspace_path, fatal: false) + rescue + Error -> :ok + end + end + end + + def remove_for_identifier(%__MODULE__{} = manager, identifier) do + workspace_path = workspace_path_for_identifier(manager, identifier) + + if File.exists?(workspace_path) do + if manager.hooks.before_remove do + try do + run_hook(manager, :before_remove, workspace_path, fatal: false) + rescue + Error -> :ok + end + end + + File.rm_rf!(workspace_path) + end + + :ok + end + + defp workspace_requires_quarantine?(_manager, workspace_path, repo_plan, config) do + cond do + !File.exists?(workspace_path) -> + false + + File.dir?(Path.join(workspace_path, ".git")) -> + true + + true -> + repos_dir = Path.join(workspace_path, "repos") + ignored = MapSet.new(["repo-plan.json", ".symphony-workspace.json", "repos"]) + + existing_entries = + workspace_path + |> File.ls!() + |> Enum.reject(&MapSet.member?(ignored, &1)) + + cond do + existing_entries != [] and !File.exists?(repos_dir) -> + true + + true -> + repository_by_slug = RepositoryPlanningConfig.repository_by_slug(config) + + Enum.any?(RepoPlan.all_repos(repo_plan), fn planned_repo -> + repo_config = repository_by_slug[planned_repo.slug] + + if repo_config do + repo_path = + Path.join([ + workspace_path, + "repos", + repo_path_name(planned_repo, repo_config) + ]) + + File.exists?(repo_path) and !repo_checkout_matches?(repo_path, repo_config) + else + false + end + end) + end + end + end + + defp quarantine_workspace(workspace_path) do + if File.exists?(workspace_path) do + quarantine_root = Path.join(Path.dirname(workspace_path), "_quarantine") + File.mkdir_p!(quarantine_root) + + timestamp = + Utils.now_utc() + |> Utils.isoformat_z() + |> String.replace(":", "") + |> String.replace(".", "-") + + base = Path.join(quarantine_root, "#{Path.basename(workspace_path)}-#{timestamp}") + target = unique_path(base) + File.rename!(workspace_path, target) + end + end + + defp unique_path(path, suffix \\ 1) + defp unique_path(path, 1), do: if(File.exists?(path), do: unique_path(path, 2), else: path) + + defp unique_path(path, suffix), + do: + if(File.exists?("#{path}-#{suffix}"), + do: unique_path(path, suffix + 1), + else: "#{path}-#{suffix}" + ) + + defp ensure_repo_checkout(_manager, repo_path, repo_config, timeout_ms, opts) do + base_branch = Keyword.fetch!(opts, :base_branch) + expected_branch = Keyword.fetch!(opts, :expected_branch) + + if File.exists?(repo_path) do + unless File.dir?(repo_path), + do: + raise(Error, + code: :repo_path_not_directory, + message: "repo path exists and is not a directory: #{repo_path}" + ) + + unless repo_checkout_matches?(repo_path, repo_config) do + raise Error, + code: :repo_checkout_mismatch, + message: + "repo path exists but remote does not match #{repo_config.slug}: #{repo_path}" + end + + current_branch = + git_output( + repo_path, + ["branch", "--show-current"], + timeout_ms, + :repo_branch_read_failed, + "failed reading current branch for #{repo_config.slug}" + ) + + install_pre_push_guard(repo_path, expected_branch) + + %{ + "base_branch" => base_branch, + "base_ref" => "origin/#{base_branch}", + "base_sha" => nil, + "expected_branch" => expected_branch, + "expected_ref" => "refs/heads/#{expected_branch}", + "current_branch" => current_branch, + "branch_prepared" => false, + "pre_push_guard" => true, + "remote_url" => git_remote(repo_path) + } + else + source = repo_config.local_path || repo_config.remote_url + + unless source, + do: + raise(Error, + code: :repository_missing_source, + message: "repository has no clone source: #{repo_config.slug}" + ) + + File.mkdir_p!(Path.dirname(repo_path)) + + command = + if repo_config.local_path, + do: ["clone", "--no-hardlinks", source, repo_path], + else: ["clone", source, repo_path] + + Logging.log_event(:info, "repo_clone_started", + repo_slug: repo_config.slug, + source: source, + repo_path: repo_path + ) + + run_git( + nil, + command, + timeout_ms, + :repo_clone_failed, + "git clone failed for #{repo_config.slug}" + ) + + if repo_config.remote_url do + git_output( + repo_path, + ["remote", "set-url", "origin", repo_config.remote_url], + timeout_ms, + :repo_remote_set_failed, + "git remote set-url failed for #{repo_path}" + ) + end + + base_sha = + prepare_expected_branch( + repo_path, + repo_config.slug, + base_branch, + expected_branch, + timeout_ms + ) + + install_pre_push_guard(repo_path, expected_branch) + + Logging.log_event(:info, "repo_clone_completed", + repo_slug: repo_config.slug, + repo_path: repo_path + ) + + %{ + "base_branch" => base_branch, + "base_ref" => "origin/#{base_branch}", + "base_sha" => base_sha, + "expected_branch" => expected_branch, + "expected_ref" => "refs/heads/#{expected_branch}", + "current_branch" => expected_branch, + "branch_prepared" => true, + "pre_push_guard" => true, + "remote_url" => git_remote(repo_path) + } + end + end + + defp prepare_expected_branch(repo_path, repo_slug, base_branch, expected_branch, timeout_ms) do + git_output( + repo_path, + ["fetch", "origin", "+#{base_branch}:refs/remotes/origin/#{base_branch}"], + timeout_ms, + :repo_base_fetch_failed, + "failed fetching origin/#{base_branch} for #{repo_slug}" + ) + + base_sha = + git_output( + repo_path, + ["rev-parse", "origin/#{base_branch}"], + timeout_ms, + :repo_base_ref_failed, + "failed resolving origin/#{base_branch} for #{repo_slug}" + ) + + git_output( + repo_path, + ["checkout", "-B", expected_branch, "origin/#{base_branch}"], + timeout_ms, + :repo_branch_checkout_failed, + "failed checking out #{expected_branch} from origin/#{base_branch} for #{repo_slug}" + ) + + git_output( + repo_path, + ["config", "branch.#{expected_branch}.remote", "origin"], + timeout_ms, + :repo_branch_config_failed, + "failed configuring push remote for #{expected_branch} in #{repo_slug}" + ) + + git_output( + repo_path, + ["config", "branch.#{expected_branch}.merge", "refs/heads/#{expected_branch}"], + timeout_ms, + :repo_branch_config_failed, + "failed configuring upstream branch for #{expected_branch} in #{repo_slug}" + ) + + base_sha + end + + defp git_output(repo_path, args, timeout_ms, error_code, error_message) do + run_git(repo_path, args, timeout_ms, error_code, error_message) + end + + defp run_git(repo_path, args, timeout_ms, error_code, error_message) do + options = [stderr_to_stdout: true] + options = if repo_path, do: Keyword.put(options, :cd, repo_path), else: options + + case command_result("git", args, options, timeout_ms) do + {:ok, output, 0} -> + String.trim(output) + + {:ok, output, _status} -> + raise Error, + code: error_code, + message: "#{error_message}: #{Utils.truncate(output, 2000)}" + + :timeout -> + raise Error, + code: timeout_error_code(error_code), + message: "#{error_message}: timed out after #{timeout_ms} ms" + + {:error, reason} -> + raise Error, code: error_code, message: "#{error_message}: #{inspect(reason)}" + end + end + + defp install_pre_push_guard(repo_path, expected_branch) do + git_dir = Path.join(repo_path, ".git") + + unless File.dir?(git_dir), + do: + raise(Error, + code: :repo_git_dir_missing, + message: "repo .git directory is missing: #{repo_path}" + ) + + hooks_dir = Path.join(git_dir, "hooks") + File.mkdir_p!(hooks_dir) + expected_ref = "refs/heads/#{expected_branch}" + + script = """ + #!/bin/sh + expected_branch=#{shell_quote(expected_branch)} + expected_ref=#{shell_quote(expected_ref)} + zero_oid=0000000000000000000000000000000000000000 + + current_branch=$(git symbolic-ref --quiet --short HEAD) || { + echo "Symphony branch guard: refusing to push from detached HEAD. Expected $expected_branch." >&2 + exit 1 + } + + if [ "$current_branch" != "$expected_branch" ]; then + echo "Symphony branch guard: refusing to push from $current_branch. Expected $expected_branch." >&2 + exit 1 + fi + + while read local_ref local_oid remote_ref remote_oid + do + [ -z "$local_ref" ] && continue + if [ "$local_oid" = "$zero_oid" ]; then + echo "Symphony branch guard: refusing to delete remote refs from an agent workspace." >&2 + exit 1 + fi + if [ "$local_ref" != "$expected_ref" ] && [ "$local_ref" != "HEAD" ]; then + echo "Symphony branch guard: refusing to push $local_ref. Expected $expected_ref." >&2 + exit 1 + fi + if [ "$remote_ref" != "$expected_ref" ]; then + echo "Symphony branch guard: refusing to push to $remote_ref. Expected $expected_ref." >&2 + exit 1 + fi + done + exit 0 + """ + + hook_path = Path.join(hooks_dir, "pre-push") + File.write!(hook_path, script) + File.chmod!(hook_path, 0o755) + end + + defp repo_checkout_matches?(repo_path, %RepositoryConfig{} = repo_config) do + if File.exists?(Path.join(repo_path, ".git")) do + remote = git_remote(repo_path) + + cond do + repo_config.remote_url -> + normalize_git_remote(remote) == normalize_git_remote(repo_config.remote_url) + + repo_config.local_path -> + Path.expand(remote) == Path.expand(repo_config.local_path) + + true -> + String.contains?(normalize_git_remote(remote), String.downcase(repo_config.slug)) + end + else + false + end + rescue + _ -> false + end + + defp git_remote(repo_path) do + git_output( + repo_path, + ["config", "--get", "remote.origin.url"], + 60_000, + :git_remote_failed, + "failed reading git remote for #{repo_path}" + ) + end + + defp write_repo_metadata(workspace_path, repo_plan, repositories) do + plan_payload = RepoPlan.to_map(repo_plan) + + File.write!( + Path.join(workspace_path, "repo-plan.json"), + Jason.encode!(plan_payload, pretty: true) + ) + + metadata = %{ + "version" => 1, + "layout" => "multi_repo", + "updated_at" => Utils.isoformat_z(Utils.now_utc()), + "repo_plan" => plan_payload, + "repositories" => repositories + } + + File.write!( + Path.join(workspace_path, ".symphony-workspace.json"), + Jason.encode!(metadata, pretty: true) + ) + end + + defp repo_path_name(%RepoPlanItem{} = repo_item, %RepositoryConfig{} = repo_config) do + Utils.sanitize_workspace_key(repo_item.path_name || RepositoryConfig.path_name(repo_config)) + end + + defp expected_branch_name(issue_identifier, repo_item, repo_config, branch_prefix) do + prefix = branch_segment(branch_prefix) || "Symphony" + issue_segment = branch_segment(issue_identifier) + + repo_segment = + branch_segment(repo_item.path_name || RepositoryConfig.path_name(repo_config)) + + "#{prefix}/#{issue_segment}-#{repo_segment}" + end + + def run_hook(%__MODULE__{} = manager, hook_name, workspace_path, opts) do + script = Map.fetch!(manager.hooks, hook_name) + fatal = Keyword.fetch!(opts, :fatal) + workspace_abs = Path.expand(workspace_path) + root_abs = Path.expand(manager.root) + + unless String.starts_with?(workspace_abs, root_abs <> "/") do + raise Error, + code: :invalid_workspace_cwd, + message: "workspace path is outside workspace root: #{workspace_abs}" + end + + Logging.log_event(:info, "hook_started", hook: hook_name, workspace_path: workspace_abs) + + case command_result( + "bash", + ["-lc", script], + [cd: workspace_abs, stderr_to_stdout: true], + manager.hooks.timeout_ms + ) do + {:ok, _output, 0} -> + Logging.log_event(:info, "hook_completed", + hook: hook_name, + workspace_path: workspace_abs + ) + + :ok + + {:ok, output, status} -> + Logging.log_event(:error, "hook_failed", + hook: hook_name, + workspace_path: workspace_abs, + exit_code: status, + fatal: fatal, + output: Utils.truncate(output, 2000) + ) + + raise Error, + code: :hook_failed, + message: "hook failed with exit code #{status}: #{Utils.truncate(output, 2000)}" + + :timeout -> + Logging.log_event(:error, "hook_timed_out", + hook: hook_name, + workspace_path: workspace_abs, + fatal: fatal + ) + + raise Error, + code: :hook_timeout, + message: "hook timed out after #{manager.hooks.timeout_ms} ms" + + {:error, reason} -> + raise Error, code: :hook_failed, message: "hook failed: #{inspect(reason)}" + end + end + + defp command_result(command, args, options, timeout_ms) do + task = + Task.async(fn -> + try do + {:ok, System.cmd(command, args, options)} + rescue + error -> {:error, error} + catch + kind, reason -> {:error, {kind, reason}} + end + end) + + case Task.yield(task, max(timeout_ms, 1)) || Task.shutdown(task, :brutal_kill) do + {:ok, {:ok, {output, status}}} -> {:ok, output, status} + {:ok, {:error, reason}} -> {:error, reason} + nil -> :timeout + end + end + + defp timeout_error_code(:repo_clone_failed), do: :repo_clone_timeout + defp timeout_error_code(:repo_remote_set_failed), do: :repo_remote_set_timeout + defp timeout_error_code(error_code), do: error_code + + defp normalize_git_remote(value) do + text = value |> to_string() |> String.trim() |> String.downcase() + + text = + if String.starts_with?(text, "git@github.com:"), + do: "https://github.com/" <> String.replace_prefix(text, "git@github.com:", ""), + else: text + + text = + if String.ends_with?(text, ".git"), + do: String.slice(text, 0, String.length(text) - 4), + else: text + + String.trim_trailing(text, "/") + end + + defp base_branch_name(value) do + text = value |> to_string() |> String.trim() + text = String.replace_prefix(text, "refs/heads/", "") + text = String.replace_prefix(text, "origin/", "") + + text + |> String.split("/") + |> Enum.map(&branch_segment/1) + |> Enum.reject(&(&1 in [nil, ""])) + |> Enum.join("/") + |> then(fn + "" -> "dev" + branch -> branch + end) + end + + defp branch_segment(value) do + value + |> to_string() + |> String.trim() + |> Utils.sanitize_workspace_key() + |> String.trim("._-") + |> case do + "" -> nil + text -> text + end + end + + defp shell_quote(value) do + "'" <> String.replace(to_string(value), "'", "'\"'\"'") <> "'" + end + end +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..295ad4b --- /dev/null +++ b/mix.exs @@ -0,0 +1,39 @@ +defmodule Symphony.MixProject do + use Mix.Project + + def project do + [ + app: :caretta_symphony, + version: "0.1.0", + elixir: "~> 1.19", + start_permanent: Mix.env() == :prod, + deps: deps(), + escript: [main_module: Symphony.CLI, name: "symphony"], + test_coverage: [tool: ExCoveralls] + ] + end + + def application do + [ + extra_applications: [:logger, :inets, :ssl] + ] + end + + def cli do + [ + preferred_envs: [ + coveralls: :test, + "coveralls.detail": :test, + "coveralls.html": :test + ] + ] + end + + defp deps do + [ + {:jason, "~> 1.4"}, + {:yaml_elixir, "~> 2.11"}, + {:excoveralls, "~> 0.18", only: :test} + ] + end +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..f88456b --- /dev/null +++ b/mix.lock @@ -0,0 +1,6 @@ +%{ + "excoveralls": {:hex, :excoveralls, "0.18.5", "e229d0a65982613332ec30f07940038fe451a2e5b29bce2a5022165f0c9b157e", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "523fe8a15603f86d64852aab2abe8ddbd78e68579c8525ae765facc5eae01562"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, + "yaml_elixir": {:hex, :yaml_elixir, "2.12.1", "d74f2d82294651b58dac849c45a82aaea639766797359baff834b64439f6b3f4", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "d9ac16563c737d55f9bfeed7627489156b91268a3a21cd55c54eb2e335207fed"}, +} diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index e6e5e6e..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,37 +0,0 @@ -[build-system] -requires = ["hatchling>=1.25"] -build-backend = "hatchling.build" - -[project] -name = "caretta-symphony" -version = "0.1.0" -description = "Python implementation of OpenAI's Symphony service specification." -readme = "README.md" -requires-python = ">=3.11" -license = { text = "Apache-2.0" } -authors = [{ name = "CarettaAI" }] -classifiers = [ - "License :: OSI Approved :: Apache Software License", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.11", -] -dependencies = [ - "PyYAML>=6.0.2", - "python-liquid>=1.12.1", -] - -[project.optional-dependencies] -dev = [ - "pytest>=8.2", - "pytest-asyncio>=0.23", -] - -[project.scripts] -symphony = "symphony.cli:main" - -[tool.hatch.build.targets.wheel] -packages = ["symphony"] - -[tool.pytest.ini_options] -asyncio_mode = "auto" -testpaths = ["tests"] diff --git a/scripts/symphony-managed.sh b/scripts/symphony-managed.sh new file mode 100755 index 0000000..3679e7a --- /dev/null +++ b/scripts/symphony-managed.sh @@ -0,0 +1,69 @@ +#!/bin/sh +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P) +ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd -P) +MODE="${1:-run}" +WORKFLOW_PATH="${SYMPHONY_WORKFLOW_PATH:-$ROOT/WORKFLOW.md}" + +die() { + printf '%s\n' "$*" >&2 + exit 70 +} + +resolve_symphony() { + if [ -n "${SYMPHONY_EXECUTABLE:-}" ]; then + if [ -x "$SYMPHONY_EXECUTABLE" ]; then + printf '%s\n' "$SYMPHONY_EXECUTABLE" + return 0 + fi + + die "configured SYMPHONY_EXECUTABLE is not executable: $SYMPHONY_EXECUTABLE" + fi + + for candidate in \ + "$ROOT/.symphony-self-heal/deploy/current/symphony" \ + "$ROOT/symphony" + do + if [ -x "$candidate" ]; then + printf '%s\n' "$candidate" + return 0 + fi + done + + die "no Symphony executable found; run mix escript.build or complete a self-heal deployment" +} + +exec_symphony() { + symphony_bin=$(resolve_symphony) + exec "$symphony_bin" "$@" +} + +case "$MODE" in + -h|--help|help) + cat <<'USAGE' +Usage: + scripts/symphony-managed.sh run + scripts/symphony-managed.sh watchdog + scripts/symphony-managed.sh restart + scripts/symphony-managed.sh self-heal-once --reason "reason" +USAGE + ;; + run) + exec_symphony "$WORKFLOW_PATH" + ;; + watchdog) + exec_symphony "$WORKFLOW_PATH" --watchdog + ;; + restart) + exec_symphony "$WORKFLOW_PATH" --restart-managed + ;; + self-heal-once) + shift + exec_symphony "$WORKFLOW_PATH" --self-heal-once "$@" + ;; + *) + echo "unknown Symphony managed mode: $MODE" >&2 + exit 64 + ;; +esac diff --git a/symphony/__init__.py b/symphony/__init__.py deleted file mode 100644 index 8d7cbce..0000000 --- a/symphony/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Symphony runner reference implementation.""" - -from .models import Issue, WorkflowDefinition - -__all__ = ["Issue", "WorkflowDefinition"] diff --git a/symphony/__main__.py b/symphony/__main__.py deleted file mode 100644 index bfdcd0c..0000000 --- a/symphony/__main__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .cli import main - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/symphony/agent_runner.py b/symphony/agent_runner.py deleted file mode 100644 index 6f1522b..0000000 --- a/symphony/agent_runner.py +++ /dev/null @@ -1,345 +0,0 @@ -from __future__ import annotations - -import logging -from dataclasses import dataclass -from typing import Awaitable, Callable - -from .codex_client import CodexAppServerSession -from .config import ConfigManager -from .coding_context import augment_prompt_with_coding_context -from .coding_context import classify_coding_issue -from .errors import SymphonyError -from .logging import log_event -from .models import Issue, RepoPlan -from .repo_planner import apply_repo_plan_to_prompt, plan_repositories -from .templating import continuation_prompt, render_prompt -from .tracker import IssueTracker -from .utils import isoformat_z, normalize_state, now_utc, truncate -from .workspace import WorkspaceManager - -LOGGER = logging.getLogger(__name__) - -RunnerEventCallback = Callable[[str, dict], Awaitable[None]] -IssueEventCallback = Callable[[dict], Awaitable[None]] - - -@dataclass(slots=True) -class AgentRunResult: - issue_id: str - issue_identifier: str - normal: bool - reason: str = "normal" - retryable: bool = True - blocked: bool = False - workspace_path: str | None = None - repo_plan: RepoPlan | None = None - - -class AgentRunner: - def __init__(self, config_manager: ConfigManager, tracker: IssueTracker): - self.config_manager = config_manager - self.tracker = tracker - - async def run_issue(self, issue: Issue, attempt: int | None, on_event: RunnerEventCallback) -> AgentRunResult: - workflow, config = self.config_manager.current() - workspace_manager = WorkspaceManager(config.workspace, config.hooks) - workspace = await workspace_manager.create_for_issue(issue.identifier) - log_event( - LOGGER, - logging.INFO, - "run_attempt_started", - issue_id=issue.id, - issue_identifier=issue.identifier, - attempt=attempt, - workspace_path=workspace.path, - ) - try: - async def emit(event: dict) -> None: - await on_event(issue.id, event) - - first_prompt = render_prompt(workflow.prompt_template, issue, attempt) - classification = await classify_coding_issue( - issue, - config.context.coding, - codex_config=config.codex, - workspace_path=workspace.path, - ) - repo_plan = await plan_repositories( - issue, - config.repositories, - config.context.coding, - classification, - codex_config=config.codex, - workspace_path=workspace.path, - ) - if repo_plan is not None: - await emit( - { - "event": "repo_plan_created", - "repo_plan": repo_plan.to_dict(), - "repo_plan_needs_human": repo_plan.needs_human, - "repo_plan_human_reason": repo_plan.human_reason, - } - ) - if repo_plan.needs_human and config.repositories.block_on_needs_human: - return AgentRunResult( - issue_id=issue.id, - issue_identifier=issue.identifier, - normal=False, - reason="repo_plan_needs_human", - retryable=False, - blocked=True, - workspace_path=str(workspace.path), - repo_plan=repo_plan, - ) - workspace = await workspace_manager.materialize_repo_plan(workspace, repo_plan, config.repositories) - await emit( - { - "event": "repo_workspace_prepared", - "repo_plan": repo_plan.to_dict(), - "workspace_path": str(workspace.path), - "primary_repo_path": str(workspace.primary_repo_path) if workspace.primary_repo_path else None, - } - ) - await workspace_manager.before_run(workspace.path) - first_prompt = await augment_prompt_with_coding_context( - first_prompt, - issue, - config.context.coding, - codex_config=config.codex, - workspace_path=workspace.path, - classification=classification, - on_event=emit, - ) - first_prompt = apply_repo_plan_to_prompt(first_prompt, repo_plan, workspace.path) - max_turns = config.agent.max_turns - - async with CodexAppServerSession( - config.codex, - workspace.path, - tracker_config=config.tracker, - on_event=emit, - ) as session: - turn_number = 1 - current_issue = issue - while True: - prompt = first_prompt if turn_number == 1 else continuation_prompt(current_issue, turn_number, max_turns) - turn_result = await session.run_turn(prompt, capture_agent_text=True) - refreshed = await self.tracker.fetch_issue_states_by_ids([issue.id]) - if refreshed: - current_issue = refreshed[0] - state = normalize_state(current_issue.state) - if state in config.tracker.active_state_set: - delivered = await self._try_delivery_fallback( - current_issue, - turn_result.agent_message_text, - workspace_path=str(workspace.path), - handoff_state=config.tracker.handoff_state, - on_event=emit, - ) - if delivered: - current_issue.state = config.tracker.handoff_state - state = normalize_state(current_issue.state) - if state not in config.tracker.active_state_set: - return AgentRunResult( - issue_id=issue.id, - issue_identifier=issue.identifier, - normal=True, - reason="issue_left_active_state", - retryable=False, - workspace_path=str(workspace.path), - repo_plan=repo_plan, - ) - if turn_number >= max_turns: - return AgentRunResult( - issue_id=issue.id, - issue_identifier=issue.identifier, - normal=True, - reason="max_turns_reached", - retryable=True, - workspace_path=str(workspace.path), - repo_plan=repo_plan, - ) - turn_number += 1 - return AgentRunResult( - issue_id=issue.id, - issue_identifier=issue.identifier, - normal=True, - workspace_path=str(workspace.path), - repo_plan=repo_plan, - ) - except SymphonyError as exc: - log_event( - LOGGER, - logging.ERROR, - "run_attempt_failed", - issue_id=issue.id, - issue_identifier=issue.identifier, - reason=exc, - ) - return AgentRunResult( - issue_id=issue.id, - issue_identifier=issue.identifier, - normal=False, - reason=exc.code, - workspace_path=str(workspace.path), - ) - except Exception as exc: - log_event( - LOGGER, - logging.ERROR, - "run_attempt_failed", - issue_id=issue.id, - issue_identifier=issue.identifier, - reason=exc, - ) - return AgentRunResult( - issue_id=issue.id, - issue_identifier=issue.identifier, - normal=False, - reason="unhandled_agent_error", - workspace_path=str(workspace.path), - ) - finally: - try: - await workspace_manager.after_run(workspace.path) - except Exception as exc: - log_event( - LOGGER, - logging.WARNING, - "after_run_ignored_failure", - issue_id=issue.id, - issue_identifier=issue.identifier, - reason=exc, - ) - - async def _try_delivery_fallback( - self, - issue: Issue, - agent_message_text: str, - *, - workspace_path: str, - handoff_state: str, - on_event: IssueEventCallback, - ) -> bool: - if not _agent_reported_linear_delivery_blocker(agent_message_text): - return False - writer = self.tracker - required_methods = ("list_issue_comments", "save_issue_comment", "save_issue_state") - if not all(hasattr(writer, method) for method in required_methods): - await on_event( - { - "event": "delivery_fallback_unavailable", - "message": "Tracker does not expose first-class Linear write methods.", - }, - ) - return False - await on_event( - { - "event": "delivery_fallback_started", - "message": "Agent completed work but reported Linear delivery rejection; Symphony is attempting tracker-owned handoff.", - }, - ) - try: - comments = await writer.list_issue_comments(issue.identifier) # type: ignore[attr-defined] - comment_id = _existing_workpad_comment_id(comments) - body = _fallback_workpad_body(issue, agent_message_text, workspace_path) - await writer.save_issue_comment(issue.identifier, body, comment_id=comment_id) # type: ignore[attr-defined] - await writer.save_issue_state(issue.identifier, handoff_state) # type: ignore[attr-defined] - except Exception as exc: - await on_event( - { - "event": "delivery_fallback_failed", - "message": f"Tracker-owned Linear handoff failed: {truncate(str(exc), 500)}", - }, - ) - return False - await on_event( - { - "event": "delivery_fallback_completed", - "message": f"Updated Linear workpad and moved issue to {handoff_state}.", - }, - ) - return True - - -def _agent_reported_linear_delivery_blocker(text: str) -> bool: - normalized = text.lower() - if "linear" not in normalized: - return False - blocker = any( - phrase in normalized - for phrase in ( - "rejected", - "could not update", - "can't update", - "couldn't update", - "could not create", - "can't create", - "couldn't create", - ) - ) - completed = any( - phrase in normalized - for phrase in ( - "completed:", - "completed actions", - "validation passed", - "validation previously completed", - "pr is open", - "pr #", - "pull request", - "branch clean", - "committed and pushed", - "already committed and pushed", - ) - ) - return blocker and completed - - -def _existing_workpad_comment_id(comments: list[dict]) -> str | None: - for comment in comments: - body = comment.get("body") or comment.get("text") or comment.get("content") or "" - if isinstance(body, str) and "## Codex Workpad" in body: - comment_id = comment.get("id") - return str(comment_id) if comment_id else None - return None - - -def _fallback_workpad_body(issue: Issue, agent_message_text: str, workspace_path: str) -> str: - timestamp = isoformat_z(now_utc()) - summary = truncate(agent_message_text.strip(), 5000) - return f"""## Codex Workpad - -```text -{workspace_path} -``` - -### Plan - -- [x] Agent completed implementation work. -- [x] Agent attempted Linear workpad/state handoff. -- [x] Symphony applied tracker-owned delivery fallback after the in-agent Linear write was rejected. - -### Acceptance Criteria - -- [x] {issue.identifier} final agent handoff captured below. - -### Validation - -- [x] See final agent handoff below. - -### Notes - -- {timestamp}: Symphony fallback created this workpad because the agent reported that Linear MCP writes were rejected inside the Codex turn. - -#### Final Agent Handoff - -```text -{summary} -``` - -### Confusions - -- In-agent Linear MCP writes were rejected; Symphony used tracker-owned Linear MCP writes for delivery. -""" diff --git a/symphony/cli.py b/symphony/cli.py deleted file mode 100644 index 539e3a3..0000000 --- a/symphony/cli.py +++ /dev/null @@ -1,62 +0,0 @@ -from __future__ import annotations - -import argparse -import asyncio -import logging - -from .config import ConfigManager -from .errors import SymphonyError -from .http_server import StatusHTTPServer -from .logging import configure_logging, log_event -from .orchestrator import Orchestrator - -LOGGER = logging.getLogger(__name__) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(prog="symphony", description="Run the Symphony automation service.") - parser.add_argument("workflow_path", nargs="?", help="Path to WORKFLOW.md. Defaults to ./WORKFLOW.md.") - parser.add_argument("--port", type=int, help="Enable the local status HTTP server on this port. Overrides server.port.") - parser.add_argument("--log-level", default="INFO", help="Python logging level. Default: INFO.") - parser.add_argument("--once", action="store_true", help="Run one poll/reconcile tick and exit. Intended for smoke tests.") - return parser - - -async def async_main(argv: list[str] | None = None) -> int: - parser = build_parser() - args = parser.parse_args(argv) - configure_logging(args.log_level) - manager = ConfigManager(args.workflow_path) - try: - manager.load_startup() - except SymphonyError as exc: - log_event(LOGGER, logging.ERROR, "startup_failed", reason=exc) - return 1 - - orchestrator = Orchestrator(manager) - http_server: StatusHTTPServer | None = None - _, config = manager.current() - port = args.port if args.port is not None else config.server.port - if port is not None: - http_server = StatusHTTPServer(orchestrator, host=config.server.host, port=port) - await http_server.start() - try: - if args.once: - await orchestrator.startup_terminal_workspace_cleanup() - await orchestrator.tick() - return 0 - await orchestrator.start() - return 0 - except KeyboardInterrupt: - return 0 - except SymphonyError as exc: - log_event(LOGGER, logging.ERROR, "host_failed", reason=exc) - return 1 - finally: - await orchestrator.stop() - if http_server: - await http_server.stop() - - -def main(argv: list[str] | None = None) -> int: - return asyncio.run(async_main(argv)) diff --git a/symphony/codex_client.py b/symphony/codex_client.py deleted file mode 100644 index 9af5629..0000000 --- a/symphony/codex_client.py +++ /dev/null @@ -1,447 +0,0 @@ -from __future__ import annotations - -import asyncio -import json -import logging -from dataclasses import dataclass -from pathlib import Path -from typing import Any, Awaitable, Callable - -from .config import CodexConfig, TrackerConfig -from .errors import AgentError -from .logging import log_event -from .tracker import LinearClient -from .utils import ( - JSONL_READ_LIMIT_BYTES, - NON_INTERACTIVE_TOOL_INPUT_ANSWER, - now_utc, - tool_request_user_input_approval_answers, - tool_request_user_input_unavailable_answers, - truncate, -) - -LOGGER = logging.getLogger(__name__) - -CodexEventCallback = Callable[[dict[str, Any]], Awaitable[None]] - - -@dataclass(slots=True) -class TurnResult: - thread_id: str - turn_id: str - status: str - agent_message_text: str = "" - - -class CodexAppServerSession: - def __init__( - self, - config: CodexConfig, - workspace_path: Path, - *, - tracker_config: TrackerConfig | None, - on_event: CodexEventCallback, - ): - self.config = config - self.workspace_path = workspace_path.resolve(strict=False) - self.tracker_config = tracker_config - self.on_event = on_event - self.proc: asyncio.subprocess.Process | None = None - self._next_id = 1 - self.thread_id: str | None = None - self.stderr_tail: list[str] = [] - self._stderr_task: asyncio.Task[None] | None = None - self._agent_text_capture: list[str] | None = None - - async def __aenter__(self) -> "CodexAppServerSession": - await self.start() - return self - - async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None: - await self.stop() - - async def start(self) -> None: - if not self.workspace_path.is_dir(): - raise AgentError("invalid_workspace_cwd", f"workspace cwd does not exist: {self.workspace_path}") - self.proc = await asyncio.create_subprocess_exec( - "bash", - "-lc", - self.config.command, - cwd=self.workspace_path, - stdin=asyncio.subprocess.PIPE, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - limit=JSONL_READ_LIMIT_BYTES, - ) - self._stderr_task = asyncio.create_task(self._read_stderr()) - await self._emit({"event": "app_server_started", "codex_app_server_pid": str(self.proc.pid)}) - try: - await self._request( - "initialize", - { - "clientInfo": {"name": "symphony_runner", "title": "Symphony Runner", "version": "0.1.0"}, - "capabilities": {"experimentalApi": True}, - }, - timeout_ms=self.config.read_timeout_ms, - ) - await self._notify("initialized", {}) - thread_params: dict[str, Any] = { - "cwd": str(self.workspace_path), - "approvalPolicy": self.config.approval_policy, - "sandbox": self.config.thread_sandbox, - "serviceName": "symphony_runner", - "sessionStartSource": "startup", - } - if self.config.model: - thread_params["model"] = self.config.model - if self.config.personality: - thread_params["personality"] = self.config.personality - response = await self._request("thread/start", thread_params, timeout_ms=self.config.read_timeout_ms) - except FileNotFoundError as exc: - await self.stop() - raise AgentError("codex_not_found", "codex app-server command could not be launched", cause=exc) from exc - except AgentError: - await self.stop() - raise - except Exception as exc: - await self.stop() - raise AgentError("response_error", f"app-server startup failed: {exc}", cause=exc) from exc - thread = (response.get("thread") or {}) if isinstance(response, dict) else {} - thread_id = thread.get("id") - if not thread_id: - raise AgentError("response_error", "thread/start response did not include thread.id") - self.thread_id = str(thread_id) - - async def stop(self) -> None: - proc = self.proc - if proc is None: - return - if proc.returncode is None: - proc.terminate() - try: - await asyncio.wait_for(proc.wait(), timeout=5) - except TimeoutError: - proc.kill() - await proc.wait() - if self._stderr_task: - self._stderr_task.cancel() - try: - await self._stderr_task - except asyncio.CancelledError: - pass - await self._emit({"event": "app_server_stopped", "codex_app_server_pid": str(proc.pid), "returncode": proc.returncode}) - self.proc = None - - async def run_turn(self, prompt: str, *, capture_agent_text: bool = False) -> TurnResult: - if not self.thread_id: - raise AgentError("response_error", "thread has not been started") - params: dict[str, Any] = { - "threadId": self.thread_id, - "input": [{"type": "text", "text": prompt}], - "cwd": str(self.workspace_path), - "approvalPolicy": self.config.approval_policy, - "sandboxPolicy": self._turn_sandbox_policy(), - } - if self.config.model: - params["model"] = self.config.model - if self.config.effort: - params["effort"] = self.config.effort - if self.config.summary: - params["summary"] = self.config.summary - if self.config.personality: - params["personality"] = self.config.personality - response = await self._request("turn/start", params, timeout_ms=self.config.read_timeout_ms) - turn = (response.get("turn") or {}) if isinstance(response, dict) else {} - turn_id = turn.get("id") - if not turn_id: - raise AgentError("response_error", "turn/start response did not include turn.id") - session_id = f"{self.thread_id}-{turn_id}" - await self._emit( - { - "event": "session_started", - "thread_id": self.thread_id, - "turn_id": turn_id, - "session_id": session_id, - "codex_app_server_pid": str(self.proc.pid) if self.proc else None, - } - ) - previous_capture = self._agent_text_capture - self._agent_text_capture = [] if capture_agent_text else None - try: - status = await self._wait_for_turn(str(turn_id)) - agent_message_text = "".join(self._agent_text_capture or []) - finally: - self._agent_text_capture = previous_capture - return TurnResult(thread_id=self.thread_id, turn_id=str(turn_id), status=status, agent_message_text=agent_message_text) - - def _turn_sandbox_policy(self) -> Any: - if self.config.turn_sandbox_policy is not None: - return self.config.turn_sandbox_policy - return {"type": "workspaceWrite", "writableRoots": [str(self.workspace_path)], "networkAccess": True} - - async def _request(self, method: str, params: dict[str, Any] | None = None, *, timeout_ms: int) -> Any: - request_id = self._next_id - self._next_id += 1 - await self._send({"method": method, "id": request_id, "params": params or {}}) - while True: - msg = await self._read_message(timeout_ms=timeout_ms) - if "id" in msg and msg.get("id") == request_id and "method" not in msg: - if "error" in msg: - raise AgentError("response_error", json.dumps(msg["error"], sort_keys=True)) - return msg.get("result", {}) - if "method" in msg and "id" in msg: - fatal = await self._handle_server_request(msg) - if fatal: - raise fatal - elif "method" in msg: - await self._handle_notification(msg) - - async def _notify(self, method: str, params: dict[str, Any]) -> None: - await self._send({"method": method, "params": params}) - - async def _send(self, message: dict[str, Any]) -> None: - if self.proc is None or self.proc.stdin is None: - raise AgentError("port_exit", "app-server stdin is closed") - self.proc.stdin.write(json.dumps(message, separators=(",", ":")).encode("utf-8") + b"\n") - await self.proc.stdin.drain() - - async def _read_message(self, *, timeout_ms: int) -> dict[str, Any]: - if self.proc is None or self.proc.stdout is None: - raise AgentError("port_exit", "app-server stdout is closed") - try: - line = await asyncio.wait_for(self.proc.stdout.readline(), timeout=timeout_ms / 1000) - except TimeoutError as exc: - raise AgentError("response_timeout", f"timed out waiting for app-server response after {timeout_ms} ms", cause=exc) from exc - except ValueError as exc: - raise AgentError("response_error", f"app-server JSONL message exceeded reader limit: {exc}", cause=exc) from exc - if not line: - stderr = "\n".join(self.stderr_tail[-10:]) - raise AgentError("port_exit", f"app-server exited before response: {truncate(stderr, 1000)}") - if len(line) > 10 * 1024 * 1024: - raise AgentError("response_error", "app-server JSONL message exceeded 10 MB") - try: - msg = json.loads(line.decode("utf-8")) - except json.JSONDecodeError as exc: - await self._emit({"event": "malformed", "message": truncate(line.decode(errors="replace"), 1000)}) - raise AgentError("response_error", "malformed app-server JSON", cause=exc) from exc - if not isinstance(msg, dict): - raise AgentError("response_error", "app-server message is not an object") - return msg - - async def _wait_for_turn(self, turn_id: str) -> str: - deadline = asyncio.get_running_loop().time() + self.config.turn_timeout_ms / 1000 - while True: - remaining = deadline - asyncio.get_running_loop().time() - if remaining <= 0: - raise AgentError("turn_timeout", f"turn timed out after {self.config.turn_timeout_ms} ms") - msg = await self._read_message(timeout_ms=max(1, int(remaining * 1000))) - if "method" in msg and "id" in msg: - fatal = await self._handle_server_request(msg) - if fatal: - raise fatal - continue - if "method" not in msg: - continue - await self._handle_notification(msg) - if msg.get("method") != "turn/completed": - continue - params = msg.get("params") or {} - turn = params.get("turn") or {} - completed_id = turn.get("id") - if completed_id and str(completed_id) != turn_id: - continue - status = str(turn.get("status") or "") - if status == "completed": - return status - if status == "interrupted": - raise AgentError("turn_cancelled", "turn was interrupted") - error = turn.get("error") or params.get("error") - raise AgentError("turn_failed", truncate(json.dumps(error, sort_keys=True, default=str), 1000) if error else "turn failed") - - async def _handle_notification(self, msg: dict[str, Any]) -> None: - method = str(msg.get("method")) - params = msg.get("params") if isinstance(msg.get("params"), dict) else {} - event: dict[str, Any] = { - "event": self._event_name(method), - "method": method, - "payload": params, - "codex_app_server_pid": str(self.proc.pid) if self.proc else None, - } - thread_id = params.get("threadId") or params.get("thread_id") - turn = params.get("turn") if isinstance(params.get("turn"), dict) else {} - turn_id = params.get("turnId") or params.get("turn_id") or turn.get("id") - if thread_id: - event["thread_id"] = thread_id - if turn_id: - event["turn_id"] = turn_id - if thread_id and turn_id: - event["session_id"] = f"{thread_id}-{turn_id}" - if method == "thread/tokenUsage/updated": - token_usage = params.get("tokenUsage") or {} - total = token_usage.get("total") if isinstance(token_usage, dict) else {} - if isinstance(total, dict): - event["usage_absolute"] = { - "input_tokens": total.get("inputTokens"), - "output_tokens": total.get("outputTokens"), - "total_tokens": total.get("totalTokens"), - } - if method in {"account/rateLimits/updated", "account/rateLimitsUpdated"}: - event["rate_limits"] = params - agent_text = self._agent_text_from_notification(method, params) - if agent_text and self._agent_text_capture is not None: - self._agent_text_capture.append(agent_text) - event["message"] = self._summarize(method, params) - await self._emit(event) - - async def _handle_server_request(self, msg: dict[str, Any]) -> AgentError | None: - request_id = msg.get("id") - method = str(msg.get("method")) - params = msg.get("params") if isinstance(msg.get("params"), dict) else {} - if method == "item/commandExecution/requestApproval": - await self._send({"id": request_id, "result": {"decision": "acceptForSession"}}) - await self._emit({"event": "approval_auto_approved", "method": method, "payload": params}) - return None - if method == "item/fileChange/requestApproval": - await self._send({"id": request_id, "result": {"decision": "acceptForSession"}}) - await self._emit({"event": "approval_auto_approved", "method": method, "payload": params}) - return None - if method == "item/tool/requestUserInput": - if await self._auto_answer_tool_user_input(request_id, method, params): - return None - if request_id is not None: - await self._send({"id": request_id, "result": {"answers": {}}}) - await self._emit({"event": "turn_input_required", "method": method, "payload": params}) - return AgentError("turn_input_required", "app-server requested user input") - if method == "item/tool/call": - result = await self._handle_dynamic_tool(params) - await self._send({"id": request_id, "result": result}) - return None - await self._send({"id": request_id, "error": {"code": -32601, "message": f"unsupported server request: {method}"}}) - await self._emit({"event": "unsupported_tool_call", "method": method, "payload": params}) - return None - - async def _handle_dynamic_tool(self, params: dict[str, Any]) -> dict[str, Any]: - tool = params.get("tool") or params.get("name") - if tool != "linear_graphql": - return self._tool_text(False, {"error": {"code": "unsupported_tool", "message": f"unsupported tool: {tool}"}}) - if self.tracker_config is None or self.tracker_config.kind != "linear" or not self.tracker_config.api_key: - return self._tool_text(False, {"error": {"code": "missing_auth", "message": "Linear auth is not configured"}}) - arguments = params.get("arguments") - if isinstance(arguments, str): - query = arguments - variables: dict[str, Any] = {} - elif isinstance(arguments, dict): - query = arguments.get("query") - variables = arguments.get("variables") or {} - else: - return self._tool_text(False, {"error": {"code": "invalid_input", "message": "arguments must be an object or query string"}}) - if not isinstance(query, str) or not query.strip(): - return self._tool_text(False, {"error": {"code": "invalid_input", "message": "query must be a non-empty string"}}) - if not isinstance(variables, dict): - return self._tool_text(False, {"error": {"code": "invalid_input", "message": "variables must be an object"}}) - if _looks_like_multiple_graphql_operations(query): - return self._tool_text(False, {"error": {"code": "invalid_input", "message": "query must contain exactly one operation"}}) - try: - body = await LinearClient(self.tracker_config).execute_graphql_once(query, variables) - except Exception as exc: - return self._tool_text(False, {"error": {"code": "linear_graphql", "message": str(exc)}}) - return self._tool_text(True, body) - - def _tool_text(self, success: bool, payload: dict[str, Any]) -> dict[str, Any]: - output = json.dumps(payload, sort_keys=True, default=str) - return {"success": success, "output": output, "contentItems": [{"type": "inputText", "text": output}]} - - async def _auto_answer_tool_user_input(self, request_id: Any, method: str, params: dict[str, Any]) -> bool: - if request_id is None: - return False - answers = tool_request_user_input_approval_answers(params) - if answers: - await self._send({"id": request_id, "result": {"answers": answers}}) - await self._emit( - { - "event": "approval_auto_approved", - "method": method, - "payload": params, - "decision": "Approve this Session", - } - ) - return True - answers = tool_request_user_input_unavailable_answers(params) - if answers: - await self._send({"id": request_id, "result": {"answers": answers}}) - await self._emit( - { - "event": "tool_input_auto_answered", - "method": method, - "payload": params, - "answer": NON_INTERACTIVE_TOOL_INPUT_ANSWER, - } - ) - return True - return False - - async def _emit(self, event: dict[str, Any]) -> None: - event.setdefault("timestamp", now_utc()) - await self.on_event(event) - - async def _read_stderr(self) -> None: - if self.proc is None or self.proc.stderr is None: - return - while True: - line = await self.proc.stderr.readline() - if not line: - return - text = line.decode(errors="replace").rstrip() - self.stderr_tail.append(text) - self.stderr_tail = self.stderr_tail[-50:] - log_event(LOGGER, logging.DEBUG, "app_server_stderr", message=truncate(text, 1000)) - - def _event_name(self, method: str) -> str: - if method == "turn/completed": - return "turn_completed" - if method == "turn/started": - return "turn_started" - if method == "item/tool/requestUserInput": - return "turn_input_required" - return method.replace("/", "_") - - def _summarize(self, method: str, params: dict[str, Any]) -> str: - if method == "item/agentMessage/delta": - return truncate(str(params.get("delta") or params.get("text") or ""), 500) - item = params.get("item") - if isinstance(item, dict): - if item.get("type") == "agentMessage": - return truncate(str(item.get("text") or ""), 500) - if item.get("type") == "commandExecution": - command = item.get("command") - return truncate(f"command={command} status={item.get('status')}", 500) - return truncate(f"item_type={item.get('type')} status={item.get('status')}", 500) - if method == "turn/completed": - turn = params.get("turn") if isinstance(params.get("turn"), dict) else {} - return f"status={turn.get('status')}" - return truncate(json.dumps(params, sort_keys=True, default=str), 500) - - def _agent_text_from_notification(self, method: str, params: dict[str, Any]) -> str: - if method == "item/agentMessage/delta": - return str(params.get("delta") or params.get("text") or "") - item = params.get("item") if isinstance(params.get("item"), dict) else None - if item and item.get("type") == "agentMessage" and not self._agent_text_capture: - return str(item.get("text") or "") - if method == "turn/completed" and not self._agent_text_capture: - turn = params.get("turn") if isinstance(params.get("turn"), dict) else {} - items = turn.get("items") if isinstance(turn.get("items"), list) else [] - parts = [ - str(item.get("text") or "") - for item in items - if isinstance(item, dict) and item.get("type") == "agentMessage" and item.get("text") - ] - return "\n".join(parts) - return "" - - -def _looks_like_multiple_graphql_operations(query: str) -> bool: - cleaned = " ".join(line.split("#", 1)[0] for line in query.splitlines()) - operation_words = 0 - for word in ("query", "mutation", "subscription"): - operation_words += len(cleaned.split(word)) - 1 - return operation_words > 1 diff --git a/symphony/coding_context.py b/symphony/coding_context.py deleted file mode 100644 index 05f2bca..0000000 --- a/symphony/coding_context.py +++ /dev/null @@ -1,220 +0,0 @@ -from __future__ import annotations - -import json -from dataclasses import dataclass -from dataclasses import replace -from pathlib import Path -from typing import Any, Awaitable, Callable - -from .codex_client import CodexAppServerSession -from .config import CodexConfig, CodingContextConfig -from .models import Issue -from .utils import truncate - -ClassifierEventCallback = Callable[[dict[str, Any]], Awaitable[None]] - - -@dataclass(frozen=True, slots=True) -class CodingClassification: - is_coding_task: bool - source: str - confidence: float | None = None - reason: str | None = None - - -def is_coding_issue(issue: Issue, config: CodingContextConfig) -> bool: - return _rules_classify(issue, config) - - -async def classify_coding_issue( - issue: Issue, - config: CodingContextConfig, - *, - codex_config: CodexConfig | None = None, - workspace_path: Path | None = None, -) -> CodingClassification: - if not config.enabled: - return CodingClassification(False, source="disabled") - if config.classifier == "always": - return CodingClassification(True, source="always", confidence=1.0, reason="Configured to always inject coding context.") - if config.classifier == "rules": - return CodingClassification(_rules_classify(issue, config), source="rules") - if codex_config is None or workspace_path is None: - return _fallback_classification(issue, config, "LLM classifier unavailable: missing codex config or workspace.") - try: - return await _classify_with_llm(issue, config, codex_config, workspace_path) - except Exception as exc: - return _fallback_classification(issue, config, f"LLM classifier failed: {exc}") - - -async def augment_prompt_with_coding_context( - prompt: str, - issue: Issue, - config: CodingContextConfig, - *, - codex_config: CodexConfig | None = None, - workspace_path: Path | None = None, - classification: CodingClassification | None = None, - on_event: ClassifierEventCallback | None = None, -) -> str: - if classification is None: - classification = await classify_coding_issue(issue, config, codex_config=codex_config, workspace_path=workspace_path) - if on_event is not None: - await on_event( - { - "event": "coding_context_classified", - "coding_context_injected": classification.is_coding_task, - "classification_source": classification.source, - "classification_confidence": classification.confidence, - "classification_reason": classification.reason, - } - ) - if not classification.is_coding_task: - return prompt - context = load_coding_context(config) - if not context.strip(): - return prompt - return ( - "\n" - "This Linear issue is classified as a coding task. Read this context before choosing a repo or editing files. " - "If this context conflicts with older assumptions, this context and the current Linear issue text win.\n\n" - f"Classification source: {classification.source}\n" - f"Classification reason: {classification.reason or '(none)'}\n\n" - f"{context}\n" - "\n\n" - f"{prompt}" - ) - - -def _rules_classify(issue: Issue, config: CodingContextConfig) -> bool: - issue_labels = {label.lower() for label in issue.labels} - if config.label_trigger_set and config.label_trigger_set.intersection(issue_labels): - return True - haystack = f"{issue.title}\n{issue.description or ''}".lower() - return any(keyword.lower() in haystack for keyword in config.keyword_triggers) - - -def _fallback_classification(issue: Issue, config: CodingContextConfig, reason: str) -> CodingClassification: - if config.classification_fallback == "inject": - return CodingClassification(True, source="fallback:inject", confidence=0.0, reason=reason) - if config.classification_fallback == "skip": - return CodingClassification(False, source="fallback:skip", confidence=0.0, reason=reason) - return CodingClassification(_rules_classify(issue, config), source="fallback:rules", confidence=0.0, reason=reason) - - -async def _classify_with_llm( - issue: Issue, - config: CodingContextConfig, - codex_config: CodexConfig, - workspace_path: Path, -) -> CodingClassification: - classifier_codex_config = replace( - codex_config, - model=config.classifier_model or codex_config.model, - effort=config.classifier_effort, - turn_timeout_ms=config.classification_timeout_ms, - summary=None, - personality=None, - ) - - async def ignore_classifier_event(_: dict[str, Any]) -> None: - return None - - async with CodexAppServerSession( - classifier_codex_config, - workspace_path, - tracker_config=None, - on_event=ignore_classifier_event, - ) as session: - result = await session.run_turn(_classification_prompt(issue), capture_agent_text=True) - data = _parse_classifier_json(result.agent_message_text) - needed = data.get("coding_context_needed", data.get("is_coding_task")) - if not isinstance(needed, bool): - raise ValueError("classifier JSON missing boolean coding_context_needed") - confidence = data.get("confidence") - if isinstance(confidence, (int, float)) and not isinstance(confidence, bool): - confidence_value = max(0.0, min(1.0, float(confidence))) - else: - confidence_value = None - reason = data.get("reason") - return CodingClassification( - needed, - source="llm", - confidence=confidence_value, - reason=truncate(str(reason), 500) if reason is not None else None, - ) - - -def _classification_prompt(issue: Issue) -> str: - issue_json = json.dumps(issue.to_template_data(), sort_keys=True, default=str) - return ( - "You are a classifier for Symphony, a background coding agent runner.\n" - "Decide whether the current Linear issue is a coding/repository task that should receive architecture and repo-map context before the agent edits files.\n\n" - "Return only a single JSON object, no markdown, no prose, no tool calls.\n" - "Schema:\n" - "{\n" - ' "coding_context_needed": boolean,\n' - ' "confidence": number,\n' - ' "reason": "short reason"\n' - "}\n\n" - "Use true for tasks that likely require code, config, scripts, repository changes, debugging, tests, provider integration, product implementation, or repo selection.\n" - "Use false for pure Linear/project-management actions, status checks, tagging, prioritization, or discussion with no likely repo changes.\n" - "If ambiguous, choose true. False negatives are more harmful than extra context.\n\n" - f"Linear issue JSON:\n{issue_json}" - ) - - -def _parse_classifier_json(text: str) -> dict[str, Any]: - stripped = text.strip() - if not stripped: - raise ValueError("classifier returned empty text") - try: - value = json.loads(stripped) - except json.JSONDecodeError: - value = json.loads(_extract_json_object(stripped)) - if not isinstance(value, dict): - raise ValueError("classifier JSON is not an object") - return value - - -def _extract_json_object(text: str) -> str: - start = text.find("{") - end = text.rfind("}") - if start == -1 or end == -1 or end <= start: - raise ValueError("classifier output did not contain a JSON object") - return text[start : end + 1] - - -def load_coding_context(config: CodingContextConfig) -> str: - chunks: list[str] = [] - remaining = config.max_chars - for path in config.skill_paths: - for file_path in _context_files(path): - if remaining <= 0: - break - try: - text = file_path.read_text(encoding="utf-8") - except OSError: - continue - header = f"## {file_path}\n" - chunk = header + text.strip() + "\n" - if len(chunk) > remaining: - chunk = chunk[:remaining].rstrip() + "\n[truncated]\n" - chunks.append(chunk) - remaining -= len(chunk) - return "\n".join(chunks).strip() - - -def _context_files(path: Path) -> list[Path]: - if path.is_file(): - return [path] - if not path.is_dir(): - return [] - files: list[Path] = [] - skill_file = path / "SKILL.md" - if skill_file.is_file(): - files.append(skill_file) - references = path / "references" - if references.is_dir(): - files.extend(sorted(references.glob("*.md"))) - return files diff --git a/symphony/config.py b/symphony/config.py deleted file mode 100644 index 880ee4b..0000000 --- a/symphony/config.py +++ /dev/null @@ -1,609 +0,0 @@ -from __future__ import annotations - -import logging -import os -import tempfile -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any, Mapping - -from .errors import ConfigError, WorkflowError -from .logging import log_event -from .models import WorkflowDefinition -from .utils import normalize_state -from .workflow import load_workflow, resolve_workflow_path - -LOGGER = logging.getLogger(__name__) - - -@dataclass(frozen=True, slots=True) -class TrackerConfig: - kind: str | None = None - endpoint: str | None = None - api_key: str | None = None - project_slug: str | None = None - team: str | None = None - mcp_command: str = "codex app-server" - mcp_server: str = "codex_apps" - active_states: list[str] = field(default_factory=lambda: ["Todo", "In Progress"]) - terminal_states: list[str] = field(default_factory=lambda: ["Closed", "Cancelled", "Canceled", "Duplicate", "Done"]) - review_states: list[str] = field(default_factory=lambda: ["In Review", "Merging"]) - required_labels: list[str] = field(default_factory=list) - handoff_state: str = "In Review" - done_state: str = "Done" - merge_base_branch: str = "dev" - - @property - def active_state_set(self) -> set[str]: - return {normalize_state(state) for state in self.active_states} - - @property - def terminal_state_set(self) -> set[str]: - return {normalize_state(state) for state in self.terminal_states} - - @property - def review_state_set(self) -> set[str]: - return {normalize_state(state) for state in self.review_states} - - @property - def required_label_set(self) -> set[str]: - return {str(label).strip().lower() for label in self.required_labels if str(label).strip()} - - -@dataclass(frozen=True, slots=True) -class PollingConfig: - interval_ms: int = 30000 - - -@dataclass(frozen=True, slots=True) -class WorkspaceConfig: - root: Path - - -@dataclass(frozen=True, slots=True) -class HooksConfig: - after_create: str | None = None - before_run: str | None = None - after_run: str | None = None - before_remove: str | None = None - timeout_ms: int = 60000 - - -@dataclass(frozen=True, slots=True) -class AgentConfig: - max_concurrent_agents: int = 10 - max_turns: int = 20 - max_retry_backoff_ms: int = 300000 - max_concurrent_agents_by_state: dict[str, int] = field(default_factory=dict) - - -@dataclass(frozen=True, slots=True) -class CodexConfig: - command: str = "codex app-server" - approval_policy: Any = "never" - thread_sandbox: Any = "workspace-write" - turn_sandbox_policy: Any = None - turn_timeout_ms: int = 3600000 - read_timeout_ms: int = 5000 - stall_timeout_ms: int = 300000 - model: str | None = None - effort: str | None = None - summary: str | None = None - personality: str | None = None - - -@dataclass(frozen=True, slots=True) -class ServerConfig: - port: int | None = None - host: str = "127.0.0.1" - - -@dataclass(frozen=True, slots=True) -class CodingContextConfig: - enabled: bool = False - classifier: str = "rules" - classification_fallback: str = "inject" - classifier_model: str | None = None - classifier_effort: str | None = "low" - classification_timeout_ms: int = 120000 - skill_paths: list[Path] = field(default_factory=list) - label_triggers: list[str] = field(default_factory=list) - keyword_triggers: list[str] = field(default_factory=list) - max_chars: int = 40000 - - @property - def label_trigger_set(self) -> set[str]: - return {label.strip().lower() for label in self.label_triggers if label.strip()} - - -@dataclass(frozen=True, slots=True) -class ContextConfig: - coding: CodingContextConfig = field(default_factory=CodingContextConfig) - - -@dataclass(frozen=True, slots=True) -class DashboardConfig: - summaries_enabled: bool = False - summary_update_interval_ms: int = 45000 - summary_timeout_ms: int = 120000 - summary_max_events: int = 60 - summary_max_chars: int = 14000 - summary_model: str | None = None - summary_effort: str | None = "low" - - -@dataclass(frozen=True, slots=True) -class RepositoryConfig: - slug: str - local_path: Path | None = None - remote_url: str | None = None - aliases: list[str] = field(default_factory=list) - description: str | None = None - base_branch: str | None = None - - @property - def path_name(self) -> str: - return self.slug.rsplit("/", 1)[-1] if "/" in self.slug else self.slug - - def to_prompt_data(self) -> dict[str, Any]: - return { - "slug": self.slug, - "local_path": str(self.local_path) if self.local_path else None, - "remote_url": self.remote_url, - "aliases": list(self.aliases), - "description": self.description, - "base_branch": self.base_branch, - } - - -@dataclass(frozen=True, slots=True) -class RepositoryPlanningConfig: - enabled: bool = False - planner: str = "rules" - plan_model: str | None = None - plan_effort: str | None = "low" - plan_timeout_ms: int = 120000 - fallback: str = "rules" - block_on_needs_human: bool = True - quarantine_on_mismatch: bool = True - clone_timeout_ms: int = 300000 - base_branch: str = "dev" - branch_prefix: str = "Symphony" - repositories: list[RepositoryConfig] = field(default_factory=list) - - @property - def repository_by_slug(self) -> dict[str, RepositoryConfig]: - return {repo.slug: repo for repo in self.repositories} - - -@dataclass(frozen=True, slots=True) -class ServiceConfig: - workflow_path: Path - tracker: TrackerConfig - polling: PollingConfig - workspace: WorkspaceConfig - hooks: HooksConfig - agent: AgentConfig - codex: CodexConfig - server: ServerConfig = field(default_factory=ServerConfig) - context: ContextConfig = field(default_factory=ContextConfig) - dashboard: DashboardConfig = field(default_factory=DashboardConfig) - repositories: RepositoryPlanningConfig = field(default_factory=RepositoryPlanningConfig) - - -def _section(raw: Mapping[str, Any], key: str) -> Mapping[str, Any]: - value = raw.get(key) or {} - if not isinstance(value, Mapping): - raise ConfigError("config_invalid_section", f"{key} must be an object") - return value - - -def _resolve_env_reference(value: Any, environ: Mapping[str, str]) -> Any: - if not isinstance(value, str): - return value - if len(value) > 1 and value.startswith("$") and value[1:].replace("_", "").isalnum(): - return environ.get(value[1:], "") or None - return value - - -def _resolve_path(value: Any, *, default: Path, workflow_dir: Path, environ: Mapping[str, str]) -> Path: - resolved = _resolve_env_reference(value, environ) if value is not None else default - path = Path(str(resolved)).expanduser() - if not path.is_absolute(): - path = workflow_dir / path - return path.resolve(strict=False) - - -def _string_list(value: Any, default: list[str], field_name: str) -> list[str]: - if value is None: - return list(default) - if not isinstance(value, list) or not all(isinstance(item, str) for item in value): - raise ConfigError("config_invalid_value", f"{field_name} must be a list of strings") - return list(value) - - -def _int_value(value: Any, default: int, field_name: str, *, positive: bool = False, minimum: int | None = None) -> int: - if value is None: - result = default - elif isinstance(value, bool): - raise ConfigError("config_invalid_value", f"{field_name} must be an integer") - else: - try: - result = int(value) - except (TypeError, ValueError) as exc: - raise ConfigError("config_invalid_value", f"{field_name} must be an integer", cause=exc) from exc - if positive and result <= 0: - raise ConfigError("config_invalid_value", f"{field_name} must be positive") - if minimum is not None and result < minimum: - raise ConfigError("config_invalid_value", f"{field_name} must be >= {minimum}") - return result - - -def _bool_value(value: Any, default: bool, field_name: str) -> bool: - if value is None: - return default - if isinstance(value, bool): - return value - raise ConfigError("config_invalid_value", f"{field_name} must be a boolean") - - -def _state_limits(value: Any) -> dict[str, int]: - if not isinstance(value, Mapping): - return {} - limits: dict[str, int] = {} - for key, raw_limit in value.items(): - try: - limit = int(raw_limit) - except (TypeError, ValueError): - continue - if limit > 0: - limits[normalize_state(str(key))] = limit - return limits - - -def _path_list(value: Any, *, workflow_dir: Path, environ: Mapping[str, str], field_name: str) -> list[Path]: - return [ - _resolve_path(item, default=workflow_dir, workflow_dir=workflow_dir, environ=environ) - for item in _string_list(value, [], field_name) - ] - - -def _repository_list(value: Any, *, workflow_dir: Path, environ: Mapping[str, str]) -> list[RepositoryConfig]: - if value is None: - return [] - if not isinstance(value, list): - raise ConfigError("config_invalid_value", "repositories.known must be a list of objects") - repositories: list[RepositoryConfig] = [] - for index, item in enumerate(value): - if not isinstance(item, Mapping): - raise ConfigError("config_invalid_value", f"repositories.known[{index}] must be an object") - slug = item.get("slug") - if not isinstance(slug, str) or not slug.strip(): - raise ConfigError("config_invalid_value", f"repositories.known[{index}].slug must be a non-empty string") - local_path = None - if item.get("local_path") is not None: - local_path = _resolve_path( - item.get("local_path"), - default=workflow_dir, - workflow_dir=workflow_dir, - environ=environ, - ) - remote_url = item.get("remote_url") - repositories.append( - RepositoryConfig( - slug=slug.strip(), - local_path=local_path, - remote_url=str(remote_url).strip() if remote_url is not None and str(remote_url).strip() else None, - aliases=_string_list(item.get("aliases"), [], f"repositories.known[{index}].aliases"), - description=str(item["description"]).strip() if item.get("description") is not None else None, - base_branch=str(item["base_branch"]).strip() if item.get("base_branch") is not None and str(item["base_branch"]).strip() else None, - ) - ) - return repositories - - -def resolve_config(workflow: WorkflowDefinition, environ: Mapping[str, str] | None = None) -> ServiceConfig: - env = environ or os.environ - raw = workflow.config - workflow_dir = workflow.path.parent - - tracker_raw = _section(raw, "tracker") - kind = tracker_raw.get("kind") - kind = str(kind) if kind is not None else None - endpoint = tracker_raw.get("endpoint") - if endpoint is None and kind == "linear": - endpoint = "https://api.linear.app/graphql" - api_key = _resolve_env_reference(tracker_raw.get("api_key"), env) - project_slug = tracker_raw.get("project_slug") - - tracker = TrackerConfig( - kind=kind, - endpoint=str(endpoint) if endpoint is not None else None, - api_key=str(api_key) if api_key else None, - project_slug=str(project_slug) if project_slug is not None else None, - team=str(tracker_raw["team"]) if tracker_raw.get("team") is not None else None, - mcp_command=str(tracker_raw.get("mcp_command", "codex app-server")), - mcp_server=str(tracker_raw.get("mcp_server", "codex_apps")), - active_states=_string_list(tracker_raw.get("active_states"), ["Todo", "In Progress"], "tracker.active_states"), - terminal_states=_string_list( - tracker_raw.get("terminal_states"), - ["Closed", "Cancelled", "Canceled", "Duplicate", "Done"], - "tracker.terminal_states", - ), - review_states=_string_list(tracker_raw.get("review_states"), ["In Review", "Merging"], "tracker.review_states"), - required_labels=_string_list(tracker_raw.get("required_labels"), [], "tracker.required_labels"), - handoff_state=str(tracker_raw.get("handoff_state", "In Review")), - done_state=str(tracker_raw.get("done_state", "Done")), - merge_base_branch=str(tracker_raw.get("merge_base_branch", "dev")), - ) - - polling_raw = _section(raw, "polling") - polling = PollingConfig(interval_ms=_int_value(polling_raw.get("interval_ms"), 30000, "polling.interval_ms", positive=True)) - - workspace_raw = _section(raw, "workspace") - workspace = WorkspaceConfig( - root=_resolve_path( - workspace_raw.get("root"), - default=Path(tempfile.gettempdir()) / "symphony_workspaces", - workflow_dir=workflow_dir, - environ=env, - ) - ) - - hooks_raw = _section(raw, "hooks") - hooks = HooksConfig( - after_create=hooks_raw.get("after_create"), - before_run=hooks_raw.get("before_run"), - after_run=hooks_raw.get("after_run"), - before_remove=hooks_raw.get("before_remove"), - timeout_ms=_int_value(hooks_raw.get("timeout_ms"), 60000, "hooks.timeout_ms", positive=True), - ) - - agent_raw = _section(raw, "agent") - agent = AgentConfig( - max_concurrent_agents=_int_value(agent_raw.get("max_concurrent_agents"), 10, "agent.max_concurrent_agents", positive=True), - max_turns=_int_value(agent_raw.get("max_turns"), 20, "agent.max_turns", positive=True), - max_retry_backoff_ms=_int_value( - agent_raw.get("max_retry_backoff_ms"), 300000, "agent.max_retry_backoff_ms", positive=True - ), - max_concurrent_agents_by_state=_state_limits(agent_raw.get("max_concurrent_agents_by_state")), - ) - - codex_raw = _section(raw, "codex") - command = codex_raw.get("command", "codex app-server") - codex = CodexConfig( - command=str(command) if command is not None else "", - approval_policy=codex_raw.get("approval_policy", "never"), - thread_sandbox=codex_raw.get("thread_sandbox", "workspace-write"), - turn_sandbox_policy=codex_raw.get("turn_sandbox_policy"), - turn_timeout_ms=_int_value(codex_raw.get("turn_timeout_ms"), 3600000, "codex.turn_timeout_ms", positive=True), - read_timeout_ms=_int_value(codex_raw.get("read_timeout_ms"), 5000, "codex.read_timeout_ms", positive=True), - stall_timeout_ms=_int_value(codex_raw.get("stall_timeout_ms"), 300000, "codex.stall_timeout_ms"), - model=str(codex_raw["model"]) if codex_raw.get("model") is not None else None, - effort=str(codex_raw["effort"]) if codex_raw.get("effort") is not None else None, - summary=str(codex_raw["summary"]) if codex_raw.get("summary") is not None else None, - personality=str(codex_raw["personality"]) if codex_raw.get("personality") is not None else None, - ) - - server_raw = _section(raw, "server") - port = server_raw.get("port") - server = ServerConfig( - port=_int_value(port, 0, "server.port", minimum=0) if port is not None else None, - host=str(server_raw.get("host", "127.0.0.1")), - ) - - context_raw = _section(raw, "context") - coding_raw = _section(context_raw, "coding") - context = ContextConfig( - coding=CodingContextConfig( - enabled=_bool_value(coding_raw.get("enabled"), False, "context.coding.enabled"), - classifier=str(coding_raw.get("classifier", "rules")).strip().lower(), - classification_fallback=str(coding_raw.get("classification_fallback", "inject")).strip().lower(), - classifier_model=str(coding_raw["classifier_model"]) if coding_raw.get("classifier_model") is not None else None, - classifier_effort=str(coding_raw["classifier_effort"]) if coding_raw.get("classifier_effort") is not None else "low", - classification_timeout_ms=_int_value( - coding_raw.get("classification_timeout_ms"), - 120000, - "context.coding.classification_timeout_ms", - positive=True, - ), - skill_paths=_path_list( - coding_raw.get("skill_paths"), - workflow_dir=workflow_dir, - environ=env, - field_name="context.coding.skill_paths", - ), - label_triggers=_string_list(coding_raw.get("label_triggers"), [], "context.coding.label_triggers"), - keyword_triggers=_string_list(coding_raw.get("keyword_triggers"), [], "context.coding.keyword_triggers"), - max_chars=_int_value(coding_raw.get("max_chars"), 40000, "context.coding.max_chars", positive=True), - ) - ) - - dashboard_raw = _section(raw, "dashboard") - summaries_raw = _section(dashboard_raw, "summaries") - dashboard = DashboardConfig( - summaries_enabled=_bool_value(summaries_raw.get("enabled"), False, "dashboard.summaries.enabled"), - summary_update_interval_ms=_int_value( - summaries_raw.get("update_interval_ms"), - 45000, - "dashboard.summaries.update_interval_ms", - positive=True, - ), - summary_timeout_ms=_int_value( - summaries_raw.get("timeout_ms"), - 120000, - "dashboard.summaries.timeout_ms", - positive=True, - ), - summary_max_events=_int_value( - summaries_raw.get("max_events"), - 60, - "dashboard.summaries.max_events", - positive=True, - ), - summary_max_chars=_int_value( - summaries_raw.get("max_chars"), - 14000, - "dashboard.summaries.max_chars", - positive=True, - ), - summary_model=str(summaries_raw["model"]) if summaries_raw.get("model") is not None else None, - summary_effort=str(summaries_raw["effort"]) if summaries_raw.get("effort") is not None else "low", - ) - - repositories_raw = _section(raw, "repositories") - repositories = RepositoryPlanningConfig( - enabled=_bool_value(repositories_raw.get("enabled"), False, "repositories.enabled"), - planner=str(repositories_raw.get("planner", "rules")).strip().lower(), - plan_model=str(repositories_raw["model"]) if repositories_raw.get("model") is not None else None, - plan_effort=str(repositories_raw["effort"]) if repositories_raw.get("effort") is not None else "low", - plan_timeout_ms=_int_value( - repositories_raw.get("timeout_ms"), - 120000, - "repositories.timeout_ms", - positive=True, - ), - fallback=str(repositories_raw.get("fallback", "rules")).strip().lower(), - block_on_needs_human=_bool_value( - repositories_raw.get("block_on_needs_human"), - True, - "repositories.block_on_needs_human", - ), - quarantine_on_mismatch=_bool_value( - repositories_raw.get("quarantine_on_mismatch"), - True, - "repositories.quarantine_on_mismatch", - ), - clone_timeout_ms=_int_value( - repositories_raw.get("clone_timeout_ms"), - 300000, - "repositories.clone_timeout_ms", - positive=True, - ), - base_branch=str(repositories_raw.get("base_branch", "dev")).strip() or "dev", - branch_prefix=str(repositories_raw.get("branch_prefix", "Symphony")).strip().strip("/") or "Symphony", - repositories=_repository_list(repositories_raw.get("known"), workflow_dir=workflow_dir, environ=env), - ) - - return ServiceConfig( - workflow_path=workflow.path, - tracker=tracker, - polling=polling, - workspace=workspace, - hooks=hooks, - agent=agent, - codex=codex, - server=server, - context=context, - dashboard=dashboard, - repositories=repositories, - ) - - -def validate_dispatch_config(config: ServiceConfig) -> None: - if config.tracker.kind not in {"linear", "linear_mcp"}: - raise ConfigError("unsupported_tracker_kind", "tracker.kind must be 'linear' or 'linear_mcp'") - if config.tracker.kind == "linear" and not config.tracker.api_key: - raise ConfigError("missing_tracker_api_key", "tracker.api_key is required after $VAR resolution") - if config.tracker.kind == "linear" and not config.tracker.project_slug: - raise ConfigError("missing_tracker_project_slug", "tracker.project_slug is required for raw Linear GraphQL") - if config.tracker.kind == "linear_mcp" and not config.tracker.project_slug and not config.tracker.team: - raise ConfigError("missing_tracker_scope", "tracker.team or tracker.project_slug is required for Linear MCP") - if not config.codex.command.strip(): - raise ConfigError("missing_codex_command", "codex.command must be present and non-empty") - if config.tracker.kind == "linear_mcp" and not config.tracker.mcp_command.strip(): - raise ConfigError("missing_tracker_mcp_command", "tracker.mcp_command must be present and non-empty") - if config.context.coding.enabled: - if config.context.coding.classifier not in {"rules", "llm", "always"}: - raise ConfigError( - "invalid_coding_context_classifier", - "context.coding.classifier must be one of: rules, llm, always", - ) - if config.context.coding.classification_fallback not in {"rules", "inject", "skip"}: - raise ConfigError( - "invalid_coding_context_fallback", - "context.coding.classification_fallback must be one of: rules, inject, skip", - ) - if not config.context.coding.skill_paths: - raise ConfigError("missing_coding_context_skills", "context.coding.skill_paths must be present when coding context is enabled") - for path in config.context.coding.skill_paths: - if not path.exists(): - raise ConfigError("missing_coding_context_skill", f"context coding skill path does not exist: {path}") - if config.repositories.enabled: - if config.repositories.planner not in {"rules", "llm"}: - raise ConfigError("invalid_repository_planner", "repositories.planner must be one of: rules, llm") - if config.repositories.fallback not in {"rules", "block"}: - raise ConfigError("invalid_repository_planner_fallback", "repositories.fallback must be one of: rules, block") - if not config.repositories.repositories: - raise ConfigError("missing_known_repositories", "repositories.known must contain at least one repository when repositories.enabled is true") - seen: set[str] = set() - for repo in config.repositories.repositories: - key = repo.slug.lower() - if key in seen: - raise ConfigError("duplicate_known_repository", f"duplicate repository slug: {repo.slug}") - seen.add(key) - if repo.local_path is not None and not repo.local_path.exists(): - raise ConfigError("missing_repository_local_path", f"repository local_path does not exist: {repo.local_path}") - if repo.local_path is None and not repo.remote_url: - raise ConfigError( - "repository_missing_source", - f"repository must define local_path or remote_url: {repo.slug}", - ) - - -class ConfigManager: - """Owns workflow reload and last-known-good config semantics.""" - - def __init__(self, workflow_path: str | Path | None = None, *, environ: Mapping[str, str] | None = None): - self.workflow_path = resolve_workflow_path(workflow_path) - self.environ = environ or os.environ - self.workflow: WorkflowDefinition | None = None - self.config: ServiceConfig | None = None - self.last_reload_error: Exception | None = None - - def load_startup(self) -> tuple[WorkflowDefinition, ServiceConfig]: - workflow = load_workflow(self.workflow_path) - config = resolve_config(workflow, self.environ) - validate_dispatch_config(config) - self.workflow = workflow - self.config = config - self.last_reload_error = None - return workflow, config - - def current(self) -> tuple[WorkflowDefinition, ServiceConfig]: - if self.workflow is None or self.config is None: - return self.load_startup() - return self.workflow, self.config - - def reload_if_changed(self) -> bool: - if self.workflow is None: - self.load_startup() - return True - try: - current_mtime = self.workflow_path.stat().st_mtime_ns - except OSError as exc: - self.last_reload_error = WorkflowError("missing_workflow_file", f"workflow file cannot be read: {self.workflow_path}", cause=exc) - log_event(LOGGER, logging.ERROR, "workflow_reload_failed", reason=self.last_reload_error) - return False - if current_mtime == self.workflow.mtime_ns: - return False - try: - workflow = load_workflow(self.workflow_path) - config = resolve_config(workflow, self.environ) - validate_dispatch_config(config) - except (WorkflowError, ConfigError) as exc: - self.last_reload_error = exc - log_event(LOGGER, logging.ERROR, "workflow_reload_failed", reason=exc) - return False - self.workflow = workflow - self.config = config - self.last_reload_error = None - log_event(LOGGER, logging.INFO, "workflow_reloaded", workflow_path=workflow.path) - return True - - def validate_for_dispatch(self) -> None: - self.reload_if_changed() - if self.last_reload_error is not None: - raise ConfigError("workflow_reload_invalid", str(self.last_reload_error), cause=self.last_reload_error) - _, config = self.current() - validate_dispatch_config(config) diff --git a/symphony/dashboard_summary.py b/symphony/dashboard_summary.py deleted file mode 100644 index ed7bfe8..0000000 --- a/symphony/dashboard_summary.py +++ /dev/null @@ -1,126 +0,0 @@ -from __future__ import annotations - -import json -from dataclasses import dataclass, replace -from pathlib import Path -from typing import Any - -from .codex_client import CodexAppServerSession -from .config import CodexConfig, DashboardConfig -from .models import Issue -from .utils import truncate - - -@dataclass(frozen=True, slots=True) -class DashboardSummary: - summary: str - current_step: str - needs_human: bool - human_reason: str | None - risk: str - confidence: float | None - - -async def summarize_activity( - *, - issue: Issue, - activity: list[dict[str, Any]], - previous_summary: str | None, - codex_config: CodexConfig, - dashboard_config: DashboardConfig, - workspace_path: Path, -) -> DashboardSummary: - summary_config = replace( - codex_config, - model=dashboard_config.summary_model or codex_config.model, - effort=dashboard_config.summary_effort, - turn_timeout_ms=dashboard_config.summary_timeout_ms, - summary=None, - personality=None, - ) - - async def ignore_event(_: dict[str, Any]) -> None: - return None - - async with CodexAppServerSession(summary_config, workspace_path, tracker_config=None, on_event=ignore_event) as session: - result = await session.run_turn( - _summary_prompt( - issue=issue, - activity=activity, - previous_summary=previous_summary, - max_chars=dashboard_config.summary_max_chars, - ), - capture_agent_text=True, - ) - data = _parse_summary_json(result.agent_message_text) - return DashboardSummary( - summary=truncate(str(data.get("summary") or "No substantive activity has been summarized yet."), 800), - current_step=truncate(str(data.get("current_step") or "Unknown."), 300), - needs_human=bool(data.get("needs_human")), - human_reason=truncate(str(data.get("human_reason")), 500) if data.get("human_reason") else None, - risk=_normalize_risk(data.get("risk")), - confidence=_normalize_confidence(data.get("confidence")), - ) - - -def _summary_prompt(*, issue: Issue, activity: list[dict[str, Any]], previous_summary: str | None, max_chars: int) -> str: - payload = { - "issue": issue.to_template_data(), - "previous_summary": previous_summary, - "recent_activity": activity, - } - payload_json = truncate(json.dumps(payload, sort_keys=True, default=str), max_chars) - return ( - "You summarize a running background coding agent for a human dashboard.\n" - "Use only the visible activity events. Do not claim completion unless the events show it. " - "Flag human attention if the agent appears blocked, confused, repeatedly failing, using the wrong repo, " - "waiting for credentials/decisions, asking for input, or operating with high uncertainty. " - "Also flag human attention if the issue reads like product/runtime/UI work but the activity shows the agent " - "working mostly in unrelated infrastructure, gateway, prompt/config, or deployment files.\n\n" - "Return only one JSON object, no markdown and no prose.\n" - "Schema:\n" - "{\n" - ' "summary": "1-2 sentence present-tense summary of what the agent is doing",\n' - ' "current_step": "short phrase for the current/next step",\n' - ' "needs_human": boolean,\n' - ' "human_reason": "why a human should step in, or null",\n' - ' "risk": "low|medium|high|unknown",\n' - ' "confidence": number\n' - "}\n\n" - f"Dashboard input JSON:\n{payload_json}" - ) - - -def _parse_summary_json(text: str) -> dict[str, Any]: - stripped = text.strip() - if not stripped: - raise ValueError("summary model returned empty text") - try: - value = json.loads(stripped) - except json.JSONDecodeError: - value = json.loads(_extract_json_object(stripped)) - if not isinstance(value, dict): - raise ValueError("summary JSON is not an object") - return value - - -def _extract_json_object(text: str) -> str: - start = text.find("{") - end = text.rfind("}") - if start == -1 or end == -1 or end <= start: - raise ValueError("summary output did not contain a JSON object") - return text[start : end + 1] - - -def _normalize_risk(value: Any) -> str: - risk = str(value or "unknown").strip().lower() - return risk if risk in {"low", "medium", "high", "unknown"} else "unknown" - - -def _normalize_confidence(value: Any) -> float | None: - if isinstance(value, bool) or value is None: - return None - try: - return max(0.0, min(1.0, float(value))) - except (TypeError, ValueError): - return None diff --git a/symphony/errors.py b/symphony/errors.py deleted file mode 100644 index a36e045..0000000 --- a/symphony/errors.py +++ /dev/null @@ -1,42 +0,0 @@ -from __future__ import annotations - - -class SymphonyError(Exception): - """Base exception carrying a stable machine-readable code.""" - - def __init__(self, code: str, message: str, *, cause: BaseException | None = None): - super().__init__(message) - self.code = code - self.message = message - self.__cause__ = cause - - def __str__(self) -> str: - return f"{self.code}: {self.message}" - - -class WorkflowError(SymphonyError): - pass - - -class ConfigError(SymphonyError): - pass - - -class TrackerError(SymphonyError): - pass - - -class WorkspaceError(SymphonyError): - pass - - -class HookError(WorkspaceError): - pass - - -class TemplateError(SymphonyError): - pass - - -class AgentError(SymphonyError): - pass diff --git a/symphony/http_server.py b/symphony/http_server.py deleted file mode 100644 index ad34894..0000000 --- a/symphony/http_server.py +++ /dev/null @@ -1,481 +0,0 @@ -from __future__ import annotations - -import asyncio -import html -import json -import logging -from http import HTTPStatus -from typing import Any -from urllib.parse import unquote, urlparse - -from .logging import log_event -from .orchestrator import Orchestrator -from .utils import now_utc, isoformat_z - -LOGGER = logging.getLogger(__name__) - - -class StatusHTTPServer: - def __init__(self, orchestrator: Orchestrator, *, host: str = "127.0.0.1", port: int = 0): - self.orchestrator = orchestrator - self.host = host - self.port = port - self.server: asyncio.AbstractServer | None = None - self.bound_port: int | None = None - - async def start(self) -> None: - self.server = await asyncio.start_server(self._handle, self.host, self.port) - socket = self.server.sockets[0] if self.server.sockets else None - self.bound_port = socket.getsockname()[1] if socket else self.port - log_event(LOGGER, logging.INFO, "http_server_started", host=self.host, port=self.bound_port) - - async def stop(self) -> None: - if self.server is None: - return - self.server.close() - await self.server.wait_closed() - - async def _handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: - try: - request_line = await reader.readline() - if not request_line: - return - method, target, _version = request_line.decode("iso-8859-1").strip().split(" ", 2) - headers: dict[str, str] = {} - while True: - line = await reader.readline() - if line in {b"\r\n", b"\n", b""}: - break - key, _, value = line.decode("iso-8859-1").partition(":") - headers[key.lower()] = value.strip() - length = int(headers.get("content-length", "0") or 0) - if length: - await reader.readexactly(length) - parsed = urlparse(target) - await self._route(method.upper(), parsed.path, writer) - except Exception as exc: - await self._send_json(writer, HTTPStatus.INTERNAL_SERVER_ERROR, {"error": {"code": "internal_error", "message": str(exc)}}) - finally: - writer.close() - await writer.wait_closed() - - async def _route(self, method: str, path: str, writer: asyncio.StreamWriter) -> None: - if path == "/": - if method != "GET": - await self._method_not_allowed(writer) - return - await self._send_html(writer, HTTPStatus.OK, self._dashboard_html(await self.orchestrator.snapshot())) - return - if path == "/api/v1/state": - if method != "GET": - await self._method_not_allowed(writer) - return - await self._send_json(writer, HTTPStatus.OK, await self.orchestrator.snapshot()) - return - if path == "/api/v1/refresh": - if method != "POST": - await self._method_not_allowed(writer) - return - coalesced = await self.orchestrator.request_refresh() - await self._send_json( - writer, - HTTPStatus.ACCEPTED, - { - "queued": True, - "coalesced": coalesced, - "requested_at": isoformat_z(now_utc()), - "operations": ["poll", "reconcile"], - }, - ) - return - if path.startswith("/api/v1/"): - if method != "GET": - await self._method_not_allowed(writer) - return - issue_identifier = unquote(path.removeprefix("/api/v1/")) - detail = await self.orchestrator.issue_snapshot(issue_identifier) - if detail is None: - await self._send_json( - writer, - HTTPStatus.NOT_FOUND, - {"error": {"code": "issue_not_found", "message": f"issue is not known: {issue_identifier}"}}, - ) - return - await self._send_json(writer, HTTPStatus.OK, detail) - return - await self._send_json(writer, HTTPStatus.NOT_FOUND, {"error": {"code": "not_found", "message": "route not found"}}) - - async def _method_not_allowed(self, writer: asyncio.StreamWriter) -> None: - await self._send_json(writer, HTTPStatus.METHOD_NOT_ALLOWED, {"error": {"code": "method_not_allowed", "message": "method not allowed"}}) - - async def _send_json(self, writer: asyncio.StreamWriter, status: HTTPStatus, payload: dict[str, Any]) -> None: - body = json.dumps(payload, sort_keys=True, default=str).encode("utf-8") - await self._send(writer, status, "application/json; charset=utf-8", body) - - async def _send_html(self, writer: asyncio.StreamWriter, status: HTTPStatus, text: str) -> None: - await self._send(writer, status, "text/html; charset=utf-8", text.encode("utf-8")) - - async def _send(self, writer: asyncio.StreamWriter, status: HTTPStatus, content_type: str, body: bytes) -> None: - writer.write( - ( - f"HTTP/1.1 {status.value} {status.phrase}\r\n" - f"Content-Type: {content_type}\r\n" - f"Content-Length: {len(body)}\r\n" - "Connection: close\r\n" - "\r\n" - ).encode("ascii") - + body - ) - await writer.drain() - - def _dashboard_html(self, snapshot: dict[str, Any]) -> str: - return f""" - - - - - Symphony - - - -
-
-

Symphony

-
Generated at {html.escape(str(snapshot.get('generated_at')))}
-
-
- - State JSON -
-
-
-
-
-

Running Agents

-
-
-
-

Continuing / Retrying

-
-
-
-

Blocked

-
-
-
-

Completed

-
-
-
- - -""" diff --git a/symphony/logging.py b/symphony/logging.py deleted file mode 100644 index 3970cc6..0000000 --- a/symphony/logging.py +++ /dev/null @@ -1,19 +0,0 @@ -from __future__ import annotations - -import logging -import sys -from typing import Any - -from .utils import key_value_message - - -def configure_logging(level: str = "INFO") -> None: - logging.basicConfig( - level=getattr(logging, level.upper(), logging.INFO), - format="%(asctime)s level=%(levelname)s logger=%(name)s %(message)s", - stream=sys.stderr, - ) - - -def log_event(logger: logging.Logger, level: int, event: str, **fields: Any) -> None: - logger.log(level, key_value_message(event, **fields)) diff --git a/symphony/models.py b/symphony/models.py deleted file mode 100644 index 2e192ed..0000000 --- a/symphony/models.py +++ /dev/null @@ -1,267 +0,0 @@ -from __future__ import annotations - -import asyncio -from dataclasses import dataclass, field -from datetime import datetime -from pathlib import Path -from typing import Any - -from .utils import isoformat_z - - -@dataclass(slots=True) -class BlockerRef: - id: str | None = None - identifier: str | None = None - state: str | None = None - - def to_dict(self) -> dict[str, Any]: - return {"id": self.id, "identifier": self.identifier, "state": self.state} - - -@dataclass(slots=True) -class IssueAttachment: - id: str | None = None - title: str | None = None - subtitle: str | None = None - url: str | None = None - - def to_dict(self) -> dict[str, Any]: - return {"id": self.id, "title": self.title, "subtitle": self.subtitle, "url": self.url} - - -@dataclass(slots=True) -class Issue: - id: str - identifier: str - title: str - description: str | None = None - priority: int | None = None - state: str = "" - branch_name: str | None = None - url: str | None = None - labels: list[str] = field(default_factory=list) - attachments: list[IssueAttachment] = field(default_factory=list) - blocked_by: list[BlockerRef] = field(default_factory=list) - created_at: datetime | None = None - updated_at: datetime | None = None - - def to_template_data(self) -> dict[str, Any]: - return { - "id": self.id, - "identifier": self.identifier, - "title": self.title, - "description": self.description, - "priority": self.priority, - "state": self.state, - "branch_name": self.branch_name, - "url": self.url, - "labels": list(self.labels), - "attachments": [attachment.to_dict() for attachment in self.attachments], - "blocked_by": [blocker.to_dict() for blocker in self.blocked_by], - "created_at": isoformat_z(self.created_at), - "updated_at": isoformat_z(self.updated_at), - } - - -@dataclass(slots=True) -class WorkflowDefinition: - config: dict[str, Any] - prompt_template: str - path: Path - mtime_ns: int | None = None - - -@dataclass(slots=True) -class Workspace: - path: Path - workspace_key: str - created_now: bool - repo_plan: RepoPlan | None = None - primary_repo_path: Path | None = None - - -@dataclass(frozen=True, slots=True) -class RepoPlanItem: - slug: str - role: str - reason: str | None = None - path_name: str | None = None - edit_allowed: bool = True - - def to_dict(self) -> dict[str, Any]: - return { - "slug": self.slug, - "role": self.role, - "reason": self.reason, - "path_name": self.path_name, - "edit_allowed": self.edit_allowed, - } - - -@dataclass(frozen=True, slots=True) -class RepoPlan: - issue_identifier: str - coding_task: bool - planner: str - source: str - primary_repo: RepoPlanItem | None = None - secondary_repos: list[RepoPlanItem] = field(default_factory=list) - read_only_context_repos: list[RepoPlanItem] = field(default_factory=list) - confidence: float | None = None - needs_human: bool = False - human_reason: str | None = None - notes: str | None = None - created_at: datetime | None = None - - def all_repos(self) -> list[RepoPlanItem]: - repos: list[RepoPlanItem] = [] - if self.primary_repo is not None: - repos.append(self.primary_repo) - repos.extend(self.secondary_repos) - repos.extend(self.read_only_context_repos) - return repos - - def edit_allowed_slugs(self) -> set[str]: - return {repo.slug for repo in self.all_repos() if repo.edit_allowed and repo.role != "read_only_context"} - - def to_dict(self) -> dict[str, Any]: - return { - "issue_identifier": self.issue_identifier, - "coding_task": self.coding_task, - "planner": self.planner, - "source": self.source, - "primary_repo": self.primary_repo.to_dict() if self.primary_repo else None, - "secondary_repos": [repo.to_dict() for repo in self.secondary_repos], - "read_only_context_repos": [repo.to_dict() for repo in self.read_only_context_repos], - "confidence": self.confidence, - "needs_human": self.needs_human, - "human_reason": self.human_reason, - "notes": self.notes, - "created_at": isoformat_z(self.created_at), - } - - -@dataclass(slots=True) -class CodexTotals: - input_tokens: int = 0 - output_tokens: int = 0 - total_tokens: int = 0 - seconds_running: float = 0.0 - - def to_dict(self) -> dict[str, Any]: - return { - "input_tokens": self.input_tokens, - "output_tokens": self.output_tokens, - "total_tokens": self.total_tokens, - "seconds_running": self.seconds_running, - } - - -@dataclass(slots=True) -class RetryEntry: - issue_id: str - identifier: str - attempt: int - due_at_monotonic: float - due_at_wall: datetime - error: str | None = None - timer_handle: asyncio.Task[None] | None = None - - -@dataclass(slots=True) -class BlockedEntry: - issue: Issue - reason: str - blocked_at: datetime - workspace_path: Path | None = None - repo_plan: RepoPlan | None = None - - -@dataclass(slots=True) -class CompletedEntry: - issue: Issue - completed_at: datetime - reason: str - workspace_path: Path | None = None - repo_plan: RepoPlan | None = None - duration_seconds: float = 0.0 - turn_count: int = 0 - session_id: str | None = None - thread_id: str | None = None - turn_id: str | None = None - codex_input_tokens: int = 0 - codex_output_tokens: int = 0 - codex_total_tokens: int = 0 - summary_text: str | None = None - summary_current_step: str | None = None - summary_needs_human: bool = False - summary_human_reason: str | None = None - summary_risk: str | None = None - summary_confidence: float | None = None - summary_updated_at: datetime | None = None - repo_deviations: list[str] = field(default_factory=list) - recent_activity: list[dict[str, Any]] = field(default_factory=list) - - -@dataclass(slots=True) -class RunningEntry: - issue: Issue - task: asyncio.Task[Any] - cancel_event: asyncio.Event - workspace_path: Path | None - started_at: datetime - started_monotonic: float - retry_attempt: int | None = None - session_id: str | None = None - thread_id: str | None = None - turn_id: str | None = None - codex_app_server_pid: str | None = None - last_codex_event: str | None = None - last_codex_timestamp: datetime | None = None - last_codex_message: str | None = None - repo_plan: RepoPlan | None = None - repo_deviations: list[str] = field(default_factory=list) - recent_activity: list[dict[str, Any]] = field(default_factory=list) - activity_revision: int = 0 - summary_revision: int = 0 - summary_pending: bool = False - summary_text: str | None = None - summary_current_step: str | None = None - summary_needs_human: bool = False - summary_human_reason: str | None = None - summary_risk: str | None = None - summary_confidence: float | None = None - summary_updated_at: datetime | None = None - summary_error: str | None = None - summary_source: str | None = None - last_summary_monotonic: float = 0.0 - codex_input_tokens: int = 0 - codex_output_tokens: int = 0 - codex_total_tokens: int = 0 - last_reported_input_tokens: int = 0 - last_reported_output_tokens: int = 0 - last_reported_total_tokens: int = 0 - turn_count: int = 0 - forced_outcome: str | None = None - forced_error: str | None = None - cleanup_workspace: bool = False - - -@dataclass(slots=True) -class RuntimeState: - poll_interval_ms: int - max_concurrent_agents: int - service_status: str = "starting" - startup_completed_at: datetime | None = None - last_poll_started_at: datetime | None = None - last_poll_completed_at: datetime | None = None - last_poll_error: str | None = None - last_candidate_count: int | None = None - running: dict[str, RunningEntry] = field(default_factory=dict) - claimed: set[str] = field(default_factory=set) - retry_attempts: dict[str, RetryEntry] = field(default_factory=dict) - blocked: dict[str, BlockedEntry] = field(default_factory=dict) - completed: dict[str, CompletedEntry] = field(default_factory=dict) - codex_totals: CodexTotals = field(default_factory=CodexTotals) - codex_rate_limits: dict[str, Any] | None = None diff --git a/symphony/orchestrator.py b/symphony/orchestrator.py deleted file mode 100644 index b8e7432..0000000 --- a/symphony/orchestrator.py +++ /dev/null @@ -1,1448 +0,0 @@ -from __future__ import annotations - -import asyncio -import json -import logging -import time -from pathlib import Path -from typing import Any - -from .agent_runner import AgentRunResult, AgentRunner -from .config import ConfigManager, ServiceConfig -from .dashboard_summary import DashboardSummary, summarize_activity -from .errors import ConfigError, TrackerError -from .logging import log_event -from .models import ( - BlockedEntry, - BlockerRef, - CodexTotals, - CompletedEntry, - Issue, - IssueAttachment, - RepoPlan, - RepoPlanItem, - RetryEntry, - RunningEntry, - RuntimeState, -) -from .tracker import IssueTracker, make_tracker -from .utils import isoformat_z, normalize_state, now_utc, parse_datetime, truncate -from .review import ReviewPullRequestResolver -from .workspace import WorkspaceManager - -LOGGER = logging.getLogger(__name__) -CONTINUATION_RETRY_MS = 1000 -STATE_FILE_NAME = ".symphony-state.json" - - -class Orchestrator: - def __init__(self, config_manager: ConfigManager, *, review_resolver: ReviewPullRequestResolver | None = None): - self.config_manager = config_manager - _, config = self.config_manager.current() - self.state = RuntimeState( - poll_interval_ms=config.polling.interval_ms, - max_concurrent_agents=config.agent.max_concurrent_agents, - ) - self.review_resolver = review_resolver or ReviewPullRequestResolver() - self._lock = asyncio.Lock() - self._refresh_event = asyncio.Event() - self._stopping = False - self._summary_tasks: dict[str, asyncio.Task[Any]] = {} - self._load_persisted_state() - - async def start(self) -> None: - self.config_manager.load_startup() - await self.startup_terminal_workspace_cleanup() - async with self._lock: - self._restore_retry_timers_locked() - self.state.service_status = "running" - self.state.startup_completed_at = now_utc() - self.state.last_poll_error = None - self._persist_state_locked() - log_event(LOGGER, logging.INFO, "service_started", workflow_path=self.config_manager.workflow_path) - await self.request_refresh() - while not self._stopping: - await self._refresh_event.wait() - self._refresh_event.clear() - await self.tick() - _, config = self.config_manager.current() - try: - await asyncio.wait_for(self._refresh_event.wait(), timeout=config.polling.interval_ms / 1000) - except TimeoutError: - self._refresh_event.set() - - async def stop(self) -> None: - self._stopping = True - self._refresh_event.set() - tasks: list[asyncio.Task[Any]] = [] - async with self._lock: - for entry in list(self.state.running.values()): - entry.forced_outcome = "release" - entry.cancel_event.set() - entry.task.cancel() - tasks.append(entry.task) - for retry in self.state.retry_attempts.values(): - if retry.timer_handle: - retry.timer_handle.cancel() - tasks.append(retry.timer_handle) - for task in self._summary_tasks.values(): - task.cancel() - tasks.append(task) - self._summary_tasks.clear() - if tasks: - await asyncio.gather(*tasks, return_exceptions=True) - async with self._lock: - self._persist_state_locked() - - async def request_refresh(self) -> bool: - already_set = self._refresh_event.is_set() - self._refresh_event.set() - return already_set - - async def tick(self) -> None: - self.config_manager.reload_if_changed() - _, config = self.config_manager.current() - async with self._lock: - self.state.service_status = "polling" - self.state.poll_interval_ms = config.polling.interval_ms - self.state.max_concurrent_agents = config.agent.max_concurrent_agents - self.state.last_poll_started_at = now_utc() - self.state.last_poll_error = None - tracker = make_tracker(config.tracker) - await self.reconcile_running_issues(tracker, config) - try: - self.config_manager.validate_for_dispatch() - except ConfigError as exc: - await self._mark_poll_failed(exc) - log_event(LOGGER, logging.ERROR, "dispatch_validation_failed", reason=exc) - return - _, config = self.config_manager.current() - tracker = make_tracker(config.tracker) - await self.reconcile_review_issues(tracker, config) - try: - candidates = await tracker.fetch_candidate_issues() - except TrackerError as exc: - await self._mark_poll_failed(exc) - log_event(LOGGER, logging.ERROR, "candidate_fetch_failed", reason=exc) - return - for issue in self.sort_for_dispatch(candidates): - async with self._lock: - if self.available_global_slots_locked(config) <= 0: - break - eligible = self.is_dispatch_eligible_locked(issue, config) - if eligible: - await self.dispatch_issue(issue, attempt=None, tracker=tracker) - await self._mark_poll_completed(len(candidates)) - - async def _mark_poll_failed(self, exc: Exception) -> None: - async with self._lock: - self.state.service_status = "degraded" - self.state.last_poll_completed_at = now_utc() - self.state.last_poll_error = truncate(str(exc), 500) - self._persist_state_locked() - - async def _mark_poll_completed(self, candidate_count: int) -> None: - async with self._lock: - self.state.service_status = "running" - self.state.last_poll_completed_at = now_utc() - self.state.last_candidate_count = candidate_count - self.state.last_poll_error = None - self._persist_state_locked() - - async def startup_terminal_workspace_cleanup(self) -> None: - _, config = self.config_manager.current() - tracker = make_tracker(config.tracker) - try: - terminal = await tracker.fetch_issues_by_states(config.tracker.terminal_states) - except TrackerError as exc: - log_event(LOGGER, logging.WARNING, "startup_cleanup_failed", reason=exc) - return - workspace_manager = WorkspaceManager(config.workspace, config.hooks) - for issue in terminal: - await workspace_manager.remove_for_identifier(issue.identifier) - - async def reconcile_running_issues(self, tracker: IssueTracker, config: ServiceConfig) -> None: - await self._reconcile_stalled(config) - async with self._lock: - running_ids = list(self.state.running.keys()) - if not running_ids: - return - try: - refreshed = await tracker.fetch_issue_states_by_ids(running_ids) - except TrackerError as exc: - log_event(LOGGER, logging.WARNING, "running_state_refresh_failed", reason=exc) - return - refreshed_by_id = {issue.id: issue for issue in refreshed} - for issue_id in running_ids: - issue = refreshed_by_id.get(issue_id) - if issue is None: - continue - state = normalize_state(issue.state) - if state in config.tracker.terminal_state_set: - await self.terminate_running_issue(issue_id, cleanup_workspace=True, retry=False, reason="terminal_state") - elif not self._has_required_labels(issue, config): - await self.terminate_running_issue(issue_id, cleanup_workspace=False, retry=False, reason="required_label_removed") - elif state in config.tracker.active_state_set: - async with self._lock: - if issue_id in self.state.running: - self.state.running[issue_id].issue = issue - else: - await self.terminate_running_issue(issue_id, cleanup_workspace=False, retry=False, reason="non_active_state") - - async def reconcile_review_issues(self, tracker: IssueTracker, config: ServiceConfig) -> None: - if not config.tracker.review_states: - return - if not hasattr(tracker, "save_issue_state"): - log_event(LOGGER, logging.DEBUG, "review_reconciliation_skipped", reason="tracker does not expose state writes") - return - try: - review_issues = await tracker.fetch_issues_by_states(config.tracker.review_states) - except TrackerError as exc: - log_event(LOGGER, logging.WARNING, "review_issue_fetch_failed", reason=exc) - return - if not review_issues: - return - try: - refreshed = await tracker.fetch_issue_states_by_ids([issue.id for issue in review_issues]) - except TrackerError as exc: - log_event(LOGGER, logging.WARNING, "review_issue_hydration_failed", reason=exc) - refreshed = review_issues - workspace_manager = WorkspaceManager(config.workspace, config.hooks) - for issue in _dedupe_by_id(refreshed): - if not self._has_required_labels(issue, config): - continue - comments: list[dict[str, Any]] = [] - if hasattr(tracker, "list_issue_comments"): - try: - comments = await tracker.list_issue_comments(issue.identifier) # type: ignore[attr-defined] - except Exception as exc: - log_event( - LOGGER, - logging.WARNING, - "review_comment_fetch_failed", - issue_id=issue.id, - issue_identifier=issue.identifier, - reason=truncate(str(exc), 500), - ) - workspace_path = workspace_manager.workspace_path_for_identifier(issue.identifier) - try: - result = await self.review_resolver.evaluate( - issue, - comments=comments, - workspace_path=workspace_path, - base_branch=config.tracker.merge_base_branch, - ) - except Exception as exc: - log_event( - LOGGER, - logging.WARNING, - "review_merge_gate_failed", - issue_id=issue.id, - issue_identifier=issue.identifier, - reason=truncate(str(exc), 500), - ) - continue - log_event( - LOGGER, - logging.INFO if result.ready else logging.DEBUG, - "review_merge_gate_evaluated", - issue_id=issue.id, - issue_identifier=issue.identifier, - ready=result.ready, - required_prs=len(result.required_prs), - reason=result.reason, - ) - if not result.ready: - continue - try: - await tracker.save_issue_state(issue.identifier, config.tracker.done_state) # type: ignore[attr-defined] - except Exception as exc: - log_event( - LOGGER, - logging.WARNING, - "review_done_transition_failed", - issue_id=issue.id, - issue_identifier=issue.identifier, - reason=truncate(str(exc), 500), - ) - continue - log_event( - LOGGER, - logging.INFO, - "review_done_transition_completed", - issue_id=issue.id, - issue_identifier=issue.identifier, - done_state=config.tracker.done_state, - required_prs=len(result.required_prs), - ) - - async def _reconcile_stalled(self, config: ServiceConfig) -> None: - if config.codex.stall_timeout_ms <= 0: - return - now = now_utc() - stalled: list[str] = [] - async with self._lock: - for issue_id, entry in self.state.running.items(): - since = entry.last_codex_timestamp or entry.started_at - elapsed_ms = (now - since).total_seconds() * 1000 - if elapsed_ms > config.codex.stall_timeout_ms: - stalled.append(issue_id) - for issue_id in stalled: - await self.terminate_running_issue(issue_id, cleanup_workspace=False, retry=True, reason="stalled") - - async def dispatch_issue(self, issue: Issue, attempt: int | None, tracker: IssueTracker) -> None: - runner = AgentRunner(self.config_manager, tracker) - cancel_event = asyncio.Event() - _, config = self.config_manager.current() - workspace_path = WorkspaceManager(config.workspace, config.hooks).workspace_path_for_identifier(issue.identifier) - async with self._lock: - task = asyncio.create_task(runner.run_issue(issue, attempt, self.handle_codex_event)) - task.add_done_callback(lambda completed, issue_id=issue.id: asyncio.create_task(self.handle_worker_done(issue_id, completed))) - entry = RunningEntry( - issue=issue, - task=task, - cancel_event=cancel_event, - workspace_path=workspace_path, - started_at=now_utc(), - started_monotonic=time.monotonic(), - retry_attempt=attempt, - ) - self.state.running[issue.id] = entry - self.state.claimed.add(issue.id) - retry = self.state.retry_attempts.pop(issue.id, None) - if retry and retry.timer_handle: - retry.timer_handle.cancel() - self._persist_state_locked() - log_event( - LOGGER, - logging.INFO, - "issue_dispatched", - issue_id=issue.id, - issue_identifier=issue.identifier, - attempt=attempt, - ) - - async def handle_worker_done(self, issue_id: str, completed: asyncio.Task[Any]) -> None: - async with self._lock: - entry = self.state.running.pop(issue_id, None) - if entry is None: - return - elapsed = time.monotonic() - entry.started_monotonic - async with self._lock: - self.state.codex_totals.seconds_running += elapsed - self._persist_state_locked() - if entry.forced_outcome == "release": - if entry.cleanup_workspace: - await self._cleanup_workspace(entry.issue) - async with self._lock: - self.state.claimed.discard(issue_id) - self._persist_state_locked() - log_event( - LOGGER, - logging.INFO, - "worker_released", - issue_id=issue_id, - issue_identifier=entry.issue.identifier, - reason=entry.forced_error, - ) - return - if entry.forced_outcome == "retry": - await self.schedule_retry(entry.issue, self._next_attempt(entry.retry_attempt), error=entry.forced_error or "worker cancelled") - return - try: - result = completed.result() - except asyncio.CancelledError: - await self.schedule_retry(entry.issue, self._next_attempt(entry.retry_attempt), error="worker cancelled") - return - except Exception as exc: - await self.schedule_retry(entry.issue, self._next_attempt(entry.retry_attempt), error=f"worker crashed: {exc}") - return - if isinstance(result, AgentRunResult) and result.normal: - async with self._lock: - self.state.completed[issue_id] = _completed_entry_from_running(entry, reason=result.reason, elapsed=elapsed) - self.state.claimed.add(issue_id) - self._persist_state_locked() - log_event( - LOGGER, - logging.INFO, - "worker_completed", - issue_id=issue_id, - issue_identifier=entry.issue.identifier, - reason=result.reason, - ) - await self.schedule_retry( - entry.issue, - 1, - delay_ms=CONTINUATION_RETRY_MS, - error=None, - ) - else: - reason = result.reason if isinstance(result, AgentRunResult) else "worker failed" - if isinstance(result, AgentRunResult) and result.blocked: - async with self._lock: - self.state.blocked[issue_id] = BlockedEntry( - issue=entry.issue, - reason=result.repo_plan.human_reason if result.repo_plan and result.repo_plan.human_reason else reason, - blocked_at=now_utc(), - workspace_path=entry.workspace_path, - repo_plan=result.repo_plan, - ) - self.state.claimed.add(issue_id) - self._persist_state_locked() - log_event( - LOGGER, - logging.WARNING, - "worker_blocked", - issue_id=issue_id, - issue_identifier=entry.issue.identifier, - reason=reason, - ) - elif isinstance(result, AgentRunResult) and not result.retryable: - async with self._lock: - self.state.claimed.discard(issue_id) - self._persist_state_locked() - log_event(LOGGER, logging.WARNING, "worker_not_retried", issue_id=issue_id, reason=reason) - else: - await self.schedule_retry(entry.issue, self._next_attempt(entry.retry_attempt), error=reason) - - async def terminate_running_issue(self, issue_id: str, *, cleanup_workspace: bool, retry: bool, reason: str) -> None: - async with self._lock: - entry = self.state.running.get(issue_id) - if entry is None: - return - entry.forced_outcome = "retry" if retry else "release" - entry.forced_error = reason - entry.cleanup_workspace = cleanup_workspace - entry.cancel_event.set() - entry.task.cancel() - log_event(LOGGER, logging.INFO, "worker_termination_requested", issue_id=issue_id, reason=reason, retry=retry) - - async def schedule_retry( - self, - issue: Issue, - attempt: int, - *, - delay_ms: int | None = None, - error: str | None, - ) -> None: - _, config = self.config_manager.current() - if delay_ms is None: - delay_ms = min(10000 * (2 ** max(attempt - 1, 0)), config.agent.max_retry_backoff_ms) - due_at_monotonic = time.monotonic() + delay_ms / 1000 - timer = asyncio.create_task(self._retry_after(issue.id, delay_ms)) - entry = RetryEntry( - issue_id=issue.id, - identifier=issue.identifier, - attempt=attempt, - due_at_monotonic=due_at_monotonic, - due_at_wall=now_utc_from_monotonic_delay(delay_ms), - error=error, - timer_handle=timer, - ) - async with self._lock: - old = self.state.retry_attempts.get(issue.id) - if old and old.timer_handle: - old.timer_handle.cancel() - self.state.retry_attempts[issue.id] = entry - self.state.claimed.add(issue.id) - self._persist_state_locked() - log_event( - LOGGER, - logging.INFO, - "retry_scheduled", - issue_id=issue.id, - issue_identifier=issue.identifier, - attempt=attempt, - delay_ms=delay_ms, - error=error, - retry_kind="continuation" if error is None else "retry", - ) - - async def _retry_after(self, issue_id: str, delay_ms: int) -> None: - try: - await asyncio.sleep(delay_ms / 1000) - await self.handle_retry_timer(issue_id) - except asyncio.CancelledError: - return - - async def handle_retry_timer(self, issue_id: str) -> None: - async with self._lock: - retry_entry = self.state.retry_attempts.pop(issue_id, None) - if retry_entry is not None: - self._persist_state_locked() - if retry_entry is None: - return - _, config = self.config_manager.current() - tracker = make_tracker(config.tracker) - try: - candidates = await tracker.fetch_candidate_issues() - except TrackerError: - issue = Issue(id=issue_id, identifier=retry_entry.identifier, title="", state="") - await self.schedule_retry(issue, retry_entry.attempt + 1, error="retry poll failed") - return - issue = next((candidate for candidate in candidates if candidate.id == issue_id), None) - if issue is None: - async with self._lock: - self.state.claimed.discard(issue_id) - self._persist_state_locked() - return - async with self._lock: - slots = self.available_global_slots_locked(config) - eligible = self.is_dispatch_eligible_locked(issue, config, ignore_claimed_issue_id=issue_id) - if slots <= 0 or not eligible: - await self.schedule_retry(issue, retry_entry.attempt, error="no available orchestrator slots") - return - await self.dispatch_issue(issue, retry_entry.attempt, tracker) - - async def handle_codex_event(self, issue_id: str, event: dict[str, Any]) -> None: - timestamp = event.get("timestamp") if hasattr(event.get("timestamp"), "tzinfo") else now_utc() - schedule_summary = False - async with self._lock: - entry = self.state.running.get(issue_id) - if entry is None: - return - entry.last_codex_event = event.get("event") - entry.last_codex_timestamp = timestamp - entry.last_codex_message = event.get("message") - entry.codex_app_server_pid = event.get("codex_app_server_pid") or entry.codex_app_server_pid - entry.thread_id = event.get("thread_id") or entry.thread_id - entry.turn_id = event.get("turn_id") or entry.turn_id - entry.session_id = event.get("session_id") or entry.session_id - if event.get("event") == "session_started": - entry.turn_count += 1 - if event.get("event") in {"repo_plan_created", "repo_workspace_prepared"} and isinstance(event.get("repo_plan"), dict): - entry.repo_plan = _repo_plan_from_dict(event["repo_plan"]) - if event.get("workspace_path"): - try: - entry.workspace_path = Path(str(event.get("workspace_path"))) - except TypeError: - pass - deviation = _repo_deviation_from_event(entry, event) - if deviation and deviation not in entry.repo_deviations: - entry.repo_deviations.append(deviation) - entry.repo_deviations = entry.repo_deviations[-20:] - usage = event.get("usage_absolute") - if isinstance(usage, dict): - self._apply_usage_locked(entry, usage) - if isinstance(event.get("rate_limits"), dict): - self.state.codex_rate_limits = event["rate_limits"] - self._record_activity_locked(entry, event, timestamp) - _, config = self.config_manager.current() - schedule_summary = self._should_schedule_summary_locked(entry, config) - if isinstance(usage, dict) or isinstance(event.get("rate_limits"), dict): - self._persist_state_locked() - if schedule_summary: - task = asyncio.create_task(self._summarize_running_issue(issue_id)) - self._summary_tasks[issue_id] = task - task.add_done_callback(lambda _completed, key=issue_id: self._summary_tasks.pop(key, None)) - log_event( - LOGGER, - logging.DEBUG, - "codex_event", - issue_id=issue_id, - session_id=event.get("session_id"), - codex_event=event.get("event"), - ) - - def _record_activity_locked(self, entry: RunningEntry, event: dict[str, Any], timestamp: Any) -> None: - message = _activity_message(event) - if not message: - return - entry.recent_activity.append( - { - "at": isoformat_z(timestamp), - "event": event.get("event"), - "message": truncate(message, 1000), - } - ) - entry.recent_activity = entry.recent_activity[-100:] - entry.activity_revision += 1 - - def _should_schedule_summary_locked(self, entry: RunningEntry, config: ServiceConfig) -> bool: - if not config.dashboard.summaries_enabled: - return False - if entry.summary_pending or entry.workspace_path is None: - return False - if entry.activity_revision <= entry.summary_revision or not entry.recent_activity: - return False - if not _has_work_signal(entry.recent_activity): - return False - now = time.monotonic() - interval = config.dashboard.summary_update_interval_ms / 1000 - if entry.summary_text and now - entry.last_summary_monotonic < interval: - return False - entry.summary_pending = True - return True - - async def _summarize_running_issue(self, issue_id: str) -> None: - async with self._lock: - entry = self.state.running.get(issue_id) - if entry is None or entry.workspace_path is None: - return - _, config = self.config_manager.current() - issue = entry.issue - workspace_path = entry.workspace_path - activity_revision = entry.activity_revision - activity = list(entry.recent_activity[-config.dashboard.summary_max_events :]) - previous_summary = entry.summary_text - try: - summary = await summarize_activity( - issue=issue, - activity=activity, - previous_summary=previous_summary, - codex_config=config.codex, - dashboard_config=config.dashboard, - workspace_path=workspace_path, - ) - except Exception as exc: - async with self._lock: - entry = self.state.running.get(issue_id) - if entry is not None: - entry.summary_pending = False - entry.summary_revision = max(entry.summary_revision, activity_revision) - entry.summary_error = truncate(str(exc), 500) - entry.summary_updated_at = now_utc() - entry.summary_source = "llm" - entry.last_summary_monotonic = time.monotonic() - log_event(LOGGER, logging.WARNING, "dashboard_summary_failed", issue_id=issue_id, reason=exc) - return - async with self._lock: - entry = self.state.running.get(issue_id) - if entry is None: - return - self._apply_summary_locked(entry, summary, activity_revision) - - def _apply_summary_locked(self, entry: RunningEntry, summary: DashboardSummary, activity_revision: int) -> None: - entry.summary_pending = False - entry.summary_revision = max(entry.summary_revision, activity_revision) - entry.summary_text = summary.summary - entry.summary_current_step = summary.current_step - entry.summary_needs_human = summary.needs_human - entry.summary_human_reason = summary.human_reason - entry.summary_risk = summary.risk - entry.summary_confidence = summary.confidence - entry.summary_updated_at = now_utc() - entry.summary_error = None - entry.summary_source = "llm" - entry.last_summary_monotonic = time.monotonic() - - def _apply_usage_locked(self, entry: RunningEntry, usage: dict[str, Any]) -> None: - input_tokens = _to_int(usage.get("input_tokens")) - output_tokens = _to_int(usage.get("output_tokens")) - total_tokens = _to_int(usage.get("total_tokens")) - if input_tokens is not None: - delta = max(input_tokens - entry.last_reported_input_tokens, 0) - self.state.codex_totals.input_tokens += delta - entry.last_reported_input_tokens = max(entry.last_reported_input_tokens, input_tokens) - entry.codex_input_tokens = input_tokens - if output_tokens is not None: - delta = max(output_tokens - entry.last_reported_output_tokens, 0) - self.state.codex_totals.output_tokens += delta - entry.last_reported_output_tokens = max(entry.last_reported_output_tokens, output_tokens) - entry.codex_output_tokens = output_tokens - if total_tokens is not None: - delta = max(total_tokens - entry.last_reported_total_tokens, 0) - self.state.codex_totals.total_tokens += delta - entry.last_reported_total_tokens = max(entry.last_reported_total_tokens, total_tokens) - entry.codex_total_tokens = total_tokens - - def _state_path(self) -> Path: - _, config = self.config_manager.current() - return config.workspace.root.resolve(strict=False) / STATE_FILE_NAME - - def _load_persisted_state(self) -> None: - path = self._state_path() - if not path.exists(): - return - try: - payload = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - log_event(LOGGER, logging.WARNING, "state_load_failed", state_path=path, reason=exc) - return - if not isinstance(payload, dict): - return - self.state.codex_totals = _codex_totals_from_dict(payload.get("codex_totals")) - self.state.codex_rate_limits = payload.get("rate_limits") if isinstance(payload.get("rate_limits"), dict) else None - service = payload.get("service") if isinstance(payload.get("service"), dict) else {} - self.state.last_poll_completed_at = parse_datetime(service.get("last_poll_completed_at")) - self.state.last_poll_error = str(service["last_poll_error"]) if service.get("last_poll_error") is not None else None - self.state.last_candidate_count = _to_int(service.get("last_candidate_count")) - self.state.completed = { - entry.issue.id: entry - for raw in payload.get("completed") or [] - if isinstance(raw, dict) and (entry := _completed_entry_from_dict(raw)) is not None - } - self.state.blocked = { - entry.issue.id: entry - for raw in payload.get("blocked") or [] - if isinstance(raw, dict) and (entry := _blocked_entry_from_dict(raw)) is not None - } - self.state.retry_attempts = { - entry.issue_id: entry - for raw in payload.get("retry_attempts") or [] - if isinstance(raw, dict) and (entry := _retry_entry_from_dict(raw)) is not None - } - self.state.claimed = set(self.state.blocked) | set(self.state.retry_attempts) - log_event( - LOGGER, - logging.INFO, - "state_loaded", - state_path=path, - completed=len(self.state.completed), - blocked=len(self.state.blocked), - retry_attempts=len(self.state.retry_attempts), - total_tokens=self.state.codex_totals.total_tokens, - ) - - def _restore_retry_timers_locked(self) -> None: - now = now_utc() - for retry in self.state.retry_attempts.values(): - if retry.timer_handle is not None and not retry.timer_handle.done(): - continue - delay_seconds = max((retry.due_at_wall - now).total_seconds(), 0) - retry.due_at_monotonic = time.monotonic() + delay_seconds - retry.timer_handle = asyncio.create_task(self._retry_after(retry.issue_id, int(delay_seconds * 1000))) - - def _persist_state_locked(self) -> None: - path = self._state_path() - payload = { - "version": 1, - "updated_at": isoformat_z(now_utc()), - "service": { - "last_poll_completed_at": isoformat_z(self.state.last_poll_completed_at), - "last_poll_error": self.state.last_poll_error, - "last_candidate_count": self.state.last_candidate_count, - }, - "codex_totals": self.state.codex_totals.to_dict(), - "rate_limits": self.state.codex_rate_limits, - "completed": [ - _completed_entry_to_dict(entry) - for entry in sorted(self.state.completed.values(), key=lambda item: item.completed_at, reverse=True) - ], - "blocked": [ - _blocked_entry_to_dict(entry) - for entry in sorted(self.state.blocked.values(), key=lambda item: item.blocked_at, reverse=True) - ], - "retry_attempts": [ - _retry_entry_to_dict(entry) - for entry in sorted(self.state.retry_attempts.values(), key=lambda item: item.due_at_wall) - ], - } - try: - path.parent.mkdir(parents=True, exist_ok=True) - tmp_path = path.with_name(f"{path.name}.tmp") - tmp_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") - tmp_path.replace(path) - except OSError as exc: - log_event(LOGGER, logging.WARNING, "state_persist_failed", state_path=path, reason=exc) - - def sort_for_dispatch(self, issues: list[Issue]) -> list[Issue]: - return sorted( - issues, - key=lambda issue: ( - issue.priority if issue.priority is not None else 999999, - issue.created_at or now_utc(), - issue.identifier, - ), - ) - - def available_global_slots_locked(self, config: ServiceConfig) -> int: - return max(config.agent.max_concurrent_agents - len(self.state.running), 0) - - def is_dispatch_eligible_locked( - self, - issue: Issue, - config: ServiceConfig, - *, - ignore_claimed_issue_id: str | None = None, - ) -> bool: - if not issue.id or not issue.identifier or not issue.title or not issue.state: - return False - state = normalize_state(issue.state) - if state not in config.tracker.active_state_set or state in config.tracker.terminal_state_set: - return False - if not self._has_required_labels(issue, config): - return False - if issue.id in self.state.running: - return False - if issue.id in self.state.blocked: - return False - if issue.id in self.state.claimed and issue.id != ignore_claimed_issue_id: - return False - if self.available_global_slots_locked(config) <= 0: - return False - state_limit = config.agent.max_concurrent_agents_by_state.get(state, config.agent.max_concurrent_agents) - state_running = sum(1 for entry in self.state.running.values() if normalize_state(entry.issue.state) == state) - if state_running >= state_limit: - return False - if state == "todo": - for blocker in issue.blocked_by: - if normalize_state(blocker.state) not in config.tracker.terminal_state_set: - return False - return True - - def _has_required_labels(self, issue: Issue, config: ServiceConfig) -> bool: - required = config.tracker.required_label_set - if not required: - return True - issue_labels = {label.lower() for label in issue.labels} - return required.issubset(issue_labels) - - async def _cleanup_workspace(self, issue: Issue) -> None: - _, config = self.config_manager.current() - await WorkspaceManager(config.workspace, config.hooks).remove_for_identifier(issue.identifier) - - def _next_attempt(self, attempt: int | None) -> int: - return 1 if attempt is None else attempt + 1 - - async def snapshot(self) -> dict[str, Any]: - generated_at = now_utc() - async with self._lock: - running = [] - active_runtime = 0.0 - for entry in self.state.running.values(): - active_runtime += time.monotonic() - entry.started_monotonic - attention_override = _attention_override(entry) - repo_deviation_reason = "; ".join(entry.repo_deviations[-3:]) if entry.repo_deviations else None - needs_human = entry.summary_needs_human or attention_override is not None - human_reason = entry.summary_human_reason or attention_override - if repo_deviation_reason: - needs_human = True - human_reason = repo_deviation_reason - risk = "high" if attention_override or repo_deviation_reason else (entry.summary_risk or "unknown") - running.append( - { - "issue_id": entry.issue.id, - "issue_identifier": entry.issue.identifier, - "state": entry.issue.state, - "session_id": entry.session_id, - "turn_count": entry.turn_count, - "last_event": entry.last_codex_event, - "last_message": entry.last_codex_message, - "started_at": isoformat_z(entry.started_at), - "last_event_at": isoformat_z(entry.last_codex_timestamp), - "elapsed_seconds": max(time.monotonic() - entry.started_monotonic, 0), - "title": entry.issue.title, - "url": entry.issue.url, - "priority": entry.issue.priority, - "labels": list(entry.issue.labels), - "updated_at": isoformat_z(entry.issue.updated_at), - "workspace": {"path": str(entry.workspace_path) if entry.workspace_path else None}, - "repo_plan": entry.repo_plan.to_dict() if entry.repo_plan else None, - "repo_deviations": list(entry.repo_deviations), - "tokens": { - "input_tokens": entry.codex_input_tokens, - "output_tokens": entry.codex_output_tokens, - "total_tokens": entry.codex_total_tokens, - }, - "summary": { - "text": entry.summary_text, - "current_step": entry.summary_current_step, - "needs_human": needs_human, - "human_reason": human_reason, - "risk": risk, - "confidence": entry.summary_confidence, - "updated_at": isoformat_z(entry.summary_updated_at), - "pending": entry.summary_pending, - "stale": entry.activity_revision > entry.summary_revision, - "error": entry.summary_error, - "source": entry.summary_source, - }, - "activity": list(entry.recent_activity[-12:]), - } - ) - retrying = [ - { - "issue_id": retry.issue_id, - "issue_identifier": retry.identifier, - "kind": "continuation" if retry.error is None else "retry", - "status": "continuing" if retry.error is None else "retrying", - "attempt": retry.attempt, - "due_at": isoformat_z(retry.due_at_wall), - "due_in_seconds": max(retry.due_at_monotonic - time.monotonic(), 0), - "error": retry.error, - } - for retry in self.state.retry_attempts.values() - ] - continuing_count = sum(1 for retry in retrying if retry["kind"] == "continuation") - retrying_count = len(retrying) - continuing_count - blocked = [ - { - "issue_id": blocked.issue.id, - "issue_identifier": blocked.issue.identifier, - "title": blocked.issue.title, - "url": blocked.issue.url, - "state": blocked.issue.state, - "labels": list(blocked.issue.labels), - "blocked_at": isoformat_z(blocked.blocked_at), - "reason": blocked.reason, - "workspace": {"path": str(blocked.workspace_path) if blocked.workspace_path else None}, - "repo_plan": blocked.repo_plan.to_dict() if blocked.repo_plan else None, - } - for blocked in self.state.blocked.values() - ] - completed = [ - { - "issue_id": completed.issue.id, - "issue_identifier": completed.issue.identifier, - "title": completed.issue.title, - "url": completed.issue.url, - "state": completed.issue.state, - "labels": list(completed.issue.labels), - "completed_at": isoformat_z(completed.completed_at), - "reason": completed.reason, - "workspace": {"path": str(completed.workspace_path) if completed.workspace_path else None}, - "repo_plan": completed.repo_plan.to_dict() if completed.repo_plan else None, - "repo_deviations": list(completed.repo_deviations), - "duration_seconds": completed.duration_seconds, - "turn_count": completed.turn_count, - "session_id": completed.session_id, - "thread_id": completed.thread_id, - "turn_id": completed.turn_id, - "tokens": { - "input_tokens": completed.codex_input_tokens, - "output_tokens": completed.codex_output_tokens, - "total_tokens": completed.codex_total_tokens, - }, - "summary": { - "text": completed.summary_text, - "current_step": completed.summary_current_step, - "needs_human": completed.summary_needs_human, - "human_reason": completed.summary_human_reason, - "risk": completed.summary_risk, - "confidence": completed.summary_confidence, - "updated_at": isoformat_z(completed.summary_updated_at), - }, - "activity": list(completed.recent_activity[-12:]), - } - for completed in sorted(self.state.completed.values(), key=lambda item: item.completed_at, reverse=True) - ] - totals = self.state.codex_totals.to_dict() - totals["seconds_running"] += active_runtime - return { - "generated_at": isoformat_z(generated_at), - "service": { - "status": self.state.service_status, - "startup_completed_at": isoformat_z(self.state.startup_completed_at), - "last_poll_started_at": isoformat_z(self.state.last_poll_started_at), - "last_poll_completed_at": isoformat_z(self.state.last_poll_completed_at), - "last_poll_error": self.state.last_poll_error, - "last_candidate_count": self.state.last_candidate_count, - "poll_interval_ms": self.state.poll_interval_ms, - "max_concurrent_agents": self.state.max_concurrent_agents, - }, - "counts": { - "running": len(running), - "continuing": continuing_count, - "retrying": retrying_count, - "queued": len(retrying), - "blocked": len(blocked), - "completed": len(completed), - }, - "running": running, - "retrying": retrying, - "blocked": blocked, - "completed": completed, - "codex_totals": totals, - "rate_limits": self.state.codex_rate_limits, - } - - async def issue_snapshot(self, issue_identifier: str) -> dict[str, Any] | None: - state = await self.snapshot() - for running in state["running"]: - if running["issue_identifier"] == issue_identifier: - return { - "issue_identifier": issue_identifier, - "issue_id": running["issue_id"], - "status": "running", - "workspace": running.get("workspace"), - "attempts": {"restart_count": None, "current_retry_attempt": None}, - "running": running, - "retry": None, - "logs": {"codex_session_logs": []}, - "recent_events": [], - "last_error": None, - "tracked": {}, - } - for retry in state["retrying"]: - if retry["issue_identifier"] == issue_identifier: - return { - "issue_identifier": issue_identifier, - "issue_id": retry["issue_id"], - "status": retry.get("status") or "retrying", - "workspace": {"path": None}, - "attempts": {"restart_count": None, "current_retry_attempt": retry["attempt"]}, - "running": None, - "retry": retry, - "logs": {"codex_session_logs": []}, - "recent_events": [], - "last_error": retry.get("error"), - "tracked": {}, - } - for blocked in state.get("blocked", []): - if blocked["issue_identifier"] == issue_identifier: - return { - "issue_identifier": issue_identifier, - "issue_id": blocked["issue_id"], - "status": "blocked", - "workspace": blocked.get("workspace"), - "attempts": {"restart_count": None, "current_retry_attempt": None}, - "running": None, - "retry": None, - "blocked": blocked, - "logs": {"codex_session_logs": []}, - "recent_events": [], - "last_error": blocked.get("reason"), - "tracked": {}, - } - for completed in state.get("completed", []): - if completed["issue_identifier"] == issue_identifier: - return { - "issue_identifier": issue_identifier, - "issue_id": completed["issue_id"], - "status": "completed", - "workspace": completed.get("workspace"), - "attempts": {"restart_count": None, "current_retry_attempt": None}, - "running": None, - "retry": None, - "completed": completed, - "logs": {"codex_session_logs": []}, - "recent_events": completed.get("activity", []), - "last_error": None, - "tracked": {}, - } - return None - - -def _dedupe_by_id(issues: list[Issue]) -> list[Issue]: - seen: set[str] = set() - deduped: list[Issue] = [] - for issue in issues: - if issue.id in seen: - continue - seen.add(issue.id) - deduped.append(issue) - return deduped - - -def _completed_entry_from_running(entry: RunningEntry, *, reason: str, elapsed: float) -> CompletedEntry: - return CompletedEntry( - issue=entry.issue, - completed_at=now_utc(), - reason=reason, - workspace_path=entry.workspace_path, - repo_plan=entry.repo_plan, - duration_seconds=max(elapsed, 0), - turn_count=entry.turn_count, - session_id=entry.session_id, - thread_id=entry.thread_id, - turn_id=entry.turn_id, - codex_input_tokens=entry.codex_input_tokens, - codex_output_tokens=entry.codex_output_tokens, - codex_total_tokens=entry.codex_total_tokens, - summary_text=entry.summary_text, - summary_current_step=entry.summary_current_step, - summary_needs_human=entry.summary_needs_human, - summary_human_reason=entry.summary_human_reason, - summary_risk=entry.summary_risk, - summary_confidence=entry.summary_confidence, - summary_updated_at=entry.summary_updated_at, - repo_deviations=list(entry.repo_deviations), - recent_activity=list(entry.recent_activity[-100:]), - ) - - -def _codex_totals_from_dict(value: Any) -> CodexTotals: - if not isinstance(value, dict): - return CodexTotals() - return CodexTotals( - input_tokens=_to_int(value.get("input_tokens")) or 0, - output_tokens=_to_int(value.get("output_tokens")) or 0, - total_tokens=_to_int(value.get("total_tokens")) or 0, - seconds_running=float(value.get("seconds_running") or 0), - ) - - -def _retry_entry_to_dict(entry: RetryEntry) -> dict[str, Any]: - return { - "issue_id": entry.issue_id, - "issue_identifier": entry.identifier, - "attempt": entry.attempt, - "due_at": isoformat_z(entry.due_at_wall), - "error": entry.error, - } - - -def _retry_entry_from_dict(value: dict[str, Any]) -> RetryEntry | None: - issue_id = str(value.get("issue_id") or "").strip() - identifier = str(value.get("issue_identifier") or value.get("identifier") or "").strip() - due_at_wall = parse_datetime(value.get("due_at") or value.get("due_at_wall")) - if not issue_id or not identifier or due_at_wall is None: - return None - delay_seconds = max((due_at_wall - now_utc()).total_seconds(), 0) - return RetryEntry( - issue_id=issue_id, - identifier=identifier, - attempt=_to_int(value.get("attempt")) or 1, - due_at_monotonic=time.monotonic() + delay_seconds, - due_at_wall=due_at_wall, - error=str(value["error"]) if value.get("error") is not None else None, - timer_handle=None, - ) - - -def _blocked_entry_to_dict(entry: BlockedEntry) -> dict[str, Any]: - return { - "issue": entry.issue.to_template_data(), - "reason": entry.reason, - "blocked_at": isoformat_z(entry.blocked_at), - "workspace_path": str(entry.workspace_path) if entry.workspace_path else None, - "repo_plan": entry.repo_plan.to_dict() if entry.repo_plan else None, - } - - -def _blocked_entry_from_dict(value: dict[str, Any]) -> BlockedEntry | None: - issue = _issue_from_dict(value.get("issue")) - blocked_at = parse_datetime(value.get("blocked_at")) - if issue is None or blocked_at is None: - return None - repo_plan_raw = value.get("repo_plan") - return BlockedEntry( - issue=issue, - reason=str(value.get("reason") or ""), - blocked_at=blocked_at, - workspace_path=Path(str(value["workspace_path"])) if value.get("workspace_path") else None, - repo_plan=_repo_plan_from_dict(repo_plan_raw) if isinstance(repo_plan_raw, dict) else None, - ) - - -def _completed_entry_to_dict(entry: CompletedEntry) -> dict[str, Any]: - return { - "issue": entry.issue.to_template_data(), - "completed_at": isoformat_z(entry.completed_at), - "reason": entry.reason, - "workspace_path": str(entry.workspace_path) if entry.workspace_path else None, - "repo_plan": entry.repo_plan.to_dict() if entry.repo_plan else None, - "duration_seconds": entry.duration_seconds, - "turn_count": entry.turn_count, - "session_id": entry.session_id, - "thread_id": entry.thread_id, - "turn_id": entry.turn_id, - "tokens": { - "input_tokens": entry.codex_input_tokens, - "output_tokens": entry.codex_output_tokens, - "total_tokens": entry.codex_total_tokens, - }, - "summary": { - "text": entry.summary_text, - "current_step": entry.summary_current_step, - "needs_human": entry.summary_needs_human, - "human_reason": entry.summary_human_reason, - "risk": entry.summary_risk, - "confidence": entry.summary_confidence, - "updated_at": isoformat_z(entry.summary_updated_at), - }, - "repo_deviations": list(entry.repo_deviations), - "recent_activity": list(entry.recent_activity[-100:]), - } - - -def _completed_entry_from_dict(value: dict[str, Any]) -> CompletedEntry | None: - issue = _issue_from_dict(value.get("issue")) - completed_at = parse_datetime(value.get("completed_at")) - if issue is None or completed_at is None: - return None - tokens = value.get("tokens") if isinstance(value.get("tokens"), dict) else {} - summary = value.get("summary") if isinstance(value.get("summary"), dict) else {} - repo_plan_raw = value.get("repo_plan") - activity = value.get("recent_activity") if isinstance(value.get("recent_activity"), list) else [] - repo_deviations = value.get("repo_deviations") if isinstance(value.get("repo_deviations"), list) else [] - return CompletedEntry( - issue=issue, - completed_at=completed_at, - reason=str(value.get("reason") or ""), - workspace_path=Path(str(value["workspace_path"])) if value.get("workspace_path") else None, - repo_plan=_repo_plan_from_dict(repo_plan_raw) if isinstance(repo_plan_raw, dict) else None, - duration_seconds=float(value.get("duration_seconds") or 0), - turn_count=_to_int(value.get("turn_count")) or 0, - session_id=str(value["session_id"]) if value.get("session_id") is not None else None, - thread_id=str(value["thread_id"]) if value.get("thread_id") is not None else None, - turn_id=str(value["turn_id"]) if value.get("turn_id") is not None else None, - codex_input_tokens=_to_int(tokens.get("input_tokens")) or 0, - codex_output_tokens=_to_int(tokens.get("output_tokens")) or 0, - codex_total_tokens=_to_int(tokens.get("total_tokens")) or 0, - summary_text=str(summary["text"]) if summary.get("text") is not None else None, - summary_current_step=str(summary["current_step"]) if summary.get("current_step") is not None else None, - summary_needs_human=bool(summary.get("needs_human")), - summary_human_reason=str(summary["human_reason"]) if summary.get("human_reason") is not None else None, - summary_risk=str(summary["risk"]) if summary.get("risk") is not None else None, - summary_confidence=_float_or_none(summary.get("confidence")), - summary_updated_at=parse_datetime(summary.get("updated_at")), - repo_deviations=[str(item) for item in repo_deviations], - recent_activity=[item for item in activity if isinstance(item, dict)], - ) - - -def _issue_from_dict(value: Any) -> Issue | None: - if not isinstance(value, dict): - return None - issue_id = str(value.get("id") or "").strip() - identifier = str(value.get("identifier") or "").strip() - title = str(value.get("title") or "").strip() - if not issue_id or not identifier: - return None - blockers = [] - for raw in value.get("blocked_by") or []: - if isinstance(raw, dict): - blockers.append( - BlockerRef( - id=str(raw["id"]) if raw.get("id") is not None else None, - identifier=str(raw["identifier"]) if raw.get("identifier") is not None else None, - state=str(raw["state"]) if raw.get("state") is not None else None, - ) - ) - attachments = [] - for raw in value.get("attachments") or []: - if isinstance(raw, dict): - attachments.append( - IssueAttachment( - id=str(raw["id"]) if raw.get("id") is not None else None, - title=str(raw["title"]) if raw.get("title") is not None else None, - subtitle=str(raw["subtitle"]) if raw.get("subtitle") is not None else None, - url=str(raw["url"]) if raw.get("url") is not None else None, - ) - ) - return Issue( - id=issue_id, - identifier=identifier, - title=title, - description=str(value["description"]) if value.get("description") is not None else None, - priority=_to_int(value.get("priority")), - state=str(value.get("state") or ""), - branch_name=str(value["branch_name"]) if value.get("branch_name") is not None else None, - url=str(value["url"]) if value.get("url") is not None else None, - labels=[str(label) for label in value.get("labels") or []], - attachments=attachments, - blocked_by=blockers, - created_at=parse_datetime(value.get("created_at")), - updated_at=parse_datetime(value.get("updated_at")), - ) - - -def now_utc_from_monotonic_delay(delay_ms: int): - from datetime import timedelta - - return now_utc() + timedelta(milliseconds=delay_ms) - - -def _to_int(value: Any) -> int | None: - if isinstance(value, bool) or value is None: - return None - try: - return int(value) - except (TypeError, ValueError): - return None - - -def _activity_message(event: dict[str, Any]) -> str | None: - name = str(event.get("event") or "") - if name in { - "thread_tokenUsage_updated", - "account_rateLimits_updated", - "account_rateLimitsUpdated", - "mcpServer_startupStatus_updated", - "thread_started", - "thread_status_changed", - "item_commandExecution_outputDelta", - }: - return None - if name == "coding_context_classified": - injected = event.get("coding_context_injected") - source = event.get("classification_source") - reason = event.get("classification_reason") - return f"Coding context classified: injected={injected}, source={source}, reason={reason or 'none'}." - if name == "repo_plan_created": - plan = event.get("repo_plan") if isinstance(event.get("repo_plan"), dict) else {} - primary = (plan.get("primary_repo") or {}).get("slug") if isinstance(plan.get("primary_repo"), dict) else None - secondary = plan.get("secondary_repos") if isinstance(plan.get("secondary_repos"), list) else [] - read_only = plan.get("read_only_context_repos") if isinstance(plan.get("read_only_context_repos"), list) else [] - return ( - f"Repo plan created: primary={primary or 'none'}, secondary={len(secondary)}, " - f"read_only_context={len(read_only)}, needs_human={bool(plan.get('needs_human'))}." - ) - if name == "repo_workspace_prepared": - primary_path = event.get("primary_repo_path") - return f"Repo workspace prepared: primary_path={primary_path or 'none'}." - if name == "session_started": - return f"Started Codex turn {event.get('turn_id') or ''}." - if name == "approval_auto_approved": - payload = event.get("payload") if isinstance(event.get("payload"), dict) else {} - return f"Auto-approved Codex request: {payload.get('command') or event.get('method') or 'approval'}." - if name == "turn_input_required": - return "The agent requested user input." - - payload = event.get("payload") if isinstance(event.get("payload"), dict) else {} - item = payload.get("item") if isinstance(payload.get("item"), dict) else {} - item_type = item.get("type") - if item_type in {"reasoning", "userMessage"}: - return None - if item_type == "agentMessage": - text = str(item.get("text") or event.get("message") or "").strip() - return f"Agent said: {text}" if text else None - if item_type == "commandExecution": - command = str(item.get("command") or "").strip() - status = str(item.get("status") or "unknown").strip() - return f"Command {status}: {command}" if command else f"Command status: {status}" - if item_type == "fileChange": - path = item.get("path") or item.get("filePath") or item.get("file") - status = item.get("status") or "updated" - return f"File change {status}: {path}" if path else f"File change {status}." - - message = str(event.get("message") or "").strip() - if not message: - return None - if name == "item_agentMessage_delta" and (len(message) < 40 or message in {".", ",", ":", ";"}): - return None - if name in {"item_started", "item_completed"} and message.startswith("item_type="): - return None - if name == "turn_completed": - return f"Turn completed: {message}." - if name.startswith("turn_"): - return f"{name}: {message}" - if name.startswith("item_"): - return f"{name}: {message}" - return message - - -def _has_work_signal(activity: list[dict[str, Any]]) -> bool: - for item in activity: - message = str(item.get("message") or "") - if message.startswith(("Agent said:", "Command ", "File change ", "The agent requested user input")): - return True - return False - - -def _attention_override(entry: RunningEntry) -> str | None: - issue_text = f"{entry.issue.title}\n{entry.issue.description or ''}".lower() - activity_text = "\n".join(str(item.get("message") or "") for item in entry.recent_activity[-20:]).lower() - product_runtime_words = ( - "screen capture", - "screenshot", - "slides", - "call", - "live", - "overlay", - "proactive", - "suggest", - "answer", - "browser extension", - "electron", - "integration", - ) - infra_config_words = ( - "infrastructure/", - "terraform", - ".tf", - "model-gateway", - "gateway", - "schemas/functions", - "system_template.minijinja", - "user_template.minijinja", - ) - if any(word in issue_text for word in product_runtime_words) and any(word in activity_text for word in infra_config_words): - return ( - "Recent activity is focused on infrastructure/gateway/config files while the issue reads like product, runtime, " - "or integration work. Check the repo boundary before letting this continue." - ) - if "command failed:" in activity_text: - failed_count = activity_text.count("command failed:") - if failed_count >= 3: - return "Several recent commands failed; the agent may be stuck or looking in the wrong place." - return None - - -def _repo_plan_from_dict(data: dict[str, Any]) -> RepoPlan: - def item(raw: Any) -> RepoPlanItem | None: - if not isinstance(raw, dict): - return None - slug = str(raw.get("slug") or "").strip() - if not slug: - return None - return RepoPlanItem( - slug=slug, - role=str(raw.get("role") or ""), - reason=str(raw.get("reason")) if raw.get("reason") is not None else None, - path_name=str(raw.get("path_name")) if raw.get("path_name") is not None else None, - edit_allowed=bool(raw.get("edit_allowed", True)), - ) - - primary = item(data.get("primary_repo")) - return RepoPlan( - issue_identifier=str(data.get("issue_identifier") or ""), - coding_task=bool(data.get("coding_task")), - planner=str(data.get("planner") or ""), - source=str(data.get("source") or ""), - primary_repo=primary, - secondary_repos=[parsed for raw in data.get("secondary_repos") or [] if (parsed := item(raw)) is not None], - read_only_context_repos=[ - parsed for raw in data.get("read_only_context_repos") or [] if (parsed := item(raw)) is not None - ], - confidence=_float_or_none(data.get("confidence")), - needs_human=bool(data.get("needs_human")), - human_reason=str(data.get("human_reason")) if data.get("human_reason") is not None else None, - notes=str(data.get("notes")) if data.get("notes") is not None else None, - created_at=now_utc(), - ) - - -def _repo_deviation_from_event(entry: RunningEntry, event: dict[str, Any]) -> str | None: - if entry.repo_plan is None or entry.workspace_path is None: - return None - path = _file_change_path(event) - if path is None: - return None - repo_slug = _repo_slug_for_path(entry, path) - if repo_slug is None: - return f"File change is outside the approved repo plan: {path}" - if repo_slug not in entry.repo_plan.edit_allowed_slugs(): - return f"File change is in an unapproved or read-only repo ({repo_slug}): {path}" - return None - - -def _file_change_path(event: dict[str, Any]) -> str | None: - payload = event.get("payload") if isinstance(event.get("payload"), dict) else {} - item = payload.get("item") if isinstance(payload.get("item"), dict) else {} - if item.get("type") != "fileChange": - return None - raw = item.get("path") or item.get("filePath") or item.get("file") - return str(raw) if raw else None - - -def _repo_slug_for_path(entry: RunningEntry, raw_path: str) -> str | None: - workspace_path = entry.workspace_path - if workspace_path is None or entry.repo_plan is None: - return None - try: - path = Path(raw_path) - if not path.is_absolute(): - path = workspace_path / path - relative = path.resolve(strict=False).relative_to(workspace_path.resolve(strict=False)) - except (OSError, ValueError): - return None - parts = relative.parts - if len(parts) < 2 or parts[0] != "repos": - return None - repo_dir = parts[1] - for repo in entry.repo_plan.all_repos(): - if repo.path_name == repo_dir: - return repo.slug - return None - - -def _float_or_none(value: Any) -> float | None: - if isinstance(value, bool) or value is None: - return None - try: - return float(value) - except (TypeError, ValueError): - return None diff --git a/symphony/repo_planner.py b/symphony/repo_planner.py deleted file mode 100644 index 3443e1f..0000000 --- a/symphony/repo_planner.py +++ /dev/null @@ -1,365 +0,0 @@ -from __future__ import annotations - -import json -from dataclasses import replace -from pathlib import Path -from typing import Any - -from .codex_client import CodexAppServerSession -from .coding_context import CodingClassification, load_coding_context -from .config import CodexConfig, CodingContextConfig, RepositoryConfig, RepositoryPlanningConfig -from .models import Issue, RepoPlan, RepoPlanItem -from .utils import now_utc, sanitize_workspace_key, truncate - - -async def plan_repositories( - issue: Issue, - config: RepositoryPlanningConfig, - coding_config: CodingContextConfig, - classification: CodingClassification, - *, - codex_config: CodexConfig, - workspace_path: Path, -) -> RepoPlan | None: - if not config.enabled: - return None - if not classification.is_coding_task: - return RepoPlan( - issue_identifier=issue.identifier, - coding_task=False, - planner=config.planner, - source=classification.source, - confidence=classification.confidence, - notes="Issue was not classified as a coding task.", - created_at=now_utc(), - ) - if config.planner == "llm": - try: - return await _plan_with_llm(issue, config, coding_config, codex_config, workspace_path) - except Exception as exc: - if config.fallback == "block": - return RepoPlan( - issue_identifier=issue.identifier, - coding_task=True, - planner=config.planner, - source="fallback:block", - needs_human=True, - human_reason=f"Repository planner failed and fallback is block: {truncate(str(exc), 300)}", - created_at=now_utc(), - ) - return _plan_with_rules(issue, config, source="rules" if config.planner == "rules" else "fallback:rules") - - -def apply_repo_plan_to_prompt(prompt: str, repo_plan: RepoPlan | None, workspace_path: Path) -> str: - if repo_plan is None or not repo_plan.coding_task: - return prompt - plan_json = json.dumps(repo_plan.to_dict(), sort_keys=True, default=str, indent=2) - git_metadata = _workspace_git_metadata(workspace_path) - git_guardrail = ( - "Git hygiene guardrail: commit and push only the expected branch recorded for each repo in " - "`.symphony-workspace.json`. Never push an inherited source checkout branch. If " - "`git branch --show-current` differs from the repo's expected branch, stop and report the mismatch. " - "The workspace-local pre-push hook rejects pushes to any other branch.\n" - ) - if git_metadata: - git_guardrail += f"Prepared git branches:\n{json.dumps(git_metadata, sort_keys=True, default=str, indent=2)}\n" - return ( - "\n" - "Symphony prepared an explicit repository plan for this issue. Treat it as a guardrail, not as proof the " - "implementation is already understood.\n\n" - f"Workspace root: {workspace_path}\n" - "Repositories are checked out under `repos/` inside the workspace root.\n" - "Start by inspecting the primary repo. You may read secondary and read-only context repos as needed. " - "Only edit the primary repo and secondary repos whose `edit_allowed` value is true. Do not edit " - "read-only context repos. If the current Linear issue text proves the repo plan is wrong or incomplete, " - "stop and report that instead of patching an unapproved repo.\n\n" - f"{git_guardrail}\n" - f"{plan_json}\n" - "\n\n" - f"{prompt}" - ) - - -def _workspace_git_metadata(workspace_path: Path) -> list[dict[str, Any]]: - metadata_path = workspace_path / ".symphony-workspace.json" - if not metadata_path.exists(): - return [] - try: - payload = json.loads(metadata_path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - return [] - repositories = payload.get("repositories") - if not isinstance(repositories, list): - return [] - result: list[dict[str, Any]] = [] - for item in repositories: - if not isinstance(item, dict): - continue - git = item.get("git") - if not isinstance(git, dict): - continue - result.append( - { - "slug": item.get("slug"), - "path": item.get("path"), - "edit_allowed": item.get("edit_allowed"), - "expected_branch": git.get("expected_branch"), - "expected_ref": git.get("expected_ref"), - "base_ref": git.get("base_ref"), - } - ) - return result - - -async def _plan_with_llm( - issue: Issue, - config: RepositoryPlanningConfig, - coding_config: CodingContextConfig, - codex_config: CodexConfig, - workspace_path: Path, -) -> RepoPlan: - planner_codex_config = replace( - codex_config, - model=config.plan_model or codex_config.model, - effort=config.plan_effort, - turn_timeout_ms=config.plan_timeout_ms, - summary=None, - personality=None, - ) - - async def ignore_event(_: dict[str, Any]) -> None: - return None - - async with CodexAppServerSession( - planner_codex_config, - workspace_path, - tracker_config=None, - on_event=ignore_event, - ) as session: - result = await session.run_turn(_planner_prompt(issue, config, coding_config), capture_agent_text=True) - data = _parse_json_object(result.agent_message_text) - return _normalize_plan(issue, data, config, planner="llm", source="llm") - - -def _planner_prompt(issue: Issue, config: RepositoryPlanningConfig, coding_config: CodingContextConfig) -> str: - payload = { - "issue": issue.to_template_data(), - "repositories": [repo.to_prompt_data() for repo in config.repositories], - "coding_context": truncate(load_coding_context(coding_config), 30000) if coding_config.enabled else "", - } - return ( - "You are Symphony's repository planner for a background coding agent.\n" - "Pick the repository set the agent is allowed to use for this Linear issue. Many valid tasks span multiple " - "repos, so return a primary repo plus optional secondary/edit repos and read-only context repos. Prefer the " - "runtime/product repo that owns the behavior as primary. Do not choose a gateway/config repo merely because " - "the issue mentions AI, search, prompts, or suggestions if the runtime/UI/provider adapter lives elsewhere. " - "If the issue requests a specific provider/integration, keep that provider direction in the reason.\n\n" - "Return only one JSON object, no markdown, no prose, and no tool calls.\n" - "Schema:\n" - "{\n" - ' "coding_task": true,\n' - ' "primary_repo": {"slug": "owner/name", "reason": "why this repo is the start"},\n' - ' "secondary_repos": [{"slug": "owner/name", "reason": "why it may need edits", "edit_allowed": true}],\n' - ' "read_only_context_repos": [{"slug": "owner/name", "reason": "why it is useful context"}],\n' - ' "confidence": 0.0,\n' - ' "needs_human": false,\n' - ' "human_reason": null,\n' - ' "notes": "short operational note"\n' - "}\n\n" - "Rules:\n" - "- Use only repository slugs listed in the input catalog.\n" - "- If there is no clear primary repo, set needs_human=true and explain.\n" - "- If a secondary repo might need edits, include it as secondary_repos with edit_allowed=true.\n" - "- If a repo is only background material, include it as read_only_context_repos.\n" - "- If the issue is not a coding/repository task, set coding_task=false and leave repo lists empty.\n\n" - f"Planner input JSON:\n{json.dumps(payload, sort_keys=True, default=str)}" - ) - - -def _plan_with_rules(issue: Issue, config: RepositoryPlanningConfig, *, source: str) -> RepoPlan: - text = f"{issue.identifier}\n{issue.title}\n{issue.description or ''}\n{' '.join(issue.labels)}".lower() - scored: list[tuple[int, RepositoryConfig, list[str]]] = [] - for repo in config.repositories: - reasons: list[str] = [] - score = 0 - candidates = {repo.slug.lower(), repo.path_name.lower(), *(alias.lower() for alias in repo.aliases)} - for candidate in sorted(candidate for candidate in candidates if candidate): - if candidate in text: - score += 4 if candidate in {repo.slug.lower(), repo.path_name.lower()} else 2 - reasons.append(candidate) - if repo.description: - for word in _keyword_terms(repo.description): - if word in text: - score += 1 - reasons.append(word) - if score > 0: - scored.append((score, repo, reasons[:6])) - scored.sort(key=lambda row: (-row[0], row[1].slug)) - if not scored: - return RepoPlan( - issue_identifier=issue.identifier, - coding_task=True, - planner=config.planner, - source=source, - needs_human=True, - human_reason="No configured repository matched the issue text.", - confidence=0.0, - created_at=now_utc(), - ) - top_score, top_repo, top_reasons = scored[0] - tied = [repo.slug for score, repo, _ in scored if score == top_score] - needs_human = len(tied) > 1 - primary = _item(top_repo, "primary", f"Rules matched: {', '.join(top_reasons)}") - secondary = [ - _item(repo, "secondary", f"Rules also matched: {', '.join(reasons)}") - for score, repo, reasons in scored[1:4] - if score > 0 - ] - return RepoPlan( - issue_identifier=issue.identifier, - coding_task=True, - planner=config.planner, - source=source, - primary_repo=primary, - secondary_repos=secondary, - confidence=min(0.85, max(0.2, top_score / 12)), - needs_human=needs_human, - human_reason=f"Rules planner found tied primary repositories: {', '.join(tied)}" if needs_human else None, - created_at=now_utc(), - ) - - -def _normalize_plan( - issue: Issue, - data: dict[str, Any], - config: RepositoryPlanningConfig, - *, - planner: str, - source: str, -) -> RepoPlan: - known = config.repository_by_slug - unknown: list[str] = [] - - def parse_item(raw: Any, role: str) -> RepoPlanItem | None: - if not isinstance(raw, dict): - return None - slug = str(raw.get("slug") or "").strip() - if not slug: - return None - repo = known.get(slug) - if repo is None: - unknown.append(slug) - return None - edit_allowed = bool(raw.get("edit_allowed", role != "read_only_context")) and role != "read_only_context" - return _item(repo, role, truncate(str(raw.get("reason") or ""), 500) or None, edit_allowed=edit_allowed) - - coding_task = bool(data.get("coding_task", data.get("is_coding_task", True))) - primary = parse_item(data.get("primary_repo"), "primary") - secondary = _dedupe_items( - [ - item - for raw in _list_value(data.get("secondary_repos")) - if (item := parse_item(raw, "secondary")) is not None - ] - ) - read_only = _dedupe_items( - [ - item - for raw in _list_value(data.get("read_only_context_repos")) - if (item := parse_item(raw, "read_only_context")) is not None - ] - ) - if primary is not None: - secondary = [item for item in secondary if item.slug != primary.slug] - read_only = [item for item in read_only if item.slug != primary.slug] - confidence = _confidence(data.get("confidence")) - needs_human = bool(data.get("needs_human")) - human_reason = truncate(str(data.get("human_reason") or ""), 500) or None - if coding_task and primary is None: - needs_human = True - human_reason = human_reason or "Repository planner did not return a primary repo." - if unknown: - needs_human = True - suffix = f"Planner returned unknown repositories: {', '.join(sorted(set(unknown)))}." - human_reason = f"{human_reason} {suffix}".strip() if human_reason else suffix - return RepoPlan( - issue_identifier=issue.identifier, - coding_task=coding_task, - planner=planner, - source=source, - primary_repo=primary, - secondary_repos=secondary, - read_only_context_repos=read_only, - confidence=confidence, - needs_human=needs_human, - human_reason=human_reason, - notes=truncate(str(data.get("notes") or ""), 500) or None, - created_at=now_utc(), - ) - - -def _item(repo: RepositoryConfig, role: str, reason: str | None, *, edit_allowed: bool = True) -> RepoPlanItem: - if role == "read_only_context": - edit_allowed = False - return RepoPlanItem( - slug=repo.slug, - role=role, - reason=reason, - path_name=sanitize_workspace_key(repo.path_name), - edit_allowed=edit_allowed, - ) - - -def _dedupe_items(items: list[RepoPlanItem]) -> list[RepoPlanItem]: - seen: set[str] = set() - deduped: list[RepoPlanItem] = [] - for item in items: - if item.slug in seen: - continue - seen.add(item.slug) - deduped.append(item) - return deduped - - -def _list_value(value: Any) -> list[Any]: - return value if isinstance(value, list) else [] - - -def _confidence(value: Any) -> float | None: - if isinstance(value, bool) or value is None: - return None - try: - return max(0.0, min(1.0, float(value))) - except (TypeError, ValueError): - return None - - -def _parse_json_object(text: str) -> dict[str, Any]: - stripped = text.strip() - if not stripped: - raise ValueError("repo planner returned empty text") - try: - value = json.loads(stripped) - except json.JSONDecodeError: - value = json.loads(_extract_json_object(stripped)) - if not isinstance(value, dict): - raise ValueError("repo planner JSON is not an object") - return value - - -def _extract_json_object(text: str) -> str: - start = text.find("{") - end = text.rfind("}") - if start == -1 or end == -1 or end <= start: - raise ValueError("repo planner output did not contain a JSON object") - return text[start : end + 1] - - -def _keyword_terms(text: str) -> list[str]: - stop = {"the", "and", "for", "with", "from", "that", "this", "repo", "choose", "validation", "local", "path"} - terms: list[str] = [] - for raw in text.lower().replace("/", " ").replace("-", " ").split(): - word = "".join(ch for ch in raw if ch.isalnum()) - if len(word) >= 5 and word not in stop: - terms.append(word) - return terms[:40] diff --git a/symphony/review.py b/symphony/review.py deleted file mode 100644 index fdabeef..0000000 --- a/symphony/review.py +++ /dev/null @@ -1,349 +0,0 @@ -from __future__ import annotations - -import asyncio -from dataclasses import dataclass, field -import json -from pathlib import Path -import re -from typing import Any, Protocol - -from .models import Issue -from .utils import truncate - -GITHUB_PR_URL_RE = re.compile(r"https://github\.com/([^/\s]+)/([^/\s]+)/pull/(\d+)", re.IGNORECASE) -OWNER_REPO_PR_RE = re.compile(r"(? str: - return f"{self.owner}/{self.repo}" - - @property - def canonical(self) -> str: - return f"{self.repo_full_name}#{self.number}" - - -@dataclass(frozen=True, slots=True) -class PullRequestInfo: - ref: PullRequestRef - url: str - state: str - base_ref_name: str | None = None - merged_at: str | None = None - head_ref_name: str | None = None - body: str = "" - - -@dataclass(slots=True) -class ReviewMergeResult: - ready: bool - required_prs: list[PullRequestInfo] = field(default_factory=list) - unresolved_refs: list[str] = field(default_factory=list) - blockers: list[str] = field(default_factory=list) - - @property - def reason(self) -> str: - if self.ready: - return f"all {len(self.required_prs)} required PR(s) are merged" - if self.blockers: - return "; ".join(self.blockers) - if self.unresolved_refs: - return f"unresolved PR reference(s): {', '.join(self.unresolved_refs)}" - return "no required PRs were found" - - -class PullRequestInspector(Protocol): - async def view_pr_url(self, url: str) -> PullRequestInfo | None: - ... - - async def view_pr_ref(self, ref: PullRequestRef) -> PullRequestInfo | None: - ... - - async def list_prs_for_branch(self, repo_full_name: str, branch: str, base_branch: str) -> list[PullRequestInfo]: - ... - - -class GhPullRequestInspector: - async def view_pr_url(self, url: str) -> PullRequestInfo | None: - body = await self._run_gh_json( - "pr", - "view", - url, - "--json", - "number,url,state,mergedAt,baseRefName,headRefName,body", - ) - return _pr_info_from_payload(body) - - async def view_pr_ref(self, ref: PullRequestRef) -> PullRequestInfo | None: - body = await self._run_gh_json( - "pr", - "view", - str(ref.number), - "--repo", - ref.repo_full_name, - "--json", - "number,url,state,mergedAt,baseRefName,headRefName,body", - ) - return _pr_info_from_payload(body, fallback_ref=ref) - - async def list_prs_for_branch(self, repo_full_name: str, branch: str, base_branch: str) -> list[PullRequestInfo]: - body = await self._run_gh_json( - "pr", - "list", - "--repo", - repo_full_name, - "--head", - branch, - "--base", - base_branch, - "--state", - "all", - "--limit", - "20", - "--json", - "number,url,state,mergedAt,baseRefName,headRefName,body", - ) - if not isinstance(body, list): - return [] - prs = [_pr_info_from_payload(item) for item in body] - return [pr for pr in prs if pr is not None] - - async def _run_gh_json(self, *args: str) -> Any | None: - try: - proc = await asyncio.create_subprocess_exec( - "gh", - *args, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - except OSError: - return None - stdout, stderr = await proc.communicate() - if proc.returncode != 0: - return None - try: - return json.loads(stdout.decode("utf-8")) - except json.JSONDecodeError: - return None - - -class ReviewPullRequestResolver: - def __init__(self, inspector: PullRequestInspector | None = None): - self.inspector = inspector or GhPullRequestInspector() - - async def evaluate( - self, - issue: Issue, - *, - comments: list[dict[str, Any]], - workspace_path: Path, - base_branch: str, - ) -> ReviewMergeResult: - required: dict[str, PullRequestInfo] = {} - unresolved: set[str] = set() - queue: list[PullRequestInfo] = [] - - initial_urls = _dedupe_strings( - [ - *_issue_attachment_pr_urls(issue), - *_comment_pr_urls(comments), - ] - ) - for url in initial_urls: - pr = await self.inspector.view_pr_url(url) - if pr is None: - unresolved.add(url) - continue - if _add_required(required, pr): - queue.append(pr) - - metadata = _load_workspace_metadata(workspace_path) - for repo_full_name, branch in _workspace_branch_candidates(metadata, issue): - for pr in await self.inspector.list_prs_for_branch(repo_full_name, branch, base_branch): - if _add_required(required, pr): - queue.append(pr) - - while queue and len(required) + len(unresolved) < MAX_DEPENDENCY_PRS: - pr = queue.pop(0) - for ref in _dependency_refs(pr.body): - if ref.canonical in required: - continue - dependency = await self.inspector.view_pr_ref(ref) - if dependency is None: - unresolved.add(ref.canonical) - continue - if _add_required(required, dependency): - queue.append(dependency) - - blockers = _merge_blockers(required.values(), unresolved, base_branch) - return ReviewMergeResult( - ready=bool(required) and not blockers, - required_prs=sorted(required.values(), key=lambda pr: pr.ref.canonical), - unresolved_refs=sorted(unresolved), - blockers=blockers, - ) - - -def _issue_attachment_pr_urls(issue: Issue) -> list[str]: - urls: list[str] = [] - for attachment in issue.attachments: - for value in (attachment.url, attachment.title, attachment.subtitle): - urls.extend(_pr_urls(str(value or ""))) - return urls - - -def _comment_pr_urls(comments: list[dict[str, Any]]) -> list[str]: - urls: list[str] = [] - for comment in comments: - body = comment.get("body") or comment.get("text") or comment.get("content") or "" - if isinstance(body, str) and "## Codex Workpad" in body: - urls.extend(_pr_urls(body)) - return urls - - -def _pr_urls(text: str) -> list[str]: - return [ - f"https://github.com/{match.group(1)}/{match.group(2)}/pull/{match.group(3)}" - for match in GITHUB_PR_URL_RE.finditer(text) - ] - - -def _dependency_refs(text: str) -> list[PullRequestRef]: - refs: dict[str, PullRequestRef] = {} - for match in GITHUB_PR_URL_RE.finditer(text): - ref = PullRequestRef(owner=match.group(1), repo=match.group(2), number=int(match.group(3))) - refs[ref.canonical] = ref - for match in OWNER_REPO_PR_RE.finditer(text): - owner, repo = match.group(1).split("/", 1) - ref = PullRequestRef(owner=owner, repo=repo, number=int(match.group(2))) - refs[ref.canonical] = ref - return sorted(refs.values(), key=lambda item: item.canonical) - - -def _add_required(required: dict[str, PullRequestInfo], pr: PullRequestInfo) -> bool: - if pr.ref.canonical in required: - return False - required[pr.ref.canonical] = pr - return True - - -def _merge_blockers(prs: Any, unresolved: set[str], base_branch: str) -> list[str]: - blockers: list[str] = [] - if unresolved: - blockers.append(f"unresolved PR reference(s): {', '.join(sorted(unresolved))}") - prs = list(prs) - if not prs and not unresolved: - blockers.append("no required PRs were found") - for pr in prs: - if pr.base_ref_name != base_branch: - blockers.append(f"{pr.ref.canonical} targets {pr.base_ref_name or 'unknown'} instead of {base_branch}") - continue - if pr.state.upper() != "MERGED": - blockers.append(f"{pr.ref.canonical} is {pr.state or 'unknown'}") - return blockers - - -def _load_workspace_metadata(workspace_path: Path) -> dict[str, Any]: - path = workspace_path / WORKSPACE_METADATA - try: - body = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - return {} - return body if isinstance(body, dict) else {} - - -def _workspace_branch_candidates(metadata: dict[str, Any], issue: Issue) -> list[tuple[str, str]]: - candidates: list[tuple[str, str]] = [] - repos = metadata.get("repositories") - if isinstance(repos, list): - for repo in repos: - if not isinstance(repo, dict) or not repo.get("edit_allowed", True): - continue - repo_full_name = _repo_full_name(repo) - git = repo.get("git") if isinstance(repo.get("git"), dict) else {} - branch = git.get("expected_branch") or git.get("current_branch") or issue.branch_name - if repo_full_name and branch: - candidates.append((repo_full_name, str(branch))) - return _dedupe_pairs(candidates) - - -def _repo_full_name(repo: dict[str, Any]) -> str | None: - slug = repo.get("slug") - if isinstance(slug, str) and "/" in slug: - return slug - remote = repo.get("remote_url") - if not isinstance(remote, str): - git = repo.get("git") if isinstance(repo.get("git"), dict) else {} - remote = git.get("remote_url") if isinstance(git.get("remote_url"), str) else "" - return _repo_full_name_from_remote(remote) - - -def _repo_full_name_from_remote(remote: str) -> str | None: - text = remote.strip() - if text.startswith("git@github.com:"): - text = "https://github.com/" + text.removeprefix("git@github.com:") - match = re.search(r"github\.com[:/]([^/\s]+)/([^/\s]+?)(?:\.git)?/?$", text) - if not match: - return None - return f"{match.group(1)}/{match.group(2)}" - - -def _pr_info_from_payload(payload: Any, *, fallback_ref: PullRequestRef | None = None) -> PullRequestInfo | None: - if not isinstance(payload, dict): - return None - url = str(payload.get("url") or "") - ref = _ref_from_url(url) or fallback_ref - number = payload.get("number") - if ref is None and isinstance(number, int): - owner = payload.get("owner") - repo = payload.get("repo") - if isinstance(owner, str) and isinstance(repo, str): - ref = PullRequestRef(owner=owner, repo=repo, number=number) - if ref is None: - return None - return PullRequestInfo( - ref=ref, - url=url or f"https://github.com/{ref.owner}/{ref.repo}/pull/{ref.number}", - state=str(payload.get("state") or ""), - base_ref_name=str(payload["baseRefName"]) if payload.get("baseRefName") is not None else None, - merged_at=str(payload["mergedAt"]) if payload.get("mergedAt") is not None else None, - head_ref_name=str(payload["headRefName"]) if payload.get("headRefName") is not None else None, - body=truncate(str(payload.get("body") or ""), 50000), - ) - - -def _ref_from_url(url: str) -> PullRequestRef | None: - match = GITHUB_PR_URL_RE.search(url) - if not match: - return None - return PullRequestRef(owner=match.group(1), repo=match.group(2), number=int(match.group(3))) - - -def _dedupe_strings(values: list[str]) -> list[str]: - result: list[str] = [] - seen: set[str] = set() - for value in values: - if value in seen: - continue - seen.add(value) - result.append(value) - return result - - -def _dedupe_pairs(values: list[tuple[str, str]]) -> list[tuple[str, str]]: - result: list[tuple[str, str]] = [] - seen: set[tuple[str, str]] = set() - for value in values: - if value in seen: - continue - seen.add(value) - result.append(value) - return result diff --git a/symphony/templating.py b/symphony/templating.py deleted file mode 100644 index b37365b..0000000 --- a/symphony/templating.py +++ /dev/null @@ -1,55 +0,0 @@ -from __future__ import annotations - -from .errors import TemplateError -from .models import Issue - -DEFAULT_PROMPT = "You are working on an issue from Linear." - - -def render_prompt(template_text: str, issue: Issue, attempt: int | None = None) -> str: - source = template_text.strip() or DEFAULT_PROMPT - try: - from liquid import Environment, StrictUndefined - from liquid.exceptions import LiquidError - - env = Environment(undefined=StrictUndefined, strict_filters=True) - template = env.from_string(source) - rendered = template.render({"issue": issue.to_template_data(), "attempt": attempt}) - except ImportError as exc: - raise TemplateError("template_parse_error", "python-liquid is required for prompt rendering", cause=exc) from exc - except LiquidError as exc: - raise TemplateError("template_render_error", str(exc), cause=exc) from exc - except Exception as exc: - raise TemplateError("template_render_error", str(exc), cause=exc) from exc - return str(rendered).strip() - - -def _format_labels(labels: list[str]) -> str: - return ", ".join(labels) if labels else "(none)" - - -def _format_description(description: str | None) -> str: - if description and description.strip(): - return description.strip() - return "(none)" - - -def continuation_prompt(issue: Issue, turn_number: int, max_turns: int) -> str: - issue_data = issue.to_template_data() - return ( - "Continue working on the same Linear issue in this existing Codex thread.\n\n" - f"Continuation turn: {turn_number} of {max_turns}.\n\n" - "Current Linear issue snapshot, authoritative for this turn:\n" - f"Issue: {issue.identifier} - {issue.title}\n" - f"URL: {issue.url or '(none)'}\n" - f"State: {issue.state or '(unknown)'}\n" - f"Priority: {issue.priority if issue.priority is not None else '(none)'}\n" - f"Labels: {_format_labels(issue.labels)}\n" - f"Updated at: {issue_data.get('updated_at') or '(unknown)'}\n\n" - "Description:\n" - f"{_format_description(issue.description)}\n\n" - "Do not resend the original task from scratch. Inspect current progress, complete the next needed work, " - "validate the result, and perform the workflow-defined handoff if ready. If this current snapshot differs " - "from earlier assumptions, pause and adapt to the current Linear text before editing more code. Do not choose " - "a repository from sibling issue workspaces; use the injected coding context, repo map, or explicit issue text." - ) diff --git a/symphony/tracker.py b/symphony/tracker.py deleted file mode 100644 index 72fabcc..0000000 --- a/symphony/tracker.py +++ /dev/null @@ -1,610 +0,0 @@ -from __future__ import annotations - -import asyncio -import json -import logging -from pathlib import Path -import urllib.error -import urllib.request -from typing import Any, Protocol - -from .config import TrackerConfig -from .errors import TrackerError -from .logging import log_event -from .models import BlockerRef, Issue, IssueAttachment -from .utils import ( - JSONL_READ_LIMIT_BYTES, - tool_request_user_input_approval_answers, - tool_request_user_input_unavailable_answers, - parse_datetime, -) - -LOGGER = logging.getLogger(__name__) - -LINEAR_PAGE_SIZE = 50 -LINEAR_TIMEOUT_SECONDS = 30 -CODEX_MCP_GATEWAY_ATTEMPTS = 3 -LINEAR_MCP_TOOL_LIST_ISSUES = "linear mcp server_list_issues" -LINEAR_MCP_TOOL_GET_ISSUE = "linear mcp server_get_issue" -LINEAR_MCP_TOOL_LIST_COMMENTS = "linear mcp server_list_comments" -LINEAR_MCP_TOOL_SAVE_COMMENT = "linear mcp server_save_comment" -LINEAR_MCP_TOOL_SAVE_ISSUE = "linear mcp server_save_issue" - - -class IssueTracker(Protocol): - async def fetch_candidate_issues(self) -> list[Issue]: - ... - - async def fetch_issues_by_states(self, state_names: list[str]) -> list[Issue]: - ... - - async def fetch_issue_states_by_ids(self, issue_ids: list[str]) -> list[Issue]: - ... - - -class IssueTrackerWriter(Protocol): - async def list_issue_comments(self, issue_id: str) -> list[dict[str, Any]]: - ... - - async def save_issue_comment(self, issue_id: str, body: str, *, comment_id: str | None = None) -> dict[str, Any]: - ... - - async def save_issue_state(self, issue_id: str, state: str) -> dict[str, Any]: - ... - - -class LinearClient: - def __init__(self, config: TrackerConfig, *, transport: Any | None = None): - self.config = config - self.transport = transport - - async def fetch_candidate_issues(self) -> list[Issue]: - return await self._fetch_by_states(self.config.active_states) - - async def fetch_issues_by_states(self, state_names: list[str]) -> list[Issue]: - if not state_names: - return [] - return await self._fetch_by_states(state_names) - - async def fetch_issue_states_by_ids(self, issue_ids: list[str]) -> list[Issue]: - if not issue_ids: - return [] - query = """ - query SymphonyIssueStates($ids: [ID!]) { - issues(filter: { id: { in: $ids } }, first: 100) { - nodes { - id - identifier - title - description - priority - branchName - url - createdAt - updatedAt - state { name } - labels { nodes { name } } - attachments { nodes { id title subtitle url } } - inverseRelations { nodes { type issue { id identifier state { name } } } } - } - } - } - """ - body = await self._graphql(query, {"ids": issue_ids}) - nodes = (((body.get("data") or {}).get("issues") or {}).get("nodes")) - if not isinstance(nodes, list): - raise TrackerError("linear_unknown_payload", "state refresh payload missing issues.nodes") - return [self._normalize_issue(node) for node in nodes] - - async def execute_graphql_once(self, query: str, variables: dict[str, Any] | None = None) -> dict[str, Any]: - return await self._graphql(query, variables or {}) - - async def _fetch_by_states(self, state_names: list[str]) -> list[Issue]: - query = """ - query SymphonyIssuesByState($projectSlug: String!, $stateNames: [String!], $first: Int!, $after: String) { - issues( - filter: { - project: { slugId: { eq: $projectSlug } } - state: { name: { in: $stateNames } } - } - first: $first - after: $after - ) { - nodes { - id - identifier - title - description - priority - branchName - url - createdAt - updatedAt - state { name } - labels { nodes { name } } - attachments { nodes { id title subtitle url } } - inverseRelations { nodes { type issue { id identifier state { name } } } } - } - pageInfo { hasNextPage endCursor } - } - } - """ - issues: list[Issue] = [] - after: str | None = None - while True: - body = await self._graphql( - query, - { - "projectSlug": self.config.project_slug, - "stateNames": state_names, - "first": LINEAR_PAGE_SIZE, - "after": after, - }, - ) - connection = ((body.get("data") or {}).get("issues") or {}) - nodes = connection.get("nodes") - page_info = connection.get("pageInfo") - if not isinstance(nodes, list) or not isinstance(page_info, dict): - raise TrackerError("linear_unknown_payload", "candidate payload missing issues.nodes/pageInfo") - issues.extend(self._normalize_issue(node) for node in nodes) - if not page_info.get("hasNextPage"): - return issues - after = page_info.get("endCursor") - if not after: - raise TrackerError("linear_missing_end_cursor", "Linear pagination requested another page without endCursor") - - async def _graphql(self, query: str, variables: dict[str, Any]) -> dict[str, Any]: - if self.transport is not None: - body = await self.transport(query, variables) - else: - body = await asyncio.to_thread(self._graphql_sync, query, variables) - if not isinstance(body, dict): - raise TrackerError("linear_unknown_payload", "Linear response is not a JSON object") - if body.get("errors"): - raise TrackerError("linear_graphql_errors", "Linear GraphQL returned errors") - return body - - def _graphql_sync(self, query: str, variables: dict[str, Any]) -> dict[str, Any]: - if not self.config.endpoint: - raise TrackerError("linear_api_request", "Linear endpoint is missing") - if not self.config.api_key: - raise TrackerError("missing_tracker_api_key", "Linear API key is missing") - payload = json.dumps({"query": query, "variables": variables}).encode("utf-8") - request = urllib.request.Request( - self.config.endpoint, - data=payload, - method="POST", - headers={ - "Authorization": self.config.api_key, - "Content-Type": "application/json", - "Accept": "application/json", - }, - ) - try: - with urllib.request.urlopen(request, timeout=LINEAR_TIMEOUT_SECONDS) as response: - status = response.status - raw = response.read() - except urllib.error.HTTPError as exc: - raise TrackerError("linear_api_status", f"Linear HTTP status {exc.code}", cause=exc) from exc - except OSError as exc: - raise TrackerError("linear_api_request", f"Linear request failed: {exc}", cause=exc) from exc - if status != 200: - raise TrackerError("linear_api_status", f"Linear HTTP status {status}") - try: - return json.loads(raw.decode("utf-8")) - except json.JSONDecodeError as exc: - raise TrackerError("linear_unknown_payload", "Linear returned invalid JSON", cause=exc) from exc - - def _normalize_issue(self, node: Any) -> Issue: - if not isinstance(node, dict): - raise TrackerError("linear_unknown_payload", "issue node is not an object") - state = ((node.get("state") or {}).get("name")) if isinstance(node.get("state"), dict) else None - labels: list[str] = [] - label_nodes = (((node.get("labels") or {}).get("nodes")) if isinstance(node.get("labels"), dict) else []) or [] - if isinstance(label_nodes, list): - labels = [str(label.get("name", "")).lower() for label in label_nodes if isinstance(label, dict) and label.get("name")] - - attachments = _normalize_attachments( - (((node.get("attachments") or {}).get("nodes")) if isinstance(node.get("attachments"), dict) else []) - ) - - blockers: list[BlockerRef] = [] - relation_nodes = ( - ((node.get("inverseRelations") or {}).get("nodes")) if isinstance(node.get("inverseRelations"), dict) else [] - ) or [] - if isinstance(relation_nodes, list): - for relation in relation_nodes: - if not isinstance(relation, dict) or relation.get("type") != "blocks": - continue - issue = relation.get("issue") or {} - if not isinstance(issue, dict): - continue - relation_state = ((issue.get("state") or {}).get("name")) if isinstance(issue.get("state"), dict) else None - blockers.append(BlockerRef(id=issue.get("id"), identifier=issue.get("identifier"), state=relation_state)) - - priority_raw = node.get("priority") - priority = priority_raw if isinstance(priority_raw, int) and not isinstance(priority_raw, bool) else None - try: - return Issue( - id=str(node["id"]), - identifier=str(node["identifier"]), - title=str(node["title"]), - description=node.get("description"), - priority=priority, - state=str(state or ""), - branch_name=node.get("branchName"), - url=node.get("url"), - labels=labels, - attachments=attachments, - blocked_by=blockers, - created_at=parse_datetime(node.get("createdAt")), - updated_at=parse_datetime(node.get("updatedAt")), - ) - except KeyError as exc: - raise TrackerError("linear_unknown_payload", f"issue node missing required field {exc}") from exc - - -def make_tracker(config: TrackerConfig) -> IssueTracker: - if config.kind == "linear_mcp": - log_event( - LOGGER, - logging.DEBUG, - "tracker_created", - kind="linear_mcp", - project_slug=config.project_slug, - team=config.team, - mcp_server=config.mcp_server, - ) - return LinearMcpClient(config) - if config.kind != "linear": - raise TrackerError("unsupported_tracker_kind", f"unsupported tracker kind: {config.kind}") - log_event(LOGGER, logging.DEBUG, "tracker_created", kind="linear", endpoint=config.endpoint, project_slug=config.project_slug) - return LinearClient(config) - - -class LinearMcpClient: - """Linear reader backed by the Codex app-server MCP tool gateway. - - This is an extension for environments where the Linear connector is available - through Codex OAuth, but a raw Linear API key should not be managed by Symphony. - The MCP tool output exposes issue identifiers rather than GraphQL UUIDs, so - this adapter intentionally uses the issue identifier as the stable issue id. - """ - - def __init__(self, config: TrackerConfig, *, gateway: "CodexMcpGateway | None" = None): - self.config = config - self.gateway = gateway or CodexMcpGateway(command=config.mcp_command, server=config.mcp_server) - - async def fetch_candidate_issues(self) -> list[Issue]: - issues: list[Issue] = [] - for state in self.config.active_states: - issues.extend(await self._list_issues(state=state)) - await self._hydrate_todo_blockers(issues) - return _dedupe_issues(issues) - - async def fetch_issues_by_states(self, state_names: list[str]) -> list[Issue]: - if not state_names: - return [] - issues: list[Issue] = [] - for state in state_names: - issues.extend(await self._list_issues(state=state)) - return _dedupe_issues(issues) - - async def fetch_issue_states_by_ids(self, issue_ids: list[str]) -> list[Issue]: - issues: list[Issue] = [] - for issue_id in issue_ids: - body = await self.gateway.call_tool( - LINEAR_MCP_TOOL_GET_ISSUE, - {"id": issue_id, "includeRelations": True}, - ) - issues.append(self._normalize_issue(body)) - return issues - - async def list_issue_comments(self, issue_id: str) -> list[dict[str, Any]]: - body = await self.gateway.call_tool( - LINEAR_MCP_TOOL_LIST_COMMENTS, - {"issueId": issue_id, "limit": 250, "orderBy": "createdAt"}, - ) - if isinstance(body, list): - return [comment for comment in body if isinstance(comment, dict)] - if not isinstance(body, dict): - raise TrackerError("linear_unknown_payload", "Linear MCP comments payload is not an object or list") - comments = body.get("comments") - if comments is None: - comments = body.get("nodes") - if not isinstance(comments, list): - raise TrackerError("linear_unknown_payload", "Linear MCP comments payload missing comments list") - return [comment for comment in comments if isinstance(comment, dict)] - - async def save_issue_comment(self, issue_id: str, body: str, *, comment_id: str | None = None) -> dict[str, Any]: - args: dict[str, Any] = {"body": body} - if comment_id: - args["id"] = comment_id - else: - args["issueId"] = issue_id - response = await self.gateway.call_tool(LINEAR_MCP_TOOL_SAVE_COMMENT, args) - if not isinstance(response, dict): - raise TrackerError("linear_unknown_payload", "Linear MCP save comment response is not an object") - return response - - async def save_issue_state(self, issue_id: str, state: str) -> dict[str, Any]: - response = await self.gateway.call_tool(LINEAR_MCP_TOOL_SAVE_ISSUE, {"id": issue_id, "state": state}) - if not isinstance(response, dict): - raise TrackerError("linear_unknown_payload", "Linear MCP save issue response is not an object") - return response - - async def _list_issues(self, *, state: str) -> list[Issue]: - cursor: str | None = None - issues: list[Issue] = [] - while True: - args: dict[str, Any] = { - "limit": min(LINEAR_PAGE_SIZE, 250), - "state": state, - "includeArchived": False, - } - if self.config.project_slug: - args["project"] = self.config.project_slug - if self.config.team: - args["team"] = self.config.team - if self.config.required_labels: - args["label"] = self.config.required_labels[0] - if cursor: - args["cursor"] = cursor - body = await self.gateway.call_tool(LINEAR_MCP_TOOL_LIST_ISSUES, args) - nodes = body.get("issues") - if not isinstance(nodes, list): - raise TrackerError("linear_unknown_payload", "Linear MCP payload missing issues list") - issues.extend(self._normalize_issue(node) for node in nodes) - if not body.get("hasNextPage"): - return issues - cursor = body.get("cursor") - if not cursor: - raise TrackerError("linear_missing_end_cursor", "Linear MCP pagination requested another page without cursor") - - async def _hydrate_todo_blockers(self, issues: list[Issue]) -> None: - for index, issue in enumerate(list(issues)): - if issue.state.lower() != "todo": - continue - body = await self.gateway.call_tool( - LINEAR_MCP_TOOL_GET_ISSUE, - {"id": issue.identifier, "includeRelations": True}, - ) - issues[index] = self._normalize_issue(body) - - def _normalize_issue(self, node: Any) -> Issue: - if not isinstance(node, dict): - raise TrackerError("linear_unknown_payload", "Linear MCP issue payload is not an object") - identifier = str(node.get("id") or "") - if not identifier: - raise TrackerError("linear_unknown_payload", "Linear MCP issue payload missing id") - priority = None - priority_raw = node.get("priority") - if isinstance(priority_raw, dict): - value = priority_raw.get("value") - priority = value if isinstance(value, int) and value > 0 else None - elif isinstance(priority_raw, int) and priority_raw > 0: - priority = priority_raw - - labels = [str(label).lower() for label in node.get("labels", []) if label] - attachments = _normalize_attachments(node.get("attachments")) - blocked_by = [] - relations = node.get("relations") - if isinstance(relations, dict): - for blocker in relations.get("blockedBy", []) or []: - if not isinstance(blocker, dict): - continue - blocker_id = blocker.get("id") or blocker.get("identifier") - blocked_by.append( - BlockerRef( - id=str(blocker_id) if blocker_id else None, - identifier=str(blocker_id) if blocker_id else None, - state=blocker.get("status") or blocker.get("state"), - ) - ) - - return Issue( - id=identifier, - identifier=identifier, - title=str(node.get("title") or ""), - description=node.get("description"), - priority=priority, - state=str(node.get("status") or node.get("state") or ""), - branch_name=node.get("gitBranchName") or node.get("branchName"), - url=node.get("url"), - labels=labels, - attachments=attachments, - blocked_by=blocked_by, - created_at=parse_datetime(node.get("createdAt")), - updated_at=parse_datetime(node.get("updatedAt")), - ) - - -class CodexMcpGateway: - def __init__(self, *, command: str = "codex app-server", server: str = "codex_apps", cwd: Path | None = None): - self.command = command - self.server = server - self.cwd = cwd or Path.cwd() - self._next_id = 1 - - async def call_tool(self, tool: str, arguments: dict[str, Any]) -> Any: - last_error: TrackerError | None = None - for attempt in range(1, CODEX_MCP_GATEWAY_ATTEMPTS + 1): - try: - return await self._call_tool_once(tool, arguments) - except TrackerError as exc: - last_error = exc - if not _retryable_mcp_gateway_error(exc) or attempt >= CODEX_MCP_GATEWAY_ATTEMPTS: - raise - await asyncio.sleep(min(2 * attempt, 5)) - if last_error is not None: - raise last_error - raise TrackerError("linear_mcp_app_server", "MCP gateway did not produce a response") - - async def _call_tool_once(self, tool: str, arguments: dict[str, Any]) -> Any: - proc = await asyncio.create_subprocess_exec( - "bash", - "-lc", - self.command, - cwd=self.cwd, - stdin=asyncio.subprocess.PIPE, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - limit=JSONL_READ_LIMIT_BYTES, - ) - try: - await self._request( - proc, - "initialize", - { - "clientInfo": {"name": "symphony_linear_mcp", "title": "Symphony Linear MCP", "version": "0.1.0"}, - "capabilities": {"experimentalApi": True}, - }, - ) - await self._send(proc, {"method": "initialized", "params": {}}) - thread_response = await self._request( - proc, - "thread/start", - { - "cwd": str(self.cwd.resolve(strict=False)), - "approvalPolicy": "never", - "sandbox": "workspace-write", - "serviceName": "symphony_linear_mcp", - "ephemeral": True, - }, - ) - thread_id = ((thread_response.get("thread") or {}).get("id")) if isinstance(thread_response, dict) else None - if not thread_id: - raise TrackerError("linear_mcp_app_server", "thread/start response missing thread.id") - response = await self._request( - proc, - "mcpServer/tool/call", - {"threadId": thread_id, "server": self.server, "tool": tool, "arguments": arguments}, - ) - return self._decode_tool_response(response) - finally: - if proc.returncode is None: - proc.terminate() - try: - await asyncio.wait_for(proc.wait(), timeout=5) - except TimeoutError: - proc.kill() - await proc.wait() - - async def _request(self, proc: asyncio.subprocess.Process, method: str, params: dict[str, Any]) -> Any: - request_id = self._next_id - self._next_id += 1 - await self._send(proc, {"method": method, "id": request_id, "params": params}) - while True: - msg = await self._read(proc) - if "id" in msg and msg.get("id") == request_id and "method" not in msg: - if "error" in msg: - raise TrackerError("linear_mcp_app_server", json.dumps(msg["error"], sort_keys=True)) - return msg.get("result", {}) - if "method" in msg and "id" in msg: - await self._handle_server_request(proc, msg) - - async def _send(self, proc: asyncio.subprocess.Process, message: dict[str, Any]) -> None: - if proc.stdin is None: - raise TrackerError("linear_mcp_app_server", "app-server stdin closed") - proc.stdin.write(json.dumps(message, separators=(",", ":")).encode("utf-8") + b"\n") - await proc.stdin.drain() - - async def _handle_server_request(self, proc: asyncio.subprocess.Process, msg: dict[str, Any]) -> None: - request_id = msg.get("id") - method = str(msg.get("method")) - params = msg.get("params") if isinstance(msg.get("params"), dict) else {} - if method in {"item/commandExecution/requestApproval", "item/fileChange/requestApproval"}: - await self._send(proc, {"id": request_id, "result": {"decision": "acceptForSession"}}) - return - if method in {"item/tool/requestUserInput", "tool/requestUserInput"}: - answers = tool_request_user_input_approval_answers(params) - if answers is None: - answers = tool_request_user_input_unavailable_answers(params) - if answers is not None: - await self._send(proc, {"id": request_id, "result": {"answers": answers}}) - return - await self._send( - proc, - {"id": request_id, "error": {"code": -32601, "message": f"unsupported server request: {method}"}}, - ) - - async def _read(self, proc: asyncio.subprocess.Process) -> dict[str, Any]: - if proc.stdout is None: - raise TrackerError("linear_mcp_app_server", "app-server stdout closed") - try: - line = await asyncio.wait_for(proc.stdout.readline(), timeout=30) - except TimeoutError as exc: - raise TrackerError("linear_mcp_app_server", "timed out waiting for app-server MCP response", cause=exc) from exc - except ValueError as exc: - raise TrackerError("linear_mcp_app_server", f"app-server MCP message exceeded reader limit: {exc}", cause=exc) from exc - if not line: - stderr = "" - if proc.stderr: - stderr = (await proc.stderr.read()).decode(errors="replace") - raise TrackerError("linear_mcp_app_server", f"app-server exited before MCP response: {stderr[-1000:]}") - try: - msg = json.loads(line.decode("utf-8")) - except json.JSONDecodeError as exc: - raise TrackerError("linear_mcp_app_server", "app-server returned malformed JSON", cause=exc) from exc - if not isinstance(msg, dict): - raise TrackerError("linear_mcp_app_server", "app-server message is not an object") - return msg - - def _decode_tool_response(self, response: Any) -> Any: - if not isinstance(response, dict): - raise TrackerError("linear_mcp_app_server", "MCP tool response is not an object") - if response.get("isError"): - raise TrackerError("linear_mcp_tool_error", json.dumps(response, sort_keys=True, default=str)) - content = response.get("content") - if not isinstance(content, list): - raise TrackerError("linear_mcp_app_server", "MCP tool response missing content list") - for item in content: - if isinstance(item, dict) and item.get("type") == "text" and isinstance(item.get("text"), str): - try: - body = json.loads(item["text"]) - except json.JSONDecodeError as exc: - raise TrackerError("linear_mcp_app_server", "Linear MCP text response is not JSON", cause=exc) from exc - return body - raise TrackerError("linear_mcp_app_server", "MCP tool response did not contain text JSON") - - -def _dedupe_issues(issues: list[Issue]) -> list[Issue]: - seen: set[str] = set() - deduped: list[Issue] = [] - for issue in issues: - if issue.id in seen: - continue - seen.add(issue.id) - deduped.append(issue) - return deduped - - -def _normalize_attachments(value: Any) -> list[IssueAttachment]: - if not isinstance(value, list): - return [] - attachments: list[IssueAttachment] = [] - for item in value: - if not isinstance(item, dict): - continue - attachment_id = item.get("id") - title = item.get("title") - subtitle = item.get("subtitle") - url = item.get("url") - attachments.append( - IssueAttachment( - id=str(attachment_id) if attachment_id is not None else None, - title=str(title) if title is not None else None, - subtitle=str(subtitle) if subtitle is not None else None, - url=str(url) if url is not None else None, - ) - ) - return attachments - - -def _retryable_mcp_gateway_error(error: TrackerError) -> bool: - if error.code == "linear_mcp_app_server": - return True - if error.code != "linear_mcp_tool_error": - return False - text = str(error).lower() - return any(fragment in text for fragment in ("transport", "http request failed", "timed out", "failed to get client")) diff --git a/symphony/utils.py b/symphony/utils.py deleted file mode 100644 index 2815d07..0000000 --- a/symphony/utils.py +++ /dev/null @@ -1,136 +0,0 @@ -from __future__ import annotations - -import os -import re -from datetime import UTC, datetime -from pathlib import Path -from typing import Any - -_WORKSPACE_KEY_RE = re.compile(r"[^A-Za-z0-9._-]") -_SECRET_FIELD_RE = re.compile(r"(api[_-]?key|token|secret|authorization)", re.IGNORECASE) -JSONL_READ_LIMIT_BYTES = 10 * 1024 * 1024 + 1 -NON_INTERACTIVE_TOOL_INPUT_ANSWER = "This is a non-interactive session. Operator input is unavailable." - - -def now_utc() -> datetime: - return datetime.now(UTC) - - -def isoformat_z(value: datetime | None) -> str | None: - if value is None: - return None - if value.tzinfo is None: - value = value.replace(tzinfo=UTC) - return value.astimezone(UTC).isoformat().replace("+00:00", "Z") - - -def parse_datetime(value: Any) -> datetime | None: - if not value: - return None - if isinstance(value, datetime): - return value if value.tzinfo else value.replace(tzinfo=UTC) - if not isinstance(value, str): - return None - text = value.strip() - if not text: - return None - if text.endswith("Z"): - text = f"{text[:-1]}+00:00" - try: - parsed = datetime.fromisoformat(text) - except ValueError: - return None - return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC) - - -def normalize_state(value: str | None) -> str: - return (value or "").strip().lower() - - -def sanitize_workspace_key(identifier: str) -> str: - sanitized = _WORKSPACE_KEY_RE.sub("_", identifier) - return sanitized or "_" - - -def resolve_under_root(root: Path, child_name: str) -> Path: - root_abs = root.expanduser().resolve(strict=False) - child = (root_abs / child_name).resolve(strict=False) - if os.path.commonpath([str(root_abs), str(child)]) != str(root_abs): - raise ValueError(f"path escapes workspace root: {child}") - return child - - -def truncate(value: str | None, limit: int = 4000) -> str: - if not value: - return "" - if len(value) <= limit: - return value - return f"{value[:limit]}..." - - -def redact_field(key: str, value: Any) -> Any: - if _SECRET_FIELD_RE.search(key): - return "" - return value - - -def key_value_message(event: str, **fields: Any) -> str: - parts = [f"event={event}"] - for key, value in fields.items(): - safe = redact_field(key, value) - if isinstance(safe, datetime): - safe = isoformat_z(safe) - if safe is None: - safe = "null" - text = str(safe).replace("\n", "\\n") - if " " in text: - text = repr(text) - parts.append(f"{key}={text}") - return " ".join(parts) - - -def tool_request_user_input_approval_answers(params: dict[str, Any]) -> dict[str, Any] | None: - questions = params.get("questions") - if not isinstance(questions, list): - return None - answers: dict[str, Any] = {} - for question in questions: - if not isinstance(question, dict): - return None - question_id = question.get("id") - if not isinstance(question_id, str) or not question_id: - return None - answer_label = _approval_option_label(question.get("options")) - if not answer_label: - return None - answers[question_id] = {"answers": [answer_label]} - return answers or None - - -def tool_request_user_input_unavailable_answers(params: dict[str, Any]) -> dict[str, Any] | None: - questions = params.get("questions") - if not isinstance(questions, list): - return None - answers: dict[str, Any] = {} - for question in questions: - if not isinstance(question, dict): - return None - question_id = question.get("id") - if not isinstance(question_id, str) or not question_id: - return None - answers[question_id] = {"answers": [NON_INTERACTIVE_TOOL_INPUT_ANSWER]} - return answers or None - - -def _approval_option_label(options: Any) -> str | None: - if not isinstance(options, list): - return None - labels = [option.get("label") for option in options if isinstance(option, dict) and isinstance(option.get("label"), str)] - for preferred in ("Approve this Session", "Approve Once"): - if preferred in labels: - return preferred - for label in labels: - normalized = label.strip().lower() - if normalized.startswith("approve") or normalized.startswith("allow"): - return label - return None diff --git a/symphony/workflow.py b/symphony/workflow.py deleted file mode 100644 index 6d9ad63..0000000 --- a/symphony/workflow.py +++ /dev/null @@ -1,62 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from typing import Any - -import yaml - -from .errors import WorkflowError -from .models import WorkflowDefinition - - -def default_workflow_path(cwd: Path | None = None) -> Path: - return (cwd or Path.cwd()) / "WORKFLOW.md" - - -def resolve_workflow_path(path: str | Path | None, cwd: Path | None = None) -> Path: - selected = Path(path) if path is not None else default_workflow_path(cwd) - return selected.expanduser().resolve(strict=False) - - -def load_workflow(path: str | Path | None = None, cwd: Path | None = None) -> WorkflowDefinition: - workflow_path = resolve_workflow_path(path, cwd) - try: - raw = workflow_path.read_text(encoding="utf-8") - except FileNotFoundError as exc: - raise WorkflowError("missing_workflow_file", f"workflow file not found: {workflow_path}", cause=exc) from exc - except OSError as exc: - raise WorkflowError("missing_workflow_file", f"workflow file cannot be read: {workflow_path}", cause=exc) from exc - - config: dict[str, Any] - body: str - if raw.startswith("---"): - lines = raw.splitlines() - closing_index: int | None = None - for index, line in enumerate(lines[1:], start=1): - if line.strip() == "---": - closing_index = index - break - if closing_index is None: - raise WorkflowError("workflow_parse_error", "YAML front matter is missing closing ---") - front_matter = "\n".join(lines[1:closing_index]) - body = "\n".join(lines[closing_index + 1 :]) - try: - parsed = yaml.safe_load(front_matter) if front_matter.strip() else {} - except yaml.YAMLError as exc: - raise WorkflowError("workflow_parse_error", f"invalid YAML front matter: {exc}", cause=exc) from exc - if parsed is None: - config = {} - elif isinstance(parsed, dict): - config = parsed - else: - raise WorkflowError("workflow_front_matter_not_a_map", "YAML front matter must decode to a map/object") - else: - config = {} - body = raw - - try: - mtime_ns = workflow_path.stat().st_mtime_ns - except OSError: - mtime_ns = None - - return WorkflowDefinition(config=config, prompt_template=body.strip(), path=workflow_path, mtime_ns=mtime_ns) diff --git a/symphony/workspace.py b/symphony/workspace.py deleted file mode 100644 index d9e15d7..0000000 --- a/symphony/workspace.py +++ /dev/null @@ -1,539 +0,0 @@ -from __future__ import annotations - -import asyncio -import json -import logging -import shlex -import shutil -from pathlib import Path -from typing import Any - -from .config import HooksConfig, RepositoryConfig, RepositoryPlanningConfig, WorkspaceConfig -from .errors import HookError, WorkspaceError -from .logging import log_event -from .models import RepoPlan, RepoPlanItem, Workspace -from .utils import isoformat_z, now_utc, resolve_under_root, sanitize_workspace_key, truncate - -LOGGER = logging.getLogger(__name__) - - -class WorkspaceManager: - def __init__(self, workspace_config: WorkspaceConfig, hooks: HooksConfig): - self.root = workspace_config.root.resolve(strict=False) - self.hooks = hooks - - def workspace_path_for_identifier(self, identifier: str) -> Path: - return resolve_under_root(self.root, sanitize_workspace_key(identifier)) - - async def create_for_issue(self, identifier: str) -> Workspace: - workspace_key = sanitize_workspace_key(identifier) - workspace_path = resolve_under_root(self.root, workspace_key) - self.root.mkdir(parents=True, exist_ok=True) - created_now = False - if workspace_path.exists() and not workspace_path.is_dir(): - raise WorkspaceError("workspace_path_not_directory", f"workspace path exists and is not a directory: {workspace_path}") - if not workspace_path.exists(): - workspace_path.mkdir(parents=False) - created_now = True - workspace = Workspace(path=workspace_path, workspace_key=workspace_key, created_now=created_now) - if created_now and self.hooks.after_create: - await self.run_hook("after_create", workspace.path, fatal=True) - return workspace - - async def materialize_repo_plan( - self, - workspace: Workspace, - repo_plan: RepoPlan, - config: RepositoryPlanningConfig, - ) -> Workspace: - if not repo_plan.coding_task or repo_plan.primary_repo is None: - workspace.repo_plan = repo_plan - return workspace - - if self._workspace_requires_quarantine(workspace.path, repo_plan, config): - if not config.quarantine_on_mismatch: - raise WorkspaceError( - "workspace_repo_mismatch", - f"workspace does not match repo plan and quarantine_on_mismatch is false: {workspace.path}", - ) - self._quarantine_workspace(workspace.path) - workspace.path.mkdir(parents=False, exist_ok=False) - workspace.created_now = True - - repos_dir = workspace.path / "repos" - repos_dir.mkdir(parents=True, exist_ok=True) - repository_by_slug = config.repository_by_slug - repo_metadata: list[dict[str, Any]] = [] - for planned_repo in repo_plan.all_repos(): - repo_config = repository_by_slug.get(planned_repo.slug) - if repo_config is None: - raise WorkspaceError("unknown_planned_repository", f"repo plan references unknown repo: {planned_repo.slug}") - repo_path = repos_dir / self._repo_path_name(planned_repo, repo_config) - base_branch = _base_branch_name(repo_config.base_branch or config.base_branch) - expected_branch = self._expected_branch_name(repo_plan.issue_identifier, planned_repo, repo_config, config.branch_prefix) - checkout_metadata = await self._ensure_repo_checkout( - repo_path, - repo_config, - config.clone_timeout_ms, - base_branch=base_branch, - expected_branch=expected_branch, - ) - repo_metadata.append( - { - "slug": planned_repo.slug, - "role": planned_repo.role, - "edit_allowed": planned_repo.edit_allowed, - "path_name": self._repo_path_name(planned_repo, repo_config), - "path": f"repos/{self._repo_path_name(planned_repo, repo_config)}", - "remote_url": checkout_metadata.get("remote_url"), - "git": checkout_metadata, - } - ) - - self._write_repo_metadata(workspace.path, repo_plan, repo_metadata) - workspace.repo_plan = repo_plan - workspace.primary_repo_path = self.repo_path(workspace.path, repo_plan.primary_repo, repository_by_slug) - return workspace - - def repo_path(self, workspace_path: Path, repo_item: RepoPlanItem, repository_by_slug: dict[str, RepositoryConfig]) -> Path: - repo_config = repository_by_slug[repo_item.slug] - return workspace_path / "repos" / self._repo_path_name(repo_item, repo_config) - - async def before_run(self, workspace_path: Path) -> None: - if self.hooks.before_run: - await self.run_hook("before_run", workspace_path, fatal=True) - - async def after_run(self, workspace_path: Path) -> None: - if self.hooks.after_run: - try: - await self.run_hook("after_run", workspace_path, fatal=False) - except HookError: - pass - - async def remove_for_identifier(self, identifier: str) -> None: - workspace_path = self.workspace_path_for_identifier(identifier) - if not workspace_path.exists(): - return - if self.hooks.before_remove: - try: - await self.run_hook("before_remove", workspace_path, fatal=False) - except HookError: - pass - if workspace_path.exists(): - if workspace_path.is_dir(): - shutil.rmtree(workspace_path) - else: - workspace_path.unlink() - log_event(LOGGER, logging.INFO, "workspace_removed", issue_identifier=identifier, workspace_path=workspace_path) - - def _workspace_requires_quarantine( - self, - workspace_path: Path, - repo_plan: RepoPlan, - config: RepositoryPlanningConfig, - ) -> bool: - if not workspace_path.exists(): - return False - if (workspace_path / ".git").is_dir(): - log_event(LOGGER, logging.WARNING, "legacy_workspace_checkout_detected", workspace_path=workspace_path) - return True - repos_dir = workspace_path / "repos" - ignored = {"repo-plan.json", ".symphony-workspace.json", "repos"} - existing_entries = [entry.name for entry in workspace_path.iterdir() if entry.name not in ignored] - if existing_entries and not repos_dir.exists(): - log_event( - LOGGER, - logging.WARNING, - "non_repo_plan_workspace_detected", - workspace_path=workspace_path, - entries=existing_entries[:10], - ) - return True - repository_by_slug = config.repository_by_slug - for planned_repo in repo_plan.all_repos(): - repo_config = repository_by_slug.get(planned_repo.slug) - if repo_config is None: - continue - repo_path = workspace_path / "repos" / self._repo_path_name(planned_repo, repo_config) - if repo_path.exists() and not self._repo_checkout_matches(repo_path, repo_config): - log_event( - LOGGER, - logging.WARNING, - "repo_checkout_mismatch_detected", - workspace_path=workspace_path, - repo_path=repo_path, - expected_slug=repo_config.slug, - ) - return True - return False - - def _quarantine_workspace(self, workspace_path: Path) -> None: - if not workspace_path.exists(): - return - quarantine_root = workspace_path.parent / "_quarantine" - quarantine_root.mkdir(parents=True, exist_ok=True) - timestamp = isoformat_z(now_utc()).replace(":", "").replace(".", "-") if isoformat_z(now_utc()) else "unknown" - target = quarantine_root / f"{workspace_path.name}-{timestamp}" - suffix = 1 - while target.exists(): - suffix += 1 - target = quarantine_root / f"{workspace_path.name}-{timestamp}-{suffix}" - workspace_path.rename(target) - log_event(LOGGER, logging.WARNING, "workspace_quarantined", original_path=workspace_path, quarantine_path=target) - - async def _ensure_repo_checkout( - self, - repo_path: Path, - repo_config: RepositoryConfig, - timeout_ms: int, - *, - base_branch: str, - expected_branch: str, - ) -> dict[str, Any]: - if repo_path.exists(): - if not repo_path.is_dir(): - raise WorkspaceError("repo_path_not_directory", f"repo path exists and is not a directory: {repo_path}") - if not self._repo_checkout_matches(repo_path, repo_config): - raise WorkspaceError("repo_checkout_mismatch", f"repo path exists but remote does not match {repo_config.slug}: {repo_path}") - current_branch = await self._git_output( - repo_path, - ["branch", "--show-current"], - timeout_ms, - "repo_branch_read_failed", - f"failed reading current branch for {repo_config.slug}", - ) - await self._install_pre_push_guard(repo_path, expected_branch) - return { - "base_branch": base_branch, - "base_ref": f"origin/{base_branch}", - "base_sha": None, - "expected_branch": expected_branch, - "expected_ref": f"refs/heads/{expected_branch}", - "current_branch": current_branch, - "branch_prepared": False, - "pre_push_guard": True, - "remote_url": self._git_remote(repo_path), - } - source = str(repo_config.local_path or repo_config.remote_url or "") - if not source: - raise WorkspaceError("repository_missing_source", f"repository has no clone source: {repo_config.slug}") - repo_path.parent.mkdir(parents=True, exist_ok=True) - command = ["git", "clone"] - if repo_config.local_path is not None: - command.append("--no-hardlinks") - command.extend([source, str(repo_path)]) - log_event(LOGGER, logging.INFO, "repo_clone_started", repo_slug=repo_config.slug, source=source, repo_path=repo_path) - proc = await asyncio.create_subprocess_exec( - *command, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - try: - stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout_ms / 1000) - except TimeoutError as exc: - proc.kill() - await proc.communicate() - raise WorkspaceError("repo_clone_timeout", f"git clone timed out after {timeout_ms} ms: {repo_config.slug}", cause=exc) from exc - if proc.returncode != 0: - output = truncate((stdout or b"").decode(errors="replace") + (stderr or b"").decode(errors="replace"), 2000) - raise WorkspaceError("repo_clone_failed", f"git clone failed for {repo_config.slug}: {output}") - if repo_config.remote_url: - await self._set_repo_remote(repo_path, repo_config.remote_url, timeout_ms) - base_sha = await self._prepare_expected_branch(repo_path, repo_config.slug, base_branch, expected_branch, timeout_ms) - await self._install_pre_push_guard(repo_path, expected_branch) - log_event(LOGGER, logging.INFO, "repo_clone_completed", repo_slug=repo_config.slug, repo_path=repo_path) - return { - "base_branch": base_branch, - "base_ref": f"origin/{base_branch}", - "base_sha": base_sha, - "expected_branch": expected_branch, - "expected_ref": f"refs/heads/{expected_branch}", - "current_branch": expected_branch, - "branch_prepared": True, - "pre_push_guard": True, - "remote_url": self._git_remote(repo_path), - } - - async def _set_repo_remote(self, repo_path: Path, remote_url: str, timeout_ms: int) -> None: - proc = await asyncio.create_subprocess_exec( - "git", - "-C", - str(repo_path), - "remote", - "set-url", - "origin", - remote_url, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - try: - stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout_ms / 1000) - except TimeoutError as exc: - proc.kill() - await proc.communicate() - raise WorkspaceError("repo_remote_set_timeout", f"git remote set-url timed out for {repo_path}", cause=exc) from exc - if proc.returncode != 0: - output = truncate((stdout or b"").decode(errors="replace") + (stderr or b"").decode(errors="replace"), 2000) - raise WorkspaceError("repo_remote_set_failed", f"git remote set-url failed for {repo_path}: {output}") - - async def _prepare_expected_branch( - self, - repo_path: Path, - repo_slug: str, - base_branch: str, - expected_branch: str, - timeout_ms: int, - ) -> str: - await self._git_output( - repo_path, - ["fetch", "origin", f"+{base_branch}:refs/remotes/origin/{base_branch}"], - timeout_ms, - "repo_base_fetch_failed", - f"failed fetching origin/{base_branch} for {repo_slug}", - ) - base_sha = await self._git_output( - repo_path, - ["rev-parse", f"origin/{base_branch}"], - timeout_ms, - "repo_base_ref_failed", - f"failed resolving origin/{base_branch} for {repo_slug}", - ) - await self._git_output( - repo_path, - ["checkout", "-B", expected_branch, f"origin/{base_branch}"], - timeout_ms, - "repo_branch_checkout_failed", - f"failed checking out {expected_branch} from origin/{base_branch} for {repo_slug}", - ) - await self._git_output( - repo_path, - ["config", f"branch.{expected_branch}.remote", "origin"], - timeout_ms, - "repo_branch_config_failed", - f"failed configuring push remote for {expected_branch} in {repo_slug}", - ) - await self._git_output( - repo_path, - ["config", f"branch.{expected_branch}.merge", f"refs/heads/{expected_branch}"], - timeout_ms, - "repo_branch_config_failed", - f"failed configuring upstream branch for {expected_branch} in {repo_slug}", - ) - log_event( - LOGGER, - logging.INFO, - "repo_branch_prepared", - repo_slug=repo_slug, - repo_path=repo_path, - base_branch=base_branch, - base_sha=base_sha, - expected_branch=expected_branch, - ) - return base_sha - - async def _git_output( - self, - repo_path: Path, - args: list[str], - timeout_ms: int, - error_code: str, - error_message: str, - ) -> str: - proc = await asyncio.create_subprocess_exec( - "git", - "-C", - str(repo_path), - *args, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - try: - stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout_ms / 1000) - except TimeoutError as exc: - proc.kill() - await proc.communicate() - raise WorkspaceError(error_code, f"{error_message}: timed out after {timeout_ms} ms", cause=exc) from exc - if proc.returncode != 0: - output = truncate((stdout or b"").decode(errors="replace") + (stderr or b"").decode(errors="replace"), 2000) - raise WorkspaceError(error_code, f"{error_message}: {output}") - return (stdout or b"").decode(errors="replace").strip() - - async def _install_pre_push_guard(self, repo_path: Path, expected_branch: str) -> None: - git_dir = repo_path / ".git" - if not git_dir.is_dir(): - raise WorkspaceError("repo_git_dir_missing", f"repo .git directory is missing: {repo_path}") - hooks_dir = git_dir / "hooks" - hooks_dir.mkdir(parents=True, exist_ok=True) - expected_ref = f"refs/heads/{expected_branch}" - script = f"""#!/bin/sh -expected_branch={shlex.quote(expected_branch)} -expected_ref={shlex.quote(expected_ref)} -zero_oid=0000000000000000000000000000000000000000 - -current_branch=$(git symbolic-ref --quiet --short HEAD) || {{ - echo "Symphony branch guard: refusing to push from detached HEAD. Expected $expected_branch." >&2 - exit 1 -}} - -if [ "$current_branch" != "$expected_branch" ]; then - echo "Symphony branch guard: refusing to push from $current_branch. Expected $expected_branch." >&2 - exit 1 -fi - -while read local_ref local_oid remote_ref remote_oid -do - [ -z "$local_ref" ] && continue - if [ "$local_oid" = "$zero_oid" ]; then - echo "Symphony branch guard: refusing to delete remote refs from an agent workspace." >&2 - exit 1 - fi - if [ "$local_ref" != "$expected_ref" ] && [ "$local_ref" != "HEAD" ]; then - echo "Symphony branch guard: refusing to push $local_ref. Expected $expected_ref." >&2 - exit 1 - fi - if [ "$remote_ref" != "$expected_ref" ]; then - echo "Symphony branch guard: refusing to push to $remote_ref. Expected $expected_ref." >&2 - exit 1 - fi -done -exit 0 -""" - hook_path = hooks_dir / "pre-push" - hook_path.write_text(script, encoding="utf-8") - hook_path.chmod(0o755) - - def _repo_checkout_matches(self, repo_path: Path, repo_config: RepositoryConfig) -> bool: - if not (repo_path / ".git").exists(): - return False - try: - remote = self._git_remote(repo_path) - except WorkspaceError: - return False - if repo_config.remote_url: - return _normalize_git_remote(remote) == _normalize_git_remote(repo_config.remote_url) - if repo_config.local_path is not None: - try: - return Path(remote).expanduser().resolve(strict=False) == repo_config.local_path.resolve(strict=False) - except OSError: - return False - return _slug_in_remote(repo_config.slug, remote) - - def _git_remote(self, repo_path: Path) -> str: - try: - import subprocess - - result = subprocess.run( - ["git", "-C", str(repo_path), "config", "--get", "remote.origin.url"], - check=False, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - except OSError as exc: - raise WorkspaceError("git_remote_failed", f"failed reading git remote for {repo_path}", cause=exc) from exc - if result.returncode != 0: - raise WorkspaceError("git_remote_failed", f"failed reading git remote for {repo_path}: {result.stderr}") - return result.stdout.strip() - - def _write_repo_metadata(self, workspace_path: Path, repo_plan: RepoPlan, repositories: list[dict[str, Any]]) -> None: - plan_payload = repo_plan.to_dict() - (workspace_path / "repo-plan.json").write_text(json.dumps(plan_payload, indent=2, sort_keys=True), encoding="utf-8") - metadata = { - "version": 1, - "layout": "multi_repo", - "updated_at": isoformat_z(now_utc()), - "repo_plan": plan_payload, - "repositories": repositories, - } - (workspace_path / ".symphony-workspace.json").write_text(json.dumps(metadata, indent=2, sort_keys=True), encoding="utf-8") - - def _repo_path_name(self, repo_item: RepoPlanItem, repo_config: RepositoryConfig) -> str: - return sanitize_workspace_key(repo_item.path_name or repo_config.path_name) - - def _expected_branch_name( - self, - issue_identifier: str, - repo_item: RepoPlanItem, - repo_config: RepositoryConfig, - branch_prefix: str, - ) -> str: - prefix = _branch_segment(branch_prefix) or "Symphony" - issue_segment = _branch_segment(issue_identifier) - repo_segment = _branch_segment(repo_item.path_name or repo_config.path_name) - return f"{prefix}/{issue_segment}-{repo_segment}" - - async def run_hook(self, hook_name: str, workspace_path: Path, *, fatal: bool) -> None: - script = getattr(self.hooks, hook_name) - if not script: - return - workspace_abs = workspace_path.resolve(strict=False) - root_abs = self.root.resolve(strict=False) - if str(workspace_abs) != str(workspace_path.resolve(strict=False)): - raise WorkspaceError("invalid_workspace_cwd", f"workspace path is not normalized: {workspace_path}") - if workspace_abs == root_abs or root_abs not in workspace_abs.parents: - raise WorkspaceError("invalid_workspace_cwd", f"workspace path is outside workspace root: {workspace_abs}") - log_event(LOGGER, logging.INFO, "hook_started", hook=hook_name, workspace_path=workspace_abs) - proc = await asyncio.create_subprocess_exec( - "bash", - "-lc", - script, - cwd=workspace_abs, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - try: - stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=self.hooks.timeout_ms / 1000) - except TimeoutError as exc: - proc.kill() - await proc.communicate() - message = f"hook timed out after {self.hooks.timeout_ms} ms" - log_event(LOGGER, logging.ERROR, "hook_timed_out", hook=hook_name, workspace_path=workspace_abs, fatal=fatal) - if fatal: - raise HookError("hook_timeout", message, cause=exc) from exc - raise HookError("hook_timeout", message, cause=exc) from exc - if proc.returncode != 0: - output = truncate((stdout or b"").decode(errors="replace") + (stderr or b"").decode(errors="replace"), 2000) - message = f"hook failed with exit code {proc.returncode}: {output}" - log_event( - LOGGER, - logging.ERROR, - "hook_failed", - hook=hook_name, - workspace_path=workspace_abs, - exit_code=proc.returncode, - fatal=fatal, - output=output, - ) - if fatal: - raise HookError("hook_failed", message) - raise HookError("hook_failed", message) - log_event(LOGGER, logging.INFO, "hook_completed", hook=hook_name, workspace_path=workspace_abs) - - -def _normalize_git_remote(value: str | None) -> str: - text = (value or "").strip().lower() - if text.startswith("git@github.com:"): - text = "https://github.com/" + text.removeprefix("git@github.com:") - if text.endswith(".git"): - text = text[:-4] - return text.rstrip("/") - - -def _slug_in_remote(slug: str, remote: str) -> bool: - normalized_slug = slug.strip().lower() - return normalized_slug in _normalize_git_remote(remote) - - -def _base_branch_name(value: str | None) -> str: - text = (value or "dev").strip() - if text.startswith("refs/heads/"): - text = text.removeprefix("refs/heads/") - if text.startswith("origin/"): - text = text.removeprefix("origin/") - parts = [_branch_segment(part) for part in text.split("/")] - return "/".join(part for part in parts if part) or "dev" - - -def _branch_segment(value: str | None) -> str: - text = sanitize_workspace_key((value or "").strip()) - return text.strip("._-") diff --git a/test/agent_runner_test.exs b/test/agent_runner_test.exs new file mode 100644 index 0000000..9e96f56 --- /dev/null +++ b/test/agent_runner_test.exs @@ -0,0 +1,195 @@ +defmodule Symphony.AgentRunnerTest do + use ExUnit.Case, async: true + + alias Symphony.AgentRunner + alias Symphony.AgentRunner.AgentRunResult + alias Symphony.Config.ConfigManager + alias Symphony.Models.Issue + + defmodule StructTracker do + defstruct parent: nil + + def list_issue_comments(%__MODULE__{parent: parent}, issue_id) do + send(parent, {:struct_tracker_list_comments, issue_id}) + [%{"id" => "comment-1", "body" => "## Codex Workpad\nold"}] + end + + def save_issue_comment(%__MODULE__{parent: parent}, issue_id, body, opts) do + send(parent, {:struct_tracker_save_comment, issue_id, body, opts}) + %{"id" => Keyword.fetch!(opts, :comment_id)} + end + + def save_issue_state(%__MODULE__{parent: parent}, issue_id, state) do + send(parent, {:struct_tracker_save_state, issue_id, state}) + %{"id" => issue_id, "state" => state} + end + end + + test "agent reported Linear delivery blocker requires completion signal" do + assert AgentRunner.agent_reported_linear_delivery_blocker?( + "Completed: implementation is committed and pushed. Validation passed. Blocker: Linear MCP calls were rejected for the workpad and state transition." + ) + + refute AgentRunner.agent_reported_linear_delivery_blocker?( + "Linear rejected the initial read; continuing repo inspection." + ) + + refute AgentRunner.agent_reported_linear_delivery_blocker?( + "Blocked; no code changed. The workspace marks `caretta-webapp` as `edit_allowed: false`, and Project-N does not contain the target UI. Linear MCP writes were rejected." + ) + end + + test "agent reported incomplete or guardrail-blocked work is not delivery-ready" do + assert AgentRunner.agent_reported_incomplete_or_blocked?( + "Blocked; no code changed. The workspace still marks `caretta-webapp` as `edit_allowed: false`. No validation was run because no implementation changes were possible." + ) + + assert AgentRunner.agent_reported_incomplete_or_blocked?( + "Blocked by repo plan guardrail: the selected repo does not contain the target UI." + ) + + refute AgentRunner.agent_reported_incomplete_or_blocked?( + "Completed: implementation is committed and pushed. Validation passed. Linear MCP writes were rejected." + ) + end + + test "agent reported unresolved external blocker detects unapplied data operations" do + assert AgentRunner.agent_reported_unresolved_external_blocker?( + "Completed: PR is open. Blocker: Linear MCP writes were rejected. Missing Postgres URL; the production data migration was not applied." + ) + + refute AgentRunner.agent_reported_unresolved_external_blocker?( + "Completed: migration dry-run and apply both succeeded. No known blockers remain." + ) + + refute AgentRunner.agent_reported_unresolved_external_blocker?( + "Completed the production data operation. Blocker: Linear MCP write calls were rejected for both the workpad update and moving ABC-1 to In Review, and no local Linear API credential was available as a fallback." + ) + end + + test "existing workpad comment id finds Codex workpad" do + assert AgentRunner.existing_workpad_comment_id([ + %{"id" => "comment-1", "body" => "ordinary note"}, + %{"id" => "comment-2", "body" => "## Codex Workpad\nstatus"} + ]) == "comment-2" + end + + test "delivery fallback dispatches through tracker structs" do + issue = %Issue{id: "1", identifier: "ABC-1", title: "Ready", state: "In Progress"} + + assert AgentRunner.try_delivery_fallback( + %StructTracker{parent: self()}, + issue, + "Completed: implementation is committed and pushed. Validation passed. Blocker: Linear MCP calls were rejected for the workpad and state transition.", + handoff_state: "In Review", + workspace_path: "/tmp/symphony-test-workspace" + ) + + assert_received {:struct_tracker_list_comments, "ABC-1"} + assert_received {:struct_tracker_save_comment, "ABC-1", body, [comment_id: "comment-1"]} + assert body =~ "Completed: implementation is committed and pushed." + assert_received {:struct_tracker_save_state, "ABC-1", "In Review"} + end + + test "delivery fallback refuses unresolved credentialed data blockers" do + issue = %Issue{id: "1", identifier: "ABC-1", title: "Ready", state: "In Progress"} + + refute AgentRunner.try_delivery_fallback( + %StructTracker{parent: self()}, + issue, + "Completed: PR is open. Blocker: Linear MCP writes were rejected. Missing Postgres URL; the data migration was not run.", + handoff_state: "In Review", + workspace_path: "/tmp/symphony-test-workspace" + ) + + refute_received {:struct_tracker_save_state, "ABC-1", "In Review"} + end + + test "delivery fallback refuses blocked no-code handoff" do + issue = %Issue{id: "1", identifier: "ABC-1", title: "Ready", state: "In Progress"} + + refute AgentRunner.try_delivery_fallback( + %StructTracker{parent: self()}, + issue, + "Blocked; no code changed. The target repo is read-only and does not contain the relevant UI. Linear MCP writes were rejected.", + handoff_state: "In Review", + workspace_path: "/tmp/symphony-test-workspace" + ) + + refute_received {:struct_tracker_save_state, "ABC-1", "In Review"} + end + + @tag :tmp_dir + test "run_issue executes a Codex turn and exits when issue leaves active state", %{ + tmp_dir: tmp_dir + } do + fake_server = Path.join(tmp_dir, "fake_app_server.py") + + File.write!(fake_server, ~S""" + import json + import sys + + thread_id = "thr_runner" + turn_id = "turn_runner" + + for line in sys.stdin: + msg = json.loads(line) + method = msg.get("method") + if method == "initialize": + print(json.dumps({"id": msg["id"], "result": {}}), flush=True) + elif method == "initialized": + pass + elif method == "thread/start": + print(json.dumps({"id": msg["id"], "result": {"thread": {"id": thread_id}}}), flush=True) + elif method == "turn/start": + print(json.dumps({"id": msg["id"], "result": {"turn": {"id": turn_id}}}), flush=True) + print(json.dumps({"method": "item/agentMessage/delta", "params": {"threadId": thread_id, "turnId": turn_id, "delta": "Done."}}), flush=True) + print(json.dumps({"method": "turn/completed", "params": {"threadId": thread_id, "turn": {"id": turn_id, "status": "completed"}}}), flush=True) + """) + + workflow_path = Path.join(tmp_dir, "WORKFLOW.md") + + File.write!(workflow_path, """ + --- + tracker: + kind: linear + api_key: key + project_slug: demo + workspace: + root: #{Path.join(tmp_dir, "workspaces")} + codex: + command: python3 #{fake_server} + agent: + max_turns: 1 + --- + Work on {{ issue.identifier }}. + """) + + manager = ConfigManager.new(workflow_path, environ: %{}) + {manager, _, _} = ConfigManager.load_startup(manager) + + tracker = %{ + fetch_issue_states_by_ids: fn ["1"] -> + [%Issue{id: "1", identifier: "ABC-1", title: "Ready", state: "In Review"}] + end + } + + parent = self() + runner = AgentRunner.new(manager, tracker) + issue = %Issue{id: "1", identifier: "ABC-1", title: "Ready", state: "In Progress"} + + result = + AgentRunner.run_issue(runner, issue, nil, fn issue_id, event -> + send(parent, {:event, issue_id, event}) + end) + + assert %AgentRunResult{ + normal: true, + reason: "issue_left_active_state", + workspace_path: workspace_path + } = result + + assert File.dir?(workspace_path) + assert_receive {:event, "1", %{"event" => "session_started"}} + end +end diff --git a/test/cli_test.exs b/test/cli_test.exs new file mode 100644 index 0000000..284b479 --- /dev/null +++ b/test/cli_test.exs @@ -0,0 +1,14 @@ +defmodule Symphony.CLITest do + use ExUnit.Case, async: true + + import ExUnit.CaptureIO + + test "run returns non-zero for startup errors" do + output = + capture_io(:stderr, fn -> + assert Symphony.CLI.run(["/definitely/missing/WORKFLOW.md", "--once"]) == 1 + end) + + assert output =~ "missing_workflow_file" + end +end diff --git a/test/codex_client_test.exs b/test/codex_client_test.exs new file mode 100644 index 0000000..5dc5c16 --- /dev/null +++ b/test/codex_client_test.exs @@ -0,0 +1,197 @@ +defmodule Symphony.CodexClientTest do + use ExUnit.Case, async: true + + alias Symphony.CodexClient + alias Symphony.Config.{CodexConfig, TrackerConfig} + alias Symphony.Error + + @tag :tmp_dir + test "Codex JSONL client runs turn and handles approval", %{tmp_dir: tmp_dir} do + fake_server = Path.join(tmp_dir, "fake_app_server.py") + + File.write!(fake_server, ~S""" + import json + import sys + + print("diagnostic log line before JSON-RPC", file=sys.stderr, flush=True) + + thread_id = "thr_1" + turn_id = "turn_1" + + for line in sys.stdin: + msg = json.loads(line) + method = msg.get("method") + if method == "initialize": + print(json.dumps({"id": msg["id"], "result": {"userAgent": "fake"}}), flush=True) + elif method == "initialized": + pass + elif method == "thread/start": + print(json.dumps({"id": msg["id"], "result": {"thread": {"id": thread_id}}}), flush=True) + elif method == "turn/start": + print(json.dumps({"id": msg["id"], "result": {"turn": {"id": turn_id, "status": "inProgress", "items": [], "error": None}}}), flush=True) + print(json.dumps({"method": "item/commandExecution/requestApproval", "id": 99, "params": {"threadId": thread_id, "turnId": turn_id}}), flush=True) + elif msg.get("id") == 99: + assert msg["result"]["decision"] == "acceptForSession" + print(json.dumps({"method": "thread/tokenUsage/updated", "params": {"threadId": thread_id, "turnId": turn_id, "tokenUsage": {"last": {"inputTokens": 1, "outputTokens": 2, "totalTokens": 3}, "total": {"inputTokens": 4, "outputTokens": 5, "totalTokens": 9}}}}), flush=True) + print(json.dumps({"method": "turn/completed", "params": {"threadId": thread_id, "turn": {"id": turn_id, "status": "completed", "items": [], "error": None}}}), flush=True) + """) + + parent = self() + on_event = fn event -> send(parent, {:event, event}) end + + session = + CodexClient.start_session(%CodexConfig{command: "python3 #{fake_server}"}, tmp_dir, + tracker_config: %TrackerConfig{ + kind: "linear", + endpoint: "https://example.test/graphql", + api_key: "key", + project_slug: "proj" + }, + on_event: on_event + ) + + {result, session} = CodexClient.run_turn(session, "hello") + CodexClient.stop_session(session) + + assert result.thread_id == "thr_1" + assert result.turn_id == "turn_1" + assert_receive {:event, %{"event" => "approval_auto_approved"}} + + assert_receive {:event, + %{ + "usage_absolute" => %{ + "input_tokens" => 4, + "output_tokens" => 5, + "total_tokens" => 9 + } + }} + end + + @tag :tmp_dir + test "Codex JSONL client auto answers freeform tool input", %{tmp_dir: tmp_dir} do + fake_server = Path.join(tmp_dir, "fake_app_server.py") + + File.write!(fake_server, ~S""" + import json + import sys + + thread_id = "thr_freeform" + turn_id = "turn_freeform" + answer = "This is a non-interactive session. Operator input is unavailable." + + for line in sys.stdin: + msg = json.loads(line) + method = msg.get("method") + if method == "initialize": + print(json.dumps({"id": msg["id"], "result": {}}), flush=True) + elif method == "initialized": + pass + elif method == "thread/start": + print(json.dumps({"id": msg["id"], "result": {"thread": {"id": thread_id}}}), flush=True) + elif method == "turn/start": + print(json.dumps({"id": msg["id"], "result": {"turn": {"id": turn_id}}}), flush=True) + print(json.dumps({"id": 111, "method": "item/tool/requestUserInput", "params": {"threadId": thread_id, "turnId": turn_id, "questions": [{"id": "freeform-1", "options": None, "question": "What should I write?"}]}}), flush=True) + elif msg.get("id") == 111: + assert msg["result"]["answers"]["freeform-1"]["answers"] == [answer] + print(json.dumps({"method": "turn/completed", "params": {"threadId": thread_id, "turn": {"id": turn_id, "status": "completed"}}}), flush=True) + """) + + parent = self() + + session = + CodexClient.start_session(%CodexConfig{command: "python3 #{fake_server}"}, tmp_dir, + tracker_config: nil, + on_event: fn event -> send(parent, {:event, event}) end + ) + + {result, session} = CodexClient.run_turn(session, "answer") + CodexClient.stop_session(session) + + assert result.status == "completed" + assert_receive {:event, %{"event" => "tool_input_auto_answered"}} + end + + @tag :tmp_dir + test "Codex JSONL client accepts dynamic tool name alias", %{tmp_dir: tmp_dir} do + fake_server = Path.join(tmp_dir, "fake_app_server.py") + + File.write!(fake_server, ~S""" + import json + import sys + + thread_id = "thr_tool_name" + turn_id = "turn_tool_name" + + for line in sys.stdin: + msg = json.loads(line) + method = msg.get("method") + if method == "initialize": + print(json.dumps({"id": msg["id"], "result": {}}), flush=True) + elif method == "initialized": + pass + elif method == "thread/start": + print(json.dumps({"id": msg["id"], "result": {"thread": {"id": thread_id}}}), flush=True) + elif method == "turn/start": + print(json.dumps({"id": msg["id"], "result": {"turn": {"id": turn_id}}}), flush=True) + print(json.dumps({"id": 112, "method": "item/tool/call", "params": {"threadId": thread_id, "turnId": turn_id, "name": "linear_graphql", "arguments": {"query": "query Viewer { viewer { id } }"}}}), flush=True) + elif msg.get("id") == 112: + content = msg["result"]["contentItems"][0]["text"] + assert "missing_auth" in content + assert "unsupported_tool" not in content + print(json.dumps({"method": "turn/completed", "params": {"threadId": thread_id, "turn": {"id": turn_id, "status": "completed"}}}), flush=True) + """) + + session = + CodexClient.start_session(%CodexConfig{command: "python3 #{fake_server}"}, tmp_dir, + tracker_config: nil, + on_event: fn _ -> :ok end + ) + + {result, session} = CodexClient.run_turn(session, "call") + CodexClient.stop_session(session) + + assert result.status == "completed" + end + + @tag :tmp_dir + test "Codex JSONL client cleans up when start times out", %{tmp_dir: tmp_dir} do + marker = Path.join(tmp_dir, "pid.txt") + fake_server = Path.join(tmp_dir, "fake_hanging_app_server.py") + + File.write!(fake_server, ~S""" + import pathlib + import sys + import time + + pathlib.Path(sys.argv[1]).write_text(str(__import__("os").getpid()), encoding="utf-8") + while True: + time.sleep(1) + """) + + assert_raise Error, ~r/response_timeout/, fn -> + CodexClient.start_session( + %CodexConfig{command: "python3 #{fake_server} #{marker}", read_timeout_ms: 1000}, + tmp_dir, + tracker_config: nil, + on_event: fn _ -> :ok end + ) + end + + assert wait_until(fn -> File.exists?(marker) end) + pid = marker |> File.read!() |> String.trim() + {_out, status} = System.cmd("ps", ["-p", pid], stderr_to_stdout: true) + assert status != 0 + end + + defp wait_until(fun, attempts \\ 20) + defp wait_until(_fun, 0), do: false + + defp wait_until(fun, attempts) do + if fun.() do + true + else + Process.sleep(100) + wait_until(fun, attempts - 1) + end + end +end diff --git a/test/managed_script_test.exs b/test/managed_script_test.exs new file mode 100644 index 0000000..21c6b54 --- /dev/null +++ b/test/managed_script_test.exs @@ -0,0 +1,75 @@ +defmodule Symphony.ManagedScriptTest do + use ExUnit.Case, async: true + + @script Path.expand("../scripts/symphony-managed.sh", __DIR__) + + @tag :tmp_dir + test "managed launcher uses deployed self-heal artifact when root build artifact is absent", %{ + tmp_dir: tmp_dir + } do + script_path = install_script!(tmp_dir) + File.write!(Path.join(tmp_dir, "WORKFLOW.md"), "body\n") + + deployed_artifact_path = + Path.join([tmp_dir, ".symphony-self-heal", "deploy", "current", "symphony"]) + + invocation_path = Path.join(tmp_dir, "invocation.txt") + File.mkdir_p!(Path.dirname(deployed_artifact_path)) + + File.write!( + deployed_artifact_path, + """ + #!/bin/sh + { + printf '%s\\n' "$0" + printf '%s\\n' "$@" + } > "$SYMPHONY_TEST_INVOCATION" + """ + ) + + File.chmod!(deployed_artifact_path, 0o755) + + assert {_, 0} = + System.cmd("/bin/sh", [script_path, "watchdog"], + cd: tmp_dir, + env: [{"SYMPHONY_TEST_INVOCATION", invocation_path}], + stderr_to_stdout: true + ) + + assert File.read!(invocation_path) == + Enum.join( + [ + deployed_artifact_path, + Path.join(tmp_dir, "WORKFLOW.md"), + "--watchdog" + ], + "\n" + ) <> "\n" + end + + @tag :tmp_dir + test "managed launcher fails before invoking escript when no executable artifact exists", %{ + tmp_dir: tmp_dir + } do + script_path = install_script!(tmp_dir) + File.write!(Path.join(tmp_dir, "WORKFLOW.md"), "body\n") + + assert {output, 70} = + System.cmd("/bin/sh", [script_path, "run"], + cd: tmp_dir, + stderr_to_stdout: true + ) + + assert output =~ "no Symphony executable found" + refute output =~ "Failed to open file" + end + + defp install_script!(tmp_dir) do + script_dir = Path.join(tmp_dir, "scripts") + script_path = Path.join(script_dir, "symphony-managed.sh") + File.mkdir_p!(script_dir) + File.cp!(@script, script_path) + File.chmod!(script_path, 0o755) + script_path + end +end diff --git a/test/orchestrator_test.exs b/test/orchestrator_test.exs new file mode 100644 index 0000000..a7b24fc --- /dev/null +++ b/test/orchestrator_test.exs @@ -0,0 +1,1554 @@ +defmodule Symphony.OrchestratorTest do + use ExUnit.Case, async: false + + alias Symphony.AgentRunner.AgentRunResult + alias Symphony.Config.ConfigManager + alias Symphony.DashboardSummary + alias Symphony.HTTPServer + + alias Symphony.Models.{ + BlockedEntry, + BlockerRef, + CompletedEntry, + Issue, + IssueAssignee, + IssueAttachment, + RepoPlan, + RepoPlanItem, + ReviewFeedbackState, + RetryEntry, + RunningEntry + } + + alias Symphony.Orchestrator + alias Symphony.Review.{PullRequestInfo, PullRequestRef, ReviewMergeResult} + alias Symphony.Utils + + defmodule StructTracker do + defstruct parent: nil + + def fetch_issues_by_states(%__MODULE__{parent: parent}, states) do + send(parent, {:struct_tracker_fetch_issues_by_states, states}) + [] + end + + def save_issue_state(%__MODULE__{}, _issue_id, _state), do: %{} + end + + defmodule FakeSummary do + def summarize_activity(opts) do + send(Application.fetch_env!(:caretta_symphony, :summary_parent), {:summary_called, opts}) + + %DashboardSummary{ + summary: "The agent is searching the codebase.", + current_step: "Inspect matching files", + needs_human: false, + risk: "low", + confidence: 0.9 + } + end + end + + defmodule HangingSummary do + def summarize_activity(_opts) do + Process.sleep(5_000) + end + end + + defmodule FastRunner do + defstruct [] + + def new(_manager, _tracker), do: %__MODULE__{} + + def run_issue(_runner, issue, attempt, _event_callback) do + send(Application.fetch_env!(:caretta_symphony, :runner_parent), { + :runner_called, + issue.identifier, + attempt + }) + + %Symphony.AgentRunner.AgentRunResult{ + issue_id: issue.id, + issue_identifier: issue.identifier, + normal: true, + reason: "done" + } + end + end + + defp make_manager(tmp_dir) do + workflow_path = Path.join(tmp_dir, "WORKFLOW.md") + + File.write!(workflow_path, """ + --- + tracker: + kind: linear + api_key: $LINEAR_API_KEY + project_slug: demo + required_labels: ["codex"] + workspace: + root: #{Path.join(tmp_dir, "workspaces")} + agent: + max_concurrent_agents: 2 + max_retry_backoff_ms: 15000 + max_concurrent_agents_by_state: + Todo: 1 + codex: + command: fake + --- + body + """) + + manager = ConfigManager.new(workflow_path, environ: %{"LINEAR_API_KEY" => "key"}) + {manager, _, _} = ConfigManager.load_startup(manager) + manager + end + + defp make_summary_manager(tmp_dir, timeout_ms \\ 250) do + workflow_path = Path.join(tmp_dir, "WORKFLOW.md") + + File.write!(workflow_path, """ + --- + tracker: + kind: linear + api_key: key + project_slug: demo + required_labels: ["codex"] + workspace: + root: #{Path.join(tmp_dir, "workspaces")} + polling: + interval_ms: 60000 + dashboard: + summaries: + enabled: true + update_interval_ms: 1 + timeout_ms: #{timeout_ms} + max_events: 10 + codex: + command: fake + --- + body + """) + + manager = ConfigManager.new(workflow_path, environ: %{}) + {manager, _, _} = ConfigManager.load_startup(manager) + manager + end + + defp make_repo_manager(tmp_dir) do + workflow_path = Path.join(tmp_dir, "WORKFLOW.md") + + File.write!(workflow_path, """ + --- + tracker: + kind: linear + api_key: key + project_slug: demo + required_labels: ["codex"] + workspace: + root: #{Path.join(tmp_dir, "workspaces")} + codex: + command: fake + repositories: + enabled: true + planner: rules + known: + - slug: CarettaAI/caretta-metrics + local_path: #{tmp_dir} + aliases: ["metrics"] + description: Metrics console and telemetry inspection. + - slug: CarettaAI/caretta-webapp + local_path: #{tmp_dir} + aliases: ["calendar", "web app"] + description: Customer-facing web app, authenticated routes, product UI, calendar, CRM. + --- + body + """) + + manager = ConfigManager.new(workflow_path, environ: %{}) + {manager, _, _} = ConfigManager.load_startup(manager) + manager + end + + @tag :tmp_dir + test "sort and blocker eligibility", %{tmp_dir: tmp_dir} do + orchestrator = Orchestrator.new(make_manager(tmp_dir)) + {_, _, config} = ConfigManager.current(orchestrator.config_manager) + + blocked = %Issue{ + id: "2", + identifier: "ABC-2", + title: "Blocked", + priority: 1, + state: "Todo", + blocked_by: [%BlockerRef{identifier: "ABC-1", state: "In Progress"}] + } + + unblocked = %Issue{ + id: "1", + identifier: "ABC-1", + title: "Ready", + priority: 2, + state: "Todo", + labels: ["codex"], + blocked_by: [%BlockerRef{identifier: "ABC-0", state: "Done"}] + } + + assert hd(Orchestrator.sort_for_dispatch([unblocked, blocked])) == blocked + refute Orchestrator.is_dispatch_eligible(orchestrator, blocked, config) + assert Orchestrator.is_dispatch_eligible(orchestrator, unblocked, config) + end + + @tag :tmp_dir + test "required label gate", %{tmp_dir: tmp_dir} do + orchestrator = Orchestrator.new(make_manager(tmp_dir)) + {_, _, config} = ConfigManager.current(orchestrator.config_manager) + + missing_label = %Issue{ + id: "1", + identifier: "ABC-1", + title: "Ready", + state: "In Progress", + labels: ["backend"] + } + + matching = %Issue{ + id: "2", + identifier: "ABC-2", + title: "Ready", + state: "In Progress", + labels: ["Codex", "backend"] + } + + refute Orchestrator.is_dispatch_eligible(orchestrator, missing_label, config) + assert Orchestrator.is_dispatch_eligible(orchestrator, matching, config) + end + + @tag :tmp_dir + test "rules planner tie blocks are released when current rules resolve cleanly", %{ + tmp_dir: tmp_dir + } do + orchestrator = Orchestrator.new(make_repo_manager(tmp_dir)) + {_, _, config} = ConfigManager.current(orchestrator.config_manager) + + issue = %Issue{ + id: "CRTTA-262", + identifier: "CRTTA-262", + title: ~s(Calendar sync: 403 "insufficient auth scopes" in prod + breaks on refresh in dev), + description: + "Google Calendar APIs return insufficient scopes. Add telemetry and compare Google Cloud Console OAuth scopes.", + state: "Todo", + labels: ["codex", "auth", "bug"] + } + + blocked = %BlockedEntry{ + issue: issue, + reason: + "Rules planner found tied primary repositories: CarettaAI/caretta-metrics, CarettaAI/caretta-webapp", + blocked_at: Utils.now_utc(), + repo_plan: %RepoPlan{ + issue_identifier: issue.identifier, + coding_task: true, + planner: "llm", + source: "fallback:rules", + needs_human: true, + human_reason: + "Rules planner found tied primary repositories: CarettaAI/caretta-metrics, CarettaAI/caretta-webapp", + primary_repo: %RepoPlanItem{slug: "CarettaAI/caretta-metrics", role: "primary"} + } + } + + orchestrator = %{ + orchestrator + | state: %{ + orchestrator.state + | blocked: %{issue.id => blocked}, + claimed: MapSet.new([issue.id]) + } + } + + orchestrator = Orchestrator.reconcile_blocked_issues(orchestrator, [issue], config) + + refute Map.has_key?(orchestrator.state.blocked, issue.id) + refute MapSet.member?(orchestrator.state.claimed, issue.id) + assert Orchestrator.is_dispatch_eligible(orchestrator, issue, config) + end + + @tag :tmp_dir + test "blocked issues are released when Linear state leaves active states", %{tmp_dir: tmp_dir} do + orchestrator = Orchestrator.new(make_repo_manager(tmp_dir)) + {_, _, config} = ConfigManager.current(orchestrator.config_manager) + + issue = %Issue{ + id: "CRTTA-277", + identifier: "CRTTA-277", + title: "Data migration", + state: "In Review", + labels: ["codex"] + } + + blocked = %BlockedEntry{ + issue: issue, + reason: "unresolved_external_blocker", + blocked_at: Utils.now_utc(), + repo_plan: %RepoPlan{ + issue_identifier: issue.identifier, + coding_task: true, + source: "llm", + needs_human: false, + primary_repo: %RepoPlanItem{slug: "CarettaAI/kb-service", role: "primary"} + } + } + + orchestrator = %{ + orchestrator + | state: %{ + orchestrator.state + | blocked: %{issue.id => blocked}, + claimed: MapSet.new([issue.id]) + } + } + + orchestrator = Orchestrator.reconcile_blocked_issues(orchestrator, [issue], config) + + refute Map.has_key?(orchestrator.state.blocked, issue.id) + refute MapSet.member?(orchestrator.state.claimed, issue.id) + end + + @tag :tmp_dir + test "blocked issues are released when absent from active candidates", %{tmp_dir: tmp_dir} do + orchestrator = Orchestrator.new(make_repo_manager(tmp_dir)) + {_, _, config} = ConfigManager.current(orchestrator.config_manager) + + issue = %Issue{ + id: "CRTTA-277", + identifier: "CRTTA-277", + title: "Data migration", + state: "In Progress", + labels: ["codex"] + } + + blocked = %BlockedEntry{ + issue: issue, + reason: "unresolved_external_blocker", + blocked_at: Utils.now_utc() + } + + orchestrator = %{ + orchestrator + | state: %{ + orchestrator.state + | blocked: %{issue.id => blocked}, + claimed: MapSet.new([issue.id]) + } + } + + orchestrator = + Orchestrator.reconcile_blocked_issues(orchestrator, [], config, + candidate_snapshot_complete: true + ) + + refute Map.has_key?(orchestrator.state.blocked, issue.id) + refute MapSet.member?(orchestrator.state.claimed, issue.id) + end + + @tag :tmp_dir + test "blocked worker results escalate to Linear assignee", %{tmp_dir: tmp_dir} do + parent = self() + + tracker = %{ + list_issue_comments: fn "ABC-1" -> + send(parent, :list_comments) + [] + end, + save_issue_comment: fn "ABC-1", body, opts -> + send(parent, {:save_comment, body, opts}) + %{"id" => "comment-1"} + end + } + + orchestrator = + make_manager(tmp_dir) + |> Orchestrator.new() + |> Map.put(:tracker_factory, fn _ -> tracker end) + + issue = %Issue{ + id: "1", + identifier: "ABC-1", + title: "Ready", + state: "In Progress", + labels: ["codex"], + assignee: %IssueAssignee{display_name: "Omar", mention: "@omar"} + } + + entry = %RunningEntry{ + issue: issue, + workspace_path: tmp_dir, + started_at: Utils.now_utc(), + started_monotonic: System.monotonic_time(:millisecond) + } + + orchestrator = put_in(orchestrator.state.running[issue.id], entry) + + orchestrator = + Orchestrator.handle_worker_done(orchestrator, issue.id, %AgentRunResult{ + issue_id: issue.id, + issue_identifier: issue.identifier, + blocked: true, + reason: "missing production credentials" + }) + + assert_received :list_comments + assert_received {:save_comment, body, []} + assert body =~ "## Symphony Blocked Escalation" + assert body =~ "@omar" + assert body =~ "missing production credentials" + assert body =~ "After a human reply" + + blocked = orchestrator.state.blocked[issue.id] + assert blocked.escalation_comment_id == "comment-1" + assert blocked.escalation_at + assert is_binary(blocked.escalation_fingerprint) + end + + @tag :tmp_dir + test "blocked issues are released when a human responds after escalation", %{tmp_dir: tmp_dir} do + orchestrator = Orchestrator.new(make_manager(tmp_dir)) + {_, _, config} = ConfigManager.current(orchestrator.config_manager) + blocked_at = ~U[2026-04-30 12:00:00Z] + + issue = %Issue{ + id: "1", + identifier: "ABC-1", + title: "Ready", + state: "In Progress", + labels: ["codex"] + } + + blocked = %BlockedEntry{ + issue: issue, + reason: "needs product decision", + blocked_at: blocked_at, + escalation_comment_id: "comment-1" + } + + tracker = %{ + list_issue_comments: fn "ABC-1" -> + [ + %{ + "id" => "comment-1", + "body" => "## Symphony Blocked Escalation\nold", + "createdAt" => "2026-04-30T12:01:00Z", + "author" => %{"name" => "Symphony", "type" => "bot"} + }, + %{ + "id" => "comment-2", + "body" => "Use caretta-webapp for this.", + "createdAt" => "2026-04-30T12:02:00Z", + "author" => %{"name" => "Omar"} + } + ] + end + } + + orchestrator = %{ + orchestrator + | state: %{ + orchestrator.state + | blocked: %{issue.id => blocked}, + claimed: MapSet.new([issue.id]) + } + } + + orchestrator = + Orchestrator.reconcile_blocked_issues(orchestrator, [issue], config, tracker: tracker) + + refute Map.has_key?(orchestrator.state.blocked, issue.id) + refute MapSet.member?(orchestrator.state.claimed, issue.id) + end + + @tag :tmp_dir + test "review reconciliation moves done only after all PRs merged", %{tmp_dir: tmp_dir} do + parent = self() + + tracker = %{ + fetch_issues_by_states: fn ["In Review", "Merging"] -> + [ + %Issue{ + id: "ABC-1", + identifier: "ABC-1", + title: "Ready", + state: "In Review", + labels: ["codex"], + attachments: [%IssueAttachment{url: "https://github.com/ExampleOrg/app/pull/1"}] + } + ] + end, + fetch_issue_states_by_ids: fn ["ABC-1"] -> + [ + %Issue{ + id: "ABC-1", + identifier: "ABC-1", + title: "Ready", + state: "In Review", + labels: ["codex"], + attachments: [%IssueAttachment{url: "https://github.com/ExampleOrg/app/pull/1"}] + } + ] + end, + list_issue_comments: fn "ABC-1" -> + [%{"body" => "## Codex Workpad\nhttps://github.com/ExampleOrg/api/pull/2"}] + end, + save_issue_state: fn issue_id, state -> + send(parent, {:saved_state, issue_id, state}) + %{"id" => issue_id, "state" => state} + end + } + + resolver = %{ + evaluate: fn issue, opts -> + assert issue.identifier == "ABC-1" + assert Keyword.fetch!(opts, :base_branch) == "dev" + assert Keyword.fetch!(opts, :comments) != [] + + %ReviewMergeResult{ + ready: true, + required_prs: [ + %PullRequestInfo{ + ref: %PullRequestRef{owner: "ExampleOrg", repo: "app", number: 1}, + url: "https://github.com/ExampleOrg/app/pull/1", + state: "MERGED", + base_ref_name: "dev" + }, + %PullRequestInfo{ + ref: %PullRequestRef{owner: "ExampleOrg", repo: "api", number: 2}, + url: "https://github.com/ExampleOrg/api/pull/2", + state: "MERGED", + base_ref_name: "dev" + } + ] + } + end + } + + orchestrator = Orchestrator.new(make_manager(tmp_dir), review_resolver: resolver) + {_, _, config} = ConfigManager.current(orchestrator.config_manager) + Orchestrator.reconcile_review_issues(orchestrator, tracker, config) + + assert_received {:saved_state, "ABC-1", "Done"} + end + + @tag :tmp_dir + test "review reconciliation dispatches through tracker structs", %{tmp_dir: tmp_dir} do + orchestrator = Orchestrator.new(make_manager(tmp_dir)) + {_, _, config} = ConfigManager.current(orchestrator.config_manager) + + Orchestrator.reconcile_review_issues(orchestrator, %StructTracker{parent: self()}, config) + + assert_received {:struct_tracker_fetch_issues_by_states, ["In Review", "Merging"]} + end + + @tag :tmp_dir + test "review reconciliation baselines then moves to rework for new Linear feedback", %{ + tmp_dir: tmp_dir + } do + parent = self() + + issue = %Issue{ + id: "ABC-1", + identifier: "ABC-1", + title: "Ready", + state: "In Review", + labels: ["codex"] + } + + tracker = fn comments -> + %{ + fetch_issues_by_states: fn ["In Review", "Merging"] -> [issue] end, + fetch_issue_states_by_ids: fn ["ABC-1"] -> [issue] end, + list_issue_comments: fn "ABC-1" -> comments end, + save_issue_state: fn issue_id, state -> + send(parent, {:saved_state, issue_id, state}) + %{"id" => issue_id, "state" => state} + end + } + end + + resolver = %{ + evaluate: fn _issue, _opts -> %ReviewMergeResult{ready: false, required_prs: []} end + } + + orchestrator = Orchestrator.new(make_manager(tmp_dir), review_resolver: resolver) + {_, _, config} = ConfigManager.current(orchestrator.config_manager) + + first = [ + %{"id" => "1", "body" => "Initial review note", "createdAt" => "2026-04-29T10:00:00Z"} + ] + + orchestrator = Orchestrator.reconcile_review_issues(orchestrator, tracker.(first), config) + + refute_received {:saved_state, "ABC-1", _} + assert orchestrator.state.review_feedback["ABC-1"].fingerprint + refute orchestrator.state.review_feedback["ABC-1"].last_triggered_at + + second = + first ++ + [ + %{ + "id" => "2", + "body" => "Please update the empty state.", + "createdAt" => "2026-04-29T10:05:00Z" + } + ] + + orchestrator = Orchestrator.reconcile_review_issues(orchestrator, tracker.(second), config) + + assert_received {:saved_state, "ABC-1", "Rework"} + assert orchestrator.state.review_feedback["ABC-1"].last_triggered_at + end + + @tag :tmp_dir + test "review reconciliation moves to rework for new PR feedback", %{tmp_dir: tmp_dir} do + parent = self() + + ref = %PullRequestRef{owner: "ExampleOrg", repo: "app", number: 1} + + pr = %PullRequestInfo{ + ref: ref, + url: "https://github.com/ExampleOrg/app/pull/1", + state: "OPEN", + base_ref_name: "dev" + } + + issue = %Issue{ + id: "ABC-1", + identifier: "ABC-1", + title: "Ready", + state: "In Review", + labels: ["codex"], + attachments: [%IssueAttachment{url: pr.url}] + } + + tracker = %{ + fetch_issues_by_states: fn ["In Review", "Merging"] -> [issue] end, + fetch_issue_states_by_ids: fn ["ABC-1"] -> [issue] end, + list_issue_comments: fn "ABC-1" -> [] end, + save_issue_state: fn issue_id, state -> + send(parent, {:saved_state, issue_id, state}) + %{"id" => issue_id, "state" => state} + end + } + + inspector = fn feedback -> + %{ + view_pr_url: fn _url -> pr end, + view_pr_ref: fn _ref -> nil end, + list_prs_for_branch: fn _repo, _branch, _base -> [] end, + list_pr_feedback: fn _ref -> feedback end + } + end + + orchestrator = + Orchestrator.new(make_manager(tmp_dir), + review_resolver: Symphony.Review.ReviewPullRequestResolver.new(inspector.([])) + ) + + {_, _, config} = ConfigManager.current(orchestrator.config_manager) + orchestrator = Orchestrator.reconcile_review_issues(orchestrator, tracker, config) + refute_received {:saved_state, "ABC-1", _} + + feedback = [ + %Symphony.Review.ReviewFeedbackItem{ + source: "github_pr_review_comment", + id: "ExampleOrg/app#1:github_pr_review_comment:1", + author: "reviewer", + author_type: "User", + body: "Please cover this branch in the tests.", + updated_at: ~U[2026-04-29 10:10:00Z] + } + ] + + orchestrator = %{ + orchestrator + | review_resolver: Symphony.Review.ReviewPullRequestResolver.new(inspector.(feedback)) + } + + Orchestrator.reconcile_review_issues(orchestrator, tracker, config) + + assert_received {:saved_state, "ABC-1", "Rework"} + end + + @tag :tmp_dir + test "review reconciliation ignores bot and workpad feedback", %{tmp_dir: tmp_dir} do + parent = self() + + issue = %Issue{ + id: "ABC-1", + identifier: "ABC-1", + title: "Ready", + state: "In Review", + labels: ["codex"] + } + + tracker = fn comments -> + %{ + fetch_issues_by_states: fn ["In Review", "Merging"] -> [issue] end, + fetch_issue_states_by_ids: fn ["ABC-1"] -> [issue] end, + list_issue_comments: fn "ABC-1" -> comments end, + save_issue_state: fn issue_id, state -> + send(parent, {:saved_state, issue_id, state}) + %{"id" => issue_id, "state" => state} + end + } + end + + resolver = %{ + evaluate: fn _issue, _opts -> %ReviewMergeResult{ready: false, required_prs: []} end + } + + orchestrator = Orchestrator.new(make_manager(tmp_dir), review_resolver: resolver) + {_, _, config} = ConfigManager.current(orchestrator.config_manager) + orchestrator = Orchestrator.reconcile_review_issues(orchestrator, tracker.([]), config) + + comments = [ + %{ + "id" => "bot", + "body" => "Automated result", + "user" => %{"login" => "ci-bot", "type" => "Bot"}, + "createdAt" => "2026-04-29T10:00:00Z" + }, + %{"id" => "workpad", "body" => "## Codex Workpad\nupdated"} + ] + + Orchestrator.reconcile_review_issues(orchestrator, tracker.(comments), config) + + refute_received {:saved_state, "ABC-1", _} + end + + @tag :tmp_dir + test "review reconciliation still moves done when feedback is unchanged", %{tmp_dir: tmp_dir} do + parent = self() + + comment = %{ + "id" => "1", + "body" => "Looks good after the latest fix.", + "createdAt" => "2026-04-29T10:00:00Z" + } + + snapshot = Symphony.Review.feedback_snapshot([comment], []) + + issue = %Issue{ + id: "ABC-1", + identifier: "ABC-1", + title: "Ready", + state: "In Review", + labels: ["codex"] + } + + tracker = %{ + fetch_issues_by_states: fn ["In Review", "Merging"] -> [issue] end, + fetch_issue_states_by_ids: fn ["ABC-1"] -> [issue] end, + list_issue_comments: fn "ABC-1" -> [comment] end, + save_issue_state: fn issue_id, state -> + send(parent, {:saved_state, issue_id, state}) + %{"id" => issue_id, "state" => state} + end + } + + resolver = %{evaluate: fn _issue, _opts -> %ReviewMergeResult{ready: true} end} + orchestrator = Orchestrator.new(make_manager(tmp_dir), review_resolver: resolver) + + orchestrator = %{ + orchestrator + | state: %{ + orchestrator.state + | review_feedback: %{ + issue.id => %ReviewFeedbackState{ + issue_id: issue.id, + identifier: issue.identifier, + fingerprint: snapshot.fingerprint, + latest_feedback_at: snapshot.latest_feedback_at + } + } + } + } + + {_, _, config} = ConfigManager.current(orchestrator.config_manager) + Orchestrator.reconcile_review_issues(orchestrator, tracker, config) + + assert_received {:saved_state, "ABC-1", "Done"} + refute_received {:saved_state, "ABC-1", "Rework"} + end + + @tag :tmp_dir + test "review feedback state persists across restart", %{tmp_dir: tmp_dir} do + issue = %Issue{ + id: "ABC-1", + identifier: "ABC-1", + title: "Ready", + state: "In Review", + labels: ["codex"] + } + + tracker = %{ + fetch_issues_by_states: fn ["In Review", "Merging"] -> [issue] end, + fetch_issue_states_by_ids: fn ["ABC-1"] -> [issue] end, + list_issue_comments: fn "ABC-1" -> + [ + %{ + "id" => "1", + "body" => "Keep the compact variant.", + "createdAt" => "2026-04-29T10:00:00Z" + } + ] + end, + save_issue_state: fn issue_id, state -> %{"id" => issue_id, "state" => state} end + } + + resolver = %{ + evaluate: fn _issue, _opts -> %ReviewMergeResult{ready: false, required_prs: []} end + } + + manager = make_manager(tmp_dir) + orchestrator = Orchestrator.new(manager, review_resolver: resolver) + {_, _, config} = ConfigManager.current(orchestrator.config_manager) + orchestrator = Orchestrator.reconcile_review_issues(orchestrator, tracker, config) + + loaded = Orchestrator.new(make_manager(tmp_dir)) + + assert loaded.state.review_feedback["ABC-1"].fingerprint == + orchestrator.state.review_feedback["ABC-1"].fingerprint + end + + @tag :tmp_dir + test "runtime review reconciliation failure does not block active dispatch", %{tmp_dir: tmp_dir} do + Application.put_env(:caretta_symphony, :runner_parent, self()) + on_exit(fn -> Application.delete_env(:caretta_symphony, :runner_parent) end) + + candidate = %Issue{ + id: "candidate", + identifier: "ABC-2", + title: "Ready candidate", + state: "In Progress", + labels: ["codex"] + } + + review = %Issue{ + id: "review", + identifier: "ABC-1", + title: "Review issue", + state: "In Review", + labels: ["codex"] + } + + tracker = %{ + fetch_issues_by_states: fn + ["In Review", "Merging"] -> [review] + _states -> [] + end, + fetch_issue_states_by_ids: fn + ["review"] -> [review] + _ids -> [] + end, + fetch_candidate_issues: fn -> [candidate] end, + list_issue_comments: fn _identifier -> [] end, + save_issue_state: fn _identifier, _state -> %{} end + } + + resolver = %{evaluate: fn _issue, _opts -> raise "review provider unavailable" end} + + {:ok, pid} = + Orchestrator.start_link(make_summary_manager(tmp_dir), + agent_runner: FastRunner, + review_resolver: resolver, + tracker_factory: fn _config -> tracker end + ) + + try do + assert_receive {:runner_called, "ABC-2", nil}, 1_000 + + assert eventually(fn -> + snapshot = Orchestrator.cached_snapshot(pid) + + get_in(snapshot, ["service", "status"]) == "running" and + get_in(snapshot, ["service", "last_poll_error"]) == nil + end) + after + Orchestrator.stop(pid) + end + end + + @tag :tmp_dir + test "retry backoff is capped", %{tmp_dir: tmp_dir} do + orchestrator = Orchestrator.new(make_manager(tmp_dir)) + issue = %Issue{id: "1", identifier: "ABC-1", title: "Ready", state: "In Progress"} + + orchestrator = Orchestrator.schedule_retry(orchestrator, issue, 3, error: "boom") + retry = orchestrator.state.retry_attempts["1"] + + assert retry.attempt == 3 + assert retry.error == "boom" + assert retry.due_at_monotonic - System.monotonic_time(:millisecond) < 20_000 + + state = Orchestrator.snapshot(orchestrator) + assert hd(state["retrying"])["kind"] == "retry" + assert state["counts"]["retrying"] == 1 + assert state["counts"]["continuing"] == 0 + end + + @tag :tmp_dir + test "due retries are cleared when the issue left active states", %{tmp_dir: tmp_dir} do + orchestrator = Orchestrator.new(make_manager(tmp_dir)) + {_, _, config} = ConfigManager.current(orchestrator.config_manager) + issue = %Issue{id: "1", identifier: "ABC-1", title: "Ready", state: "In Progress"} + orchestrator = Orchestrator.schedule_retry(orchestrator, issue, 1, delay_ms: 0) + + tracker = %{ + fetch_issue_states_by_ids: fn ["1"] -> + [%Issue{id: "1", identifier: "ABC-1", title: "Ready", state: "Done"}] + end + } + + orchestrator = Orchestrator.process_due_retries(orchestrator, tracker, config) + + refute Map.has_key?(orchestrator.state.retry_attempts, issue.id) + end + + @tag :tmp_dir + test "normal worker completion schedules continuation", %{tmp_dir: tmp_dir} do + orchestrator = Orchestrator.new(make_manager(tmp_dir)) + + issue = %Issue{ + id: "1", + identifier: "ABC-1", + title: "Ready", + state: "In Progress", + labels: ["codex"] + } + + entry = %RunningEntry{ + issue: issue, + workspace_path: tmp_dir, + started_at: Utils.now_utc(), + started_monotonic: System.monotonic_time(:millisecond) + } + + orchestrator = put_in(orchestrator.state.running[issue.id], entry) + + orchestrator = + put_in(orchestrator.state.claimed, MapSet.put(orchestrator.state.claimed, issue.id)) + + result = %AgentRunResult{ + issue_id: issue.id, + issue_identifier: issue.identifier, + normal: true, + reason: "issue_left_active_state" + } + + orchestrator = Orchestrator.handle_worker_done(orchestrator, issue.id, result) + {_, _, config} = ConfigManager.current(orchestrator.config_manager) + + assert Map.has_key?(orchestrator.state.completed, issue.id) + assert Map.has_key?(orchestrator.state.retry_attempts, issue.id) + retry = orchestrator.state.retry_attempts[issue.id] + assert retry.attempt == 1 + assert retry.error == nil + assert retry.due_at_monotonic - System.monotonic_time(:millisecond) < 2_000 + + assert Orchestrator.is_dispatch_eligible(orchestrator, issue, config, + ignore_claimed_issue_id: issue.id + ) + + state = Orchestrator.snapshot(orchestrator) + assert hd(state["retrying"])["kind"] == "continuation" + assert state["counts"]["continuing"] == 1 + assert state["counts"]["retrying"] == 0 + assert state["counts"]["completed"] == 1 + assert hd(state["completed"])["issue_identifier"] == "ABC-1" + end + + @tag :tmp_dir + test "token usage absolute deltas are aggregated", %{tmp_dir: tmp_dir} do + orchestrator = Orchestrator.new(make_manager(tmp_dir)) + issue = %Issue{id: "1", identifier: "ABC-1", title: "Ready", state: "In Progress"} + + entry = %RunningEntry{ + issue: issue, + workspace_path: nil, + started_at: Utils.now_utc(), + started_monotonic: System.monotonic_time(:millisecond) + } + + orchestrator = put_in(orchestrator.state.running[issue.id], entry) + + orchestrator = + Orchestrator.handle_codex_event(orchestrator, "1", %{ + "event" => "thread_tokenUsage_updated", + "usage_absolute" => %{"input_tokens" => 10, "output_tokens" => 5, "total_tokens" => 15} + }) + + orchestrator = + Orchestrator.handle_codex_event(orchestrator, "1", %{ + "event" => "thread_tokenUsage_updated", + "usage_absolute" => %{"input_tokens" => 12, "output_tokens" => 7, "total_tokens" => 19} + }) + + assert orchestrator.state.codex_totals.input_tokens == 12 + assert orchestrator.state.codex_totals.output_tokens == 7 + assert orchestrator.state.codex_totals.total_tokens == 19 + end + + @tag :tmp_dir + test "agent message delta fragments do not inflate recent activity", %{tmp_dir: tmp_dir} do + orchestrator = Orchestrator.new(make_manager(tmp_dir)) + issue = %Issue{id: "1", identifier: "ABC-1", title: "Ready", state: "In Progress"} + + entry = %RunningEntry{ + issue: issue, + workspace_path: nil, + started_at: Utils.now_utc(), + started_monotonic: System.monotonic_time(:millisecond) + } + + orchestrator = put_in(orchestrator.state.running[issue.id], entry) + + orchestrator = + Orchestrator.handle_codex_event(orchestrator, issue.id, %{ + "event" => "item_agentMessage_delta", + "message" => "Care" + }) + + entry = orchestrator.state.running[issue.id] + assert entry.recent_activity == [] + assert entry.activity_revision == 0 + assert entry.last_codex_event == "item_agentMessage_delta" + end + + @tag :tmp_dir + test "runtime state persists across orchestrator restart", %{tmp_dir: tmp_dir} do + manager = make_manager(tmp_dir) + orchestrator = Orchestrator.new(manager) + + issue = %Issue{ + id: "1", + identifier: "ABC-1", + title: "Ready", + state: "In Progress", + labels: ["codex"] + } + + entry = %RunningEntry{ + issue: issue, + workspace_path: tmp_dir, + started_at: Utils.now_utc(), + started_monotonic: System.monotonic_time(:millisecond), + summary_text: "Implementation complete.", + turn_count: 2 + } + + orchestrator = put_in(orchestrator.state.running[issue.id], entry) + + orchestrator = + Orchestrator.handle_codex_event(orchestrator, issue.id, %{ + "event" => "thread_tokenUsage_updated", + "usage_absolute" => %{"input_tokens" => 7, "output_tokens" => 8, "total_tokens" => 15} + }) + + _orchestrator = + Orchestrator.handle_worker_done(orchestrator, issue.id, %AgentRunResult{ + issue_id: issue.id, + issue_identifier: issue.identifier, + normal: true, + reason: "issue_left_active_state" + }) + + reloaded = Orchestrator.new(manager) + state = Orchestrator.snapshot(reloaded) + + assert state["codex_totals"]["input_tokens"] == 7 + assert state["codex_totals"]["output_tokens"] == 8 + assert state["codex_totals"]["total_tokens"] == 15 + assert state["counts"]["completed"] == 1 + assert hd(state["completed"])["issue_identifier"] == "ABC-1" + assert get_in(hd(state["completed"]), ["summary", "text"]) == "Implementation complete." + assert hd(state["retrying"])["kind"] == "continuation" + end + + @tag :tmp_dir + test "snapshot includes activity and dashboard summary", %{tmp_dir: tmp_dir} do + orchestrator = Orchestrator.new(make_manager(tmp_dir)) + + issue = %Issue{ + id: "1", + identifier: "ABC-1", + title: "Ready", + state: "In Progress", + labels: ["codex"] + } + + entry = %RunningEntry{ + issue: issue, + workspace_path: tmp_dir, + started_at: Utils.now_utc(), + started_monotonic: System.monotonic_time(:millisecond), + summary_text: "The agent is inspecting the repo.", + summary_current_step: "Inspect architecture", + summary_needs_human: true, + summary_human_reason: "Repo choice is ambiguous.", + summary_risk: "high", + summary_confidence: 0.82, + summary_source: "llm" + } + + orchestrator = put_in(orchestrator.state.running[issue.id], entry) + + orchestrator = + Orchestrator.handle_codex_event(orchestrator, "1", %{ + "event" => "item_completed", + "payload" => %{ + "item" => %{ + "type" => "commandExecution", + "command" => "rg provider", + "status" => "completed" + } + }, + "message" => "command=rg provider status=completed" + }) + + state = Orchestrator.snapshot(orchestrator) + running = hd(state["running"]) + + assert running["title"] == "Ready" + assert get_in(running, ["summary", "text"]) == "The agent is inspecting the repo." + assert get_in(running, ["summary", "needs_human"]) + assert hd(running["activity"])["message"] == "Command completed: rg provider" + end + + @tag :tmp_dir + test "snapshot flags possible repo boundary mismatch", %{tmp_dir: tmp_dir} do + orchestrator = Orchestrator.new(make_manager(tmp_dir)) + + issue = %Issue{ + id: "1", + identifier: "ABC-1", + title: "Screen capture for live call answers", + state: "In Progress", + labels: ["codex"] + } + + entry = %RunningEntry{ + issue: issue, + workspace_path: tmp_dir, + started_at: Utils.now_utc(), + started_monotonic: System.monotonic_time(:millisecond) + } + + orchestrator = put_in(orchestrator.state.running[issue.id], entry) + + orchestrator = + Orchestrator.handle_codex_event(orchestrator, "1", %{ + "event" => "item_completed", + "payload" => %{ + "item" => %{ + "type" => "commandExecution", + "command" => + "git diff -- infrastructure/config/schemas/functions/analyze_transcript/system_template.minijinja", + "status" => "completed" + } + }, + "message" => "command=git diff status=completed" + }) + + summary = Orchestrator.snapshot(orchestrator)["running"] |> hd() |> Map.fetch!("summary") + assert summary["needs_human"] + assert summary["risk"] == "high" + assert summary["human_reason"] =~ "repo boundary" + end + + @tag :tmp_dir + test "snapshot flags file changes outside repo plan", %{tmp_dir: tmp_dir} do + orchestrator = Orchestrator.new(make_manager(tmp_dir)) + + issue = %Issue{ + id: "1", + identifier: "ABC-1", + title: "Live suggestions", + state: "In Progress", + labels: ["codex"] + } + + workspace = Path.join(tmp_dir, "workspace") + File.mkdir!(workspace) + + entry = %RunningEntry{ + issue: issue, + workspace_path: workspace, + started_at: Utils.now_utc(), + started_monotonic: System.monotonic_time(:millisecond), + repo_plan: %RepoPlan{ + issue_identifier: "ABC-1", + coding_task: true, + planner: "llm", + source: "llm", + primary_repo: %RepoPlanItem{ + slug: "ExampleOrg/desktop-runtime", + role: "primary", + path_name: "desktop-runtime" + }, + read_only_context_repos: [ + %RepoPlanItem{ + slug: "ExampleOrg/knowledge-docs", + role: "read_only_context", + path_name: "knowledge-docs", + edit_allowed: false + } + ] + } + } + + orchestrator = put_in(orchestrator.state.running[issue.id], entry) + + orchestrator = + Orchestrator.handle_codex_event(orchestrator, "1", %{ + "event" => "item_completed", + "payload" => %{ + "item" => %{ + "type" => "fileChange", + "path" => Path.join([workspace, "repos", "knowledge-docs", "README.md"]), + "status" => "updated" + } + }, + "message" => "file changed" + }) + + running = Orchestrator.snapshot(orchestrator)["running"] |> hd() + assert get_in(running, ["summary", "needs_human"]) + assert get_in(running, ["summary", "human_reason"]) =~ "read-only repo" + assert running["repo_deviations"] != [] + end + + @tag :tmp_dir + test "genserver schedules dashboard summary on useful activity", %{tmp_dir: tmp_dir} do + Application.put_env(:caretta_symphony, :summary_parent, self()) + on_exit(fn -> Application.delete_env(:caretta_symphony, :summary_parent) end) + + {:ok, pid} = + Orchestrator.start_link(make_summary_manager(tmp_dir), + dashboard_summary: FakeSummary, + tracker_factory: fn _config -> empty_tracker() end + ) + + try do + issue = %Issue{ + id: "1", + identifier: "ABC-1", + title: "Ready", + state: "In Progress", + labels: ["codex"] + } + + :sys.replace_state(pid, fn orchestrator -> + entry = %RunningEntry{ + issue: issue, + workspace_path: tmp_dir, + started_at: Utils.now_utc(), + started_monotonic: System.monotonic_time(:millisecond) + } + + put_in(orchestrator.state.running[issue.id], entry) + end) + + GenServer.cast(pid, {:codex_event, issue.id, command_event()}) + + assert_receive {:summary_called, opts}, 1_000 + assert Keyword.fetch!(opts, :issue).identifier == "ABC-1" + + assert eventually(fn -> + summary = Orchestrator.snapshot(pid)["running"] |> hd() |> Map.fetch!("summary") + + summary["text"] == "The agent is searching the codebase." and + summary["current_step"] == "Inspect matching files" and + summary["pending"] == false + end) + after + Orchestrator.stop(pid) + end + end + + @tag :tmp_dir + test "genserver applies dashboard summary timeout as error state", %{tmp_dir: tmp_dir} do + {:ok, pid} = + Orchestrator.start_link(make_summary_manager(tmp_dir, 10), + dashboard_summary: HangingSummary, + tracker_factory: fn _config -> empty_tracker() end + ) + + try do + issue = %Issue{ + id: "1", + identifier: "ABC-1", + title: "Ready", + state: "In Progress", + labels: ["codex"] + } + + :sys.replace_state(pid, fn orchestrator -> + entry = %RunningEntry{ + issue: issue, + workspace_path: tmp_dir, + started_at: Utils.now_utc(), + started_monotonic: System.monotonic_time(:millisecond) + } + + put_in(orchestrator.state.running[issue.id], entry) + end) + + GenServer.cast(pid, {:codex_event, issue.id, command_event()}) + + assert eventually(fn -> + summary = Orchestrator.snapshot(pid)["running"] |> hd() |> Map.fetch!("summary") + summary["pending"] == false and to_string(summary["error"]) =~ "summary timed out" + end) + after + Orchestrator.stop(pid) + end + end + + @tag :tmp_dir + test "genserver ignores normal linked process exit messages", %{tmp_dir: tmp_dir} do + {:ok, pid} = + Orchestrator.start_link(make_summary_manager(tmp_dir), + tracker_factory: fn _config -> empty_tracker() end + ) + + try do + send(pid, {:EXIT, make_ref(), :normal}) + Process.sleep(25) + assert Process.alive?(pid) + after + Orchestrator.stop(pid) + end + end + + @tag :tmp_dir + test "HTTP state reads live cache while orchestrator mailbox is busy", %{tmp_dir: tmp_dir} do + parent = self() + + tracker = %{ + fetch_issues_by_states: fn _states -> [] end, + fetch_issue_states_by_ids: fn _ids -> [] end, + fetch_candidate_issues: fn -> + send(parent, :slow_candidate_fetch_started) + Process.sleep(750) + [] + end + } + + {:ok, pid} = + Orchestrator.start_link(make_summary_manager(tmp_dir), + tracker_factory: fn _config -> tracker end + ) + + server = HTTPServer.start(pid, port: 0) + + try do + assert_receive :slow_candidate_fetch_started, 1_000 + + {:ok, {{_, 200, _}, _headers, body}} = + :httpc.request( + :get, + {~c"http://127.0.0.1:#{server.bound_port}/api/v1/state", []}, + [], + body_format: :binary + ) + + state = Jason.decode!(body) + assert get_in(state, ["service", "snapshot_source"]) == "live_cache" + refute get_in(state, ["service", "status"]) == "busy" + after + HTTPServer.stop(server) + Orchestrator.stop(pid) + end + end + + @tag :tmp_dir + test "HTTP state reads starting live cache while startup cleanup is busy", %{tmp_dir: tmp_dir} do + parent = self() + + tracker = %{ + fetch_issues_by_states: fn _states -> + send(parent, :slow_startup_cleanup_started) + Process.sleep(750) + [] + end, + fetch_issue_states_by_ids: fn _ids -> [] end, + fetch_candidate_issues: fn -> [] end + } + + {:ok, pid} = + Orchestrator.start_link(make_summary_manager(tmp_dir), + tracker_factory: fn _config -> tracker end + ) + + server = HTTPServer.start(pid, port: 0) + + try do + assert_receive :slow_startup_cleanup_started, 1_000 + + {:ok, {{_, 200, _}, _headers, body}} = + :httpc.request( + :get, + {~c"http://127.0.0.1:#{server.bound_port}/api/v1/state", []}, + [], + body_format: :binary + ) + + state = Jason.decode!(body) + assert get_in(state, ["service", "snapshot_source"]) == "live_cache" + refute get_in(state, ["service", "status"]) == "busy" + after + HTTPServer.stop(server) + Orchestrator.stop(pid) + end + end + + @tag :tmp_dir + test "cached issue snapshot covers running retrying blocked and completed entries", %{ + tmp_dir: tmp_dir + } do + {:ok, pid} = + Orchestrator.start_link(make_summary_manager(tmp_dir), + tracker_factory: fn _config -> empty_tracker() end + ) + + try do + assert eventually(fn -> + get_in(Orchestrator.snapshot(pid), ["service", "last_poll_completed_at"]) + end) + + now = Utils.now_utc() + + running_issue = %Issue{ + id: "running", + identifier: "ABC-1", + title: "Running", + state: "In Progress", + labels: ["codex"] + } + + retry_issue = %Issue{ + id: "retry", + identifier: "ABC-2", + title: "Retrying", + state: "In Progress", + labels: ["codex"] + } + + blocked_issue = %Issue{ + id: "blocked", + identifier: "ABC-3", + title: "Blocked", + state: "In Progress", + labels: ["codex"] + } + + completed_issue = %Issue{ + id: "completed", + identifier: "ABC-4", + title: "Completed", + state: "In Review", + labels: ["codex"] + } + + :sys.replace_state(pid, fn orchestrator -> + running_entry = %RunningEntry{ + issue: running_issue, + workspace_path: tmp_dir, + started_at: now, + started_monotonic: System.monotonic_time(:millisecond) + } + + retry = %RetryEntry{ + issue_id: retry_issue.id, + identifier: retry_issue.identifier, + attempt: 2, + due_at_monotonic: System.monotonic_time(:millisecond) + 60_000, + due_at_wall: DateTime.add(now, 60, :second), + error: "boom" + } + + blocked = %BlockedEntry{ + issue: blocked_issue, + reason: "needs human", + blocked_at: now, + workspace_path: tmp_dir + } + + completed = %CompletedEntry{ + issue: completed_issue, + completed_at: now, + reason: "done", + workspace_path: tmp_dir + } + + %{ + orchestrator + | state: %{ + orchestrator.state + | running: %{running_issue.id => running_entry}, + retry_attempts: %{retry_issue.id => retry}, + blocked: %{blocked_issue.id => blocked}, + completed: %{completed_issue.id => completed} + } + } + end) + + GenServer.cast(pid, {:codex_event, running_issue.id, command_event()}) + + assert eventually(fn -> + Orchestrator.cached_issue_snapshot(pid, "ABC-1")["status"] == "running" + end) + + assert Orchestrator.cached_issue_snapshot(pid, "ABC-2")["status"] == "retrying" + assert Orchestrator.cached_issue_snapshot(pid, "ABC-3")["status"] == "blocked" + assert Orchestrator.cached_issue_snapshot(pid, "ABC-4")["status"] == "completed" + after + Orchestrator.stop(pid) + end + end + + defp command_event do + %{ + "event" => "item_completed", + "payload" => %{ + "item" => %{ + "type" => "commandExecution", + "command" => "rg provider", + "status" => "completed" + } + }, + "message" => "command=rg provider status=completed" + } + end + + defp empty_tracker do + %{ + fetch_issues_by_states: fn _states -> [] end, + fetch_issue_states_by_ids: fn _ids -> [] end, + fetch_candidate_issues: fn -> [] end + } + end + + defp eventually(fun, attempts \\ 20) + + defp eventually(fun, attempts) when attempts > 0 do + if fun.() do + true + else + Process.sleep(25) + eventually(fun, attempts - 1) + end + end + + defp eventually(_fun, 0), do: false +end diff --git a/test/repo_planner_http_test.exs b/test/repo_planner_http_test.exs new file mode 100644 index 0000000..5cc63b3 --- /dev/null +++ b/test/repo_planner_http_test.exs @@ -0,0 +1,474 @@ +defmodule Symphony.RepoPlannerHttpTest do + use ExUnit.Case, async: true + + alias Symphony.CodingContext.CodingClassification + + alias Symphony.Config.{ + CodexConfig, + CodingContextConfig, + RepositoryConfig, + RepositoryPlanningConfig + } + + alias Symphony.HTTPServer + alias Symphony.Models.Issue + alias Symphony.Orchestrator + alias Symphony.RepoPlanner + + defmodule FakePlannerCodexClient do + def start_session(config, workspace_path, _opts) do + send(Process.get(:repo_planner_parent), {:planner_session_started, config, workspace_path}) + :fake_session + end + + def run_turn(:fake_session, prompt, opts) do + send(Process.get(:repo_planner_parent), {:planner_prompt, prompt, opts}) + + case Process.get(:repo_planner_response) do + {:raise, reason} -> + raise reason + + response -> + {%Symphony.CodexClient.TurnResult{agent_message_text: response}, :fake_session} + end + end + + def stop_session(:fake_session), do: send(Process.get(:repo_planner_parent), :planner_stopped) + end + + @tag :tmp_dir + test "rules planner picks a primary repo and injects guardrails", %{tmp_dir: tmp_dir} do + config = %RepositoryPlanningConfig{ + enabled: true, + planner: "rules", + repositories: [ + %RepositoryConfig{ + slug: "ExampleOrg/desktop-runtime", + aliases: ["desktop"], + description: "Desktop app runtime" + }, + %RepositoryConfig{ + slug: "ExampleOrg/model-gateway", + aliases: ["gateway"], + description: "Provider routing" + } + ] + } + + issue = %Issue{ + id: "1", + identifier: "ENG-1", + title: "Fix desktop overlay", + description: "The desktop runtime needs a provider integration.", + state: "Todo" + } + + classification = %CodingClassification{is_coding_task: true, source: "rules"} + plan = RepoPlanner.plan_repositories(issue, config, %CodingContextConfig{}, classification) + + assert plan.primary_repo.slug == "ExampleOrg/desktop-runtime" + assert plan.confidence > 0 + + prompt = RepoPlanner.apply_repo_plan_to_prompt("Original", plan, tmp_dir) + assert prompt =~ "" + assert prompt =~ "Git hygiene guardrail" + assert prompt =~ "ExampleOrg/desktop-runtime" + assert String.ends_with?(prompt, "Original") + end + + test "rules planner prefers explicit aliases over weak description keyword ties" do + config = %RepositoryPlanningConfig{ + enabled: true, + planner: "rules", + repositories: [ + %RepositoryConfig{ + slug: "CarettaAI/caretta-metrics", + aliases: ["metrics"], + description: "Metrics console and telemetry inspection." + }, + %RepositoryConfig{ + slug: "CarettaAI/caretta-webapp", + aliases: ["calendar", "web app"], + description: "Customer-facing web app, authenticated routes, product UI, calendar, CRM." + } + ] + } + + issue = %Issue{ + id: "CRTTA-262", + identifier: "CRTTA-262", + title: ~s(Calendar sync: 403 "insufficient auth scopes" in prod + breaks on refresh in dev), + description: + "Google Calendar APIs return insufficient scopes. Add telemetry and compare Google Cloud Console OAuth scopes.", + state: "Todo", + labels: ["codex", "auth", "bug"] + } + + plan = + RepoPlanner.plan_repositories( + issue, + config, + %CodingContextConfig{}, + %CodingClassification{is_coding_task: true, source: "rules"} + ) + + assert plan.primary_repo.slug == "CarettaAI/caretta-webapp" + refute plan.needs_human + assert plan.human_reason == nil + end + + test "rules planner does not match description keywords inside larger words" do + config = %RepositoryPlanningConfig{ + enabled: true, + planner: "rules", + repositories: [ + %RepositoryConfig{ + slug: "CarettaAI/caretta-webapp", + aliases: ["webapp"], + description: "Customer-facing product UI and post-call surfaces." + }, + %RepositoryConfig{ + slug: "VoysCoders/llm-gateway", + aliases: ["llm-gateway"], + description: "Alternate or older model gateway work when explicitly named." + } + ] + } + + issue = %Issue{ + id: "CRTTA-283", + identifier: "CRTTA-283", + title: "Drafted follow-up email tab", + description: + "A new tab after the call with a pre-drafted email recap using bracketed placeholders for customer questions.", + state: "Todo", + labels: ["codex"] + } + + plan = + RepoPlanner.plan_repositories( + issue, + config, + %CodingContextConfig{}, + %CodingClassification{is_coding_task: true, source: "rules"} + ) + + assert plan.primary_repo.slug == "CarettaAI/caretta-webapp" + refute Enum.any?(plan.secondary_repos, &(&1.slug == "VoysCoders/llm-gateway")) + refute plan.needs_human + end + + test "rules planner routes post-call history tab work to webapp over live desktop runtime" do + config = %RepositoryPlanningConfig{ + enabled: true, + planner: "rules", + repositories: [ + %RepositoryConfig{ + slug: "CarettaAI/Project-N", + aliases: ["Project-N", "desktop", "electron", "overlay", "live runtime"], + description: + "Desktop app and live in-call runtime; not saved-call history pages or post-call detail tabs." + }, + %RepositoryConfig{ + slug: "CarettaAI/caretta-webapp", + aliases: ["webapp", "history", "history tab", "post-call", "follow-up email"], + description: + "Customer-facing web app, saved-call history, post-call detail tabs, and follow-up email drafts." + } + ] + } + + issue = %Issue{ + id: "CRTTA-283", + identifier: "CRTTA-283", + title: "Drafted follow-up email tab", + description: "A new tab after the call with a pre-drafted email recap in the post-call UI.", + state: "Todo", + labels: ["codex"] + } + + plan = + RepoPlanner.plan_repositories( + issue, + config, + %CodingContextConfig{}, + %CodingClassification{is_coding_task: true, source: "rules"} + ) + + assert plan.primary_repo.slug == "CarettaAI/caretta-webapp" + refute plan.needs_human + end + + @tag :tmp_dir + test "llm planner normalizes primary secondary and read-only repos", %{tmp_dir: tmp_dir} do + Process.put(:repo_planner_parent, self()) + + Process.put( + :repo_planner_response, + Jason.encode!(%{ + "coding_task" => true, + "primary_repo" => %{ + "slug" => "ExampleOrg/desktop-runtime", + "reason" => "Owns the visible desktop behavior." + }, + "secondary_repos" => [ + %{ + "slug" => "ExampleOrg/model-gateway", + "reason" => "Provider adapter", + "edit_allowed" => true + } + ], + "read_only_context_repos" => [ + %{"slug" => "ExampleOrg/knowledge-docs", "reason" => "Background docs"} + ], + "confidence" => 0.74, + "needs_human" => false, + "notes" => "Start in desktop." + }) + ) + + plan = + RepoPlanner.plan_repositories( + issue("Live desktop provider suggestions"), + llm_repo_config(), + %CodingContextConfig{}, + %CodingClassification{is_coding_task: true, source: "rules"}, + codex_config: %CodexConfig{command: "fake"}, + workspace_path: tmp_dir, + codex_client: FakePlannerCodexClient + ) + + assert plan.source == "llm" + assert plan.primary_repo.slug == "ExampleOrg/desktop-runtime" + assert hd(plan.secondary_repos).slug == "ExampleOrg/model-gateway" + assert hd(plan.read_only_context_repos).edit_allowed == false + assert plan.confidence == 0.74 + refute plan.needs_human + assert_receive {:planner_session_started, %CodexConfig{effort: "low"}, ^tmp_dir} + assert_receive {:planner_prompt, prompt, [capture_agent_text: true]} + assert prompt =~ "Use only repository slugs listed in the input catalog" + assert prompt =~ "Route post-call, saved-call, call-history" + assert_receive :planner_stopped + end + + @tag :tmp_dir + test "llm planner promotes rules-matched read-only repo when llm chose wrong primary", %{ + tmp_dir: tmp_dir + } do + Process.put(:repo_planner_parent, self()) + + Process.put( + :repo_planner_response, + Jason.encode!(%{ + "coding_task" => true, + "primary_repo" => %{ + "slug" => "CarettaAI/Project-N", + "reason" => "The issue says post-call and call summary." + }, + "read_only_context_repos" => [ + %{"slug" => "CarettaAI/caretta-webapp", "reason" => "Contains history tabs."} + ], + "confidence" => 0.86, + "needs_human" => false, + "notes" => "Start in desktop." + }) + ) + + config = %RepositoryPlanningConfig{ + enabled: true, + planner: "llm", + repositories: [ + %RepositoryConfig{ + slug: "CarettaAI/Project-N", + aliases: ["Project-N", "desktop", "electron", "overlay", "live runtime"], + description: "Desktop app and live in-call runtime." + }, + %RepositoryConfig{ + slug: "CarettaAI/caretta-webapp", + aliases: ["webapp", "history", "history tab", "post-call", "follow-up email"], + description: + "Customer-facing web app, saved-call history, post-call detail tabs, and follow-up email drafts." + } + ] + } + + issue = %Issue{ + id: "CRTTA-283", + identifier: "CRTTA-283", + title: "Drafted follow-up email tab", + description: "A new tab after the call with a pre-drafted email recap in the post-call UI.", + state: "Todo", + labels: ["codex"] + } + + plan = + RepoPlanner.plan_repositories( + issue, + config, + %CodingContextConfig{}, + %CodingClassification{is_coding_task: true, source: "rules"}, + codex_config: %CodexConfig{command: "fake"}, + workspace_path: tmp_dir, + codex_client: FakePlannerCodexClient + ) + + assert plan.source == "llm+rules_crosscheck" + assert plan.primary_repo.slug == "CarettaAI/caretta-webapp" + assert plan.primary_repo.edit_allowed + assert hd(plan.read_only_context_repos).slug == "CarettaAI/Project-N" + refute hd(plan.read_only_context_repos).edit_allowed + end + + @tag :tmp_dir + test "llm planner flags unknown repos and missing primary", %{tmp_dir: tmp_dir} do + Process.put(:repo_planner_parent, self()) + + Process.put( + :repo_planner_response, + Jason.encode!(%{ + "coding_task" => true, + "secondary_repos" => [%{"slug" => "ExampleOrg/unknown", "reason" => "Maybe this"}], + "confidence" => 0.4 + }) + ) + + plan = + RepoPlanner.plan_repositories( + issue("Ambiguous repo work"), + llm_repo_config(), + %CodingContextConfig{}, + %CodingClassification{is_coding_task: true, source: "rules"}, + codex_config: %CodexConfig{command: "fake"}, + workspace_path: tmp_dir, + codex_client: FakePlannerCodexClient + ) + + assert plan.needs_human + assert plan.primary_repo == nil + assert plan.human_reason =~ "did not return a primary repo" + assert plan.human_reason =~ "ExampleOrg/unknown" + end + + @tag :tmp_dir + test "llm planner can fallback to block or rules", %{tmp_dir: tmp_dir} do + Process.put(:repo_planner_parent, self()) + Process.put(:repo_planner_response, {:raise, "planner unavailable"}) + + block_plan = + RepoPlanner.plan_repositories( + issue("Fix desktop runtime"), + %{llm_repo_config() | fallback: "block"}, + %CodingContextConfig{}, + %CodingClassification{is_coding_task: true, source: "rules"}, + codex_config: %CodexConfig{command: "fake"}, + workspace_path: tmp_dir, + codex_client: FakePlannerCodexClient + ) + + assert block_plan.source == "fallback:block" + assert block_plan.needs_human + assert block_plan.human_reason =~ "planner unavailable" + + rules_plan = + RepoPlanner.plan_repositories( + issue("Fix desktop runtime"), + %{llm_repo_config() | fallback: "rules"}, + %CodingContextConfig{}, + %CodingClassification{is_coding_task: true, source: "rules"}, + codex_config: %CodexConfig{command: "fake"}, + workspace_path: tmp_dir, + codex_client: FakePlannerCodexClient + ) + + assert rules_plan.source == "fallback:rules" + assert rules_plan.primary_repo.slug == "ExampleOrg/desktop-runtime" + end + + @tag :tmp_dir + test "HTTP server exposes state and issue detail", %{tmp_dir: tmp_dir} do + workflow_path = Path.join(tmp_dir, "WORKFLOW.md") + + File.write!(workflow_path, """ + --- + tracker: + kind: linear + api_key: key + project_slug: demo + workspace: + root: #{Path.join(tmp_dir, "workspaces")} + codex: + command: fake + --- + body + """) + + config_manager = Symphony.Config.ConfigManager.new(workflow_path, environ: %{}) + {config_manager, _, _} = Symphony.Config.ConfigManager.load_startup(config_manager) + orchestrator = Orchestrator.new(config_manager) + server = HTTPServer.start(orchestrator, port: 0) + + try do + {:ok, {{_, 200, _}, _headers, body}} = + :httpc.request(:get, {~c"http://127.0.0.1:#{server.bound_port}/api/v1/state", []}, [], + body_format: :binary + ) + + assert Jason.decode!(body)["counts"]["running"] == 0 + + {:ok, {{_, 404, _}, _headers, body}} = + :httpc.request(:get, {~c"http://127.0.0.1:#{server.bound_port}/api/v1/ABC-404", []}, [], + body_format: :binary + ) + + assert Jason.decode!(body)["error"]["code"] == "issue_not_found" + + {:ok, {{_, 200, _}, _headers, html}} = + :httpc.request(:get, {~c"http://127.0.0.1:#{server.bound_port}/", []}, [], + body_format: :binary + ) + + assert html =~ "Running Agents" + assert html =~ "Continuing / Retrying" + assert html =~ "Repo plan" + assert html =~ "setInterval" + assert html =~ "/api/v1/refresh" + after + HTTPServer.stop(server) + end + end + + defp issue(title) do + %Issue{ + id: "1", + identifier: "ENG-1", + title: title, + description: "The desktop runtime needs provider work.", + state: "Todo", + labels: ["codex"] + } + end + + defp llm_repo_config do + %RepositoryPlanningConfig{ + enabled: true, + planner: "llm", + repositories: [ + %RepositoryConfig{ + slug: "ExampleOrg/desktop-runtime", + aliases: ["desktop"], + description: "Desktop app runtime" + }, + %RepositoryConfig{ + slug: "ExampleOrg/model-gateway", + aliases: ["gateway"], + description: "Provider routing" + }, + %RepositoryConfig{ + slug: "ExampleOrg/knowledge-docs", + aliases: ["docs"], + description: "Documentation and context" + } + ] + } + end +end diff --git a/test/review_test.exs b/test/review_test.exs new file mode 100644 index 0000000..6231f4a --- /dev/null +++ b/test/review_test.exs @@ -0,0 +1,272 @@ +defmodule Symphony.ReviewTest do + use ExUnit.Case, async: true + + alias Symphony.Models.{Issue, IssueAttachment} + alias Symphony.Review + + alias Symphony.Review.{ + PullRequestInfo, + PullRequestRef, + ReviewFeedbackItem, + ReviewPullRequestResolver, + ReviewMergeResult + } + + defp pr(owner, repo, number, opts \\ []) do + state = Keyword.get(opts, :state, "MERGED") + + %PullRequestInfo{ + ref: %PullRequestRef{owner: owner, repo: repo, number: number}, + url: "https://github.com/#{owner}/#{repo}/pull/#{number}", + state: state, + base_ref_name: Keyword.get(opts, :base, "dev"), + merged_at: if(state == "MERGED", do: "2026-04-29T12:00:00Z"), + body: Keyword.get(opts, :body, "") + } + end + + @tag :tmp_dir + test "review resolver requires linked workpad and dependency PRs", %{tmp_dir: tmp_dir} do + first = pr("ExampleOrg", "app", 1, body: "Depends on ExampleOrg/api#3") + second = pr("ExampleOrg", "app", 2) + dependency = pr("ExampleOrg", "api", 3) + + inspector = %{ + view_pr_url: fn url -> %{first.url => first, second.url => second}[url] end, + view_pr_ref: fn ref -> + %{Review.PullRequestRef.canonical(dependency.ref) => dependency}[ + Review.PullRequestRef.canonical(ref) + ] + end, + list_prs_for_branch: fn _repo, _branch, _base -> [] end + } + + resolver = ReviewPullRequestResolver.new(inspector) + + issue = %Issue{ + id: "ENG-1", + identifier: "ENG-1", + title: "Ready", + state: "In Review", + attachments: [%IssueAttachment{url: first.url}] + } + + comments = [%{"body" => "## Codex Workpad\nPR: #{second.url}"}] + + result = + ReviewPullRequestResolver.evaluate(resolver, issue, + comments: comments, + workspace_path: tmp_dir, + base_branch: "dev" + ) + + assert result.ready + + assert Enum.map(result.required_prs, &Review.PullRequestRef.canonical(&1.ref)) == [ + "ExampleOrg/api#3", + "ExampleOrg/app#1", + "ExampleOrg/app#2" + ] + end + + @tag :tmp_dir + test "review resolver blocks on open wrong base and unresolved dependency", %{tmp_dir: tmp_dir} do + open_pr = pr("ExampleOrg", "app", 1, state: "OPEN", body: "Needs ExampleOrg/missing#7") + wrong_base = pr("ExampleOrg", "api", 2, base: "main") + + inspector = %{ + view_pr_url: fn url -> %{open_pr.url => open_pr, wrong_base.url => wrong_base}[url] end, + view_pr_ref: fn _ref -> nil end, + list_prs_for_branch: fn _repo, _branch, _base -> [] end + } + + resolver = ReviewPullRequestResolver.new(inspector) + + issue = %Issue{ + id: "ENG-1", + identifier: "ENG-1", + title: "Ready", + state: "In Review", + attachments: [%IssueAttachment{url: open_pr.url}, %IssueAttachment{url: wrong_base.url}] + } + + result = + ReviewPullRequestResolver.evaluate(resolver, issue, + comments: [], + workspace_path: tmp_dir, + base_branch: "dev" + ) + + reason = ReviewMergeResult.reason(result) + + refute result.ready + assert reason =~ "ExampleOrg/app#1 is OPEN" + assert reason =~ "ExampleOrg/api#2 targets main instead of dev" + assert reason =~ "ExampleOrg/missing#7" + end + + @tag :tmp_dir + test "review resolver uses workspace branch fallback", %{tmp_dir: tmp_dir} do + workspace = Path.join(tmp_dir, "ENG-1") + File.mkdir!(workspace) + + File.write!( + Path.join(workspace, ".symphony-workspace.json"), + Jason.encode!(%{ + "repositories" => [ + %{ + "slug" => "ExampleOrg/app", + "edit_allowed" => true, + "git" => %{"expected_branch" => "Symphony/ENG-1-app"} + } + ] + }) + ) + + fallback = pr("ExampleOrg", "app", 4) + + inspector = %{ + view_pr_url: fn _url -> nil end, + view_pr_ref: fn _ref -> nil end, + list_prs_for_branch: fn "ExampleOrg/app", "Symphony/ENG-1-app", "dev" -> [fallback] end + } + + resolver = ReviewPullRequestResolver.new(inspector) + issue = %Issue{id: "ENG-1", identifier: "ENG-1", title: "Ready", state: "In Review"} + + result = + ReviewPullRequestResolver.evaluate(resolver, issue, + comments: [], + workspace_path: workspace, + base_branch: "dev" + ) + + assert result.ready + + assert Enum.map(result.required_prs, &Review.PullRequestRef.canonical(&1.ref)) == [ + "ExampleOrg/app#4" + ] + end + + @tag :tmp_dir + test "review resolver requires some PR evidence", %{tmp_dir: tmp_dir} do + inspector = %{ + view_pr_url: fn _ -> nil end, + view_pr_ref: fn _ -> nil end, + list_prs_for_branch: fn _, _, _ -> [] end + } + + resolver = ReviewPullRequestResolver.new(inspector) + issue = %Issue{id: "ENG-1", identifier: "ENG-1", title: "Ready", state: "In Review"} + + result = + ReviewPullRequestResolver.evaluate(resolver, issue, + comments: [], + workspace_path: tmp_dir, + base_branch: "dev" + ) + + refute result.ready + assert ReviewMergeResult.reason(result) == "no required PRs were found" + end + + test "feedback snapshot filters bots workpads and blank reviews" do + comments = [ + %{ + "id" => "linear-1", + "body" => "Please handle the empty state.", + "createdAt" => "2026-04-29T10:00:00Z", + "user" => %{"login" => "omar", "type" => "User"} + }, + %{ + "id" => "linear-2", + "body" => "## Codex Workpad\nupdated", + "createdAt" => "2026-04-29T10:01:00Z", + "user" => %{"login" => "codex-agent", "type" => "Bot"} + } + ] + + pr_feedback = [ + %ReviewFeedbackItem{ + source: "github_pr_comment", + id: "pr-comment-1", + body: "Automated check passed.", + author: "ci-bot", + author_type: "Bot", + updated_at: ~U[2026-04-29 10:02:00Z] + }, + %ReviewFeedbackItem{ + source: "github_pr_review", + id: "review-1", + body: "", + author: "human", + author_type: "User", + updated_at: ~U[2026-04-29 10:03:00Z] + } + ] + + snapshot = Review.feedback_snapshot(comments, pr_feedback) + + assert Enum.map(snapshot.items, & &1.id) == ["linear_comment:linear-1"] + assert snapshot.latest_feedback_at == ~U[2026-04-29 10:00:00Z] + end + + test "feedback fingerprint changes when human feedback changes" do + first = + Review.feedback_snapshot( + [%{"id" => "1", "body" => "First", "createdAt" => "2026-04-29T10:00:00Z"}], + [] + ) + + second = + Review.feedback_snapshot( + [%{"id" => "1", "body" => "Second", "updatedAt" => "2026-04-29T10:05:00Z"}], + [] + ) + + assert first.fingerprint != second.fingerprint + assert second.latest_feedback_at == ~U[2026-04-29 10:05:00Z] + end + + test "PR feedback payloads normalize review comment metadata" do + ref = %PullRequestRef{owner: "ExampleOrg", repo: "app", number: 12} + + item = + Review.pr_feedback_item_from_payload(ref, "github_pr_review_comment", %{ + "id" => 99, + "body" => "This branch needs the same validation as the API PR.", + "html_url" => "https://github.com/ExampleOrg/app/pull/12#discussion_r99", + "created_at" => "2026-04-29T11:00:00Z", + "user" => %{"login" => "reviewer", "type" => "User"} + }) + + assert item.id == "ExampleOrg/app#12:github_pr_review_comment:99" + assert item.author == "reviewer" + assert item.updated_at == ~U[2026-04-29 11:00:00Z] + assert Review.human_feedback_item?(item) + end + + test "blank approving reviews are ignored but blank request-changes reviews count" do + ref = %PullRequestRef{owner: "ExampleOrg", repo: "app", number: 12} + + approval = + Review.pr_feedback_item_from_payload(ref, "github_pr_review", %{ + "id" => 1, + "body" => "", + "state" => "APPROVED", + "user" => %{"login" => "reviewer", "type" => "User"} + }) + + changes = + Review.pr_feedback_item_from_payload(ref, "github_pr_review", %{ + "id" => 2, + "body" => "", + "state" => "CHANGES_REQUESTED", + "user" => %{"login" => "reviewer", "type" => "User"} + }) + + refute Review.human_feedback_item?(approval) + assert Review.human_feedback_item?(changes) + assert changes.body == "Review state: CHANGES_REQUESTED" + end +end diff --git a/test/self_heal_test.exs b/test/self_heal_test.exs new file mode 100644 index 0000000..564f199 --- /dev/null +++ b/test/self_heal_test.exs @@ -0,0 +1,272 @@ +defmodule Symphony.SelfHealTest do + use ExUnit.Case, async: true + + alias Symphony.Config.{PollingConfig, SelfHealingConfig, ServiceConfig} + alias Symphony.SelfHeal + alias Symphony.SelfHeal.{CommandResult, RunResult} + + test "run_id is stable and branch-safe" do + now = ~U[2026-04-30 12:00:00Z] + run_id = SelfHeal.run_id("Broken API / bad poll!!!", now) + + assert run_id == "20260430T120000-broken-api-bad-poll" + assert String.length(SelfHeal.run_id(String.duplicate("failure ", 40), now)) <= 80 + refute SelfHeal.run_id(String.duplicate("failure ", 40), now) =~ "truncated" + end + + test "github sync classification distinguishes blocked auto-merge" do + assert {:ok, "auto-merge enabled"} = + SelfHeal.classify_github_sync( + %CommandResult{status: 0, output: "https://github.com/org/repo/pull/1"}, + %CommandResult{status: 0, output: ""} + ) + + assert {:blocked, message} = + SelfHeal.classify_github_sync( + %CommandResult{status: 0, output: "https://github.com/org/repo/pull/1"}, + %CommandResult{status: 1, output: "reviews required"} + ) + + assert message == "reviews required" + + assert {:error, "failed"} = + SelfHeal.classify_github_sync( + %CommandResult{status: 1, output: "failed"}, + %CommandResult{status: 0, output: ""} + ) + end + + @tag :tmp_dir + test "restart_managed builds tmux restart commands", %{tmp_dir: tmp_dir} do + workflow_path = Path.join(tmp_dir, "WORKFLOW.md") + File.write!(workflow_path, "body") + artifact_path = Path.join(tmp_dir, "symphony") + File.write!(artifact_path, "#!/bin/sh\n") + File.chmod!(artifact_path, 0o755) + + config = + service_config(tmp_dir, + self_healing: %SelfHealingConfig{ + enabled: true, + workspace_root: Path.join(tmp_dir, "heal"), + tmux_session: "symphony-test", + restart_port: 9999, + restart_workflow_path: workflow_path + } + ) + + test_pid = self() + + runner = fn command, cwd, _env -> + send(test_pid, {:command, command, cwd}) + %CommandResult{command: command, cwd: cwd, status: 0, output: ""} + end + + assert {:ok, results} = + SelfHeal.restart_managed(config, artifact_path: artifact_path, runner: runner) + + assert length(results) == 3 + assert_received {:command, "tmux has-session" <> _, ^tmp_dir} + assert_received {:command, "pids=$(lsof" <> _, ^tmp_dir} + assert_received {:command, "tmux new-session" <> command, ^tmp_dir} + assert command =~ "symphony-test" + assert command =~ artifact_path + assert command =~ workflow_path + end + + @tag :tmp_dir + test "restart_managed refuses to stop a running service before artifact preflight passes", %{ + tmp_dir: tmp_dir + } do + workflow_path = Path.join(tmp_dir, "WORKFLOW.md") + File.write!(workflow_path, "body") + + config = + service_config(tmp_dir, + self_healing: %SelfHealingConfig{ + enabled: true, + workspace_root: Path.join(tmp_dir, "heal"), + tmux_session: "symphony-test", + restart_port: 9999, + restart_workflow_path: workflow_path + } + ) + + test_pid = self() + + runner = fn command, cwd, _env -> + send(test_pid, {:unexpected_command, command, cwd}) + %CommandResult{command: command, cwd: cwd, status: 0, output: ""} + end + + missing_artifact_path = Path.join(tmp_dir, "missing-symphony") + + assert {:error, [%CommandResult{} = result]} = + SelfHeal.restart_managed(config, + artifact_path: missing_artifact_path, + runner: runner + ) + + assert result.command == "preflight managed Symphony artifact" + assert result.cwd == tmp_dir + assert result.status == 1 + assert result.output =~ "managed restart requires an executable Symphony artifact" + assert result.output =~ missing_artifact_path + refute_received {:unexpected_command, _command, _cwd} + end + + @tag :tmp_dir + test "run_once repairs in isolated worktree, deploys artifact, and opens PR", %{ + tmp_dir: tmp_dir + } do + setup_git_repo!(tmp_dir) + workflow_path = Path.join(tmp_dir, "WORKFLOW.md") + + config = + service_config(tmp_dir, + self_healing: %SelfHealingConfig{ + enabled: true, + base_branch: "main", + branch_prefix: "codex/self-heal", + workspace_root: Path.join(tmp_dir, ".symphony-self-heal"), + cooldown_ms: 1, + max_attempts: 1, + validation_commands: ["true"], + tmux_session: "symphony-test", + restart_port: 9999, + restart_workflow_path: workflow_path + } + ) + + test_pid = self() + + repair_fun = fn _config, worktree_path, prompt, 1, _opts -> + assert prompt =~ "Fix the codebase generically" + File.write!(Path.join(worktree_path, "fix.txt"), "fixed\n") + File.write!(Path.join(worktree_path, "symphony"), "#!/bin/sh\n") + File.chmod!(Path.join(worktree_path, "symphony"), 0o755) + {:ok, "fixed"} + end + + runner = fn command, cwd, env -> + cond do + String.starts_with?(command, "git push ") -> + send(test_pid, {:command, command}) + %CommandResult{command: command, cwd: cwd, status: 0, output: ""} + + String.starts_with?(command, "gh pr create ") -> + send(test_pid, {:command, command}) + + %CommandResult{ + command: command, + cwd: cwd, + status: 0, + output: "https://github.com/CarettaAI/caretta-symphony/pull/123\n" + } + + String.starts_with?(command, "gh pr merge ") -> + send(test_pid, {:command, command}) + %CommandResult{command: command, cwd: cwd, status: 1, output: "reviews required"} + + String.starts_with?(command, "tmux ") or String.starts_with?(command, "pids=") -> + %CommandResult{command: command, cwd: cwd, status: 0, output: ""} + + true -> + {output, status} = + System.cmd("bash", ["-lc", command], + cd: cwd, + env: env, + stderr_to_stdout: true + ) + + %CommandResult{command: command, cwd: cwd, status: status, output: output} + end + end + + result = + SelfHeal.run_once(config, + reason: "poll failure", + run_id: "test-run", + repair_fun: repair_fun, + runner: runner + ) + + assert %RunResult{status: :ok, attempts: 1} = result + assert result.branch == "codex/self-heal/test-run" + + assert result.worktree_path == + Path.join(config.self_healing.workspace_root, "worktrees/test-run") + + assert result.pr_url == "https://github.com/CarettaAI/caretta-symphony/pull/123" + assert result.auto_merge_status == "reviews required" + assert File.exists?(result.artifact_path) + assert File.read!(result.artifact_path) == "#!/bin/sh\n" + + assert File.read!(Path.join(Path.dirname(result.evidence_path), "github-sync.json")) =~ + "reviews required" + + assert_received {:command, "git push " <> _} + assert_received {:command, "gh pr create " <> _} + assert_received {:command, "gh pr merge " <> _} + end + + @tag :tmp_dir + test "lock and cooldown skip repair before touching worktree", %{tmp_dir: tmp_dir} do + config = + service_config(tmp_dir, + self_healing: %SelfHealingConfig{ + enabled: true, + workspace_root: Path.join(tmp_dir, "heal"), + cooldown_ms: 60_000 + } + ) + + File.mkdir_p!(config.self_healing.workspace_root) + File.write!(Path.join(config.self_healing.workspace_root, "self-heal.lock"), "locked\n") + + assert %RunResult{status: :skipped, error: "another self-heal run is active"} = + SelfHeal.run_once(config, reason: "manual") + + File.rm!(Path.join(config.self_healing.workspace_root, "self-heal.lock")) + + File.write!( + Path.join(config.self_healing.workspace_root, "last-success-ms"), + "#{System.os_time(:millisecond)}" + ) + + assert %RunResult{status: :skipped, error: "self-heal cooldown is active"} = + SelfHeal.run_once(config, reason: "manual") + end + + defp service_config(tmp_dir, overrides) do + self_healing = + Keyword.get( + overrides, + :self_healing, + %SelfHealingConfig{enabled: true, workspace_root: Path.join(tmp_dir, "heal")} + ) + + %ServiceConfig{ + workflow_path: Path.join(tmp_dir, "WORKFLOW.md"), + polling: %PollingConfig{interval_ms: 1_000}, + self_healing: self_healing + } + end + + defp setup_git_repo!(tmp_dir) do + File.write!(Path.join(tmp_dir, "WORKFLOW.md"), "body\n") + File.write!(Path.join(tmp_dir, "README.md"), "repo\n") + git!(tmp_dir, ["init"]) + git!(tmp_dir, ["config", "user.name", "Symphony Test"]) + git!(tmp_dir, ["config", "user.email", "symphony-test@example.com"]) + git!(tmp_dir, ["add", "."]) + git!(tmp_dir, ["commit", "-m", "initial"]) + git!(tmp_dir, ["branch", "-M", "main"]) + end + + defp git!(cwd, args) do + {output, status} = System.cmd("git", args, cd: cwd, stderr_to_stdout: true) + assert status == 0, output + output + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start() diff --git a/test/tracker_test.exs b/test/tracker_test.exs new file mode 100644 index 0000000..f43cf2e --- /dev/null +++ b/test/tracker_test.exs @@ -0,0 +1,335 @@ +defmodule Symphony.TrackerTest do + use ExUnit.Case, async: true + + alias Symphony.Config.TrackerConfig + alias Symphony.Tracker.{CodexMcpGateway, LinearClient, LinearMcpClient} + + test "Linear candidate pagination and normalization" do + parent = self() + + transport = fn query, variables -> + send(parent, {:call, query, variables}) + assert query =~ "slugId" + + if variables["after"] == nil do + %{ + "data" => %{ + "issues" => %{ + "nodes" => [ + %{ + "id" => "id-1", + "identifier" => "ABC-1", + "title" => "First", + "description" => "Body", + "priority" => 1, + "branchName" => "abc-1", + "url" => "https://linear.app/x/ABC-1", + "createdAt" => "2026-01-01T00:00:00Z", + "updatedAt" => "2026-01-02T00:00:00Z", + "state" => %{"name" => "Todo"}, + "labels" => %{"nodes" => [%{"name" => "Backend"}]}, + "inverseRelations" => %{ + "nodes" => [ + %{ + "type" => "blocks", + "issue" => %{ + "id" => "blocker", + "identifier" => "ABC-0", + "state" => %{"name" => "Done"} + } + } + ] + } + } + ], + "pageInfo" => %{"hasNextPage" => true, "endCursor" => "cursor-1"} + } + } + } + else + %{ + "data" => %{ + "issues" => %{ + "nodes" => [], + "pageInfo" => %{"hasNextPage" => false, "endCursor" => nil} + } + } + } + end + end + + client = %LinearClient{ + config: %TrackerConfig{ + kind: "linear", + endpoint: "https://example.test/graphql", + api_key: "key", + project_slug: "proj" + }, + transport: transport + } + + issues = LinearClient.fetch_candidate_issues(client) + + assert_received {:call, _query, %{"stateNames" => ["Todo", "In Progress"], "after" => nil}} + assert_received {:call, _query, %{"after" => "cursor-1"}} + assert hd(issues).labels == ["backend"] + assert hd(issues).blocked_by |> hd() |> Map.fetch!(:identifier) == "ABC-0" + assert hd(issues).created_at + end + + test "empty fetch by states skips API call" do + transport = fn _query, _variables -> flunk("transport should not be called") end + + client = %LinearClient{ + config: %TrackerConfig{ + kind: "linear", + endpoint: "https://example.test/graphql", + api_key: "key", + project_slug: "proj" + }, + transport: transport + } + + assert LinearClient.fetch_issues_by_states(client, []) == [] + end + + test "state refresh query uses GraphQL id typing" do + parent = self() + + transport = fn query, _variables -> + send(parent, {:query, query}) + + %{ + "data" => %{ + "issues" => %{ + "nodes" => [ + %{ + "id" => "id-1", + "identifier" => "ABC-1", + "title" => "First", + "state" => %{"name" => "In Progress"}, + "labels" => %{"nodes" => []}, + "inverseRelations" => %{"nodes" => []} + } + ] + } + } + } + end + + client = %LinearClient{ + config: %TrackerConfig{ + kind: "linear", + endpoint: "https://example.test/graphql", + api_key: "key", + project_slug: "proj" + }, + transport: transport + } + + issues = LinearClient.fetch_issue_states_by_ids(client, ["id-1"]) + + assert_received {:query, query} + assert query =~ "[ID!]" + assert hd(issues).state == "In Progress" + end + + test "Linear MCP client lists and hydrates todo blockers" do + parent = self() + + gateway = fn tool, arguments -> + send(parent, {:gateway, tool, arguments}) + + if String.ends_with?(tool, "list_issues") do + %{ + "issues" => [ + %{ + "id" => "ENG-1", + "title" => "Ready", + "status" => "Todo", + "priority" => %{"value" => 2, "name" => "High"}, + "assignee" => %{"id" => "user-1", "displayName" => "Omar", "username" => "omar"}, + "labels" => ["Bug"], + "gitBranchName" => "agent/eng-1-ready", + "attachments" => [ + %{ + "id" => "att-1", + "title" => "PR 1", + "url" => "https://github.com/ExampleOrg/app/pull/1" + }, + %{ + "id" => "att-2", + "title" => "PR 2", + "url" => "https://github.com/ExampleOrg/app/pull/2" + } + ] + } + ], + "hasNextPage" => false + } + else + %{ + "id" => "ENG-1", + "title" => "Ready", + "status" => "Todo", + "priority" => %{"value" => 2, "name" => "High"}, + "assignee" => %{"id" => "user-1", "displayName" => "Omar", "username" => "omar"}, + "labels" => ["Bug"], + "attachments" => [ + %{ + "id" => "att-1", + "title" => "PR 1", + "url" => "https://github.com/ExampleOrg/app/pull/1" + }, + %{ + "id" => "att-2", + "title" => "PR 2", + "url" => "https://github.com/ExampleOrg/app/pull/2" + } + ], + "relations" => %{"blockedBy" => [%{"id" => "ENG-0", "status" => "Done"}]} + } + end + end + + client = %LinearMcpClient{ + config: %TrackerConfig{ + kind: "linear_mcp", + project_slug: "Pilot", + team: "Platform Automation", + active_states: ["Todo"], + required_labels: ["codex"] + }, + gateway: gateway + } + + issues = LinearMcpClient.fetch_candidate_issues(client) + issue = hd(issues) + + assert issue.id == "ENG-1" + assert issue.identifier == "ENG-1" + assert issue.labels == ["bug"] + assert issue.assignee.display_name == "Omar" + assert issue.assignee.mention == "@omar" + + assert Enum.map(issue.attachments, & &1.url) == [ + "https://github.com/ExampleOrg/app/pull/1", + "https://github.com/ExampleOrg/app/pull/2" + ] + + assert hd(issue.blocked_by).identifier == "ENG-0" + + assert_received {:gateway, "linear_list_issues", + %{"project" => "Pilot", "team" => "Platform Automation", "label" => "codex"}} + end + + test "Linear MCP client writes comments and state" do + parent = self() + + gateway = fn tool, arguments -> + send(parent, {:gateway, tool, arguments}) + + cond do + String.ends_with?(tool, "list_comments") -> + %{"comments" => [%{"id" => "comment-1", "body" => "## Codex Workpad\nold"}]} + + String.ends_with?(tool, "save_comment") -> + %{"id" => arguments["id"] || "comment-2"} + + String.ends_with?(tool, "save_issue") -> + %{"id" => arguments["id"], "state" => arguments["state"]} + end + end + + client = %LinearMcpClient{ + config: %TrackerConfig{kind: "linear_mcp", project_slug: "Pilot"}, + gateway: gateway + } + + comments = LinearMcpClient.list_issue_comments(client, "ENG-1") + + LinearMcpClient.save_issue_comment(client, "ENG-1", "## Codex Workpad\nnew", + comment_id: hd(comments)["id"] + ) + + LinearMcpClient.save_issue_state(client, "ENG-1", "completed") + + assert_received {:gateway, "linear_list_comments", + %{"issueId" => "ENG-1", "limit" => 250, "orderBy" => "createdAt"}} + + assert_received {:gateway, "linear_save_comment", + %{"body" => "## Codex Workpad\nnew", "id" => "comment-1"}} + + assert_received {:gateway, "linear_save_issue", %{"id" => "ENG-1", "state" => "completed"}} + end + + @tag :tmp_dir + test "Codex MCP gateway resolves advertised tool aliases", %{tmp_dir: tmp_dir} do + fake_server = Path.join(tmp_dir, "fake_app_server.py") + + File.write!(fake_server, ~S""" + import json + import sys + + thread_id = "thr_alias" + + for line in sys.stdin: + msg = json.loads(line) + method = msg.get("method") + if method == "initialize": + print(json.dumps({"id": msg["id"], "result": {}}), flush=True) + elif method == "initialized": + pass + elif method == "thread/start": + print(json.dumps({"id": msg["id"], "result": {"thread": {"id": thread_id}}}), flush=True) + elif method == "mcpServerStatus/list": + print(json.dumps({"id": msg["id"], "result": {"data": [{"name": "codex_apps", "authStatus": "bearerToken", "resources": [], "resourceTemplates": [], "tools": {"linear_list_issues": {"name": "linear_list_issues", "inputSchema": {}}}}]}}), flush=True) + elif method == "mcpServer/tool/call": + assert msg["params"]["tool"] == "linear_list_issues" + print(json.dumps({"id": msg["id"], "result": {"content": [{"type": "text", "text": "{\"issues\": [], \"hasNextPage\": false}"}], "isError": False}}), flush=True) + """) + + gateway = %CodexMcpGateway{command: "python3 #{fake_server}", cwd: tmp_dir} + + assert CodexMcpGateway.call_tool( + gateway, + ["linear mcp server_list_issues", "linear_list_issues"], + %{"state" => "Todo"} + ) == %{"issues" => [], "hasNextPage" => false} + end + + @tag :tmp_dir + test "Codex MCP gateway decodes tool response and approvals", %{tmp_dir: tmp_dir} do + fake_server = Path.join(tmp_dir, "fake_app_server.py") + + File.write!(fake_server, ~S""" + import json + import sys + + call_request_id = None + + for line in sys.stdin: + msg = json.loads(line) + method = msg.get("method") + if method == "initialize": + print(json.dumps({"id": msg["id"], "result": {}}), flush=True) + elif method == "initialized": + pass + elif method == "thread/start": + print(json.dumps({"id": msg["id"], "result": {"thread": {"id": "thr_1"}}}), flush=True) + elif method == "mcpServer/tool/call": + call_request_id = msg["id"] + print(json.dumps({"id": 110, "method": "item/tool/requestUserInput", "params": {"questions": [{"id": "mcp_tool_call_approval_call-1", "options": [{"label": "Approve Once"}, {"label": "Approve this Session"}, {"label": "Deny"}]}]}}), flush=True) + elif msg.get("id") == 110: + assert msg["result"]["answers"]["mcp_tool_call_approval_call-1"]["answers"] == ["Approve this Session"] + print(json.dumps({"id": call_request_id, "result": {"content": [{"type": "text", "text": "{\"ok\": true}"}], "isError": False}}), flush=True) + """) + + gateway = %CodexMcpGateway{command: "python3 #{fake_server}", cwd: tmp_dir} + + assert CodexMcpGateway.call_tool(gateway, "linear mcp server_save_issue", %{ + "id" => "ENG-1", + "state" => "completed" + }) == %{"ok" => true} + end +end diff --git a/test/watchdog_test.exs b/test/watchdog_test.exs new file mode 100644 index 0000000..e47019d --- /dev/null +++ b/test/watchdog_test.exs @@ -0,0 +1,113 @@ +defmodule Symphony.WatchdogTest do + use ExUnit.Case, async: true + + alias Symphony.Config.{PollingConfig, SelfHealingConfig, ServiceConfig} + alias Symphony.SelfHeal.RunResult + alias Symphony.Watchdog + + test "classifies unreachable API as a self-heal trigger" do + config = config() + + assert {:trigger, reason} = + Watchdog.classify_state(%{"error" => ":econnrefused"}, config, now()) + + assert reason =~ "unreachable" + assert reason =~ "8765" + end + + test "classifies degraded runtime as a self-heal trigger" do + config = config() + + assert {:trigger, reason} = + Watchdog.classify_state( + %{"service" => %{"status" => "degraded", "last_poll_error" => "Linear failed"}}, + config, + now() + ) + + assert reason =~ "degraded" + assert reason =~ "Linear failed" + end + + test "classifies stale poll completion as a self-heal trigger" do + config = config(stale_poll_ms: 120_000) + + assert {:trigger, reason} = + Watchdog.classify_state( + %{ + "service" => %{ + "status" => "running", + "last_poll_completed_at" => "2026-04-30T11:55:00Z" + } + }, + config, + now() + ) + + assert reason =~ "stale" + assert reason =~ "120000" + end + + test "healthy current state is a no-op" do + config = config(stale_poll_ms: 120_000) + + assert :healthy = + Watchdog.classify_state( + %{ + "service" => %{ + "status" => "running", + "last_poll_completed_at" => "2026-04-30T11:59:30Z" + } + }, + config, + now() + ) + end + + test "run_once dispatches self-heal with trigger reason" do + config = config(stale_poll_ms: 120_000) + test_pid = self() + + self_heal_fun = fn _config, opts -> + send(test_pid, {:self_heal, opts[:reason]}) + %RunResult{status: :skipped, reason: opts[:reason]} + end + + assert {:triggered, %RunResult{status: :skipped}} = + Watchdog.run_once(config, + state: %{"error" => ":econnrefused"}, + now: now(), + self_heal_fun: self_heal_fun + ) + + assert_received {:self_heal, reason} + assert reason =~ "unreachable" + end + + test "run_once is disabled when self_healing is disabled" do + config = config(enabled: false) + + assert {:ok, :disabled} = + Watchdog.run_once(config, + state: %{"error" => ":econnrefused"}, + now: now(), + self_heal_fun: fn _, _ -> flunk("should not run") end + ) + end + + defp config(opts \\ []) do + self_healing = %SelfHealingConfig{ + enabled: Keyword.get(opts, :enabled, true), + restart_port: 8765, + stale_poll_ms: Keyword.get(opts, :stale_poll_ms, 120_000), + workspace_root: "/tmp/symphony-watchdog-test" + } + + %ServiceConfig{ + polling: %PollingConfig{interval_ms: 30_000}, + self_healing: self_healing + } + end + + defp now, do: ~U[2026-04-30 12:00:00Z] +end diff --git a/test/workflow_config_template_test.exs b/test/workflow_config_template_test.exs new file mode 100644 index 0000000..3fdd9e8 --- /dev/null +++ b/test/workflow_config_template_test.exs @@ -0,0 +1,428 @@ +defmodule Symphony.WorkflowConfigTemplateTest do + use ExUnit.Case, async: true + + alias Symphony.CodingContext + alias Symphony.Config + alias Symphony.Config.ConfigManager + alias Symphony.Error + alias Symphony.Models.Issue + alias Symphony.Templating + alias Symphony.Workflow + + @tag :tmp_dir + test "workflow front matter config and prompt", %{tmp_dir: tmp_dir} do + workflow_path = Path.join(tmp_dir, "WORKFLOW.md") + + File.write!(workflow_path, """ + --- + tracker: + kind: linear + api_key: $LINEAR_API_KEY + project_slug: demo + required_labels: ["Codex"] + workspace: + root: ./work + agent: + max_concurrent_agents_by_state: + Todo: 1 + Bad: 0 + codex: + command: codex app-server --listen stdio:// + --- + Work on {{ issue.identifier }} attempt={{ attempt }}. + """) + + workflow = Workflow.load_workflow(workflow_path) + config = Config.resolve_config(workflow, %{"LINEAR_API_KEY" => "lin-key"}) + + assert workflow.config["tracker"]["kind"] == "linear" + assert workflow.prompt_template == "Work on {{ issue.identifier }} attempt={{ attempt }}." + assert config.tracker.api_key == "lin-key" + assert Config.TrackerConfig.required_label_set(config.tracker) == MapSet.new(["codex"]) + assert config.tracker.handoff_state == "In Review" + assert config.tracker.rework_state == "Rework" + assert config.tracker.done_state == "Done" + assert config.tracker.merge_base_branch == "dev" + assert config.tracker.blocked_escalation_enabled + assert config.tracker.blocked_escalation_mentions == [] + assert config.tracker.review_states == ["In Review", "Merging"] + assert config.workspace.root == Path.expand(Path.join(tmp_dir, "work")) + assert config.agent.max_concurrent_agents_by_state == %{"todo" => 1} + assert config.codex.command == "codex app-server --listen stdio://" + refute config.self_healing.enabled + + assert config.self_healing.workspace_root == + Path.expand(Path.join(tmp_dir, ".symphony-self-heal")) + + assert config.self_healing.repair_codex.model == "gpt-5.5" + assert config.self_healing.repair_codex.effort == "xhigh" + assert config.self_healing.repair_codex.command == config.codex.command + + rendered = + Templating.render_prompt( + workflow.prompt_template, + %Issue{id: "1", identifier: "ABC-1", title: "Title", state: "Todo"}, + 2 + ) + + assert rendered == "Work on ABC-1 attempt=2." + end + + @tag :tmp_dir + test "missing and non-map workflow errors", %{tmp_dir: tmp_dir} do + assert_raise Error, ~r/missing_workflow_file/, fn -> + Workflow.load_workflow(Path.join(tmp_dir, "WORKFLOW.md")) + end + + workflow_path = Path.join(tmp_dir, "WORKFLOW.md") + File.write!(workflow_path, "---\n- nope\n---\nbody\n") + + assert_raise Error, ~r/workflow_front_matter_not_a_map/, fn -> + Workflow.load_workflow(workflow_path) + end + end + + test "strict template unknown variable fails" do + assert_raise Error, ~r/template_render_error/, fn -> + Templating.render_prompt("{{ issue.identifier }} {{ missing.value }}", %Issue{ + id: "1", + identifier: "ABC-1", + title: "Title", + state: "Todo" + }) + end + end + + @tag :tmp_dir + test "config manager invalid reload blocks dispatch", %{tmp_dir: tmp_dir} do + workflow_path = Path.join(tmp_dir, "WORKFLOW.md") + + File.write!(workflow_path, """ + --- + tracker: + kind: linear + api_key: $LINEAR_API_KEY + project_slug: demo + blocked_escalation_mentions: ["@operator"] + --- + body + """) + + manager = ConfigManager.new(workflow_path, environ: %{"LINEAR_API_KEY" => "key"}) + {manager, _, _} = ConfigManager.load_startup(manager) + + Process.sleep(1100) + File.write!(workflow_path, "---\ntracker: []\n---\nbody\n") + + {manager, changed?} = ConfigManager.reload_if_changed(manager) + refute changed? + + assert_raise Error, ~r/workflow_reload_invalid/, fn -> + ConfigManager.validate_for_dispatch!(manager) + end + end + + @tag :tmp_dir + test "default workflow path uses cwd", %{tmp_dir: tmp_dir} do + assert Workflow.resolve_workflow_path(nil, tmp_dir) == + Path.join(tmp_dir, "WORKFLOW.md") |> Path.expand() + end + + @tag :tmp_dir + test "coding context config and prompt augmentation", %{tmp_dir: tmp_dir} do + skill_dir = Path.join(tmp_dir, "platform-architecture") + references_dir = Path.join(skill_dir, "references") + File.mkdir_p!(references_dir) + + File.write!( + Path.join(skill_dir, "SKILL.md"), + "---\nname: platform-architecture\n---\nRead the repo map.\n" + ) + + File.write!( + Path.join(references_dir, "repo-map.md"), + "Use desktop-runtime for live workflow provider work.\n" + ) + + workflow_path = Path.join(tmp_dir, "WORKFLOW.md") + + File.write!(workflow_path, """ + --- + tracker: + kind: linear + api_key: $LINEAR_API_KEY + project_slug: demo + context: + coding: + enabled: true + skill_paths: + - #{skill_dir} + label_triggers: ["codex"] + keyword_triggers: ["provider"] + max_chars: 5000 + dashboard: + summaries: + enabled: true + update_interval_ms: 30000 + repositories: + enabled: true + planner: llm + fallback: rules + block_on_needs_human: true + known: + - slug: ExampleOrg/desktop-runtime + local_path: #{tmp_dir} + remote_url: https://github.com/ExampleOrg/desktop-runtime.git + aliases: ["desktop-runtime", "desktop"] + description: Desktop app runtime + --- + Work on {{ issue.identifier }}. + """) + + config = + Config.resolve_config(Workflow.load_workflow(workflow_path), %{ + "LINEAR_API_KEY" => "lin-key" + }) + + assert :ok = Config.validate_dispatch_config!(config) + + issue = %Issue{ + id: "1", + identifier: "ENG-1", + title: "Use Linkup provider", + state: "Todo", + labels: ["codex"] + } + + augmented = + CodingContext.augment_prompt_with_coding_context( + "Original prompt", + issue, + config.context.coding + ) + + assert config.context.coding.enabled + assert config.context.coding.classifier == "rules" + assert config.context.coding.skill_paths == [skill_dir] + assert config.dashboard.summaries_enabled + assert config.dashboard.summary_update_interval_ms == 30000 + assert config.repositories.enabled + assert config.repositories.planner == "llm" + assert hd(config.repositories.repositories).slug == "ExampleOrg/desktop-runtime" + assert augmented =~ "" + assert augmented =~ "Use desktop-runtime for live workflow provider work." + assert String.ends_with?(augmented, "Original prompt") + end + + @tag :tmp_dir + test "self-healing config parses nested codex and restart settings", %{tmp_dir: tmp_dir} do + workflow_path = Path.join(tmp_dir, "WORKFLOW.md") + + File.write!(workflow_path, """ + --- + tracker: + kind: linear + api_key: $LINEAR_API_KEY + project_slug: demo + blocked_escalation_mentions: ["@operator"] + codex: + command: codex app-server + turn_timeout_ms: 1000 + server: + port: 8765 + self_healing: + enabled: true + base_branch: main + branch_prefix: codex/self-heal + workspace_root: ./heal + stale_poll_ms: 120000 + cooldown_ms: 900000 + max_attempts: 3 + validation_commands: + - mix test + codex: + command: custom codex app-server + model: gpt-5.5 + effort: xhigh + approval_policy: never + turn_timeout_ms: 2000 + turn_sandbox_policy: + type: workspaceWrite + networkAccess: true + restart: + tmux_session: symphony-test + port: 9876 + workflow_path: ./WORKFLOW.md + --- + body + """) + + config = + Config.resolve_config(Workflow.load_workflow(workflow_path), %{ + "LINEAR_API_KEY" => "lin-key" + }) + + assert :ok = Config.validate_dispatch_config!(config) + assert config.tracker.blocked_escalation_mentions == ["@operator"] + assert config.self_healing.enabled + assert config.self_healing.base_branch == "main" + assert config.self_healing.branch_prefix == "codex/self-heal" + assert config.self_healing.workspace_root == Path.expand(Path.join(tmp_dir, "heal")) + assert config.self_healing.validation_commands == ["mix test"] + assert config.self_healing.repair_codex.command == "custom codex app-server" + assert config.self_healing.repair_codex.model == "gpt-5.5" + assert config.self_healing.repair_codex.effort == "xhigh" + assert config.self_healing.repair_codex.turn_timeout_ms == 2000 + assert config.self_healing.tmux_session == "symphony-test" + assert config.self_healing.restart_port == 9876 + assert config.self_healing.restart_workflow_path == workflow_path + end + + @tag :tmp_dir + test "coding context ignores non-coding issue", %{tmp_dir: tmp_dir} do + workflow_path = Path.join(tmp_dir, "WORKFLOW.md") + skill = Path.join(tmp_dir, "skill.md") + File.write!(skill, "context") + + File.write!(workflow_path, """ + --- + tracker: + kind: linear + api_key: $LINEAR_API_KEY + project_slug: demo + context: + coding: + enabled: true + skill_paths: + - #{skill} + label_triggers: ["codex"] + --- + body + """) + + config = + Config.resolve_config(Workflow.load_workflow(workflow_path), %{ + "LINEAR_API_KEY" => "lin-key" + }) + + issue = %Issue{id: "1", identifier: "ENG-1", title: "Triage only", state: "Todo", labels: []} + + assert CodingContext.augment_prompt_with_coding_context( + "Original prompt", + issue, + config.context.coding + ) == "Original prompt" + end + + @tag :tmp_dir + test "llm coding context classifier controls prompt augmentation", %{tmp_dir: tmp_dir} do + fake_server = Path.join(tmp_dir, "fake_classifier_server.py") + + File.write!(fake_server, ~S""" + import json + import sys + + thread_id = "thr_classifier" + turn_id = "turn_classifier" + + for line in sys.stdin: + msg = json.loads(line) + method = msg.get("method") + if method == "initialize": + print(json.dumps({"id": msg["id"], "result": {}}), flush=True) + elif method == "initialized": + pass + elif method == "thread/start": + print(json.dumps({"id": msg["id"], "result": {"thread": {"id": thread_id}}}), flush=True) + elif method == "turn/start": + print(json.dumps({"id": msg["id"], "result": {"turn": {"id": turn_id, "status": "inProgress"}}}), flush=True) + print(json.dumps({"method": "item/agentMessage/delta", "params": {"threadId": thread_id, "turnId": turn_id, "delta": "{\"coding_context_needed\": true, \"confidence\": 0.91, \"reason\": \"Requires repo changes.\"}"}}), flush=True) + print(json.dumps({"method": "turn/completed", "params": {"threadId": thread_id, "turn": {"id": turn_id, "status": "completed", "items": [], "error": None}}}), flush=True) + """) + + skill_dir = Path.join(tmp_dir, "platform-architecture") + File.mkdir!(skill_dir) + File.write!(Path.join(skill_dir, "SKILL.md"), "Architecture context") + + config = %Config.CodingContextConfig{ + enabled: true, + classifier: "llm", + classification_fallback: "skip", + skill_paths: [skill_dir] + } + + codex = %Config.CodexConfig{command: "python3 #{fake_server}"} + parent = self() + + issue = %Issue{ + id: "1", + identifier: "ENG-1", + title: "Ambiguous but code", + state: "Todo", + labels: [] + } + + augmented = + CodingContext.augment_prompt_with_coding_context("Original prompt", issue, config, + codex_config: codex, + workspace_path: tmp_dir, + on_event: fn event -> send(parent, {:event, event}) end + ) + + assert augmented =~ "" + assert augmented =~ "Classification source: llm" + assert augmented =~ "Architecture context" + + assert_receive {:event, + %{"coding_context_injected" => true, "classification_source" => "llm"}} + end + + @tag :tmp_dir + test "missing enabled coding context skill fails validation", %{tmp_dir: tmp_dir} do + workflow_path = Path.join(tmp_dir, "WORKFLOW.md") + + File.write!(workflow_path, """ + --- + tracker: + kind: linear + api_key: $LINEAR_API_KEY + project_slug: demo + context: + coding: + enabled: true + skill_paths: + - ./missing-skill + --- + body + """) + + config = + Config.resolve_config(Workflow.load_workflow(workflow_path), %{ + "LINEAR_API_KEY" => "lin-key" + }) + + assert_raise Error, ~r/missing_coding_context_skill/, fn -> + Config.validate_dispatch_config!(config) + end + end + + test "continuation prompt includes fresh Linear snapshot" do + issue = %Issue{ + id: "1", + identifier: "ENG-251", + title: "Web search provider", + state: "In Progress", + url: "https://linear.app/example/issue/ENG-251", + labels: ["codex"], + description: "Use Linkup for web search provider work." + } + + prompt = Templating.continuation_prompt(issue, 2, 20) + + assert prompt =~ "Current Linear issue snapshot" + assert prompt =~ "ENG-251 - Web search provider" + assert prompt =~ "Labels: codex" + assert prompt =~ "Use Linkup for web search provider work." + assert prompt =~ "current Linear text" + end +end diff --git a/test/workspace_test.exs b/test/workspace_test.exs new file mode 100644 index 0000000..6680981 --- /dev/null +++ b/test/workspace_test.exs @@ -0,0 +1,277 @@ +defmodule Symphony.WorkspaceTest do + use ExUnit.Case, async: false + + alias Symphony.Config.{HooksConfig, RepositoryConfig, RepositoryPlanningConfig, WorkspaceConfig} + alias Symphony.Error + alias Symphony.Models.{RepoPlan, RepoPlanItem} + alias Symphony.Workspace.Manager + + @tag :tmp_dir + test "workspace sanitizes and after_create runs once", %{tmp_dir: tmp_dir} do + manager = + Manager.new( + %WorkspaceConfig{root: Path.join(tmp_dir, "root")}, + %HooksConfig{ + after_create: "echo created >> marker.txt", + before_run: "echo before >> marker.txt" + } + ) + + first = Manager.create_for_issue(manager, "ABC/1") + second = Manager.create_for_issue(manager, "ABC/1") + Manager.before_run(manager, first.path) + + assert first.workspace_key == "ABC_1" + refute second.created_now + assert first.path == second.path + + assert Path.join(first.path, "marker.txt") |> File.read!() |> String.split("\n", trim: true) == + ["created", "before"] + end + + @tag :tmp_dir + test "before_run failure is fatal", %{tmp_dir: tmp_dir} do + manager = Manager.new(%WorkspaceConfig{root: tmp_dir}, %HooksConfig{before_run: "exit 7"}) + workspace = Manager.create_for_issue(manager, "ABC-1") + + assert_raise Error, ~r/hook_failed/, fn -> + Manager.before_run(manager, workspace.path) + end + end + + @tag :tmp_dir + test "before_run hook timeout is fatal", %{tmp_dir: tmp_dir} do + manager = + Manager.new( + %WorkspaceConfig{root: tmp_dir}, + %HooksConfig{before_run: "sleep 5", timeout_ms: 10} + ) + + workspace = Manager.create_for_issue(manager, "ABC-1") + + assert_raise Error, ~r/hook_timeout/, fn -> + Manager.before_run(manager, workspace.path) + end + end + + @tag :tmp_dir + test "existing non-directory workspace fails", %{tmp_dir: tmp_dir} do + root = Path.join(tmp_dir, "root") + File.mkdir!(root) + File.write!(Path.join(root, "ABC-1"), "not a dir") + manager = Manager.new(%WorkspaceConfig{root: root}, %HooksConfig{}) + + assert_raise Error, ~r/workspace_path_not_directory/, fn -> + Manager.create_for_issue(manager, "ABC-1") + end + end + + @tag :tmp_dir + test "repo plan materializes multi-repo workspace and quarantines legacy checkout", %{ + tmp_dir: tmp_dir + } do + {project_source, project_remote} = git_repo_with_remote(tmp_dir, "desktop-runtime") + {wrong_source, _wrong_remote} = git_repo_with_remote(tmp_dir, "model-gateway") + checkout_branch_with_commit(project_source, "feature/aec-bugfix", "feature.txt") + + root = Path.join(tmp_dir, "root") + File.mkdir!(root) + legacy_workspace = Path.join(root, "ENG-251") + System.cmd("git", ["clone", wrong_source, legacy_workspace], stderr_to_stdout: true) + + manager = Manager.new(%WorkspaceConfig{root: root}, %HooksConfig{}) + workspace = Manager.create_for_issue(manager, "ENG-251") + + plan = %RepoPlan{ + issue_identifier: "ENG-251", + coding_task: true, + planner: "rules", + source: "test", + primary_repo: %RepoPlanItem{ + slug: "ExampleOrg/desktop-runtime", + role: "primary", + path_name: "desktop-runtime" + } + } + + repo_config = %RepositoryPlanningConfig{ + enabled: true, + repositories: [ + %RepositoryConfig{ + slug: "ExampleOrg/desktop-runtime", + local_path: project_source, + remote_url: project_remote + } + ] + } + + prepared = Manager.materialize_repo_plan(manager, workspace, plan, repo_config) + repo_path = Path.join([root, "ENG-251", "repos", "desktop-runtime"]) + + assert prepared.primary_repo_path == repo_path + assert File.exists?(Path.join(repo_path, ".git")) + assert File.exists?(Path.join(root, "ENG-251/repo-plan.json")) + assert Path.join(root, "_quarantine") |> Path.join("ENG-251-*") |> Path.wildcard() != [] + assert git(repo_path, ["config", "--get", "remote.origin.url"]) == project_remote + assert git(repo_path, ["branch", "--show-current"]) == "Symphony/ENG-251-desktop-runtime" + refute File.exists?(Path.join(repo_path, "feature.txt")) + + metadata = + Path.join(root, "ENG-251/.symphony-workspace.json") |> File.read!() |> Jason.decode!() + + assert get_in(metadata, ["repositories", Access.at(0), "git", "expected_branch"]) == + "Symphony/ENG-251-desktop-runtime" + + assert get_in(metadata, ["repositories", Access.at(0), "git", "base_ref"]) == "origin/dev" + assert File.exists?(Path.join(repo_path, ".git/hooks/pre-push")) + end + + @tag :tmp_dir + test "pre-push guard allows expected branch and rejects wrong pushes", %{tmp_dir: tmp_dir} do + {project_source, project_remote} = git_repo_with_remote(tmp_dir, "desktop-runtime") + manager = Manager.new(%WorkspaceConfig{root: Path.join(tmp_dir, "root")}, %HooksConfig{}) + workspace = Manager.create_for_issue(manager, "ENG-260") + + plan = %RepoPlan{ + issue_identifier: "ENG-260", + coding_task: true, + planner: "rules", + source: "test", + primary_repo: %RepoPlanItem{ + slug: "ExampleOrg/desktop-runtime", + role: "primary", + path_name: "desktop-runtime" + } + } + + repo_config = %RepositoryPlanningConfig{ + enabled: true, + repositories: [ + %RepositoryConfig{ + slug: "ExampleOrg/desktop-runtime", + local_path: project_source, + remote_url: project_remote + } + ] + } + + prepared = Manager.materialize_repo_plan(manager, workspace, plan, repo_config) + repo_path = prepared.primary_repo_path + expected_branch = "Symphony/ENG-260-desktop-runtime" + + {_out, allowed} = + System.cmd("git", ["-C", repo_path, "push", "origin", "HEAD:#{expected_branch}"], + stderr_to_stdout: true + ) + + {wrong_destination_out, wrong_destination} = + System.cmd("git", ["-C", repo_path, "push", "origin", "HEAD:feature/aec-bugfix"], + stderr_to_stdout: true + ) + + System.cmd("git", ["-C", repo_path, "checkout", "-b", "feature/aec-bugfix"], + stderr_to_stdout: true + ) + + {wrong_current_branch_out, wrong_current_branch} = + System.cmd("git", ["-C", repo_path, "push", "origin", "HEAD:#{expected_branch}"], + stderr_to_stdout: true + ) + + assert allowed == 0 + assert wrong_destination != 0 + assert wrong_destination_out =~ "Symphony branch guard" + assert wrong_current_branch != 0 + assert wrong_current_branch_out =~ "Symphony branch guard" + end + + @tag :tmp_dir + test "repo clone timeout is enforced", %{tmp_dir: tmp_dir} do + old_path = System.get_env("PATH") || "" + fake_bin = Path.join(tmp_dir, "bin") + File.mkdir_p!(fake_bin) + fake_git = Path.join(fake_bin, "git") + File.write!(fake_git, "#!/bin/sh\nsleep 5\n") + File.chmod!(fake_git, 0o755) + System.put_env("PATH", fake_bin <> ":" <> old_path) + + try do + manager = Manager.new(%WorkspaceConfig{root: Path.join(tmp_dir, "root")}, %HooksConfig{}) + workspace = Manager.create_for_issue(manager, "ENG-300") + + plan = %RepoPlan{ + issue_identifier: "ENG-300", + coding_task: true, + planner: "rules", + source: "test", + primary_repo: %RepoPlanItem{ + slug: "ExampleOrg/desktop-runtime", + role: "primary", + path_name: "desktop-runtime" + } + } + + repo_config = %RepositoryPlanningConfig{ + enabled: true, + clone_timeout_ms: 10, + repositories: [ + %RepositoryConfig{ + slug: "ExampleOrg/desktop-runtime", + remote_url: "https://example.invalid/desktop-runtime.git" + } + ] + } + + assert_raise Error, ~r/repo_clone_timeout/, fn -> + Manager.materialize_repo_plan(manager, workspace, plan, repo_config) + end + after + System.put_env("PATH", old_path) + end + end + + defp git_repo_with_remote(tmp_dir, name) do + remote = Path.join([tmp_dir, "remotes", "#{name}.git"]) + File.mkdir_p!(Path.dirname(remote)) + System.cmd("git", ["init", "--bare", "-q", remote], stderr_to_stdout: true) + source = Path.join([tmp_dir, "source", name]) + File.mkdir_p!(source) + System.cmd("git", ["init", "-q"], cd: source, stderr_to_stdout: true) + configure_git_user(source) + File.write!(Path.join(source, "README.md"), "# #{name}\n") + System.cmd("git", ["add", "README.md"], cd: source, stderr_to_stdout: true) + System.cmd("git", ["commit", "-q", "-m", "initial"], cd: source, stderr_to_stdout: true) + System.cmd("git", ["branch", "-M", "dev"], cd: source, stderr_to_stdout: true) + System.cmd("git", ["remote", "add", "origin", remote], cd: source, stderr_to_stdout: true) + System.cmd("git", ["push", "-q", "-u", "origin", "dev"], cd: source, stderr_to_stdout: true) + {source, remote} + end + + defp checkout_branch_with_commit(repo_path, branch, filename) do + System.cmd("git", ["checkout", "-q", "-b", branch], cd: repo_path, stderr_to_stdout: true) + File.write!(Path.join(repo_path, filename), "source branch residue\n") + System.cmd("git", ["add", filename], cd: repo_path, stderr_to_stdout: true) + + System.cmd("git", ["commit", "-q", "-m", "source branch residue"], + cd: repo_path, + stderr_to_stdout: true + ) + end + + defp configure_git_user(repo_path) do + System.cmd("git", ["config", "user.name", "Symphony Test"], + cd: repo_path, + stderr_to_stdout: true + ) + + System.cmd("git", ["config", "user.email", "symphony@example.com"], + cd: repo_path, + stderr_to_stdout: true + ) + end + + defp git(repo_path, args) do + {out, 0} = System.cmd("git", ["-C", repo_path | args], stderr_to_stdout: true) + String.trim(out) + end +end diff --git a/tests/test_agent_runner.py b/tests/test_agent_runner.py deleted file mode 100644 index a4e1de1..0000000 --- a/tests/test_agent_runner.py +++ /dev/null @@ -1,23 +0,0 @@ -from __future__ import annotations - -from symphony.agent_runner import _agent_reported_linear_delivery_blocker, _existing_workpad_comment_id - - -def test_agent_reported_linear_delivery_blocker_requires_completion_signal() -> None: - assert _agent_reported_linear_delivery_blocker( - "Completed: implementation is committed and pushed. Validation passed. " - "Blocker: Linear MCP calls were rejected for the workpad and state transition." - ) - assert not _agent_reported_linear_delivery_blocker("Linear rejected the initial read; continuing repo inspection.") - - -def test_existing_workpad_comment_id_finds_codex_workpad() -> None: - assert ( - _existing_workpad_comment_id( - [ - {"id": "comment-1", "body": "ordinary note"}, - {"id": "comment-2", "body": "## Codex Workpad\nstatus"}, - ] - ) - == "comment-2" - ) diff --git a/tests/test_codex_client.py b/tests/test_codex_client.py deleted file mode 100644 index a5f5735..0000000 --- a/tests/test_codex_client.py +++ /dev/null @@ -1,282 +0,0 @@ -from __future__ import annotations - -import os -from pathlib import Path -import subprocess -import sys - -import pytest - -from symphony.codex_client import CodexAppServerSession -from symphony.config import CodexConfig, TrackerConfig -from symphony.errors import AgentError - - -@pytest.mark.asyncio -async def test_codex_jsonl_client_runs_turn_and_handles_approval(tmp_path: Path) -> None: - fake_server = tmp_path / "fake_app_server.py" - fake_server.write_text( - r''' -import json -import sys - -thread_id = "thr_1" -turn_id = "turn_1" - -for line in sys.stdin: - msg = json.loads(line) - method = msg.get("method") - if method == "initialize": - print(json.dumps({"id": msg["id"], "result": {"userAgent": "fake"}}), flush=True) - elif method == "initialized": - pass - elif method == "thread/start": - print(json.dumps({"id": msg["id"], "result": {"thread": {"id": thread_id}}}), flush=True) - elif method == "turn/start": - print(json.dumps({"id": msg["id"], "result": {"turn": {"id": turn_id, "status": "inProgress", "items": [], "error": None}}}), flush=True) - print(json.dumps({"method": "item/commandExecution/requestApproval", "id": 99, "params": {"threadId": thread_id, "turnId": turn_id}}), flush=True) - elif msg.get("id") == 99: - assert msg["result"]["decision"] == "acceptForSession" - print(json.dumps({"method": "thread/tokenUsage/updated", "params": {"threadId": thread_id, "turnId": turn_id, "tokenUsage": {"last": {"inputTokens": 1, "outputTokens": 2, "totalTokens": 3, "cachedInputTokens": 0, "reasoningOutputTokens": 0}, "total": {"inputTokens": 4, "outputTokens": 5, "totalTokens": 9, "cachedInputTokens": 0, "reasoningOutputTokens": 0}}}}), flush=True) - print(json.dumps({"method": "turn/completed", "params": {"threadId": thread_id, "turn": {"id": turn_id, "status": "completed", "items": [], "error": None}}}), flush=True) -''', - encoding="utf-8", - ) - events = [] - - async def on_event(event): - events.append(event) - - async with CodexAppServerSession( - CodexConfig(command=f"{os.environ.get('PYTHON', 'python3')} {fake_server}"), - tmp_path, - tracker_config=TrackerConfig(kind="linear", endpoint="https://example.test/graphql", api_key="key", project_slug="proj"), - on_event=on_event, - ) as session: - result = await session.run_turn("hello") - - assert result.thread_id == "thr_1" - assert result.turn_id == "turn_1" - assert any(event["event"] == "approval_auto_approved" for event in events) - usage_events = [event for event in events if event.get("usage_absolute")] - assert usage_events[0]["usage_absolute"] == {"input_tokens": 4, "output_tokens": 5, "total_tokens": 9} - - -@pytest.mark.asyncio -async def test_codex_jsonl_client_auto_approves_tool_user_input(tmp_path: Path) -> None: - fake_server = tmp_path / "fake_app_server.py" - fake_server.write_text( - r''' -import json -import sys - -thread_id = "thr_approval" -turn_id = "turn_approval" - -for line in sys.stdin: - msg = json.loads(line) - method = msg.get("method") - if method == "initialize": - print(json.dumps({"id": msg["id"], "result": {}}), flush=True) - elif method == "initialized": - pass - elif method == "thread/start": - print(json.dumps({"id": msg["id"], "result": {"thread": {"id": thread_id}}}), flush=True) - elif method == "turn/start": - print(json.dumps({"id": msg["id"], "result": {"turn": {"id": turn_id}}}), flush=True) - print(json.dumps({"id": 110, "method": "item/tool/requestUserInput", "params": {"threadId": thread_id, "turnId": turn_id, "questions": [{"id": "mcp_tool_call_approval_call-1", "options": [{"label": "Approve Once"}, {"label": "Approve this Session"}, {"label": "Deny"}], "question": "Allow this Linear write?"}]}}), flush=True) - elif msg.get("id") == 110: - assert msg["result"]["answers"]["mcp_tool_call_approval_call-1"]["answers"] == ["Approve this Session"] - print(json.dumps({"method": "turn/completed", "params": {"threadId": thread_id, "turn": {"id": turn_id, "status": "completed"}}}), flush=True) -''', - encoding="utf-8", - ) - events = [] - - async def on_event(event): - events.append(event) - - async with CodexAppServerSession( - CodexConfig(command=f"{os.environ.get('PYTHON', 'python3')} {fake_server}"), - tmp_path, - tracker_config=None, - on_event=on_event, - ) as session: - result = await session.run_turn("approve the tool call") - - assert result.status == "completed" - assert any(event["event"] == "approval_auto_approved" for event in events) - - -@pytest.mark.asyncio -async def test_codex_jsonl_client_auto_answers_freeform_tool_user_input(tmp_path: Path) -> None: - fake_server = tmp_path / "fake_app_server.py" - fake_server.write_text( - r''' -import json -import sys - -thread_id = "thr_freeform" -turn_id = "turn_freeform" -answer = "This is a non-interactive session. Operator input is unavailable." - -for line in sys.stdin: - msg = json.loads(line) - method = msg.get("method") - if method == "initialize": - print(json.dumps({"id": msg["id"], "result": {}}), flush=True) - elif method == "initialized": - pass - elif method == "thread/start": - print(json.dumps({"id": msg["id"], "result": {"thread": {"id": thread_id}}}), flush=True) - elif method == "turn/start": - print(json.dumps({"id": msg["id"], "result": {"turn": {"id": turn_id}}}), flush=True) - print(json.dumps({"id": 111, "method": "item/tool/requestUserInput", "params": {"threadId": thread_id, "turnId": turn_id, "questions": [{"id": "freeform-1", "options": None, "question": "What should I write?"}]}}), flush=True) - elif msg.get("id") == 111: - assert msg["result"]["answers"]["freeform-1"]["answers"] == [answer] - print(json.dumps({"method": "turn/completed", "params": {"threadId": thread_id, "turn": {"id": turn_id, "status": "completed"}}}), flush=True) -''', - encoding="utf-8", - ) - events = [] - - async def on_event(event): - events.append(event) - - async with CodexAppServerSession( - CodexConfig(command=f"{os.environ.get('PYTHON', 'python3')} {fake_server}"), - tmp_path, - tracker_config=None, - on_event=on_event, - ) as session: - result = await session.run_turn("answer the prompt") - - assert result.status == "completed" - assert any(event["event"] == "tool_input_auto_answered" for event in events) - - -@pytest.mark.asyncio -async def test_codex_jsonl_client_accepts_dynamic_tool_name_alias(tmp_path: Path) -> None: - fake_server = tmp_path / "fake_app_server.py" - fake_server.write_text( - r''' -import json -import sys - -thread_id = "thr_tool_name" -turn_id = "turn_tool_name" - -for line in sys.stdin: - msg = json.loads(line) - method = msg.get("method") - if method == "initialize": - print(json.dumps({"id": msg["id"], "result": {}}), flush=True) - elif method == "initialized": - pass - elif method == "thread/start": - print(json.dumps({"id": msg["id"], "result": {"thread": {"id": thread_id}}}), flush=True) - elif method == "turn/start": - print(json.dumps({"id": msg["id"], "result": {"turn": {"id": turn_id}}}), flush=True) - print(json.dumps({"id": 112, "method": "item/tool/call", "params": {"threadId": thread_id, "turnId": turn_id, "name": "linear_graphql", "arguments": {"query": "query Viewer { viewer { id } }"}}}), flush=True) - elif msg.get("id") == 112: - content = msg["result"]["contentItems"][0]["text"] - assert "missing_auth" in content - assert "unsupported_tool" not in content - print(json.dumps({"method": "turn/completed", "params": {"threadId": thread_id, "turn": {"id": turn_id, "status": "completed"}}}), flush=True) -''', - encoding="utf-8", - ) - - async def on_event(event): - pass - - async with CodexAppServerSession( - CodexConfig(command=f"{os.environ.get('PYTHON', 'python3')} {fake_server}"), - tmp_path, - tracker_config=None, - on_event=on_event, - ) as session: - result = await session.run_turn("call the tool") - - assert result.status == "completed" - - -@pytest.mark.asyncio -async def test_codex_jsonl_client_cleans_up_when_start_times_out(tmp_path: Path) -> None: - marker = tmp_path / "pid.txt" - fake_server = tmp_path / "fake_hanging_app_server.py" - fake_server.write_text( - r''' -import pathlib -import sys -import time - -pathlib.Path(sys.argv[1]).write_text(str(__import__("os").getpid()), encoding="utf-8") -while True: - time.sleep(1) -''', - encoding="utf-8", - ) - events = [] - - async def on_event(event): - events.append(event) - - session = CodexAppServerSession( - CodexConfig(command=f"{sys.executable} {fake_server} {marker}", read_timeout_ms=100), - tmp_path, - tracker_config=None, - on_event=on_event, - ) - - with pytest.raises(AgentError) as exc: - await session.start() - - assert exc.value.code == "response_timeout" - pid = next(int(event["codex_app_server_pid"]) for event in events if event["event"] == "app_server_started") - probe = subprocess.run(["ps", "-p", str(pid)], check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - assert probe.returncode != 0 - assert any(event["event"] == "app_server_stopped" for event in events) - - -@pytest.mark.asyncio -async def test_codex_jsonl_client_handles_large_jsonl_notifications(tmp_path: Path) -> None: - fake_server = tmp_path / "fake_large_message_app_server.py" - fake_server.write_text( - r''' -import json -import sys - -thread_id = "thr_large" -turn_id = "turn_large" -large_delta = "x" * 120000 - -for line in sys.stdin: - msg = json.loads(line) - method = msg.get("method") - if method == "initialize": - print(json.dumps({"id": msg["id"], "result": {}}), flush=True) - elif method == "initialized": - pass - elif method == "thread/start": - print(json.dumps({"id": msg["id"], "result": {"thread": {"id": thread_id}}}), flush=True) - elif method == "turn/start": - print(json.dumps({"id": msg["id"], "result": {"turn": {"id": turn_id}}}), flush=True) - print(json.dumps({"method": "item/agentMessage/delta", "params": {"threadId": thread_id, "turnId": turn_id, "delta": large_delta}}), flush=True) - print(json.dumps({"method": "turn/completed", "params": {"threadId": thread_id, "turn": {"id": turn_id, "status": "completed"}}}), flush=True) -''', - encoding="utf-8", - ) - - async def on_event(event): - pass - - async with CodexAppServerSession( - CodexConfig(command=f"{sys.executable} {fake_server}"), - tmp_path, - tracker_config=None, - on_event=on_event, - ) as session: - result = await session.run_turn("handle large output") - - assert result.status == "completed" diff --git a/tests/test_orchestrator.py b/tests/test_orchestrator.py deleted file mode 100644 index be960a7..0000000 --- a/tests/test_orchestrator.py +++ /dev/null @@ -1,396 +0,0 @@ -from __future__ import annotations - -import asyncio -from pathlib import Path - -import pytest - -from symphony.agent_runner import AgentRunResult -from symphony.config import ConfigManager -from symphony.models import BlockerRef, Issue, IssueAttachment, RepoPlan, RepoPlanItem, RunningEntry -from symphony.orchestrator import Orchestrator -from symphony.review import PullRequestInfo, PullRequestRef, ReviewMergeResult -from symphony.utils import now_utc - - -def make_manager(tmp_path: Path) -> ConfigManager: - workflow_path = tmp_path / "WORKFLOW.md" - workflow_path.write_text( - f"""--- -tracker: - kind: linear - api_key: $LINEAR_API_KEY - project_slug: demo - required_labels: ["codex"] -workspace: - root: {tmp_path / "workspaces"} -agent: - max_concurrent_agents: 2 - max_retry_backoff_ms: 15000 - max_concurrent_agents_by_state: - Todo: 1 -codex: - command: fake ---- -body -""", - encoding="utf-8", - ) - manager = ConfigManager(workflow_path, environ={"LINEAR_API_KEY": "key"}) - manager.load_startup() - return manager - - -def test_sort_and_blocker_eligibility(tmp_path: Path) -> None: - orchestrator = Orchestrator(make_manager(tmp_path)) - _, config = orchestrator.config_manager.current() - blocked = Issue( - id="2", - identifier="ABC-2", - title="Blocked", - priority=1, - state="Todo", - blocked_by=[BlockerRef(identifier="ABC-1", state="In Progress")], - ) - unblocked = Issue( - id="1", - identifier="ABC-1", - title="Ready", - priority=2, - state="Todo", - labels=["codex"], - blocked_by=[BlockerRef(identifier="ABC-0", state="Done")], - ) - - assert orchestrator.sort_for_dispatch([unblocked, blocked])[0] is blocked - assert orchestrator.is_dispatch_eligible_locked(blocked, config) is False - assert orchestrator.is_dispatch_eligible_locked(unblocked, config) is True - - -def test_required_label_gate(tmp_path: Path) -> None: - orchestrator = Orchestrator(make_manager(tmp_path)) - _, config = orchestrator.config_manager.current() - - missing_label = Issue(id="1", identifier="ABC-1", title="Ready", state="In Progress", labels=["backend"]) - matching = Issue(id="2", identifier="ABC-2", title="Ready", state="In Progress", labels=["Codex", "backend"]) - - assert orchestrator.is_dispatch_eligible_locked(missing_label, config) is False - assert orchestrator.is_dispatch_eligible_locked(matching, config) is True - - -@pytest.mark.asyncio -async def test_review_reconciliation_moves_done_only_after_all_prs_merged(tmp_path: Path) -> None: - class FakeTracker: - def __init__(self) -> None: - self.saved_states: list[tuple[str, str]] = [] - - async def fetch_issues_by_states(self, state_names: list[str]) -> list[Issue]: - assert state_names == ["In Review", "Merging"] - return [ - Issue( - id="ABC-1", - identifier="ABC-1", - title="Ready", - state="In Review", - labels=["codex"], - attachments=[IssueAttachment(url="https://github.com/ExampleOrg/app/pull/1")], - ) - ] - - async def fetch_issue_states_by_ids(self, issue_ids: list[str]) -> list[Issue]: - assert issue_ids == ["ABC-1"] - return [ - Issue( - id="ABC-1", - identifier="ABC-1", - title="Ready", - state="In Review", - labels=["codex"], - attachments=[IssueAttachment(url="https://github.com/ExampleOrg/app/pull/1")], - ) - ] - - async def list_issue_comments(self, issue_id: str): - assert issue_id == "ABC-1" - return [{"body": "## Codex Workpad\nhttps://github.com/ExampleOrg/api/pull/2"}] - - async def save_issue_state(self, issue_id: str, state: str): - self.saved_states.append((issue_id, state)) - return {"id": issue_id, "state": state} - - class FakeResolver: - async def evaluate(self, issue, *, comments, workspace_path, base_branch): - assert issue.identifier == "ABC-1" - assert base_branch == "dev" - assert comments - return ReviewMergeResult( - ready=True, - required_prs=[ - PullRequestInfo( - ref=PullRequestRef(owner="ExampleOrg", repo="app", number=1), - url="https://github.com/ExampleOrg/app/pull/1", - state="MERGED", - base_ref_name="dev", - ), - PullRequestInfo( - ref=PullRequestRef(owner="ExampleOrg", repo="api", number=2), - url="https://github.com/ExampleOrg/api/pull/2", - state="MERGED", - base_ref_name="dev", - ), - ], - ) - - tracker = FakeTracker() - orchestrator = Orchestrator(make_manager(tmp_path), review_resolver=FakeResolver()) - _, config = orchestrator.config_manager.current() - - await orchestrator.reconcile_review_issues(tracker, config) - - assert tracker.saved_states == [("ABC-1", "Done")] - - -@pytest.mark.asyncio -async def test_retry_backoff_is_capped(tmp_path: Path) -> None: - orchestrator = Orchestrator(make_manager(tmp_path)) - issue = Issue(id="1", identifier="ABC-1", title="Ready", state="In Progress") - - await orchestrator.schedule_retry(issue, 3, error="boom") - retry = orchestrator.state.retry_attempts["1"] - assert retry.attempt == 3 - assert retry.error == "boom" - assert 0 < retry.due_at_monotonic - asyncio.get_running_loop().time() < 20 - assert retry.timer_handle is not None - state = await orchestrator.snapshot() - assert state["retrying"][0]["kind"] == "retry" - assert state["counts"]["retrying"] == 1 - assert state["counts"]["continuing"] == 0 - retry.timer_handle.cancel() - - -@pytest.mark.asyncio -async def test_normal_worker_completion_schedules_continuation(tmp_path: Path) -> None: - orchestrator = Orchestrator(make_manager(tmp_path)) - issue = Issue(id="1", identifier="ABC-1", title="Ready", state="In Progress", labels=["codex"]) - - async def _completed_result(): - return AgentRunResult(issue_id=issue.id, issue_identifier=issue.identifier, normal=True, reason="issue_left_active_state") - - task = asyncio.create_task(_completed_result()) - await task - orchestrator.state.running[issue.id] = RunningEntry( - issue=issue, - task=task, - cancel_event=asyncio.Event(), - workspace_path=tmp_path, - started_at=now_utc(), - started_monotonic=asyncio.get_running_loop().time(), - ) - orchestrator.state.claimed.add(issue.id) - - await orchestrator.handle_worker_done(issue.id, task) - _, config = orchestrator.config_manager.current() - - assert issue.id in orchestrator.state.completed - assert issue.id in orchestrator.state.retry_attempts - retry = orchestrator.state.retry_attempts[issue.id] - assert retry.attempt == 1 - assert retry.error is None - assert 0 < retry.due_at_monotonic - asyncio.get_running_loop().time() < 2 - assert orchestrator.is_dispatch_eligible_locked(issue, config, ignore_claimed_issue_id=issue.id) is True - state = await orchestrator.snapshot() - assert state["retrying"][0]["kind"] == "continuation" - assert state["retrying"][0]["status"] == "continuing" - assert state["counts"]["continuing"] == 1 - assert state["counts"]["retrying"] == 0 - assert state["counts"]["completed"] == 1 - assert state["completed"][0]["issue_identifier"] == "ABC-1" - retry.timer_handle.cancel() - - -@pytest.mark.asyncio -async def test_token_usage_absolute_deltas_are_aggregated(tmp_path: Path) -> None: - orchestrator = Orchestrator(make_manager(tmp_path)) - issue = Issue(id="1", identifier="ABC-1", title="Ready", state="In Progress") - task = asyncio.create_task(asyncio.sleep(10)) - orchestrator.state.running[issue.id] = RunningEntry( - issue=issue, - task=task, - cancel_event=asyncio.Event(), - workspace_path=None, - started_at=now_utc(), - started_monotonic=asyncio.get_running_loop().time(), - ) - - await orchestrator.handle_codex_event("1", {"event": "thread_tokenUsage_updated", "usage_absolute": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}}) - await orchestrator.handle_codex_event("1", {"event": "thread_tokenUsage_updated", "usage_absolute": {"input_tokens": 12, "output_tokens": 7, "total_tokens": 19}}) - - assert orchestrator.state.codex_totals.input_tokens == 12 - assert orchestrator.state.codex_totals.output_tokens == 7 - assert orchestrator.state.codex_totals.total_tokens == 19 - task.cancel() - - -@pytest.mark.asyncio -async def test_runtime_state_persists_across_orchestrator_restart(tmp_path: Path) -> None: - orchestrator = Orchestrator(make_manager(tmp_path)) - issue = Issue(id="1", identifier="ABC-1", title="Ready", state="In Progress", labels=["codex"]) - - async def _completed_result(): - return AgentRunResult(issue_id=issue.id, issue_identifier=issue.identifier, normal=True, reason="issue_left_active_state") - - task = asyncio.create_task(_completed_result()) - await task - orchestrator.state.running[issue.id] = RunningEntry( - issue=issue, - task=task, - cancel_event=asyncio.Event(), - workspace_path=tmp_path, - started_at=now_utc(), - started_monotonic=asyncio.get_running_loop().time(), - summary_text="Implementation complete.", - turn_count=2, - ) - await orchestrator.handle_codex_event( - issue.id, - {"event": "thread_tokenUsage_updated", "usage_absolute": {"input_tokens": 7, "output_tokens": 8, "total_tokens": 15}}, - ) - await orchestrator.handle_worker_done(issue.id, task) - retry = orchestrator.state.retry_attempts[issue.id] - retry.timer_handle.cancel() - - reloaded = Orchestrator(make_manager(tmp_path)) - state = await reloaded.snapshot() - - assert state["codex_totals"]["input_tokens"] == 7 - assert state["codex_totals"]["output_tokens"] == 8 - assert state["codex_totals"]["total_tokens"] == 15 - assert state["counts"]["completed"] == 1 - assert state["completed"][0]["issue_identifier"] == "ABC-1" - assert state["completed"][0]["summary"]["text"] == "Implementation complete." - assert state["retrying"][0]["kind"] == "continuation" - - -@pytest.mark.asyncio -async def test_snapshot_includes_activity_and_dashboard_summary(tmp_path: Path) -> None: - orchestrator = Orchestrator(make_manager(tmp_path)) - issue = Issue(id="1", identifier="ABC-1", title="Ready", state="In Progress", labels=["codex"]) - task = asyncio.create_task(asyncio.sleep(10)) - orchestrator.state.running[issue.id] = RunningEntry( - issue=issue, - task=task, - cancel_event=asyncio.Event(), - workspace_path=tmp_path, - started_at=now_utc(), - started_monotonic=asyncio.get_running_loop().time(), - summary_text="The agent is inspecting the repo.", - summary_current_step="Inspect architecture", - summary_needs_human=True, - summary_human_reason="Repo choice is ambiguous.", - summary_risk="high", - summary_confidence=0.82, - summary_source="llm", - ) - - await orchestrator.handle_codex_event( - "1", - { - "event": "item_completed", - "payload": {"item": {"type": "commandExecution", "command": "rg provider", "status": "completed"}}, - "message": "command=rg provider status=completed", - }, - ) - state = await orchestrator.snapshot() - running = state["running"][0] - - assert running["title"] == "Ready" - assert running["summary"]["text"] == "The agent is inspecting the repo." - assert running["summary"]["needs_human"] is True - assert running["activity"][0]["message"] == "Command completed: rg provider" - task.cancel() - - -@pytest.mark.asyncio -async def test_snapshot_flags_possible_repo_boundary_mismatch(tmp_path: Path) -> None: - orchestrator = Orchestrator(make_manager(tmp_path)) - issue = Issue(id="1", identifier="ABC-1", title="Screen capture for live call answers", state="In Progress", labels=["codex"]) - task = asyncio.create_task(asyncio.sleep(10)) - orchestrator.state.running[issue.id] = RunningEntry( - issue=issue, - task=task, - cancel_event=asyncio.Event(), - workspace_path=tmp_path, - started_at=now_utc(), - started_monotonic=asyncio.get_running_loop().time(), - ) - - await orchestrator.handle_codex_event( - "1", - { - "event": "item_completed", - "payload": { - "item": { - "type": "commandExecution", - "command": "git diff -- infrastructure/config/schemas/functions/analyze_transcript/system_template.minijinja", - "status": "completed", - } - }, - "message": "command=git diff status=completed", - }, - ) - state = await orchestrator.snapshot() - summary = state["running"][0]["summary"] - - assert summary["needs_human"] is True - assert summary["risk"] == "high" - assert "repo boundary" in summary["human_reason"] - task.cancel() - - -@pytest.mark.asyncio -async def test_snapshot_flags_file_changes_outside_repo_plan(tmp_path: Path) -> None: - orchestrator = Orchestrator(make_manager(tmp_path)) - issue = Issue(id="1", identifier="ABC-1", title="Live suggestions", state="In Progress", labels=["codex"]) - workspace = tmp_path / "workspace" - workspace.mkdir() - task = asyncio.create_task(asyncio.sleep(10)) - orchestrator.state.running[issue.id] = RunningEntry( - issue=issue, - task=task, - cancel_event=asyncio.Event(), - workspace_path=workspace, - started_at=now_utc(), - started_monotonic=asyncio.get_running_loop().time(), - repo_plan=RepoPlan( - issue_identifier="ABC-1", - coding_task=True, - planner="llm", - source="llm", - primary_repo=RepoPlanItem(slug="ExampleOrg/desktop-runtime", role="primary", path_name="desktop-runtime"), - read_only_context_repos=[ - RepoPlanItem(slug="ExampleOrg/knowledge-docs", role="read_only_context", path_name="knowledge-docs", edit_allowed=False) - ], - ), - ) - - await orchestrator.handle_codex_event( - "1", - { - "event": "item_completed", - "payload": { - "item": { - "type": "fileChange", - "path": str(workspace / "repos" / "knowledge-docs" / "README.md"), - "status": "updated", - } - }, - "message": "file changed", - }, - ) - state = await orchestrator.snapshot() - running = state["running"][0] - - assert running["summary"]["needs_human"] is True - assert "read-only repo" in running["summary"]["human_reason"] - assert running["repo_deviations"] - task.cancel() diff --git a/tests/test_review.py b/tests/test_review.py deleted file mode 100644 index f459ed4..0000000 --- a/tests/test_review.py +++ /dev/null @@ -1,131 +0,0 @@ -from __future__ import annotations - -import json -from pathlib import Path -from typing import Any - -import pytest - -from symphony.models import Issue, IssueAttachment -from symphony.review import PullRequestInfo, PullRequestRef, ReviewPullRequestResolver - - -def pr(owner: str, repo: str, number: int, *, state: str = "MERGED", base: str = "dev", body: str = "") -> PullRequestInfo: - return PullRequestInfo( - ref=PullRequestRef(owner=owner, repo=repo, number=number), - url=f"https://github.com/{owner}/{repo}/pull/{number}", - state=state, - base_ref_name=base, - merged_at="2026-04-29T12:00:00Z" if state == "MERGED" else None, - body=body, - ) - - -class FakeInspector: - def __init__(self) -> None: - self.urls: dict[str, PullRequestInfo | None] = {} - self.refs: dict[str, PullRequestInfo | None] = {} - self.branches: dict[tuple[str, str, str], list[PullRequestInfo]] = {} - - async def view_pr_url(self, url: str) -> PullRequestInfo | None: - return self.urls.get(url) - - async def view_pr_ref(self, ref: PullRequestRef) -> PullRequestInfo | None: - return self.refs.get(ref.canonical) - - async def list_prs_for_branch(self, repo_full_name: str, branch: str, base_branch: str) -> list[PullRequestInfo]: - return self.branches.get((repo_full_name, branch, base_branch), []) - - -@pytest.mark.asyncio -async def test_review_resolver_requires_linked_workpad_and_dependency_prs(tmp_path: Path) -> None: - inspector = FakeInspector() - first = pr("ExampleOrg", "app", 1, body="Depends on ExampleOrg/api#3") - second = pr("ExampleOrg", "app", 2) - dependency = pr("ExampleOrg", "api", 3) - inspector.urls[first.url] = first - inspector.urls[second.url] = second - inspector.refs[dependency.ref.canonical] = dependency - resolver = ReviewPullRequestResolver(inspector) - issue = Issue( - id="ENG-1", - identifier="ENG-1", - title="Ready", - state="In Review", - attachments=[IssueAttachment(url=first.url)], - ) - comments: list[dict[str, Any]] = [{"body": f"## Codex Workpad\nPR: {second.url}"}] - - result = await resolver.evaluate(issue, comments=comments, workspace_path=tmp_path, base_branch="dev") - - assert result.ready is True - assert [item.ref.canonical for item in result.required_prs] == [ - "ExampleOrg/api#3", - "ExampleOrg/app#1", - "ExampleOrg/app#2", - ] - - -@pytest.mark.asyncio -async def test_review_resolver_blocks_on_open_wrong_base_and_unresolved_dependency(tmp_path: Path) -> None: - inspector = FakeInspector() - open_pr = pr("ExampleOrg", "app", 1, state="OPEN", body="Needs ExampleOrg/missing#7") - wrong_base = pr("ExampleOrg", "api", 2, base="main") - inspector.urls[open_pr.url] = open_pr - inspector.urls[wrong_base.url] = wrong_base - resolver = ReviewPullRequestResolver(inspector) - issue = Issue( - id="ENG-1", - identifier="ENG-1", - title="Ready", - state="In Review", - attachments=[IssueAttachment(url=open_pr.url), IssueAttachment(url=wrong_base.url)], - ) - - result = await resolver.evaluate(issue, comments=[], workspace_path=tmp_path, base_branch="dev") - - assert result.ready is False - assert "ExampleOrg/app#1 is OPEN" in result.reason - assert "ExampleOrg/api#2 targets main instead of dev" in result.reason - assert "ExampleOrg/missing#7" in result.reason - - -@pytest.mark.asyncio -async def test_review_resolver_uses_workspace_branch_fallback(tmp_path: Path) -> None: - workspace = tmp_path / "ENG-1" - workspace.mkdir() - (workspace / ".symphony-workspace.json").write_text( - json.dumps( - { - "repositories": [ - { - "slug": "ExampleOrg/app", - "edit_allowed": True, - "git": {"expected_branch": "Symphony/ENG-1-app"}, - } - ] - } - ), - encoding="utf-8", - ) - inspector = FakeInspector() - fallback = pr("ExampleOrg", "app", 4) - inspector.branches[("ExampleOrg/app", "Symphony/ENG-1-app", "dev")] = [fallback] - resolver = ReviewPullRequestResolver(inspector) - issue = Issue(id="ENG-1", identifier="ENG-1", title="Ready", state="In Review") - - result = await resolver.evaluate(issue, comments=[], workspace_path=workspace, base_branch="dev") - - assert result.ready is True - assert [item.ref.canonical for item in result.required_prs] == ["ExampleOrg/app#4"] - - -@pytest.mark.asyncio -async def test_review_resolver_requires_some_pr_evidence(tmp_path: Path) -> None: - resolver = ReviewPullRequestResolver(FakeInspector()) - issue = Issue(id="ENG-1", identifier="ENG-1", title="Ready", state="In Review") - - result = await resolver.evaluate(issue, comments=[], workspace_path=tmp_path, base_branch="dev") - - assert result.ready is False - assert result.reason == "no required PRs were found" diff --git a/tests/test_tracker.py b/tests/test_tracker.py deleted file mode 100644 index 365a1b7..0000000 --- a/tests/test_tracker.py +++ /dev/null @@ -1,345 +0,0 @@ -from __future__ import annotations - -from typing import Any - -import pytest - -from symphony.config import TrackerConfig -from symphony.tracker import CodexMcpGateway, LinearClient, LinearMcpClient - - -@pytest.mark.asyncio -async def test_linear_candidate_pagination_and_normalization() -> None: - calls: list[tuple[str, dict[str, Any]]] = [] - - async def transport(query: str, variables: dict[str, Any]) -> dict[str, Any]: - calls.append((query, variables)) - assert "slugId" in query - if variables["after"] is None: - return { - "data": { - "issues": { - "nodes": [ - { - "id": "id-1", - "identifier": "ABC-1", - "title": "First", - "description": "Body", - "priority": 1, - "branchName": "abc-1", - "url": "https://linear.app/x/ABC-1", - "createdAt": "2026-01-01T00:00:00Z", - "updatedAt": "2026-01-02T00:00:00Z", - "state": {"name": "Todo"}, - "labels": {"nodes": [{"name": "Backend"}]}, - "inverseRelations": { - "nodes": [ - { - "type": "blocks", - "issue": {"id": "blocker", "identifier": "ABC-0", "state": {"name": "Done"}}, - } - ] - }, - } - ], - "pageInfo": {"hasNextPage": True, "endCursor": "cursor-1"}, - } - } - } - return {"data": {"issues": {"nodes": [], "pageInfo": {"hasNextPage": False, "endCursor": None}}}} - - client = LinearClient( - TrackerConfig(kind="linear", endpoint="https://example.test/graphql", api_key="key", project_slug="proj"), - transport=transport, - ) - issues = await client.fetch_candidate_issues() - - assert len(calls) == 2 - assert calls[0][1]["stateNames"] == ["Todo", "In Progress"] - assert issues[0].labels == ["backend"] - assert issues[0].blocked_by[0].identifier == "ABC-0" - assert issues[0].created_at is not None - - -@pytest.mark.asyncio -async def test_empty_fetch_by_states_skips_api_call() -> None: - called = False - - async def transport(query: str, variables: dict[str, Any]) -> dict[str, Any]: - nonlocal called - called = True - return {} - - client = LinearClient( - TrackerConfig(kind="linear", endpoint="https://example.test/graphql", api_key="key", project_slug="proj"), - transport=transport, - ) - - assert await client.fetch_issues_by_states([]) == [] - assert called is False - - -@pytest.mark.asyncio -async def test_state_refresh_query_uses_graphql_id_typing() -> None: - captured = "" - - async def transport(query: str, variables: dict[str, Any]) -> dict[str, Any]: - nonlocal captured - captured = query - return { - "data": { - "issues": { - "nodes": [ - { - "id": "id-1", - "identifier": "ABC-1", - "title": "First", - "state": {"name": "In Progress"}, - "labels": {"nodes": []}, - "inverseRelations": {"nodes": []}, - } - ] - } - } - } - - client = LinearClient( - TrackerConfig(kind="linear", endpoint="https://example.test/graphql", api_key="key", project_slug="proj"), - transport=transport, - ) - issues = await client.fetch_issue_states_by_ids(["id-1"]) - - assert "[ID!]" in captured - assert issues[0].state == "In Progress" - - -@pytest.mark.asyncio -async def test_linear_mcp_client_lists_and_hydrates_todo_blockers() -> None: - class FakeGateway: - def __init__(self) -> None: - self.calls: list[tuple[str, dict[str, Any]]] = [] - - async def call_tool(self, tool: str, arguments: dict[str, Any]) -> dict[str, Any]: - self.calls.append((tool, arguments)) - if tool.endswith("list_issues"): - return { - "issues": [ - { - "id": "ENG-1", - "title": "Ready", - "status": "Todo", - "priority": {"value": 2, "name": "High"}, - "labels": ["Bug"], - "gitBranchName": "agent/eng-1-ready", - "attachments": [ - {"id": "att-1", "title": "PR 1", "url": "https://github.com/ExampleOrg/app/pull/1"}, - {"id": "att-2", "title": "PR 2", "url": "https://github.com/ExampleOrg/app/pull/2"}, - ], - } - ], - "hasNextPage": False, - } - return { - "id": "ENG-1", - "title": "Ready", - "status": "Todo", - "priority": {"value": 2, "name": "High"}, - "labels": ["Bug"], - "attachments": [ - {"id": "att-1", "title": "PR 1", "url": "https://github.com/ExampleOrg/app/pull/1"}, - {"id": "att-2", "title": "PR 2", "url": "https://github.com/ExampleOrg/app/pull/2"}, - ], - "relations": {"blockedBy": [{"id": "ENG-0", "status": "Done"}]}, - } - - gateway = FakeGateway() - client = LinearMcpClient( - TrackerConfig(kind="linear_mcp", project_slug="Pilot", team="Platform Automation", active_states=["Todo"], required_labels=["codex"]), - gateway=gateway, - ) - - issues = await client.fetch_candidate_issues() - - assert issues[0].id == "ENG-1" - assert issues[0].identifier == "ENG-1" - assert issues[0].labels == ["bug"] - assert [attachment.url for attachment in issues[0].attachments] == [ - "https://github.com/ExampleOrg/app/pull/1", - "https://github.com/ExampleOrg/app/pull/2", - ] - assert issues[0].blocked_by[0].identifier == "ENG-0" - assert gateway.calls[0][1]["project"] == "Pilot" - assert gateway.calls[0][1]["team"] == "Platform Automation" - assert gateway.calls[0][1]["label"] == "codex" - - -@pytest.mark.asyncio -async def test_linear_mcp_client_can_query_team_without_project_scope() -> None: - class FakeGateway: - def __init__(self) -> None: - self.calls: list[tuple[str, dict[str, Any]]] = [] - - async def call_tool(self, tool: str, arguments: dict[str, Any]) -> Any: - self.calls.append((tool, arguments)) - if tool.endswith("list_issues"): - return { - "issues": [ - { - "id": "ENG-240", - "title": "Dependabot", - "status": "Todo", - "labels": ["codex"], - } - ], - "hasNextPage": False, - } - return { - "id": arguments["id"], - "title": "Dependabot", - "status": "Todo", - "labels": ["codex"], - "relations": {"blockedBy": []}, - } - - gateway = FakeGateway() - client = LinearMcpClient( - TrackerConfig(kind="linear_mcp", team="Platform Automation", active_states=["Todo"], required_labels=["codex"]), - gateway=gateway, - ) - - issues = await client.fetch_candidate_issues() - - assert issues[0].identifier == "ENG-240" - assert "project" not in gateway.calls[0][1] - assert gateway.calls[0][1]["team"] == "Platform Automation" - assert gateway.calls[0][1]["label"] == "codex" - - -@pytest.mark.asyncio -async def test_linear_mcp_client_writes_comments_and_state() -> None: - class FakeGateway: - def __init__(self) -> None: - self.calls: list[tuple[str, dict[str, Any]]] = [] - - async def call_tool(self, tool: str, arguments: dict[str, Any]) -> Any: - self.calls.append((tool, arguments)) - if tool.endswith("list_comments"): - return {"comments": [{"id": "comment-1", "body": "## Codex Workpad\nold"}]} - if tool.endswith("save_comment"): - return {"id": arguments.get("id") or "comment-2"} - if tool.endswith("save_issue"): - return {"id": arguments["id"], "state": arguments["state"]} - raise AssertionError(tool) - - gateway = FakeGateway() - client = LinearMcpClient( - TrackerConfig(kind="linear_mcp", project_slug="Pilot"), - gateway=gateway, - ) - - comments = await client.list_issue_comments("ENG-1") - await client.save_issue_comment("ENG-1", "## Codex Workpad\nnew", comment_id=comments[0]["id"]) - await client.save_issue_state("ENG-1", "completed") - - assert gateway.calls == [ - ("linear mcp server_list_comments", {"issueId": "ENG-1", "limit": 250, "orderBy": "createdAt"}), - ("linear mcp server_save_comment", {"body": "## Codex Workpad\nnew", "id": "comment-1"}), - ("linear mcp server_save_issue", {"id": "ENG-1", "state": "completed"}), - ] - - -@pytest.mark.asyncio -async def test_codex_mcp_gateway_decodes_tool_response(tmp_path) -> None: - fake_server = tmp_path / "fake_app_server.py" - fake_server.write_text( - r''' -import json -import sys - -call_request_id = None - -for line in sys.stdin: - msg = json.loads(line) - method = msg.get("method") - if method == "initialize": - print(json.dumps({"id": msg["id"], "result": {}}), flush=True) - elif method == "initialized": - pass - elif method == "thread/start": - print(json.dumps({"id": msg["id"], "result": {"thread": {"id": "thr_1"}}}), flush=True) - elif method == "mcpServer/tool/call": - print(json.dumps({"id": msg["id"], "result": {"content": [{"type": "text", "text": "{\"issues\": [], \"hasNextPage\": false}"}], "isError": False}}), flush=True) -''', - encoding="utf-8", - ) - gateway = CodexMcpGateway(command=f"python3 {fake_server}", cwd=tmp_path) - - body = await gateway.call_tool("linear mcp server_list_issues", {"limit": 1}) - - assert body == {"issues": [], "hasNextPage": False} - - -@pytest.mark.asyncio -async def test_codex_mcp_gateway_handles_large_tool_response(tmp_path) -> None: - fake_server = tmp_path / "fake_large_app_server.py" - fake_server.write_text( - r''' -import json -import sys - -large_description = "x" * 120000 - -for line in sys.stdin: - msg = json.loads(line) - method = msg.get("method") - if method == "initialize": - print(json.dumps({"id": msg["id"], "result": {}}), flush=True) - elif method == "initialized": - pass - elif method == "thread/start": - print(json.dumps({"id": msg["id"], "result": {"thread": {"id": "thr_1"}}}), flush=True) - elif method == "mcpServer/tool/call": - payload = {"issues": [{"id": "ENG-1", "description": large_description}], "hasNextPage": False} - print(json.dumps({"id": msg["id"], "result": {"content": [{"type": "text", "text": json.dumps(payload)}], "isError": False}}), flush=True) -''', - encoding="utf-8", - ) - gateway = CodexMcpGateway(command=f"python3 {fake_server}", cwd=tmp_path) - - body = await gateway.call_tool("linear mcp server_list_issues", {"limit": 1}) - - assert body["issues"][0]["id"] == "ENG-1" - assert len(body["issues"][0]["description"]) == 120000 - - -@pytest.mark.asyncio -async def test_codex_mcp_gateway_auto_approves_tool_user_input(tmp_path) -> None: - fake_server = tmp_path / "fake_approval_app_server.py" - fake_server.write_text( - r''' -import json -import sys - -for line in sys.stdin: - msg = json.loads(line) - method = msg.get("method") - if method == "initialize": - print(json.dumps({"id": msg["id"], "result": {}}), flush=True) - elif method == "initialized": - pass - elif method == "thread/start": - print(json.dumps({"id": msg["id"], "result": {"thread": {"id": "thr_1"}}}), flush=True) - elif method == "mcpServer/tool/call": - call_request_id = msg["id"] - print(json.dumps({"id": 110, "method": "item/tool/requestUserInput", "params": {"questions": [{"id": "mcp_tool_call_approval_call-1", "options": [{"label": "Approve Once"}, {"label": "Approve this Session"}, {"label": "Deny"}]}]}}), flush=True) - elif msg.get("id") == 110: - assert msg["result"]["answers"]["mcp_tool_call_approval_call-1"]["answers"] == ["Approve this Session"] - print(json.dumps({"id": call_request_id, "result": {"content": [{"type": "text", "text": "{\"ok\": true}"}], "isError": False}}), flush=True) -''', - encoding="utf-8", - ) - gateway = CodexMcpGateway(command=f"python3 {fake_server}", cwd=tmp_path) - - body = await gateway.call_tool("linear mcp server_save_issue", {"id": "ENG-1", "state": "completed"}) - - assert body == {"ok": True} diff --git a/tests/test_workflow_config_template.py b/tests/test_workflow_config_template.py deleted file mode 100644 index d114d69..0000000 --- a/tests/test_workflow_config_template.py +++ /dev/null @@ -1,309 +0,0 @@ -from __future__ import annotations - -import os -from pathlib import Path - -import pytest - -from symphony.coding_context import augment_prompt_with_coding_context -from symphony.config import ConfigManager, resolve_config, validate_dispatch_config -from symphony.errors import TemplateError, WorkflowError -from symphony.models import Issue -from symphony.templating import continuation_prompt, render_prompt -from symphony.workflow import load_workflow, resolve_workflow_path - - -def test_workflow_front_matter_config_and_prompt(tmp_path: Path) -> None: - workflow_path = tmp_path / "WORKFLOW.md" - workflow_path.write_text( - """--- -tracker: - kind: linear - api_key: $LINEAR_API_KEY - project_slug: demo - required_labels: ["Codex"] -workspace: - root: ./work -agent: - max_concurrent_agents_by_state: - Todo: 1 - Bad: 0 -codex: - command: codex app-server --listen stdio:// ---- -Work on {{ issue.identifier }} attempt={{ attempt }}. -""", - encoding="utf-8", - ) - - workflow = load_workflow(workflow_path) - config = resolve_config(workflow, {"LINEAR_API_KEY": "lin-key"}) - - assert workflow.config["tracker"]["kind"] == "linear" - assert workflow.prompt_template == "Work on {{ issue.identifier }} attempt={{ attempt }}." - assert config.tracker.api_key == "lin-key" - assert config.tracker.required_label_set == {"codex"} - assert config.tracker.handoff_state == "In Review" - assert config.tracker.done_state == "Done" - assert config.tracker.merge_base_branch == "dev" - assert config.tracker.review_states == ["In Review", "Merging"] - assert config.workspace.root == tmp_path / "work" - assert config.agent.max_concurrent_agents_by_state == {"todo": 1} - assert config.codex.command == "codex app-server --listen stdio://" - - rendered = render_prompt(workflow.prompt_template, Issue(id="1", identifier="ABC-1", title="Title", state="Todo"), 2) - assert rendered == "Work on ABC-1 attempt=2." - - -def test_missing_and_non_map_workflow_errors(tmp_path: Path) -> None: - with pytest.raises(WorkflowError) as missing: - load_workflow(tmp_path / "WORKFLOW.md") - assert missing.value.code == "missing_workflow_file" - - workflow_path = tmp_path / "WORKFLOW.md" - workflow_path.write_text("---\n- nope\n---\nbody\n", encoding="utf-8") - with pytest.raises(WorkflowError) as non_map: - load_workflow(workflow_path) - assert non_map.value.code == "workflow_front_matter_not_a_map" - - -def test_strict_template_unknown_variable_fails() -> None: - with pytest.raises(TemplateError): - render_prompt("{{ issue.identifier }} {{ missing.value }}", Issue(id="1", identifier="ABC-1", title="Title", state="Todo")) - - -def test_config_manager_invalid_reload_blocks_dispatch(tmp_path: Path) -> None: - workflow_path = tmp_path / "WORKFLOW.md" - workflow_path.write_text( - """--- -tracker: - kind: linear - api_key: $LINEAR_API_KEY - project_slug: demo ---- -body -""", - encoding="utf-8", - ) - manager = ConfigManager(workflow_path, environ={"LINEAR_API_KEY": "key"}) - manager.load_startup() - - workflow_path.write_text("---\ntracker: []\n---\nbody\n", encoding="utf-8") - assert manager.reload_if_changed() is False - with pytest.raises(Exception): - manager.validate_for_dispatch() - - -def test_default_workflow_path_uses_cwd(tmp_path: Path) -> None: - assert resolve_workflow_path(None, cwd=tmp_path) == tmp_path / "WORKFLOW.md" - - -@pytest.mark.asyncio -async def test_coding_context_config_and_prompt_augmentation(tmp_path: Path) -> None: - skill_dir = tmp_path / "platform-architecture" - references_dir = skill_dir / "references" - references_dir.mkdir(parents=True) - (skill_dir / "SKILL.md").write_text("---\nname: platform-architecture\n---\nRead the repo map.\n", encoding="utf-8") - (references_dir / "repo-map.md").write_text("Use desktop-runtime for live workflow provider work.\n", encoding="utf-8") - - workflow_path = tmp_path / "WORKFLOW.md" - workflow_path.write_text( - f"""--- -tracker: - kind: linear - api_key: $LINEAR_API_KEY - project_slug: demo -context: - coding: - enabled: true - skill_paths: - - {skill_dir} - label_triggers: ["codex"] - keyword_triggers: ["provider"] - max_chars: 5000 -dashboard: - summaries: - enabled: true - update_interval_ms: 30000 -repositories: - enabled: true - planner: llm - fallback: rules - block_on_needs_human: true - known: - - slug: ExampleOrg/desktop-runtime - local_path: {tmp_path} - remote_url: https://github.com/ExampleOrg/desktop-runtime.git - aliases: ["desktop-runtime", "desktop"] - description: Desktop app runtime ---- -Work on {{ issue.identifier }}. -""", - encoding="utf-8", - ) - - config = resolve_config(load_workflow(workflow_path), {"LINEAR_API_KEY": "lin-key"}) - validate_dispatch_config(config) - - issue = Issue(id="1", identifier="ENG-1", title="Use Linkup provider", state="Todo", labels=["codex"]) - augmented = await augment_prompt_with_coding_context("Original prompt", issue, config.context.coding) - - assert config.context.coding.enabled is True - assert config.context.coding.classifier == "rules" - assert config.context.coding.skill_paths == [skill_dir] - assert config.dashboard.summaries_enabled is True - assert config.dashboard.summary_update_interval_ms == 30000 - assert config.repositories.enabled is True - assert config.repositories.planner == "llm" - assert config.repositories.repositories[0].slug == "ExampleOrg/desktop-runtime" - assert "" in augmented - assert "Use desktop-runtime for live workflow provider work." in augmented - assert augmented.endswith("Original prompt") - - -@pytest.mark.asyncio -async def test_coding_context_ignores_non_coding_issue(tmp_path: Path) -> None: - workflow_path = tmp_path / "WORKFLOW.md" - workflow_path.write_text( - """--- -tracker: - kind: linear - api_key: $LINEAR_API_KEY - project_slug: demo -context: - coding: - enabled: true - skill_paths: - - ./skill.md - label_triggers: ["codex"] ---- -body -""", - encoding="utf-8", - ) - (tmp_path / "skill.md").write_text("context", encoding="utf-8") - config = resolve_config(load_workflow(workflow_path), {"LINEAR_API_KEY": "lin-key"}) - - issue = Issue(id="1", identifier="ENG-1", title="Triage only", state="Todo", labels=[]) - assert await augment_prompt_with_coding_context("Original prompt", issue, config.context.coding) == "Original prompt" - - -@pytest.mark.asyncio -async def test_llm_coding_context_classifier_controls_prompt_augmentation(tmp_path: Path) -> None: - fake_server = tmp_path / "fake_classifier_server.py" - fake_server.write_text( - r''' -import json -import sys - -thread_id = "thr_classifier" -turn_id = "turn_classifier" - -for line in sys.stdin: - msg = json.loads(line) - method = msg.get("method") - if method == "initialize": - print(json.dumps({"id": msg["id"], "result": {}}), flush=True) - elif method == "initialized": - pass - elif method == "thread/start": - print(json.dumps({"id": msg["id"], "result": {"thread": {"id": thread_id}}}), flush=True) - elif method == "turn/start": - print(json.dumps({"id": msg["id"], "result": {"turn": {"id": turn_id, "status": "inProgress"}}}), flush=True) - print(json.dumps({"method": "item/agentMessage/delta", "params": {"threadId": thread_id, "turnId": turn_id, "delta": "{\"coding_context_needed\": true, \"confidence\": 0.91, \"reason\": \"Requires repo changes.\"}"}}), flush=True) - print(json.dumps({"method": "turn/completed", "params": {"threadId": thread_id, "turn": {"id": turn_id, "status": "completed", "items": [], "error": None}}}), flush=True) -''', - encoding="utf-8", - ) - - skill_dir = tmp_path / "platform-architecture" - skill_dir.mkdir() - (skill_dir / "SKILL.md").write_text("Architecture context", encoding="utf-8") - workflow_path = tmp_path / "WORKFLOW.md" - workflow_path.write_text( - f"""--- -tracker: - kind: linear - api_key: $LINEAR_API_KEY - project_slug: demo -codex: - command: {os.environ.get('PYTHON', 'python3')} {fake_server} -context: - coding: - enabled: true - classifier: llm - classification_fallback: skip - skill_paths: - - {skill_dir} ---- -body -""", - encoding="utf-8", - ) - - config = resolve_config(load_workflow(workflow_path), {"LINEAR_API_KEY": "lin-key"}) - events = [] - - async def on_event(event): - events.append(event) - - issue = Issue(id="1", identifier="ENG-1", title="Ambiguous but code", state="Todo", labels=[]) - augmented = await augment_prompt_with_coding_context( - "Original prompt", - issue, - config.context.coding, - codex_config=config.codex, - workspace_path=tmp_path, - on_event=on_event, - ) - - assert "" in augmented - assert "Classification source: llm" in augmented - assert "Architecture context" in augmented - assert events[0]["coding_context_injected"] is True - assert events[0]["classification_source"] == "llm" - - -def test_missing_enabled_coding_context_skill_fails_validation(tmp_path: Path) -> None: - workflow_path = tmp_path / "WORKFLOW.md" - workflow_path.write_text( - """--- -tracker: - kind: linear - api_key: $LINEAR_API_KEY - project_slug: demo -context: - coding: - enabled: true - skill_paths: - - ./missing-skill ---- -body -""", - encoding="utf-8", - ) - - config = resolve_config(load_workflow(workflow_path), {"LINEAR_API_KEY": "lin-key"}) - with pytest.raises(Exception) as missing_skill: - validate_dispatch_config(config) - assert getattr(missing_skill.value, "code", None) == "missing_coding_context_skill" - - -def test_continuation_prompt_includes_fresh_linear_snapshot() -> None: - issue = Issue( - id="1", - identifier="ENG-251", - title="Web search provider", - state="In Progress", - url="https://linear.app/example/issue/ENG-251", - labels=["codex"], - description="Use Linkup for web search provider work.", - ) - - prompt = continuation_prompt(issue, turn_number=2, max_turns=20) - - assert "Current Linear issue snapshot" in prompt - assert "ENG-251 - Web search provider" in prompt - assert "Labels: codex" in prompt - assert "Use Linkup for web search provider work." in prompt - assert "current Linear text" in prompt diff --git a/tests/test_workspace.py b/tests/test_workspace.py deleted file mode 100644 index c920b2d..0000000 --- a/tests/test_workspace.py +++ /dev/null @@ -1,193 +0,0 @@ -from __future__ import annotations - -import json -from pathlib import Path -import subprocess - -import pytest - -from symphony.config import HooksConfig, RepositoryConfig, RepositoryPlanningConfig, WorkspaceConfig -from symphony.errors import HookError, WorkspaceError -from symphony.models import RepoPlan, RepoPlanItem -from symphony.workspace import WorkspaceManager - - -@pytest.mark.asyncio -async def test_workspace_sanitizes_and_after_create_runs_once(tmp_path: Path) -> None: - manager = WorkspaceManager( - WorkspaceConfig(root=tmp_path / "root"), - HooksConfig(after_create="echo created >> marker.txt", before_run="echo before >> marker.txt"), - ) - - first = await manager.create_for_issue("ABC/1") - second = await manager.create_for_issue("ABC/1") - await manager.before_run(first.path) - - assert first.workspace_key == "ABC_1" - assert second.created_now is False - assert first.path == second.path - assert (first.path / "marker.txt").read_text(encoding="utf-8").splitlines() == ["created", "before"] - - -@pytest.mark.asyncio -async def test_before_run_failure_is_fatal(tmp_path: Path) -> None: - manager = WorkspaceManager(WorkspaceConfig(root=tmp_path), HooksConfig(before_run="exit 7")) - workspace = await manager.create_for_issue("ABC-1") - - with pytest.raises(HookError) as exc: - await manager.before_run(workspace.path) - assert exc.value.code == "hook_failed" - - -@pytest.mark.asyncio -async def test_existing_non_directory_workspace_fails(tmp_path: Path) -> None: - root = tmp_path / "root" - root.mkdir() - (root / "ABC-1").write_text("not a dir", encoding="utf-8") - manager = WorkspaceManager(WorkspaceConfig(root=root), HooksConfig()) - - with pytest.raises(WorkspaceError) as exc: - await manager.create_for_issue("ABC-1") - assert exc.value.code == "workspace_path_not_directory" - - -@pytest.mark.asyncio -async def test_repo_plan_materializes_multi_repo_workspace_and_quarantines_legacy_checkout(tmp_path: Path) -> None: - project_source, project_remote = _git_repo_with_remote(tmp_path, "desktop-runtime") - wrong_source, _ = _git_repo_with_remote(tmp_path, "model-gateway") - _checkout_branch_with_commit(project_source, "feature/aec-bugfix", "feature.txt") - root = tmp_path / "root" - root.mkdir() - legacy_workspace = root / "ENG-251" - subprocess.run(["git", "clone", str(wrong_source), str(legacy_workspace)], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - - manager = WorkspaceManager(WorkspaceConfig(root=root), HooksConfig()) - workspace = await manager.create_for_issue("ENG-251") - plan = RepoPlan( - issue_identifier="ENG-251", - coding_task=True, - planner="rules", - source="test", - primary_repo=RepoPlanItem(slug="ExampleOrg/desktop-runtime", role="primary", path_name="desktop-runtime"), - ) - repo_config = RepositoryPlanningConfig( - enabled=True, - repositories=[ - RepositoryConfig( - slug="ExampleOrg/desktop-runtime", - local_path=project_source, - remote_url=str(project_remote), - ) - ], - ) - - prepared = await manager.materialize_repo_plan(workspace, plan, repo_config) - - assert prepared.primary_repo_path == root / "ENG-251" / "repos" / "desktop-runtime" - assert (prepared.primary_repo_path / ".git").exists() - assert (root / "ENG-251" / "repo-plan.json").exists() - assert list((root / "_quarantine").glob("ENG-251-*")) - remote = subprocess.run( - ["git", "-C", str(prepared.primary_repo_path), "config", "--get", "remote.origin.url"], - check=True, - stdout=subprocess.PIPE, - text=True, - ).stdout.strip() - branch = subprocess.run( - ["git", "-C", str(prepared.primary_repo_path), "branch", "--show-current"], - check=True, - stdout=subprocess.PIPE, - text=True, - ).stdout.strip() - metadata = json.loads((root / "ENG-251" / ".symphony-workspace.json").read_text(encoding="utf-8")) - - assert remote == str(project_remote) - assert branch == "Symphony/ENG-251-desktop-runtime" - assert not (prepared.primary_repo_path / "feature.txt").exists() - assert metadata["repositories"][0]["git"]["expected_branch"] == "Symphony/ENG-251-desktop-runtime" - assert metadata["repositories"][0]["git"]["base_ref"] == "origin/dev" - assert (prepared.primary_repo_path / ".git" / "hooks" / "pre-push").exists() - - -@pytest.mark.asyncio -async def test_pre_push_guard_allows_expected_branch_and_rejects_wrong_pushes(tmp_path: Path) -> None: - project_source, project_remote = _git_repo_with_remote(tmp_path, "desktop-runtime") - manager = WorkspaceManager(WorkspaceConfig(root=tmp_path / "root"), HooksConfig()) - workspace = await manager.create_for_issue("ENG-260") - plan = RepoPlan( - issue_identifier="ENG-260", - coding_task=True, - planner="rules", - source="test", - primary_repo=RepoPlanItem(slug="ExampleOrg/desktop-runtime", role="primary", path_name="desktop-runtime"), - ) - repo_config = RepositoryPlanningConfig( - enabled=True, - repositories=[ - RepositoryConfig( - slug="ExampleOrg/desktop-runtime", - local_path=project_source, - remote_url=str(project_remote), - ) - ], - ) - - prepared = await manager.materialize_repo_plan(workspace, plan, repo_config) - repo_path = prepared.primary_repo_path - assert repo_path is not None - expected_branch = "Symphony/ENG-260-desktop-runtime" - - allowed = subprocess.run( - ["git", "-C", str(repo_path), "push", "origin", f"HEAD:{expected_branch}"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - wrong_destination = subprocess.run( - ["git", "-C", str(repo_path), "push", "origin", "HEAD:feature/aec-bugfix"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - subprocess.run(["git", "-C", str(repo_path), "checkout", "-b", "feature/aec-bugfix"], check=True) - wrong_current_branch = subprocess.run( - ["git", "-C", str(repo_path), "push", "origin", f"HEAD:{expected_branch}"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - - assert allowed.returncode == 0, allowed.stderr - assert wrong_destination.returncode != 0 - assert "Symphony branch guard" in wrong_destination.stderr - assert wrong_current_branch.returncode != 0 - assert "Symphony branch guard" in wrong_current_branch.stderr - - -def _git_repo_with_remote(tmp_path: Path, name: str) -> tuple[Path, Path]: - remote = tmp_path / "remotes" / f"{name}.git" - remote.parent.mkdir(parents=True, exist_ok=True) - subprocess.run(["git", "init", "--bare", "-q", str(remote)], check=True) - source = tmp_path / "source" / name - source.mkdir(parents=True) - subprocess.run(["git", "init", "-q"], cwd=source, check=True) - _configure_git_user(source) - (source / "README.md").write_text(f"# {name}\n", encoding="utf-8") - subprocess.run(["git", "add", "README.md"], cwd=source, check=True) - subprocess.run(["git", "commit", "-q", "-m", "initial"], cwd=source, check=True) - subprocess.run(["git", "branch", "-M", "dev"], cwd=source, check=True) - subprocess.run(["git", "remote", "add", "origin", str(remote)], cwd=source, check=True) - subprocess.run(["git", "push", "-q", "-u", "origin", "dev"], cwd=source, check=True) - return source, remote - - -def _checkout_branch_with_commit(repo_path: Path, branch: str, filename: str) -> None: - subprocess.run(["git", "checkout", "-q", "-b", branch], cwd=repo_path, check=True) - (repo_path / filename).write_text("source branch residue\n", encoding="utf-8") - subprocess.run(["git", "add", filename], cwd=repo_path, check=True) - subprocess.run(["git", "commit", "-q", "-m", "source branch residue"], cwd=repo_path, check=True) - - -def _configure_git_user(repo_path: Path) -> None: - subprocess.run(["git", "config", "user.name", "Symphony Test"], cwd=repo_path, check=True) - subprocess.run(["git", "config", "user.email", "symphony@example.com"], cwd=repo_path, check=True)