Compare commits
No commits in common. "main" and "codex/move-web-server-to-phoenix" have entirely different histories.
main
...
codex/move
@ -30,12 +30,15 @@ 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.
|
||||
@ -64,16 +67,35 @@ branch=$(git branch --show-current)
|
||||
# Minimal validation gate
|
||||
make -C elixir all
|
||||
|
||||
# Initial push: respect the current origin remote.
|
||||
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.
|
||||
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
|
||||
|
||||
@ -108,10 +130,27 @@ 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.
|
||||
- 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.
|
||||
- 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`.
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -1,3 +0,0 @@
|
||||
.worktrees
|
||||
debug.log
|
||||
workspaces/
|
||||
1
.pebbles/.gitignore
vendored
1
.pebbles/.gitignore
vendored
@ -1 +0,0 @@
|
||||
pebbles.db
|
||||
@ -1,3 +0,0 @@
|
||||
{
|
||||
"prefix": "caclawphony"
|
||||
}
|
||||
@ -1,18 +0,0 @@
|
||||
{"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
119
PLAN.md
@ -1,119 +0,0 @@
|
||||
# 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 |
|
||||
90
README.md
90
README.md
@ -1,86 +1,40 @@
|
||||
# Caclawphony
|
||||
# Symphony
|
||||
|
||||
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 turns project work into isolated, autonomous implementation runs, allowing teams to manage
|
||||
work instead of supervising coding agents.
|
||||
|
||||
[📺 Demo video](https://drive.google.com/file/d/1QsTwj9oLY9FlceI3TT_AEVBXFvy31dtd/view)
|
||||
[](.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._
|
||||
|
||||
> [!WARNING]
|
||||
> This is a maintainer tool for openclaw/openclaw — not a general-purpose framework.
|
||||
> Symphony is a low-key engineering preview for testing in trusted environments.
|
||||
|
||||
## 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
|
||||
## Running Symphony
|
||||
|
||||
### Requirements
|
||||
|
||||
- Elixir + Mix
|
||||
- Linear workspace with a project board
|
||||
- GitHub CLI (`gh`) authenticated
|
||||
- Codex CLI
|
||||
- `LINEAR_API_KEY` environment variable
|
||||
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.
|
||||
|
||||
### Import PRs
|
||||
### Option 1. Make your own
|
||||
|
||||
```bash
|
||||
cd elixir
|
||||
LINEAR_API_KEY=<key> mix caclawphony.review <PR#> [<PR#> ...]
|
||||
```
|
||||
Tell your favorite coding agent to build Symphony in a programming language of your choice:
|
||||
|
||||
This creates Linear issues in **Triage** state. Use `--direct` to skip enrichment and go straight to **Review**.
|
||||
> Implement Symphony according to the following spec:
|
||||
> https://github.com/openai/symphony/blob/main/SPEC.md
|
||||
|
||||
### Run Symphony
|
||||
### Option 2. Use our experimental reference implementation
|
||||
|
||||
```bash
|
||||
cd elixir
|
||||
LINEAR_API_KEY=<key> mix run --no-halt
|
||||
```
|
||||
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:
|
||||
|
||||
Symphony polls Linear every 30 seconds, picks up issues in active states, and dispatches Codex agents.
|
||||
> Set up Symphony for my repository based on
|
||||
> https://github.com/openai/symphony/blob/main/elixir/README.md
|
||||
|
||||
## 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
28
SPEC.md
@ -1647,34 +1647,6 @@ 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
881
WORKFLOW.md
@ -1,881 +0,0 @@
|
||||
---
|
||||
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
|
||||
@ -1,11 +1,10 @@
|
||||
# Symphony Elixir
|
||||
|
||||
This directory contains the current Elixir/OTP implementation of Symphony, based on
|
||||
[`SPEC.md`](../SPEC.md) at the repository root.
|
||||
[`SPEC.md`](../SPEC.md) in the repository root.
|
||||
|
||||
> [!WARNING]
|
||||
> 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`.
|
||||
> SymphonyElixir is preview software for testing in trusted environments. It is presented as-is.
|
||||
|
||||
## Screenshot
|
||||
|
||||
@ -15,13 +14,12 @@ 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 the
|
||||
3. Launches Codex in [App Server mode](https://developers.openai.com/codex/app-server/) inside that
|
||||
workspace
|
||||
4. Sends a workflow prompt to Codex
|
||||
5. Keeps Codex working on the issue until the work is done
|
||||
4. Sends workflow prompt to Codex
|
||||
|
||||
During app-server sessions, Symphony also serves a client-side `linear_graphql` tool so that repo
|
||||
skills can make raw Linear GraphQL calls.
|
||||
During app-server sessions, Symphony also serves a client-side `linear_graphql` tool so 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.
|
||||
@ -30,40 +28,13 @@ 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.
|
||||
3. Copy this directory's `WORKFLOW.md` to your repo and adjust it to fit your workflow.
|
||||
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. 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
|
||||
```
|
||||
5. Follow the instructions below to install the required runtime dependencies and start the service.
|
||||
|
||||
## Configuration
|
||||
|
||||
@ -80,8 +51,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 configuration, plus a Markdown body used as the
|
||||
Codex session prompt.
|
||||
The `WORKFLOW.md` file uses YAML front matter for config, plus a Markdown body used as the Codex
|
||||
session prompt.
|
||||
|
||||
Minimal example:
|
||||
|
||||
@ -125,18 +96,27 @@ 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 dependencies in `hooks.after_create` before invoking `mise` later from other hooks.
|
||||
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:
|
||||
- `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.
|
||||
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.
|
||||
|
||||
```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" .
|
||||
@ -164,6 +144,27 @@ 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
|
||||
@ -175,7 +176,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 stopping
|
||||
active ecosystem of tools and libraries. It also supports hot code reloading without killing
|
||||
actively running subagents, which is very useful during development.
|
||||
|
||||
### What's the easiest way to set this up for my own codebase?
|
||||
|
||||
@ -1 +0,0 @@
|
||||
/Users/phaedrus/Projects/caclawphony/WORKFLOW.md
|
||||
328
elixir/WORKFLOW.md
Normal file
328
elixir/WORKFLOW.md
Normal file
@ -0,0 +1,328 @@
|
||||
---
|
||||
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,195 +0,0 @@
|
||||
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
|
||||
@ -1,191 +0,0 @@
|
||||
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
|
||||
@ -871,24 +871,14 @@ defmodule SymphonyElixir.Codex.AppServer do
|
||||
|> String.slice(0, @max_stream_log_bytes)
|
||||
|
||||
if text != "" do
|
||||
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}")
|
||||
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}")
|
||||
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
|
||||
|
||||
@ -28,45 +28,6 @@ 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
|
||||
@ -133,14 +94,6 @@ 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: %{}
|
||||
@ -202,37 +155,6 @@ 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: %{}
|
||||
]
|
||||
)
|
||||
|
||||
@ -337,16 +259,6 @@ 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])
|
||||
@ -418,101 +330,6 @@ 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])
|
||||
@ -636,11 +453,7 @@ 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")),
|
||||
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"))
|
||||
server: extract_server_options(section_map(config, "server"))
|
||||
}
|
||||
end
|
||||
|
||||
@ -669,11 +482,6 @@ 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"))
|
||||
@ -710,77 +518,6 @@ 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
|
||||
@ -831,15 +568,6 @@ 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)
|
||||
@ -1064,117 +792,6 @@ 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) ->
|
||||
|
||||
@ -7,7 +7,6 @@ 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) {
|
||||
@ -169,11 +168,7 @@ defmodule SymphonyElixir.Linear.Client do
|
||||
{:ok, body}
|
||||
else
|
||||
{:ok, response} ->
|
||||
Logger.error(
|
||||
"Linear GraphQL request failed status=#{response.status}" <>
|
||||
linear_error_context(payload, response)
|
||||
)
|
||||
|
||||
Logger.error("Linear GraphQL request failed status=#{response.status}")
|
||||
{:error, {:linear_api_status, response.status}}
|
||||
|
||||
{:error, reason} ->
|
||||
@ -287,43 +282,6 @@ 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 ->
|
||||
|
||||
@ -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)
|
||||
File.mkdir_p!(Path.dirname(expanded_path))
|
||||
:ok = File.mkdir_p(Path.dirname(expanded_path))
|
||||
:ok = remove_existing_handler()
|
||||
|
||||
case :logger.add_handler(
|
||||
|
||||
@ -1,103 +0,0 @@
|
||||
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
|
||||
@ -7,9 +7,11 @@ defmodule SymphonyElixir.Orchestrator do
|
||||
require Logger
|
||||
import Bitwise, only: [<<<: 2]
|
||||
|
||||
alias SymphonyElixir.{AgentRunner, Config, Notifier, StatusDashboard, Tracker}
|
||||
alias SymphonyElixir.{AgentRunner, Config, StatusDashboard, Tracker, Workspace}
|
||||
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 %{
|
||||
@ -313,7 +315,6 @@ 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
|
||||
@ -321,20 +322,6 @@ 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 ->
|
||||
@ -781,10 +768,7 @@ defmodule SymphonyElixir.Orchestrator do
|
||||
end
|
||||
|
||||
defp cleanup_issue_workspace(identifier) when is_binary(identifier) do
|
||||
workspace = Config.workspace_root() <> "/" <> identifier
|
||||
Logger.info("Cleaning up terminal issue workspace: issue_identifier=#{identifier} workspace=#{workspace}")
|
||||
File.rm_rf(workspace)
|
||||
:ok
|
||||
Workspace.remove_issue_workspaces(identifier)
|
||||
end
|
||||
|
||||
defp cleanup_issue_workspace(_identifier), do: :ok
|
||||
@ -836,7 +820,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
|
||||
Config.agent_continuation_delay_ms()
|
||||
@continuation_retry_delay_ms
|
||||
else
|
||||
failure_retry_delay(attempt)
|
||||
end
|
||||
@ -844,7 +828,7 @@ defmodule SymphonyElixir.Orchestrator do
|
||||
|
||||
defp failure_retry_delay(attempt) do
|
||||
max_delay_power = min(attempt - 1, 10)
|
||||
min(Config.agent_retry_base_ms() * (1 <<< max_delay_power), Config.max_retry_backoff_ms())
|
||||
min(@failure_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
|
||||
|
||||
@ -18,10 +18,7 @@ defmodule SymphonyElixir.PromptBuilder do
|
||||
|> Solid.render!(
|
||||
%{
|
||||
"attempt" => Keyword.get(opts, :attempt),
|
||||
"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()
|
||||
"issue" => issue |> Map.from_struct() |> to_solid_map()
|
||||
},
|
||||
@render_opts
|
||||
)
|
||||
|
||||
@ -1177,7 +1177,6 @@ defmodule SymphonyElixir.StatusDashboard do
|
||||
payload
|
||||
|> inspect(pretty: true, limit: 30)
|
||||
|> String.replace("\n", " ")
|
||||
|> sanitize_ansi_and_control_bytes()
|
||||
|> String.trim()
|
||||
end
|
||||
end
|
||||
@ -1186,7 +1185,6 @@ 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
|
||||
|
||||
@ -1194,17 +1192,9 @@ 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])
|
||||
|
||||
|
||||
@ -123,7 +123,7 @@ defmodule SymphonyElixir.Workspace do
|
||||
end
|
||||
|
||||
defp maybe_run_after_create_hook(workspace, issue_context, created?) do
|
||||
case created? and not workspace_git_initialized?(workspace) do
|
||||
case created? do
|
||||
true ->
|
||||
case Config.workspace_hooks()[:after_create] do
|
||||
nil ->
|
||||
@ -138,12 +138,6 @@ 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 ->
|
||||
@ -174,17 +168,9 @@ 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, env: hook_env)
|
||||
System.cmd("sh", ["-lc", command], cd: workspace, stderr_to_stdout: true)
|
||||
end)
|
||||
|
||||
case Task.yield(task, timeout_ms) do
|
||||
@ -269,33 +255,24 @@ defmodule SymphonyElixir.Workspace do
|
||||
end
|
||||
end
|
||||
|
||||
defp issue_context(%{id: issue_id, identifier: identifier} = issue) do
|
||||
defp issue_context(%{id: issue_id, identifier: identifier}) do
|
||||
%{
|
||||
issue_id: issue_id,
|
||||
issue_identifier: identifier || "issue",
|
||||
issue_title: Map.get(issue, :title, ""),
|
||||
issue_state: Map.get(issue, :state, ""),
|
||||
issue_description: Map.get(issue, :description, "")
|
||||
issue_identifier: identifier || "issue"
|
||||
}
|
||||
end
|
||||
|
||||
defp issue_context(identifier) when is_binary(identifier) do
|
||||
%{
|
||||
issue_id: nil,
|
||||
issue_identifier: identifier,
|
||||
issue_title: "",
|
||||
issue_state: "",
|
||||
issue_description: ""
|
||||
issue_identifier: identifier
|
||||
}
|
||||
end
|
||||
|
||||
defp issue_context(_identifier) do
|
||||
%{
|
||||
issue_id: nil,
|
||||
issue_identifier: "issue",
|
||||
issue_title: "",
|
||||
issue_state: "",
|
||||
issue_description: ""
|
||||
issue_identifier: "issue"
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@ -1,35 +0,0 @@
|
||||
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
|
||||
@ -16,12 +16,40 @@ 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: [:urlencoded, :multipart, :json],
|
||||
pass: ["*/*"],
|
||||
parsers: [:json],
|
||||
pass: ["application/json"],
|
||||
json_decoder: Jason
|
||||
)
|
||||
|
||||
|
||||
@ -14,13 +14,6 @@ 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)
|
||||
|
||||
|
||||
@ -1,33 +0,0 @@
|
||||
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
|
||||
@ -33,16 +33,10 @@ 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]
|
||||
],
|
||||
|
||||
@ -1,202 +0,0 @@
|
||||
#!/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
|
||||
@ -278,11 +278,9 @@ defmodule Mix.Tasks.PrBody.CheckTest do
|
||||
write_template!(@template)
|
||||
File.write!("body.md", "#### Context\nContext text.")
|
||||
|
||||
capture_io(:stderr, fn ->
|
||||
assert_raise Mix.Error, ~r/PR body format invalid/, fn ->
|
||||
Check.run(["lint", "--file", "body.md"])
|
||||
end
|
||||
end)
|
||||
assert_raise Mix.Error, ~r/PR body format invalid/, fn ->
|
||||
Check.run(["lint", "--file", "body.md"])
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
|
||||
@ -81,13 +81,12 @@ defmodule Mix.Tasks.Workspace.BeforeRemoveTest do
|
||||
exit 0
|
||||
""",
|
||||
fn log_path ->
|
||||
{output, error_output} =
|
||||
capture_task_output(fn ->
|
||||
output =
|
||||
capture_io(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)
|
||||
|
||||
@ -104,13 +103,7 @@ defmodule Mix.Tasks.Workspace.BeforeRemoveTest do
|
||||
with_fake_gh(fn log_path ->
|
||||
File.write!(log_path, "")
|
||||
|
||||
{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"
|
||||
BeforeRemove.run(["--branch", "feature/workpad"])
|
||||
|
||||
log = File.read!(log_path)
|
||||
|
||||
@ -119,13 +112,12 @@ defmodule Mix.Tasks.Workspace.BeforeRemoveTest do
|
||||
assert log =~ "pr close 101 --repo openai/symphony"
|
||||
assert log =~ "pr close 102 --repo openai/symphony"
|
||||
|
||||
{second_output, error_output} =
|
||||
capture_task_output(fn ->
|
||||
error_output =
|
||||
capture_io(:stderr, 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
|
||||
@ -363,28 +355,4 @@ 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
|
||||
|
||||
@ -104,8 +104,6 @@ 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}},
|
||||
@ -124,13 +122,6 @@ 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
|
||||
@ -148,8 +139,6 @@ 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)
|
||||
@ -168,13 +157,6 @@ 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 =
|
||||
@ -196,9 +178,6 @@ 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)}",
|
||||
@ -211,15 +190,6 @@ 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
|
||||
]
|
||||
@ -287,45 +257,6 @@ 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
|
||||
|
||||
@ -1055,75 +1055,4 @@ 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
|
||||
|
||||
@ -13,15 +13,7 @@ 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
|
||||
|
||||
@ -57,10 +49,7 @@ 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")
|
||||
@ -94,60 +83,21 @@ defmodule SymphonyElixir.CoreTest do
|
||||
assert is_map(tracker)
|
||||
assert Map.get(tracker, "kind") == "linear"
|
||||
assert is_binary(Map.get(tracker, "project_slug"))
|
||||
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)
|
||||
assert is_list(Map.get(tracker, "active_states"))
|
||||
assert is_list(Map.get(tracker, "terminal_states"))
|
||||
|
||||
hooks = Map.get(config, "hooks", %{})
|
||||
assert is_map(hooks)
|
||||
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 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"
|
||||
|
||||
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"
|
||||
@ -207,9 +157,7 @@ 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"}} =
|
||||
@ -217,20 +165,15 @@ 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)
|
||||
@ -251,8 +194,7 @@ 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()
|
||||
@ -328,135 +270,6 @@ 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(
|
||||
@ -646,69 +459,6 @@ 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()
|
||||
@ -821,39 +571,8 @@ 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)
|
||||
|
||||
@ -990,12 +709,9 @@ 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",
|
||||
@ -1020,7 +736,7 @@ defmodule SymphonyElixir.CoreTest do
|
||||
title: "Use rich templates for WORKFLOW.md",
|
||||
description: "Render with rich template variables",
|
||||
state: "In Progress",
|
||||
url: "https://example.org/issues/MT-616/use-rich-templates-for-workflowmd",
|
||||
url: "https://linear.app/openai/issue/MT-616/use-rich-templates-for-workflowmd",
|
||||
labels: ["templating", "workflow"]
|
||||
}
|
||||
|
||||
@ -1028,27 +744,19 @@ defmodule SymphonyElixir.CoreTest do
|
||||
|
||||
prompt = PromptBuilder.build_prompt(issue, 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"
|
||||
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"
|
||||
end
|
||||
|
||||
test "prompt builder adds continuation guidance for retries" do
|
||||
@ -1763,10 +1471,7 @@ 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")]
|
||||
}
|
||||
)
|
||||
|
||||
@ -1801,10 +1506,7 @@ 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 ->
|
||||
|
||||
@ -468,7 +468,7 @@ defmodule SymphonyElixir.ExtensionsTest do
|
||||
}
|
||||
end
|
||||
|
||||
test "dashboard bootstraps liveview from embedded static assets" do
|
||||
test "dashboard bootstraps liveview from dependency static assets" do
|
||||
orchestrator_name = Module.concat(__MODULE__, :AssetOrchestrator)
|
||||
|
||||
{:ok, _pid} =
|
||||
@ -499,9 +499,6 @@ 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 = (() => {"
|
||||
|
||||
@ -598,7 +595,7 @@ defmodule SymphonyElixir.ExtensionsTest do
|
||||
assert html =~ "snapshot_unavailable"
|
||||
end
|
||||
|
||||
test "http server serves embedded assets, accepts form posts, and rejects invalid hosts" do
|
||||
test "http server starts the phoenix endpoint, resolves the bound port, and rejects invalid hosts" do
|
||||
spec = HttpServer.child_spec(port: 0)
|
||||
assert spec.id == HttpServer
|
||||
assert spec.start == {HttpServer, :start_link, [[port: 0]]}
|
||||
@ -634,32 +631,6 @@ 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
|
||||
|
||||
|
||||
@ -1,27 +0,0 @@
|
||||
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
|
||||
@ -1296,35 +1296,6 @@ 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
|
||||
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
defmodule SymphonyElixir.WorkspaceAndConfigTest do
|
||||
use SymphonyElixir.TestSupport
|
||||
alias SymphonyElixir.Linear.Client
|
||||
|
||||
test "workspace bootstrap can be implemented in after_create hook" do
|
||||
test_root =
|
||||
@ -348,35 +347,6 @@ 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",
|
||||
@ -708,12 +678,6 @@ 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
|
||||
|
||||
@ -743,8 +707,6 @@ 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
|
||||
@ -753,31 +715,6 @@ 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: "")
|
||||
|
||||
@ -835,43 +772,29 @@ 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
|
||||
|
||||
@ -923,49 +846,6 @@ 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."
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user