Compare commits

..

No commits in common. "codex/move-web-server-to-phoenix" and "main" have entirely different histories.

37 changed files with 3179 additions and 539 deletions

View File

@ -30,15 +30,12 @@ description:
3. Push branch to `origin` with upstream tracking if needed, using whatever
remote URL is already configured.
4. If push is not clean/rejected:
- If the failure is due to auth or workflow limitations on the configured
remote (for example, updating workflow files over HTTPS/token auth),
retry the push over SSH before treating it as a sync problem.
- Check the current SSH agent with `ssh-add -L`.
- If no keys are available there, check `/tmp/ssh-dev-user.sock`.
- Retry the push with the repo SSH URL.
- If the failure is a non-fast-forward or sync problem, run the `pull`
skill to merge `origin/main`, resolve conflicts, and rerun validation.
- Push again; use `--force-with-lease` only when history was rewritten.
- If the failure is due to auth, permissions, or workflow restrictions on
the configured remote, stop and surface the exact error instead of
rewriting remotes or switching protocols as a workaround.
5. Ensure a PR exists for the branch:
- If no PR exists, create one.
@ -67,35 +64,16 @@ branch=$(git branch --show-current)
# Minimal validation gate
make -C elixir all
ssh_url=org-14957082@github.com:openai/symphony.git
ssh_fallback_push() {
if ssh-add -L >/dev/null 2>&1; then
git push -u "$ssh_url" HEAD
return
fi
if [ -S /tmp/ssh-dev-user.sock ] && \
SSH_AUTH_SOCK=/tmp/ssh-dev-user.sock ssh-add -L >/dev/null 2>&1; then
SSH_AUTH_SOCK=/tmp/ssh-dev-user.sock git push -u "$ssh_url" HEAD
return
fi
echo "No SSH agent available for fallback push." >&2
return 1
}
# Initial push: respect the current origin remote first.
# Initial push: respect the current origin remote.
git push -u origin HEAD
# If that failed because the current remote auth cannot perform the push
# (for example, workflow file updates over HTTPS/token auth), retry over SSH:
ssh_fallback_push
# If that failed because the remote moved, use the pull skill. After
# pull-skill resolution and re-validation, retry the normal push:
git push -u origin HEAD
# If the configured remote rejects the push for auth, permissions, or workflow
# restrictions, stop and surface the exact error.
# Only if history was rewritten locally:
git push --force-with-lease origin HEAD
@ -130,27 +108,10 @@ rm -f "$tmp_pr_body"
gh pr view --json url -q .url
```
## Git Remote Failure Fallback
- Prefer whatever URL is already configured on `origin`; do not rewrite the
remote just to prefer SSH or HTTPS.
- First try `git push -u origin HEAD` normally.
- If that fails because the current auth method cannot perform the operation
(for example, workflow file updates rejected over HTTPS/token auth), retry
over SSH with `org-14957082@github.com:openai/symphony.git`.
- Check the active SSH agent first with `ssh-add -L`.
- If no agent keys are available, try the known fallback socket:
- `SSH_AUTH_SOCK=/tmp/ssh-dev-user.sock ssh-add -L`
- `SSH_AUTH_SOCK=/tmp/ssh-dev-user.sock git push -u org-14957082@github.com:openai/symphony.git HEAD`
- Leave `origin` unchanged unless there is a specific need to set a persistent
SSH push URL for repeated retries.
- If SSH fallback is unavailable or also fails, stop and surface the exact
error instead of silently changing remotes.
## Notes
- Do not use `--force`; only use `--force-with-lease` as the last resort.
- Remote/auth fallback for push failures:
- Start with the configured `origin` remote.
- Use SSH fallback only for auth/workflow-limit failures on that remote.
- Probe the current SSH agent first, then `/tmp/ssh-dev-user.sock`.
- Distinguish sync problems from remote auth/permission problems:
- Use the `pull` skill for non-fast-forward or stale-branch issues.
- Surface auth, permissions, or workflow restrictions directly instead of
changing remotes or protocols.

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
.worktrees
debug.log
workspaces/

1
.pebbles/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
pebbles.db

3
.pebbles/config.json Normal file
View File

@ -0,0 +1,3 @@
{
"prefix": "caclawphony"
}

18
.pebbles/events.jsonl Normal file
View File

@ -0,0 +1,18 @@
{"type":"create","timestamp":"2026-03-05T08:24:14.538267Z","issue_id":"caclawphony-5fb","payload":{"description":"Codex workspace-write sandbox blocks network. pnpm install, gh pr, npm test all fail. Review worked via local git fallback but prepare-pr and merge-pr need network access. Options: (1) switch to danger-full-access, (2) pre-install deps in after_create hook, (3) hybrid — use hooks for setup, sandbox for agent. Start by testing danger-full-access in WORKFLOW.md thread_sandbox config, then evaluate hook-based approach for better security.","priority":"0","title":"Fix Codex sandbox networking for prepare/merge phases","type":"task"}}
{"type":"create","timestamp":"2026-03-05T08:24:14.606909Z","issue_id":"caclawphony-fa2","payload":{"description":"Currently each dispatch creates a fresh workspace via after_create hook. When an issue transitions Review→Prepare, the prepare agent needs access to .local/review.md from the review phase. Fix: in workspace.ex create_workspace, check if workspace dir already exists and skip after_create hook if so. The before_run hook should still run (to ensure correct branch checkout). Key file: elixir/lib/symphony_elixir/workspace.ex","priority":"0","title":"Implement workspace reuse across pipeline stages","type":"task"}}
{"type":"create","timestamp":"2026-03-05T08:24:14.670702Z","issue_id":"caclawphony-18b","payload":{"description":"LogFile module fails silently because log directory doesn't exist on first run. Add File.mkdir_p to LogFile.open or workspace creation. Key file: elixir/lib/symphony_elixir/log_file.ex","priority":"0","title":"Fix log directory creation for LogFile module","type":"task"}}
{"type":"create","timestamp":"2026-03-05T08:24:14.697017Z","issue_id":"caclawphony-432","payload":{"description":"Need a way to feed PRs into Linear from the command line. Create a mix task or script: caclawphony review 34511 → creates Linear issue titled 'PR #34511: \u003ctitle\u003e' in Review state, assigned to Caclawphony project. Should fetch PR title/description from GitHub via gh CLI. Also support batch: caclawphony review 34511 34554 29533. Key integration: Linear GraphQL API for issue creation.","priority":"1","title":"Build PR intake CLI (caclawphony review \u003cPR#\u003e)","type":"feature"}}
{"type":"create","timestamp":"2026-03-05T08:24:14.77437Z","issue_id":"caclawphony-87b","payload":{"description":"Marie Clawndo currently spawns maniple/Codex workers for review/prepare/merge. Replace with Linear issue creation so Symphony handles the agent lifecycle. When Josh says 'review PR #X', Marie creates a Linear issue in Review state. Marie monitors Linear for gate states (Review Complete, Prepare Complete) and notifies Josh. This replaces the maniple-based worker spawning for the PR pipeline.","priority":"1","title":"Wire Marie Clawndo to create Linear issues instead of maniple workers","type":"feature"}}
{"type":"create","timestamp":"2026-03-05T08:24:14.823043Z","issue_id":"caclawphony-f2f","payload":{"description":"After an issue reaches Done/Canceled/Duplicate, delete the workspace directory. Can be done in orchestrator.ex when detecting terminal state during poll, or via an after_run hook that checks issue state. Workspaces live in ~/Projects/caclawphony/workspaces/\u003cissue-identifier\u003e.","priority":"1","title":"Implement workspace cleanup on terminal state","type":"task"}}
{"type":"create","timestamp":"2026-03-05T08:24:14.87961Z","issue_id":"caclawphony-5c2","payload":{"description":"When an issue transitions to a gate state (Review Complete, Prepare Complete), send a Telegram notification via Marie Clawndo bot. Include: issue identifier, PR number, summary of review findings or prepare results. Can be implemented as a webhook from Linear or polling-based detection in the orchestrator.","priority":"2","title":"Add Telegram notifications on gate state transitions","type":"feature"}}
{"type":"create","timestamp":"2026-03-05T08:24:14.956359Z","issue_id":"caclawphony-9ed","payload":{"description":"Codex spews ~50 'state db missing rollout path' ERROR lines on startup from old sessions. These are harmless but noisy in symphony.log. Options: (1) clean up ~/.codex/ state DB, (2) filter these lines in the Codex stream output handler in codex_app_server.ex, (3) set Codex log level.","priority":"2","title":"Suppress stale rollout path errors on Codex startup","type":"task"}}
{"type":"dep_add","timestamp":"2026-03-05T08:24:18.975269Z","issue_id":"caclawphony-87b","payload":{"dep_type":"blocks","depends_on":"caclawphony-432"}}
{"type":"dep_add","timestamp":"2026-03-05T08:24:19.032248Z","issue_id":"caclawphony-5c2","payload":{"dep_type":"blocks","depends_on":"caclawphony-87b"}}
{"type":"close","timestamp":"2026-03-05T08:24:47.5443Z","issue_id":"caclawphony-5fb","payload":{}}
{"type":"close","timestamp":"2026-03-05T08:27:20.845748Z","issue_id":"caclawphony-432","payload":{}}
{"type":"close","timestamp":"2026-03-05T08:27:29.273715Z","issue_id":"caclawphony-18b","payload":{}}
{"type":"close","timestamp":"2026-03-05T08:28:41.40889Z","issue_id":"caclawphony-fa2","payload":{}}
{"type":"close","timestamp":"2026-03-05T08:29:08.255352Z","issue_id":"caclawphony-f2f","payload":{}}
{"type":"close","timestamp":"2026-03-05T08:48:57.228293Z","issue_id":"caclawphony-87b","payload":{}}
{"type":"close","timestamp":"2026-03-05T08:50:36.166536Z","issue_id":"caclawphony-9ed","payload":{}}
{"type":"close","timestamp":"2026-03-05T08:51:52.358253Z","issue_id":"caclawphony-5c2","payload":{}}

119
PLAN.md Normal file
View File

@ -0,0 +1,119 @@
# Caclawphony — Production Readiness Plan
## What Is Caclawphony?
A fork of [Symphony](https://github.com/openai/symphony) (Elixir) wired to Linear and Codex for automated PR review/prepare/merge on `openclaw/openclaw`.
**Current state:** Proof of concept working end-to-end. MAR-45 (test issue) completed the full Review cycle — Codex picked up the issue, cloned the repo, ran review-pr, produced `.local/review.md` + `.local/review.json`, transitioned the issue to "Review Complete", and Symphony detected the state change and stopped the agent.
## Architecture
```
Linear (issue tracker)
↕ polling (30s)
Symphony/Caclawphony (Elixir orchestrator)
↕ JSON-RPC over stdio
Codex app-server (agent runtime)
↕ shell + file I/O
openclaw/openclaw repo (PR workspace)
```
### Linear Workflow States
| State | Type | Description |
|-------|------|-------------|
| Triage | manual | New PRs land here |
| **Review** | active | Codex runs review-pr |
| Review Complete | gate | Human evaluates review |
| **Prepare** | active | Codex runs prepare-pr |
| Prepare Complete | gate | Human evaluates preparation |
| **Merge** | active | Codex runs merge-pr |
| Done | terminal | Merged successfully |
| Canceled | terminal | Abandoned |
| Duplicate | terminal | Superseded by another PR |
Active states trigger agent dispatch. Gate states require human intervention.
### Key Files
| File | Purpose |
|------|---------|
| `WORKFLOW.md` | Symphony config: tracker, hooks, codex settings, prompt template |
| `elixir/lib/symphony_elixir/` | Elixir source (orchestrator, agent_runner, tracker, workspace) |
| `elixir/lib/symphony_elixir/workspace.ex` | Modified: passes issue env vars to hooks |
| `SPEC.md` | Original Symphony spec (reference) |
## What Works
- [x] Linear polling picks up issues in active states
- [x] Workspace creation with git clone + PR checkout hooks
- [x] Codex app-server handshake (initialize → thread/start → turn/start)
- [x] State-aware prompt template (Review/Prepare/Merge conditional sections)
- [x] Agent executes review-pr skill, produces artifacts
- [x] Agent transitions issue state via Linear GraphQL
- [x] Symphony detects state change and stops agent (continuation check)
- [x] Issue env vars passed to hooks (SYMPHONY_ISSUE_ID, _IDENTIFIER, _TITLE, _STATE)
## What Needs Work
### P0 — Must Have for Production
1. **Codex sandbox networking** — Codex `workspace-write` sandbox blocks network access. `pnpm install`, `gh pr`, `npm test` all fail. Review worked because it fell back to local git analysis, but prepare-pr and merge-pr need network. Options:
- Switch to `danger-full-access` sandbox (works but no safety net)
- Use Symphony hooks for network-dependent setup (pre-install deps, fetch PR data)
- Contribute upstream Codex sandbox network allowlist
2. **Workspace reuse across pipeline stages** — Currently each dispatch creates a fresh workspace. Review → Prepare should reuse the same workspace so prepare-pr can read `.local/review.md`. Options:
- Key workspaces by issue ID, skip `after_create` if dir exists
- Workspace.ex `create_workspace` already does `File.mkdir_p` — just need to skip hooks on re-entry
3. **Log file creation**`LogFile` module fails silently because log dir doesn't exist. Need to ensure `log/` directory is created.
4. **Stale rollout path errors** — Codex spews ~50 "state db missing rollout path" errors on startup from old sessions. Harmless but noisy. Clean up `~/.codex/` state DB or suppress in log config.
### P1 — Important for Usability
5. **PR intake pipeline** — Need a way to feed PRs into Linear. Options:
- CLI command: `caclawphony review 34511` → creates Linear issue in Review state
- Batch import: read from `pb list` or `gh pr list` and create issues
- GitHub webhook → Linear issue creation (future)
6. **Marie Clawndo integration** — Marie should be able to create Linear issues when Josh says "review PR #X", monitor Symphony status, and report completions. Replace maniple worker spawning with Linear issue creation for the review→prepare→merge pipeline.
7. **Workspace cleanup** — After merge (terminal state), workspaces should be deleted. `after_run` hook or orchestrator cleanup on terminal state detection.
8. **Dashboard improvements** — Symphony's TUI dashboard works but could show more: current turn number, last activity timestamp, Codex token usage.
### P2 — Nice to Have
9. **PR-to-issue metadata** — Store PR number, author, URL in Linear issue description so agents have full context without parsing titles.
10. **Notification on completion** — When an issue reaches a gate state, notify via Telegram (Marie Clawndo bot).
11. **Multi-turn prepare-pr** — Prepare phase may need multiple Codex turns (fix code → run tests → iterate). Symphony supports `max_turns: 20` but we haven't tested multi-turn with state persistence.
12. **Metrics & reporting** — Track review quality, time-to-merge, agent success rate. Symphony has `StatusDashboard` with session totals — extend to persist.
## Configuration Reference
### WORKFLOW.md Codex Section
```yaml
codex:
command: codex app-server
approval_policy: on-failure # valid: untrusted, on-failure, on-request, never
read_timeout_ms: 30000 # handshake timeout (default 5000 too tight)
turn_timeout_ms: 1800000 # 30 min per turn
stall_timeout_ms: 300000 # 5 min stall detection
# thread_sandbox: workspace-write # default, valid: read-only, workspace-write, danger-full-access
```
### Linear State IDs (MAR team)
| State | ID |
|-------|----|
| Review | 2b76930f-a193-4b8f-ade5-97afed5414aa |
| Review Complete | 4f363475-bf45-48a0-9466-c38eef79aded |
| Prepare | 42036e0f-ab10-480b-9fe3-28d7cf2a6ef2 |
| Prepare Complete | 0671e7cc-46b5-424e-aed3-d9408c9d3eb9 |
| Merge | a976450a-2b6f-4fd1-90b4-f9f2eac30c92 |

View File

@ -1,40 +1,86 @@
# Symphony
# Caclawphony
Symphony turns project work into isolated, autonomous implementation runs, allowing teams to manage
work instead of supervising coding agents.
Caclawphony is an automated PR triage, review, and merge pipeline for [openclaw/openclaw](https://github.com/openclaw/openclaw). It connects a Linear project board to coding agents (Codex) via [Symphony](https://github.com/openai/symphony), turning each PR into a tracked issue that flows through a multi-stage pipeline with human gates between stages.
[![Symphony demo video preview](.github/media/symphony-demo-poster.jpg)](.github/media/symphony-demo.mp4)
_In this [demo video](.github/media/symphony-demo.mp4), Symphony monitors a Linear board for work and spawns agents to handle the tasks. The agents complete the tasks and provide proof of work: CI status, PR review feedback, complexity analysis, and walkthrough videos. When accepted, the agents land the PR safely. Engineers do not need to supervise Codex; they can manage the work at a higher level._
[📺 Demo video](https://drive.google.com/file/d/1QsTwj9oLY9FlceI3TT_AEVBXFvy31dtd/view)
> [!WARNING]
> Symphony is a low-key engineering preview for testing in trusted environments.
> This is a maintainer tool for openclaw/openclaw — not a general-purpose framework.
## Running Symphony
## How It Works
PRs are imported into Linear via `mix caclawphony.review <PR#>`, which creates an issue in the **Triage** state. Symphony polls Linear for issues in active states and dispatches Codex agents to handle each stage. Human gates between stages let the maintainer review agent output before advancing.
### Pipeline States
```
Triage → Todo → Review → Review Complete → Prepare → Prepare Complete → Merge → Done
Request Changes → Backlog → (re-entry via Triage)
Closure → Done/Duplicate
```
| State | Type | What Happens |
|-------|------|-------------|
| **Triage** | Agent | Enrichment, cluster detection, duplicate identification, vital signs. Lightweight — no repo clone needed. |
| **Todo** | Human gate | Maintainer reviews triage output, decides next step. |
| **Review** | Agent | Full PR review using `review-pr` skill. Produces structured findings. |
| **Review Complete** | Human gate | Maintainer reviews findings. Routes to Prepare, Request Changes, or Closure. |
| **Prepare** | Agent | Rebases, fixes BLOCKER/IMPORTANT findings, runs gates, pushes. Max 1 at a time (resource constraint). |
| **Prepare Complete** | Human gate | Maintainer verifies prepare output. |
| **Merge** | Agent | Deterministic squash merge with attribution and co-author trailers. |
| **Request Changes** | Agent | Posts `gh pr review --request-changes` on GitHub, moves issue to Backlog to wait for author. |
| **Closure** | Agent | Closes PR on GitHub with appropriate comment (duplicate, superseded, stale, or not useful). |
| **Done** | Terminal | PR merged or closed. |
| **Duplicate** | Terminal | PR identified as duplicate of a canonical PR. Includes structured assessment comment. |
### Key Design Decisions
- **GitHub is source of truth for review status.** Triage agents check `gh pr reviews` for prior CHANGES_REQUESTED reviews and whether the author has pushed new commits since.
- **Human gates are explicit.** Moving an issue to Request Changes IS the approval to comment on the PR. Agents never comment on GitHub without being in an authorized state.
- **Duplicates get assessments.** When a PR is marked as duplicate, a structured comment explains why the canonical PR is preferred and what unique fixes might be lost.
- **Cluster detection uses multi-signal search.** Triage runs `pr-plan --live` to refresh the PR cache, then `pr-cluster` for per-PR search across scope, keywords, files, and linked issues.
## Setup
### Requirements
Symphony works best in codebases that have adopted
[harness engineering](https://openai.com/index/harness-engineering/). Symphony is the next step --
moving from managing coding agents to managing work that needs to get done.
- Elixir + Mix
- Linear workspace with a project board
- GitHub CLI (`gh`) authenticated
- Codex CLI
- `LINEAR_API_KEY` environment variable
### Option 1. Make your own
### Import PRs
Tell your favorite coding agent to build Symphony in a programming language of your choice:
```bash
cd elixir
LINEAR_API_KEY=<key> mix caclawphony.review <PR#> [<PR#> ...]
```
> Implement Symphony according to the following spec:
> https://github.com/openai/symphony/blob/main/SPEC.md
This creates Linear issues in **Triage** state. Use `--direct` to skip enrichment and go straight to **Review**.
### Option 2. Use our experimental reference implementation
### Run Symphony
Check out [elixir/README.md](elixir/README.md) for instructions on how to set up your environment
and run the Elixir-based Symphony implementation. You can also ask your favorite coding agent to
help with the setup:
```bash
cd elixir
LINEAR_API_KEY=<key> mix run --no-halt
```
> Set up Symphony for my repository based on
> https://github.com/openai/symphony/blob/main/elixir/README.md
Symphony polls Linear every 30 seconds, picks up issues in active states, and dispatches Codex agents.
---
## Architecture
Caclawphony is built on [Symphony](https://github.com/openai/symphony) — a framework for turning project management boards into autonomous agent dispatch systems. The workflow configuration lives in [`WORKFLOW.md`](elixir/WORKFLOW.md), which defines:
- **Active and terminal states** — which Linear states trigger agent dispatch
- **Hooks**`after_create` (workspace setup, skill copy, repo clone) and `before_run` (branch checkout)
- **Gates** — human checkpoints that assign the issue back to the maintainer and notify
- **Templates** — Jinja-style prompts per state, with access to issue metadata and state IDs
- **Rules** — global constraints (e.g., never comment on GitHub except in Request Changes state)
See [`SPEC.md`](SPEC.md) for the full Symphony specification and [`elixir/README.md`](elixir/README.md) for Elixir-specific setup.
## License

28
SPEC.md
View File

@ -1647,6 +1647,34 @@ Implications:
- Hook output should be truncated in logs.
- Hook timeouts are required to avoid hanging the orchestrator.
### 15.5 Harness Hardening Guidance
Running Codex agents against repositories, issue trackers, and other inputs that may contain
sensitive data or externally-controlled content can be dangerous. A permissive deployment can lead
to data leaks, destructive mutations, or full machine compromise if the agent is induced to execute
harmful commands or use overly-powerful integrations.
Implementations should explicitly evaluate their own risk profile and harden the execution harness
where appropriate. This specification intentionally does not mandate a single hardening posture, but
ports should not assume that tracker data, repository contents, prompt inputs, or tool arguments are
fully trustworthy just because they originate inside a normal workflow.
Possible hardening measures include:
- Tightening Codex approval and sandbox settings described elsewhere in this specification instead
of running with a maximally permissive configuration.
- Adding external isolation layers such as OS/container/VM sandboxing, network restrictions, or
separate credentials beyond the built-in Codex policy controls.
- Filtering which Linear issues, projects, teams, labels, or other tracker sources are eligible for
dispatch so untrusted or out-of-scope tasks do not automatically reach the agent.
- Narrowing the optional `linear_graphql` tool so it can only read or mutate data inside the
intended project scope, rather than exposing general workspace-wide tracker access.
- Reducing the set of client-side tools, credentials, filesystem paths, and network destinations
available to the agent to the minimum needed for the workflow.
The correct controls are deployment-specific, but implementations should document them clearly and
treat harness hardening as part of the core safety model rather than an optional afterthought.
## 16. Reference Algorithms (Language-Agnostic)
### 16.1 Service Startup

881
WORKFLOW.md Normal file
View File

@ -0,0 +1,881 @@
---
tracker:
kind: linear
api_key: $LINEAR_API_KEY
project_slug: d9873e6beee9
active_states: Triage, Review, Prepare, Test, Merge, Closure, Request Changes, Rebase
terminal_states: Done, Canceled, Duplicate
polling:
interval_ms: 30000
workspace:
root: ~/Projects/caclawphony/workspaces
hooks:
after_create: |
copy_skills() {
# Copy skill files into workspace (resolving symlinks from maintainers repo)
SKILLS_SRC="/Users/phaedrus/Projects/maintainers/.agents/skills"
SKILLS_DST=".agents/skills"
if [ -d "$SKILLS_SRC" ]; then
mkdir -p "$SKILLS_DST"
for skill in review-pr prepare-pr merge-pr pr-cluster; do
if [ -d "$SKILLS_SRC/$skill" ]; then
cp -RL "$SKILLS_SRC/$skill" "$SKILLS_DST/" 2>/dev/null || true
fi
done
# Copy PR_WORKFLOW.md if present
[ -f "$SKILLS_SRC/PR_WORKFLOW.md" ] && cp "$SKILLS_SRC/PR_WORKFLOW.md" "$SKILLS_DST/" 2>/dev/null || true
fi
}
# Triage enrichment is lightweight -- just needs gh CLI + skills, no repo clone
if [ "$SYMPHONY_ISSUE_STATE" = "Triage" ]; then
copy_skills
echo "Triage enrichment -- skipping repo clone"
exit 0
fi
if [ "$SYMPHONY_ISSUE_STATE" = "Closure" ]; then
copy_skills
echo "Closure agent -- just needs gh CLI, no repo clone"
exit 0
fi
if [ "$SYMPHONY_ISSUE_STATE" = "Request Changes" ]; then
copy_skills
echo "Request Changes agent -- just needs gh CLI, no repo clone"
exit 0
fi
git clone /Users/phaedrus/Projects/openclaw . 2>/dev/null || true
git remote set-url origin https://github.com/openclaw/openclaw.git
# Extract PR number from issue title (format: "PR #1234: title" or "#1234")
PR_NUM=$(echo "$SYMPHONY_ISSUE_TITLE" | grep -oE '#[0-9]+' | head -1 | tr -d '#')
if [ -n "$PR_NUM" ]; then
gh pr checkout "$PR_NUM" --force 2>/dev/null || git checkout main
fi
copy_skills
before_run: |
# Triage, Closure, and Request Changes phases don't need repo operations
if [ "$SYMPHONY_ISSUE_STATE" = "Triage" ] || [ "$SYMPHONY_ISSUE_STATE" = "Closure" ] || [ "$SYMPHONY_ISSUE_STATE" = "Request Changes" ]; then
exit 0
fi
# Ensure we're on the right branch and up to date
git fetch origin 2>/dev/null || true
PR_NUM=$(echo "$SYMPHONY_ISSUE_TITLE" | grep -oE '#[0-9]+' | head -1 | tr -d '#')
if [ -n "$PR_NUM" ]; then
gh pr checkout "$PR_NUM" --force 2>/dev/null || true
git rebase origin/main 2>/dev/null || true
fi
timeout_ms: 120000
agent:
max_concurrent_agents: 4
max_turns: 20
max_retry_backoff_ms: 300000
retry_base_ms: 10000
continuation_delay_ms: 1000
max_concurrent_agents_by_state:
prepare: 1
test: 1
codex:
command: codex app-server
approval_policy: never
read_timeout_ms: 30000
turn_timeout_ms: 1800000
stall_timeout_ms: 300000
thread_sandbox: danger-full-access
turn_sandbox_policy:
type: dangerFullAccess
notifications:
telegram:
bot_token: $TELEGRAM_BOT_TOKEN
chat_id: $TELEGRAM_CHAT_ID
gate_states:
- Review Complete
- Prepare Complete
- Pre-merge
template: "{{ issue.identifier }}: moved to {{ issue.state }}. Review results in workspace."
gates:
review_complete:
state_id: "4f363475-bf45-48a0-9466-c38eef79aded"
assignee: "5bbd2a49-0fde-4fdd-b265-f6991c718e87"
notify: true
prepare_complete:
state_id: "0671e7cc-46b5-424e-aed3-d9408c9d3eb9"
assignee: "5bbd2a49-0fde-4fdd-b265-f6991c718e87"
notify: true
pre_merge:
state_id: "3f6e88cf-0d4b-430d-bad1-19ccdf124b3a"
assignee: "5bbd2a49-0fde-4fdd-b265-f6991c718e87"
notify: true
states:
backlog: "33710d02-89f4-4a7b-8b0c-075250c19b3e"
triage: "0b100831-6a06-431d-848a-6d20980ec7e5"
todo: "0772f6b2-85fa-4c21-ab14-6705687d475f"
request_changes: "ca300fc0-0b39-496a-a969-fed20901996c"
review: "2b76930f-a193-4b8f-ade5-97afed5414aa"
review_complete: "4f363475-bf45-48a0-9466-c38eef79aded"
prepare: "42036e0f-29e1-4ece-9ab7-6dd0de1783f8"
prepare_complete: "0671e7cc-46b5-424e-aed3-d9408c9d3eb9"
test: "591e5db0-b66e-4970-a3ea-68ba5f7b87a0"
pre_merge: "3f6e88cf-0d4b-430d-bad1-19ccdf124b3a"
duplicate: "e0c34ba1-e3b3-4de1-b16b-51a7b1be6e4d"
rebase: "de50ceb9-a0ef-4f13-849f-bf31a65392ee"
closure: "8279191b-e703-4d17-b5c0-16f17af7206f"
done: "e085693d-8142-4671-9de5-20286fae8ec6"
labels:
recommendation:
review: "884ba56a-fb80-4c83-a35e-90ab4dbff32a"
wait: "e2cfbdbb-13e3-4ccc-adeb-5abd00e2b7f9"
skip: "8488053c-9614-4fba-a84e-f2b8b8e65d32"
subsystem:
gateway: "dc7faf59-f14a-4f03-a549-c0f7fa68ae91"
channels: "69c1023d-71ee-43b3-ab2c-c2dbb2a3b93a"
browser: "4d8f75c4-96e0-4ba3-afe0-d47d36ffe48a"
agents: "406758af-c1ca-490e-800e-b8fcaa199d07"
config: "ac615836-f2a0-48b3-906c-fcf5f8e61c72"
cli: "904c5231-c8b2-4f68-9db0-2d7ca16a5607"
runtime: "e2a2870b-cd3e-4b9c-a2ec-6e116e2e1efc"
auth: "34fc1c6d-e47a-4e3e-9a51-b9cdade2f5d9"
providers: "74bb9b68-bd9b-4c88-b5c2-56ec3b0a4bde"
docs: "49152b2e-0c39-470e-9b27-3f71e1f27da7"
activity:
triaging: "aad55766-f201-4988-b430-30be21d9f94a"
reviewing: "05a165c6-2b8c-4090-a886-ff3c378c37cc"
preparing: "86496a8c-f3f0-4b89-9d1d-b0cd3389c8c6"
merging: "efc416d9-fce7-4a88-8f3d-84e46e1410bf"
rebasing: "5860c502-78fd-4122-948a-144b1fe012cc"
testing: "1580f3b6-a1e6-4365-b012-b4c901258b36"
closing: "3f58a85b-ebb1-4596-b9b5-5238432aa117"
---
# Caclawphony -- openclaw/openclaw PR Pipeline
You are a maintainer agent working on the openclaw/openclaw repository.
## Issue Context
- **Issue:** {{ issue.identifier }} -- {{ issue.title }}
- **Description:** {{ issue.description }}
- **State:** {{ issue.state }}
{% if attempt %}- **Attempt:** {{ attempt }}{% endif %}
Extract the PR number from the issue title (format: "PR #1234: title"). Use this PR number throughout.
## Activity Label
**Immediately** — before doing anything else — apply your phase's activity label:
{% if issue.state == "Triage" %}
```graphql
mutation { issueUpdate(id: "{{ issue.id }}", input: { addedLabelIds: ["{{ labels.activity.triaging }}"] }) { success } }
```
{% elsif issue.state == "Review" %}
```graphql
mutation { issueUpdate(id: "{{ issue.id }}", input: { addedLabelIds: ["{{ labels.activity.reviewing }}"] }) { success } }
```
{% elsif issue.state == "Prepare" %}
```graphql
mutation { issueUpdate(id: "{{ issue.id }}", input: { addedLabelIds: ["{{ labels.activity.preparing }}"] }) { success } }
```
{% elsif issue.state == "Test" %}
```graphql
mutation { issueUpdate(id: "{{ issue.id }}", input: { addedLabelIds: ["{{ labels.activity.testing }}"] }) { success } }
```
{% elsif issue.state == "Merge" %}
```graphql
mutation { issueUpdate(id: "{{ issue.id }}", input: { addedLabelIds: ["{{ labels.activity.merging }}"] }) { success } }
```
{% elsif issue.state == "Rebase" %}
```graphql
mutation { issueUpdate(id: "{{ issue.id }}", input: { addedLabelIds: ["{{ labels.activity.rebasing }}"] }) { success } }
```
{% elsif issue.state == "Closure" or issue.state == "Request Changes" %}
```graphql
mutation { issueUpdate(id: "{{ issue.id }}", input: { addedLabelIds: ["{{ labels.activity.closing }}"] }) { success } }
```
{% endif %}
## Your Task
{% if issue.state == "Triage" %}
### Triage / Enrichment Phase
You are a PR triage agent for openclaw/openclaw. This issue contains a PR number or GitHub URL.
Your job is to enrich it into a structured assessment that helps a maintainer decide whether to
promote this PR to code review.
Extract the PR number from the issue title or description (formats: "PR #1234: ...", "#1234",
or a GitHub URL like "https://github.com/openclaw/openclaw/pull/1234").
#### 0. Prior Review Check (Re-entry Detection)
Before gathering data, check if this PR has previously had changes requested:
```bash
gh pr reviews <PR> --repo openclaw/openclaw --json state,submittedAt,author
```
If there is a `CHANGES_REQUESTED` review, check whether new commits exist after that review's `submittedAt` timestamp:
```bash
gh pr view <PR> --repo openclaw/openclaw --json commits --jq '.commits[-1].committedDate'
```
If the latest commit is **after** the review timestamp: note "Author has pushed updates since changes were requested — prior findings may be addressed. Re-triage accordingly."
If the latest commit is **before** the review timestamp: note "Changes still outstanding from prior review — PR not ready for re-review." Set recommendation to WAIT unless the PR has been otherwise updated.
Include this finding in your assessment under a "Prior Review Status" line in Vital Signs.
---
Gather data using `gh` CLI, then produce an assessment with the following sections:
#### 1. Summary (2-3 sentences)
What does this PR do? Restate in plain language -- don't just copy the title.
#### 2. Vital Signs
- **Status:** Open / Draft / Closed / Merged
- **CI:** PASS Passing / FAIL Failing (list failed checks) / PENDING Pending
- **Mergeable:** Yes / Conflicts / Unknown
- **Age:** Created X days ago, last updated Y days ago
- **Author:** @username (association: member/contributor/first-timer, N total open PRs)
#### 3. Scope
- **Files changed:** N files, +X / -Y lines
- **Subsystems touched:** (e.g., browser, agents, config, gateway, CLI, channels)
- **Risk areas:** Flag if touching auth, migrations, core runtime, protocols
#### 4. Change Quality Signals
- Does the PR have tests?
- Does it have a clear description or is it title-only?
- Single focused change or multiple unrelated changes?
- Any obvious code smells from the diff summary?
#### 5. Related PRs
Search for open PRs touching the same primary files or by the same author.
Flag potential duplicates or conflicts.
#### 6. Recommendation & Metadata
Determine a recommendation and priority:
| Recommendation | When | Priority (Linear int) |
|---|---|---|
| **REVIEW** | Looks good, ready for code review | 2 (high) if <5 files and clean CI; 3 (medium) otherwise |
| **WAIT** | Has issues but worth watching | 3 (medium) |
| **SKIP** | Not worth reviewing right now | 4 (low) or 0 (none) for spam/stale |
Determine an **estimate** (Fibonacci complexity):
- **1** -- trivial (typo, one-liner, docs-only)
- **2** -- small (single-file fix, <50 lines)
- **3** -- medium (multi-file, focused change)
- **5** -- large (new feature, cross-cutting)
- **8** -- very large (architectural, multi-subsystem)
Determine **subsystem labels** from the files changed. Map to these label IDs:
| Subsystem | Label ID | Heuristic (file paths) |
|---|---|---|
| gateway | `{{ labels.subsystem.gateway }}` | `src/gateway/`, gateway config |
| channels | `{{ labels.subsystem.channels }}` | `src/channels/`, telegram/discord/slack/etc |
| browser | `{{ labels.subsystem.browser }}` | `src/browser/`, playwright |
| agents | `{{ labels.subsystem.agents }}` | `src/agents/`, agent config |
| config | `{{ labels.subsystem.config }}` | config schemas, settings |
| cli | `{{ labels.subsystem.cli }}` | `src/cli/`, bin/ |
| runtime | `{{ labels.subsystem.runtime }}` | core runtime, process management |
| auth | `{{ labels.subsystem.auth }}` | auth, tokens, OAuth |
| providers | `{{ labels.subsystem.providers }}` | `src/providers/`, LLM integrations |
| docs | `{{ labels.subsystem.docs }}` | `docs/`, README, markdown-only |
Recommendation labels (always apply exactly one):
| Label | Label ID |
|---|---|
| review | `{{ labels.recommendation.review }}` |
| wait | `{{ labels.recommendation.wait }}` |
| skip | `{{ labels.recommendation.skip }}` |
#### 7. Cluster Detection
**Step 1:** Refresh the local PR cache (incremental — fast if cache exists):
```bash
/Users/phaedrus/Projects/maintainers/scripts/pr-plan --live --out /Users/phaedrus/Projects/maintainers/.local/pr-plan
```
**Step 2:** Run the pr-cluster skill for this specific PR to find related/duplicate PRs:
Follow the instructions in `.agents/skills/pr-cluster/SKILL.md` to search for clusters around PR `<PR#>`.
The skill uses multi-signal GitHub API search (scope, keywords, files, linked issues) for precise per-PR clustering.
Combine results from both sources — the pr-plan clusters.json and the pr-cluster skill output — to build the full cluster picture.
If the PR is in a cluster with medium or high confidence:
1. For each cluster member, fetch metadata:
```bash
gh pr view <N> --repo openclaw/openclaw --json number,title,state,createdAt,updatedAt,additions,deletions,changedFiles,reviews,isDraft,mergeable
```
2. Pick the canonical PR -- the best candidate for merging. Prioritize:
- Not draft and not closed
- Clean CI with passing checks
- Mergeable with no conflicts
- Has tests or a meaningful description
- Fresher (more recently updated)
- Smaller and more focused
- Has reviews or approvals
- **Final tiebreaker: lowest PR number wins** (deterministic — every agent reaches the same answer)
3. **If this issue's PR IS the canonical PR:**
- For each non-canonical cluster member, check if a Linear issue already exists for it:
```bash
# Search by PR number in issue titles
```
```graphql
query {
project(id: "07919ebc-e133-4c0c-82b9-ead654ec06a2") {
issues(filter: { title: { contains: "#XXXX" } }) {
nodes { id identifier title state { name } }
}
}
}
```
- If a Linear issue exists: create a `duplicates` relation between it and this issue.
- If no Linear issue exists: create one in Duplicate state, then relate it.
Create duplicate issues (only if no existing issue found):
```graphql
mutation {
issueCreate(input: {
teamId: "2d3d9f55-ef35-47cc-a820-aeeb61399256"
title: "[#XXXX] <title>"
description: "**PR:** [openclaw/openclaw#XXXX](https://github.com/openclaw/openclaw/pull/XXXX)\n**Author:** @username (ASSOCIATION)\n\n<1-2 sentence summary>"
stateId: "{{ states.duplicate }}"
projectId: "07919ebc-e133-4c0c-82b9-ead654ec06a2"
}) {
success
issue { id identifier }
}
}
```
Then add a duplicate assessment comment on each created issue:
```graphql
mutation {
commentCreate(input: {
issueId: "<duplicate_issue_id>"
body: "## Duplicate Assessment\n\n**This PR (#XXXX)** — <1-sentence summary>. <N> files changed, +X/-Y.\n\n**Canonical PR: {{ issue.identifier }} [#YYYY](https://github.com/openclaw/openclaw/pull/YYYY)** — `<canonical title>`. Status: <MERGEABLE/CONFLICTING>.\n\n### Why #YYYY is preferred:\n\n- <concrete reasons>\n\n### What #XXXX has that #YYYY may not:\n\n- <unique fixes/edge cases, or 'Nothing canonical PR fully subsumes this one.'>"
}) { success }
}
```
Create relation (and verify it was persisted):
```graphql
mutation {
issueRelationCreate(input: {
issueId: "<duplicate_issue_id>"
relatedIssueId: "{{ issue.id }}"
type: duplicates
}) {
success
issueRelation { id }
}
}
```
**Verify each relation was created** by querying back:
```graphql
query {
issue(id: "{{ issue.id }}") {
relations { nodes { type relatedIssue { identifier } } }
}
}
```
If any expected relations are missing, retry `issueRelationCreate`. Do not proceed until all duplicate relations are confirmed.
4. **If this issue's PR is NOT the canonical PR:**
- Check if a Linear issue already exists for the canonical PR (search by PR number as above).
- If yes: create a `duplicates` relation from this issue to the canonical issue, then move **this issue** to Duplicate state.
- If no: create a new Linear issue for the canonical PR in Triage state (so it gets enriched immediately), relate this issue to it as a duplicate, then move **this issue** to Duplicate state.
Create canonical PR issue in Triage:
```graphql
mutation {
issueCreate(input: {
teamId: "2d3d9f55-ef35-47cc-a820-aeeb61399256"
title: "[#XXXX] <canonical PR's title>"
description: "**PR:** [openclaw/openclaw#XXXX](https://github.com/openclaw/openclaw/pull/XXXX)\n**Author:** @username (ASSOCIATION)\n\n<1-2 sentence summary>"
stateId: "{{ states.triage }}"
projectId: "07919ebc-e133-4c0c-82b9-ead654ec06a2"
}) {
success
issue { id identifier }
}
}
```
- **Before moving to Duplicate**, add a comment explaining the duplicate assessment:
```graphql
mutation {
commentCreate(input: {
issueId: "{{ issue.id }}"
body: "## Duplicate Assessment\n\n**This PR (#XXXX)** — <1-sentence summary>. <N> files changed, +X/-Y.\n\n**Canonical PR: [#YYYY](https://github.com/openclaw/openclaw/pull/YYYY)** — `<canonical title>` by @author. <N> files changed, +X/-Y. Status: <MERGEABLE/CONFLICTING>.\n\n### Why #YYYY is preferred over #XXXX:\n\n- <concrete reasons: mergeable vs conflicting, broader scope, fresher, better tests, etc.>\n\n### What #XXXX has that #YYYY may not:\n\n- <any unique fixes or edge cases worth checking during canonical PR review, or 'Nothing canonical PR fully subsumes this one.'>"
}) { success }
}
```
This assessment is critical — it preserves the reasoning for future review of the canonical PR and ensures unique fixes don't get lost.
- **Then stop** — do not proceed to the final metadata update. The canonical PR's issue will handle enrichment.
Move self to Duplicate:
```graphql
mutation {
issueUpdate(id: "{{ issue.id }}", input: {
title: "[#XXXX] <this PR's title>"
stateId: "{{ states.duplicate }}"
removedLabelIds: ["{{ labels.activity.triaging }}"]
}) { success }
}
```
5. Include cluster info in your assessment comment: members, canonical PR, and canonical selection rationale.
If the PR is not in any cluster, or confidence is low/unknown, skip this section and continue normal enrichment.
**Data gathering commands:**
```bash
gh pr view <PR> --repo openclaw/openclaw --json number,title,body,author,state,isDraft,createdAt,updatedAt,mergeable,files,additions,deletions,changedFiles,statusCheckRollup,reviews,authorAssociation,headRepository
gh pr diff <PR> --repo openclaw/openclaw --stat
gh pr checks <PR> --repo openclaw/openclaw
gh search prs --repo openclaw/openclaw --state open -- "<search terms from changed files>"
```
**When finished**, do these steps IN THIS ORDER (comment first, mutation last):
**Step 1: Post your full assessment as a comment on this Linear issue.**
```graphql
mutation {
commentCreate(input: {
issueId: "{{ issue.id }}"
body: "<your full assessment markdown>"
}) { success }
}
```
**Step 2: Update the issue metadata in a single mutation (this MUST be last -- it triggers a state transition that ends your session):**
1. **Title** -> `[#XXXX] <PR title>` (stable format — PR number is the identity, recommendation lives in labels)
2. **Description** -> A short description block with PR link, author, and summary (see format below)
3. **State** -> Todo (`{{ states.todo }}`)
4. **Priority** -> integer from the table above
5. **Estimate** -> Fibonacci complexity from the table above
6. **Labels** -> one recommendation label + all matching subsystem labels (array of IDs)
7. **Assignee** -> `{{ gates.review_complete.assignee }}` (maintainer -- for human review gate)
Description format (markdown):
```
**PR:** [openclaw/openclaw#1234](https://github.com/openclaw/openclaw/pull/1234)
**Author:** @username (FIRST_TIME_CONTRIBUTOR | CONTRIBUTOR | MEMBER | etc.)
<1-2 sentence summary of what the PR does>
```
```graphql
mutation {
issueUpdate(id: "{{ issue.id }}", input: {
title: "[#1234] fix streaming response"
description: "**PR:** [openclaw/openclaw#1234](https://github.com/openclaw/openclaw/pull/1234)\n**Author:** @username (CONTRIBUTOR)\n\nFixes streaming response handling by adding proper buffer flushing on connection close."
stateId: "{{ states.todo }}"
priority: 2
estimate: 3
labelIds: ["{{ labels.recommendation.review }}", "{{ labels.subsystem.gateway }}"]
removedLabelIds: ["{{ labels.activity.triaging }}"]
assigneeId: "{{ gates.review_complete.assignee }}"
}) { success }
}
```
{% elsif issue.state == "Review" %}
### Review Phase
**Before starting work**, check this Linear issue for maintainer comments that may contain context, focus areas, or known issues. Query:
```graphql
query { issue(id: "{{ issue.id }}") { comments { nodes { body createdAt user { name } } } } }
```
If any comments contain review guidance from the maintainer, factor them into your analysis.
Read the skill file at `.agents/skills/review-pr/SKILL.md` and follow its instructions exactly.
Do NOT comment on the PR on GitHub. Do NOT push any changes. This is a read-only review.
**When finished**, do these steps IN THIS ORDER (comment first, state transition last):
1. **Post a summary comment** on this Linear issue with:
- The recommendation from `.local/review.json`
- A concise summary of findings (severity + title for each)
- Key concerns or blockers
2. **Then transition this issue** to Review Complete (this MUST be last -- it ends your session):
```
mutation { issueUpdate(id: "{{ issue.id }}", input: { stateId: "4f363475-bf45-48a0-9466-c38eef79aded", assigneeId: "5bbd2a49-0fde-4fdd-b265-f6991c718e87", removedLabelIds: ["{{ labels.activity.reviewing }}"] }) { success } }
```
{% elsif issue.state == "Prepare" %}
### Prepare Phase
**Before starting work**, check this Linear issue for maintainer comments that may contain guidance, fix directions, or constraints. Query:
```graphql
query { issue(id: "{{ issue.id }}") { comments { nodes { body createdAt user { name } } } } }
```
If any comments contain fix guidance or specific instructions from the maintainer, incorporate them into your approach. Maintainer direction takes priority over review findings when they conflict.
Read the skill file at `.agents/skills/prepare-pr/SKILL.md` and follow its instructions exactly.
The `.local/review.md` and `.local/review.json` from the review phase should already be in this workspace.
**When finished**, do these steps IN THIS ORDER (comment first, state transition last):
1. **Post a summary comment** on this Linear issue with:
- What findings were fixed (with before/after if relevant)
- Gate results (pass/fail)
- Push status (commit SHA, branch name)
2. **Then transition this issue** to Prepare Complete (this MUST be last -- it ends your session):
```
mutation { issueUpdate(id: "{{ issue.id }}", input: { stateId: "0671e7cc-46b5-424e-aed3-d9408c9d3eb9", assigneeId: "5bbd2a49-0fde-4fdd-b265-f6991c718e87", removedLabelIds: ["{{ labels.activity.preparing }}"] }) { success } }
```
{% elsif issue.state == "Test" %}
### Test Phase
Run the full test suite against the prepared PR branch. This phase is intentionally separate from Prepare to avoid resource pressure during fix+gate cycles.
**Before starting work**, check this Linear issue for maintainer comments that may contain test guidance or scope restrictions. Query:
```graphql
query { issue(id: "{{ issue.id }}") { comments { nodes { body createdAt user { name } } } } }
```
#### Step 1: Identify the PR and branch
Extract the PR number from the issue title. The prepare phase should have left a prep branch:
```bash
PR_NUM=<extracted PR number>
git fetch origin
git checkout "pr-${PR_NUM}-prep" 2>/dev/null || gh pr checkout "$PR_NUM" --force
```
#### Step 2: Run the full test suite
```bash
pnpm test 2>&1 | tee .local/test-results.txt
TEST_EXIT=$?
```
If the test suite exits non-zero, analyze the failures:
- Are they **pre-existing** (known flakes, Windows-only, provider-specific)? Note them but don't block.
- Are they **introduced by this PR**? These are blockers.
To distinguish, check if the same tests fail on main:
```bash
git stash
git checkout main
pnpm test -- --grep "<failing test name>" 2>&1 | tee .local/test-baseline.txt
git checkout -
git stash pop
```
#### Step 3: Run the test kit (if present)
If `.local/test-kit/` exists from the prepare phase:
```bash
if [ -d ".local/test-kit" ]; then
for script in .local/test-kit/[0-9]*.sh; do
echo "=== Running $script ==="
bash "$script" 2>&1
done | tee .local/test-kit-results.txt
fi
```
#### Step 4: Generate test report
Create `.local/test-report.md` with:
- Overall pass/fail status
- Number of tests run, passed, failed, skipped
- For any failures: whether they're pre-existing or PR-introduced
- Test kit results (if applicable)
- Recommendation: PASS (safe to merge) or FAIL (needs fixes — send back to Prepare)
#### Step 5: Post results and transition
**If tests PASS** (no PR-introduced failures):
1. Post a summary comment on this Linear issue with the test report.
2. Transition to Pre-merge (human gate):
```graphql
mutation { issueUpdate(id: "{{ issue.id }}", input: { stateId: "{{ states.pre_merge }}", assigneeId: "{{ gates.pre_merge.assignee }}", removedLabelIds: ["{{ labels.activity.testing }}"] }) { success } }
```
**If tests FAIL** (PR-introduced failures):
1. Post a detailed failure comment on this Linear issue with failing tests, stack traces, and analysis.
2. Move back to Prepare for fixes:
```graphql
mutation { issueUpdate(id: "{{ issue.id }}", input: { stateId: "{{ states.prepare }}", removedLabelIds: ["{{ labels.activity.testing }}"] }) { success } }
```
{% elsif issue.state == "Merge" %}
### Merge Phase
Read the skill file at `.agents/skills/merge-pr/SKILL.md` and follow its instructions exactly.
**If merge-verify or merge-run fails** (conflicts, mainline drift, head mismatch, CI failure, etc.):
1. **Post a comment** on this Linear issue explaining what failed and why (include the error output).
2. **Move the issue back to Prepare** so the PR gets rebased and re-gated:
```graphql
mutation { issueUpdate(id: "{{ issue.id }}", input: { stateId: "42036e0f-29e1-4ece-9ab7-6dd0de1783f8", removedLabelIds: ["{{ labels.activity.merging }}"] }) { success } }
```
Do NOT retry the merge yourself. Stop after the state transition.
**When merge succeeds**, do these steps IN THIS ORDER (comment first, state transition last):
1. **Post a summary comment** on this Linear issue with:
- Merge commit SHA
- PR URL
- Any cleanup performed
- Duplicate review summary (for each related Duplicate issue): whether duplicate has unique value, recommended action (CLOSE or REOPEN), and a draft closing comment
Before the state transition, query issue relations to find Duplicate issues:
```graphql
query {
issue(id: "{{ issue.id }}") {
relations {
nodes {
relatedIssue {
id
identifier
title
state {
name
}
}
type
}
}
}
}
```
For each related Duplicate issue, extract the duplicate PR number and check whether it contains uncaptured value not covered by the canonical merge. Post a comment on the Duplicate Linear issue including merge confirmation, unique-value determination, recommended action (CLOSE or REOPEN), and a draft closing comment.
Then **move each Duplicate issue to Closure** so the closure agent can process it:
```graphql
mutation {
issueUpdate(id: "<duplicate_issue_id>", input: { stateId: "{{ states.closure }}" }) { success }
}
```
2. **Then transition this issue** to Done (this MUST be last -- it ends your session):
```
mutation { issueUpdate(id: "{{ issue.id }}", input: { stateId: "e085693d-8142-4671-9de5-20286fae8ec6", removedLabelIds: ["{{ labels.activity.merging }}"] }) { success } }
```
{% elsif issue.state == "Rebase" %}
### Rebase Phase
Lightweight rebase of the PR branch onto current main. No review, no gates, no tests -- just bring the branch up to date.
#### Step 1: Identify the PR
Extract the PR number from the issue title (format: `PR #1234` or `[#1234]`).
```bash
PR_NUM=<extracted PR number>
```
#### Step 2: Fetch and rebase
```bash
git fetch origin
git checkout main && git pull origin main
gh pr checkout "$PR_NUM" --force
git rebase origin/main
```
#### Step 3: Handle conflicts
**If the rebase is clean** (no conflicts), proceed to Step 4.
**If there are conflicts**, attempt to resolve them:
- **Mechanical conflicts** (import ordering, adjacent-line edits, CHANGELOG.md collisions, lockfile regeneration): resolve automatically, `git add` the resolved files, `git rebase --continue`.
- **Semantic conflicts** (both sides changed the same logic, function signatures changed, structural rewrites): do NOT guess. Abort the rebase (`git rebase --abort`) and report the conflicts in detail.
If you resolved conflicts, optionally check if the result compiles:
```bash
pnpm build 2>&1 | tail -20
```
**Important:** If build fails, DO NOT abort the rebase. The rebase was likely correct -- build failures after rebase usually indicate mainline API drift (changed function signatures, moved exports, etc.), not bad conflict resolution. These are problems for the Prepare phase to fix, not Rebase. Always proceed to Step 4 (force push) regardless of build outcome. Note any build failures in your report.
#### Step 4: Force push
```bash
git push --force-with-lease
```
If push fails (fork permissions, protected branch), try the GraphQL `updateRef` fallback:
```bash
BRANCH=$(gh pr view "$PR_NUM" --json headRefName -q .headRefName)
NEW_SHA=$(git rev-parse HEAD)
REPO=$(gh pr view "$PR_NUM" --json headRepository -q '.headRepository.owner.login + "/" + .headRepository.name')
gh api graphql -f query="mutation { updateRef(input: { refId: \"refs/heads/$BRANCH\", oid: \"$NEW_SHA\" }) { clientMutationId } }" --hostname github.com
```
#### Step 5: Report and transition
**When finished**, do these steps IN THIS ORDER (comment first, state transition last):
1. **Post a summary comment** on this Linear issue with:
- Whether the rebase was clean or required conflict resolution
- Which files had conflicts (if any) and how they were resolved
- New HEAD SHA after force push
- If rebase was aborted: detailed conflict report (which files, what kind of conflict)
2. **Then transition this issue** to Todo (this MUST be last -- it ends your session):
```
mutation { issueUpdate(id: "{{ issue.id }}", input: { stateId: "0772f6b2-85fa-4c21-ab14-6705687d475f", assigneeId: "5bbd2a49-0fde-4fdd-b265-f6991c718e87", removedLabelIds: ["{{ labels.activity.rebasing }}"] }) { success } }
```
{% elsif issue.state == "Closure" %}
### Closure Phase
Close a PR on GitHub. The reason may vary — duplicate, superseded, stale, or not useful.
#### Step 1: Gather context
1. Extract the PR number from this issue title.
2. Read **all comments on this Linear issue** to understand why it's being closed.
3. Check for **related Linear issues** (duplicates, canonical PRs):
```graphql
query {
issue(id: "{{ issue.id }}") {
relations { nodes { type relatedIssue { id identifier title state { name } } } }
}
}
```
4. Check the PR status on GitHub:
```bash
gh pr view <PR> --repo openclaw/openclaw --json state,mergedBy,mergeCommit,title
```
#### Step 2: Determine closure reason
From the context gathered, classify the closure:
| Reason | Signal | Comment tone |
|--------|--------|-------------|
| **Duplicate** | Related Linear issue with canonical PR | "This is addressed by #CANONICAL (merged as COMMIT / still open)" |
| **Superseded** | Review comments mention a merged PR or upstream commit that covers this | "This has been addressed upstream via #PR / commit SHA" |
| **Stale** | PR is old, conflicting, author inactive | "Closing as stale — feel free to reopen against current main" |
| **Not useful** | Review recommends SKIP/CLOSE, no redeeming value | "Closing — [brief reason from review]. Thank you for the contribution" |
Always be respectful. Thank the contributor.
#### Step 3: Close the PR
1. Post the closing comment:
```bash
gh pr comment <PR> --repo openclaw/openclaw --body "<closing comment>"
```
2. Close the PR:
```bash
gh pr close <PR> --repo openclaw/openclaw
```
#### Step 4: Wrap up
1. Post a confirmation comment on this Linear issue summarizing what was done.
2. Then transition this issue to Done (this MUST be last — it ends your session):
```graphql
mutation {
issueUpdate(id: "{{ issue.id }}", input: { stateId: "{{ states.done }}", removedLabelIds: ["{{ labels.activity.closing }}"] }) {
success
}
}
```
{% elsif issue.state == "Request Changes" %}
### Request Changes Phase
Post a GitHub code review requesting changes from the PR author, based on findings from the review phase.
#### Step 1: Gather review findings
1. Extract the PR number from this issue title.
2. Read **all comments on this Linear issue** — especially the review phase comment that contains findings.
3. Identify findings classified as **author-required** — these are issues the maintainer decided can't be fixed mechanically by prepare-pr:
- Fundamental design/approach problems
- Changes that need to be split into separate PRs
- Domain knowledge the author has but we don't
- Bundled unrelated changes
#### Step 2: Draft the review comment
Write a clear, actionable, respectful GitHub review comment. Structure:
```
## Changes Requested
Thank you for this contribution! We've reviewed this PR and have some feedback before it can move forward.
### [Finding 1 title]
[Clear explanation of what needs to change and why]
### [Finding 2 title]
[Clear explanation]
---
Once these are addressed, we'll re-review. Feel free to ask questions if anything is unclear.
```
Guidelines:
- Be specific about what needs to change
- Explain *why*, not just *what*
- Suggest concrete approaches where possible
- Thank the contributor
- Keep it concise — no need to repeat the full review
#### Step 3: Post the review on GitHub
```bash
gh pr review <PR> --repo openclaw/openclaw --request-changes --body "<review comment>"
```
#### Step 4: Move to Backlog
1. Post a confirmation comment on this Linear issue summarizing what was posted.
2. Then move the issue to Backlog (this MUST be last — it ends your session):
```graphql
mutation {
issueUpdate(id: "{{ issue.id }}", input: {
stateId: "{{ states.backlog }}"
removedLabelIds: ["{{ labels.activity.closing }}"]
}) { success }
}
```
{% endif %}
## Rules
{% if issue.state != "Closure" and issue.state != "Request Changes" %}
- **Never comment on the PR on GitHub** -- no PR comments, no review submissions
{% endif %}
- **Never delete the worktree** -- it persists across pipeline stages
- If you encounter an error you can't resolve, leave a comment on the Linear issue explaining what went wrong

View File

@ -1,10 +1,11 @@
# Symphony Elixir
This directory contains the current Elixir/OTP implementation of Symphony, based on
[`SPEC.md`](../SPEC.md) in the repository root.
[`SPEC.md`](../SPEC.md) at the repository root.
> [!WARNING]
> SymphonyElixir is preview software for testing in trusted environments. It is presented as-is.
> Symphony Elixir is prototype software intended for evaluation only and is presented as-is.
> We recommend implementing your own hardened version based on `SPEC.md`.
## Screenshot
@ -14,12 +15,13 @@ This directory contains the current Elixir/OTP implementation of Symphony, based
1. Polls Linear for candidate work
2. Creates an isolated workspace per issue
3. Launches Codex in [App Server mode](https://developers.openai.com/codex/app-server/) inside that
3. Launches Codex in [App Server mode](https://developers.openai.com/codex/app-server/) inside the
workspace
4. Sends workflow prompt to Codex
4. Sends a workflow prompt to Codex
5. Keeps Codex working on the issue until the work is done
During app-server sessions, Symphony also serves a client-side `linear_graphql` tool so repo skills
can make raw Linear GraphQL calls.
During app-server sessions, Symphony also serves a client-side `linear_graphql` tool so that repo
skills can make raw Linear GraphQL calls.
If a claimed issue moves to a terminal state (`Done`, `Closed`, `Cancelled`, or `Duplicate`),
Symphony stops the active agent for that issue and cleans up matching workspaces.
@ -28,13 +30,40 @@ Symphony stops the active agent for that issue and cleans up matching workspaces
1. Make sure your codebase is set up to work well with agents: see
[Harness engineering](https://openai.com/index/harness-engineering/).
2. Get a new personal token in Linear via Settings -> Security & access -> Personal API keys, and
2. Get a new personal token in Linear via Settings → Security & access → Personal API keys, and
set it as the `LINEAR_API_KEY` environment variable.
3. Copy this directory's `WORKFLOW.md` to your repo and adjust it to fit your workflow.
3. Copy this directory's `WORKFLOW.md` to your repo.
4. Optionally copy the `commit`, `push`, `pull`, `land`, and `linear` skills to your repo.
- The `linear` skill expects Symphony's `linear_graphql` app-server tool for raw Linear GraphQL
operations such as comment editing or upload flows.
5. Follow the instructions below to install the required runtime dependencies and start the service.
5. Customize the copied `WORKFLOW.md` file for your project.
- To get your project's slug, right-click the project and copy its URL. The slug is part of the
URL.
- When creating a workflow based on this repo, note that it depends on non-standard Linear
issue statuses: "Rework", "Human Review", and "Merging". You can customize them in
Team Settings → Workflow in Linear.
6. Follow the instructions below to install the required runtime dependencies and start the service.
## Prerequisites
We recommend using [mise](https://mise.jdx.dev/) to manage Elixir/Erlang versions.
```bash
mise install
mise exec -- elixir --version
```
## Run
```bash
git clone https://github.com/openai/symphony
cd symphony/elixir
mise trust
mise install
mise exec -- mix setup
mise exec -- mix build
mise exec -- ./bin/symphony ./WORKFLOW.md
```
## Configuration
@ -51,8 +80,8 @@ Optional flags:
- `--logs-root` tells Symphony to write logs under a different directory (default: `./log`)
- `--port` also starts the Phoenix observability service (default: disabled)
The `WORKFLOW.md` file uses YAML front matter for config, plus a Markdown body used as the Codex
session prompt.
The `WORKFLOW.md` file uses YAML front matter for configuration, plus a Markdown body used as the
Codex session prompt.
Minimal example:
@ -96,27 +125,18 @@ Notes:
- Use `hooks.after_create` to bootstrap a fresh workspace. For a Git-backed repo, you can run
`git clone ... .` there, along with any other setup commands you need.
- If a hook needs `mise exec` inside a freshly cloned workspace, trust the repo config and fetch
the project deps in `hooks.after_create` before invoking `mise` later from other hooks.
- `tracker.api_key` reads from `LINEAR_API_KEY` when unset or when value is `env:LINEAR_API_KEY`.
- For path values, `~` is expanded to the home directory and values prefixed with `env:VAR` are
replaced by `$VAR` before use. Example:
the project dependencies in `hooks.after_create` before invoking `mise` later from other hooks.
- `tracker.api_key` reads from `LINEAR_API_KEY` when unset or when value is `$LINEAR_API_KEY`.
- For path values, `~` is expanded to the home directory.
- For env-backed path values, use `$VAR`. `workspace.root` resolves `$VAR` before path handling,
while `codex.command` stays a shell command string and any `$VAR` expansion there happens in the
launched shell. Example:
- If a hook needs `mise exec` inside a freshly cloned workspace, trust the repo config and fetch
the project deps in `hooks.after_create` before invoking `mise` later from other hooks.
- `tracker.api_key` reads from `LINEAR_API_KEY` when unset or when value is `env:LINEAR_API_KEY`.
- For env-backed values:
- For path values, `~` is expanded to the home directory and values prefixed with `env:VAR` are
replaced by `$VAR` before use.
- For shell commands, values like `$VAR` are expanded by the launching shell.
launched shell.
```yaml
tracker:
api_key: "$LINEAR_API_KEY"
api_key: $LINEAR_API_KEY
workspace:
root: "$SYMPHONY_WORKSPACE_ROOT"
root: $SYMPHONY_WORKSPACE_ROOT
hooks:
after_create: |
git clone --depth 1 "$SOURCE_REPO_URL" .
@ -144,27 +164,6 @@ The observability UI now runs on a minimal Phoenix stack:
- `WORKFLOW.md`: in-repo workflow contract used by local runs
- `../.codex/`: repository-local Codex skills and setup helpers
## Prerequisites
This project uses [mise](https://mise.jdx.dev/) to manage Elixir/Erlang versions.
```bash
mise install
mise exec -- elixir --version
```
## Run
```bash
git clone https://github.com/openai/symphony
cd symphony/elixir
mise trust
mise install
mise exec -- mix setup
mise exec -- mix build
mise exec -- ./bin/symphony ./WORKFLOW.md
```
## Testing
```bash
@ -176,7 +175,7 @@ make all
### Why Elixir?
Elixir is built on Erlang/BEAM/OTP, which is great for supervising long-running processes. It has an
active ecosystem of tools and libraries. It also supports hot code reloading without killing
active ecosystem of tools and libraries. It also supports hot code reloading without stopping
actively running subagents, which is very useful during development.
### What's the easiest way to set this up for my own codebase?

View File

@ -1,328 +0,0 @@
---
tracker:
kind: linear
project_slug: "symphony-0c79b11b75ea"
active_states:
- Todo
- In Progress
- Merging
- Rework
terminal_states:
- Closed
- Cancelled
- Canceled
- Duplicate
- Done
polling:
interval_ms: 5000
workspace:
root: ~/code/symphony-workspaces
hooks:
after_create: |
git clone --depth 1 https://github.com/openai/symphony .
if command -v mise >/dev/null 2>&1; then
cd elixir && mise trust && mise exec -- mix deps.get
fi
before_remove: |
cd elixir && mise exec -- mix workspace.before_remove
agent:
max_concurrent_agents: 10
max_turns: 20
codex:
command: codex --config shell_environment_policy.inherit=all --config model_reasoning_effort=xhigh --model gpt-5.3-codex app-server
approval_policy: "never"
thread_sandbox: "danger-full-access"
turn_sandbox_policy:
type: dangerFullAccess
server:
port: 31335
---
You are working on a Linear ticket `{{ issue.identifier }}`
{% if attempt %}
Continuation context:
- This is retry attempt #{{ attempt }} because the ticket is still in an active state.
- Resume from the current workspace state instead of restarting from scratch.
- Do not repeat already-completed investigation or validation unless needed for new code changes.
- Do not end the turn while the issue remains in an active state unless you are blocked by missing required permissions/secrets.
{% endif %}
Issue context:
Identifier: {{ issue.identifier }}
Title: {{ issue.title }}
Current status: {{ issue.state }}
Labels: {{ issue.labels }}
URL: {{ issue.url }}
Description:
{% if issue.description %}
{{ issue.description }}
{% else %}
No description provided.
{% endif %}
Instructions:
1. This is an unattended orchestration session. Never ask a human to perform follow-up actions.
2. Only stop early for a true blocker (missing required auth/permissions/secrets). If blocked, record it in the workpad and move the issue according to workflow.
3. Final message must report completed actions and blockers only. Do not include "next steps for user".
Work only in the provided repository copy. Do not touch any other path.
## Prerequisite: Linear MCP or `linear_graphql` tool is available
The agent should be able to talk to Linear, either via a configured Linear MCP server or injected `linear_graphql` tool. If none are present, stop and ask the user to configure Linear.
## Default posture
- Start by determining the ticket's current status, then follow the matching flow for that status.
- Start every task by opening the tracking workpad comment and bringing it up to date before doing new implementation work.
- Spend extra effort up front on planning and verification design before implementation.
- Reproduce first: always confirm the current behavior/issue signal before changing code so the fix target is explicit.
- Keep ticket metadata current (state, checklist, acceptance criteria, links).
- Treat a single persistent Linear comment as the source of truth for progress.
- Use that single workpad comment for all progress and handoff notes; do not post separate "done"/summary comments.
- Treat any ticket-authored `Validation`, `Test Plan`, or `Testing` section as non-negotiable acceptance input: mirror it in the workpad and execute it before considering the work complete.
- When meaningful out-of-scope improvements are discovered during execution,
file a separate Linear issue instead of expanding scope. The follow-up issue
must include a clear title, description, and acceptance criteria, be placed in
`Backlog`, be assigned to the same project as the current issue, link the
current issue as `related`, and use `blockedBy` when the follow-up depends on
the current issue.
- Move status only when the matching quality bar is met.
- Operate autonomously end-to-end unless blocked by missing requirements, secrets, or permissions.
- Use the blocked-access escape hatch only for true external blockers (missing required tools/auth) after exhausting documented fallbacks.
## Related skills
- `linear`: interact with Linear.
- `commit`: produce clean, logical commits during implementation.
- `push`: keep remote branch current and publish updates.
- `pull`: keep branch updated with latest `origin/main` before handoff.
- `land`: when ticket reaches `Merging`, explicitly open and follow `.codex/skills/land/SKILL.md`, which includes the `land` loop.
## Status map
- `Backlog` -> out of scope for this workflow; do not modify.
- `Todo` -> queued; immediately transition to `In Progress` before active work.
- Special case: if a PR is already attached, treat as feedback/rework loop (run full PR feedback sweep, address or explicitly push back, revalidate, return to `Human Review`).
- `In Progress` -> implementation actively underway.
- `Human Review` -> PR is attached and validated; waiting on human approval.
- `Merging` -> approved by human; execute the `land` skill flow (do not call `gh pr merge` directly).
- `Rework` -> reviewer requested changes; planning + implementation required.
- `Done` -> terminal state; no further action required.
## Step 0: Determine current ticket state and route
1. Fetch the issue by explicit ticket ID.
2. Read the current state.
3. Route to the matching flow:
- `Backlog` -> do not modify issue content/state; stop and wait for human to move it to `Todo`.
- `Todo` -> immediately move to `In Progress`, then ensure bootstrap workpad comment exists (create if missing), then start execution flow.
- If PR is already attached, start by reviewing all open PR comments and deciding required changes vs explicit pushback responses.
- `In Progress` -> continue execution flow from current scratchpad comment.
- `Human Review` -> wait and poll for decision/review updates.
- `Merging` -> on entry, open and follow `.codex/skills/land/SKILL.md`; do not call `gh pr merge` directly.
- `Rework` -> run rework flow.
- `Done` -> do nothing and shut down.
4. Check whether a PR already exists for the current branch and whether it is closed.
- If a branch PR exists and is `CLOSED` or `MERGED`, treat prior branch work as non-reusable for this run.
- Create a fresh branch from `origin/main` and restart execution flow as a new attempt.
5. For `Todo` tickets, do startup sequencing in this exact order:
- `update_issue(..., state: "In Progress")`
- find/create `## Codex Workpad` bootstrap comment
- only then begin analysis/planning/implementation work.
6. Add a short comment if state and issue content are inconsistent, then proceed with the safest flow.
## Step 1: Start/continue execution (Todo or In Progress)
1. Find or create a single persistent scratchpad comment for the issue:
- Search existing comments for a marker header: `## Codex Workpad`.
- Ignore resolved comments while searching; only active/unresolved comments are eligible to be reused as the live workpad.
- If found, reuse that comment; do not create a new workpad comment.
- If not found, create one workpad comment and use it for all updates.
- Persist the workpad comment ID and only write progress updates to that ID.
2. If arriving from `Todo`, do not delay on additional status transitions: the issue should already be `In Progress` before this step begins.
3. Immediately reconcile the workpad before new edits:
- Check off items that are already done.
- Expand/fix the plan so it is comprehensive for current scope.
- Ensure `Acceptance Criteria` and `Validation` are current and still make sense for the task.
4. Start work by writing/updating a hierarchical plan in the workpad comment.
5. Ensure the workpad includes a compact environment stamp at the top as a code fence line:
- Format: `<host>:<abs-workdir>@<short-sha>`
- Example: `devbox-01:/home/dev-user/code/symphony-workspaces/MT-32@7bdde33bc`
- Do not include metadata already inferable from Linear issue fields (`issue ID`, `status`, `branch`, `PR link`).
6. Add explicit acceptance criteria and TODOs in checklist form in the same comment.
- If changes are user-facing, include a UI walkthrough acceptance criterion that describes the end-to-end user path to validate.
- If changes touch app files or app behavior, add explicit app-specific flow checks to `Acceptance Criteria` in the workpad (for example: launch path, changed interaction path, and expected result path).
- If the ticket description/comment context includes `Validation`, `Test Plan`, or `Testing` sections, copy those requirements into the workpad `Acceptance Criteria` and `Validation` sections as required checkboxes (no optional downgrade).
7. Run a principal-style self-review of the plan and refine it in the comment.
8. Before implementing, capture a concrete reproduction signal and record it in the workpad `Notes` section (command/output, screenshot, or deterministic UI behavior).
9. Run the `pull` skill to sync with latest `origin/main` before any code edits, then record the pull/sync result in the workpad `Notes`.
- Include a `pull skill evidence` note with:
- merge source(s),
- result (`clean` or `conflicts resolved`),
- resulting `HEAD` short SHA.
10. Compact context and proceed to execution.
## PR feedback sweep protocol (required)
When a ticket has an attached PR, run this protocol before moving to `Human Review`:
1. Identify the PR number from issue links/attachments.
2. Gather feedback from all channels:
- Top-level PR comments (`gh pr view --comments`).
- Inline review comments (`gh api repos/<owner>/<repo>/pulls/<pr>/comments`).
- Review summaries/states (`gh pr view --json reviews`).
3. Treat every actionable reviewer comment (human or bot), including inline review comments, as blocking until one of these is true:
- code/test/docs updated to address it, or
- explicit, justified pushback reply is posted on that thread.
4. Update the workpad plan/checklist to include each feedback item and its resolution status.
5. Re-run validation after feedback-driven changes and push updates.
6. Repeat this sweep until there are no outstanding actionable comments.
## Blocked-access escape hatch (required behavior)
Use this only when completion is blocked by missing required tools or missing auth/permissions that cannot be resolved in-session.
- GitHub is **not** a valid blocker by default. Always try fallback strategies first (alternate remote/auth mode, then continue publish/review flow).
- Do not move to `Human Review` for GitHub access/auth until all fallback strategies have been attempted and documented in the workpad.
- If a non-GitHub required tool is missing, or required non-GitHub auth is unavailable, move the ticket to `Human Review` with a short blocker brief in the workpad that includes:
- what is missing,
- why it blocks required acceptance/validation,
- exact human action needed to unblock.
- Keep the brief concise and action-oriented; do not add extra top-level comments outside the workpad.
## Step 2: Execution phase (Todo -> In Progress -> Human Review)
1. Determine current repo state (`branch`, `git status`, `HEAD`) and verify the kickoff `pull` sync result is already recorded in the workpad before implementation continues.
2. If current issue state is `Todo`, move it to `In Progress`; otherwise leave the current state unchanged.
3. Load the existing workpad comment and treat it as the active execution checklist.
- Edit it liberally whenever reality changes (scope, risks, validation approach, discovered tasks).
4. Implement against the hierarchical TODOs and keep the comment current:
- Check off completed items.
- Add newly discovered items in the appropriate section.
- Keep parent/child structure intact as scope evolves.
- Update the workpad immediately after each meaningful milestone (for example: reproduction complete, code change landed, validation run, review feedback addressed).
- Never leave completed work unchecked in the plan.
- For tickets that started as `Todo` with an attached PR, run the full PR feedback sweep protocol immediately after kickoff and before new feature work.
5. Run validation/tests required for the scope.
- Mandatory gate: execute all ticket-provided `Validation`/`Test Plan`/ `Testing` requirements when present; treat unmet items as incomplete work.
- Prefer a targeted proof that directly demonstrates the behavior you changed.
- You may make temporary local proof edits to validate assumptions (for example: tweak a local build input for `make`, or hardcode a UI account / response path) when this increases confidence.
- Revert every temporary proof edit before commit/push.
- Document these temporary proof steps and outcomes in the workpad `Validation`/`Notes` sections so reviewers can follow the evidence.
- If app-touching, run `launch-app` validation and capture/upload media via `github-pr-media` before handoff.
6. Re-check all acceptance criteria and close any gaps.
7. Before every `git push` attempt, run the required validation for your scope and confirm it passes; if it fails, address issues and rerun until green, then commit and push changes.
8. Attach PR URL to the issue (prefer attachment; use the workpad comment only if attachment is unavailable).
- Ensure the GitHub PR has label `symphony` (add it if missing).
9. Merge latest `origin/main` into branch, resolve conflicts, and rerun checks.
10. Update the workpad comment with final checklist status and validation notes.
- Mark completed plan/acceptance/validation checklist items as checked.
- Add final handoff notes (commit + validation summary) in the same workpad comment.
- Do not include PR URL in the workpad comment; keep PR linkage on the issue via attachment/link fields.
- Add a short `### Confusions` section at the bottom when any part of task execution was unclear/confusing, with concise bullets.
- Do not post any additional completion summary comment.
11. Before moving to `Human Review`, poll PR feedback and checks:
- Read the PR `Manual QA Plan` comment (when present) and use it to sharpen UI/runtime test coverage for the current change.
- Run the full PR feedback sweep protocol.
- Confirm PR checks are passing (green) after the latest changes.
- Confirm every required ticket-provided validation/test-plan item is explicitly marked complete in the workpad.
- Repeat this check-address-verify loop until no outstanding comments remain and checks are fully passing.
- Re-open and refresh the workpad before state transition so `Plan`, `Acceptance Criteria`, and `Validation` exactly match completed work.
12. Only then move issue to `Human Review`.
- Exception: if blocked by missing required non-GitHub tools/auth per the blocked-access escape hatch, move to `Human Review` with the blocker brief and explicit unblock actions.
13. For `Todo` tickets that already had a PR attached at kickoff:
- Ensure all existing PR feedback was reviewed and resolved, including inline review comments (code changes or explicit, justified pushback response).
- Ensure branch was pushed with any required updates.
- Then move to `Human Review`.
## Step 3: Human Review and merge handling
1. When the issue is in `Human Review`, do not code or change ticket content.
2. Poll for updates as needed, including GitHub PR review comments from humans and bots.
3. If review feedback requires changes, move the issue to `Rework` and follow the rework flow.
4. If approved, human moves the issue to `Merging`.
5. When the issue is in `Merging`, open and follow `.codex/skills/land/SKILL.md`, then run the `land` skill in a loop until the PR is merged. Do not call `gh pr merge` directly.
6. After merge is complete, move the issue to `Done`.
## Step 4: Rework handling
1. Treat `Rework` as a full approach reset, not incremental patching.
2. Re-read the full issue body and all human comments; explicitly identify what will be done differently this attempt.
3. Close the existing PR tied to the issue.
4. Remove the existing `## Codex Workpad` comment from the issue.
5. Create a fresh branch from `origin/main`.
6. Start over from the normal kickoff flow:
- If current issue state is `Todo`, move it to `In Progress`; otherwise keep the current state.
- Create a new bootstrap `## Codex Workpad` comment.
- Build a fresh plan/checklist and execute end-to-end.
## Completion bar before Human Review
- Step 1/2 checklist is fully complete and accurately reflected in the single workpad comment.
- Acceptance criteria and required ticket-provided validation items are complete.
- Validation/tests are green for the latest commit.
- PR feedback sweep is complete and no actionable comments remain.
- PR checks are green, branch is pushed, and PR is linked on the issue.
- Required PR metadata is present (`symphony` label).
- If app-touching, runtime validation/media requirements from `App runtime validation (required)` are complete.
## Guardrails
- If the branch PR is already closed/merged, do not reuse that branch or prior implementation state for continuation.
- For closed/merged branch PRs, create a new branch from `origin/main` and restart from reproduction/planning as if starting fresh.
- If issue state is `Backlog`, do not modify it; wait for human to move to `Todo`.
- Do not edit the issue body/description for planning or progress tracking.
- Use exactly one persistent workpad comment (`## Codex Workpad`) per issue.
- If comment editing is unavailable in-session, use the update script. Only report blocked if both MCP editing and script-based editing are unavailable.
- Temporary proof edits are allowed only for local verification and must be reverted before commit.
- If out-of-scope improvements are found, create a separate Backlog issue rather
than expanding current scope, and include a clear
title/description/acceptance criteria, same-project assignment, a `related`
link to the current issue, and `blockedBy` when the follow-up depends on the
current issue.
- Do not move to `Human Review` unless the `Completion bar before Human Review` is satisfied.
- In `Human Review`, do not make changes; wait and poll.
- If state is terminal (`Done`), do nothing and shut down.
- Keep issue text concise, specific, and reviewer-oriented.
- If blocked and no workpad exists yet, add one blocker comment describing blocker, impact, and next unblock action.
## Workpad template
Use this exact structure for the persistent workpad comment and keep it updated in place throughout execution:
````md
## Codex Workpad
```text
<hostname>:<abs-path>@<short-sha>
```
### Plan
- [ ] 1\. Parent task
- [ ] 1.1 Child task
- [ ] 1.2 Child task
- [ ] 2\. Parent task
### Acceptance Criteria
- [ ] Criterion 1
- [ ] Criterion 2
### Validation
- [ ] targeted tests: `<command>`
### Notes
- <short progress note with timestamp>
### Confusions
- <only include when something was confusing during execution>
````

1
elixir/WORKFLOW.md Symbolic link
View File

@ -0,0 +1 @@
/Users/phaedrus/Projects/caclawphony/WORKFLOW.md

View File

@ -0,0 +1,195 @@
defmodule Mix.Tasks.Caclawphony.Review do
use Mix.Task
alias SymphonyElixir.Linear.Client
@shortdoc "Create Linear review issues from GitHub PR numbers"
@moduledoc """
Creates Linear review issues from one or more GitHub PR numbers.
Usage:
mix caclawphony.review 34511 34554
mix caclawphony.review --direct 34511 # skip triage, go straight to review
mix caclawphony.review --help
"""
@triage_state_id "0b100831-6a06-431d-848a-6d20980ec7e5"
@review_state_id "2b76930f-a193-4b8f-ade5-97afed5414aa"
@project_id "07919ebc-e133-4c0c-82b9-ead654ec06a2"
@team_key "MAR"
@team_query """
query TeamByKey($key: String!) {
teams(filter: { key: { eq: $key } }, first: 1) {
nodes {
id
key
}
}
}
"""
@issue_create_mutation """
mutation CreateIssue($input: IssueCreateInput!) {
issueCreate(input: $input) {
success
issue {
id
identifier
url
}
}
}
"""
@doc """
Creates one Linear review issue per provided pull request number.
"""
@impl Mix.Task
def run(args) do
{opts, pr_args, invalid} =
OptionParser.parse(args, strict: [help: :boolean, direct: :boolean], aliases: [h: :help, d: :direct])
cond do
opts[:help] ->
Mix.shell().info(@moduledoc)
invalid != [] ->
Mix.raise("Invalid option(s): #{inspect(invalid)}")
pr_args == [] ->
Mix.raise("Provide at least one PR number. Example: mix caclawphony.review 34511 34554")
true ->
Mix.Task.run("app.start")
pr_numbers = Enum.map(pr_args, &parse_pr_number!/1)
team_id = fetch_team_id!(@team_key)
state_id = if opts[:direct], do: @review_state_id, else: @triage_state_id
Enum.each(pr_numbers, fn pr_number ->
pr_title = fetch_pr_field!(pr_number, "title")
pr_url = fetch_pr_field!(pr_number, "url")
issue =
create_review_issue!(%{
title: "PR ##{pr_number}: #{pr_title}",
description: build_description(pr_number, pr_title, pr_url),
team_id: team_id,
state_id: state_id,
project_id: @project_id
})
Mix.shell().info("Created #{issue["identifier"]} for PR ##{pr_number} (#{issue["url"]})")
end)
end
end
defp parse_pr_number!(value) do
case Integer.parse(value) do
{number, ""} when number > 0 -> Integer.to_string(number)
_ -> Mix.raise("Invalid PR number: #{inspect(value)}")
end
end
defp fetch_pr_field!(pr_number, field) do
gh_path =
case System.find_executable("gh") do
nil -> Mix.raise("GitHub CLI (gh) is required but was not found in PATH")
path -> path
end
args = ["pr", "view", pr_number, "--repo", "openclaw/openclaw", "--json", field, "-q", ".#{field}"]
case System.cmd(gh_path, args, stderr_to_stdout: true) do
{value, 0} ->
value
|> String.trim()
|> case do
"" -> Mix.raise("PR ##{pr_number} returned an empty #{field}")
trimmed -> trimmed
end
{output, status} ->
Mix.raise("Failed to read PR ##{pr_number} #{field} via gh (exit #{status}): #{String.trim(output)}")
end
end
defp fetch_team_id!(team_key) do
body = graphql_or_raise!(@team_query, %{key: team_key}, operation_name: "TeamByKey")
case get_in(body, ["data", "teams", "nodes"]) do
[%{"id" => id} | _rest] when is_binary(id) and id != "" ->
id
_ ->
Mix.raise("Could not find Linear team with key #{inspect(team_key)}")
end
end
defp create_review_issue!(attrs) do
body =
graphql_or_raise!(
@issue_create_mutation,
%{
input: %{
title: attrs.title,
description: attrs.description,
teamId: attrs.team_id,
stateId: attrs.state_id,
projectId: attrs.project_id
}
},
operation_name: "CreateIssue"
)
issue_create = get_in(body, ["data", "issueCreate"])
cond do
!is_map(issue_create) ->
Mix.raise("Linear issueCreate payload missing")
issue_create["success"] != true ->
Mix.raise("Linear issueCreate reported success=false")
!is_map(issue_create["issue"]) ->
Mix.raise("Linear issueCreate did not return an issue")
true ->
issue_create["issue"]
end
end
defp graphql_or_raise!(query, variables, opts) do
case Client.graphql(query, variables, opts) do
{:ok, %{"errors" => errors}} when is_list(errors) ->
Mix.raise("Linear GraphQL returned errors: #{inspect(errors)}")
{:ok, body} when is_map(body) ->
body
{:ok, other} ->
Mix.raise("Unexpected Linear GraphQL payload: #{inspect(other)}")
{:error, reason} ->
Mix.raise("Linear GraphQL request failed: #{inspect(reason)}")
end
end
defp build_description(pr_number, pr_title, pr_url) do
imported_at = DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601()
"""
GitHub PR intake for review.
- PR Number: ##{pr_number}
- PR Title: #{pr_title}
- PR URL: #{pr_url}
- Imported At (UTC): #{imported_at}
- Imported By: `mix caclawphony.review`
"""
|> String.trim()
end
end

View File

@ -0,0 +1,191 @@
defmodule Mix.Tasks.Caclawphony.Triage do
use Mix.Task
alias SymphonyElixir.Linear.Client
@shortdoc "Queue GitHub PRs for triage enrichment (creates Backlog issues)"
@moduledoc """
Creates lightweight Linear issues in the Backlog column from PR numbers.
Symphony's enrichment agent will pick them up and expand them into
structured assessments before moving them to Todo.
Usage:
mix caclawphony.triage 35628 35714
mix caclawphony.triage --priority 2 35628
mix caclawphony.triage --help
Options:
--priority N Set initial priority (0=none, 1=urgent, 2=high, 3=medium, 4=low)
Default: none (enrichment agent will set it)
"""
@backlog_state_id "33710d02-89f4-4a7b-8b0c-075250c19b3e"
@project_id "07919ebc-e133-4c0c-82b9-ead654ec06a2"
@team_key "MAR"
@team_query """
query TeamByKey($key: String!) {
teams(filter: { key: { eq: $key } }, first: 1) {
nodes { id key }
}
}
"""
@issue_create_mutation """
mutation CreateIssue($input: IssueCreateInput!) {
issueCreate(input: $input) {
success
issue { id identifier url }
}
}
"""
@impl Mix.Task
def run(args) do
{opts, pr_args, invalid} =
OptionParser.parse(args,
strict: [help: :boolean, priority: :integer],
aliases: [h: :help, p: :priority]
)
cond do
opts[:help] ->
Mix.shell().info(@moduledoc)
invalid != [] ->
Mix.raise("Invalid option(s): #{inspect(invalid)}")
pr_args == [] ->
Mix.raise("Provide at least one PR number. Example: mix caclawphony.triage 35628 35714")
true ->
Mix.Task.run("app.start")
priority = opts[:priority]
if priority && priority not in 0..4 do
Mix.raise("Priority must be 0-4 (got #{priority})")
end
pr_numbers = Enum.map(pr_args, &parse_pr_number!/1)
team_id = fetch_team_id!(@team_key)
Enum.each(pr_numbers, fn pr_number ->
pr_title = fetch_pr_field!(pr_number, "title")
pr_url = fetch_pr_field!(pr_number, "url")
issue =
create_backlog_issue!(%{
title: "PR ##{pr_number}: #{pr_title}",
description: "#{pr_url}",
team_id: team_id,
state_id: @backlog_state_id,
project_id: @project_id,
priority: priority
})
Mix.shell().info(
"Queued #{issue["identifier"]} for triage: PR ##{pr_number} (#{issue["url"]})"
)
end)
end
end
defp parse_pr_number!(value) do
case Integer.parse(value) do
{number, ""} when number > 0 -> Integer.to_string(number)
_ -> Mix.raise("Invalid PR number: #{inspect(value)}")
end
end
defp fetch_pr_field!(pr_number, field) do
gh_path =
case System.find_executable("gh") do
nil -> Mix.raise("GitHub CLI (gh) is required but was not found in PATH")
path -> path
end
args = ["pr", "view", pr_number, "--repo", "openclaw/openclaw", "--json", field, "-q", ".#{field}"]
case System.cmd(gh_path, args, stderr_to_stdout: true) do
{value, 0} ->
value
|> String.trim()
|> case do
"" -> Mix.raise("PR ##{pr_number} returned an empty #{field}")
trimmed -> trimmed
end
{output, status} ->
Mix.raise(
"Failed to read PR ##{pr_number} #{field} via gh (exit #{status}): #{String.trim(output)}"
)
end
end
defp fetch_team_id!(team_key) do
body = graphql_or_raise!(@team_query, %{key: team_key}, operation_name: "TeamByKey")
case get_in(body, ["data", "teams", "nodes"]) do
[%{"id" => id} | _rest] when is_binary(id) and id != "" -> id
_ -> Mix.raise("Could not find Linear team with key #{inspect(team_key)}")
end
end
defp create_backlog_issue!(attrs) do
input =
%{
title: attrs.title,
description: attrs.description,
teamId: attrs.team_id,
stateId: attrs.state_id,
projectId: attrs.project_id
}
|> maybe_put(:priority, attrs[:priority])
body =
graphql_or_raise!(
@issue_create_mutation,
%{input: input},
operation_name: "CreateIssue"
)
issue_create = get_in(body, ["data", "issueCreate"])
cond do
!is_map(issue_create) ->
Mix.raise("Linear issueCreate payload missing")
issue_create["success"] != true ->
Mix.raise("Linear issueCreate reported success=false")
!is_map(issue_create["issue"]) ->
Mix.raise("Linear issueCreate did not return an issue")
true ->
issue_create["issue"]
end
end
defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value)
defp graphql_or_raise!(query, variables, opts) do
case Client.graphql(query, variables, opts) do
{:ok, %{"errors" => errors}} when is_list(errors) ->
Mix.raise("Linear GraphQL returned errors: #{inspect(errors)}")
{:ok, body} when is_map(body) ->
body
{:ok, other} ->
Mix.raise("Unexpected Linear GraphQL payload: #{inspect(other)}")
{:error, reason} ->
Mix.raise("Linear GraphQL request failed: #{inspect(reason)}")
end
end
end

View File

@ -871,14 +871,24 @@ defmodule SymphonyElixir.Codex.AppServer do
|> String.slice(0, @max_stream_log_bytes)
if text != "" do
if String.match?(text, ~r/\b(error|warn|warning|failed|fatal|panic|exception)\b/i) do
Logger.warning("Codex #{stream_label} output: #{text}")
else
Logger.debug("Codex #{stream_label} output: #{text}")
cond do
suppress_stream_log_line?(text) ->
:ok
String.match?(text, ~r/\b(error|warn|warning|failed|fatal|panic|exception)\b/i) ->
Logger.warning("Codex #{stream_label} output: #{text}")
true ->
Logger.debug("Codex #{stream_label} output: #{text}")
end
end
end
defp suppress_stream_log_line?(text) when is_binary(text) do
normalized = String.downcase(text)
String.contains?(normalized, "missing rollout path") or String.contains?(normalized, "rollout path missing")
end
defp issue_context(%{id: issue_id, identifier: identifier}) do
"issue_id=#{issue_id} issue_identifier=#{identifier}"
end

View File

@ -28,6 +28,45 @@ defmodule SymphonyElixir.Config do
@default_max_concurrent_agents 10
@default_agent_max_turns 20
@default_max_retry_backoff_ms 300_000
@default_agent_retry_base_ms 10_000
@default_agent_continuation_delay_ms 1_000
@default_notification_gate_states []
@default_notification_template "🧹 {{ issue.identifier }}: moved to {{ issue.state }}. Review results in workspace."
@default_gate_assignee "5bbd2a49-0fde-4fdd-b265-f6991c718e87"
@default_states %{
"todo" => "0772f6b2-85fa-4c21-ab14-6705687d475f"
}
@default_labels %{
"recommendation" => %{
"review" => "884ba56a-fb80-4c83-a35e-90ab4dbff32a",
"wait" => "e2cfbdbb-13e3-4ccc-adeb-5abd00e2b7f9",
"skip" => "8488053c-9614-4fba-a84e-f2b8b8e65d32"
},
"subsystem" => %{
"gateway" => "dc7faf59-f14a-4f03-a549-c0f7fa68ae91",
"channels" => "69c1023d-71ee-43b3-ab2c-c2dbb2a3b93a",
"browser" => "4d8f75c4-96e0-4ba3-afe0-d47d36ffe48a",
"agents" => "406758af-c1ca-490e-800e-b8fcaa199d07",
"config" => "ac615836-f2a0-48b3-906c-fcf5f8e61c72",
"cli" => "904c5231-c8b2-4f68-9db0-2d7ca16a5607",
"runtime" => "e2a2870b-cd3e-4b9c-a2ec-6e116e2e1efc",
"auth" => "34fc1c6d-e47a-4e3e-9a51-b9cdade2f5d9",
"providers" => "74bb9b68-bd9b-4c88-b5c2-56ec3b0a4bde",
"docs" => "49152b2e-0c39-470e-9b27-3f71e1f27da7"
}
}
@default_gates %{
"review_complete" => %{
"state_id" => "4f363475-bf45-48a0-9466-c38eef79aded",
"assignee" => @default_gate_assignee,
"notify" => true
},
"prepare_complete" => %{
"state_id" => "0671e7cc-46b5-424e-aed3-d9408c9d3eb9",
"assignee" => @default_gate_assignee,
"notify" => true
}
}
@default_codex_command "codex app-server"
@default_codex_turn_timeout_ms 3_600_000
@default_codex_read_timeout_ms 5_000
@ -94,6 +133,14 @@ defmodule SymphonyElixir.Config do
type: :pos_integer,
default: @default_max_retry_backoff_ms
],
retry_base_ms: [
type: :pos_integer,
default: @default_agent_retry_base_ms
],
continuation_delay_ms: [
type: :pos_integer,
default: @default_agent_continuation_delay_ms
],
max_concurrent_agents_by_state: [
type: {:map, :string, :pos_integer},
default: %{}
@ -155,6 +202,37 @@ defmodule SymphonyElixir.Config do
port: [type: {:or, [:non_neg_integer, nil]}, default: nil],
host: [type: :string, default: @default_server_host]
]
],
notifications: [
type: :map,
default: %{},
keys: [
telegram: [
type: :map,
default: %{},
keys: [
bot_token: [type: {:or, [:string, nil]}, default: nil],
chat_id: [type: {:or, [:string, nil]}, default: nil]
]
],
gate_states: [
type: {:list, :string},
default: @default_notification_gate_states
],
template: [type: :string, default: @default_notification_template]
]
],
gates: [
type: {:map, :string, :map},
default: %{}
],
labels: [
type: {:map, :string, :any},
default: %{}
],
states: [
type: {:map, :string, :string},
default: %{}
]
)
@ -259,6 +337,16 @@ defmodule SymphonyElixir.Config do
get_in(validated_workflow_options(), [:agent, :max_retry_backoff_ms])
end
@spec agent_retry_base_ms() :: pos_integer()
def agent_retry_base_ms do
get_in(validated_workflow_options(), [:agent, :retry_base_ms])
end
@spec agent_continuation_delay_ms() :: pos_integer()
def agent_continuation_delay_ms do
get_in(validated_workflow_options(), [:agent, :continuation_delay_ms])
end
@spec agent_max_turns() :: pos_integer()
def agent_max_turns do
get_in(validated_workflow_options(), [:agent, :max_turns])
@ -330,6 +418,101 @@ defmodule SymphonyElixir.Config do
end
end
@spec notifications() :: %{
telegram: %{
bot_token: String.t() | nil,
chat_id: String.t() | nil
},
gate_states: [String.t()],
template: String.t()
}
def notifications do
notification_settings = get_in(validated_workflow_options(), [:notifications])
telegram_settings = Map.get(notification_settings, :telegram, %{})
%{
telegram: %{
bot_token:
telegram_settings
|> Map.get(:bot_token)
|> resolve_env_value(System.get_env("TELEGRAM_BOT_TOKEN"))
|> normalize_secret_value(),
chat_id:
telegram_settings
|> Map.get(:chat_id)
|> resolve_env_value(System.get_env("TELEGRAM_CHAT_ID"))
|> normalize_secret_value()
},
gate_states: notification_gate_states(),
template: Map.get(notification_settings, :template, @default_notification_template)
}
end
@spec gates() :: %{String.t() => %{String.t() => String.t() | boolean() | nil}}
def gates do
configured_gates = get_in(validated_workflow_options(), [:gates]) || %{}
default_assignee = @default_gate_assignee
@default_gates
|> merge_gate_definitions(configured_gates)
|> Enum.into(%{}, fn {gate_name, gate_options} ->
{gate_name, normalize_gate_options(gate_options, default_assignee)}
end)
end
@spec labels() :: %{String.t() => term()}
def labels do
configured_labels = get_in(validated_workflow_options(), [:labels]) || %{}
@default_labels
|> deep_merge_maps(configured_labels)
|> keep_string_values_only()
|> case do
:omit -> %{}
labels -> labels
end
end
@spec states() :: %{String.t() => String.t()}
def states do
configured_states = get_in(validated_workflow_options(), [:states]) || %{}
@default_states
|> Map.merge(normalize_keys(configured_states))
|> Enum.reduce(%{}, fn {state_name, state_id}, acc ->
case state_id |> resolve_env_value(nil) |> normalize_secret_value() do
nil -> acc
resolved_state_id -> Map.put(acc, state_name, resolved_state_id)
end
end)
end
@spec notification_gate_states() :: [String.t()]
def notification_gate_states do
configured_gate_states = get_in(validated_workflow_options(), [:notifications, :gate_states]) || []
case configured_gate_states do
[] ->
gates()
|> Enum.reduce([], fn {gate_name, gate_options}, acc ->
if gate_options["notify"] == true do
[gate_name_to_state(gate_name) | acc]
else
acc
end
end)
|> Enum.reverse()
states ->
states
end
end
@spec notification_template() :: String.t()
def notification_template do
notifications().template
end
@spec observability_enabled?() :: boolean()
def observability_enabled? do
get_in(validated_workflow_options(), [:observability, :dashboard_enabled])
@ -453,7 +636,11 @@ defmodule SymphonyElixir.Config do
codex: extract_codex_options(section_map(config, "codex")),
hooks: extract_hooks_options(section_map(config, "hooks")),
observability: extract_observability_options(section_map(config, "observability")),
server: extract_server_options(section_map(config, "server"))
server: extract_server_options(section_map(config, "server")),
notifications: extract_notifications_options(section_map(config, "notifications")),
gates: extract_gates_options(section_map(config, "gates")),
labels: extract_labels_options(section_map(config, "labels")),
states: extract_states_options(section_map(config, "states"))
}
end
@ -482,6 +669,11 @@ defmodule SymphonyElixir.Config do
|> put_if_present(:max_concurrent_agents, integer_value(Map.get(section, "max_concurrent_agents")))
|> put_if_present(:max_turns, positive_integer_value(Map.get(section, "max_turns")))
|> put_if_present(:max_retry_backoff_ms, positive_integer_value(Map.get(section, "max_retry_backoff_ms")))
|> put_if_present(:retry_base_ms, positive_integer_value(Map.get(section, "retry_base_ms")))
|> put_if_present(
:continuation_delay_ms,
positive_integer_value(Map.get(section, "continuation_delay_ms"))
)
|> put_if_present(
:max_concurrent_agents_by_state,
state_limits_value(Map.get(section, "max_concurrent_agents_by_state"))
@ -518,6 +710,77 @@ defmodule SymphonyElixir.Config do
|> put_if_present(:host, scalar_string_value(Map.get(section, "host")))
end
defp extract_notifications_options(section) do
%{}
|> put_if_present(:telegram, extract_notification_telegram_options(section_map(section, "telegram")))
|> put_if_present(:gate_states, csv_value(Map.get(section, "gate_states")))
|> put_if_present(:template, notification_template_value(Map.get(section, "template")))
end
defp extract_gates_options(section) when is_map(section) do
Enum.reduce(section, %{}, fn {gate_name, gate_options}, acc ->
normalized_gate_name = normalize_gate_name(gate_name)
case extract_gate_options(gate_options) do
:omit -> acc
normalized_gate_options -> Map.put(acc, normalized_gate_name, normalized_gate_options)
end
end)
end
defp extract_gates_options(_section), do: %{}
defp extract_gate_options(gate_options) when is_map(gate_options) do
gate_options = normalize_keys(gate_options)
gate_options =
%{}
|> put_if_present(:state_id, binary_value(Map.get(gate_options, "state_id")))
|> put_if_present(:assignee, binary_value(Map.get(gate_options, "assignee"), allow_empty: true))
|> put_if_present(:notify, boolean_value(Map.get(gate_options, "notify")))
if map_size(gate_options) > 0 do
gate_options
else
:omit
end
end
defp extract_gate_options(_gate_options), do: :omit
defp extract_labels_options(section) when is_map(section) do
case nested_string_map_value(section) do
%{} = labels when map_size(labels) > 0 -> labels
_ -> %{}
end
end
defp extract_labels_options(_section), do: %{}
defp extract_states_options(section) when is_map(section) do
section
|> normalize_keys()
|> Enum.reduce(%{}, fn {state_name, raw_state_id}, acc ->
case scalar_string_value(raw_state_id) do
:omit -> acc
"" -> acc
state_id -> Map.put(acc, state_name, state_id)
end
end)
|> case do
states when map_size(states) > 0 -> states
_ -> %{}
end
end
defp extract_states_options(_section), do: %{}
defp extract_notification_telegram_options(section) do
%{}
|> put_if_present(:bot_token, binary_value(Map.get(section, "bot_token"), allow_empty: true))
|> put_if_present(:chat_id, binary_value(Map.get(section, "chat_id"), allow_empty: true))
end
defp section_map(config, key) do
case Map.get(config, key) do
section when is_map(section) -> section
@ -568,6 +831,15 @@ defmodule SymphonyElixir.Config do
defp hook_command_value(_value), do: :omit
defp notification_template_value(value) when is_binary(value) do
case String.trim(value) do
"" -> :omit
_ -> value
end
end
defp notification_template_value(_value), do: :omit
defp csv_value(values) when is_list(values) do
values
|> Enum.reduce([], fn value, acc -> maybe_append_csv_value(acc, value) end)
@ -792,6 +1064,117 @@ defmodule SymphonyElixir.Config do
defp normalize_tracker_kind(_kind), do: nil
defp normalize_gate_name(gate_name) when is_binary(gate_name) do
gate_name
|> String.trim()
|> String.downcase()
end
defp normalize_gate_name(gate_name) when is_atom(gate_name) do
gate_name
|> Atom.to_string()
|> normalize_gate_name()
end
defp normalize_gate_name(gate_name), do: gate_name |> to_string() |> normalize_gate_name()
defp gate_name_to_state(gate_name) when is_binary(gate_name) do
gate_name
|> String.trim()
|> String.split("_", trim: true)
|> Enum.map(&String.capitalize/1)
|> Enum.join(" ")
end
defp gate_name_to_state(_gate_name), do: nil
defp nested_string_map_value(value) when is_map(value) do
value
|> normalize_keys()
|> Enum.reduce(%{}, fn {key, nested_value}, acc ->
case nested_string_map_value(nested_value) do
:omit -> acc
normalized_value -> Map.put(acc, key, normalized_value)
end
end)
|> case do
result when map_size(result) > 0 -> result
_ -> :omit
end
end
defp nested_string_map_value(value) do
case scalar_string_value(value) do
:omit -> :omit
"" -> :omit
normalized_value -> normalized_value
end
end
defp merge_gate_definitions(default_gates, configured_gates) do
normalized_configured_gates = normalize_keys(configured_gates)
Map.merge(default_gates, normalized_configured_gates, fn _gate_name, default_gate, configured_gate ->
Map.merge(default_gate, configured_gate)
end)
end
defp deep_merge_maps(default_map, configured_map)
when is_map(default_map) and is_map(configured_map) do
normalized_configured_map = normalize_keys(configured_map)
Map.merge(default_map, normalized_configured_map, fn _key, default_value, configured_value ->
deep_merge_maps(default_value, configured_value)
end)
end
defp deep_merge_maps(_default_value, configured_value), do: configured_value
defp keep_string_values_only(value) when is_map(value) do
value
|> Enum.reduce(%{}, fn {key, nested_value}, acc ->
case keep_string_values_only(nested_value) do
:omit -> acc
normalized_value -> Map.put(acc, key, normalized_value)
end
end)
|> case do
result when map_size(result) > 0 -> result
_ -> :omit
end
end
defp keep_string_values_only(value) when is_binary(value), do: value
defp keep_string_values_only(_value), do: :omit
defp normalize_gate_options(gate_options, default_assignee) when is_map(gate_options) do
state_id =
gate_options
|> Map.get("state_id")
|> resolve_env_value(nil)
|> normalize_secret_value()
assignee =
gate_options
|> Map.get("assignee")
|> resolve_env_value(default_assignee)
|> normalize_secret_value()
%{
"state_id" => state_id,
"assignee" => assignee,
"notify" => if(is_boolean(gate_options["notify"]), do: gate_options["notify"], else: true)
}
end
defp normalize_gate_options(_gate_options, default_assignee) do
%{
"state_id" => nil,
"assignee" => normalize_secret_value(default_assignee),
"notify" => true
}
end
defp workflow_config do
case current_workflow() do
{:ok, %{config: config}} when is_map(config) ->

View File

@ -7,6 +7,7 @@ defmodule SymphonyElixir.Linear.Client do
alias SymphonyElixir.{Config, Linear.Issue}
@issue_page_size 50
@max_error_body_log_bytes 1_000
@query """
query SymphonyLinearPoll($projectSlug: String!, $stateNames: [String!]!, $first: Int!, $relationFirst: Int!, $after: String) {
@ -168,7 +169,11 @@ defmodule SymphonyElixir.Linear.Client do
{:ok, body}
else
{:ok, response} ->
Logger.error("Linear GraphQL request failed status=#{response.status}")
Logger.error(
"Linear GraphQL request failed status=#{response.status}" <>
linear_error_context(payload, response)
)
{:error, {:linear_api_status, response.status}}
{:error, reason} ->
@ -282,6 +287,43 @@ defmodule SymphonyElixir.Linear.Client do
defp maybe_put_operation_name(payload, _operation_name), do: payload
defp linear_error_context(payload, response) when is_map(payload) do
operation_name =
case Map.get(payload, "operationName") do
name when is_binary(name) and name != "" -> " operation=#{name}"
_ -> ""
end
body =
response
|> Map.get(:body)
|> summarize_error_body()
operation_name <> " body=" <> body
end
defp summarize_error_body(body) when is_binary(body) do
body
|> String.replace(~r/\s+/, " ")
|> String.trim()
|> truncate_error_body()
|> inspect()
end
defp summarize_error_body(body) do
body
|> inspect(limit: 20, printable_limit: @max_error_body_log_bytes)
|> truncate_error_body()
end
defp truncate_error_body(body) when is_binary(body) do
if byte_size(body) > @max_error_body_log_bytes do
binary_part(body, 0, @max_error_body_log_bytes) <> "...<truncated>"
else
body
end
end
defp graphql_headers do
case Config.linear_api_token() do
nil ->

View File

@ -31,7 +31,7 @@ defmodule SymphonyElixir.LogFile do
defp setup_disk_handler(log_file, max_bytes, max_files) do
expanded_path = Path.expand(log_file)
:ok = File.mkdir_p(Path.dirname(expanded_path))
File.mkdir_p!(Path.dirname(expanded_path))
:ok = remove_existing_handler()
case :logger.add_handler(

View File

@ -0,0 +1,103 @@
defmodule SymphonyElixir.Notifier do
@moduledoc """
Sends external notifications for orchestrator gate transitions.
"""
require Logger
alias SymphonyElixir.Config
alias SymphonyElixir.Linear.Issue
@spec notify(String.t() | nil, String.t() | nil) :: :ok
def notify(issue_identifier, state_name) do
issue_context = issue_context(issue_identifier, state_name)
notifications = Config.notifications()
case telegram_credentials(notifications) do
{:ok, token, chat_id} ->
send_telegram_message(token, chat_id, issue_context, notifications.template)
{:error, reason} ->
Logger.warning("Telegram notification skipped for issue=#{inspect(issue_identifier)} state=#{inspect(state_name)} reason=#{inspect(reason)}")
end
:ok
end
@spec notify(Issue.t()) :: :ok
def notify(%Issue{} = issue) do
notify(issue.identifier, issue.state)
end
defp send_telegram_message(token, chat_id, issue_context, template) do
text = render_template(template, issue_context)
payload = %{
chat_id: chat_id,
text: text
}
url = "https://api.telegram.org/bot#{token}/sendMessage"
case Req.post(url, json: payload, connect_options: [timeout: 10_000], receive_timeout: 10_000) do
{:ok, %Req.Response{status: status}} when status in 200..299 ->
:ok
{:ok, %Req.Response{status: status, body: body}} ->
Logger.warning("Telegram notification failed for issue=#{inspect(issue_context["identifier"])} state=#{inspect(issue_context["state"])} status=#{status} body=#{inspect(body)}")
{:error, reason} ->
Logger.warning("Telegram notification request error for issue=#{inspect(issue_context["identifier"])} state=#{inspect(issue_context["state"])} error=#{inspect(reason)}")
end
rescue
exception ->
Logger.warning("Telegram notification crashed for issue=#{inspect(issue_context["identifier"])} state=#{inspect(issue_context["state"])} error=#{Exception.message(exception)}")
end
defp telegram_credentials(notifications) do
token = get_in(notifications, [:telegram, :bot_token])
chat_id = get_in(notifications, [:telegram, :chat_id])
cond do
is_nil(token) or token == "" -> {:error, :missing_telegram_bot_token}
is_nil(chat_id) or chat_id == "" -> {:error, :missing_telegram_chat_id}
true -> {:ok, token, chat_id}
end
end
defp render_template(template, issue_context) do
template =
case String.trim(to_string(template || "")) do
"" -> Config.notification_template()
configured -> configured
end
try do
template
|> Solid.parse!()
|> Solid.render!(%{"issue" => issue_context}, strict_variables: true, strict_filters: true)
|> IO.iodata_to_binary()
|> case do
"" -> default_message(issue_context)
rendered -> rendered
end
rescue
error ->
Logger.warning("Telegram notification template render failed for issue=#{inspect(issue_context["identifier"])} state=#{inspect(issue_context["state"])} error=#{Exception.message(error)}")
default_message(issue_context)
end
end
defp default_message(issue_context) do
"🧹 #{format_message_field(issue_context["identifier"])}: moved to #{format_message_field(issue_context["state"])}. Review results in workspace."
end
defp issue_context(issue_identifier, state_name) do
%{
"identifier" => issue_identifier,
"state" => state_name
}
end
defp format_message_field(value) when is_binary(value) and byte_size(value) > 0, do: value
defp format_message_field(_value), do: "unknown"
end

View File

@ -7,11 +7,9 @@ defmodule SymphonyElixir.Orchestrator do
require Logger
import Bitwise, only: [<<<: 2]
alias SymphonyElixir.{AgentRunner, Config, StatusDashboard, Tracker, Workspace}
alias SymphonyElixir.{AgentRunner, Config, Notifier, StatusDashboard, Tracker}
alias SymphonyElixir.Linear.Issue
@continuation_retry_delay_ms 1_000
@failure_retry_base_ms 10_000
# Slightly above the dashboard render interval so "checking now…" can render.
@poll_transition_render_delay_ms 20
@empty_codex_totals %{
@ -315,6 +313,7 @@ defmodule SymphonyElixir.Orchestrator do
true ->
Logger.info("Issue moved to non-active state: #{issue_context(issue)} state=#{issue.state}; stopping active agent")
notify_gate_state_transition(issue)
terminate_running_issue(state, issue.id, false)
end
@ -322,6 +321,20 @@ defmodule SymphonyElixir.Orchestrator do
defp reconcile_issue_state(_issue, state, _active_states, _terminal_states), do: state
defp notify_gate_state_transition(%Issue{} = issue) do
if gate_state_for_notification?(issue.state) do
Notifier.notify(issue)
end
end
defp gate_state_for_notification?(state) when is_binary(state) do
Config.notification_gate_states()
|> MapSet.new()
|> MapSet.member?(state)
end
defp gate_state_for_notification?(_state), do: false
defp refresh_running_issue_state(%State{} = state, %Issue{} = issue) do
case Map.get(state.running, issue.id) do
%{issue: _} = running_entry ->
@ -768,7 +781,10 @@ defmodule SymphonyElixir.Orchestrator do
end
defp cleanup_issue_workspace(identifier) when is_binary(identifier) do
Workspace.remove_issue_workspaces(identifier)
workspace = Config.workspace_root() <> "/" <> identifier
Logger.info("Cleaning up terminal issue workspace: issue_identifier=#{identifier} workspace=#{workspace}")
File.rm_rf(workspace)
:ok
end
defp cleanup_issue_workspace(_identifier), do: :ok
@ -820,7 +836,7 @@ defmodule SymphonyElixir.Orchestrator do
defp retry_delay(attempt, metadata) when is_integer(attempt) and attempt > 0 and is_map(metadata) do
if metadata[:delay_type] == :continuation and attempt == 1 do
@continuation_retry_delay_ms
Config.agent_continuation_delay_ms()
else
failure_retry_delay(attempt)
end
@ -828,7 +844,7 @@ defmodule SymphonyElixir.Orchestrator do
defp failure_retry_delay(attempt) do
max_delay_power = min(attempt - 1, 10)
min(@failure_retry_base_ms * (1 <<< max_delay_power), Config.max_retry_backoff_ms())
min(Config.agent_retry_base_ms() * (1 <<< max_delay_power), Config.max_retry_backoff_ms())
end
defp normalize_retry_attempt(attempt) when is_integer(attempt) and attempt > 0, do: attempt

View File

@ -18,7 +18,10 @@ defmodule SymphonyElixir.PromptBuilder do
|> Solid.render!(
%{
"attempt" => Keyword.get(opts, :attempt),
"issue" => issue |> Map.from_struct() |> to_solid_map()
"issue" => issue |> Map.from_struct() |> to_solid_map(),
"gates" => Config.gates() |> to_solid_map(),
"labels" => Config.labels() |> to_solid_map(),
"states" => Config.states() |> to_solid_map()
},
@render_opts
)

View File

@ -1177,6 +1177,7 @@ defmodule SymphonyElixir.StatusDashboard do
payload
|> inspect(pretty: true, limit: 30)
|> String.replace("\n", " ")
|> sanitize_ansi_and_control_bytes()
|> String.trim()
end
end
@ -1185,6 +1186,7 @@ defmodule SymphonyElixir.StatusDashboard do
defp humanize_codex_payload(payload) when is_binary(payload) do
payload
|> String.replace("\n", " ")
|> sanitize_ansi_and_control_bytes()
|> String.trim()
end
@ -1192,9 +1194,17 @@ defmodule SymphonyElixir.StatusDashboard do
payload
|> inspect(pretty: true, limit: 20)
|> String.replace("\n", " ")
|> sanitize_ansi_and_control_bytes()
|> String.trim()
end
defp sanitize_ansi_and_control_bytes(value) when is_binary(value) do
value
|> String.replace(~r/\x1B\[[0-9;]*[A-Za-z]/, "")
|> String.replace(~r/\x1B./, "")
|> String.replace(~r/[\x00-\x1F\x7F]/, "")
end
defp humanize_codex_method("thread/started", payload) do
thread_id = map_path(payload, ["params", "thread", "id"]) || map_path(payload, [:params, :thread, :id])

View File

@ -123,7 +123,7 @@ defmodule SymphonyElixir.Workspace do
end
defp maybe_run_after_create_hook(workspace, issue_context, created?) do
case created? do
case created? and not workspace_git_initialized?(workspace) do
true ->
case Config.workspace_hooks()[:after_create] do
nil ->
@ -138,6 +138,12 @@ defmodule SymphonyElixir.Workspace do
end
end
defp workspace_git_initialized?(workspace) when is_binary(workspace) do
workspace
|> Path.join(".git")
|> File.dir?()
end
defp maybe_run_before_remove_hook(workspace) do
case File.dir?(workspace) do
true ->
@ -168,9 +174,17 @@ defmodule SymphonyElixir.Workspace do
Logger.info("Running workspace hook hook=#{hook_name} #{issue_log_context(issue_context)} workspace=#{workspace}")
hook_env = [
{"SYMPHONY_ISSUE_ID", issue_context[:issue_id] || ""},
{"SYMPHONY_ISSUE_IDENTIFIER", issue_context[:issue_identifier] || ""},
{"SYMPHONY_ISSUE_TITLE", issue_context[:issue_title] || ""},
{"SYMPHONY_ISSUE_STATE", issue_context[:issue_state] || ""},
{"SYMPHONY_HOOK_NAME", hook_name}
]
task =
Task.async(fn ->
System.cmd("sh", ["-lc", command], cd: workspace, stderr_to_stdout: true)
System.cmd("sh", ["-lc", command], cd: workspace, stderr_to_stdout: true, env: hook_env)
end)
case Task.yield(task, timeout_ms) do
@ -255,24 +269,33 @@ defmodule SymphonyElixir.Workspace do
end
end
defp issue_context(%{id: issue_id, identifier: identifier}) do
defp issue_context(%{id: issue_id, identifier: identifier} = issue) do
%{
issue_id: issue_id,
issue_identifier: identifier || "issue"
issue_identifier: identifier || "issue",
issue_title: Map.get(issue, :title, ""),
issue_state: Map.get(issue, :state, ""),
issue_description: Map.get(issue, :description, "")
}
end
defp issue_context(identifier) when is_binary(identifier) do
%{
issue_id: nil,
issue_identifier: identifier
issue_identifier: identifier,
issue_title: "",
issue_state: "",
issue_description: ""
}
end
defp issue_context(_identifier) do
%{
issue_id: nil,
issue_identifier: "issue"
issue_identifier: "issue",
issue_title: "",
issue_state: "",
issue_description: ""
}
end

View File

@ -0,0 +1,35 @@
defmodule SymphonyElixirWeb.StaticAssetController do
@moduledoc """
Serves the dashboard's embedded CSS and JavaScript assets.
"""
use Phoenix.Controller, formats: []
alias Plug.Conn
alias SymphonyElixirWeb.StaticAssets
@spec dashboard_css(Conn.t(), map()) :: Conn.t()
def dashboard_css(conn, _params), do: serve(conn, "/dashboard.css")
@spec phoenix_html_js(Conn.t(), map()) :: Conn.t()
def phoenix_html_js(conn, _params), do: serve(conn, "/vendor/phoenix_html/phoenix_html.js")
@spec phoenix_js(Conn.t(), map()) :: Conn.t()
def phoenix_js(conn, _params), do: serve(conn, "/vendor/phoenix/phoenix.js")
@spec phoenix_live_view_js(Conn.t(), map()) :: Conn.t()
def phoenix_live_view_js(conn, _params), do: serve(conn, "/vendor/phoenix_live_view/phoenix_live_view.js")
defp serve(conn, path) do
case StaticAssets.fetch(path) do
{:ok, content_type, body} ->
conn
|> put_resp_content_type(content_type)
|> put_resp_header("cache-control", "public, max-age=31536000")
|> send_resp(200, body)
:error ->
send_resp(conn, 404, "Not Found")
end
end
end

View File

@ -16,40 +16,12 @@ defmodule SymphonyElixirWeb.Endpoint do
longpoll: false
)
plug(Plug.Static,
at: "/",
from: {:symphony_elixir, "priv/static"},
gzip: false,
only: ~w(assets dashboard.css)
)
plug(Plug.Static,
at: "/vendor/phoenix_html",
from: {:phoenix_html, "priv/static"},
gzip: false,
only: ~w(phoenix_html.js)
)
plug(Plug.Static,
at: "/vendor/phoenix",
from: {:phoenix, "priv/static"},
gzip: false,
only: ~w(phoenix.js)
)
plug(Plug.Static,
at: "/vendor/phoenix_live_view",
from: {:phoenix_live_view, "priv/static"},
gzip: false,
only: ~w(phoenix_live_view.js)
)
plug(Plug.RequestId)
plug(Plug.Telemetry, event_prefix: [:phoenix, :endpoint])
plug(Plug.Parsers,
parsers: [:json],
pass: ["application/json"],
parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"],
json_decoder: Jason
)

View File

@ -14,6 +14,13 @@ defmodule SymphonyElixirWeb.Router do
plug(:put_secure_browser_headers)
end
scope "/", SymphonyElixirWeb do
get("/dashboard.css", StaticAssetController, :dashboard_css)
get("/vendor/phoenix_html/phoenix_html.js", StaticAssetController, :phoenix_html_js)
get("/vendor/phoenix/phoenix.js", StaticAssetController, :phoenix_js)
get("/vendor/phoenix_live_view/phoenix_live_view.js", StaticAssetController, :phoenix_live_view_js)
end
scope "/", SymphonyElixirWeb do
pipe_through(:browser)

View File

@ -0,0 +1,33 @@
defmodule SymphonyElixirWeb.StaticAssets do
@moduledoc false
@dashboard_css_path Path.expand("../../priv/static/dashboard.css", __DIR__)
@phoenix_html_js_path Application.app_dir(:phoenix_html, "priv/static/phoenix_html.js")
@phoenix_js_path Application.app_dir(:phoenix, "priv/static/phoenix.js")
@phoenix_live_view_js_path Application.app_dir(:phoenix_live_view, "priv/static/phoenix_live_view.js")
@external_resource @dashboard_css_path
@external_resource @phoenix_html_js_path
@external_resource @phoenix_js_path
@external_resource @phoenix_live_view_js_path
@dashboard_css File.read!(@dashboard_css_path)
@phoenix_html_js File.read!(@phoenix_html_js_path)
@phoenix_js File.read!(@phoenix_js_path)
@phoenix_live_view_js File.read!(@phoenix_live_view_js_path)
@assets %{
"/dashboard.css" => {"text/css", @dashboard_css},
"/vendor/phoenix_html/phoenix_html.js" => {"application/javascript", @phoenix_html_js},
"/vendor/phoenix/phoenix.js" => {"application/javascript", @phoenix_js},
"/vendor/phoenix_live_view/phoenix_live_view.js" => {"application/javascript", @phoenix_live_view_js}
}
@spec fetch(String.t()) :: {:ok, String.t(), binary()} | :error
def fetch(path) when is_binary(path) do
case Map.fetch(@assets, path) do
{:ok, {content_type, body}} -> {:ok, content_type, body}
:error -> :error
end
end
end

View File

@ -33,10 +33,16 @@ defmodule SymphonyElixir.MixProject do
SymphonyElixirWeb.Layouts,
SymphonyElixirWeb.ObservabilityApiController,
SymphonyElixirWeb.Presenter,
SymphonyElixirWeb.StaticAssetController,
SymphonyElixirWeb.StaticAssets,
SymphonyElixirWeb.Router,
SymphonyElixirWeb.Router.Helpers
]
],
test_ignore_filters: [
"test/support/snapshot_support.exs",
"test/support/test_support.exs"
],
dialyzer: [
plt_add_apps: [:mix]
],

View File

@ -0,0 +1,202 @@
#!/usr/bin/env bash
# test-gateway.sh — Run a PR worktree build as an isolated gateway instance
#
# Usage:
# test-gateway.sh start <worktree-path> [--port PORT] [--config-dir DIR]
# test-gateway.sh stop
# test-gateway.sh status
# test-gateway.sh call <method> [--params JSON]
#
# Starts the PR build on an alternate port (default 18790) alongside the
# production gateway. No plist juggling, no config mutation. Ctrl-C or
# `test-gateway.sh stop` to tear down.
#
# Environment:
# TEST_GW_PORT Override default port (18790)
# TEST_GW_CONFIG_DIR Override config directory (default: ~/.openclaw)
# TEST_GW_LOG Log file path (default: /tmp/test-gateway.log)
set -euo pipefail
DEFAULT_PORT=18790
PIDFILE="/tmp/test-gateway.pid"
LOGFILE="${TEST_GW_LOG:-/tmp/test-gateway.log}"
die() { echo "ERROR: $*" >&2; exit 1; }
cmd_start() {
local worktree=""
local port="${TEST_GW_PORT:-$DEFAULT_PORT}"
local config_dir="${TEST_GW_CONFIG_DIR:-$HOME/.openclaw}"
while [[ $# -gt 0 ]]; do
case "$1" in
--port) port="$2"; shift 2 ;;
--config-dir) config_dir="$2"; shift 2 ;;
*)
if [[ -z "$worktree" ]]; then
worktree="$1"; shift
else
die "Unknown argument: $1"
fi
;;
esac
done
[[ -n "$worktree" ]] || die "Usage: test-gateway.sh start <worktree-path>"
[[ -d "$worktree" ]] || die "Worktree not found: $worktree"
[[ -f "$worktree/dist/index.js" ]] || die "No build found at $worktree/dist/index.js — run pnpm build first"
# Check for existing test gateway
if [[ -f "$PIDFILE" ]]; then
local old_pid
old_pid=$(cat "$PIDFILE")
if kill -0 "$old_pid" 2>/dev/null; then
die "Test gateway already running (PID $old_pid). Run 'test-gateway.sh stop' first."
fi
rm -f "$PIDFILE"
fi
# Check port availability
if lsof -i ":$port" -sTCP:LISTEN >/dev/null 2>&1; then
die "Port $port already in use"
fi
echo "Starting test gateway..."
echo " Worktree: $worktree"
echo " Port: $port"
echo " Config: $config_dir"
echo " Log: $LOGFILE"
# Start gateway in background
OPENCLAW_DATA_DIR="$config_dir" \
node "$worktree/dist/index.js" gateway \
--port "$port" \
--bind localhost \
> "$LOGFILE" 2>&1 &
local pid=$!
echo "$pid" > "$PIDFILE"
# Wait for healthcheck
echo -n "Waiting for gateway to be ready"
local attempts=0
local max_attempts=30
while (( attempts < max_attempts )); do
if curl -sf "http://127.0.0.1:$port/healthz" >/dev/null 2>&1; then
echo " ✓"
echo "Test gateway running on port $port (PID $pid)"
echo ""
echo "Usage:"
echo " export TEST_GW_PORT=$port"
echo " test-gateway.sh call sessions.list"
echo " test-gateway.sh stop"
return 0
fi
echo -n "."
sleep 1
(( attempts++ ))
# Check process is still alive
if ! kill -0 "$pid" 2>/dev/null; then
echo " ✗"
echo "Gateway process died. Last 20 lines of log:"
tail -20 "$LOGFILE"
rm -f "$PIDFILE"
return 1
fi
done
echo " ✗ (timeout)"
echo "Gateway didn't respond after ${max_attempts}s. Log tail:"
tail -20 "$LOGFILE"
kill "$pid" 2>/dev/null || true
rm -f "$PIDFILE"
return 1
}
cmd_stop() {
if [[ ! -f "$PIDFILE" ]]; then
echo "No test gateway running."
return 0
fi
local pid
pid=$(cat "$PIDFILE")
if kill -0 "$pid" 2>/dev/null; then
echo "Stopping test gateway (PID $pid)..."
kill "$pid"
# Wait for graceful shutdown
local attempts=0
while (( attempts < 10 )); do
if ! kill -0 "$pid" 2>/dev/null; then
break
fi
sleep 0.5
(( attempts++ ))
done
# Force kill if still alive
if kill -0 "$pid" 2>/dev/null; then
echo "Force killing..."
kill -9 "$pid" 2>/dev/null || true
fi
echo "Stopped."
else
echo "Process $pid not running (stale pidfile)."
fi
rm -f "$PIDFILE"
}
cmd_status() {
local port="${TEST_GW_PORT:-$DEFAULT_PORT}"
if [[ ! -f "$PIDFILE" ]]; then
echo "No test gateway running."
return 1
fi
local pid
pid=$(cat "$PIDFILE")
if kill -0 "$pid" 2>/dev/null; then
echo "Test gateway running (PID $pid, port $port)"
if curl -sf "http://127.0.0.1:$port/healthz" >/dev/null 2>&1; then
echo "Health: OK"
else
echo "Health: UNREACHABLE"
fi
else
echo "Stale pidfile (PID $pid not running)"
rm -f "$PIDFILE"
return 1
fi
}
cmd_call() {
local port="${TEST_GW_PORT:-$DEFAULT_PORT}"
local method="$1"; shift
local params='{}'
while [[ $# -gt 0 ]]; do
case "$1" in
--params) params="$2"; shift 2 ;;
--json) shift ;; # compat, always JSON
*) die "Unknown argument: $1" ;;
esac
done
curl -sf "http://127.0.0.1:$port/api" \
-H 'Content-Type: application/json' \
-d "{\"method\": \"$method\", \"params\": $params}"
}
# Main dispatch
case "${1:-}" in
start) shift; cmd_start "$@" ;;
stop) cmd_stop ;;
status) cmd_status ;;
call) shift; cmd_call "$@" ;;
*) echo "Usage: test-gateway.sh {start|stop|status|call} [args]"; exit 1 ;;
esac

View File

@ -278,9 +278,11 @@ defmodule Mix.Tasks.PrBody.CheckTest do
write_template!(@template)
File.write!("body.md", "#### Context\nContext text.")
assert_raise Mix.Error, ~r/PR body format invalid/, fn ->
Check.run(["lint", "--file", "body.md"])
end
capture_io(:stderr, fn ->
assert_raise Mix.Error, ~r/PR body format invalid/, fn ->
Check.run(["lint", "--file", "body.md"])
end
end)
end)
end

View File

@ -81,12 +81,13 @@ defmodule Mix.Tasks.Workspace.BeforeRemoveTest do
exit 0
""",
fn log_path ->
output =
capture_io(fn ->
{output, error_output} =
capture_task_output(fn ->
BeforeRemove.run([])
end)
assert output =~ "Closed PR #101 for branch feature/workpad"
assert error_output =~ "Failed to close PR #102 for branch feature/workpad"
log = File.read!(log_path)
@ -103,7 +104,13 @@ defmodule Mix.Tasks.Workspace.BeforeRemoveTest do
with_fake_gh(fn log_path ->
File.write!(log_path, "")
BeforeRemove.run(["--branch", "feature/workpad"])
{output, error_output} =
capture_task_output(fn ->
BeforeRemove.run(["--branch", "feature/workpad"])
end)
assert output =~ "Closed PR #101 for branch feature/workpad"
assert error_output =~ "Failed to close PR #102 for branch feature/workpad"
log = File.read!(log_path)
@ -112,12 +119,13 @@ defmodule Mix.Tasks.Workspace.BeforeRemoveTest do
assert log =~ "pr close 101 --repo openai/symphony"
assert log =~ "pr close 102 --repo openai/symphony"
error_output =
capture_io(:stderr, fn ->
{second_output, error_output} =
capture_task_output(fn ->
Mix.Task.reenable("workspace.before_remove")
BeforeRemove.run(["--branch", "feature/workpad"])
end)
assert second_output =~ "Closed PR #101 for branch feature/workpad"
assert error_output =~ "Failed to close PR #102 for branch feature/workpad"
end)
end
@ -355,4 +363,28 @@ defmodule Mix.Tasks.Workspace.BeforeRemoveTest do
File.rm_rf!(root)
end
end
defp capture_task_output(fun) do
parent = self()
ref = make_ref()
error_output =
capture_io(:stderr, fn ->
output =
capture_io(fn ->
fun.()
end)
send(parent, {ref, output})
end)
output =
receive do
{^ref, output} -> output
after
1_000 -> flunk("Timed out waiting for captured task output")
end
{output, error_output}
end
end

View File

@ -104,6 +104,8 @@ defmodule SymphonyElixir.TestSupport do
max_concurrent_agents: 10,
max_turns: 20,
max_retry_backoff_ms: 300_000,
agent_retry_base_ms: nil,
agent_continuation_delay_ms: nil,
max_concurrent_agents_by_state: %{},
codex_command: "codex app-server",
codex_approval_policy: %{reject: %{sandbox_approval: true, rules: true, mcp_elicitations: true}},
@ -122,6 +124,13 @@ defmodule SymphonyElixir.TestSupport do
observability_render_interval_ms: 16,
server_port: nil,
server_host: nil,
notification_telegram_bot_token: nil,
notification_telegram_chat_id: nil,
notification_gate_states: nil,
notification_template: "🧹 {{ issue.identifier }}: moved to {{ issue.state }}. Review results in workspace.",
gates: nil,
labels: nil,
states: nil,
prompt: @workflow_prompt
],
overrides
@ -139,6 +148,8 @@ defmodule SymphonyElixir.TestSupport do
max_concurrent_agents = Keyword.get(config, :max_concurrent_agents)
max_turns = Keyword.get(config, :max_turns)
max_retry_backoff_ms = Keyword.get(config, :max_retry_backoff_ms)
agent_retry_base_ms = Keyword.get(config, :agent_retry_base_ms)
agent_continuation_delay_ms = Keyword.get(config, :agent_continuation_delay_ms)
max_concurrent_agents_by_state = Keyword.get(config, :max_concurrent_agents_by_state)
codex_command = Keyword.get(config, :codex_command)
codex_approval_policy = Keyword.get(config, :codex_approval_policy)
@ -157,6 +168,13 @@ defmodule SymphonyElixir.TestSupport do
observability_render_interval_ms = Keyword.get(config, :observability_render_interval_ms)
server_port = Keyword.get(config, :server_port)
server_host = Keyword.get(config, :server_host)
notification_telegram_bot_token = Keyword.get(config, :notification_telegram_bot_token)
notification_telegram_chat_id = Keyword.get(config, :notification_telegram_chat_id)
notification_gate_states = Keyword.get(config, :notification_gate_states)
notification_template = Keyword.get(config, :notification_template)
gates = Keyword.get(config, :gates)
labels = Keyword.get(config, :labels)
states = Keyword.get(config, :states)
prompt = Keyword.get(config, :prompt)
sections =
@ -178,6 +196,9 @@ defmodule SymphonyElixir.TestSupport do
" max_concurrent_agents: #{yaml_value(max_concurrent_agents)}",
" max_turns: #{yaml_value(max_turns)}",
" max_retry_backoff_ms: #{yaml_value(max_retry_backoff_ms)}",
agent_retry_base_ms && " retry_base_ms: #{yaml_value(agent_retry_base_ms)}",
agent_continuation_delay_ms &&
" continuation_delay_ms: #{yaml_value(agent_continuation_delay_ms)}",
" max_concurrent_agents_by_state: #{yaml_value(max_concurrent_agents_by_state)}",
"codex:",
" command: #{yaml_value(codex_command)}",
@ -190,6 +211,15 @@ defmodule SymphonyElixir.TestSupport do
hooks_yaml(hook_after_create, hook_before_run, hook_after_run, hook_before_remove, hook_timeout_ms),
observability_yaml(observability_enabled, observability_refresh_ms, observability_render_interval_ms),
server_yaml(server_port, server_host),
notifications_yaml(
notification_telegram_bot_token,
notification_telegram_chat_id,
notification_gate_states,
notification_template
),
gates_yaml(gates),
labels_yaml(labels),
states_yaml(states),
"---",
prompt
]
@ -257,6 +287,45 @@ defmodule SymphonyElixir.TestSupport do
|> Enum.join("\n")
end
defp notifications_yaml(bot_token, chat_id, gate_states, template) do
[
"notifications:",
" telegram:",
" bot_token: #{yaml_value(bot_token)}",
" chat_id: #{yaml_value(chat_id)}",
" gate_states: #{yaml_value(gate_states)}",
" template: #{yaml_value(template)}"
]
|> Enum.join("\n")
end
defp gates_yaml(nil), do: nil
defp gates_yaml(gates) when is_map(gates) do
[
"gates: #{yaml_value(gates)}"
]
|> Enum.join("\n")
end
defp labels_yaml(nil), do: nil
defp labels_yaml(labels) when is_map(labels) do
[
"labels: #{yaml_value(labels)}"
]
|> Enum.join("\n")
end
defp states_yaml(nil), do: nil
defp states_yaml(states) when is_map(states) do
[
"states: #{yaml_value(states)}"
]
|> Enum.join("\n")
end
defp hook_entry(_name, nil), do: nil
defp hook_entry(name, command) when is_binary(command) do

View File

@ -1055,4 +1055,75 @@ defmodule SymphonyElixir.AppServerTest do
File.rm_rf(test_root)
end
end
test "app server suppresses stale rollout path startup noise from codex" do
test_root =
Path.join(
System.tmp_dir!(),
"symphony-elixir-app-server-rollout-noise-#{System.unique_integer([:positive])}"
)
try do
workspace_root = Path.join(test_root, "workspaces")
workspace = Path.join(workspace_root, "MT-93")
codex_binary = Path.join(test_root, "fake-codex")
File.mkdir_p!(workspace)
File.write!(codex_binary, """
#!/bin/sh
count=0
while IFS= read -r line; do
count=$((count + 1))
case "$count" in
1)
printf '%s\\n' '{"id":1,"result":{}}'
;;
2)
printf '%s\\n' '{"id":2,"result":{"thread":{"id":"thread-93"}}}'
;;
3)
printf '%s\\n' '{"id":3,"result":{"turn":{"id":"turn-93"}}}'
;;
4)
printf '%s\\n' 'error: state db missing rollout path for session abc123' >&2
printf '%s\\n' 'warning: this is stderr noise' >&2
printf '%s\\n' '{"method":"turn/completed"}'
exit 0
;;
*)
exit 0
;;
esac
done
""")
File.chmod!(codex_binary, 0o755)
write_workflow_file!(Workflow.workflow_file_path(),
workspace_root: workspace_root,
codex_command: "#{codex_binary} app-server"
)
issue = %Issue{
id: "issue-rollout-noise",
identifier: "MT-93",
title: "Suppress rollout noise",
description: "Ensure stale rollout path errors are ignored",
state: "In Progress",
url: "https://example.org/issues/MT-93",
labels: ["backend"]
}
log =
capture_log(fn ->
assert {:ok, _result} = AppServer.run(workspace, "Suppress rollout path noise", issue)
end)
refute log =~ "state db missing rollout path"
assert log =~ "Codex turn stream output: warning: this is stderr noise"
after
File.rm_rf(test_root)
end
end
end

View File

@ -13,7 +13,15 @@ defmodule SymphonyElixir.CoreTest do
assert Config.poll_interval_ms() == 30_000
assert Config.linear_active_states() == ["Todo", "In Progress"]
assert Config.linear_terminal_states() == ["Closed", "Cancelled", "Canceled", "Duplicate", "Done"]
assert Config.linear_terminal_states() == [
"Closed",
"Cancelled",
"Canceled",
"Duplicate",
"Done"
]
assert Config.linear_assignee() == nil
assert Config.agent_max_turns() == 20
@ -49,7 +57,10 @@ defmodule SymphonyElixir.CoreTest do
write_workflow_file!(Workflow.workflow_file_path(), codex_command: "/bin/sh app-server")
assert :ok = Config.validate!()
write_workflow_file!(Workflow.workflow_file_path(), codex_approval_policy: "definitely-not-valid")
write_workflow_file!(Workflow.workflow_file_path(),
codex_approval_policy: "definitely-not-valid"
)
assert :ok = Config.validate!()
write_workflow_file!(Workflow.workflow_file_path(), codex_thread_sandbox: "unsafe-ish")
@ -83,21 +94,60 @@ defmodule SymphonyElixir.CoreTest do
assert is_map(tracker)
assert Map.get(tracker, "kind") == "linear"
assert is_binary(Map.get(tracker, "project_slug"))
assert is_list(Map.get(tracker, "active_states"))
assert is_list(Map.get(tracker, "terminal_states"))
active_states = Map.get(tracker, "active_states")
assert is_list(active_states) or is_binary(active_states)
terminal_states = Map.get(tracker, "terminal_states")
assert is_list(terminal_states) or is_binary(terminal_states)
hooks = Map.get(config, "hooks", %{})
assert is_map(hooks)
assert Map.get(hooks, "after_create") =~ "git clone --depth 1 https://github.com/openai/symphony ."
assert Map.get(hooks, "after_create") =~ "cd elixir && mise trust"
assert Map.get(hooks, "after_create") =~ "mise exec -- mix deps.get"
assert Map.get(hooks, "before_remove") =~ "cd elixir && mise exec -- mix workspace.before_remove"
after_create = Map.get(hooks, "after_create")
assert is_binary(after_create) and byte_size(after_create) > 0
before_remove = Map.get(hooks, "before_remove")
assert is_nil(before_remove) or is_binary(before_remove)
assert String.trim(prompt) != ""
assert is_binary(Config.workflow_prompt())
assert Config.workflow_prompt() == prompt
end
test "current WORKFLOW.md bootstraps repo before skills and rebases PR branches onto main" do
original_workflow_path = Workflow.workflow_file_path()
on_exit(fn -> Workflow.set_workflow_file_path(original_workflow_path) end)
Workflow.clear_workflow_file_path()
assert {:ok, %{config: config}} = Workflow.load()
hooks = Map.get(config, "hooks", %{})
after_create = Map.fetch!(hooks, "after_create")
before_run = Map.fetch!(hooks, "before_run")
assert after_create =~ "copy_skills() {"
assert after_create =~ "if [ \"$SYMPHONY_ISSUE_STATE\" = \"Triage\" ]; then\n copy_skills"
assert after_create =~ "if [ \"$SYMPHONY_ISSUE_STATE\" = \"Closure\" ]; then\n copy_skills"
assert after_create =~
"if [ \"$SYMPHONY_ISSUE_STATE\" = \"Request Changes\" ]; then\n copy_skills"
{clone_index, _} =
:binary.match(after_create, "git clone /Users/phaedrus/Projects/openclaw .")
{checkout_index, _} = :binary.match(after_create, "gh pr checkout \"$PR_NUM\" --force")
{copy_after_clone_index, _} =
after_create
|> :binary.matches("copy_skills")
|> List.last()
assert clone_index < checkout_index
assert checkout_index < copy_after_clone_index
assert before_run =~ "git fetch origin"
assert before_run =~ "gh pr checkout \"$PR_NUM\" --force"
assert before_run =~ "git rebase origin/main"
refute before_run =~ "git pull --rebase origin HEAD"
end
test "linear api token resolves from LINEAR_API_KEY env var" do
previous_linear_api_key = System.get_env("LINEAR_API_KEY")
env_api_key = "test-linear-api-key"
@ -157,7 +207,9 @@ defmodule SymphonyElixir.CoreTest do
end
test "workflow load accepts prompt-only files without front matter" do
workflow_path = Path.join(Path.dirname(Workflow.workflow_file_path()), "PROMPT_ONLY_WORKFLOW.md")
workflow_path =
Path.join(Path.dirname(Workflow.workflow_file_path()), "PROMPT_ONLY_WORKFLOW.md")
File.write!(workflow_path, "Prompt only\n")
assert {:ok, %{config: %{}, prompt: "Prompt only", prompt_template: "Prompt only"}} =
@ -165,15 +217,20 @@ defmodule SymphonyElixir.CoreTest do
end
test "workflow load accepts unterminated front matter with an empty prompt" do
workflow_path = Path.join(Path.dirname(Workflow.workflow_file_path()), "UNTERMINATED_WORKFLOW.md")
workflow_path =
Path.join(Path.dirname(Workflow.workflow_file_path()), "UNTERMINATED_WORKFLOW.md")
File.write!(workflow_path, "---\ntracker:\n kind: linear\n")
assert {:ok, %{config: %{"tracker" => %{"kind" => "linear"}}, prompt: "", prompt_template: ""}} =
assert {:ok,
%{config: %{"tracker" => %{"kind" => "linear"}}, prompt: "", prompt_template: ""}} =
Workflow.load(workflow_path)
end
test "workflow load rejects non-map front matter" do
workflow_path = Path.join(Path.dirname(Workflow.workflow_file_path()), "INVALID_FRONT_MATTER_WORKFLOW.md")
workflow_path =
Path.join(Path.dirname(Workflow.workflow_file_path()), "INVALID_FRONT_MATTER_WORKFLOW.md")
File.write!(workflow_path, "---\n- not-a-map\n---\nPrompt body\n")
assert {:error, :workflow_front_matter_not_a_map} = Workflow.load(workflow_path)
@ -194,7 +251,8 @@ defmodule SymphonyElixir.CoreTest do
end)
if is_pid(orchestrator_pid) do
assert :ok = Supervisor.terminate_child(SymphonyElixir.Supervisor, SymphonyElixir.Orchestrator)
assert :ok =
Supervisor.terminate_child(SymphonyElixir.Supervisor, SymphonyElixir.Orchestrator)
end
assert {:ok, pid} = SymphonyElixir.start_link()
@ -270,6 +328,135 @@ defmodule SymphonyElixir.CoreTest do
end
end
test "gate issue state triggers telegram notification and stops running agent" do
previous_token = System.get_env("TELEGRAM_BOT_TOKEN")
previous_chat_id = System.get_env("TELEGRAM_CHAT_ID")
on_exit(fn -> restore_env("TELEGRAM_BOT_TOKEN", previous_token) end)
on_exit(fn -> restore_env("TELEGRAM_CHAT_ID", previous_chat_id) end)
System.delete_env("TELEGRAM_BOT_TOKEN")
System.delete_env("TELEGRAM_CHAT_ID")
test_root =
Path.join(
System.tmp_dir!(),
"symphony-elixir-gate-reconcile-#{System.unique_integer([:positive])}"
)
issue_id = "issue-gate"
issue_identifier = "MT-557"
workspace = Path.join(test_root, issue_identifier)
try do
write_workflow_file!(Workflow.workflow_file_path(),
workspace_root: test_root,
tracker_active_states: ["Todo", "In Progress", "In Review"],
tracker_terminal_states: ["Closed", "Cancelled", "Canceled", "Duplicate"]
)
File.mkdir_p!(test_root)
File.mkdir_p!(workspace)
agent_pid =
spawn(fn ->
receive do
:stop -> :ok
end
end)
state = %Orchestrator.State{
running: %{
issue_id => %{
pid: agent_pid,
ref: nil,
identifier: issue_identifier,
issue: %Issue{id: issue_id, state: "Todo", identifier: issue_identifier},
started_at: DateTime.utc_now()
}
},
claimed: MapSet.new([issue_id]),
codex_totals: %{input_tokens: 0, output_tokens: 0, total_tokens: 0, seconds_running: 0},
retry_attempts: %{}
}
issue = %Issue{
id: issue_id,
identifier: issue_identifier,
state: "Review Complete",
title: "Review done",
description: "Gate complete",
labels: []
}
log =
capture_log(fn ->
updated_state = Orchestrator.reconcile_issue_states_for_test([issue], state)
refute Map.has_key?(updated_state.running, issue_id)
refute MapSet.member?(updated_state.claimed, issue_id)
refute Process.alive?(agent_pid)
assert File.exists?(workspace)
end)
assert log =~ "Issue moved to non-active state"
assert log =~ "Telegram notification skipped"
assert log =~ "missing_telegram_bot_token"
after
File.rm_rf(test_root)
end
end
test "custom notification gate states come from workflow config" do
previous_token = System.get_env("TELEGRAM_BOT_TOKEN")
previous_chat_id = System.get_env("TELEGRAM_CHAT_ID")
on_exit(fn -> restore_env("TELEGRAM_BOT_TOKEN", previous_token) end)
on_exit(fn -> restore_env("TELEGRAM_CHAT_ID", previous_chat_id) end)
System.delete_env("TELEGRAM_BOT_TOKEN")
System.delete_env("TELEGRAM_CHAT_ID")
issue_id = "issue-custom-gate"
issue_identifier = "MT-558"
agent_pid = spawn(fn -> Process.sleep(:infinity) end)
write_workflow_file!(Workflow.workflow_file_path(),
tracker_active_states: ["Todo", "In Progress", "In Review"],
tracker_terminal_states: ["Closed", "Cancelled", "Canceled", "Duplicate"],
notification_gate_states: ["Quality Gate"]
)
state = %Orchestrator.State{
running: %{
issue_id => %{
pid: agent_pid,
ref: nil,
identifier: issue_identifier,
issue: %Issue{id: issue_id, state: "Todo", identifier: issue_identifier},
started_at: DateTime.utc_now()
}
},
claimed: MapSet.new([issue_id]),
codex_totals: %{input_tokens: 0, output_tokens: 0, total_tokens: 0, seconds_running: 0},
retry_attempts: %{}
}
issue = %Issue{
id: issue_id,
identifier: issue_identifier,
state: "Quality Gate",
title: "Custom gate",
description: "Configured gate state",
labels: []
}
log =
capture_log(fn ->
updated_state = Orchestrator.reconcile_issue_states_for_test([issue], state)
refute Map.has_key?(updated_state.running, issue_id)
refute MapSet.member?(updated_state.claimed, issue_id)
end)
assert log =~ "Telegram notification skipped"
assert log =~ "missing_telegram_bot_token"
end
test "terminal issue state stops running agent and cleans workspace" do
test_root =
Path.join(
@ -459,6 +646,69 @@ defmodule SymphonyElixir.CoreTest do
assert_due_in_range(due_at_ms, 500, 1_100)
end
test "retry delays use configurable agent continuation and base settings" do
issue_id = "issue-configurable-retry"
ref = make_ref()
orchestrator_name = Module.concat(__MODULE__, :ConfigurableRetryOrchestrator)
write_workflow_file!(Workflow.workflow_file_path(),
agent_continuation_delay_ms: 2_000,
agent_retry_base_ms: 20_000
)
{:ok, pid} = Orchestrator.start_link(name: orchestrator_name)
on_exit(fn ->
if Process.alive?(pid) do
Process.exit(pid, :normal)
end
end)
initial_state = :sys.get_state(pid)
running_entry = %{
pid: self(),
ref: ref,
identifier: "MT-562",
issue: %Issue{id: issue_id, identifier: "MT-562", state: "In Progress"},
started_at: DateTime.utc_now()
}
:sys.replace_state(pid, fn _ ->
initial_state
|> Map.put(:running, %{issue_id => running_entry})
|> Map.put(:claimed, MapSet.new([issue_id]))
|> Map.put(:retry_attempts, %{})
end)
send(pid, {:DOWN, ref, :process, self(), :normal})
Process.sleep(50)
continuation_state = :sys.get_state(pid)
assert %{attempt: 1, due_at_ms: continuation_due_at_ms} =
continuation_state.retry_attempts[issue_id]
assert_due_in_range(continuation_due_at_ms, 1_400, 2_200)
:sys.replace_state(pid, fn state ->
state
|> Map.put(:running, %{
issue_id => Map.put(running_entry, :retry_attempt, 0)
})
|> Map.put(:claimed, MapSet.new([issue_id]))
|> Map.put(:retry_attempts, %{})
end)
send(pid, {:DOWN, ref, :process, self(), :boom})
Process.sleep(50)
failure_state = :sys.get_state(pid)
assert %{attempt: 1, due_at_ms: failure_due_at_ms, error: "agent exited: :boom"} =
failure_state.retry_attempts[issue_id]
assert_due_in_range(failure_due_at_ms, 19_000, 20_500)
end
test "abnormal worker exit increments retry attempt progressively" do
issue_id = "issue-crash"
ref = make_ref()
@ -571,8 +821,39 @@ defmodule SymphonyElixir.CoreTest do
assert prompt =~ "attempt=3"
end
test "prompt builder renders config-backed labels, gates, and states variables" do
workflow_prompt =
"review={{ labels.recommendation.review }} gate={{ gates.review_complete.state_id }} assignee={{ gates.review_complete.assignee }} todo={{ states.todo }}"
write_workflow_file!(Workflow.workflow_file_path(),
prompt: workflow_prompt,
labels: %{"recommendation" => %{"review" => "label-review"}},
gates: %{
"review_complete" => %{"state_id" => "state-review", "assignee" => "assignee-review"}
},
states: %{"todo" => "state-todo"}
)
issue = %Issue{
identifier: "S-2",
title: "Template vars",
description: "Use config variables",
state: "Todo",
url: "https://example.org/issues/S-2",
labels: ["backend"]
}
prompt = PromptBuilder.build_prompt(issue)
assert prompt =~ "review=label-review"
assert prompt =~ "gate=state-review"
assert prompt =~ "assignee=assignee-review"
assert prompt =~ "todo=state-todo"
end
test "prompt builder renders issue datetime fields without crashing" do
workflow_prompt = "Ticket {{ issue.identifier }} created={{ issue.created_at }} updated={{ issue.updated_at }}"
workflow_prompt =
"Ticket {{ issue.identifier }} created={{ issue.created_at }} updated={{ issue.updated_at }}"
write_workflow_file!(Workflow.workflow_file_path(), prompt: workflow_prompt)
@ -709,9 +990,12 @@ defmodule SymphonyElixir.CoreTest do
end
end)
assert :ok = Supervisor.terminate_child(SymphonyElixir.Supervisor, SymphonyElixir.WorkflowStore)
assert :ok =
Supervisor.terminate_child(SymphonyElixir.Supervisor, SymphonyElixir.WorkflowStore)
Workflow.set_workflow_file_path(Path.join(System.tmp_dir!(), "missing-workflow-#{System.unique_integer([:positive])}.md"))
Workflow.set_workflow_file_path(
Path.join(System.tmp_dir!(), "missing-workflow-#{System.unique_integer([:positive])}.md")
)
issue = %Issue{
identifier: "MT-780",
@ -736,7 +1020,7 @@ defmodule SymphonyElixir.CoreTest do
title: "Use rich templates for WORKFLOW.md",
description: "Render with rich template variables",
state: "In Progress",
url: "https://linear.app/openai/issue/MT-616/use-rich-templates-for-workflowmd",
url: "https://example.org/issues/MT-616/use-rich-templates-for-workflowmd",
labels: ["templating", "workflow"]
}
@ -744,19 +1028,27 @@ defmodule SymphonyElixir.CoreTest do
prompt = PromptBuilder.build_prompt(issue, attempt: 2)
assert prompt =~ "You are working on a Linear ticket `MT-616`"
assert prompt =~ "Issue context:"
assert prompt =~ "Identifier: MT-616"
assert prompt =~ "Title: Use rich templates for WORKFLOW.md"
assert prompt =~ "Current status: In Progress"
assert prompt =~ "https://linear.app/openai/issue/MT-616/use-rich-templates-for-workflowmd"
assert prompt =~ "This is an unattended orchestration session."
assert prompt =~ "Only stop early for a true blocker"
assert prompt =~ "Do not include \"next steps for user\""
assert prompt =~ "open and follow `.codex/skills/land/SKILL.md`"
assert prompt =~ "Do not call `gh pr merge` directly"
assert prompt =~ "Continuation context:"
assert prompt =~ "retry attempt #2"
assert prompt =~ "MT-616"
assert prompt =~ "Use rich templates for WORKFLOW.md"
assert prompt =~ "In Progress"
assert prompt =~ "Never comment on the PR on GitHub"
assert prompt =~ "Never delete the worktree"
assert Config.linear_active_states() == [
"Triage",
"Review",
"Prepare",
"Test",
"Merge",
"Closure",
"Request Changes",
"Rebase"
]
assert Config.states()["todo"] == "0772f6b2-85fa-4c21-ab14-6705687d475f"
assert Config.states()["duplicate"] == "e0c34ba1-e3b3-4de1-b16b-51a7b1be6e4d"
assert Config.states()["closure"] == "8279191b-e703-4d17-b5c0-16f17af7206f"
assert Config.states()["done"] == "e085693d-8142-4671-9de5-20286fae8ec6"
end
test "prompt builder adds continuation guidance for retries" do
@ -1471,7 +1763,10 @@ defmodule SymphonyElixir.CoreTest do
codex_thread_sandbox: "workspace-write",
codex_turn_sandbox_policy: %{
type: "workspaceWrite",
writableRoots: [Path.expand(workspace), Path.join(Path.expand(workspace_root), ".cache")]
writableRoots: [
Path.expand(workspace),
Path.join(Path.expand(workspace_root), ".cache")
]
}
)
@ -1506,7 +1801,10 @@ defmodule SymphonyElixir.CoreTest do
expected_turn_policy = %{
"type" => "workspaceWrite",
"writableRoots" => [Path.expand(workspace), Path.join(Path.expand(workspace_root), ".cache")]
"writableRoots" => [
Path.expand(workspace),
Path.join(Path.expand(workspace_root), ".cache")
]
}
assert Enum.any?(lines, fn line ->

View File

@ -468,7 +468,7 @@ defmodule SymphonyElixir.ExtensionsTest do
}
end
test "dashboard bootstraps liveview from dependency static assets" do
test "dashboard bootstraps liveview from embedded static assets" do
orchestrator_name = Module.concat(__MODULE__, :AssetOrchestrator)
{:ok, _pid} =
@ -499,6 +499,9 @@ defmodule SymphonyElixir.ExtensionsTest do
assert dashboard_css =~ "[data-phx-main].phx-connected .status-badge-live"
assert dashboard_css =~ "[data-phx-main].phx-connected .status-badge-offline"
phoenix_html_js = response(get(build_conn(), "/vendor/phoenix_html/phoenix_html.js"), 200)
assert phoenix_html_js =~ "phoenix.link.click"
phoenix_js = response(get(build_conn(), "/vendor/phoenix/phoenix.js"), 200)
assert phoenix_js =~ "var Phoenix = (() => {"
@ -595,7 +598,7 @@ defmodule SymphonyElixir.ExtensionsTest do
assert html =~ "snapshot_unavailable"
end
test "http server starts the phoenix endpoint, resolves the bound port, and rejects invalid hosts" do
test "http server serves embedded assets, accepts form posts, and rejects invalid hosts" do
spec = HttpServer.child_spec(port: 0)
assert spec.id == HttpServer
assert spec.start == {HttpServer, :start_link, [[port: 0]]}
@ -631,6 +634,32 @@ defmodule SymphonyElixir.ExtensionsTest do
assert response.status == 200
assert response.body["counts"] == %{"running" => 1, "retrying" => 1}
dashboard_css = Req.get!("http://127.0.0.1:#{port}/dashboard.css")
assert dashboard_css.status == 200
assert dashboard_css.body =~ ":root {"
phoenix_js = Req.get!("http://127.0.0.1:#{port}/vendor/phoenix/phoenix.js")
assert phoenix_js.status == 200
assert phoenix_js.body =~ "var Phoenix = (() => {"
refresh_response =
Req.post!("http://127.0.0.1:#{port}/api/v1/refresh",
headers: [{"content-type", "application/x-www-form-urlencoded"}],
body: ""
)
assert refresh_response.status == 202
assert refresh_response.body["queued"] == true
method_not_allowed_response =
Req.post!("http://127.0.0.1:#{port}/api/v1/state",
headers: [{"content-type", "application/x-www-form-urlencoded"}],
body: ""
)
assert method_not_allowed_response.status == 405
assert method_not_allowed_response.body["error"]["code"] == "method_not_allowed"
assert {:error, _reason} = HttpServer.start_link(host: "bad host", port: 0)
end

View File

@ -0,0 +1,27 @@
defmodule SymphonyElixir.NotifierTest do
use ExUnit.Case
import ExUnit.CaptureLog
alias SymphonyElixir.Notifier
alias SymphonyElixir.TestSupport
test "notify is best-effort when chat id is missing" do
previous_token = System.get_env("TELEGRAM_BOT_TOKEN")
previous_chat_id = System.get_env("TELEGRAM_CHAT_ID")
on_exit(fn -> TestSupport.restore_env("TELEGRAM_BOT_TOKEN", previous_token) end)
on_exit(fn -> TestSupport.restore_env("TELEGRAM_CHAT_ID", previous_chat_id) end)
System.put_env("TELEGRAM_BOT_TOKEN", "token")
System.delete_env("TELEGRAM_CHAT_ID")
log =
capture_log(fn ->
assert :ok = Notifier.notify("MT-700", "Prepare Complete")
end)
assert log =~ "Telegram notification skipped"
assert log =~ "MT-700"
assert log =~ "Prepare Complete"
assert log =~ "missing_telegram_chat_id"
end
end

View File

@ -1296,6 +1296,35 @@ defmodule SymphonyElixir.OrchestratorStatusTest do
refute plain =~ " notification "
end
test "status dashboard strips ANSI and control bytes from last codex message" do
payload =
"cmd: " <>
<<27>> <>
"[31mRED" <>
<<27>> <>
"[0m" <>
<<0>> <>
" after\nline"
row =
StatusDashboard.format_running_summary_for_test(%{
identifier: "MT-898",
state: "running",
session_id: "thread-1234567890",
codex_app_server_pid: "4242",
codex_total_tokens: 12,
runtime_seconds: 15,
last_codex_event: :notification,
last_codex_message: payload
})
plain = Regex.replace(~r/\e\[[0-9;]*m/, row, "")
assert plain =~ "cmd: RED after line"
refute plain =~ <<27>>
refute plain =~ <<0>>
end
test "status dashboard expands running row to requested terminal width" do
terminal_columns = 140

View File

@ -1,5 +1,6 @@
defmodule SymphonyElixir.WorkspaceAndConfigTest do
use SymphonyElixir.TestSupport
alias SymphonyElixir.Linear.Client
test "workspace bootstrap can be implemented in after_create hook" do
test_root =
@ -347,6 +348,35 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do
assert Enum.map(merged, & &1.identifier) == ["MT-1", "MT-2", "MT-3"]
end
test "linear client logs response bodies for non-200 graphql responses" do
log =
ExUnit.CaptureLog.capture_log(fn ->
assert {:error, {:linear_api_status, 400}} =
Client.graphql(
"query Viewer { viewer { id } }",
%{},
request_fun: fn _payload, _headers ->
{:ok,
%{
status: 400,
body: %{
"errors" => [
%{
"message" => "Variable \"$ids\" got invalid value",
"extensions" => %{"code" => "BAD_USER_INPUT"}
}
]
}
}}
end
)
end)
assert log =~ "Linear GraphQL request failed status=400"
assert log =~ ~s(body=%{"errors" => [%{"extensions" => %{"code" => "BAD_USER_INPUT"})
assert log =~ "Variable \\\"$ids\\\" got invalid value"
end
test "orchestrator sorts dispatch by priority then oldest created_at" do
issue_same_priority_older = %Issue{
id: "issue-old-high",
@ -678,6 +708,12 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do
write_workflow_file!(Workflow.workflow_file_path(), max_concurrent_agents: "bad")
assert Config.max_concurrent_agents() == 10
write_workflow_file!(Workflow.workflow_file_path(), agent_retry_base_ms: "bad")
assert Config.agent_retry_base_ms() == 10_000
write_workflow_file!(Workflow.workflow_file_path(), agent_continuation_delay_ms: "bad")
assert Config.agent_continuation_delay_ms() == 1_000
write_workflow_file!(Workflow.workflow_file_path(), codex_turn_timeout_ms: "bad")
assert Config.codex_turn_timeout_ms() == 3_600_000
@ -707,6 +743,8 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do
assert Config.poll_interval_ms() == 30_000
assert Config.workspace_root() == Path.join(System.tmp_dir!(), "symphony_workspaces")
assert Config.max_retry_backoff_ms() == 300_000
assert Config.agent_retry_base_ms() == 10_000
assert Config.agent_continuation_delay_ms() == 1_000
assert Config.max_concurrent_agents_for_state("Todo") == 1
assert Config.max_concurrent_agents_for_state("Review") == 10
assert Config.hook_timeout_ms() == 60_000
@ -715,6 +753,31 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do
assert Config.observability_render_interval_ms() == 16
assert Config.server_port() == nil
assert Config.server_host() == "123"
assert Enum.sort(Config.notification_gate_states()) == ["Prepare Complete", "Review Complete"]
assert Config.notification_template() =~ "🧹"
assert Config.notifications().telegram.bot_token == nil
assert Config.notifications().telegram.chat_id == nil
assert Config.gates() == %{
"prepare_complete" => %{
"state_id" => "0671e7cc-46b5-424e-aed3-d9408c9d3eb9",
"assignee" => "5bbd2a49-0fde-4fdd-b265-f6991c718e87",
"notify" => true
},
"review_complete" => %{
"state_id" => "4f363475-bf45-48a0-9466-c38eef79aded",
"assignee" => "5bbd2a49-0fde-4fdd-b265-f6991c718e87",
"notify" => true
}
}
assert Config.states() == %{
"todo" => "0772f6b2-85fa-4c21-ab14-6705687d475f"
}
assert Config.labels()["recommendation"]["review"] == "884ba56a-fb80-4c83-a35e-90ab4dbff32a"
assert Config.labels()["recommendation"]["wait"] == "e2cfbdbb-13e3-4ccc-adeb-5abd00e2b7f9"
assert Config.labels()["subsystem"]["gateway"] == "dc7faf59-f14a-4f03-a549-c0f7fa68ae91"
write_workflow_file!(Workflow.workflow_file_path(), codex_approval_policy: "")
@ -772,29 +835,43 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do
test "config resolves $VAR references for env-backed secret and path values" do
workspace_env_var = "SYMP_WORKSPACE_ROOT_#{System.unique_integer([:positive])}"
api_key_env_var = "SYMP_LINEAR_API_KEY_#{System.unique_integer([:positive])}"
bot_token_env_var = "SYMP_TELEGRAM_BOT_TOKEN_#{System.unique_integer([:positive])}"
chat_id_env_var = "SYMP_TELEGRAM_CHAT_ID_#{System.unique_integer([:positive])}"
workspace_root = Path.join("/tmp", "symphony-workspace-root")
api_key = "resolved-secret"
bot_token = "resolved-bot-token"
chat_id = "resolved-chat-id"
codex_bin = Path.join(["~", "bin", "codex"])
previous_workspace_root = System.get_env(workspace_env_var)
previous_api_key = System.get_env(api_key_env_var)
previous_bot_token = System.get_env(bot_token_env_var)
previous_chat_id = System.get_env(chat_id_env_var)
System.put_env(workspace_env_var, workspace_root)
System.put_env(api_key_env_var, api_key)
System.put_env(bot_token_env_var, bot_token)
System.put_env(chat_id_env_var, chat_id)
on_exit(fn ->
restore_env(workspace_env_var, previous_workspace_root)
restore_env(api_key_env_var, previous_api_key)
restore_env(bot_token_env_var, previous_bot_token)
restore_env(chat_id_env_var, previous_chat_id)
end)
write_workflow_file!(Workflow.workflow_file_path(),
tracker_api_token: "$#{api_key_env_var}",
workspace_root: "$#{workspace_env_var}",
notification_telegram_bot_token: "$#{bot_token_env_var}",
notification_telegram_chat_id: "$#{chat_id_env_var}",
codex_command: "#{codex_bin} app-server"
)
assert Config.linear_api_token() == api_key
assert Config.workspace_root() == Path.expand(workspace_root)
assert Config.notifications().telegram.bot_token == bot_token
assert Config.notifications().telegram.chat_id == chat_id
assert Config.codex_command() == "#{codex_bin} app-server"
end
@ -846,6 +923,49 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do
assert Config.max_concurrent_agents_for_state(:not_a_string) == 10
end
test "config derives notification gate states from gates when no explicit list is set" do
write_workflow_file!(Workflow.workflow_file_path(),
notification_gate_states: nil,
gates: %{
"review_complete" => %{
"state_id" => "4f363475-bf45-48a0-9466-c38eef79aded",
"assignee" => "reviewer",
"notify" => false
},
"prepare_complete" => %{
"state_id" => "0671e7cc-46b5-424e-aed3-d9408c9d3eb9",
"assignee" => "preparer",
"notify" => true
},
"human_gate" => %{
"state_id" => "state-human-gate",
"assignee" => "human",
"notify" => true
}
}
)
assert Enum.sort(Config.notification_gate_states()) == ["Human Gate", "Prepare Complete"]
assert Config.gates()["human_gate"]["state_id"] == "state-human-gate"
assert Config.gates()["human_gate"]["assignee"] == "human"
assert Config.gates()["human_gate"]["notify"] == true
end
test "config supports label and state overrides from workflow" do
write_workflow_file!(Workflow.workflow_file_path(),
labels: %{
"recommendation" => %{"review" => "review-override"},
"subsystem" => %{"gateway" => "gateway-override"}
},
states: %{"todo" => "todo-override", "custom" => "custom-state-id"}
)
assert Config.labels()["recommendation"]["review"] == "review-override"
assert Config.labels()["subsystem"]["gateway"] == "gateway-override"
assert Config.states()["todo"] == "todo-override"
assert Config.states()["custom"] == "custom-state-id"
end
test "workflow prompt is used when building base prompt" do
workflow_prompt = "Workflow prompt body used as codex instruction."