diff --git a/.github/workflows/make-all.yml b/.github/workflows/make-all.yml index ca2ab2744..f27fc7c66 100644 --- a/.github/workflows/make-all.yml +++ b/.github/workflows/make-all.yml @@ -15,17 +15,17 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Set up mise tools - uses: jdx/mise-action@v3 + uses: jdx/mise-action@5228313ee0372e111a38da051671ca30fc5a96db # v3 with: install: true cache: true working_directory: elixir - name: Cache deps and build - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: | elixir/deps diff --git a/.github/workflows/pr-description-lint.yml b/.github/workflows/pr-description-lint.yml index 9f2e6ac5f..2532abfea 100644 --- a/.github/workflows/pr-description-lint.yml +++ b/.github/workflows/pr-description-lint.yml @@ -13,10 +13,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Set up mise tools - uses: jdx/mise-action@v3 + uses: jdx/mise-action@5228313ee0372e111a38da051671ca30fc5a96db # v3 with: install: true cache: true diff --git a/SPEC.md b/SPEC.md index 47d6abe72..f9e2b63a1 100644 --- a/SPEC.md +++ b/SPEC.md @@ -273,7 +273,7 @@ Fields: - Derive from `issue.identifier` by replacing any character not in `[A-Za-z0-9._-]` with `_`. - Use the sanitized value for the workspace directory name. - `Normalized Issue State` - - Compare states after `trim` + `lowercase`. + - Compare states after `lowercase`. - `Session ID` - Compose from coding-agent `thread_id` and `turn_id` as `-`. @@ -351,9 +351,9 @@ Fields: - If `$VAR_NAME` resolves to an empty string, treat the key as missing. - `project_slug` (string) - Required for dispatch when `tracker.kind == "linear"`. -- `active_states` (list of strings or comma-separated string) +- `active_states` (list of strings) - Default: `Todo`, `In Progress` -- `terminal_states` (list of strings or comma-separated string) +- `terminal_states` (list of strings) - Default: `Closed`, `Cancelled`, `Canceled`, `Duplicate`, `Done` #### 5.3.2 `polling` (object) @@ -410,7 +410,7 @@ Fields: - Changes should be re-applied at runtime and affect future retry scheduling. - `max_concurrent_agents_by_state` (map `state_name -> positive integer`) - Default: empty map. - - State keys are normalized (`trim` + `lowercase`) for lookup. + - State keys are normalized (`lowercase`) for lookup. - Invalid entries (non-positive or non-numeric) are ignored. #### 5.3.6 `codex` (object) @@ -555,10 +555,14 @@ This section is intentionally redundant so a coding agent can implement the conf - `tracker.endpoint`: string, default `https://api.linear.app/graphql` when `tracker.kind=linear` - `tracker.api_key`: string or `$VAR`, canonical env `LINEAR_API_KEY` when `tracker.kind=linear` - `tracker.project_slug`: string, required when `tracker.kind=linear` -- `tracker.active_states`: list/string, default `Todo, In Progress` -- `tracker.terminal_states`: list/string, default `Closed, Cancelled, Canceled, Duplicate, Done` +- `tracker.active_states`: list of strings, default `["Todo", "In Progress"]` +- `tracker.terminal_states`: list of strings, default `["Closed", "Cancelled", "Canceled", "Duplicate", "Done"]` - `polling.interval_ms`: integer, default `30000` - `workspace.root`: path, default `/symphony_workspaces` +- `worker.ssh_hosts` (extension): list of SSH host strings, optional; when omitted, work runs + locally +- `worker.max_concurrent_agents_per_host` (extension): positive integer, optional; shared per-host + cap applied across configured SSH hosts - `hooks.after_create`: shell script or null - `hooks.before_run`: shell script or null - `hooks.after_run`: shell script or null @@ -729,6 +733,12 @@ Per-state limit: The runtime counts issues by their current tracked state in the `running` map. +Optional SSH host limit: + +- When `worker.max_concurrent_agents_per_host` is set, each configured SSH host may run at most + that many concurrent agents at once. +- Hosts at that cap are skipped for new dispatch until capacity frees up. + ### 8.4 Retry and Backoff Retry entry creation: @@ -2108,3 +2118,58 @@ Use the same validation profiles as Section 17: - Verify hook execution and workflow path resolution on the target host OS/shell environment. - If the optional HTTP server is shipped, verify the configured port behavior and loopback/default bind expectations on the target environment. + +## Appendix A. SSH Worker Extension (Optional) + +This appendix describes a common extension profile in which Symphony keeps one central +orchestrator but executes worker runs on one or more remote hosts over SSH. + +### A.1 Execution Model + +- The orchestrator remains the single source of truth for polling, claims, retries, and + reconciliation. +- `worker.ssh_hosts` provides the candidate SSH destinations for remote execution. +- Each worker run is assigned to one host at a time, and that host becomes part of the run's + effective execution identity along with the issue workspace. +- `workspace.root` is interpreted on the remote host, not on the orchestrator host. +- The coding-agent app-server is launched over SSH stdio instead of as a local subprocess, so the + orchestrator still owns the session lifecycle even though commands execute remotely. +- Continuation turns inside one worker lifetime should stay on the same host and workspace. +- A remote host should satisfy the same basic contract as a local worker environment: reachable + shell, writable workspace root, coding-agent executable, and any required auth or repository + prerequisites. + +### A.2 Scheduling Notes + +- SSH hosts may be treated as a pool for dispatch. +- Implementations may prefer the previously used host on retries when that host is still + available. +- `worker.max_concurrent_agents_per_host` is an optional shared per-host cap across configured SSH + hosts. +- When all SSH hosts are at capacity, dispatch should wait rather than silently falling back to a + different execution mode. +- Implementations may fail over to another host when the original host is unavailable before work + has meaningfully started. +- Once a run has already produced side effects, a transparent rerun on another host should be + treated as a new attempt, not as invisible failover. + +### A.3 Problems to Consider + +- Remote environment drift: + - Each host needs the expected shell environment, coding-agent executable, auth, and repository + prerequisites. +- Workspace locality: + - Workspaces are usually host-local, so moving an issue to a different host is typically a cold + restart unless shared storage exists. +- Path and command safety: + - Remote path resolution, shell quoting, and workspace-boundary checks matter more once execution + crosses a machine boundary. +- Startup and failover semantics: + - Implementations should distinguish host-connectivity/startup failures from in-workspace agent + failures so the same ticket is not accidentally re-executed on multiple hosts. +- Host health and saturation: + - A dead or overloaded host should reduce available capacity, not cause duplicate execution or an + accidental fallback to local work. +- Cleanup and observability: + - Operators need to know which host owns a run, where its workspace lives, and whether cleanup + happened on the right machine. diff --git a/docs/design/stage-workflow-chain.md b/docs/design/stage-workflow-chain.md new file mode 100644 index 000000000..f89dde85b --- /dev/null +++ b/docs/design/stage-workflow-chain.md @@ -0,0 +1,470 @@ +# Stage-based Workflow Chain — Design (slim) + +**状态**: Draft, 精简版 +**作者**: 同 v1; 经过 6 轮 Codex review 后反思,砍掉过度工程化的部分 +**相关仓库**: `openai/symphony` (upstream), `guangyucoder/symphony` (fork) + +## Executive Summary + +v1 提了"每个 stage 是同一 AgentRunner 的不同 invocation"的架构。中间版本累积了过多 defensive field/gate。本版回到核心:**4 个机器门 + BEGIN/END bounded marker + 3 个 marker kind**,其他交给 agent + WORKFLOW prompt。 + +核心意图不变: +1. 不分叉 upstream 代码 +2. 自动享受 upstream 升级 +3. 我们的增量可上游化 + +--- + +## 1. 背景 + +参见 v1 `stage-workflow-chain.md` §1(fork 43 commits ahead, ~2500 行 Elixir 增量, ~80% bug 来自自己机器层)。本文不重复。 + +--- + +## 2. 架构 + +### 2.1 核心 insight + +**Stage = 同一 AgentRunner,不同 WORKFLOW 模板**。 + +### 2.2 模块架构 + +``` +Upstream (不改): + Orchestrator / AgentRunner / PromptBuilder / Workspace + +Fork 新增: + StageOrchestrator (~100 行) # 按 workpad markers + Linear state 决定下一 stage + StageCloseout (~80 行) # 4 个机器门 + MarkerParser (~60 行) # BEGIN/END 截取 + fenced block + YAML 解析 + +Workflow 文件: + WORKFLOW.md # upstream 版 + handoff 指令 + WORKFLOW-review.md # 独立 review prompt (~80 行) + WORKFLOW-docfix.md # 独立 doc-fix prompt (~60 行) +``` + +### 2.3 三个 Stage + +每个 Stage = 一次 `AgentRunner.run/3` 调用 with `max_turns: 1`。一次 Codex turn 内 agent 做完自己的事 (code/review/docs),写 marker,停 turn。Session 结束后 closeout 跑 + orchestrator 下次 tick 根据 markers 决定 next stage。 + +- **Stage 1 Implement**: `WORKFLOW.md`。plan→code→test→push→写 `review-request` marker。大 ticket 可能一次 turn 做不完,orchestrator 看到 Linear=In Progress 且无 pending review → 下次 tick 再 dispatch implement (连续多 turn) +- **Stage 2 Review**: `WORKFLOW-review.md`。Reviewer 读 `git diff $(git merge-base HEAD origin/main)..HEAD`,按 consumer repo 提供的 review 协议审查正确性 / 安全 / 风格;diff 若触及 frontend 路径(`apps/web/**`, `apps/*/components/**` 等)则另外起 dev server 截图审视觉 → 写 `code-review` marker。不 commit +- **Stage 3 Doc Fix**: `WORKFLOW-docfix.md`。只改 `*.md` / `docs/**` → 写 `docs-checked` marker + +为什么 `max_turns: 1`: upstream `AgentRunner.run/3` 的默认行为是一直 turn 直到 Linear 状态离开 active set。我们要在 marker 写完后切到下一 stage,最简单的做法是每次 dispatch 只跑一 turn,让 session 自然结束。Upstream 已支持 `max_turns` opt,不用额外 patch。 + +### 2.4 状态机 + +```elixir +def next_stage(workpad, linear_state, workspace) do + cond do + # Consumer 必须把 Rework 配进 tracker.active_states, 否则 upstream Orchestrator + # 不会拉取 Rework ticket 派给 StageOrchestrator (见 orchestrator.ex / linear/client.ex + # 按 active_states 过滤)。所以 StageOrchestrator 只在 Rework 在 active_states 时才会 + # 看到这个 state, 下面的短路是正确的。 + linear_state == "Rework" -> + :implement + + # 所有非 active 状态直接 stop (active_states 见 Config.settings!().tracker.active_states) + not active_issue_state?(linear_state) -> + :stop + + # Review pending: current round 内, BEGIN/END 区最后一个 review-request / code-review + # 是 review-request (按 workpad 文本顺序,最新 marker 就在 END 之前) + review_pending?(workpad) -> + :review + + # Latest code-review verdict=findings → Implement + latest_code_review_verdict(workpad) == :findings -> + :implement + + # Review clean 但 HEAD 已推进 → 重审 + latest_code_review_verdict(workpad) == :clean and + latest_review_sha(workpad) != current_head(workspace) -> + :review + + # Review clean + HEAD 匹配 + 未 doc_fix for this clean review → Doc Fix + latest_code_review_verdict(workpad) == :clean and + not docs_checked_matches_review?(workpad) -> + :doc_fix + + # Review clean + docs-checked 绑定当前 clean review + HEAD 匹配 → handoff + docs_checked_matches_review?(workpad) and + latest_review_sha(workpad) == current_head(workspace) -> + :implement + + # 其他 active 状态(Todo / In Progress / Merging 等)兜底走 implement。 + # 能走到这条一定是 active (上面 not active_issue_state? 已 :stop)。 + # Merging: WORKFLOW.md 里 agent 会检测到 Merging → 走 land skill。 + # Todo: agent 会把 state 移到 In Progress 再开始实现。 + # 由 WORKFLOW.md 决定具体行为,orchestrator 只需 dispatch implement。 + true -> + :implement + end +end +``` + +Helpers(都在 current_round 内计算,current_round = 最大 round_id 且非 archived): +- `review_pending?`: BEGIN/END 区内当前 round 的 review-request / code-review marker 按**文本顺序**列出,最后一个是 review-request(初始时 code-review 不存在自然成立) +- `latest_code_review_verdict`: 当前 round 最新 code-review 的 verdict +- `latest_review_sha`: 最新 clean code-review 的 `reviewed_sha` +- `docs_checked_matches_review?`: 当前 round 有 docs-checked marker,且它的 `reviewed_sha` 等于 latest clean code-review 的 `reviewed_sha`(即 docs-checked 是针对当前最新 clean review 跑的,不是旧的 stale)。用共同字段 `reviewed_sha` 比对,不引入新字段 + +注意: agent 追加新 marker 时**必须**放在 BEGIN/END 区最后一个 marker 之后(END 之前),WORKFLOW 明确写出。这样文本顺序 == 时间顺序。 + +### 2.5 Marker 合约 + +#### 2.5.1 Bounded section + +所有 marker 写在 workpad 的专属子区: + +``` +## Codex Workpad + +<自由叙述: plan, notes, logs — orchestrator 不解析> + + +```symphony-marker +kind: review-request +round_id: 1 +stage_round: 1 +reviewed_sha: <40 hex> +issue_identifier: ENT-187 +``` + +``` + +- Orchestrator 用 regex 先截 BEGIN/END 区,再找 fenced block +- 区外任何 ` ```symphony-marker ` 都不被解析(防碰撞) +- Agent 在 WORKFLOW.md 里被明确要求 marker 只能写在 BEGIN/END 之间 + +#### 2.5.2 Schema + +3 种 `kind`,共享通用字段: + +| 字段 | 类型 | 所有 kind | +|---|---|---| +| `kind` | `review-request` / `code-review` / `docs-checked` | 必填 | +| `round_id` | integer ≥ 1 | 必填,同一 review→doc_fix 链路共享。初始值: workpad 里从未有 marker(包括 archived)时 `round_id = 1`;否则 `round_id = max(所有 marker 的 round_id) + 1`(新 round 开启时)或 `= 当前 round id`(同 round 内追加)| +| `stage_round` | integer ≥ 1 | 必填,同 round_id 同 kind 重试 +1。**不同 kind 各自独立计数** —— `review-request.stage_round` 和 `code-review.stage_round` 不共享序号,也不需要对齐。e.g. 同 round_id=1 里可以有 review-request(stage_round=1) → code-review(stage_round=1, findings) → review-request(stage_round=2) → code-review(stage_round=2, clean),两个 kind 各走到自己的 2 | +| `reviewed_sha` | 40-char hex | 必填,写 marker 时的 `git rev-parse HEAD` | +| `issue_identifier` | string | 必填,必须等于当前 ticket | + +Per-kind 字段: + +- **`review-request`**: 无 +- **`code-review`**: `verdict: clean | findings`。`verdict: findings` 时可选加 `findings: [{severity: high|medium|low, summary: string}]`(作为 reviewer 给 implement 看的摘要,不参与 closeout 校验) +- **`docs-checked`**: `docfix_outcome: no-updates | updated` + +#### 2.5.3 解析规则 + +- YAML 解析失败 / 缺通用必填字段 / `issue_identifier` 不匹配 → 该 block 无效 +- Per-kind 必填字段缺失也视为 invalid: `code-review` 缺 `verdict` 无效;`docs-checked` 缺 `docfix_outcome` 无效。防止 agent 写漏 verdict 后 `latest_code_review_verdict` 返回 nil 让状态机误路由 +- 解析单位 = BEGIN/END 区内的 raw valid markers 按**文本顺序**列出(agent 只能在区末尾追加新 marker,见 §2.5.1)。`latest_*` / `review_pending?` / `docs_checked_matches_review?` 都按文本顺序取最末的一条判断。不做 max-stage_round collapse +- Gate 3(findings→clean flip 检测,§2.6)同样看 raw 文本顺序的相邻 code-review marker 对 +- 跨 `round_id` 的 marker 不参与当前状态机,仅用于 `round_id` bump +- **空 round 时** (新 ticket 第一次进来 / Rework 刚 archive 完还没写新 review-request): helpers 默认 `review_pending? = false`, `latest_code_review_verdict = nil`, `latest_review_sha = nil`, `docs_checked_matches_review? = false`。这样状态机会落到末尾 `true → :implement` 兜底 clause(Rework 由 §2.4 顶部的短路处理) + +### 2.6 机器门 + +4 个,各自在对应 stage 结束时 closeout (非 "每 stage 都跑所有门"): + +1. **`review_stage_clean?`** (Review stage only): Review stage 结束时,workspace 必须干净 — (a) `git rev-parse HEAD` 等于 dispatch 时 snapshot 的 SHA(reviewer 没 commit)且 (b) `git status --porcelain` 为空(没有 uncommitted 改动和 untracked 文件)。StageOrchestrator 在 dispatch `:review` 前存 HEAD snapshot 进 closeout context。任一条件不满足 → 拒绝 + 转 Rework +2. **`reviewed_sha matches head + working tree clean`** (Review + Doc-Fix stages): Marker 的 `reviewed_sha` 必须等于 closeout 时的 HEAD;同时 `git status --porcelain` 为空(无 uncommitted/untracked 文件)。防止 stage 结束时留脏 workspace 流到下一 stage +3. **`findings→clean flip at same HEAD rejected`** (Review stage only): 同一 HEAD 上 code-review 从 findings 翻 clean 拒绝。Agent 若要消 false-positive,必须让 HEAD 推进一次(即便是空格修改) +4. **`doc_fix only touched *.md / docs/**`** (Doc-Fix stage only): `git diff $(latest_review_sha(workpad))..HEAD --name-only` 的所有路径必须匹配 `*.md` 或 `docs/**`。Base 锚定 doc-fix 开始前的 clean review SHA(即 §2.4 helper `latest_review_sha`),不是 merge-base/origin/main,否则会把之前 implement 阶段的代码变更也算进 doc_fix 的 diff + +Implement stage 结束不跑任何 gate — 实现的正确性靠 WORKFLOW.md + CI + 下一 Review stage 把关。 + +**所有 gate 失败的处理** (fix v15-H1): 统一 — StageCloseout 把 ticket 转 Rework,在 Linear workpad 追加一条自由叙述(区外)说明失败原因 (`gate_failed: , reason: `)。Orchestrator 下一 tick 按 Rework 短路走 `:implement`,agent 执行 §3.4 reset 流程重新来过。 + +### 2.7 跟 Upstream 的接口 + +只需 upstream 改 `PromptBuilder` 看 `opts[:workflow_path]`。`AgentRunner.run/3` 已经支持 opts 透传 (`max_turns`, etc),无需改动。 + +**改动范围**(基于 `rg` 审计 `elixir/lib/` + `elixir/test/` + `elixir/priv/` + `scripts/`): + +| 模块 | 改动 | ~行数 | +|---|---|---| +| `Workflow.load/1` 已存在 (upstream `workflow.ex:49`) — 无改动 | 按 path 读文件 + front matter 解析 | 0 | +| `AgentRunner.run/3` 已支持 opts 透传 (`max_turns`, 等) — 无改动 | — | 0 | +| `PromptBuilder.build_prompt/2` | 加 `opts[:workflow_path]` 分支(`Workflow.current()` → `Workflow.load(path)`);当显式传入 path 时**不走** `default_prompt/1` fallback,body 空 → `raise` 防 silent 跑 default implement prompt | ~8 | +| `StageOrchestrator.dispatch/2` (fork 新增) | 按 stage 选 path,dispatch closeout HEAD snapshot | ~10 | +| Property test: 并发不同 stage 隔离 | 新增 | ~30 | +| **合计 non-test** | | **~18 行** | +| **合计含测试** | | **~48 行** | + +关键保证: +- `Workflow.load/1` upstream 已实现(会解析 front matter)。Stage workflow 文件可以带 front matter,`load/1` 会照常解析;但 runtime config 仍由 `WorkflowStore.current()` 的 default workflow 决定 —— PromptBuilder 从 stage workflow 取 prompt body,`Config` 从 default workflow 取 config。这样 stage front matter 即便存在也不影响 runtime,符合"只影响 prompt 文本"的设计 +- 若 stage workflow file 里的 front matter 声明了 Linear token 或其他全局字段,会在 `Workflow.load/1` 返回的 struct 里但被 PromptBuilder 忽略。对 reader 不造成 silent misconfig 印象(因为 `Config` 不从那读) +- `WorkflowStore` 仍只缓存 default `WORKFLOW.md`;stage-specific path 每次 dispatch 直接 `Workflow.load/1`,不进 Store + +两条路径并行: +- **A** PR 给 upstream(~24 行 non-test) +- **B** Fork patch 同样内容,等 upstream 合并后 rebase 掉 + +--- + +## 3. Workflow 文件 + +### 3.1 `WORKFLOW.md` (Implement) + +基本是 upstream 那份加几处: +- **加** Handoff 指令: "实现完成且 CI 绿后,不要直接移 Linear 到 Human Review。在 workpad BEGIN/END 区(最后一个 marker 之后)写 `review-request` marker,然后停 turn。" +- **加** Handoff 模式: "若当前 round 有 `docs-checked` marker 且 latest code-review verdict=clean + reviewed_sha==HEAD,可以移 Linear 到 Human Review" +- **加** Findings loop: "收到 review findings 后,新 commit 修完,再写一个更高 stage_round 的 review-request 请 reviewer 重看。**不要**在没有新 commit 的情况下请 reviewer 翻 clean" +- **加** doc_fix 后 code 改动: "如果 Stage 3 doc-fix 写完 `docs-checked` 之后你还要动代码,同一次改动里顺手更新相关 docs(inline)后,按 Findings loop 的规则在**同** `round_id` 下写新的 `review-request`(bump stage_round)。状态机看到 HEAD 已推进 + 新 review-request 会自动重走 review → docs-checked 路径" +- **加** 工作区洁净: "写 review-request 之前必须 `git status --porcelain` 为空(所有改动 commit 或丢弃)。留脏文件会让下一 stage closeout gate 误 fail,ticket 被错转 Rework" +- **加** Rework 指令(见 §3.4) + +净变化 ~40 行。 + +### 3.2 `WORKFLOW-review.md` + +~80 行。核心指令: + +```markdown +你是 second-opinion reviewer。不要 commit、不要改源码、不要动 Linear state。 + +读 diff: `git diff $(git merge-base HEAD origin/main)..HEAD`。审查正确性、安全、错误处理、风格(consumer repo 自己提供具体 review 协议,例如放在 `.codex/skills/` 或 `.claude/skills/` 下的 review skill,或 AGENTS.md 里的 review checklist;Symphony 本身不规定具体协议)。 +如果 diff 触及 frontend 路径(`apps/web/`、`apps/*/components/**`、`apps/*/app/**/*.tsx` 等),额外起 `pnpm --dir apps/web dev`,打开相关 route 截图审视觉(对照 PR 关联 ticket 描述的意图),审完 kill dev server。 + +把 verdict 写到 workpad BEGIN/END 区: + +```symphony-marker +kind: code-review +round_id: <从 workpad 读最新 review-request 的 round_id> +stage_round: <同 round_id 下第几次 review, 从 1 开始> +reviewed_sha: +issue_identifier: +verdict: clean | findings +findings: + - severity: high + summary: ... +``` + +停 turn。 +``` + +### 3.3 `WORKFLOW-docfix.md` + +~60 行。核心指令: + +```markdown +你是 docs sweeper。只可以改 *.md / docs/**,其他路径会被 closeout 拒绝。 + +检查 AGENTS.md、docs/、README.md 是否跟当前 HEAD 的代码一致。 + +**顺序**: +- 如果不需要改: 直接写 `docs-checked` marker with `docfix_outcome: no-updates`, `reviewed_sha: ` +- 如果需要改: 先改、commit (message 前缀 `docs:`)、push,**然后** 写 marker with `docfix_outcome: updated`, `reviewed_sha: ` + +Marker 写在 workpad BEGIN/END 区最后一个 marker 之后: + +```symphony-marker +kind: docs-checked +round_id: <同 review 的 round_id> +stage_round: +reviewed_sha: +issue_identifier: +docfix_outcome: no-updates | updated +``` + +停 turn。 +``` + +### 3.4 Rework + +进入 Rework 时 (WORKFLOW.md 的 Rework 段指令): + +1. 读 workpad,定位 BEGIN/END 区 +2. 把区内所有 ` ```symphony-marker ` 改写成 ` ```symphony-marker-archived `(只改 fence 语言标识符) +3. 清 workspace: `git reset --hard HEAD && git clean -fdx`(丢掉上次 stage 遗留的 uncommitted / untracked 文件,否则 branch 切换可能失败或泄露到新 branch) +4. close 旧 PR,fresh branch,implement 新 plan +5. code + test + push +6. 在 BEGIN/END 区最后追加新 round 的 `review-request`,`round_id = max(archived) + 1`, `stage_round = 1`(**必须先写 marker 再移 Linear**;若 crash 在 6 和 7 之间,下一 tick 仍看到 Linear=Rework 走短路重入 implement,agent 看到 marker 已写只需做 step 7) +7. 把 Linear state 从 Rework 移回 In Progress,停 turn + +Orchestrator 只认 `symphony-marker`,忽略 `symphony-marker-archived`。Rework 期间 orchestrator 看到 Linear=Rework 时命中顶部 Rework 短路 → dispatch `:implement`。Agent 按上述 7 步执行。若 agent 崩在中间(如步 2 完但步 6 未完),下一 tick 仍看到 Linear=Rework → 再 dispatch implement,WORKFLOW 指示 idempotent: 已 archive 的不再 archive,review-request 已写的不重写;agent 直接补完剩下步骤。一旦步 7 move 了 Linear state,Rework 短路不再触发,marker 路由接管(看到新 review-request → `:review`)。 + +--- + +## 4. 流程图 + +### 4.1 Happy path + +``` +Linear: In Progress + │ + ▼ +Stage 1: Implement (WORKFLOW.md) + │ code + test + push + │ write review-request (round_id=1, stage_round=1) + ▼ +Stage 2: Review (WORKFLOW-review.md) + │ diff review per consumer protocol (+ visual check if frontend diff) + │ write code-review verdict=clean (round_id=1, stage_round=1) + │ closeout: review_stage_clean? ✓ (HEAD unchanged + working tree clean) + ▼ +Stage 3: Doc Fix (WORKFLOW-docfix.md) + │ audit docs; no changes needed + │ write docs-checked docfix_outcome=no-updates + │ closeout: only docs paths changed (here: 0 changes) ✓ + ▼ +Stage 1 (handoff mode): + agent sees clean review + docs-checked + HEAD match + → moves Linear to Human Review + → orchestrator next_stage returns :stop +``` + +### 4.2 Findings loop + +``` +Stage 2 writes verdict=findings + ▼ +next_stage → :implement + ▼ +Stage 1: agent fixes, new commit, writes new review-request (round_id=1, stage_round=2) + ▼ +Stage 2: $code-review again, writes code-review (round_id=1, stage_round=2) + ▼ +verdict=clean → continues to §4.1 +``` + +### 4.3 Rework + +``` +Human moves ticket → Rework + ▼ +next_stage (Rework 短路) → :implement + ▼ +Stage 1: agent 读 WORKFLOW.md Rework 段 + archive old markers + close PR, fresh branch + implement + test + push + write review-request (round_id=max_archived+1, stage_round=1) (先写 marker) + move Linear → In Progress (再移 state) + ▼ +next_stage 看到新 review-request + Linear=In Progress → :review + ▼ +走 §4.1 正常流程 +``` + +### 4.4 Doc fix updated 后 + +``` +Stage 3 docs-checked outcome=updated (新 commit 在 docs/, HEAD=new) + ▼ +next_stage: + - review_pending? false (last review-request 被 code-review 应答) + - latest_code_review_verdict = :clean, latest_review_sha = old-HEAD != new-HEAD + → :review (clause 5) + ▼ +Stage 2: 重审 new HEAD, 写新 code-review (round_id=1, stage_round=2) + verdict=clean + HEAD 匹配 + ▼ +next_stage: docs_checked_matches_review? true + HEAD matches → :implement (handoff) +``` + +注意: WORKFLOW.md 里 Implement 指令加一句 "如果你在 doc_fix 之后还要动代码, 同一次改动里顺手更新相关 docs, 在**同** round_id 下写新的 review-request(bump stage_round)"。状态机看到 HEAD 已推进会自动重走 review → docs-checked 路径。不需要 Stage 3 有多轮计数器。 + +--- + +## 5. 迁移路径 + +### Phase 0: 准备 +- [ ] 同步 upstream: `git merge upstream/main` +- [ ] 所有测试绿 + +### Phase 1: 上游接口 +- [ ] PR upstream: `workflow_path` option(~24 行 non-test) +- [ ] Fork patch 同步(不等合并) + +### Phase 2: 新增 stage 层 +- [ ] `StageOrchestrator` (~100 行) +- [ ] `StageCloseout` 4 个门 (~80 行) +- [ ] `MarkerParser` BEGIN/END + YAML (~60 行) +- [ ] 3 份 WORKFLOW 文件 + +### Phase 3: 砍掉旧机器层 +- [ ] 删除 `DispatchResolver` (457 行) +- [ ] 删除 `Closeout` 大部分 (~700 行) +- [ ] 删除 `unit-lite` mode 和所有 unit kind (~600 行) +- [ ] 删除 `WorkpadParser` 大部分 (~450 行) +- [ ] 删除 `issue_exec.json` 的 warm-session / unit state 字段 +- [ ] 删除对应 tests (~3000 行) + +### Phase 4: 验证 +- [ ] 所有测试绿 +- [ ] 端到端 smoke test: happy path + findings loop + rework +- [ ] Tag `v1-baseline` before Phase 3,允许 rollback cherry-pick + +### 工作量 +2-3 天。 + +--- + +## 6. Trade-offs + +### 6.1 Cold boundary between stages +每 stage 是 fresh Codex session,跨 stage context 丢失。**Mitigation**: workpad 是 persistent context(upstream pattern);cold boundary 有价值(review agent 无 implementer 偏见)。**Cost**: 每 stage 一次 codex boot ~几秒。 + +### 6.2 Findings→clean 同 HEAD 拒绝 +Reviewer 报 false-positive 时,implement 必须让 HEAD 推进(空格改动也行)才能翻 clean。简单合约胜过可辨 dismiss 机制。 + +### 6.3 Stage workflow files 无 config +stage files 只有 prompt body。不支持 stage-specific `max_turns` / `sandbox`。如需,future 单独 PR 扩展。 + +### 6.4 Verify / CI / Merge +Upstream WORKFLOW.md 里 agent self-check + `land` skill 已处理。删掉我们的 `verify` / `merge-sync-*` unit 不损失。 + +### 6.5 Rework 的脆弱性 +Agent 如果漏掉 archive 步或漏写新 review-request,下一 orchestrator tick 仍看到 Linear=Rework 继续 dispatch implement。WORKFLOW Rework 段要求 idempotent(archive 已 archived 的是 no-op;已存在新 review-request 则跳过),让 retry 安全。若 agent 反复失败(如 3 次连续 Rework tick 但 linear state 未变回 In Progress),orchestrator 可以记 Linear comment 提醒人工。这个 counter 若要加,可以放在 orchestrator 的 GenServer state,但当前简化版不 implement — 先观察真实发生频率再决定。 + +--- + +## 7. 对比总结 + +``` + 当前 fork 本提案 上游 baseline +───────────────────────────────────────────────────────────── +新增 Elixir ~2500 行 ~240 行 0 +新增 Markdown ~500 行 ~200 行 0 +Upstream 代码改动 ~1500 行 ~24 行 0 +新增测试 ~4600 行 ~800 行 0 +───────────────────────────────────────────────────────────── +Marker kinds — (implicit) 3 — +Closeout gates 10+ 4 — +Upstream 升级冲突 严重 极低 — +可 PR 回上游 否 是 — +``` + +--- + +## 8. 请 reviewer 看的点 + +1. §2.4 状态机 8 个 clause 能否覆盖所有 happy / findings / rework / docfix-updated 路径 +2. §2.5.2 3 个 marker kind 的字段是否够;有没有真实 edge case 需要额外字段 +3. §2.6 4 个 gate 是否够;哪些可以删 +4. §2.7 patch size 估算(~24 行 non-test)是否合理 +5. §3.4 Rework 流程是否足够 robust + +--- + +## 9. 为什么不用 Codex Hooks + +2026-04 研究:`codex_hooks` feature flag 仍 experimental;仅 `command` handler 支持(无 Prompt/Agent);Stop hook 的 `decision:"block"` 只注入 continuation prompt,实际不 block;PreToolUse/PostToolUse 只 fire Bash。 + +结论: 当前 risk/reward 不划算。orchestrator 读 marker + 派下一 AgentRunner 是 Elixir level 可观测 + 可测试,比 hook side-effect 更清晰。Hooks 成熟后再评估。 + +--- + +## Appendix + +v1 原始 design doc: `stage-workflow-chain.md` (保留供对照) +Upstream 关键文件: `agent_runner.ex` (203 行), `prompt_builder.ex` (64 行), `WORKFLOW.md` (327 行) + +--- + +*End of design doc.* diff --git a/elixir/Makefile b/elixir/Makefile index 9c1ae9096..61c40270a 100644 --- a/elixir/Makefile +++ b/elixir/Makefile @@ -1,9 +1,9 @@ -.PHONY: help all setup deps build fmt fmt-check lint test coverage ci dialyzer +.PHONY: help all setup deps build fmt fmt-check lint test coverage ci dialyzer e2e MIX ?= mix help: - @echo "Targets: setup, deps, fmt, fmt-check, lint, test, coverage, dialyzer, ci" + @echo "Targets: setup, deps, fmt, fmt-check, lint, test, coverage, dialyzer, e2e, ci" setup: $(MIX) setup @@ -33,6 +33,9 @@ dialyzer: $(MIX) deps.get $(MIX) dialyzer --format short +e2e: + SYMPHONY_RUN_LIVE_E2E=1 $(MIX) test test/symphony_elixir/live_e2e_test.exs + ci: $(MAKE) setup $(MAKE) build diff --git a/elixir/README.md b/elixir/README.md index 3f711587c..603b4bb00 100644 --- a/elixir/README.md +++ b/elixir/README.md @@ -14,7 +14,7 @@ This directory contains the current Elixir/OTP implementation of Symphony, based ## How it works 1. Polls Linear for candidate work -2. Creates an isolated workspace per issue +2. Creates a workspace per issue 3. Launches Codex in [App Server mode](https://developers.openai.com/codex/app-server/) inside the workspace 4. Sends a workflow prompt to Codex @@ -116,8 +116,9 @@ Notes: - `codex.turn_sandbox_policy` defaults to a `workspaceWrite` policy rooted at the current issue workspace - Supported `codex.approval_policy` values depend on the targeted Codex app-server version. In the current local Codex schema, string values include `untrusted`, `on-failure`, `on-request`, and `never`, and object-form `reject` is also supported. - Supported `codex.thread_sandbox` values: `read-only`, `workspace-write`, `danger-full-access`. -- Supported `codex.turn_sandbox_policy.type` values: `dangerFullAccess`, `readOnly`, - `externalSandbox`, `workspaceWrite`. +- When `codex.turn_sandbox_policy` is set explicitly, Symphony passes the map through to Codex + unchanged. Compatibility then depends on the targeted Codex app-server version rather than local + Symphony validation. - `agent.max_turns` caps how many back-to-back Codex turns Symphony will run in a single agent invocation when a turn completes normally but the issue is still in an active state. Default: `20`. - If the Markdown body is blank, Symphony uses a default prompt template that includes the issue @@ -144,7 +145,9 @@ codex: command: "$CODEX_BIN app-server --model gpt-5.3-codex" ``` -- If `WORKFLOW.md` is missing or has invalid YAML, startup and scheduling are halted until fixed. +- If `WORKFLOW.md` is missing or has invalid YAML at startup, Symphony does not boot. +- If a later reload fails, Symphony keeps running with the last known good workflow and logs the + reload error until the file is fixed. - `server.port` or CLI `--port` enables the optional Phoenix LiveView dashboard and JSON API at `/`, `/api/v1/state`, `/api/v1/`, and `/api/v1/refresh`. @@ -170,6 +173,36 @@ The observability UI now runs on a minimal Phoenix stack: make all ``` +Run the real external end-to-end test only when you want Symphony to create disposable Linear +resources and launch a real `codex app-server` session: + +```bash +cd elixir +export LINEAR_API_KEY=... +make e2e +``` + +Optional environment variables: + +- `SYMPHONY_LIVE_LINEAR_TEAM_KEY` defaults to `SYME2E` +- `SYMPHONY_LIVE_SSH_WORKER_HOSTS` uses those SSH hosts when set, as a comma-separated list + +`make e2e` runs two live scenarios: +- one with a local worker +- one with SSH workers + +If `SYMPHONY_LIVE_SSH_WORKER_HOSTS` is unset, the SSH scenario uses `docker compose` to start two +disposable SSH workers on `localhost:`. The live test generates a temporary SSH keypair, +mounts the host `~/.codex/auth.json` into each worker, verifies that Symphony can talk to them +over real SSH, then runs the same orchestration flow against those worker addresses. This keeps +the transport representative without depending on long-lived external machines. + +Set `SYMPHONY_LIVE_SSH_WORKER_HOSTS` if you want `make e2e` to target real SSH hosts instead. + +The live test creates a temporary Linear project and issue, writes a temporary `WORKFLOW.md`, runs +a real agent turn, verifies the workspace side effect, requires Codex to comment on and close the +Linear issue, then marks the project completed so the run remains visible in Linear. + ## FAQ ### Why Elixir? diff --git a/elixir/WORKFLOW-docfix.md b/elixir/WORKFLOW-docfix.md new file mode 100644 index 000000000..1e1748348 --- /dev/null +++ b/elixir/WORKFLOW-docfix.md @@ -0,0 +1,67 @@ +You are working on Linear ticket `{{ issue.identifier }}`: `{{ issue.title }}`. +Current status: `{{ issue.state }}`. Labels: {{ issue.labels }}. URL: {{ issue.url }} + +{% if issue.description %} +Description: +{{ issue.description }} +{% endif %} + +## Marker Contract (STRICT) +- First, find the existing Linear issue comment whose header is `## Codex Workpad`. +- In that comment, locate the bounded marker section delimited by `` and ``. +- Markers live only inside that bounded section of the existing Linear `## Codex Workpad` comment: + +````markdown + +```symphony-marker +kind: review-request | code-review | docs-checked +round_id: = 1> +stage_round: = 1> +reviewed_sha: <40-char hex SHA> +issue_identifier: {{ issue.identifier }} +``` + +```` + +- Append new markers only between `` and `` in that Linear comment, after the last existing ` ```symphony-marker ` block and before the END marker. +- Only bounded ` ```symphony-marker ` blocks are parsed. Do not write markers outside that section. +- Required fields for every marker: `kind`, `round_id`, `stage_round`, `reviewed_sha`, `issue_identifier`. +- `issue_identifier` must exactly equal `{{ issue.identifier }}`. +- `reviewed_sha` must be `git rev-parse HEAD` at the moment you write the marker. +- `stage_round` increments independently per `kind` within the same `round_id`. +- Per-kind rules: + - `review-request`: no extra fields. + - `code-review`: must include `verdict: clean | findings`; when `verdict: findings`, you may also include optional `findings:` entries with `severity: high | medium | low` and `summary`. + - `docs-checked`: must include `docfix_outcome: no-updates | updated`. + +## Doc-Fix Stage + +You are the docs sweeper for this ticket. +Only change `*.md` files and files under `docs/**`. Do not change any non-doc file. Keep docs aligned with the current `HEAD`. + +Check whether documentation that should reflect the current code is still accurate, including places such as `AGENTS.md`, `README.md`, and `docs/`. + +Follow this order exactly: + +1. If no documentation updates are needed, do not make repo changes. +2. If documentation updates are needed, make only doc-path edits, commit them with a message that starts with `docs:`, then push the commit. + +Before writing the `docs-checked` marker in either path, ensure all intended doc edits are committed, verify `git status --porcelain` is empty, and take `reviewed_sha` from that clean `git rev-parse HEAD`. + +When writing the marker: +- `round_id`: the same `round_id` as the clean review you are responding to. +- `stage_round`: `max(existing docs-checked.stage_round in that round_id) + 1`; first doc-fix in the round is `1`. +- `issue_identifier`: `{{ issue.identifier }}`. + +Example: + +```symphony-marker +kind: docs-checked +round_id: +stage_round: +reviewed_sha: +issue_identifier: {{ issue.identifier }} +docfix_outcome: no-updates | updated +``` + +Stop the turn immediately after writing the marker. diff --git a/elixir/WORKFLOW-review.md b/elixir/WORKFLOW-review.md new file mode 100644 index 000000000..5271e5c62 --- /dev/null +++ b/elixir/WORKFLOW-review.md @@ -0,0 +1,85 @@ +You are working on Linear ticket `{{ issue.identifier }}`: `{{ issue.title }}`. +Current status: `{{ issue.state }}`. Labels: {{ issue.labels }}. URL: {{ issue.url }} + +{% if issue.description %} +Description: +{{ issue.description }} +{% endif %} + +## Marker Contract (STRICT) +- First, find the existing Linear issue comment whose header is `## Codex Workpad`. +- In that comment, locate the bounded marker section delimited by `` and ``. +- Markers live only inside that bounded section of the existing Linear `## Codex Workpad` comment: + +````markdown + +```symphony-marker +kind: review-request | code-review | docs-checked +round_id: = 1> +stage_round: = 1> +reviewed_sha: <40-char hex SHA> +issue_identifier: {{ issue.identifier }} +``` + +```` + +- Append new markers only between `` and `` in that Linear comment, after the last existing ` ```symphony-marker ` block and before the END marker. +- Only bounded ` ```symphony-marker ` blocks are parsed. Do not write markers outside that section. +- Required fields for every marker: `kind`, `round_id`, `stage_round`, `reviewed_sha`, `issue_identifier`. +- `issue_identifier` must exactly equal `{{ issue.identifier }}`. +- `reviewed_sha` must be `git rev-parse HEAD` at the moment you write the marker. +- `stage_round` increments independently per `kind` within the same `round_id`. +- Per-kind rules: + - `review-request`: no extra fields. + - `code-review`: must include `verdict: clean | findings`; when `verdict: findings`, you may also include optional `findings:` entries with `severity: high | medium | low` and `summary`. + - `docs-checked`: must include `docfix_outcome: no-updates | updated`. + +## Review Stage + +You are the second-opinion reviewer for this ticket. Do not commit, do not change the Linear state, and do not edit repository files. The only allowed write is appending a marker inside the existing Linear `## Codex Workpad` comment. + +Read the review diff with: + +```bash +git diff $(git merge-base HEAD origin/main)..HEAD +``` + +Review the diff for correctness, security, error handling, and style using the consumer repo's review protocol. If the repo provides review instructions in places such as `AGENTS.md`, `.codex/skills/`, or `.claude/skills/`, follow them. + +If the diff touches frontend paths such as `apps/web/**`, `apps/*/components/**`, or `apps/*/app/**/*.tsx`, also do a visual review: + +```bash +pnpm --dir apps/web dev +``` + +Open the relevant route or routes implied by the diff and ticket intent, inspect them visually, capture screenshots as needed, then stop the dev server when done. + +Before writing the marker, you must: +- stop any dev server started for visual review; +- remove any temporary artifacts you created; +- confirm `git rev-parse HEAD` is unchanged from the stage-start HEAD; +- verify `git status --porcelain` is empty. + +When the review is complete, write a `code-review` marker in the bounded section of the existing Linear `## Codex Workpad` comment with: +- `round_id`: the `round_id` from the latest `review-request` marker in the current round. +- `stage_round`: `max(existing code-review.stage_round in that round_id) + 1`; first review in the round is `1`. +- `reviewed_sha`: current `git rev-parse HEAD`. +- `issue_identifier`: `{{ issue.identifier }}`. +- `verdict`: `clean` or `findings`. +- `findings`: optional, only when you want to summarize findings for the implementer. + +Example: + +```symphony-marker +kind: code-review +round_id: +stage_round: +reviewed_sha: +issue_identifier: {{ issue.identifier }} +verdict: clean | findings +findings: + - severity: high + summary: +``` + +Stop the turn immediately after writing the marker. diff --git a/elixir/WORKFLOW.md b/elixir/WORKFLOW.md index d102b62fe..59f052833 100644 --- a/elixir/WORKFLOW.md +++ b/elixir/WORKFLOW.md @@ -23,6 +23,12 @@ hooks: if command -v mise >/dev/null 2>&1; then cd elixir && mise trust && mise exec -- mix deps.get fi + after_implement: | + if command -v mise >/dev/null 2>&1; then + cd elixir && mise exec -- mix test + else + cd elixir && mix test + fi before_remove: | cd elixir && mise exec -- mix workspace.before_remove agent: @@ -101,6 +107,17 @@ The agent should be able to talk to Linear, either via a configured Linear MCP s - `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. +## Stage-chain handoff + +- Keep the existing `## Codex Workpad` artifact and preserve a bounded marker section inside it: + - `` + - `` +- Markers are machine-owned data. Keep all free-form notes outside that bounded section. +- When creating or reconciling the workpad, ensure the bounded marker section exists even if it is empty. +- Normal implement mode (`Todo`, `In Progress`, or findings-driven rework while the issue stays active): after the branch is pushed and `git status --porcelain` is empty, stop the turn without writing a `review-request` marker. Symphony will run the repo's standard post-implement check and append `review-request` automatically if it passes. +- Handoff mode: if the current round already has a clean `code-review` plus matching `docs-checked` for the current `HEAD`, run the final PR/check sweep and move the issue to `Human Review`. +- Rework mode keeps ownership of branch reset / archival / state-reset steps in this prompt. Do not delete the bounded marker section during rework. + ## Status map - `Backlog` -> out of scope for this workflow; do not modify. @@ -142,6 +159,7 @@ The agent should be able to talk to Linear, either via a configured Linear MCP s - 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. + - Ensure the bounded marker section exists in the workpad before continuing. 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. @@ -209,6 +227,7 @@ Use this only when completion is blocked by missing required tools or missing au 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. + - Symphony will run the repo's standard post-implement check after the turn. Use in-turn validation for targeted proof, ticket-specific requirements, and quick iteration before you push. - 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. @@ -224,16 +243,20 @@ Use this only when completion is blocked by missing required tools or missing au - 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: +11. Before ending a normal implement turn: + - Ensure the branch is pushed, the workpad is current, and `git status --porcelain` is empty. + - Do not move the issue to `Human Review` yet. + - Stop the turn and let Symphony run the configured post-implement check plus automatic `review-request` handoff. +12. Handoff mode only: 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`. +13. 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: +14. 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`. @@ -252,11 +275,11 @@ Use this only when completion is blocked by missing required tools or missing au 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. +4. Keep the existing `## Codex Workpad` artifact, preserve the bounded marker section, and refresh the free-form plan/notes in place. 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. + - Reconcile the existing workpad and bounded marker section instead of creating a new one. - Build a fresh plan/checklist and execute end-to-end. ## Completion bar before Human Review @@ -278,6 +301,7 @@ Use this only when completion is blocked by missing required tools or missing au - 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. +- Keep the bounded `SYMPHONY-MARKERS` section intact; never put free-form notes inside it. - 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` @@ -323,4 +347,7 @@ Use this exact structure for the persistent workpad comment and keep it updated ### Confusions - + + + ```` diff --git a/elixir/lib/symphony_elixir/agent_runner.ex b/elixir/lib/symphony_elixir/agent_runner.ex index 7292a4bc2..502c8cf5f 100644 --- a/elixir/lib/symphony_elixir/agent_runner.ex +++ b/elixir/lib/symphony_elixir/agent_runner.ex @@ -1,34 +1,48 @@ defmodule SymphonyElixir.AgentRunner do @moduledoc """ - Executes a single Linear issue in an isolated workspace with Codex. + Executes a single Linear issue in its workspace with Codex. """ require Logger alias SymphonyElixir.Codex.AppServer alias SymphonyElixir.{Config, Linear.Issue, PromptBuilder, Tracker, Workspace} + @type worker_host :: String.t() | nil + @spec run(map(), pid() | nil, keyword()) :: :ok | no_return() def run(issue, codex_update_recipient \\ nil, opts \\ []) do - Logger.info("Starting agent run for #{issue_context(issue)}") + # The orchestrator owns host retries so one worker lifetime never hops machines. + worker_host = selected_worker_host(Keyword.get(opts, :worker_host), Config.settings!().worker.ssh_hosts) + + Logger.info("Starting agent run for #{issue_context(issue)} worker_host=#{worker_host_for_log(worker_host)}") + + case run_on_worker_host(issue, codex_update_recipient, opts, worker_host) do + :ok -> + :ok + + {:error, reason} -> + Logger.error("Agent run failed for #{issue_context(issue)}: #{inspect(reason)}") + raise RuntimeError, "Agent run failed for #{issue_context(issue)}: #{inspect(reason)}" + end + end - case Workspace.create_for_issue(issue) do + defp run_on_worker_host(issue, codex_update_recipient, opts, worker_host) do + Logger.info("Starting worker attempt for #{issue_context(issue)} worker_host=#{worker_host_for_log(worker_host)}") + + case Workspace.create_for_issue(issue, worker_host) do {:ok, workspace} -> + send_worker_runtime_info(codex_update_recipient, issue, worker_host, workspace) + try do - with :ok <- Workspace.run_before_run_hook(workspace, issue), - :ok <- run_codex_turns(workspace, issue, codex_update_recipient, opts) do - :ok - else - {:error, reason} -> - Logger.error("Agent run failed for #{issue_context(issue)}: #{inspect(reason)}") - raise RuntimeError, "Agent run failed for #{issue_context(issue)}: #{inspect(reason)}" + with :ok <- Workspace.run_before_run_hook(workspace, issue, worker_host) do + run_codex_turns(workspace, issue, codex_update_recipient, opts, worker_host) end after - Workspace.run_after_run_hook(workspace, issue) + Workspace.run_after_run_hook(workspace, issue, worker_host) end {:error, reason} -> - Logger.error("Agent run failed for #{issue_context(issue)}: #{inspect(reason)}") - raise RuntimeError, "Agent run failed for #{issue_context(issue)}: #{inspect(reason)}" + {:error, reason} end end @@ -46,11 +60,27 @@ defmodule SymphonyElixir.AgentRunner do defp send_codex_update(_recipient, _issue, _message), do: :ok - defp run_codex_turns(workspace, issue, codex_update_recipient, opts) do - max_turns = Keyword.get(opts, :max_turns, Config.agent_max_turns()) + defp send_worker_runtime_info(recipient, %Issue{id: issue_id}, worker_host, workspace) + when is_binary(issue_id) and is_pid(recipient) and is_binary(workspace) do + send( + recipient, + {:worker_runtime_info, issue_id, + %{ + worker_host: worker_host, + workspace_path: workspace + }} + ) + + :ok + end + + defp send_worker_runtime_info(_recipient, _issue, _worker_host, _workspace), do: :ok + + defp run_codex_turns(workspace, issue, codex_update_recipient, opts, worker_host) do + max_turns = Keyword.get(opts, :max_turns, Config.settings!().agent.max_turns) issue_state_fetcher = Keyword.get(opts, :issue_state_fetcher, &Tracker.fetch_issue_states_by_ids/1) - with {:ok, session} <- AppServer.start_session(workspace) do + with {:ok, session} <- AppServer.start_session(workspace, worker_host: worker_host) do try do do_run_codex_turns(session, workspace, issue, codex_update_recipient, opts, issue_state_fetcher, 1, max_turns) after @@ -136,12 +166,31 @@ defmodule SymphonyElixir.AgentRunner do defp active_issue_state?(state_name) when is_binary(state_name) do normalized_state = normalize_issue_state(state_name) - Config.linear_active_states() + Config.settings!().tracker.active_states |> Enum.any?(fn active_state -> normalize_issue_state(active_state) == normalized_state end) end defp active_issue_state?(_state_name), do: false + defp selected_worker_host(nil, []), do: nil + + defp selected_worker_host(preferred_host, configured_hosts) when is_list(configured_hosts) do + hosts = + configured_hosts + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + |> Enum.uniq() + + case preferred_host do + host when is_binary(host) and host != "" -> host + _ when hosts == [] -> nil + _ -> List.first(hosts) + end + end + + defp worker_host_for_log(nil), do: "local" + defp worker_host_for_log(worker_host), do: worker_host + defp normalize_issue_state(state_name) when is_binary(state_name) do state_name |> String.trim() diff --git a/elixir/lib/symphony_elixir/codex/app_server.ex b/elixir/lib/symphony_elixir/codex/app_server.ex index e824c63b8..9f36bc7a0 100644 --- a/elixir/lib/symphony_elixir/codex/app_server.ex +++ b/elixir/lib/symphony_elixir/codex/app_server.ex @@ -4,7 +4,7 @@ defmodule SymphonyElixir.Codex.AppServer do """ require Logger - alias SymphonyElixir.{Codex.DynamicTool, Config} + alias SymphonyElixir.{Codex.DynamicTool, Config, PathSafety, SSH} @initialize_id 1 @thread_start_id 2 @@ -21,12 +21,13 @@ defmodule SymphonyElixir.Codex.AppServer do thread_sandbox: String.t(), turn_sandbox_policy: map(), thread_id: String.t(), - workspace: Path.t() + workspace: Path.t(), + worker_host: String.t() | nil } @spec run(Path.t(), String.t(), map(), keyword()) :: {:ok, map()} | {:error, term()} def run(workspace, prompt, issue, opts \\ []) do - with {:ok, session} <- start_session(workspace) do + with {:ok, session} <- start_session(workspace, opts) do try do run_turn(session, prompt, issue, opts) after @@ -35,14 +36,15 @@ defmodule SymphonyElixir.Codex.AppServer do end end - @spec start_session(Path.t()) :: {:ok, session()} | {:error, term()} - def start_session(workspace) do - with :ok <- validate_workspace_cwd(workspace), - {:ok, port} <- start_port(workspace) do - metadata = port_metadata(port) - expanded_workspace = Path.expand(workspace) + @spec start_session(Path.t(), keyword()) :: {:ok, session()} | {:error, term()} + def start_session(workspace, opts \\ []) do + worker_host = Keyword.get(opts, :worker_host) - with {:ok, session_policies} <- session_policies(expanded_workspace), + with {:ok, expanded_workspace} <- validate_workspace_cwd(workspace, worker_host), + {:ok, port} <- start_port(expanded_workspace, worker_host) do + metadata = port_metadata(port, worker_host) + + with {:ok, session_policies} <- session_policies(expanded_workspace, worker_host), {:ok, thread_id} <- do_start_session(port, expanded_workspace, session_policies) do {:ok, %{ @@ -53,7 +55,8 @@ defmodule SymphonyElixir.Codex.AppServer do thread_sandbox: session_policies.thread_sandbox, turn_sandbox_policy: session_policies.turn_sandbox_policy, thread_id: thread_id, - workspace: expanded_workspace + workspace: expanded_workspace, + worker_host: worker_host }} else {:error, reason} -> @@ -141,25 +144,49 @@ defmodule SymphonyElixir.Codex.AppServer do stop_port(port) end - defp validate_workspace_cwd(workspace) when is_binary(workspace) do - workspace_path = Path.expand(workspace) - workspace_root = Path.expand(Config.workspace_root()) + defp validate_workspace_cwd(workspace, nil) when is_binary(workspace) do + expanded_workspace = Path.expand(workspace) + expanded_root = Path.expand(Config.settings!().workspace.root) + expanded_root_prefix = expanded_root <> "/" + + with {:ok, canonical_workspace} <- PathSafety.canonicalize(expanded_workspace), + {:ok, canonical_root} <- PathSafety.canonicalize(expanded_root) do + canonical_root_prefix = canonical_root <> "/" + + cond do + canonical_workspace == canonical_root -> + {:error, {:invalid_workspace_cwd, :workspace_root, canonical_workspace}} + + String.starts_with?(canonical_workspace <> "/", canonical_root_prefix) -> + {:ok, canonical_workspace} - root_prefix = workspace_root <> "/" + String.starts_with?(expanded_workspace <> "/", expanded_root_prefix) -> + {:error, {:invalid_workspace_cwd, :symlink_escape, expanded_workspace, canonical_root}} + + true -> + {:error, {:invalid_workspace_cwd, :outside_workspace_root, canonical_workspace, canonical_root}} + end + else + {:error, {:path_canonicalize_failed, path, reason}} -> + {:error, {:invalid_workspace_cwd, :path_unreadable, path, reason}} + end + end + defp validate_workspace_cwd(workspace, worker_host) + when is_binary(workspace) and is_binary(worker_host) do cond do - workspace_path == workspace_root -> - {:error, {:invalid_workspace_cwd, :workspace_root, workspace_path}} + String.trim(workspace) == "" -> + {:error, {:invalid_workspace_cwd, :empty_remote_workspace, worker_host}} - not String.starts_with?(workspace_path <> "/", root_prefix) -> - {:error, {:invalid_workspace_cwd, :outside_workspace_root, workspace_path, workspace_root}} + String.contains?(workspace, ["\n", "\r", <<0>>]) -> + {:error, {:invalid_workspace_cwd, :invalid_remote_workspace, worker_host, workspace}} true -> - :ok + {:ok, workspace} end end - defp start_port(workspace) do + defp start_port(workspace, nil) do executable = System.find_executable("bash") if is_nil(executable) do @@ -172,7 +199,7 @@ defmodule SymphonyElixir.Codex.AppServer do :binary, :exit_status, :stderr_to_stdout, - args: [~c"-lc", String.to_charlist(Config.codex_command())], + args: [~c"-lc", String.to_charlist(Config.settings!().codex.command)], cd: String.to_charlist(workspace), line: @port_line_bytes ] @@ -182,13 +209,32 @@ defmodule SymphonyElixir.Codex.AppServer do end end - defp port_metadata(port) when is_port(port) do - case :erlang.port_info(port, :os_pid) do - {:os_pid, os_pid} -> - %{codex_app_server_pid: to_string(os_pid)} + defp start_port(workspace, worker_host) when is_binary(worker_host) do + remote_command = remote_launch_command(workspace) + SSH.start_port(worker_host, remote_command, line: @port_line_bytes) + end - _ -> - %{} + defp remote_launch_command(workspace) when is_binary(workspace) do + [ + "cd #{shell_escape(workspace)}", + "exec #{Config.settings!().codex.command}" + ] + |> Enum.join(" && ") + end + + defp port_metadata(port, worker_host) when is_port(port) do + base_metadata = + case :erlang.port_info(port, :os_pid) do + {:os_pid, os_pid} -> + %{codex_app_server_pid: to_string(os_pid)} + + _ -> + %{} + end + + case worker_host do + host when is_binary(host) -> Map.put(base_metadata, :worker_host, host) + _ -> base_metadata end end @@ -216,10 +262,14 @@ defmodule SymphonyElixir.Codex.AppServer do end end - defp session_policies(workspace) do + defp session_policies(workspace, nil) do Config.codex_runtime_settings(workspace) end + defp session_policies(workspace, worker_host) when is_binary(worker_host) do + Config.codex_runtime_settings(workspace, remote: true) + end + defp do_start_session(port, workspace, session_policies) do case send_initialize(port) do :ok -> start_thread(port, workspace, session_policies) @@ -234,7 +284,7 @@ defmodule SymphonyElixir.Codex.AppServer do "params" => %{ "approvalPolicy" => approval_policy, "sandbox" => thread_sandbox, - "cwd" => Path.expand(workspace), + "cwd" => workspace, "dynamicTools" => DynamicTool.tool_specs() } }) @@ -263,7 +313,7 @@ defmodule SymphonyElixir.Codex.AppServer do "text" => prompt } ], - "cwd" => Path.expand(workspace), + "cwd" => workspace, "title" => "#{issue.identifier}: #{issue.title}", "approvalPolicy" => approval_policy, "sandboxPolicy" => turn_sandbox_policy @@ -277,7 +327,14 @@ defmodule SymphonyElixir.Codex.AppServer do end defp await_turn_completion(port, on_message, tool_executor, auto_approve_requests) do - receive_loop(port, on_message, Config.codex_turn_timeout_ms(), "", tool_executor, auto_approve_requests) + receive_loop( + port, + on_message, + Config.settings!().codex.turn_timeout_ms, + "", + tool_executor, + auto_approve_requests + ) end defp receive_loop(port, on_message, timeout_ms, pending_line, tool_executor, auto_approve_requests) do @@ -365,15 +422,17 @@ defmodule SymphonyElixir.Codex.AppServer do {:error, _reason} -> log_non_json_stream_line(payload_string, "turn stream") - emit_message( - on_message, - :malformed, - %{ - payload: payload_string, - raw: payload_string - }, - metadata_from_message(port, %{raw: payload_string}) - ) + if protocol_message_candidate?(payload_string) do + emit_message( + on_message, + :malformed, + %{ + payload: payload_string, + raw: payload_string + }, + metadata_from_message(port, %{raw: payload_string}) + ) + end receive_loop(port, on_message, timeout_ms, "", tool_executor, auto_approve_requests) end @@ -499,7 +558,10 @@ defmodule SymphonyElixir.Codex.AppServer do tool_name = tool_call_name(params) arguments = tool_call_arguments(params) - result = tool_executor.(tool_name, arguments) + result = + tool_name + |> tool_executor.(arguments) + |> normalize_dynamic_tool_result() send_message(port, %{ "id" => id, @@ -619,6 +681,44 @@ defmodule SymphonyElixir.Codex.AppServer do :unhandled end + defp normalize_dynamic_tool_result(%{"success" => success} = result) when is_boolean(success) do + output = + case Map.get(result, "output") do + existing_output when is_binary(existing_output) -> existing_output + _ -> dynamic_tool_output(result) + end + + content_items = + case Map.get(result, "contentItems") do + existing_items when is_list(existing_items) -> existing_items + _ -> dynamic_tool_content_items(output) + end + + result + |> Map.put("output", output) + |> Map.put("contentItems", content_items) + end + + defp normalize_dynamic_tool_result(result) do + %{ + "success" => false, + "output" => inspect(result), + "contentItems" => dynamic_tool_content_items(inspect(result)) + } + end + + defp dynamic_tool_output(%{"contentItems" => [%{"text" => text} | _]}) when is_binary(text), do: text + defp dynamic_tool_output(result), do: Jason.encode!(result, pretty: true) + + defp dynamic_tool_content_items(output) when is_binary(output) do + [ + %{ + "type" => "inputText", + "text" => output + } + ] + end + defp approve_or_require( port, id, @@ -820,7 +920,7 @@ defmodule SymphonyElixir.Codex.AppServer do end defp await_response(port, request_id) do - with_timeout_response(port, request_id, Config.codex_read_timeout_ms(), "") + with_timeout_response(port, request_id, Config.settings!().codex.read_timeout_ms, "") end defp with_timeout_response(port, request_id, timeout_ms, pending_line) do @@ -879,6 +979,13 @@ defmodule SymphonyElixir.Codex.AppServer do end end + defp protocol_message_candidate?(data) do + data + |> to_string() + |> String.trim_leading() + |> String.starts_with?("{") + end + defp issue_context(%{id: issue_id, identifier: identifier}) do "issue_id=#{issue_id} issue_identifier=#{identifier}" end @@ -905,7 +1012,7 @@ defmodule SymphonyElixir.Codex.AppServer do end defp metadata_from_message(port, payload) do - port |> port_metadata() |> maybe_set_usage(payload) + port |> port_metadata(nil) |> maybe_set_usage(payload) end defp maybe_set_usage(metadata, payload) when is_map(payload) do @@ -920,6 +1027,10 @@ defmodule SymphonyElixir.Codex.AppServer do defp maybe_set_usage(metadata, _payload), do: metadata + defp shell_escape(value) when is_binary(value) do + "'" <> String.replace(value, "'", "'\"'\"'") <> "'" + end + defp default_on_message(_message), do: :ok defp tool_call_name(params) when is_map(params) do diff --git a/elixir/lib/symphony_elixir/codex/dynamic_tool.ex b/elixir/lib/symphony_elixir/codex/dynamic_tool.ex index 716d36070..446c7fd2c 100644 --- a/elixir/lib/symphony_elixir/codex/dynamic_tool.ex +++ b/elixir/lib/symphony_elixir/codex/dynamic_tool.ex @@ -118,24 +118,21 @@ defmodule SymphonyElixir.Codex.DynamicTool do _ -> true end - %{ - "success" => success, - "contentItems" => [ - %{ - "type" => "inputText", - "text" => encode_payload(response) - } - ] - } + dynamic_tool_response(success, encode_payload(response)) end defp failure_response(payload) do + dynamic_tool_response(false, encode_payload(payload)) + end + + defp dynamic_tool_response(success, output) when is_boolean(success) and is_binary(output) do %{ - "success" => false, + "success" => success, + "output" => output, "contentItems" => [ %{ "type" => "inputText", - "text" => encode_payload(payload) + "text" => output } ] } diff --git a/elixir/lib/symphony_elixir/config.ex b/elixir/lib/symphony_elixir/config.ex index 3a9f0d997..00e7f9b7e 100644 --- a/elixir/lib/symphony_elixir/config.ex +++ b/elixir/lib/symphony_elixir/config.ex @@ -3,12 +3,9 @@ defmodule SymphonyElixir.Config do Runtime configuration loaded from `WORKFLOW.md`. """ - alias NimbleOptions + alias SymphonyElixir.Config.Schema alias SymphonyElixir.Workflow - @default_active_states ["Todo", "In Progress"] - @default_terminal_states ["Closed", "Cancelled", "Canceled", "Duplicate", "Done"] - @default_linear_endpoint "https://api.linear.app/graphql" @default_prompt_template """ You are working on a Linear issue. @@ -22,306 +19,62 @@ defmodule SymphonyElixir.Config do No description provided. {% endif %} """ - @default_poll_interval_ms 30_000 - @default_workspace_root Path.join(System.tmp_dir!(), "symphony_workspaces") - @default_hook_timeout_ms 60_000 - @default_max_concurrent_agents 10 - @default_agent_max_turns 20 - @default_max_retry_backoff_ms 300_000 - @default_codex_command "codex app-server" - @default_codex_turn_timeout_ms 3_600_000 - @default_codex_read_timeout_ms 5_000 - @default_codex_stall_timeout_ms 300_000 - @default_codex_approval_policy %{ - "reject" => %{ - "sandbox_approval" => true, - "rules" => true, - "mcp_elicitations" => true - } - } - @default_codex_thread_sandbox "workspace-write" - @default_observability_enabled true - @default_observability_refresh_ms 1_000 - @default_observability_render_interval_ms 16 - @default_server_host "127.0.0.1" - @workflow_options_schema NimbleOptions.new!( - tracker: [ - type: :map, - default: %{}, - keys: [ - kind: [type: {:or, [:string, nil]}, default: nil], - endpoint: [type: :string, default: @default_linear_endpoint], - api_key: [type: {:or, [:string, nil]}, default: nil], - project_slug: [type: {:or, [:string, nil]}, default: nil], - assignee: [type: {:or, [:string, nil]}, default: nil], - active_states: [ - type: {:list, :string}, - default: @default_active_states - ], - terminal_states: [ - type: {:list, :string}, - default: @default_terminal_states - ] - ] - ], - polling: [ - type: :map, - default: %{}, - keys: [ - interval_ms: [type: :integer, default: @default_poll_interval_ms] - ] - ], - workspace: [ - type: :map, - default: %{}, - keys: [ - root: [type: {:or, [:string, nil]}, default: @default_workspace_root] - ] - ], - agent: [ - type: :map, - default: %{}, - keys: [ - max_concurrent_agents: [ - type: :integer, - default: @default_max_concurrent_agents - ], - max_turns: [ - type: :pos_integer, - default: @default_agent_max_turns - ], - max_retry_backoff_ms: [ - type: :pos_integer, - default: @default_max_retry_backoff_ms - ], - max_concurrent_agents_by_state: [ - type: {:map, :string, :pos_integer}, - default: %{} - ] - ] - ], - codex: [ - type: :map, - default: %{}, - keys: [ - command: [type: :string, default: @default_codex_command], - turn_timeout_ms: [ - type: :integer, - default: @default_codex_turn_timeout_ms - ], - read_timeout_ms: [ - type: :integer, - default: @default_codex_read_timeout_ms - ], - stall_timeout_ms: [ - type: :integer, - default: @default_codex_stall_timeout_ms - ] - ] - ], - hooks: [ - type: :map, - default: %{}, - keys: [ - after_create: [type: {:or, [:string, nil]}, default: nil], - before_run: [type: {:or, [:string, nil]}, default: nil], - after_run: [type: {:or, [:string, nil]}, default: nil], - before_remove: [type: {:or, [:string, nil]}, default: nil], - timeout_ms: [type: :pos_integer, default: @default_hook_timeout_ms] - ] - ], - observability: [ - type: :map, - default: %{}, - keys: [ - dashboard_enabled: [ - type: :boolean, - default: @default_observability_enabled - ], - refresh_ms: [ - type: :integer, - default: @default_observability_refresh_ms - ], - render_interval_ms: [ - type: :integer, - default: @default_observability_render_interval_ms - ] - ] - ], - server: [ - type: :map, - default: %{}, - keys: [ - port: [type: {:or, [:non_neg_integer, nil]}, default: nil], - host: [type: :string, default: @default_server_host] - ] - ] - ) - @type workflow_payload :: Workflow.loaded_workflow() - @type tracker_kind :: String.t() | nil @type codex_runtime_settings :: %{ approval_policy: String.t() | map(), thread_sandbox: String.t(), turn_sandbox_policy: map() } - @type workspace_hooks :: %{ - after_create: String.t() | nil, - before_run: String.t() | nil, - after_run: String.t() | nil, - before_remove: String.t() | nil, - timeout_ms: pos_integer() - } - - @spec current_workflow() :: {:ok, workflow_payload()} | {:error, term()} - def current_workflow do - Workflow.current() - end - - @spec tracker_kind() :: tracker_kind() - def tracker_kind do - get_in(validated_workflow_options(), [:tracker, :kind]) - end - - @spec linear_endpoint() :: String.t() - def linear_endpoint do - get_in(validated_workflow_options(), [:tracker, :endpoint]) - end - - @spec linear_api_token() :: String.t() | nil - def linear_api_token do - validated_workflow_options() - |> get_in([:tracker, :api_key]) - |> resolve_env_value(System.get_env("LINEAR_API_KEY")) - |> normalize_secret_value() - end - - @spec linear_project_slug() :: String.t() | nil - def linear_project_slug do - get_in(validated_workflow_options(), [:tracker, :project_slug]) - end - - @spec linear_assignee() :: String.t() | nil - def linear_assignee do - validated_workflow_options() - |> get_in([:tracker, :assignee]) - |> resolve_env_value(System.get_env("LINEAR_ASSIGNEE")) - |> normalize_secret_value() - end - - @spec linear_active_states() :: [String.t()] - def linear_active_states do - get_in(validated_workflow_options(), [:tracker, :active_states]) - end - @spec linear_terminal_states() :: [String.t()] - def linear_terminal_states do - get_in(validated_workflow_options(), [:tracker, :terminal_states]) - end - - @spec poll_interval_ms() :: pos_integer() - def poll_interval_ms do - get_in(validated_workflow_options(), [:polling, :interval_ms]) - end - - @spec workspace_root() :: Path.t() - def workspace_root do - validated_workflow_options() - |> get_in([:workspace, :root]) - |> resolve_path_value(@default_workspace_root) - end - - @spec workspace_hooks() :: workspace_hooks() - def workspace_hooks do - hooks = get_in(validated_workflow_options(), [:hooks]) - - %{ - after_create: Map.get(hooks, :after_create), - before_run: Map.get(hooks, :before_run), - after_run: Map.get(hooks, :after_run), - before_remove: Map.get(hooks, :before_remove), - timeout_ms: Map.get(hooks, :timeout_ms) - } - end - - @spec hook_timeout_ms() :: pos_integer() - def hook_timeout_ms do - get_in(validated_workflow_options(), [:hooks, :timeout_ms]) - end + @spec settings() :: {:ok, Schema.t()} | {:error, term()} + def settings do + case Workflow.current() do + {:ok, %{config: config}} when is_map(config) -> + Schema.parse(config) - @spec max_concurrent_agents() :: pos_integer() - def max_concurrent_agents do - get_in(validated_workflow_options(), [:agent, :max_concurrent_agents]) + {:error, reason} -> + {:error, reason} + end end - @spec max_retry_backoff_ms() :: pos_integer() - def max_retry_backoff_ms do - get_in(validated_workflow_options(), [:agent, :max_retry_backoff_ms]) - end + @spec settings!() :: Schema.t() + def settings! do + case settings() do + {:ok, settings} -> + settings - @spec agent_max_turns() :: pos_integer() - def agent_max_turns do - get_in(validated_workflow_options(), [:agent, :max_turns]) + {:error, reason} -> + raise ArgumentError, message: format_config_error(reason) + end end @spec max_concurrent_agents_for_state(term()) :: pos_integer() def max_concurrent_agents_for_state(state_name) when is_binary(state_name) do - state_limits = get_in(validated_workflow_options(), [:agent, :max_concurrent_agents_by_state]) - global_limit = max_concurrent_agents() - Map.get(state_limits, normalize_issue_state(state_name), global_limit) - end - - def max_concurrent_agents_for_state(_state_name), do: max_concurrent_agents() + config = settings!() - @spec codex_command() :: String.t() - def codex_command do - get_in(validated_workflow_options(), [:codex, :command]) - end - - @spec codex_turn_timeout_ms() :: pos_integer() - def codex_turn_timeout_ms do - get_in(validated_workflow_options(), [:codex, :turn_timeout_ms]) - end - - @spec codex_approval_policy() :: String.t() | map() - def codex_approval_policy do - case resolve_codex_approval_policy() do - {:ok, approval_policy} -> approval_policy - {:error, _reason} -> @default_codex_approval_policy - end + Map.get( + config.agent.max_concurrent_agents_by_state, + Schema.normalize_issue_state(state_name), + config.agent.max_concurrent_agents + ) end - @spec codex_thread_sandbox() :: String.t() - def codex_thread_sandbox do - case resolve_codex_thread_sandbox() do - {:ok, thread_sandbox} -> thread_sandbox - {:error, _reason} -> @default_codex_thread_sandbox - end - end + def max_concurrent_agents_for_state(_state_name), do: settings!().agent.max_concurrent_agents @spec codex_turn_sandbox_policy(Path.t() | nil) :: map() def codex_turn_sandbox_policy(workspace \\ nil) do - case resolve_codex_turn_sandbox_policy(workspace) do - {:ok, turn_sandbox_policy} -> turn_sandbox_policy - {:error, _reason} -> default_codex_turn_sandbox_policy(workspace) - end - end - - @spec codex_read_timeout_ms() :: pos_integer() - def codex_read_timeout_ms do - get_in(validated_workflow_options(), [:codex, :read_timeout_ms]) - end + case Schema.resolve_runtime_turn_sandbox_policy(settings!(), workspace) do + {:ok, policy} -> + policy - @spec codex_stall_timeout_ms() :: non_neg_integer() - def codex_stall_timeout_ms do - validated_workflow_options() - |> get_in([:codex, :stall_timeout_ms]) - |> max(0) + {:error, reason} -> + raise ArgumentError, message: "Invalid codex turn sandbox policy: #{inspect(reason)}" + end end @spec workflow_prompt() :: String.t() def workflow_prompt do - case current_workflow() do + case Workflow.current() do {:ok, %{prompt_template: prompt}} -> if String.trim(prompt) == "", do: @default_prompt_template, else: prompt @@ -330,609 +83,72 @@ defmodule SymphonyElixir.Config do end end - @spec observability_enabled?() :: boolean() - def observability_enabled? do - get_in(validated_workflow_options(), [:observability, :dashboard_enabled]) - end - - @spec observability_refresh_ms() :: pos_integer() - def observability_refresh_ms do - get_in(validated_workflow_options(), [:observability, :refresh_ms]) - end - - @spec observability_render_interval_ms() :: pos_integer() - def observability_render_interval_ms do - get_in(validated_workflow_options(), [:observability, :render_interval_ms]) - end - @spec server_port() :: non_neg_integer() | nil def server_port do case Application.get_env(:symphony_elixir, :server_port_override) do - port when is_integer(port) and port >= 0 -> - port - - _ -> - get_in(validated_workflow_options(), [:server, :port]) + port when is_integer(port) and port >= 0 -> port + _ -> settings!().server.port end end - @spec server_host() :: String.t() - def server_host do - get_in(validated_workflow_options(), [:server, :host]) - end - @spec validate!() :: :ok | {:error, term()} def validate! do - with {:ok, _workflow} <- current_workflow(), - :ok <- require_tracker_kind(), - :ok <- require_linear_token(), - :ok <- require_linear_project(), - :ok <- require_valid_codex_runtime_settings() do - require_codex_command() - end - end - - @spec codex_runtime_settings(Path.t() | nil) :: {:ok, codex_runtime_settings()} | {:error, term()} - def codex_runtime_settings(workspace \\ nil) do - with {:ok, approval_policy} <- resolve_codex_approval_policy(), - {:ok, thread_sandbox} <- resolve_codex_thread_sandbox(), - {:ok, turn_sandbox_policy} <- resolve_codex_turn_sandbox_policy(workspace) do - {:ok, - %{ - approval_policy: approval_policy, - thread_sandbox: thread_sandbox, - turn_sandbox_policy: turn_sandbox_policy - }} - end - end - - defp require_tracker_kind do - case tracker_kind() do - "linear" -> :ok - "memory" -> :ok - nil -> {:error, :missing_tracker_kind} - other -> {:error, {:unsupported_tracker_kind, other}} - end - end - - defp require_linear_token do - case tracker_kind() do - "linear" -> - if is_binary(linear_api_token()) do - :ok - else - {:error, :missing_linear_api_token} - end - - _ -> - :ok - end - end - - defp require_linear_project do - case tracker_kind() do - "linear" -> - if is_binary(linear_project_slug()) do - :ok - else - {:error, :missing_linear_project_slug} - end - - _ -> - :ok - end - end - - defp require_codex_command do - if byte_size(String.trim(codex_command())) > 0 do - :ok - else - {:error, :missing_codex_command} - end - end - - defp require_valid_codex_runtime_settings do - case codex_runtime_settings() do - {:ok, _settings} -> :ok - {:error, reason} -> {:error, reason} - end - end - - defp validated_workflow_options do - workflow_config() - |> extract_workflow_options() - |> NimbleOptions.validate!(@workflow_options_schema) - end - - defp extract_workflow_options(config) do - %{ - tracker: extract_tracker_options(section_map(config, "tracker")), - polling: extract_polling_options(section_map(config, "polling")), - workspace: extract_workspace_options(section_map(config, "workspace")), - agent: extract_agent_options(section_map(config, "agent")), - 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")) - } - end - - defp extract_tracker_options(section) do - %{} - |> put_if_present(:kind, normalize_tracker_kind(scalar_string_value(Map.get(section, "kind")))) - |> put_if_present(:endpoint, scalar_string_value(Map.get(section, "endpoint"))) - |> put_if_present(:api_key, binary_value(Map.get(section, "api_key"), allow_empty: true)) - |> put_if_present(:project_slug, scalar_string_value(Map.get(section, "project_slug"))) - |> put_if_present(:active_states, csv_value(Map.get(section, "active_states"))) - |> put_if_present(:terminal_states, csv_value(Map.get(section, "terminal_states"))) - end - - defp extract_polling_options(section) do - %{} - |> put_if_present(:interval_ms, integer_value(Map.get(section, "interval_ms"))) - end - - defp extract_workspace_options(section) do - %{} - |> put_if_present(:root, binary_value(Map.get(section, "root"))) - end - - defp extract_agent_options(section) 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( - :max_concurrent_agents_by_state, - state_limits_value(Map.get(section, "max_concurrent_agents_by_state")) - ) - end - - defp extract_codex_options(section) do - %{} - |> put_if_present(:command, command_value(Map.get(section, "command"))) - |> put_if_present(:turn_timeout_ms, integer_value(Map.get(section, "turn_timeout_ms"))) - |> put_if_present(:read_timeout_ms, integer_value(Map.get(section, "read_timeout_ms"))) - |> put_if_present(:stall_timeout_ms, integer_value(Map.get(section, "stall_timeout_ms"))) - end - - defp extract_hooks_options(section) do - %{} - |> put_if_present(:after_create, hook_command_value(Map.get(section, "after_create"))) - |> put_if_present(:before_run, hook_command_value(Map.get(section, "before_run"))) - |> put_if_present(:after_run, hook_command_value(Map.get(section, "after_run"))) - |> put_if_present(:before_remove, hook_command_value(Map.get(section, "before_remove"))) - |> put_if_present(:timeout_ms, positive_integer_value(Map.get(section, "timeout_ms"))) - end - - defp extract_observability_options(section) do - %{} - |> put_if_present(:dashboard_enabled, boolean_value(Map.get(section, "dashboard_enabled"))) - |> put_if_present(:refresh_ms, integer_value(Map.get(section, "refresh_ms"))) - |> put_if_present(:render_interval_ms, integer_value(Map.get(section, "render_interval_ms"))) - end - - defp extract_server_options(section) do - %{} - |> put_if_present(:port, non_negative_integer_value(Map.get(section, "port"))) - |> put_if_present(:host, scalar_string_value(Map.get(section, "host"))) - end - - defp section_map(config, key) do - case Map.get(config, key) do - section when is_map(section) -> section - _ -> %{} - end - end - - defp put_if_present(map, _key, :omit), do: map - defp put_if_present(map, key, value), do: Map.put(map, key, value) - - defp scalar_string_value(nil), do: :omit - defp scalar_string_value(value) when is_binary(value), do: String.trim(value) - defp scalar_string_value(value) when is_boolean(value), do: to_string(value) - defp scalar_string_value(value) when is_integer(value), do: to_string(value) - defp scalar_string_value(value) when is_float(value), do: to_string(value) - defp scalar_string_value(value) when is_atom(value), do: Atom.to_string(value) - defp scalar_string_value(_value), do: :omit - - defp binary_value(value, opts \\ []) - - defp binary_value(value, opts) when is_binary(value) do - allow_empty = Keyword.get(opts, :allow_empty, false) - - if value == "" and not allow_empty do - :omit - else - value - end - end - - defp binary_value(_value, _opts), do: :omit - - defp command_value(value) when is_binary(value) do - case String.trim(value) do - "" -> :omit - trimmed -> trimmed - end - end - - defp command_value(_value), do: :omit - - defp hook_command_value(value) when is_binary(value) do - case String.trim(value) do - "" -> :omit - _ -> String.trim_trailing(value) - end - end - - defp hook_command_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) - |> Enum.reverse() - |> case do - [] -> :omit - normalized_values -> normalized_values - end - end - - defp csv_value(value) when is_binary(value) do - value - |> String.split(",", trim: true) - |> Enum.map(&String.trim/1) - |> Enum.reject(&(&1 == "")) - |> case do - [] -> :omit - normalized_values -> normalized_values - end - end - - defp csv_value(_value), do: :omit - - defp maybe_append_csv_value(acc, value) do - case scalar_string_value(value) do - :omit -> - acc - - normalized -> - append_csv_value_if_present(acc, normalized) - end - end - - defp append_csv_value_if_present(acc, value) do - trimmed = String.trim(value) - - if trimmed == "" do - acc - else - [trimmed | acc] - end - end - - defp integer_value(value) do - case parse_integer(value) do - {:ok, parsed} -> parsed - :error -> :omit - end - end - - defp positive_integer_value(value) do - case parse_positive_integer(value) do - {:ok, parsed} -> parsed - :error -> :omit - end - end - - defp non_negative_integer_value(value) do - case parse_non_negative_integer(value) do - {:ok, parsed} -> parsed - :error -> :omit - end - end - - defp boolean_value(value) when is_boolean(value), do: value - - defp boolean_value(value) when is_binary(value) do - case String.downcase(String.trim(value)) do - "true" -> true - "false" -> false - _ -> :omit - end - end - - defp boolean_value(_value), do: :omit - - defp state_limits_value(value) when is_map(value) do - value - |> Enum.reduce(%{}, fn {state_name, limit}, acc -> - case parse_positive_integer(limit) do - {:ok, parsed} -> - Map.put(acc, normalize_issue_state(to_string(state_name)), parsed) - - :error -> - acc + with {:ok, settings} <- settings() do + validate_semantics(settings) + end + end + + @spec codex_runtime_settings(Path.t() | nil, keyword()) :: + {:ok, codex_runtime_settings()} | {:error, term()} + def codex_runtime_settings(workspace \\ nil, opts \\ []) do + with {:ok, settings} <- settings() do + with {:ok, turn_sandbox_policy} <- + Schema.resolve_runtime_turn_sandbox_policy(settings, workspace, opts) do + {:ok, + %{ + approval_policy: settings.codex.approval_policy, + thread_sandbox: settings.codex.thread_sandbox, + turn_sandbox_policy: turn_sandbox_policy + }} end - end) - end - - defp state_limits_value(_value), do: :omit - - defp parse_integer(value) when is_integer(value), do: {:ok, value} - - defp parse_integer(value) when is_binary(value) do - case Integer.parse(String.trim(value)) do - {parsed, _} -> {:ok, parsed} - :error -> :error - end - end - - defp parse_integer(_value), do: :error - - defp parse_positive_integer(value) do - case parse_integer(value) do - {:ok, parsed} when parsed > 0 -> {:ok, parsed} - _ -> :error - end - end - - defp parse_non_negative_integer(value) do - case parse_integer(value) do - {:ok, parsed} when parsed >= 0 -> {:ok, parsed} - _ -> :error - end - end - - defp fetch_value(paths, default) do - config = workflow_config() - - case resolve_config_value(config, paths) do - :missing -> default - value -> value - end - end - - defp resolve_codex_approval_policy do - case fetch_value([["codex", "approval_policy"]], :missing) do - :missing -> - {:ok, @default_codex_approval_policy} - - nil -> - {:ok, @default_codex_approval_policy} - - value when is_binary(value) -> - approval_policy = String.trim(value) - - if approval_policy == "" do - {:error, {:invalid_codex_approval_policy, value}} - else - {:ok, approval_policy} - end - - value when is_map(value) -> - {:ok, value} - - value -> - {:error, {:invalid_codex_approval_policy, value}} end end - defp resolve_codex_thread_sandbox do - case fetch_value([["codex", "thread_sandbox"]], :missing) do - :missing -> - {:ok, @default_codex_thread_sandbox} - - nil -> - {:ok, @default_codex_thread_sandbox} - - value when is_binary(value) -> - thread_sandbox = String.trim(value) - - if thread_sandbox == "" do - {:error, {:invalid_codex_thread_sandbox, value}} - else - {:ok, thread_sandbox} - end - - value -> - {:error, {:invalid_codex_thread_sandbox, value}} - end - end - - defp resolve_codex_turn_sandbox_policy(workspace) do - case fetch_value([["codex", "turn_sandbox_policy"]], :missing) do - :missing -> - {:ok, default_codex_turn_sandbox_policy(workspace)} - - nil -> - {:ok, default_codex_turn_sandbox_policy(workspace)} - - value when is_map(value) -> - {:ok, value} - - value -> - {:error, {:invalid_codex_turn_sandbox_policy, {:unsupported_value, value}}} - end - end - - defp default_codex_turn_sandbox_policy(workspace) do - writable_root = - if is_binary(workspace) and String.trim(workspace) != "" do - Path.expand(workspace) - else - Path.expand(workspace_root()) - end - - %{ - "type" => "workspaceWrite", - "writableRoots" => [writable_root], - "readOnlyAccess" => %{"type" => "fullAccess"}, - "networkAccess" => false, - "excludeTmpdirEnvVar" => false, - "excludeSlashTmp" => false - } - end - - defp normalize_issue_state(state_name) when is_binary(state_name) do - state_name - |> String.trim() - |> String.downcase() - end - - defp normalize_tracker_kind(kind) when is_binary(kind) do - kind - |> String.trim() - |> String.downcase() - |> case do - "" -> nil - normalized -> normalized - end - end - - defp normalize_tracker_kind(_kind), do: nil - - defp workflow_config do - case current_workflow() do - {:ok, %{config: config}} when is_map(config) -> - normalize_keys(config) - - _ -> - %{} - end - end - - defp resolve_config_value(%{} = config, paths) do - Enum.reduce_while(paths, :missing, fn path, _acc -> - case get_in_path(config, path) do - :missing -> {:cont, :missing} - value -> {:halt, value} - end - end) - end - - defp get_in_path(config, path) when is_list(path) and is_map(config) do - get_in_path(config, path, 0) - end - - defp get_in_path(_, _), do: :missing - - defp get_in_path(config, [], _depth), do: config - - defp get_in_path(%{} = current, [segment | rest], _depth) do - case Map.fetch(current, normalize_key(segment)) do - {:ok, value} -> get_in_path(value, rest, 0) - :error -> :missing - end - end - - defp get_in_path(_, _, _depth), do: :missing - - defp normalize_keys(value) when is_map(value) do - Enum.reduce(value, %{}, fn {key, raw_value}, normalized -> - Map.put(normalized, normalize_key(key), normalize_keys(raw_value)) - end) - end - - defp normalize_keys(value) when is_list(value), do: Enum.map(value, &normalize_keys/1) - defp normalize_keys(value), do: value - - defp normalize_key(value) when is_atom(value), do: Atom.to_string(value) - defp normalize_key(value), do: to_string(value) - - defp resolve_path_value(:missing, default), do: default - defp resolve_path_value(nil, default), do: default - - defp resolve_path_value(value, default) when is_binary(value) do - case normalize_path_token(value) do - :missing -> - default - - path -> - path - |> String.trim() - |> preserve_command_name() - |> then(fn - "" -> default - resolved -> resolved - end) - end - end - - defp resolve_path_value(_value, default), do: default - - defp preserve_command_name(path) do + defp validate_semantics(settings) do cond do - uri_path?(path) -> - path - - String.contains?(path, "/") or String.contains?(path, "\\") -> - Path.expand(path) - - true -> - path - end - end - - defp uri_path?(path) do - String.match?(to_string(path), ~r/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//) - end + is_nil(settings.tracker.kind) -> + {:error, :missing_tracker_kind} - defp resolve_env_value(:missing, fallback), do: fallback - defp resolve_env_value(nil, fallback), do: fallback + settings.tracker.kind not in ["linear", "memory"] -> + {:error, {:unsupported_tracker_kind, settings.tracker.kind}} - defp resolve_env_value(value, fallback) when is_binary(value) do - trimmed = String.trim(value) + settings.tracker.kind == "linear" and not is_binary(settings.tracker.api_key) -> + {:error, :missing_linear_api_token} - case env_reference_name(trimmed) do - {:ok, env_name} -> - env_name - |> System.get_env() - |> then(fn - nil -> fallback - "" -> nil - env_value -> env_value - end) + settings.tracker.kind == "linear" and not is_binary(settings.tracker.project_slug) -> + {:error, :missing_linear_project_slug} - :error -> - trimmed + true -> + :ok end end - defp resolve_env_value(_value, fallback), do: fallback + defp format_config_error(reason) do + case reason do + {:invalid_workflow_config, message} -> + "Invalid WORKFLOW.md config: #{message}" - defp normalize_path_token(value) when is_binary(value) do - trimmed = String.trim(value) + {:missing_workflow_file, path, raw_reason} -> + "Missing WORKFLOW.md at #{path}: #{inspect(raw_reason)}" - case env_reference_name(trimmed) do - {:ok, env_name} -> resolve_env_token(env_name) - :error -> trimmed - end - end + {:workflow_parse_error, raw_reason} -> + "Failed to parse WORKFLOW.md: #{inspect(raw_reason)}" - defp env_reference_name("$" <> env_name) do - if String.match?(env_name, ~r/^[A-Za-z_][A-Za-z0-9_]*$/) do - {:ok, env_name} - else - :error - end - end + :workflow_front_matter_not_a_map -> + "Failed to parse WORKFLOW.md: workflow front matter must decode to a map" - defp env_reference_name(_value), do: :error - - defp resolve_env_token(value) do - case System.get_env(value) do - nil -> :missing - env_value -> env_value + other -> + "Invalid WORKFLOW.md config: #{inspect(other)}" end end - - defp normalize_secret_value(value) when is_binary(value) do - case String.trim(value) do - "" -> nil - trimmed -> trimmed - end - end - - defp normalize_secret_value(_value), do: nil end diff --git a/elixir/lib/symphony_elixir/config/schema.ex b/elixir/lib/symphony_elixir/config/schema.ex new file mode 100644 index 000000000..afbd09b9e --- /dev/null +++ b/elixir/lib/symphony_elixir/config/schema.ex @@ -0,0 +1,558 @@ +defmodule SymphonyElixir.Config.Schema do + @moduledoc false + + use Ecto.Schema + + import Ecto.Changeset + + alias SymphonyElixir.PathSafety + + @primary_key false + + @type t :: %__MODULE__{} + + defmodule StringOrMap do + @moduledoc false + @behaviour Ecto.Type + + @spec type() :: :map + def type, do: :map + + @spec embed_as(term()) :: :self + def embed_as(_format), do: :self + + @spec equal?(term(), term()) :: boolean() + def equal?(left, right), do: left == right + + @spec cast(term()) :: {:ok, String.t() | map()} | :error + def cast(value) when is_binary(value) or is_map(value), do: {:ok, value} + def cast(_value), do: :error + + @spec load(term()) :: {:ok, String.t() | map()} | :error + def load(value) when is_binary(value) or is_map(value), do: {:ok, value} + def load(_value), do: :error + + @spec dump(term()) :: {:ok, String.t() | map()} | :error + def dump(value) when is_binary(value) or is_map(value), do: {:ok, value} + def dump(_value), do: :error + end + + defmodule Tracker do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + @primary_key false + + embedded_schema do + field(:kind, :string) + field(:endpoint, :string, default: "https://api.linear.app/graphql") + field(:api_key, :string) + field(:project_slug, :string) + field(:assignee, :string) + field(:active_states, {:array, :string}, default: ["Todo", "In Progress"]) + field(:terminal_states, {:array, :string}, default: ["Closed", "Cancelled", "Canceled", "Duplicate", "Done"]) + end + + @spec changeset(%__MODULE__{}, map()) :: Ecto.Changeset.t() + def changeset(schema, attrs) do + schema + |> cast( + attrs, + [:kind, :endpoint, :api_key, :project_slug, :assignee, :active_states, :terminal_states], + empty_values: [] + ) + end + end + + defmodule Polling do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + @primary_key false + embedded_schema do + field(:interval_ms, :integer, default: 30_000) + end + + @spec changeset(%__MODULE__{}, map()) :: Ecto.Changeset.t() + def changeset(schema, attrs) do + schema + |> cast(attrs, [:interval_ms], empty_values: []) + |> validate_number(:interval_ms, greater_than: 0) + end + end + + defmodule Workspace do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + @primary_key false + embedded_schema do + field(:root, :string, default: Path.join(System.tmp_dir!(), "symphony_workspaces")) + end + + @spec changeset(%__MODULE__{}, map()) :: Ecto.Changeset.t() + def changeset(schema, attrs) do + schema + |> cast(attrs, [:root], empty_values: []) + end + end + + defmodule Worker do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + @primary_key false + embedded_schema do + field(:ssh_hosts, {:array, :string}, default: []) + field(:max_concurrent_agents_per_host, :integer) + end + + @spec changeset(%__MODULE__{}, map()) :: Ecto.Changeset.t() + def changeset(schema, attrs) do + schema + |> cast(attrs, [:ssh_hosts, :max_concurrent_agents_per_host], empty_values: []) + |> validate_number(:max_concurrent_agents_per_host, greater_than: 0) + end + end + + defmodule Agent do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + alias SymphonyElixir.Config.Schema + + @primary_key false + embedded_schema do + field(:max_concurrent_agents, :integer, default: 10) + field(:max_turns, :integer, default: 20) + field(:max_retry_backoff_ms, :integer, default: 300_000) + field(:max_concurrent_agents_by_state, :map, default: %{}) + end + + @spec changeset(%__MODULE__{}, map()) :: Ecto.Changeset.t() + def changeset(schema, attrs) do + schema + |> cast( + attrs, + [:max_concurrent_agents, :max_turns, :max_retry_backoff_ms, :max_concurrent_agents_by_state], + empty_values: [] + ) + |> validate_number(:max_concurrent_agents, greater_than: 0) + |> validate_number(:max_turns, greater_than: 0) + |> validate_number(:max_retry_backoff_ms, greater_than: 0) + |> update_change(:max_concurrent_agents_by_state, &Schema.normalize_state_limits/1) + |> Schema.validate_state_limits(:max_concurrent_agents_by_state) + end + end + + defmodule Codex do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + @primary_key false + embedded_schema do + field(:command, :string, default: "codex app-server") + + field(:approval_policy, StringOrMap, + default: %{ + "reject" => %{ + "sandbox_approval" => true, + "rules" => true, + "mcp_elicitations" => true + } + } + ) + + field(:thread_sandbox, :string, default: "workspace-write") + field(:turn_sandbox_policy, :map) + field(:turn_timeout_ms, :integer, default: 3_600_000) + field(:read_timeout_ms, :integer, default: 5_000) + field(:stall_timeout_ms, :integer, default: 300_000) + end + + @spec changeset(%__MODULE__{}, map()) :: Ecto.Changeset.t() + def changeset(schema, attrs) do + schema + |> cast( + attrs, + [ + :command, + :approval_policy, + :thread_sandbox, + :turn_sandbox_policy, + :turn_timeout_ms, + :read_timeout_ms, + :stall_timeout_ms + ], + empty_values: [] + ) + |> validate_required([:command]) + |> validate_number(:turn_timeout_ms, greater_than: 0) + |> validate_number(:read_timeout_ms, greater_than: 0) + |> validate_number(:stall_timeout_ms, greater_than_or_equal_to: 0) + end + end + + defmodule Hooks do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + @primary_key false + embedded_schema do + field(:after_create, :string) + field(:before_run, :string) + field(:after_run, :string) + field(:after_implement, :string) + field(:before_remove, :string) + field(:timeout_ms, :integer, default: 60_000) + end + + @spec changeset(%__MODULE__{}, map()) :: Ecto.Changeset.t() + def changeset(schema, attrs) do + schema + |> cast(attrs, [:after_create, :before_run, :after_run, :after_implement, :before_remove, :timeout_ms], empty_values: []) + |> validate_number(:timeout_ms, greater_than: 0) + end + end + + defmodule Observability do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + @primary_key false + embedded_schema do + field(:dashboard_enabled, :boolean, default: true) + field(:refresh_ms, :integer, default: 1_000) + field(:render_interval_ms, :integer, default: 16) + end + + @spec changeset(%__MODULE__{}, map()) :: Ecto.Changeset.t() + def changeset(schema, attrs) do + schema + |> cast(attrs, [:dashboard_enabled, :refresh_ms, :render_interval_ms], empty_values: []) + |> validate_number(:refresh_ms, greater_than: 0) + |> validate_number(:render_interval_ms, greater_than: 0) + end + end + + defmodule Server do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + @primary_key false + embedded_schema do + field(:port, :integer) + field(:host, :string, default: "127.0.0.1") + end + + @spec changeset(%__MODULE__{}, map()) :: Ecto.Changeset.t() + def changeset(schema, attrs) do + schema + |> cast(attrs, [:port, :host], empty_values: []) + |> validate_number(:port, greater_than_or_equal_to: 0) + end + end + + embedded_schema do + embeds_one(:tracker, Tracker, on_replace: :update, defaults_to_struct: true) + embeds_one(:polling, Polling, on_replace: :update, defaults_to_struct: true) + embeds_one(:workspace, Workspace, on_replace: :update, defaults_to_struct: true) + embeds_one(:worker, Worker, on_replace: :update, defaults_to_struct: true) + embeds_one(:agent, Agent, on_replace: :update, defaults_to_struct: true) + embeds_one(:codex, Codex, on_replace: :update, defaults_to_struct: true) + embeds_one(:hooks, Hooks, on_replace: :update, defaults_to_struct: true) + embeds_one(:observability, Observability, on_replace: :update, defaults_to_struct: true) + embeds_one(:server, Server, on_replace: :update, defaults_to_struct: true) + end + + @spec parse(map()) :: {:ok, %__MODULE__{}} | {:error, {:invalid_workflow_config, String.t()}} + def parse(config) when is_map(config) do + config + |> normalize_keys() + |> drop_nil_values() + |> changeset() + |> apply_action(:validate) + |> case do + {:ok, settings} -> + {:ok, finalize_settings(settings)} + + {:error, changeset} -> + {:error, {:invalid_workflow_config, format_errors(changeset)}} + end + end + + @spec resolve_turn_sandbox_policy(%__MODULE__{}, Path.t() | nil) :: map() + def resolve_turn_sandbox_policy(settings, workspace \\ nil) do + case settings.codex.turn_sandbox_policy do + %{} = policy -> + policy + + _ -> + workspace + |> default_workspace_root(settings.workspace.root) + |> expand_local_workspace_root() + |> default_turn_sandbox_policy() + end + end + + @spec resolve_runtime_turn_sandbox_policy(%__MODULE__{}, Path.t() | nil, keyword()) :: + {:ok, map()} | {:error, term()} + def resolve_runtime_turn_sandbox_policy(settings, workspace \\ nil, opts \\ []) do + case settings.codex.turn_sandbox_policy do + %{} = policy -> + {:ok, policy} + + _ -> + workspace + |> default_workspace_root(settings.workspace.root) + |> default_runtime_turn_sandbox_policy(opts) + end + end + + @spec normalize_issue_state(String.t()) :: String.t() + def normalize_issue_state(state_name) when is_binary(state_name) do + String.downcase(state_name) + end + + @doc false + @spec normalize_state_limits(nil | map()) :: map() + def normalize_state_limits(nil), do: %{} + + def normalize_state_limits(limits) when is_map(limits) do + Enum.reduce(limits, %{}, fn {state_name, limit}, acc -> + Map.put(acc, normalize_issue_state(to_string(state_name)), limit) + end) + end + + @doc false + @spec validate_state_limits(Ecto.Changeset.t(), atom()) :: Ecto.Changeset.t() + def validate_state_limits(changeset, field) do + validate_change(changeset, field, fn ^field, limits -> + Enum.flat_map(limits, fn {state_name, limit} -> + cond do + to_string(state_name) == "" -> + [{field, "state names must not be blank"}] + + not is_integer(limit) or limit <= 0 -> + [{field, "limits must be positive integers"}] + + true -> + [] + end + end) + end) + end + + defp changeset(attrs) do + %__MODULE__{} + |> cast(attrs, []) + |> cast_embed(:tracker, with: &Tracker.changeset/2) + |> cast_embed(:polling, with: &Polling.changeset/2) + |> cast_embed(:workspace, with: &Workspace.changeset/2) + |> cast_embed(:worker, with: &Worker.changeset/2) + |> cast_embed(:agent, with: &Agent.changeset/2) + |> cast_embed(:codex, with: &Codex.changeset/2) + |> cast_embed(:hooks, with: &Hooks.changeset/2) + |> cast_embed(:observability, with: &Observability.changeset/2) + |> cast_embed(:server, with: &Server.changeset/2) + end + + defp finalize_settings(settings) do + tracker = %{ + settings.tracker + | api_key: resolve_secret_setting(settings.tracker.api_key, System.get_env("LINEAR_API_KEY")), + assignee: resolve_secret_setting(settings.tracker.assignee, System.get_env("LINEAR_ASSIGNEE")) + } + + workspace = %{ + settings.workspace + | root: resolve_path_value(settings.workspace.root, Path.join(System.tmp_dir!(), "symphony_workspaces")) + } + + codex = %{ + settings.codex + | approval_policy: normalize_keys(settings.codex.approval_policy), + turn_sandbox_policy: normalize_optional_map(settings.codex.turn_sandbox_policy) + } + + %{settings | tracker: tracker, workspace: workspace, codex: codex} + end + + defp normalize_keys(value) when is_map(value) do + Enum.reduce(value, %{}, fn {key, raw_value}, normalized -> + Map.put(normalized, normalize_key(key), normalize_keys(raw_value)) + end) + end + + defp normalize_keys(value) when is_list(value), do: Enum.map(value, &normalize_keys/1) + defp normalize_keys(value), do: value + + defp normalize_optional_map(nil), do: nil + defp normalize_optional_map(value) when is_map(value), do: normalize_keys(value) + + defp normalize_key(value) when is_atom(value), do: Atom.to_string(value) + defp normalize_key(value), do: to_string(value) + + defp drop_nil_values(value) when is_map(value) do + Enum.reduce(value, %{}, fn {key, nested}, acc -> + case drop_nil_values(nested) do + nil -> acc + normalized -> Map.put(acc, key, normalized) + end + end) + end + + defp drop_nil_values(value) when is_list(value), do: Enum.map(value, &drop_nil_values/1) + defp drop_nil_values(value), do: value + + defp resolve_secret_setting(nil, fallback), do: normalize_secret_value(fallback) + + defp resolve_secret_setting(value, fallback) when is_binary(value) do + case resolve_env_value(value, fallback) do + resolved when is_binary(resolved) -> normalize_secret_value(resolved) + resolved -> resolved + end + end + + defp resolve_path_value(value, default) when is_binary(value) do + case normalize_path_token(value) do + :missing -> + default + + "" -> + default + + path -> + path + end + end + + defp resolve_env_value(value, fallback) when is_binary(value) do + case env_reference_name(value) do + {:ok, env_name} -> + case System.get_env(env_name) do + nil -> fallback + "" -> nil + env_value -> env_value + end + + :error -> + value + end + end + + defp normalize_path_token(value) when is_binary(value) do + case env_reference_name(value) do + {:ok, env_name} -> resolve_env_token(env_name) + :error -> value + end + end + + defp env_reference_name("$" <> env_name) do + if String.match?(env_name, ~r/^[A-Za-z_][A-Za-z0-9_]*$/) do + {:ok, env_name} + else + :error + end + end + + defp env_reference_name(_value), do: :error + + defp resolve_env_token(env_name) do + case System.get_env(env_name) do + nil -> :missing + env_value -> env_value + end + end + + defp normalize_secret_value(value) when is_binary(value) do + if value == "", do: nil, else: value + end + + defp normalize_secret_value(_value), do: nil + + defp default_turn_sandbox_policy(workspace) do + %{ + "type" => "workspaceWrite", + "writableRoots" => [workspace], + "readOnlyAccess" => %{"type" => "fullAccess"}, + "networkAccess" => false, + "excludeTmpdirEnvVar" => false, + "excludeSlashTmp" => false + } + end + + defp default_runtime_turn_sandbox_policy(workspace_root, opts) when is_binary(workspace_root) do + if Keyword.get(opts, :remote, false) do + {:ok, default_turn_sandbox_policy(workspace_root)} + else + with expanded_workspace_root <- expand_local_workspace_root(workspace_root), + {:ok, canonical_workspace_root} <- PathSafety.canonicalize(expanded_workspace_root) do + {:ok, default_turn_sandbox_policy(canonical_workspace_root)} + end + end + end + + defp default_runtime_turn_sandbox_policy(workspace_root, _opts) do + {:error, {:unsafe_turn_sandbox_policy, {:invalid_workspace_root, workspace_root}}} + end + + defp default_workspace_root(workspace, _fallback) when is_binary(workspace) and workspace != "", + do: workspace + + defp default_workspace_root(nil, fallback), do: fallback + defp default_workspace_root("", fallback), do: fallback + defp default_workspace_root(workspace, _fallback), do: workspace + + defp expand_local_workspace_root(workspace_root) + when is_binary(workspace_root) and workspace_root != "" do + Path.expand(workspace_root) + end + + defp expand_local_workspace_root(_workspace_root) do + Path.expand(Path.join(System.tmp_dir!(), "symphony_workspaces")) + end + + defp format_errors(changeset) do + changeset + |> traverse_errors(&translate_error/1) + |> flatten_errors() + |> Enum.join(", ") + end + + defp flatten_errors(errors, prefix \\ nil) + + defp flatten_errors(errors, prefix) when is_map(errors) do + Enum.flat_map(errors, fn {key, value} -> + next_prefix = + case prefix do + nil -> to_string(key) + current -> current <> "." <> to_string(key) + end + + flatten_errors(value, next_prefix) + end) + end + + defp flatten_errors(errors, prefix) when is_list(errors) do + Enum.map(errors, &(prefix <> " " <> &1)) + end + + defp translate_error({message, options}) do + Enum.reduce(options, message, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", error_value_to_string(value)) + end) + end + + defp error_value_to_string(value) when is_atom(value), do: Atom.to_string(value) + defp error_value_to_string(value), do: inspect(value) +end diff --git a/elixir/lib/symphony_elixir/http_server.ex b/elixir/lib/symphony_elixir/http_server.ex index 47686e934..5f947b880 100644 --- a/elixir/lib/symphony_elixir/http_server.ex +++ b/elixir/lib/symphony_elixir/http_server.ex @@ -20,7 +20,7 @@ defmodule SymphonyElixir.HttpServer do def start_link(opts \\ []) do case Keyword.get(opts, :port, Config.server_port()) do port when is_integer(port) and port >= 0 -> - host = Keyword.get(opts, :host, Config.server_host()) + host = Keyword.get(opts, :host, Config.settings!().server.host) orchestrator = Keyword.get(opts, :orchestrator, Orchestrator) snapshot_timeout_ms = Keyword.get(opts, :snapshot_timeout_ms, 15_000) diff --git a/elixir/lib/symphony_elixir/linear/adapter.ex b/elixir/lib/symphony_elixir/linear/adapter.ex index ab17eeec4..052768cfe 100644 --- a/elixir/lib/symphony_elixir/linear/adapter.ex +++ b/elixir/lib/symphony_elixir/linear/adapter.ex @@ -15,6 +15,14 @@ defmodule SymphonyElixir.Linear.Adapter do } """ + @update_description_mutation """ + mutation SymphonyUpdateIssueDescription($issueId: String!, $description: String!) { + issueUpdate(id: $issueId, input: {description: $description}) { + success + } + } + """ + @update_state_mutation """ mutation SymphonyUpdateIssueState($issueId: String!, $stateId: String!) { issueUpdate(id: $issueId, input: {stateId: $stateId}) { @@ -58,6 +66,23 @@ defmodule SymphonyElixir.Linear.Adapter do end end + @spec update_issue_description(String.t(), String.t()) :: :ok | {:error, term()} + def update_issue_description(issue_id, description) + when is_binary(issue_id) and is_binary(description) do + with {:ok, response} <- + client_module().graphql( + @update_description_mutation, + %{issueId: issue_id, description: description} + ), + true <- get_in(response, ["data", "issueUpdate", "success"]) == true do + :ok + else + false -> {:error, :issue_update_failed} + {:error, reason} -> {:error, reason} + _ -> {:error, :issue_update_failed} + end + end + @spec update_issue_state(String.t(), String.t()) :: :ok | {:error, term()} def update_issue_state(issue_id, state_name) when is_binary(issue_id) and is_binary(state_name) do diff --git a/elixir/lib/symphony_elixir/linear/client.ex b/elixir/lib/symphony_elixir/linear/client.ex index ad8eee550..1a1964966 100644 --- a/elixir/lib/symphony_elixir/linear/client.ex +++ b/elixir/lib/symphony_elixir/linear/client.ex @@ -105,10 +105,11 @@ defmodule SymphonyElixir.Linear.Client do @spec fetch_candidate_issues() :: {:ok, [Issue.t()]} | {:error, term()} def fetch_candidate_issues do - project_slug = Config.linear_project_slug() + tracker = Config.settings!().tracker + project_slug = tracker.project_slug cond do - is_nil(Config.linear_api_token()) -> + is_nil(tracker.api_key) -> {:error, :missing_linear_api_token} is_nil(project_slug) -> @@ -116,7 +117,7 @@ defmodule SymphonyElixir.Linear.Client do true -> with {:ok, assignee_filter} <- routing_assignee_filter() do - do_fetch_by_states(project_slug, Config.linear_active_states(), assignee_filter) + do_fetch_by_states(project_slug, tracker.active_states, assignee_filter) end end end @@ -128,10 +129,11 @@ defmodule SymphonyElixir.Linear.Client do if normalized_states == [] do {:ok, []} else - project_slug = Config.linear_project_slug() + tracker = Config.settings!().tracker + project_slug = tracker.project_slug cond do - is_nil(Config.linear_api_token()) -> + is_nil(tracker.api_key) -> {:error, :missing_linear_api_token} is_nil(project_slug) -> @@ -218,6 +220,22 @@ defmodule SymphonyElixir.Linear.Client do |> finalize_paginated_issues() end + @doc false + @spec fetch_issue_states_by_ids_for_test([String.t()], (String.t(), map() -> {:ok, map()} | {:error, term()})) :: + {:ok, [Issue.t()]} | {:error, term()} + def fetch_issue_states_by_ids_for_test(issue_ids, graphql_fun) + when is_list(issue_ids) and is_function(graphql_fun, 2) do + ids = Enum.uniq(issue_ids) + + case ids do + [] -> + {:ok, []} + + ids -> + do_fetch_issue_states(ids, nil, graphql_fun) + end + end + defp do_fetch_by_states(project_slug, state_names, assignee_filter) do do_fetch_by_states_page(project_slug, state_names, assignee_filter, nil, []) end @@ -254,19 +272,57 @@ defmodule SymphonyElixir.Linear.Client do defp finalize_paginated_issues(acc_issues) when is_list(acc_issues), do: Enum.reverse(acc_issues) defp do_fetch_issue_states(ids, assignee_filter) do - case graphql(@query_by_ids, %{ - ids: ids, - first: Enum.min([length(ids), @issue_page_size]), + do_fetch_issue_states(ids, assignee_filter, &graphql/2) + end + + defp do_fetch_issue_states(ids, assignee_filter, graphql_fun) + when is_list(ids) and is_function(graphql_fun, 2) do + issue_order_index = issue_order_index(ids) + do_fetch_issue_states_page(ids, assignee_filter, graphql_fun, [], issue_order_index) + end + + defp do_fetch_issue_states_page([], _assignee_filter, _graphql_fun, acc_issues, issue_order_index) do + acc_issues + |> finalize_paginated_issues() + |> sort_issues_by_requested_ids(issue_order_index) + |> then(&{:ok, &1}) + end + + defp do_fetch_issue_states_page(ids, assignee_filter, graphql_fun, acc_issues, issue_order_index) do + {batch_ids, rest_ids} = Enum.split(ids, @issue_page_size) + + case graphql_fun.(@query_by_ids, %{ + ids: batch_ids, + first: length(batch_ids), relationFirst: @issue_page_size }) do {:ok, body} -> - decode_linear_response(body, assignee_filter) + with {:ok, issues} <- decode_linear_response(body, assignee_filter) do + updated_acc = prepend_page_issues(issues, acc_issues) + do_fetch_issue_states_page(rest_ids, assignee_filter, graphql_fun, updated_acc, issue_order_index) + end {:error, reason} -> {:error, reason} end end + defp issue_order_index(ids) when is_list(ids) do + ids + |> Enum.with_index() + |> Map.new() + end + + defp sort_issues_by_requested_ids(issues, issue_order_index) + when is_list(issues) and is_map(issue_order_index) do + fallback_index = map_size(issue_order_index) + + Enum.sort_by(issues, fn + %Issue{id: issue_id} -> Map.get(issue_order_index, issue_id, fallback_index) + _ -> fallback_index + end) + end + defp build_graphql_payload(query, variables, operation_name) do %{ "query" => query, @@ -325,7 +381,7 @@ defmodule SymphonyElixir.Linear.Client do end defp graphql_headers do - case Config.linear_api_token() do + case Config.settings!().tracker.api_key do nil -> {:error, :missing_linear_api_token} @@ -339,7 +395,7 @@ defmodule SymphonyElixir.Linear.Client do end defp post_graphql_request(payload, headers) do - Req.post(Config.linear_endpoint(), + Req.post(Config.settings!().tracker.endpoint, headers: headers, json: payload, connect_options: [timeout: 30_000] @@ -432,7 +488,7 @@ defmodule SymphonyElixir.Linear.Client do defp assignee_id(%{} = assignee), do: normalize_assignee_match_value(assignee["id"]) defp routing_assignee_filter do - case Config.linear_assignee() do + case Config.settings!().tracker.assignee do nil -> {:ok, nil} diff --git a/elixir/lib/symphony_elixir/marker_parser.ex b/elixir/lib/symphony_elixir/marker_parser.ex new file mode 100644 index 000000000..124beb139 --- /dev/null +++ b/elixir/lib/symphony_elixir/marker_parser.ex @@ -0,0 +1,201 @@ +defmodule SymphonyElixir.MarkerParser do + @moduledoc false + + defmodule Marker do + @enforce_keys [:kind, :round_id, :stage_round, :reviewed_sha, :issue_identifier] + defstruct @enforce_keys ++ [:verdict, :findings, :docfix_outcome] + + @type t :: %__MODULE__{ + kind: :review_request | :code_review | :docs_checked, + round_id: pos_integer(), + stage_round: pos_integer(), + reviewed_sha: String.t(), + issue_identifier: String.t(), + verdict: :clean | :findings | nil, + findings: [map()] | nil, + docfix_outcome: :no_updates | :updated | nil + } + end + + @region_regex ~r/(.*?)/ms + @marker_regex ~r/^[ \t]*```symphony-marker[ \t]*\R(.*?)^[ \t]*```[ \t]*$/ms + @sha_regex ~r/^[0-9a-f]{40}$/ + @kinds %{ + "review-request" => :review_request, + "code-review" => :code_review, + "docs-checked" => :docs_checked + } + @verdicts %{"clean" => :clean, "findings" => :findings} + @docfix_outcomes %{"no-updates" => :no_updates, "updated" => :updated} + @severities %{"high" => :high, "medium" => :medium, "low" => :low} + + @spec parse(String.t(), String.t()) :: [Marker.t()] + def parse(workpad, issue_identifier) when is_binary(workpad) and is_binary(issue_identifier) do + case Regex.run(@region_regex, workpad, capture: :all_but_first) do + [region] -> + Regex.scan(@marker_regex, region, capture: :all_but_first) + |> Enum.reduce([], fn [yaml], acc -> + case parse_marker(yaml) do + %Marker{issue_identifier: ^issue_identifier} = marker -> [marker | acc] + _ -> acc + end + end) + |> Enum.reverse() + + _ -> + [] + end + end + + @spec review_pending?([Marker.t()]) :: boolean() + def review_pending?(markers) do + case current_round(markers) + |> Enum.filter(&(&1.kind in [:review_request, :code_review])) + |> List.last() do + %Marker{kind: :review_request} -> true + _ -> false + end + end + + @spec latest_code_review([Marker.t()]) :: Marker.t() | nil + def latest_code_review(markers) do + current_round(markers) + |> Enum.filter(&match?(%Marker{kind: :code_review}, &1)) + |> List.last() + end + + @spec latest_review_sha([Marker.t()]) :: String.t() | nil + def latest_review_sha(markers) do + case latest_code_review(markers) do + %Marker{verdict: :clean, reviewed_sha: reviewed_sha} -> reviewed_sha + _ -> nil + end + end + + @spec docs_checked_matches_review?([Marker.t()]) :: boolean() + def docs_checked_matches_review?(markers) do + case latest_review_sha(markers) do + nil -> + false + + reviewed_sha -> + case current_round(markers) |> Enum.filter(&(&1.kind == :docs_checked)) |> List.last() do + %Marker{reviewed_sha: ^reviewed_sha} -> true + _ -> false + end + end + end + + defp parse_marker(yaml) do + with {:ok, decoded} <- YamlElixir.read_from_string(yaml), + true <- is_map(decoded), + {:ok, common} <- parse_common(decoded), + {:ok, kind_fields} <- parse_kind_fields(decoded, common.kind) do + struct(Marker, Map.merge(common, kind_fields)) + else + _ -> nil + end + end + + defp parse_common(decoded) do + with {:ok, kind} <- enum(@kinds, field(decoded, "kind")), + {:ok, round_id} <- positive_integer(field(decoded, "round_id")), + {:ok, stage_round} <- positive_integer(field(decoded, "stage_round")), + {:ok, reviewed_sha} <- sha(field(decoded, "reviewed_sha")), + {:ok, issue_identifier} <- non_empty(field(decoded, "issue_identifier")) do + {:ok, + %{ + kind: kind, + round_id: round_id, + stage_round: stage_round, + reviewed_sha: reviewed_sha, + issue_identifier: issue_identifier + }} + else + _ -> :error + end + end + + defp parse_kind_fields(_decoded, :review_request), do: {:ok, %{}} + + defp parse_kind_fields(decoded, :code_review) do + with {:ok, verdict} <- enum(@verdicts, field(decoded, "verdict")) do + # findings is optional + informational; malformed findings must not drop the marker + findings = + case parse_findings(field(decoded, "findings")) do + {:ok, parsed} -> parsed + :error -> nil + end + + {:ok, %{verdict: verdict, findings: findings}} + else + _ -> :error + end + end + + defp parse_kind_fields(decoded, :docs_checked) do + with {:ok, docfix_outcome} <- enum(@docfix_outcomes, field(decoded, "docfix_outcome")) do + {:ok, %{docfix_outcome: docfix_outcome}} + else + _ -> :error + end + end + + defp parse_findings(nil), do: {:ok, nil} + + defp parse_findings(findings) when is_list(findings) do + Enum.reduce_while(findings, {:ok, []}, fn finding, {:ok, acc} -> + with true <- is_map(finding), + {:ok, severity} <- enum(@severities, field(finding, "severity")), + summary when is_binary(summary) <- field(finding, "summary") do + {:cont, {:ok, [%{severity: severity, summary: summary} | acc]}} + else + _ -> {:halt, :error} + end + end) + |> case do + {:ok, parsed} -> {:ok, Enum.reverse(parsed)} + _ -> :error + end + end + + defp parse_findings(_), do: :error + + defp current_round(markers) do + current_round = markers |> Enum.map(& &1.round_id) |> Enum.max(fn -> 0 end) + Enum.filter(markers, &(&1.round_id == current_round)) + end + + defp field(map, key) do + Map.get(map, key) || + Enum.find_value(map, fn + {atom_key, value} when is_atom(atom_key) -> + if Atom.to_string(atom_key) == key, do: value + + _ -> + nil + end) + end + + defp enum(mapping, value) do + case mapping[value] do + nil -> :error + parsed -> {:ok, parsed} + end + end + + defp positive_integer(value) when is_integer(value) and value >= 1, do: {:ok, value} + defp positive_integer(_), do: :error + + defp sha(value) when is_binary(value) do + if String.match?(value, @sha_regex), do: {:ok, value}, else: :error + end + + defp sha(_), do: :error + + defp non_empty(value) when is_binary(value) do + if String.trim(value) != "", do: {:ok, value}, else: :error + end + + defp non_empty(_), do: :error +end diff --git a/elixir/lib/symphony_elixir/orchestrator.ex b/elixir/lib/symphony_elixir/orchestrator.ex index a4dead129..0194ae71d 100644 --- a/elixir/lib/symphony_elixir/orchestrator.ex +++ b/elixir/lib/symphony_elixir/orchestrator.ex @@ -7,13 +7,25 @@ defmodule SymphonyElixir.Orchestrator do require Logger import Bitwise, only: [<<<: 2] - alias SymphonyElixir.{AgentRunner, Config, StatusDashboard, Tracker, Workspace} + alias SymphonyElixir.{ + AgentRunner, + Config, + MarkerParser, + StageCloseout, + StageOrchestrator, + StatusDashboard, + Tracker, + Workflow, + 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 + @marker_region_regex ~r/()(.*?)()/ms @empty_codex_totals %{ input_tokens: 0, output_tokens: 0, @@ -31,6 +43,8 @@ defmodule SymphonyElixir.Orchestrator do :max_concurrent_agents, :next_poll_due_at_ms, :poll_check_in_progress, + :tick_timer_ref, + :tick_token, running: %{}, completed: MapSet.new(), claimed: MapSet.new(), @@ -49,26 +63,55 @@ defmodule SymphonyElixir.Orchestrator do @impl true def init(_opts) do now_ms = System.monotonic_time(:millisecond) + config = Config.settings!() state = %State{ - poll_interval_ms: Config.poll_interval_ms(), - max_concurrent_agents: Config.max_concurrent_agents(), + poll_interval_ms: config.polling.interval_ms, + max_concurrent_agents: config.agent.max_concurrent_agents, next_poll_due_at_ms: now_ms, poll_check_in_progress: false, + tick_timer_ref: nil, + tick_token: nil, codex_totals: @empty_codex_totals, codex_rate_limits: nil } run_terminal_workspace_cleanup() - :ok = schedule_tick(0) + state = schedule_tick(state, 0) {:ok, state} end @impl true + def handle_info({:tick, tick_token}, %{tick_token: tick_token} = state) + when is_reference(tick_token) do + state = refresh_runtime_config(state) + + state = %{ + state + | poll_check_in_progress: true, + next_poll_due_at_ms: nil, + tick_timer_ref: nil, + tick_token: nil + } + + notify_dashboard() + :ok = schedule_poll_cycle_start() + {:noreply, state} + end + + def handle_info({:tick, _tick_token}, state), do: {:noreply, state} + def handle_info(:tick, state) do state = refresh_runtime_config(state) - state = %{state | poll_check_in_progress: true, next_poll_due_at_ms: nil} + + state = %{ + state + | poll_check_in_progress: true, + next_poll_due_at_ms: nil, + tick_timer_ref: nil, + tick_token: nil + } notify_dashboard() :ok = schedule_poll_cycle_start() @@ -78,11 +121,8 @@ defmodule SymphonyElixir.Orchestrator do def handle_info(:run_poll_cycle, state) do state = refresh_runtime_config(state) state = maybe_dispatch(state) - now_ms = System.monotonic_time(:millisecond) - next_poll_due_at_ms = now_ms + state.poll_interval_ms - :ok = schedule_tick(state.poll_interval_ms) - - state = %{state | poll_check_in_progress: false, next_poll_due_at_ms: next_poll_due_at_ms} + state = schedule_tick(state, state.poll_interval_ms) + state = %{state | poll_check_in_progress: false} notify_dashboard() {:noreply, state} @@ -105,12 +145,15 @@ defmodule SymphonyElixir.Orchestrator do case reason do :normal -> Logger.info("Agent task completed for issue_id=#{issue_id} session_id=#{session_id}; scheduling active-state continuation check") + maybe_handle_stage_completion(running_entry) state |> complete_issue(issue_id) |> schedule_issue_retry(issue_id, 1, %{ identifier: running_entry.identifier, - delay_type: :continuation + delay_type: :continuation, + worker_host: Map.get(running_entry, :worker_host), + workspace_path: Map.get(running_entry, :workspace_path) }) _ -> @@ -120,7 +163,9 @@ defmodule SymphonyElixir.Orchestrator do schedule_issue_retry(state, issue_id, next_attempt, %{ identifier: running_entry.identifier, - error: "agent exited: #{inspect(reason)}" + error: "agent exited: #{inspect(reason)}", + worker_host: Map.get(running_entry, :worker_host), + workspace_path: Map.get(running_entry, :workspace_path) }) end @@ -131,6 +176,23 @@ defmodule SymphonyElixir.Orchestrator do end end + def handle_info({:worker_runtime_info, issue_id, runtime_info}, %{running: running} = state) + when is_binary(issue_id) and is_map(runtime_info) do + case Map.get(running, issue_id) do + nil -> + {:noreply, state} + + running_entry -> + updated_running_entry = + running_entry + |> maybe_put_runtime_value(:worker_host, runtime_info[:worker_host]) + |> maybe_put_runtime_value(:workspace_path, runtime_info[:workspace_path]) + + notify_dashboard() + {:noreply, %{state | running: Map.put(running, issue_id, updated_running_entry)}} + end + end + def handle_info( {:codex_worker_update, issue_id, %{event: _, timestamp: _} = update}, %{running: running} = state @@ -154,9 +216,9 @@ defmodule SymphonyElixir.Orchestrator do def handle_info({:codex_worker_update, _issue_id, _update}, state), do: {:noreply, state} - def handle_info({:retry_issue, issue_id}, state) do + def handle_info({:retry_issue, issue_id, retry_token}, state) do result = - case pop_retry_attempt_state(state, issue_id) do + case pop_retry_attempt_state(state, issue_id, retry_token) do {:ok, attempt, metadata, state} -> handle_retry_issue(state, issue_id, attempt, metadata) :missing -> {:noreply, state} end @@ -165,6 +227,8 @@ defmodule SymphonyElixir.Orchestrator do result end + def handle_info({:retry_issue, _issue_id}, state), do: {:noreply, state} + def handle_info(msg, state) do Logger.debug("Orchestrator ignored message: #{inspect(msg)}") {:noreply, state} @@ -196,20 +260,8 @@ defmodule SymphonyElixir.Orchestrator do state - {:error, :missing_codex_command} -> - Logger.error("Codex command missing in WORKFLOW.md") - state - - {:error, {:invalid_codex_approval_policy, value}} -> - Logger.error("Invalid codex.approval_policy in WORKFLOW.md: #{inspect(value)}") - state - - {:error, {:invalid_codex_thread_sandbox, value}} -> - Logger.error("Invalid codex.thread_sandbox in WORKFLOW.md: #{inspect(value)}") - state - - {:error, {:invalid_codex_turn_sandbox_policy, reason}} -> - Logger.error("Invalid codex.turn_sandbox_policy in WORKFLOW.md: #{inspect(reason)}") + {:error, {:invalid_workflow_config, message}} -> + Logger.error("Invalid WORKFLOW.md config: #{message}") state {:error, {:missing_workflow_file, path, reason}} -> @@ -242,12 +294,13 @@ defmodule SymphonyElixir.Orchestrator do else case Tracker.fetch_issue_states_by_ids(running_ids) do {:ok, issues} -> - reconcile_running_issue_states( - issues, + issues + |> reconcile_running_issue_states( state, active_state_set(), terminal_state_set() ) + |> reconcile_missing_running_issue_ids(running_ids, issues) {:error, reason} -> Logger.debug("Failed to refresh running issue states: #{inspect(reason)}; keeping active workers") @@ -287,6 +340,12 @@ defmodule SymphonyElixir.Orchestrator do sort_issues_for_dispatch(issues) end + @doc false + @spec select_worker_host_for_test(term(), String.t() | nil) :: String.t() | nil | :no_worker_capacity + def select_worker_host_for_test(%State{} = state, preferred_worker_host) do + select_worker_host(state, preferred_worker_host) + end + defp reconcile_running_issue_states([], state, _active_states, _terminal_states), do: state defp reconcile_running_issue_states([issue | rest], state, active_states, terminal_states) do @@ -322,6 +381,40 @@ defmodule SymphonyElixir.Orchestrator do defp reconcile_issue_state(_issue, state, _active_states, _terminal_states), do: state + defp reconcile_missing_running_issue_ids(%State{} = state, requested_issue_ids, issues) + when is_list(requested_issue_ids) and is_list(issues) do + visible_issue_ids = + issues + |> Enum.flat_map(fn + %Issue{id: issue_id} when is_binary(issue_id) -> [issue_id] + _ -> [] + end) + |> MapSet.new() + + Enum.reduce(requested_issue_ids, state, fn issue_id, state_acc -> + if MapSet.member?(visible_issue_ids, issue_id) do + state_acc + else + log_missing_running_issue(state_acc, issue_id) + terminate_running_issue(state_acc, issue_id, false) + end + end) + end + + defp reconcile_missing_running_issue_ids(state, _requested_issue_ids, _issues), do: state + + defp log_missing_running_issue(%State{} = state, issue_id) when is_binary(issue_id) do + case Map.get(state.running, issue_id) do + %{identifier: identifier} -> + Logger.info("Issue no longer visible during running-state refresh: issue_id=#{issue_id} issue_identifier=#{identifier}; stopping active agent") + + _ -> + Logger.info("Issue no longer visible during running-state refresh: issue_id=#{issue_id}; stopping active agent") + end + end + + defp log_missing_running_issue(_state, _issue_id), do: :ok + defp refresh_running_issue_state(%State{} = state, %Issue{} = issue) do case Map.get(state.running, issue.id) do %{issue: _} = running_entry -> @@ -339,9 +432,10 @@ defmodule SymphonyElixir.Orchestrator do %{pid: pid, ref: ref, identifier: identifier} = running_entry -> state = record_session_completion_totals(state, running_entry) + worker_host = Map.get(running_entry, :worker_host) if cleanup_workspace do - cleanup_issue_workspace(identifier) + cleanup_issue_workspace(identifier, worker_host) end if is_pid(pid) do @@ -365,7 +459,7 @@ defmodule SymphonyElixir.Orchestrator do end defp reconcile_stalled_running_issues(%State{} = state) do - timeout_ms = Config.codex_stall_timeout_ms() + timeout_ms = Config.settings!().codex.stall_timeout_ms cond do timeout_ms <= 0 -> @@ -481,7 +575,8 @@ defmodule SymphonyElixir.Orchestrator do !MapSet.member?(claimed, issue.id) and !Map.has_key?(running, issue.id) and available_slots(state) > 0 and - state_slots_available?(issue, running) + state_slots_available?(issue, running) and + worker_slots_available?(state) end defp should_dispatch_issue?(_issue, _state, _active_states, _terminal_states), do: false @@ -562,23 +657,23 @@ defmodule SymphonyElixir.Orchestrator do end defp terminal_state_set do - Config.linear_terminal_states() + Config.settings!().tracker.terminal_states |> Enum.map(&normalize_issue_state/1) |> Enum.filter(&(&1 != "")) |> MapSet.new() end defp active_state_set do - Config.linear_active_states() + Config.settings!().tracker.active_states |> Enum.map(&normalize_issue_state/1) |> Enum.filter(&(&1 != "")) |> MapSet.new() end - defp dispatch_issue(%State{} = state, issue, attempt \\ nil) do + defp dispatch_issue(%State{} = state, issue, attempt \\ nil, preferred_worker_host \\ nil) do case revalidate_issue_for_dispatch(issue, &Tracker.fetch_issue_states_by_ids/1, terminal_state_set()) do {:ok, %Issue{} = refreshed_issue} -> - do_dispatch_issue(state, refreshed_issue, attempt) + do_dispatch_issue(state, refreshed_issue, attempt, preferred_worker_host) {:skip, :missing} -> Logger.info("Skipping dispatch; issue no longer active or visible: #{issue_context(issue)}") @@ -595,57 +690,113 @@ defmodule SymphonyElixir.Orchestrator do end end - defp do_dispatch_issue(%State{} = state, issue, attempt) do + defp do_dispatch_issue(%State{} = state, issue, attempt, preferred_worker_host) do recipient = self() - case Task.Supervisor.start_child(SymphonyElixir.TaskSupervisor, fn -> - AgentRunner.run(issue, recipient, attempt: attempt) - end) do - {:ok, pid} -> - ref = Process.monitor(pid) - - Logger.info("Dispatching issue to agent: #{issue_context(issue)} pid=#{inspect(pid)} attempt=#{inspect(attempt)}") - - running = - Map.put(state.running, issue.id, %{ - pid: pid, - ref: ref, - identifier: issue.identifier, - issue: issue, - session_id: nil, - last_codex_message: nil, - last_codex_timestamp: nil, - last_codex_event: nil, - codex_app_server_pid: nil, - codex_input_tokens: 0, - codex_output_tokens: 0, - codex_total_tokens: 0, - codex_last_reported_input_tokens: 0, - codex_last_reported_output_tokens: 0, - codex_last_reported_total_tokens: 0, - turn_count: 0, - retry_attempt: normalize_retry_attempt(attempt), - started_at: DateTime.utc_now() - }) + case select_worker_host(state, preferred_worker_host) do + :no_worker_capacity -> + Logger.debug("No SSH worker slots available for #{issue_context(issue)} preferred_worker_host=#{inspect(preferred_worker_host)}") + state - %{ - state - | running: running, - claimed: MapSet.put(state.claimed, issue.id), - retry_attempts: Map.delete(state.retry_attempts, issue.id) - } + worker_host -> + spawn_issue_on_worker_host(state, issue, attempt, recipient, worker_host) + end + end + + defp spawn_issue_on_worker_host(%State{} = state, issue, attempt, recipient, worker_host) do + case stage_dispatch_context(issue, worker_host) do + {:ok, stage, workspace_path, dispatch_head_sha} -> + case Task.Supervisor.start_child(SymphonyElixir.TaskSupervisor, fn -> + agent_runner_module().run( + issue, + recipient, + Keyword.merge( + [attempt: attempt, worker_host: worker_host], + stage_dispatch_opts(stage) + ) + ) + end) do + {:ok, pid} -> + ref = Process.monitor(pid) + + Logger.info("Dispatching issue to agent: #{issue_context(issue)} pid=#{inspect(pid)} attempt=#{inspect(attempt)} worker_host=#{worker_host || "local"} stage=#{stage}") + + running = + Map.put(state.running, issue.id, %{ + pid: pid, + ref: ref, + identifier: issue.identifier, + issue: issue, + stage: stage, + dispatch_head_sha: dispatch_head_sha, + worker_host: worker_host, + workspace_path: workspace_path, + session_id: nil, + last_codex_message: nil, + last_codex_timestamp: nil, + last_codex_event: nil, + codex_app_server_pid: nil, + codex_input_tokens: 0, + codex_output_tokens: 0, + codex_total_tokens: 0, + codex_last_reported_input_tokens: 0, + codex_last_reported_output_tokens: 0, + codex_last_reported_total_tokens: 0, + turn_count: 0, + retry_attempt: normalize_retry_attempt(attempt), + started_at: DateTime.utc_now() + }) + + %{ + state + | running: running, + claimed: MapSet.put(state.claimed, issue.id), + retry_attempts: Map.delete(state.retry_attempts, issue.id) + } + + {:error, reason} -> + Logger.error("Unable to spawn agent for #{issue_context(issue)}: #{inspect(reason)}") + next_attempt = if is_integer(attempt), do: attempt + 1, else: nil + + schedule_issue_retry(state, issue.id, next_attempt, %{ + identifier: issue.identifier, + error: "failed to spawn agent: #{inspect(reason)}", + worker_host: worker_host, + workspace_path: workspace_path + }) + end + + :stop -> + Logger.info("Skipping dispatch after stage evaluation: #{issue_context(issue)}") + state {:error, reason} -> - Logger.error("Unable to spawn agent for #{issue_context(issue)}: #{inspect(reason)}") + Logger.error("Unable to prepare stage dispatch for #{issue_context(issue)}: #{inspect(reason)}") next_attempt = if is_integer(attempt), do: attempt + 1, else: nil schedule_issue_retry(state, issue.id, next_attempt, %{ identifier: issue.identifier, - error: "failed to spawn agent: #{inspect(reason)}" + error: "failed to prepare stage dispatch: #{inspect(reason)}", + worker_host: worker_host }) end end + defp stage_dispatch_context(%Issue{} = issue, worker_host) do + with {:ok, workspace_path} <- Workspace.path_for_issue(issue, worker_host) do + stage = stage_orchestrator_module().next_stage(issue.description, issue.state, workspace_path) + + case stage do + :stop -> + :stop + + _ -> + dispatch_head_sha = if stage == :review, do: workspace_head(workspace_path), else: nil + {:ok, stage, workspace_path, dispatch_head_sha} + end + end + end + defp revalidate_issue_for_dispatch(%Issue{id: issue_id}, issue_fetcher, terminal_states) when is_binary(issue_id) and is_function(issue_fetcher, 1) do case issue_fetcher.([issue_id]) do @@ -680,15 +831,18 @@ defmodule SymphonyElixir.Orchestrator do next_attempt = if is_integer(attempt), do: attempt, else: previous_retry.attempt + 1 delay_ms = retry_delay(next_attempt, metadata) old_timer = Map.get(previous_retry, :timer_ref) + retry_token = make_ref() due_at_ms = System.monotonic_time(:millisecond) + delay_ms identifier = pick_retry_identifier(issue_id, previous_retry, metadata) error = pick_retry_error(previous_retry, metadata) + worker_host = pick_retry_worker_host(previous_retry, metadata) + workspace_path = pick_retry_workspace_path(previous_retry, metadata) if is_reference(old_timer) do Process.cancel_timer(old_timer) end - timer_ref = Process.send_after(self(), {:retry_issue, issue_id}, delay_ms) + timer_ref = Process.send_after(self(), {:retry_issue, issue_id, retry_token}, delay_ms) error_suffix = if is_binary(error), do: " error=#{error}", else: "" @@ -700,19 +854,24 @@ defmodule SymphonyElixir.Orchestrator do Map.put(state.retry_attempts, issue_id, %{ attempt: next_attempt, timer_ref: timer_ref, + retry_token: retry_token, due_at_ms: due_at_ms, identifier: identifier, - error: error + error: error, + worker_host: worker_host, + workspace_path: workspace_path }) } end - defp pop_retry_attempt_state(%State{} = state, issue_id) do + defp pop_retry_attempt_state(%State{} = state, issue_id, retry_token) when is_reference(retry_token) do case Map.get(state.retry_attempts, issue_id) do - %{attempt: attempt} = retry_entry -> + %{attempt: attempt, retry_token: ^retry_token} = retry_entry -> metadata = %{ identifier: Map.get(retry_entry, :identifier), - error: Map.get(retry_entry, :error) + error: Map.get(retry_entry, :error), + worker_host: Map.get(retry_entry, :worker_host), + workspace_path: Map.get(retry_entry, :workspace_path) } {:ok, attempt, metadata, %{state | retry_attempts: Map.delete(state.retry_attempts, issue_id)}} @@ -749,7 +908,7 @@ defmodule SymphonyElixir.Orchestrator do terminal_issue_state?(issue.state, terminal_states) -> Logger.info("Issue state is terminal: issue_id=#{issue_id} issue_identifier=#{issue.identifier} state=#{issue.state}; removing associated workspace") - cleanup_issue_workspace(issue.identifier) + cleanup_issue_workspace(issue.identifier, metadata[:worker_host]) {:noreply, release_issue_claim(state, issue_id)} retry_candidate_issue?(issue, terminal_states) -> @@ -767,14 +926,16 @@ defmodule SymphonyElixir.Orchestrator do {:noreply, release_issue_claim(state, issue_id)} end - defp cleanup_issue_workspace(identifier) when is_binary(identifier) do - Workspace.remove_issue_workspaces(identifier) + defp cleanup_issue_workspace(identifier, worker_host \\ nil) + + defp cleanup_issue_workspace(identifier, worker_host) when is_binary(identifier) do + Workspace.remove_issue_workspaces(identifier, worker_host) end - defp cleanup_issue_workspace(_identifier), do: :ok + defp cleanup_issue_workspace(_identifier, _worker_host), do: :ok defp run_terminal_workspace_cleanup do - case Tracker.fetch_issues_by_states(Config.linear_terminal_states()) do + case Tracker.fetch_issues_by_states(Config.settings!().tracker.terminal_states) do {:ok, issues} -> issues |> Enum.each(fn @@ -794,10 +955,289 @@ defmodule SymphonyElixir.Orchestrator do StatusDashboard.notify_update() end + defp maybe_handle_stage_completion(running_entry) when is_map(running_entry) do + case run_stage_closeout(running_entry) do + {:ok, issue, stage} -> + maybe_handle_implement_handoff(issue, running_entry, stage) + + {:error, issue, stage, reason} -> + Logger.warning("Stage closeout failed for #{issue_context(issue)} stage=#{stage} reason=#{inspect(reason)}; moving issue to Rework") + move_issue_to_rework(issue, stage, reason) + end + end + + defp maybe_handle_stage_completion(_running_entry), do: :ok + + defp maybe_handle_implement_handoff(%Issue{} = issue, running_entry, :implement) do + workspace_path = Map.get(running_entry, :workspace_path) + worker_host = Map.get(running_entry, :worker_host) + issue_identifier = issue.identifier || Map.get(running_entry, :identifier) + workpad_text = issue.description || "" + head = workspace_head(workspace_path) + markers = if is_binary(issue_identifier), do: MarkerParser.parse(workpad_text, issue_identifier), else: [] + + cond do + !is_binary(issue.id) or !is_binary(issue_identifier) or !is_binary(workspace_path) -> + :ok + + is_binary(issue.state) and normalize_issue_state(issue.state) == "rework" -> + :ok + + !active_issue_state?(issue.state, active_state_set()) -> + :ok + + implement_handoff_ready?(markers, head) -> + :ok + + MarkerParser.review_pending?(markers) -> + :ok + + true -> + case Workspace.run_after_implement_hook(workspace_path, issue, worker_host) do + :skip -> + :ok + + :ok -> + maybe_append_review_request(issue, workpad_text, issue_identifier, workspace_path, markers) + + {:error, reason} -> + append_implement_handoff_narration(issue, reason) + end + end + end + + defp maybe_handle_implement_handoff(_issue, _running_entry, _stage), do: :ok + + defp maybe_append_review_request(%Issue{id: issue_id} = issue, workpad_text, issue_identifier, workspace_path, markers) + when is_binary(issue_id) and is_binary(issue_identifier) and is_binary(workspace_path) do + head = workspace_head(workspace_path) + + with head when is_binary(head) <- head, + :ok <- ensure_clean_workspace(workspace_path), + {:ok, updated_workpad} <- append_review_request_marker(workpad_text, issue_identifier, head, markers) do + case Tracker.update_issue_description(issue_id, updated_workpad) do + :ok -> + :ok + + {:error, tracker_reason} -> + Logger.warning("Failed to append review-request marker to workpad: #{inspect(tracker_reason)}") + end + else + nil -> + append_implement_handoff_narration(issue, :missing_head) + + {:error, reason} -> + append_implement_handoff_narration(issue, reason) + end + end + + defp maybe_append_review_request(_issue, _workpad_text, _issue_identifier, _workspace_path, _markers), do: :ok + + defp implement_handoff_ready?(markers, head) when is_binary(head) do + MarkerParser.docs_checked_matches_review?(markers) and MarkerParser.latest_review_sha(markers) == head + end + + defp implement_handoff_ready?(_markers, _head), do: false + + defp ensure_clean_workspace(workspace_path) when is_binary(workspace_path) do + case System.cmd("git", ["status", "--porcelain"], cd: workspace_path, stderr_to_stdout: true) do + {output, 0} -> + dirty_lines = + output + |> String.split("\n", trim: true) + + if dirty_lines == [], do: :ok, else: {:error, {:working_tree_dirty, dirty_lines}} + + {output, status} -> + {:error, {:git_status_failed, status, String.trim(output)}} + end + end + + defp ensure_clean_workspace(_workspace_path), do: {:error, :missing_workspace_path} + + defp append_review_request_marker(workpad_text, issue_identifier, reviewed_sha, markers) + when is_binary(workpad_text) and is_binary(issue_identifier) and is_binary(reviewed_sha) do + round_id = markers |> Enum.map(& &1.round_id) |> Enum.max(fn -> 0 end) |> max(1) + + stage_round = + markers + |> Enum.filter(&(&1.round_id == round_id and &1.kind == :review_request)) + |> Enum.map(& &1.stage_round) + |> Enum.max(fn -> 0 end) + |> Kernel.+(1) + + marker = + """ + ```symphony-marker + kind: review-request + round_id: #{round_id} + stage_round: #{stage_round} + reviewed_sha: #{reviewed_sha} + issue_identifier: #{issue_identifier} + ``` + """ + |> String.trim_trailing() + + case Regex.run(@marker_region_regex, workpad_text, capture: :all_but_first) do + [begin_marker, body, end_marker] -> + trimmed_body = String.trim(body) + + updated_body = + case trimmed_body do + "" -> "\n" <> marker <> "\n" + _ -> "\n" <> trimmed_body <> "\n\n" <> marker <> "\n" + end + + {:ok, + Regex.replace( + @marker_region_regex, + workpad_text, + begin_marker <> updated_body <> end_marker, + global: false + )} + + _ -> + {:error, :missing_marker_region} + end + end + + defp append_review_request_marker(_workpad_text, _issue_identifier, _reviewed_sha, _markers), + do: {:error, :invalid_review_request_marker} + + defp append_implement_handoff_narration(%Issue{id: issue_id} = issue, reason) when is_binary(issue_id) do + narration = implement_handoff_narration(reason) + + case Tracker.update_issue_description(issue_id, append_closeout_narration(issue.description, narration)) do + :ok -> + :ok + + {:error, tracker_reason} -> + Logger.warning("Failed to append implement handoff narration to workpad: #{inspect(tracker_reason)}") + end + end + + defp append_implement_handoff_narration(_issue, _reason), do: :ok + + defp implement_handoff_narration(reason) do + """ + Symphony implement handoff blocked + stage: implement + reason: #{inspect(reason)} + """ + |> String.trim_trailing() + end + + defp run_stage_closeout(running_entry) do + issue = refresh_issue_for_closeout(running_entry) + stage = Map.get(running_entry, :stage, :implement) + workspace_path = Map.get(running_entry, :workspace_path) + issue_identifier = issue.identifier || Map.get(running_entry, :identifier) + workpad_text = issue.description || "" + + result = + case stage do + :review -> + stage_closeout_module().check_review( + workspace_path, + workpad_text, + Map.get(running_entry, :dispatch_head_sha), + issue_identifier + ) + + :doc_fix -> + stage_closeout_module().check_doc_fix(workspace_path, workpad_text, issue_identifier) + + _ -> + stage_closeout_module().check_implement(workspace_path, workpad_text, issue_identifier) + end + + case result do + :ok -> {:ok, issue, stage} + {:error, reason} -> {:error, issue, stage, reason} + end + end + + defp refresh_issue_for_closeout(%{issue: %Issue{id: issue_id} = issue}) + when is_binary(issue_id) do + case Tracker.fetch_issue_states_by_ids([issue_id]) do + {:ok, [%Issue{} = refreshed_issue | _]} -> refreshed_issue + _ -> issue + end + end + + defp refresh_issue_for_closeout(%{issue: %Issue{} = issue}), do: issue + defp refresh_issue_for_closeout(_running_entry), do: %Issue{} + + defp move_issue_to_rework(%Issue{id: issue_id} = issue, stage, reason) when is_binary(issue_id) do + narration = closeout_failure_narration(stage, reason) + + case Tracker.update_issue_state(issue_id, "Rework") do + :ok -> + :ok + + {:error, tracker_reason} -> + Logger.warning("Failed to move issue to Rework after closeout failure: #{inspect(tracker_reason)}") + end + + case Tracker.update_issue_description(issue_id, append_closeout_narration(issue.description, narration)) do + :ok -> + :ok + + {:error, tracker_reason} -> + Logger.warning("Failed to append closeout narration to workpad: #{inspect(tracker_reason)}") + end + end + + defp move_issue_to_rework(_issue, _stage, _reason), do: :ok + + defp closeout_failure_narration(stage, reason) do + """ + Symphony closeout failure + stage: #{stage} + gate_failed: #{reason |> elem(0) |> to_string()} + reason: #{inspect(reason)} + """ + |> String.trim_trailing() + end + + defp append_closeout_narration(description, narration) when is_binary(description) do + suffix = + case String.trim_trailing(description) do + "" -> narration + trimmed -> trimmed <> "\n\n" <> narration + end + + suffix <> "\n" + end + + defp append_closeout_narration(_description, narration), do: narration <> "\n" + + defp workspace_head(workspace_path) when is_binary(workspace_path) do + case System.cmd("git", ["rev-parse", "HEAD"], cd: workspace_path, stderr_to_stdout: true) do + {head, 0} -> String.trim(head) + _ -> nil + end + end + + defp workspace_head(_workspace_path), do: nil + + defp stage_dispatch_opts(:implement) do + [workflow_path: Workflow.workflow_file_path(), max_turns: 1] + end + + defp stage_dispatch_opts(:review) do + [workflow_path: Path.join(Path.dirname(Workflow.workflow_file_path()), "WORKFLOW-review.md"), max_turns: 1] + end + + defp stage_dispatch_opts(:doc_fix) do + [workflow_path: Path.join(Path.dirname(Workflow.workflow_file_path()), "WORKFLOW-docfix.md"), max_turns: 1] + end + defp handle_active_retry(state, issue, attempt, metadata) do if retry_candidate_issue?(issue, terminal_state_set()) and - dispatch_slots_available?(issue, state) do - {:noreply, dispatch_issue(state, issue, attempt)} + dispatch_slots_available?(issue, state) and + worker_slots_available?(state, metadata[:worker_host]) do + {:noreply, dispatch_issue(state, issue, attempt, metadata[:worker_host])} else Logger.debug("No available slots for retrying #{issue_context(issue)}; retrying again") @@ -828,7 +1268,7 @@ defmodule SymphonyElixir.Orchestrator do defp failure_retry_delay(attempt) do max_delay_power = min(attempt - 1, 10) - min(@failure_retry_base_ms * (1 <<< max_delay_power), Config.max_retry_backoff_ms()) + min(@failure_retry_base_ms * (1 <<< max_delay_power), Config.settings!().agent.max_retry_backoff_ms) end defp normalize_retry_attempt(attempt) when is_integer(attempt) and attempt > 0, do: attempt @@ -849,6 +1289,94 @@ defmodule SymphonyElixir.Orchestrator do metadata[:error] || Map.get(previous_retry, :error) end + defp pick_retry_worker_host(previous_retry, metadata) do + metadata[:worker_host] || Map.get(previous_retry, :worker_host) + end + + defp pick_retry_workspace_path(previous_retry, metadata) do + metadata[:workspace_path] || Map.get(previous_retry, :workspace_path) + end + + defp stage_orchestrator_module do + Application.get_env(:symphony_elixir, :stage_orchestrator_module, StageOrchestrator) + end + + defp stage_closeout_module do + Application.get_env(:symphony_elixir, :stage_closeout_module, StageCloseout) + end + + defp agent_runner_module do + Application.get_env(:symphony_elixir, :orchestrator_agent_runner_module, AgentRunner) + end + + defp maybe_put_runtime_value(running_entry, _key, nil), do: running_entry + + defp maybe_put_runtime_value(running_entry, key, value) when is_map(running_entry) do + Map.put(running_entry, key, value) + end + + defp select_worker_host(%State{} = state, preferred_worker_host) do + case Config.settings!().worker.ssh_hosts do + [] -> + nil + + hosts -> + available_hosts = Enum.filter(hosts, &worker_host_slots_available?(state, &1)) + + cond do + available_hosts == [] -> + :no_worker_capacity + + preferred_worker_host_available?(preferred_worker_host, available_hosts) -> + preferred_worker_host + + true -> + least_loaded_worker_host(state, available_hosts) + end + end + end + + defp preferred_worker_host_available?(preferred_worker_host, hosts) + when is_binary(preferred_worker_host) and is_list(hosts) do + preferred_worker_host != "" and preferred_worker_host in hosts + end + + defp preferred_worker_host_available?(_preferred_worker_host, _hosts), do: false + + defp least_loaded_worker_host(%State{} = state, hosts) when is_list(hosts) do + hosts + |> Enum.with_index() + |> Enum.min_by(fn {host, index} -> + {running_worker_host_count(state.running, host), index} + end) + |> elem(0) + end + + defp running_worker_host_count(running, worker_host) when is_map(running) and is_binary(worker_host) do + Enum.count(running, fn + {_issue_id, %{worker_host: ^worker_host}} -> true + _ -> false + end) + end + + defp worker_slots_available?(%State{} = state) do + select_worker_host(state, nil) != :no_worker_capacity + end + + defp worker_slots_available?(%State{} = state, preferred_worker_host) do + select_worker_host(state, preferred_worker_host) != :no_worker_capacity + end + + defp worker_host_slots_available?(%State{} = state, worker_host) when is_binary(worker_host) do + case Config.settings!().worker.max_concurrent_agents_per_host do + limit when is_integer(limit) and limit > 0 -> + running_worker_host_count(state.running, worker_host) < limit + + _ -> + true + end + end + defp find_issue_by_id(issues, issue_id) when is_binary(issue_id) do Enum.find(issues, fn %Issue{id: ^issue_id} -> @@ -877,7 +1405,8 @@ defmodule SymphonyElixir.Orchestrator do defp available_slots(%State{} = state) do max( - (state.max_concurrent_agents || Config.max_concurrent_agents()) - map_size(state.running), + (state.max_concurrent_agents || Config.settings!().agent.max_concurrent_agents) - + map_size(state.running), 0 ) end @@ -926,6 +1455,8 @@ defmodule SymphonyElixir.Orchestrator do issue_id: issue_id, identifier: metadata.identifier, state: metadata.issue.state, + worker_host: Map.get(metadata, :worker_host), + workspace_path: Map.get(metadata, :workspace_path), session_id: metadata.session_id, codex_app_server_pid: metadata.codex_app_server_pid, codex_input_tokens: metadata.codex_input_tokens, @@ -948,7 +1479,9 @@ defmodule SymphonyElixir.Orchestrator do attempt: attempt, due_in_ms: max(0, due_at_ms - now_ms), identifier: Map.get(retry, :identifier), - error: Map.get(retry, :error) + error: Map.get(retry, :error), + worker_host: Map.get(retry, :worker_host), + workspace_path: Map.get(retry, :workspace_path) } end) @@ -970,10 +1503,7 @@ defmodule SymphonyElixir.Orchestrator do now_ms = System.monotonic_time(:millisecond) already_due? = is_integer(state.next_poll_due_at_ms) and state.next_poll_due_at_ms <= now_ms coalesced = state.poll_check_in_progress == true or already_due? - - unless coalesced do - :ok = schedule_tick(0) - end + state = if coalesced, do: state, else: schedule_tick(state, 0) {:reply, %{ @@ -1058,9 +1588,20 @@ defmodule SymphonyElixir.Orchestrator do } end - defp schedule_tick(delay_ms) do - :timer.send_after(delay_ms, self(), :tick) - :ok + defp schedule_tick(%State{} = state, delay_ms) when is_integer(delay_ms) and delay_ms >= 0 do + if is_reference(state.tick_timer_ref) do + Process.cancel_timer(state.tick_timer_ref) + end + + tick_token = make_ref() + timer_ref = Process.send_after(self(), {:tick, tick_token}, delay_ms) + + %{ + state + | tick_timer_ref: timer_ref, + tick_token: tick_token, + next_poll_due_at_ms: System.monotonic_time(:millisecond) + delay_ms + } end defp schedule_poll_cycle_start do @@ -1098,10 +1639,12 @@ defmodule SymphonyElixir.Orchestrator do defp record_session_completion_totals(state, _running_entry), do: state defp refresh_runtime_config(%State{} = state) do + config = Config.settings!() + %{ state - | poll_interval_ms: Config.poll_interval_ms(), - max_concurrent_agents: Config.max_concurrent_agents() + | poll_interval_ms: config.polling.interval_ms, + max_concurrent_agents: config.agent.max_concurrent_agents } end diff --git a/elixir/lib/symphony_elixir/path_safety.ex b/elixir/lib/symphony_elixir/path_safety.ex new file mode 100644 index 000000000..fca59887a --- /dev/null +++ b/elixir/lib/symphony_elixir/path_safety.ex @@ -0,0 +1,50 @@ +defmodule SymphonyElixir.PathSafety do + @moduledoc false + + @spec canonicalize(Path.t()) :: {:ok, Path.t()} | {:error, term()} + def canonicalize(path) when is_binary(path) do + expanded_path = Path.expand(path) + {root, segments} = split_absolute_path(expanded_path) + + case resolve_segments(root, [], segments) do + {:ok, canonical_path} -> + {:ok, canonical_path} + + {:error, reason} -> + {:error, {:path_canonicalize_failed, expanded_path, reason}} + end + end + + defp split_absolute_path(path) when is_binary(path) do + [root | segments] = Path.split(path) + {root, segments} + end + + defp resolve_segments(root, resolved_segments, []), do: {:ok, join_path(root, resolved_segments)} + + defp resolve_segments(root, resolved_segments, [segment | rest]) do + candidate_path = join_path(root, resolved_segments ++ [segment]) + + case File.lstat(candidate_path) do + {:ok, %File.Stat{type: :symlink}} -> + with {:ok, target} <- :file.read_link_all(String.to_charlist(candidate_path)) do + resolved_target = Path.expand(IO.chardata_to_string(target), join_path(root, resolved_segments)) + {target_root, target_segments} = split_absolute_path(resolved_target) + resolve_segments(target_root, [], target_segments ++ rest) + end + + {:ok, _stat} -> + resolve_segments(root, resolved_segments ++ [segment], rest) + + {:error, :enoent} -> + {:ok, join_path(root, resolved_segments ++ [segment | rest])} + + {:error, reason} -> + {:error, reason} + end + end + + defp join_path(root, segments) when is_list(segments) do + Enum.reduce(segments, root, fn segment, acc -> Path.join(acc, segment) end) + end +end diff --git a/elixir/lib/symphony_elixir/prompt_builder.ex b/elixir/lib/symphony_elixir/prompt_builder.ex index 1b334a105..2ad94b31c 100644 --- a/elixir/lib/symphony_elixir/prompt_builder.ex +++ b/elixir/lib/symphony_elixir/prompt_builder.ex @@ -9,9 +9,11 @@ defmodule SymphonyElixir.PromptBuilder do @spec build_prompt(SymphonyElixir.Linear.Issue.t(), keyword()) :: String.t() def build_prompt(issue, opts \\ []) do + workflow_path = Keyword.get(opts, :workflow_path) + template = - Workflow.current() - |> prompt_template!() + if(is_binary(workflow_path), do: Workflow.load(workflow_path), else: Workflow.current()) + |> prompt_template!(workflow_path) |> parse_template!() template @@ -25,9 +27,13 @@ defmodule SymphonyElixir.PromptBuilder do |> IO.iodata_to_binary() end - defp prompt_template!({:ok, %{prompt_template: prompt}}), do: default_prompt(prompt) + defp prompt_template!({:ok, %{prompt_template: prompt}}, workflow_path) when is_binary(workflow_path) do + if String.trim(prompt) == "", do: raise(RuntimeError, "stage_workflow_empty_body: #{workflow_path}"), else: prompt + end + + defp prompt_template!({:ok, %{prompt_template: prompt}}, _workflow_path), do: default_prompt(prompt) - defp prompt_template!({:error, reason}) do + defp prompt_template!({:error, reason}, _workflow_path) do raise RuntimeError, "workflow_unavailable: #{inspect(reason)}" end diff --git a/elixir/lib/symphony_elixir/ssh.ex b/elixir/lib/symphony_elixir/ssh.ex new file mode 100644 index 000000000..0493adb08 --- /dev/null +++ b/elixir/lib/symphony_elixir/ssh.ex @@ -0,0 +1,100 @@ +defmodule SymphonyElixir.SSH do + @moduledoc false + + @spec run(String.t(), String.t(), keyword()) :: {:ok, {String.t(), non_neg_integer()}} | {:error, term()} + def run(host, command, opts \\ []) when is_binary(host) and is_binary(command) do + with {:ok, executable} <- ssh_executable() do + {:ok, System.cmd(executable, ssh_args(host, command), opts)} + end + end + + @spec start_port(String.t(), String.t(), keyword()) :: {:ok, port()} | {:error, term()} + def start_port(host, command, opts \\ []) when is_binary(host) and is_binary(command) do + with {:ok, executable} <- ssh_executable() do + line_bytes = Keyword.get(opts, :line) + + port_opts = + [ + :binary, + :exit_status, + :stderr_to_stdout, + args: Enum.map(ssh_args(host, command), &String.to_charlist/1) + ] + |> maybe_put_line_option(line_bytes) + + {:ok, Port.open({:spawn_executable, String.to_charlist(executable)}, port_opts)} + end + end + + @spec remote_shell_command(String.t()) :: String.t() + def remote_shell_command(command) when is_binary(command) do + "bash -lc " <> shell_escape(command) + end + + defp ssh_executable do + case System.find_executable("ssh") do + nil -> {:error, :ssh_not_found} + executable -> {:ok, executable} + end + end + + defp ssh_args(host, command) do + %{destination: destination, port: port} = parse_target(host) + + [] + |> maybe_put_config() + |> Kernel.++(["-T"]) + |> maybe_put_port(port) + |> Kernel.++([destination, remote_shell_command(command)]) + end + + defp maybe_put_line_option(port_opts, nil), do: port_opts + defp maybe_put_line_option(port_opts, line_bytes), do: Keyword.put(port_opts, :line, line_bytes) + + defp maybe_put_config(args) do + case System.get_env("SYMPHONY_SSH_CONFIG") do + config_path when is_binary(config_path) and config_path != "" -> + args ++ ["-F", config_path] + + _ -> + args + end + end + + defp maybe_put_port(args, nil), do: args + defp maybe_put_port(args, port), do: args ++ ["-p", port] + + defp parse_target(target) when is_binary(target) do + trimmed_target = String.trim(target) + + # OpenSSH does not interpret bare "host:port" as "host + port"; it treats the + # whole value as a hostname and leaves the port at 22. We split that shorthand + # here so worker config can use "localhost:2222" without requiring ssh:// URIs. + case Regex.run(~r/^(.*):(\d+)$/, trimmed_target, capture: :all_but_first) do + [destination, port] -> + if valid_port_destination?(destination) do + %{destination: destination, port: port} + else + %{destination: trimmed_target, port: nil} + end + + _ -> + %{destination: trimmed_target, port: nil} + end + end + + defp valid_port_destination?(destination) when is_binary(destination) do + destination != "" and + (not String.contains?(destination, ":") or bracketed_host?(destination)) + end + + defp bracketed_host?(destination) when is_binary(destination) do + # IPv6 literals contain ":" already, so we only accept additional ":port" + # parsing when the host is explicitly bracketed, e.g. "[::1]:2222". + String.contains?(destination, "[") and String.contains?(destination, "]") + end + + defp shell_escape(value) when is_binary(value) do + "'" <> String.replace(value, "'", "'\"'\"'") <> "'" + end +end diff --git a/elixir/lib/symphony_elixir/stage_closeout.ex b/elixir/lib/symphony_elixir/stage_closeout.ex new file mode 100644 index 000000000..ce40f2fe4 --- /dev/null +++ b/elixir/lib/symphony_elixir/stage_closeout.ex @@ -0,0 +1,125 @@ +defmodule SymphonyElixir.StageCloseout do + @moduledoc false + + alias SymphonyElixir.MarkerParser + alias SymphonyElixir.MarkerParser.Marker + + @spec check_review(Path.t(), String.t(), String.t(), String.t()) :: :ok | {:error, term()} + def check_review(workspace_path, workpad_text, dispatch_head_sha, issue_identifier) do + markers = MarkerParser.parse(workpad_text, issue_identifier) + + with {:ok, head} <- current_head(workspace_path), + :ok <- ensure_dispatch_head_matches(dispatch_head_sha, head), + :ok <- ensure_marker_matches_head_and_clean_tree(workspace_path, markers, :code_review, head), + :ok <- ensure_no_findings_to_clean_flip(markers) do + :ok + end + end + + @spec check_doc_fix(Path.t(), String.t(), String.t()) :: :ok | {:error, term()} + def check_doc_fix(workspace_path, workpad_text, issue_identifier) do + markers = MarkerParser.parse(workpad_text, issue_identifier) + + with {:ok, head} <- current_head(workspace_path), + :ok <- ensure_marker_matches_head_and_clean_tree(workspace_path, markers, :docs_checked, head), + :ok <- ensure_only_docs_changed(workspace_path, markers) do + :ok + end + end + + @spec check_implement(term(), term(), term()) :: :ok + def check_implement(_, _, _), do: :ok + + defp ensure_dispatch_head_matches(dispatch_head_sha, dispatch_head_sha), do: :ok + + defp ensure_dispatch_head_matches(old_head, new_head) do + {:error, {:reviewer_committed, old: old_head, new: new_head}} + end + + defp ensure_marker_matches_head_and_clean_tree(workspace_path, markers, kind, head) do + case latest_marker_of_kind(markers, kind) do + nil -> + {:error, {:missing_marker, kind}} + + %Marker{reviewed_sha: reviewed_sha} when reviewed_sha != head -> + {:error, {:reviewed_sha_mismatch, marker: kind, reviewed_sha: reviewed_sha, head: head}} + + %Marker{} -> + case status_lines(workspace_path) do + {:ok, []} -> :ok + {:ok, lines} -> {:error, {:working_tree_dirty, lines}} + {:error, reason} -> {:error, reason} + end + end + end + + defp ensure_no_findings_to_clean_flip(markers) do + case current_round(markers) + |> Enum.filter(&(&1.kind == :code_review)) + |> Enum.take(-2) do + [ + %Marker{verdict: :findings, reviewed_sha: reviewed_sha}, + %Marker{verdict: :clean, reviewed_sha: reviewed_sha} + ] -> + {:error, {:findings_to_clean_flip_same_head, reviewed_sha}} + + _ -> + :ok + end + end + + defp ensure_only_docs_changed(workspace_path, markers) do + case MarkerParser.latest_review_sha(markers) do + nil -> + {:error, {:missing_marker, :code_review}} + + reviewed_sha -> + with {:ok, paths} <- diff_paths(workspace_path, reviewed_sha) do + case Enum.reject(paths, &docs_path?/1) do + [] -> :ok + non_docs_paths -> {:error, {:non_docs_paths, non_docs_paths}} + end + end + end + end + + defp latest_marker_of_kind(markers, kind) do + current_round(markers) + |> Enum.filter(&(&1.kind == kind)) + |> List.last() + end + + defp current_round(markers) do + round_id = markers |> Enum.map(& &1.round_id) |> Enum.max(fn -> 0 end) + Enum.filter(markers, &(&1.round_id == round_id)) + end + + defp docs_path?(path) do + String.ends_with?(path, ".md") or String.starts_with?(path, "docs/") + end + + defp current_head(workspace_path) do + with {:ok, head} <- git(workspace_path, ["rev-parse", "HEAD"]) do + {:ok, String.trim(head)} + end + end + + defp status_lines(workspace_path) do + with {:ok, output} <- git(workspace_path, ["status", "--porcelain"]) do + {:ok, String.split(output, "\n", trim: true)} + end + end + + defp diff_paths(workspace_path, reviewed_sha) do + with {:ok, output} <- git(workspace_path, ["diff", "#{reviewed_sha}..HEAD", "--name-only"]) do + {:ok, String.split(output, "\n", trim: true)} + end + end + + defp git(workspace_path, args) do + case System.cmd("git", args, cd: workspace_path, stderr_to_stdout: true) do + {output, 0} -> {:ok, output} + {output, status} -> {:error, {:git_failed, args, status, String.trim_trailing(output)}} + end + end +end diff --git a/elixir/lib/symphony_elixir/stage_orchestrator.ex b/elixir/lib/symphony_elixir/stage_orchestrator.ex new file mode 100644 index 000000000..131ae596b --- /dev/null +++ b/elixir/lib/symphony_elixir/stage_orchestrator.ex @@ -0,0 +1,143 @@ +defmodule SymphonyElixir.StageOrchestrator do + @moduledoc false + + alias SymphonyElixir.{AgentRunner, Config, MarkerParser, Workflow} + + @region_regex ~r/(.*?)/ms + @issue_identifier_regex ~r/^\s*issue_identifier:\s*(.+?)\s*$/m + + @type stage :: :review | :doc_fix | :implement | :stop + + @spec next_stage(String.t() | nil, String.t() | nil, Path.t()) :: stage() + def next_stage(workpad, linear_state, workspace_path) do + markers = parse_markers(workpad) + latest_verdict = latest_code_review_verdict(markers) + latest_review_sha = MarkerParser.latest_review_sha(markers) + docs_checked_matches_review? = MarkerParser.docs_checked_matches_review?(markers) + + cond do + linear_state == "Rework" -> + :implement + + not active_issue_state?(linear_state) -> + :stop + + MarkerParser.review_pending?(markers) -> + :review + + latest_verdict == :findings -> + :implement + + latest_verdict == :clean -> + current_head = current_head(workspace_path) + + cond do + latest_review_sha != current_head -> + :review + + not docs_checked_matches_review? -> + :doc_fix + + docs_checked_matches_review? and latest_review_sha == current_head -> + :implement + end + + true -> + :implement + end + end + + @spec dispatch(SymphonyElixir.Linear.Issue.t(), stage()) :: :ok | no_return() + def dispatch(_issue, :stop), do: :ok + + def dispatch(issue, stage) when stage in [:review, :doc_fix, :implement] do + agent_runner_module().run(issue, nil, workflow_path: workflow_path_for(stage), max_turns: 1) + end + + defp latest_code_review_verdict(markers) do + case MarkerParser.latest_code_review(markers) do + %{verdict: verdict} -> verdict + _ -> nil + end + end + + defp active_issue_state?(state_name) when is_binary(state_name) do + normalized_state = normalize_state(state_name) + + Config.settings!().tracker.active_states + |> Enum.map(&normalize_state/1) + |> Enum.reject(&(&1 == "")) + |> Enum.any?(&(&1 == normalized_state)) + end + + defp active_issue_state?(_state_name), do: false + + defp normalize_state(state_name) do + state_name + |> String.trim() + |> String.downcase() + end + + # next_stage/3 only receives workpad text, so we pick the issue_identifier that + # yields the largest internally-valid marker set inside the bounded marker region. + defp parse_markers(workpad) when is_binary(workpad) do + workpad + |> candidate_issue_identifiers() + |> Enum.reduce([], fn issue_identifier, best_markers -> + parsed_markers = MarkerParser.parse(workpad, issue_identifier) + if length(parsed_markers) > length(best_markers), do: parsed_markers, else: best_markers + end) + end + + defp parse_markers(_workpad), do: [] + + defp candidate_issue_identifiers(workpad) do + case Regex.run(@region_regex, workpad, capture: :all_but_first) do + [region] -> + Regex.scan(@issue_identifier_regex, region, capture: :all_but_first) + |> Enum.map(fn [raw_issue_identifier] -> normalize_issue_identifier(raw_issue_identifier) end) + |> Enum.reject(&(&1 == "")) + |> Enum.uniq() + + _ -> + [] + end + end + + defp normalize_issue_identifier(raw_issue_identifier) do + trimmed = String.trim(raw_issue_identifier) + + case trimmed do + <> when quote in [?", ?'] and rest != "" -> + if String.ends_with?(rest, <>) do + String.trim_trailing(rest, <>) + else + trimmed + end + + _ -> + trimmed + end + end + + defp current_head(workspace_path) do + case System.cmd("git", ["rev-parse", "HEAD"], cd: workspace_path, stderr_to_stdout: true) do + {head, 0} -> String.trim(head) + _ -> nil + end + end + + defp workflow_path_for(:implement), do: Workflow.workflow_file_path() + + defp workflow_path_for(:review) do + Path.join(Path.dirname(Workflow.workflow_file_path()), "WORKFLOW-review.md") + end + + defp workflow_path_for(:doc_fix) do + Path.join(Path.dirname(Workflow.workflow_file_path()), "WORKFLOW-docfix.md") + end + + defp agent_runner_module do + Application.get_env(:symphony_elixir, :stage_orchestrator_agent_runner_module, AgentRunner) + end +end diff --git a/elixir/lib/symphony_elixir/status_dashboard.ex b/elixir/lib/symphony_elixir/status_dashboard.ex index 19b628bfa..8cd4b17f3 100644 --- a/elixir/lib/symphony_elixir/status_dashboard.ex +++ b/elixir/lib/symphony_elixir/status_dashboard.ex @@ -99,10 +99,11 @@ defmodule SymphonyElixir.StatusDashboard do refresh_ms_override = keyword_override(opts, :refresh_ms) enabled_override = keyword_override(opts, :enabled) render_interval_ms_override = keyword_override(opts, :render_interval_ms) - refresh_ms = refresh_ms_override || Config.observability_refresh_ms() - render_interval_ms = render_interval_ms_override || Config.observability_render_interval_ms() + observability = Config.settings!().observability + refresh_ms = refresh_ms_override || observability.refresh_ms + render_interval_ms = render_interval_ms_override || observability.render_interval_ms render_fun = Keyword.get(opts, :render_fun, &render_to_terminal/1) - enabled = resolve_override(enabled_override, Config.observability_enabled?() and dashboard_enabled?()) + enabled = resolve_override(enabled_override, observability.dashboard_enabled and dashboard_enabled?()) schedule_tick(refresh_ms, enabled) {:ok, @@ -176,11 +177,13 @@ defmodule SymphonyElixir.StatusDashboard do def handle_info(:tick, state), do: {:noreply, state} defp refresh_runtime_config(%__MODULE__{} = state) do + observability = Config.settings!().observability + %{ state - | enabled: resolve_override(state.enabled_override, Config.observability_enabled?() and dashboard_enabled?()), - refresh_ms: state.refresh_ms_override || Config.observability_refresh_ms(), - render_interval_ms: state.render_interval_ms_override || Config.observability_render_interval_ms() + | enabled: resolve_override(state.enabled_override, observability.dashboard_enabled and dashboard_enabled?()), + refresh_ms: state.refresh_ms_override || observability.refresh_ms, + render_interval_ms: state.render_interval_ms_override || observability.render_interval_ms } end @@ -338,7 +341,7 @@ defmodule SymphonyElixir.StatusDashboard do codex_total_tokens = Map.get(codex_totals, :total_tokens, 0) codex_seconds_running = Map.get(codex_totals, :seconds_running, 0) agent_count = length(running) - max_agents = Config.max_concurrent_agents() + max_agents = Config.settings!().agent.max_concurrent_agents running_event_width = running_event_width(terminal_columns_override) running_rows = format_running_rows(running, running_event_width) running_to_backoff_spacer = if(running == [], do: [], else: ["│"]) @@ -391,7 +394,7 @@ defmodule SymphonyElixir.StatusDashboard do defp format_project_link_lines do project_part = - case Config.linear_project_slug() do + case Config.settings!().tracker.project_slug do project_slug when is_binary(project_slug) and project_slug != "" -> colorize(linear_project_url(project_slug), @ansi_cyan) @@ -427,7 +430,7 @@ defmodule SymphonyElixir.StatusDashboard do defp linear_project_url(project_slug), do: "https://linear.app/project/#{project_slug}/issues" defp dashboard_url do - dashboard_url(Config.server_host(), Config.server_port(), HttpServer.bound_port()) + dashboard_url(Config.settings!().server.host, Config.server_port(), HttpServer.bound_port()) end defp dashboard_url(_host, nil, _bound_port), do: nil diff --git a/elixir/lib/symphony_elixir/tracker.ex b/elixir/lib/symphony_elixir/tracker.ex index 504b54af3..d21cecf13 100644 --- a/elixir/lib/symphony_elixir/tracker.ex +++ b/elixir/lib/symphony_elixir/tracker.ex @@ -9,6 +9,7 @@ defmodule SymphonyElixir.Tracker do @callback fetch_issues_by_states([String.t()]) :: {:ok, [term()]} | {:error, term()} @callback fetch_issue_states_by_ids([String.t()]) :: {:ok, [term()]} | {:error, term()} @callback create_comment(String.t(), String.t()) :: :ok | {:error, term()} + @callback update_issue_description(String.t(), String.t()) :: :ok | {:error, term()} @callback update_issue_state(String.t(), String.t()) :: :ok | {:error, term()} @spec fetch_candidate_issues() :: {:ok, [term()]} | {:error, term()} @@ -31,6 +32,11 @@ defmodule SymphonyElixir.Tracker do adapter().create_comment(issue_id, body) end + @spec update_issue_description(String.t(), String.t()) :: :ok | {:error, term()} + def update_issue_description(issue_id, description) do + adapter().update_issue_description(issue_id, description) + end + @spec update_issue_state(String.t(), String.t()) :: :ok | {:error, term()} def update_issue_state(issue_id, state_name) do adapter().update_issue_state(issue_id, state_name) @@ -38,7 +44,7 @@ defmodule SymphonyElixir.Tracker do @spec adapter() :: module() def adapter do - case Config.tracker_kind() do + case Config.settings!().tracker.kind do "memory" -> SymphonyElixir.Tracker.Memory _ -> SymphonyElixir.Linear.Adapter end diff --git a/elixir/lib/symphony_elixir/tracker/memory.ex b/elixir/lib/symphony_elixir/tracker/memory.ex index ad84a23c6..f21a15cdf 100644 --- a/elixir/lib/symphony_elixir/tracker/memory.ex +++ b/elixir/lib/symphony_elixir/tracker/memory.ex @@ -41,6 +41,12 @@ defmodule SymphonyElixir.Tracker.Memory do :ok end + @spec update_issue_description(String.t(), String.t()) :: :ok | {:error, term()} + def update_issue_description(issue_id, description) do + send_event({:memory_tracker_description_update, issue_id, description}) + :ok + end + @spec update_issue_state(String.t(), String.t()) :: :ok | {:error, term()} def update_issue_state(issue_id, state_name) do send_event({:memory_tracker_state_update, issue_id, state_name}) diff --git a/elixir/lib/symphony_elixir/workspace.ex b/elixir/lib/symphony_elixir/workspace.ex index e8c085ea5..7b7457511 100644 --- a/elixir/lib/symphony_elixir/workspace.ex +++ b/elixir/lib/symphony_elixir/workspace.ex @@ -4,36 +4,45 @@ defmodule SymphonyElixir.Workspace do """ require Logger - alias SymphonyElixir.Config + alias SymphonyElixir.{Config, PathSafety, SSH} - @excluded_entries MapSet.new([".elixir_ls", "tmp"]) + @remote_workspace_marker "__SYMPHONY_WORKSPACE__" - @spec create_for_issue(map() | String.t() | nil) :: {:ok, Path.t()} | {:error, term()} - def create_for_issue(issue_or_identifier) do + @type worker_host :: String.t() | nil + + @spec path_for_issue(map() | String.t() | nil, worker_host()) :: + {:ok, Path.t()} | {:error, term()} + def path_for_issue(issue_or_identifier, worker_host \\ nil) do + issue_context = issue_context(issue_or_identifier) + safe_id = safe_identifier(issue_context.issue_identifier) + workspace_path_for_issue(safe_id, worker_host) + end + + @spec create_for_issue(map() | String.t() | nil, worker_host()) :: + {:ok, Path.t()} | {:error, term()} + def create_for_issue(issue_or_identifier, worker_host \\ nil) do issue_context = issue_context(issue_or_identifier) try do safe_id = safe_identifier(issue_context.issue_identifier) - workspace = workspace_path_for_issue(safe_id) - - with :ok <- validate_workspace_path(workspace), - {:ok, created?} <- ensure_workspace(workspace), - :ok <- maybe_run_after_create_hook(workspace, issue_context, created?) do + with {:ok, workspace} <- workspace_path_for_issue(safe_id, worker_host), + :ok <- validate_workspace_path(workspace, worker_host), + {:ok, workspace, created?} <- ensure_workspace(workspace, worker_host), + :ok <- maybe_run_after_create_hook(workspace, issue_context, created?, worker_host) do {:ok, workspace} end rescue error in [ArgumentError, ErlangError, File.Error] -> - Logger.error("Workspace creation failed #{issue_log_context(issue_context)} error=#{Exception.message(error)}") + Logger.error("Workspace creation failed #{issue_log_context(issue_context)} worker_host=#{worker_host_for_log(worker_host)} error=#{Exception.message(error)}") {:error, error} end end - defp ensure_workspace(workspace) do + defp ensure_workspace(workspace, nil) do cond do File.dir?(workspace) -> - clean_tmp_artifacts(workspace) - {:ok, false} + {:ok, workspace, false} File.exists?(workspace) -> File.rm_rf!(workspace) @@ -44,19 +53,55 @@ defmodule SymphonyElixir.Workspace do end end + defp ensure_workspace(workspace, worker_host) when is_binary(worker_host) do + script = + [ + "set -eu", + remote_shell_assign("workspace", workspace), + "if [ -d \"$workspace\" ]; then", + " created=0", + "elif [ -e \"$workspace\" ]; then", + " rm -rf \"$workspace\"", + " mkdir -p \"$workspace\"", + " created=1", + "else", + " mkdir -p \"$workspace\"", + " created=1", + "fi", + "cd \"$workspace\"", + "printf '%s\\t%s\\t%s\\n' '#{@remote_workspace_marker}' \"$created\" \"$(pwd -P)\"" + ] + |> Enum.reject(&(&1 == "")) + |> Enum.join("\n") + + case run_remote_command(worker_host, script, Config.settings!().hooks.timeout_ms) do + {:ok, {output, 0}} -> + parse_remote_workspace_output(output) + + {:ok, {output, status}} -> + {:error, {:workspace_prepare_failed, worker_host, status, output}} + + {:error, reason} -> + {:error, reason} + end + end + defp create_workspace(workspace) do File.rm_rf!(workspace) File.mkdir_p!(workspace) - {:ok, true} + {:ok, workspace, true} end @spec remove(Path.t()) :: {:ok, [String.t()]} | {:error, term(), String.t()} - def remove(workspace) do + def remove(workspace), do: remove(workspace, nil) + + @spec remove(Path.t(), worker_host()) :: {:ok, [String.t()]} | {:error, term(), String.t()} + def remove(workspace, nil) do case File.exists?(workspace) do true -> - case validate_workspace_path(workspace) do + case validate_workspace_path(workspace, nil) do :ok -> - maybe_run_before_remove_hook(workspace) + maybe_run_before_remove_hook(workspace, nil) File.rm_rf(workspace) {:error, reason} -> @@ -68,69 +113,134 @@ defmodule SymphonyElixir.Workspace do end end + def remove(workspace, worker_host) when is_binary(worker_host) do + maybe_run_before_remove_hook(workspace, worker_host) + + script = + [ + remote_shell_assign("workspace", workspace), + "rm -rf \"$workspace\"" + ] + |> Enum.join("\n") + + case run_remote_command(worker_host, script, Config.settings!().hooks.timeout_ms) do + {:ok, {_output, 0}} -> + {:ok, []} + + {:ok, {output, status}} -> + {:error, {:workspace_remove_failed, worker_host, status, output}, ""} + + {:error, reason} -> + {:error, reason, ""} + end + end + @spec remove_issue_workspaces(term()) :: :ok - def remove_issue_workspaces(identifier) when is_binary(identifier) do + def remove_issue_workspaces(identifier), do: remove_issue_workspaces(identifier, nil) + + @spec remove_issue_workspaces(term(), worker_host()) :: :ok + def remove_issue_workspaces(identifier, worker_host) when is_binary(identifier) and is_binary(worker_host) do + safe_id = safe_identifier(identifier) + + case workspace_path_for_issue(safe_id, worker_host) do + {:ok, workspace} -> remove(workspace, worker_host) + {:error, _reason} -> :ok + end + + :ok + end + + def remove_issue_workspaces(identifier, nil) when is_binary(identifier) do safe_id = safe_identifier(identifier) - workspace = Path.join(Config.workspace_root(), safe_id) - remove(workspace) + case Config.settings!().worker.ssh_hosts do + [] -> + case workspace_path_for_issue(safe_id, nil) do + {:ok, workspace} -> remove(workspace, nil) + {:error, _reason} -> :ok + end + + worker_hosts -> + Enum.each(worker_hosts, &remove_issue_workspaces(identifier, &1)) + end + :ok end - def remove_issue_workspaces(_identifier) do + def remove_issue_workspaces(_identifier, _worker_host) do :ok end - @spec run_before_run_hook(Path.t(), map() | String.t() | nil) :: :ok | {:error, term()} - def run_before_run_hook(workspace, issue_or_identifier) when is_binary(workspace) do + @spec run_before_run_hook(Path.t(), map() | String.t() | nil, worker_host()) :: + :ok | {:error, term()} + def run_before_run_hook(workspace, issue_or_identifier, worker_host \\ nil) when is_binary(workspace) do issue_context = issue_context(issue_or_identifier) + hooks = Config.settings!().hooks - case Config.workspace_hooks()[:before_run] do + case hooks.before_run do nil -> :ok command -> - run_hook(command, workspace, issue_context, "before_run") + run_hook(command, workspace, issue_context, "before_run", worker_host) end end - @spec run_after_run_hook(Path.t(), map() | String.t() | nil) :: :ok - def run_after_run_hook(workspace, issue_or_identifier) when is_binary(workspace) do + @spec run_after_run_hook(Path.t(), map() | String.t() | nil, worker_host()) :: :ok + def run_after_run_hook(workspace, issue_or_identifier, worker_host \\ nil) when is_binary(workspace) do issue_context = issue_context(issue_or_identifier) + hooks = Config.settings!().hooks - case Config.workspace_hooks()[:after_run] do + case hooks.after_run do nil -> :ok command -> - run_hook(command, workspace, issue_context, "after_run") + run_hook(command, workspace, issue_context, "after_run", worker_host) |> ignore_hook_failure() end end - defp workspace_path_for_issue(safe_id) when is_binary(safe_id) do - Path.join(Config.workspace_root(), safe_id) + @spec run_after_implement_hook(Path.t(), map() | String.t() | nil, worker_host()) :: + :ok | :skip | {:error, term()} + def run_after_implement_hook(workspace, issue_or_identifier, worker_host \\ nil) when is_binary(workspace) do + issue_context = issue_context(issue_or_identifier) + hooks = Config.settings!().hooks + + case hooks.after_implement do + nil -> + :skip + + command -> + run_hook(command, workspace, issue_context, "after_implement", worker_host) + end + end + + defp workspace_path_for_issue(safe_id, nil) when is_binary(safe_id) do + Config.settings!().workspace.root + |> Path.join(safe_id) + |> PathSafety.canonicalize() + end + + defp workspace_path_for_issue(safe_id, worker_host) when is_binary(safe_id) and is_binary(worker_host) do + {:ok, Path.join(Config.settings!().workspace.root, safe_id)} end defp safe_identifier(identifier) do String.replace(identifier || "issue", ~r/[^a-zA-Z0-9._-]/, "_") end - defp clean_tmp_artifacts(workspace) do - Enum.each(MapSet.to_list(@excluded_entries), fn entry -> - File.rm_rf(Path.join(workspace, entry)) - end) - end + defp maybe_run_after_create_hook(workspace, issue_context, created?, worker_host) do + hooks = Config.settings!().hooks - defp maybe_run_after_create_hook(workspace, issue_context, created?) do case created? do true -> - case Config.workspace_hooks()[:after_create] do + case hooks.after_create do nil -> :ok command -> - run_hook(command, workspace, issue_context, "after_create") + run_hook(command, workspace, issue_context, "after_create", worker_host) end false -> @@ -138,10 +248,12 @@ defmodule SymphonyElixir.Workspace do end end - defp maybe_run_before_remove_hook(workspace) do + defp maybe_run_before_remove_hook(workspace, nil) do + hooks = Config.settings!().hooks + case File.dir?(workspace) do true -> - case Config.workspace_hooks()[:before_remove] do + case hooks.before_remove do nil -> :ok @@ -150,7 +262,8 @@ defmodule SymphonyElixir.Workspace do command, workspace, %{issue_id: nil, issue_identifier: Path.basename(workspace)}, - "before_remove" + "before_remove", + nil ) |> ignore_hook_failure() end @@ -160,13 +273,51 @@ defmodule SymphonyElixir.Workspace do end end + defp maybe_run_before_remove_hook(workspace, worker_host) when is_binary(worker_host) do + hooks = Config.settings!().hooks + + case hooks.before_remove do + nil -> + :ok + + command -> + script = + [ + remote_shell_assign("workspace", workspace), + "if [ -d \"$workspace\" ]; then", + " cd \"$workspace\"", + " #{command}", + "fi" + ] + |> Enum.join("\n") + + run_remote_command(worker_host, script, Config.settings!().hooks.timeout_ms) + |> case do + {:ok, {output, status}} -> + handle_hook_command_result( + {output, status}, + workspace, + %{issue_id: nil, issue_identifier: Path.basename(workspace)}, + "before_remove" + ) + + {:error, {:workspace_hook_timeout, "before_remove", _timeout_ms} = reason} -> + {:error, reason} + + {:error, reason} -> + {:error, reason} + end + |> ignore_hook_failure() + end + end + defp ignore_hook_failure(:ok), do: :ok defp ignore_hook_failure({:error, _reason}), do: :ok - defp run_hook(command, workspace, issue_context, hook_name) do - timeout_ms = Config.workspace_hooks()[:timeout_ms] + defp run_hook(command, workspace, issue_context, hook_name, nil) do + timeout_ms = Config.settings!().hooks.timeout_ms - Logger.info("Running workspace hook hook=#{hook_name} #{issue_log_context(issue_context)} workspace=#{workspace}") + Logger.info("Running workspace hook hook=#{hook_name} #{issue_log_context(issue_context)} workspace=#{workspace} worker_host=local") task = Task.async(fn -> @@ -180,12 +331,29 @@ defmodule SymphonyElixir.Workspace do nil -> Task.shutdown(task, :brutal_kill) - Logger.warning("Workspace hook timed out hook=#{hook_name} #{issue_log_context(issue_context)} workspace=#{workspace} timeout_ms=#{timeout_ms}") + Logger.warning("Workspace hook timed out hook=#{hook_name} #{issue_log_context(issue_context)} workspace=#{workspace} worker_host=local timeout_ms=#{timeout_ms}") {:error, {:workspace_hook_timeout, hook_name, timeout_ms}} end end + defp run_hook(command, workspace, issue_context, hook_name, worker_host) when is_binary(worker_host) do + timeout_ms = Config.settings!().hooks.timeout_ms + + Logger.info("Running workspace hook hook=#{hook_name} #{issue_log_context(issue_context)} workspace=#{workspace} worker_host=#{worker_host}") + + case run_remote_command(worker_host, "cd #{shell_escape(workspace)} && #{command}", timeout_ms) do + {:ok, cmd_result} -> + handle_hook_command_result(cmd_result, workspace, issue_context, hook_name) + + {:error, {:workspace_hook_timeout, ^hook_name, _timeout_ms} = reason} -> + {:error, reason} + + {:error, reason} -> + {:error, reason} + end + end + defp handle_hook_command_result({_output, 0}, _workspace, _issue_id, _hook_name) do :ok end @@ -210,51 +378,107 @@ defmodule SymphonyElixir.Workspace do end end - defp validate_workspace_path(workspace) when is_binary(workspace) do + defp validate_workspace_path(workspace, nil) when is_binary(workspace) do expanded_workspace = Path.expand(workspace) - root = Path.expand(Config.workspace_root()) - root_prefix = root <> "/" + expanded_root = Path.expand(Config.settings!().workspace.root) + expanded_root_prefix = expanded_root <> "/" + + with {:ok, canonical_workspace} <- PathSafety.canonicalize(expanded_workspace), + {:ok, canonical_root} <- PathSafety.canonicalize(expanded_root) do + canonical_root_prefix = canonical_root <> "/" + + cond do + canonical_workspace == canonical_root -> + {:error, {:workspace_equals_root, canonical_workspace, canonical_root}} + String.starts_with?(canonical_workspace <> "/", canonical_root_prefix) -> + :ok + + String.starts_with?(expanded_workspace <> "/", expanded_root_prefix) -> + {:error, {:workspace_symlink_escape, expanded_workspace, canonical_root}} + + true -> + {:error, {:workspace_outside_root, canonical_workspace, canonical_root}} + end + else + {:error, {:path_canonicalize_failed, path, reason}} -> + {:error, {:workspace_path_unreadable, path, reason}} + end + end + + defp validate_workspace_path(workspace, worker_host) + when is_binary(workspace) and is_binary(worker_host) do cond do - expanded_workspace == root -> - {:error, {:workspace_equals_root, expanded_workspace, root}} + String.trim(workspace) == "" -> + {:error, {:workspace_path_unreadable, workspace, :empty}} - String.starts_with?(expanded_workspace <> "/", root_prefix) -> - ensure_no_symlink_components(expanded_workspace, root) + String.contains?(workspace, ["\n", "\r", <<0>>]) -> + {:error, {:workspace_path_unreadable, workspace, :invalid_characters}} true -> - {:error, {:workspace_outside_root, expanded_workspace, root}} + :ok end end - defp ensure_no_symlink_components(workspace, root) do - workspace - |> Path.relative_to(root) - |> Path.split() - |> Enum.reduce_while(root, fn segment, current_path -> - next_path = Path.join(current_path, segment) + defp remote_shell_assign(variable_name, raw_path) + when is_binary(variable_name) and is_binary(raw_path) do + [ + "#{variable_name}=#{shell_escape(raw_path)}", + "case \"$#{variable_name}\" in", + " '~') #{variable_name}=\"$HOME\" ;;", + " '~/'*) " <> variable_name <> "=\"$HOME/${" <> variable_name <> "#~/}\" ;;", + "esac" + ] + |> Enum.join("\n") + end + + defp parse_remote_workspace_output(output) do + lines = String.split(IO.iodata_to_binary(output), "\n", trim: true) - case File.lstat(next_path) do - {:ok, %File.Stat{type: :symlink}} -> - {:halt, {:error, {:workspace_symlink_escape, next_path, root}}} + payload = + Enum.find_value(lines, fn line -> + case String.split(line, "\t", parts: 3) do + [@remote_workspace_marker, created, path] when created in ["0", "1"] and path != "" -> + {created == "1", path} - {:ok, _stat} -> - {:cont, next_path} + _ -> + nil + end + end) - {:error, :enoent} -> - {:halt, :ok} + case payload do + {created?, workspace} when is_boolean(created?) and is_binary(workspace) -> + {:ok, workspace, created?} - {:error, reason} -> - {:halt, {:error, {:workspace_path_unreadable, next_path, reason}}} - end - end) - |> case do - :ok -> :ok - {:error, _reason} = error -> error - _final_path -> :ok + _ -> + {:error, {:workspace_prepare_failed, :invalid_output, output}} end end + defp run_remote_command(worker_host, script, timeout_ms) + when is_binary(worker_host) and is_binary(script) and is_integer(timeout_ms) and timeout_ms > 0 do + task = + Task.async(fn -> + SSH.run(worker_host, script, stderr_to_stdout: true) + end) + + case Task.yield(task, timeout_ms) do + {:ok, result} -> + result + + nil -> + Task.shutdown(task, :brutal_kill) + {:error, {:workspace_hook_timeout, "remote_command", timeout_ms}} + end + end + + defp shell_escape(value) when is_binary(value) do + "'" <> String.replace(value, "'", "'\"'\"'") <> "'" + end + + defp worker_host_for_log(nil), do: "local" + defp worker_host_for_log(worker_host), do: worker_host + defp issue_context(%{id: issue_id, identifier: identifier}) do %{ issue_id: issue_id, diff --git a/elixir/lib/symphony_elixir_web/presenter.ex b/elixir/lib/symphony_elixir_web/presenter.ex index 34eb1e664..1063cf7a6 100644 --- a/elixir/lib/symphony_elixir_web/presenter.ex +++ b/elixir/lib/symphony_elixir_web/presenter.ex @@ -66,7 +66,8 @@ defmodule SymphonyElixirWeb.Presenter do issue_id: issue_id_from_entries(running, retry), status: issue_status(running, retry), workspace: %{ - path: Path.join(Config.workspace_root(), issue_identifier) + path: workspace_path(issue_identifier, running, retry), + host: workspace_host(running, retry) }, attempts: %{ restart_count: restart_count(retry), @@ -99,6 +100,8 @@ defmodule SymphonyElixirWeb.Presenter do issue_id: entry.issue_id, issue_identifier: entry.identifier, state: entry.state, + worker_host: Map.get(entry, :worker_host), + workspace_path: Map.get(entry, :workspace_path), session_id: entry.session_id, turn_count: Map.get(entry, :turn_count, 0), last_event: entry.last_codex_event, @@ -119,12 +122,16 @@ defmodule SymphonyElixirWeb.Presenter do issue_identifier: entry.identifier, attempt: entry.attempt, due_at: due_at_iso8601(entry.due_in_ms), - error: entry.error + error: entry.error, + worker_host: Map.get(entry, :worker_host), + workspace_path: Map.get(entry, :workspace_path) } end defp running_issue_payload(running) do %{ + worker_host: Map.get(running, :worker_host), + workspace_path: Map.get(running, :workspace_path), session_id: running.session_id, turn_count: Map.get(running, :turn_count, 0), state: running.state, @@ -144,10 +151,22 @@ defmodule SymphonyElixirWeb.Presenter do %{ attempt: retry.attempt, due_at: due_at_iso8601(retry.due_in_ms), - error: retry.error + error: retry.error, + worker_host: Map.get(retry, :worker_host), + workspace_path: Map.get(retry, :workspace_path) } end + defp workspace_path(issue_identifier, running, retry) do + (running && Map.get(running, :workspace_path)) || + (retry && Map.get(retry, :workspace_path)) || + Path.join(Config.settings!().workspace.root, issue_identifier) + end + + defp workspace_host(running, retry) do + (running && Map.get(running, :worker_host)) || (retry && Map.get(retry, :worker_host)) + end + defp recent_events_payload(running) do [ %{ diff --git a/elixir/mix.exs b/elixir/mix.exs index 062706aab..aff9e4d98 100644 --- a/elixir/mix.exs +++ b/elixir/mix.exs @@ -73,7 +73,7 @@ defmodule SymphonyElixir.MixProject do {:jason, "~> 1.4"}, {:yaml_elixir, "~> 2.12"}, {:solid, "~> 1.2"}, - {:nimble_options, "~> 1.1"}, + {:ecto, "~> 3.13"}, {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, {:dialyxir, "~> 1.4", only: [:dev], runtime: false} ] diff --git a/elixir/mix.lock b/elixir/mix.lock index 4f52fd700..f2f7c58d1 100644 --- a/elixir/mix.lock +++ b/elixir/mix.lock @@ -6,6 +6,7 @@ "date_time_parser": {:hex, :date_time_parser, "1.3.0", "6ba16850b5ab83dd126576451023ab65349e29af2336ca5084aa1e37025b476e", [:mix], [{:kday, "~> 1.0", [hex: :kday, repo: "hexpm", optional: false]}], "hexpm", "93c8203a8ddc66b1f1531fc0e046329bf0b250c75ffa09567ef03d2c09218e8c"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, + "ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"}, "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, "erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, diff --git a/elixir/test/support/live_e2e_docker/Dockerfile b/elixir/test/support/live_e2e_docker/Dockerfile new file mode 100644 index 000000000..974625c1c --- /dev/null +++ b/elixir/test/support/live_e2e_docker/Dockerfile @@ -0,0 +1,22 @@ +FROM node:20-bookworm-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + bash \ + ca-certificates \ + git \ + openssh-server \ + python3 \ + ripgrep \ + && rm -rf /var/lib/apt/lists/* + +RUN install -d -m 700 /root/.ssh /root/.codex /run/symphony/ssh /var/run/sshd + +RUN npm install --global @openai/codex + +COPY symphony-live-worker.conf /etc/ssh/sshd_config.d/symphony-live-worker.conf +COPY live_worker_entrypoint.sh /usr/local/bin/symphony-live-worker +RUN chmod 755 /usr/local/bin/symphony-live-worker + +EXPOSE 22 + +ENTRYPOINT ["/usr/local/bin/symphony-live-worker"] diff --git a/elixir/test/support/live_e2e_docker/docker-compose.yml b/elixir/test/support/live_e2e_docker/docker-compose.yml new file mode 100644 index 000000000..31584538d --- /dev/null +++ b/elixir/test/support/live_e2e_docker/docker-compose.yml @@ -0,0 +1,20 @@ +services: + worker1: + build: + context: . + dockerfile: Dockerfile + ports: + - "${SYMPHONY_LIVE_DOCKER_WORKER_1_PORT}:22" + volumes: + - ${SYMPHONY_LIVE_DOCKER_AUTHORIZED_KEY}:/run/symphony/ssh/authorized_key.pub:ro + - ${SYMPHONY_LIVE_DOCKER_AUTH_JSON}:/root/.codex/auth.json:ro + + worker2: + build: + context: . + dockerfile: Dockerfile + ports: + - "${SYMPHONY_LIVE_DOCKER_WORKER_2_PORT}:22" + volumes: + - ${SYMPHONY_LIVE_DOCKER_AUTHORIZED_KEY}:/run/symphony/ssh/authorized_key.pub:ro + - ${SYMPHONY_LIVE_DOCKER_AUTH_JSON}:/root/.codex/auth.json:ro diff --git a/elixir/test/support/live_e2e_docker/live_worker_entrypoint.sh b/elixir/test/support/live_e2e_docker/live_worker_entrypoint.sh new file mode 100644 index 000000000..3b70e6f4e --- /dev/null +++ b/elixir/test/support/live_e2e_docker/live_worker_entrypoint.sh @@ -0,0 +1,13 @@ +#!/bin/sh +set -eu + +install -d -m 700 /root/.ssh /root/.codex + +if [ ! -s /run/symphony/ssh/authorized_key.pub ]; then + echo "missing authorized key at /run/symphony/ssh/authorized_key.pub" >&2 + exit 1 +fi + +install -m 600 /run/symphony/ssh/authorized_key.pub /root/.ssh/authorized_keys + +exec /usr/sbin/sshd -D -e diff --git a/elixir/test/support/live_e2e_docker/symphony-live-worker.conf b/elixir/test/support/live_e2e_docker/symphony-live-worker.conf new file mode 100644 index 000000000..45cc12dc6 --- /dev/null +++ b/elixir/test/support/live_e2e_docker/symphony-live-worker.conf @@ -0,0 +1,7 @@ +PubkeyAuthentication yes +PasswordAuthentication no +KbdInteractiveAuthentication no +ChallengeResponseAuthentication no +UsePAM no +PermitRootLogin yes +AuthorizedKeysFile .ssh/authorized_keys diff --git a/elixir/test/support/test_support.exs b/elixir/test/support/test_support.exs index bea30f2cf..973fbfbfb 100644 --- a/elixir/test/support/test_support.exs +++ b/elixir/test/support/test_support.exs @@ -101,6 +101,8 @@ defmodule SymphonyElixir.TestSupport do tracker_terminal_states: ["Closed", "Cancelled", "Canceled", "Duplicate", "Done"], poll_interval_ms: 30_000, workspace_root: Path.join(System.tmp_dir!(), "symphony_workspaces"), + worker_ssh_hosts: [], + worker_max_concurrent_agents_per_host: nil, max_concurrent_agents: 10, max_turns: 20, max_retry_backoff_ms: 300_000, @@ -115,6 +117,7 @@ defmodule SymphonyElixir.TestSupport do hook_after_create: nil, hook_before_run: nil, hook_after_run: nil, + hook_after_implement: nil, hook_before_remove: nil, hook_timeout_ms: 60_000, observability_enabled: true, @@ -136,6 +139,8 @@ defmodule SymphonyElixir.TestSupport do tracker_terminal_states = Keyword.get(config, :tracker_terminal_states) poll_interval_ms = Keyword.get(config, :poll_interval_ms) workspace_root = Keyword.get(config, :workspace_root) + worker_ssh_hosts = Keyword.get(config, :worker_ssh_hosts) + worker_max_concurrent_agents_per_host = Keyword.get(config, :worker_max_concurrent_agents_per_host) 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) @@ -150,6 +155,7 @@ defmodule SymphonyElixir.TestSupport do hook_after_create = Keyword.get(config, :hook_after_create) hook_before_run = Keyword.get(config, :hook_before_run) hook_after_run = Keyword.get(config, :hook_after_run) + hook_after_implement = Keyword.get(config, :hook_after_implement) hook_before_remove = Keyword.get(config, :hook_before_remove) hook_timeout_ms = Keyword.get(config, :hook_timeout_ms) observability_enabled = Keyword.get(config, :observability_enabled) @@ -174,6 +180,7 @@ defmodule SymphonyElixir.TestSupport do " interval_ms: #{yaml_value(poll_interval_ms)}", "workspace:", " root: #{yaml_value(workspace_root)}", + worker_yaml(worker_ssh_hosts, worker_max_concurrent_agents_per_host), "agent:", " max_concurrent_agents: #{yaml_value(max_concurrent_agents)}", " max_turns: #{yaml_value(max_turns)}", @@ -187,7 +194,14 @@ defmodule SymphonyElixir.TestSupport do " turn_timeout_ms: #{yaml_value(codex_turn_timeout_ms)}", " read_timeout_ms: #{yaml_value(codex_read_timeout_ms)}", " stall_timeout_ms: #{yaml_value(codex_stall_timeout_ms)}", - hooks_yaml(hook_after_create, hook_before_run, hook_after_run, hook_before_remove, hook_timeout_ms), + hooks_yaml( + hook_after_create, + hook_before_run, + hook_after_run, + hook_after_implement, + hook_before_remove, + hook_timeout_ms + ), observability_yaml(observability_enabled, observability_refresh_ms, observability_render_interval_ms), server_yaml(server_port, server_host), "---", @@ -220,21 +234,45 @@ defmodule SymphonyElixir.TestSupport do defp yaml_value(value), do: yaml_value(to_string(value)) - defp hooks_yaml(nil, nil, nil, nil, timeout_ms), do: "hooks:\n timeout_ms: #{yaml_value(timeout_ms)}" - - defp hooks_yaml(hook_after_create, hook_before_run, hook_after_run, hook_before_remove, timeout_ms) do + defp hooks_yaml(nil, nil, nil, nil, nil, timeout_ms), + do: "hooks:\n timeout_ms: #{yaml_value(timeout_ms)}" + + defp hooks_yaml( + hook_after_create, + hook_before_run, + hook_after_run, + hook_after_implement, + hook_before_remove, + timeout_ms + ) do [ "hooks:", " timeout_ms: #{yaml_value(timeout_ms)}", hook_entry("after_create", hook_after_create), hook_entry("before_run", hook_before_run), hook_entry("after_run", hook_after_run), + hook_entry("after_implement", hook_after_implement), hook_entry("before_remove", hook_before_remove) ] |> Enum.reject(&is_nil/1) |> Enum.join("\n") end + defp worker_yaml(ssh_hosts, max_concurrent_agents_per_host) + when ssh_hosts in [nil, []] and is_nil(max_concurrent_agents_per_host), + do: nil + + defp worker_yaml(ssh_hosts, max_concurrent_agents_per_host) do + [ + "worker:", + ssh_hosts not in [nil, []] && " ssh_hosts: #{yaml_value(ssh_hosts)}", + !is_nil(max_concurrent_agents_per_host) && + " max_concurrent_agents_per_host: #{yaml_value(max_concurrent_agents_per_host)}" + ] + |> Enum.reject(&(&1 in [nil, false])) + |> Enum.join("\n") + end + defp observability_yaml(enabled, refresh_ms, render_interval_ms) do [ "observability:", diff --git a/elixir/test/symphony_elixir/app_server_test.exs b/elixir/test/symphony_elixir/app_server_test.exs index 20ab61e9c..d03627f8b 100644 --- a/elixir/test/symphony_elixir/app_server_test.exs +++ b/elixir/test/symphony_elixir/app_server_test.exs @@ -39,6 +39,150 @@ defmodule SymphonyElixir.AppServerTest do end end + test "app server rejects symlink escape cwd paths under the workspace root" do + test_root = + Path.join( + System.tmp_dir!(), + "symphony-elixir-app-server-symlink-cwd-guard-#{System.unique_integer([:positive])}" + ) + + try do + workspace_root = Path.join(test_root, "workspaces") + outside_workspace = Path.join(test_root, "outside") + symlink_workspace = Path.join(workspace_root, "MT-1000") + + File.mkdir_p!(workspace_root) + File.mkdir_p!(outside_workspace) + File.ln_s!(outside_workspace, symlink_workspace) + + write_workflow_file!(Workflow.workflow_file_path(), + workspace_root: workspace_root + ) + + issue = %Issue{ + id: "issue-workspace-symlink-guard", + identifier: "MT-1000", + title: "Validate symlink workspace guard", + description: "Ensure app-server refuses symlink escape cwd targets", + state: "In Progress", + url: "https://example.org/issues/MT-1000", + labels: ["backend"] + } + + assert {:error, {:invalid_workspace_cwd, :symlink_escape, ^symlink_workspace, _root}} = + AppServer.run(symlink_workspace, "guard", issue) + after + File.rm_rf(test_root) + end + end + + test "app server passes explicit turn sandbox policies through unchanged" do + test_root = + Path.join( + System.tmp_dir!(), + "symphony-elixir-app-server-supported-turn-policies-#{System.unique_integer([:positive])}" + ) + + try do + workspace_root = Path.join(test_root, "workspaces") + workspace = Path.join(workspace_root, "MT-1001") + codex_binary = Path.join(test_root, "fake-codex") + trace_file = Path.join(test_root, "codex-supported-turn-policies.trace") + previous_trace = System.get_env("SYMP_TEST_CODEx_TRACE") + + on_exit(fn -> + if is_binary(previous_trace) do + System.put_env("SYMP_TEST_CODEx_TRACE", previous_trace) + else + System.delete_env("SYMP_TEST_CODEx_TRACE") + end + end) + + System.put_env("SYMP_TEST_CODEx_TRACE", trace_file) + File.mkdir_p!(workspace) + + File.write!(codex_binary, """ + #!/bin/sh + trace_file="${SYMP_TEST_CODEx_TRACE:-/tmp/codex-supported-turn-policies.trace}" + count=0 + + while IFS= read -r line; do + count=$((count + 1)) + printf 'JSON:%s\\n' "$line" >> "$trace_file" + + case "$count" in + 1) + printf '%s\\n' '{"id":1,"result":{}}' + ;; + 2) + printf '%s\\n' '{"id":2,"result":{"thread":{"id":"thread-1001"}}}' + ;; + 3) + printf '%s\\n' '{"id":3,"result":{"turn":{"id":"turn-1001"}}}' + ;; + 4) + printf '%s\\n' '{"method":"turn/completed"}' + exit 0 + ;; + *) + exit 0 + ;; + esac + done + """) + + File.chmod!(codex_binary, 0o755) + + issue = %Issue{ + id: "issue-supported-turn-policies", + identifier: "MT-1001", + title: "Validate explicit turn sandbox policy passthrough", + description: "Ensure runtime startup forwards configured turn sandbox policies unchanged", + state: "In Progress", + url: "https://example.org/issues/MT-1001", + labels: ["backend"] + } + + policy_cases = [ + %{"type" => "dangerFullAccess"}, + %{"type" => "externalSandbox", "profile" => "remote-ci"}, + %{"type" => "workspaceWrite", "writableRoots" => ["relative/path"], "networkAccess" => true}, + %{"type" => "futureSandbox", "nested" => %{"flag" => true}} + ] + + Enum.each(policy_cases, fn configured_policy -> + File.rm(trace_file) + + write_workflow_file!(Workflow.workflow_file_path(), + workspace_root: workspace_root, + codex_command: "#{codex_binary} app-server", + codex_turn_sandbox_policy: configured_policy + ) + + assert {:ok, _result} = AppServer.run(workspace, "Validate supported turn policy", issue) + + trace = File.read!(trace_file) + lines = String.split(trace, "\n", trim: true) + + assert Enum.any?(lines, fn line -> + if String.starts_with?(line, "JSON:") do + line + |> String.trim_leading("JSON:") + |> Jason.decode!() + |> then(fn payload -> + payload["method"] == "turn/start" && + get_in(payload, ["params", "sandboxPolicy"]) == configured_policy + end) + else + false + end + end) + end) + after + File.rm_rf(test_root) + end + end + test "app server marks request-for-input events as a hard failure" do test_root = Path.join( @@ -681,9 +825,8 @@ defmodule SymphonyElixir.AppServerTest do payload["id"] == 101 and get_in(payload, ["result", "success"]) == false and - get_in(payload, ["result", "contentItems", Access.at(0), "type"]) == "inputText" and String.contains?( - get_in(payload, ["result", "contentItems", Access.at(0), "text"]), + get_in(payload, ["result", "output"]), "Unsupported dynamic tool" ) else @@ -806,7 +949,7 @@ defmodule SymphonyElixir.AppServerTest do payload["id"] == 102 and get_in(payload, ["result", "success"]) == true and - get_in(payload, ["result", "contentItems", Access.at(0), "text"]) == + get_in(payload, ["result", "output"]) == ~s({"data":{"viewer":{"id":"usr_123"}}}) else false @@ -1045,14 +1188,223 @@ defmodule SymphonyElixir.AppServerTest do labels: ["backend"] } + test_pid = self() + on_message = fn message -> send(test_pid, {:app_server_message, message}) end + log = capture_log(fn -> - assert {:ok, _result} = AppServer.run(workspace, "Capture stderr log", issue) + assert {:ok, _result} = + AppServer.run(workspace, "Capture stderr log", issue, on_message: on_message) end) + assert_received {:app_server_message, %{event: :turn_completed}} + refute_received {:app_server_message, %{event: :malformed}} assert log =~ "Codex turn stream output: warning: this is stderr noise" after File.rm_rf(test_root) end end + + test "app server emits malformed events for JSON-like protocol lines that fail to decode" do + test_root = + Path.join( + System.tmp_dir!(), + "symphony-elixir-app-server-malformed-protocol-#{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' '{"method":"turn/completed"' + 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-malformed-protocol", + identifier: "MT-93", + title: "Malformed protocol frame", + description: "Ensure malformed JSON-like frames are surfaced to the orchestrator", + state: "In Progress", + url: "https://example.org/issues/MT-93", + labels: ["backend"] + } + + test_pid = self() + on_message = fn message -> send(test_pid, {:app_server_message, message}) end + + assert {:ok, _result} = + AppServer.run(workspace, "Capture malformed protocol line", issue, on_message: on_message) + + assert_received {:app_server_message, %{event: :malformed, payload: "{\"method\":\"turn/completed\""}} + assert_received {:app_server_message, %{event: :turn_completed}} + after + File.rm_rf(test_root) + end + end + + test "app server launches over ssh for remote workers" do + test_root = + Path.join( + System.tmp_dir!(), + "symphony-elixir-app-server-remote-ssh-#{System.unique_integer([:positive])}" + ) + + previous_path = System.get_env("PATH") + previous_trace = System.get_env("SYMP_TEST_SSH_TRACE") + + on_exit(fn -> + restore_env("PATH", previous_path) + restore_env("SYMP_TEST_SSH_TRACE", previous_trace) + end) + + try do + trace_file = Path.join(test_root, "ssh.trace") + fake_ssh = Path.join(test_root, "ssh") + remote_workspace = "/remote/workspaces/MT-REMOTE" + + File.mkdir_p!(test_root) + System.put_env("SYMP_TEST_SSH_TRACE", trace_file) + System.put_env("PATH", test_root <> ":" <> (previous_path || "")) + + File.write!(fake_ssh, """ + #!/bin/sh + trace_file="${SYMP_TEST_SSH_TRACE:-/tmp/symphony-fake-ssh.trace}" + count=0 + printf 'ARGV:%s\\n' "$*" >> "$trace_file" + + while IFS= read -r line; do + count=$((count + 1)) + printf 'JSON:%s\\n' "$line" >> "$trace_file" + + case "$count" in + 1) + printf '%s\\n' '{"id":1,"result":{}}' + ;; + 2) + printf '%s\\n' '{"id":2,"result":{"thread":{"id":"thread-remote"}}}' + ;; + 3) + printf '%s\\n' '{"id":3,"result":{"turn":{"id":"turn-remote"}}}' + ;; + 4) + printf '%s\\n' '{"method":"turn/completed"}' + exit 0 + ;; + *) + exit 0 + ;; + esac + done + """) + + File.chmod!(fake_ssh, 0o755) + + write_workflow_file!(Workflow.workflow_file_path(), + workspace_root: "/remote/workspaces", + codex_command: "fake-remote-codex app-server" + ) + + issue = %Issue{ + id: "issue-remote", + identifier: "MT-REMOTE", + title: "Run remote app server", + description: "Validate ssh-backed codex startup", + state: "In Progress", + url: "https://example.org/issues/MT-REMOTE", + labels: ["backend"] + } + + assert {:ok, _result} = + AppServer.run( + remote_workspace, + "Run remote worker", + issue, + worker_host: "worker-01:2200" + ) + + trace = File.read!(trace_file) + lines = String.split(trace, "\n", trim: true) + + assert argv_line = Enum.find(lines, &String.starts_with?(&1, "ARGV:")) + assert argv_line =~ "-T -p 2200 worker-01 bash -lc" + assert argv_line =~ "cd " + assert argv_line =~ remote_workspace + assert argv_line =~ "exec " + assert argv_line =~ "fake-remote-codex app-server" + + expected_turn_policy = %{ + "type" => "workspaceWrite", + "writableRoots" => [remote_workspace], + "readOnlyAccess" => %{"type" => "fullAccess"}, + "networkAccess" => false, + "excludeTmpdirEnvVar" => false, + "excludeSlashTmp" => false + } + + assert Enum.any?(lines, fn line -> + if String.starts_with?(line, "JSON:") do + line + |> String.trim_leading("JSON:") + |> Jason.decode!() + |> then(fn payload -> + payload["method"] == "thread/start" && + get_in(payload, ["params", "cwd"]) == remote_workspace + end) + else + false + end + end) + + assert Enum.any?(lines, fn line -> + if String.starts_with?(line, "JSON:") do + line + |> String.trim_leading("JSON:") + |> Jason.decode!() + |> then(fn payload -> + payload["method"] == "turn/start" && + get_in(payload, ["params", "cwd"]) == remote_workspace && + get_in(payload, ["params", "sandboxPolicy"]) == expected_turn_policy + end) + else + false + end + end) + after + File.rm_rf(test_root) + end + end end diff --git a/elixir/test/symphony_elixir/core_test.exs b/elixir/test/symphony_elixir/core_test.exs index 400c006e4..b734299cc 100644 --- a/elixir/test/symphony_elixir/core_test.exs +++ b/elixir/test/symphony_elixir/core_test.exs @@ -1,6 +1,55 @@ defmodule SymphonyElixir.CoreTest do use SymphonyElixir.TestSupport + defmodule FakeStageOrchestrator do + def next_stage(workpad, linear_state, workspace_path) do + notify({:stage_next_stage, workpad, linear_state, workspace_path}) + Application.get_env(:symphony_elixir, :fake_stage_next_stage_result, :implement) + end + + defp notify(message) do + case Application.get_env(:symphony_elixir, :fake_stage_test_recipient) do + pid when is_pid(pid) -> send(pid, message) + _ -> :ok + end + end + end + + defmodule FakeAgentRunner do + def run(issue, recipient, opts) do + case Application.get_env(:symphony_elixir, :fake_stage_test_recipient) do + pid when is_pid(pid) -> send(pid, {:agent_runner_run, issue, recipient, opts}) + _ -> :ok + end + + :ok + end + end + + defmodule FakeStageCloseout do + def check_review(workspace_path, workpad_text, dispatch_head_sha, issue_identifier) do + notify({:stage_closeout_review, workspace_path, workpad_text, dispatch_head_sha, issue_identifier}) + Application.get_env(:symphony_elixir, :fake_stage_closeout_review_result, :ok) + end + + def check_doc_fix(workspace_path, workpad_text, issue_identifier) do + notify({:stage_closeout_doc_fix, workspace_path, workpad_text, issue_identifier}) + Application.get_env(:symphony_elixir, :fake_stage_closeout_doc_fix_result, :ok) + end + + def check_implement(workspace_path, workpad_text, issue_identifier) do + notify({:stage_closeout_implement, workspace_path, workpad_text, issue_identifier}) + Application.get_env(:symphony_elixir, :fake_stage_closeout_implement_result, :ok) + end + + defp notify(message) do + case Application.get_env(:symphony_elixir, :fake_stage_test_recipient) do + pid when is_pid(pid) -> send(pid, message) + _ -> :ok + end + end + end + test "config defaults and validation checks" do write_workflow_file!(Workflow.workflow_file_path(), tracker_api_token: nil, @@ -11,26 +60,35 @@ defmodule SymphonyElixir.CoreTest do codex_command: nil ) - 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_assignee() == nil - assert Config.agent_max_turns() == 20 + config = Config.settings!() + assert config.polling.interval_ms == 30_000 + assert config.tracker.active_states == ["Todo", "In Progress"] + assert config.tracker.terminal_states == ["Closed", "Cancelled", "Canceled", "Duplicate", "Done"] + assert config.tracker.assignee == nil + assert config.agent.max_turns == 20 write_workflow_file!(Workflow.workflow_file_path(), poll_interval_ms: "invalid") - assert Config.poll_interval_ms() == 30_000 + + assert_raise ArgumentError, ~r/interval_ms/, fn -> + Config.settings!().polling.interval_ms + end + + assert {:error, {:invalid_workflow_config, message}} = Config.validate!() + assert message =~ "polling.interval_ms" write_workflow_file!(Workflow.workflow_file_path(), poll_interval_ms: 45_000) - assert Config.poll_interval_ms() == 45_000 + assert Config.settings!().polling.interval_ms == 45_000 write_workflow_file!(Workflow.workflow_file_path(), max_turns: 0) - assert Config.agent_max_turns() == 20 + assert {:error, {:invalid_workflow_config, message}} = Config.validate!() + assert message =~ "agent.max_turns" write_workflow_file!(Workflow.workflow_file_path(), max_turns: 5) - assert Config.agent_max_turns() == 5 + assert Config.settings!().agent.max_turns == 5 write_workflow_file!(Workflow.workflow_file_path(), tracker_active_states: "Todo, Review,") - assert Config.linear_active_states() == ["Todo", "Review"] + assert {:error, {:invalid_workflow_config, message}} = Config.validate!() + assert message =~ "tracker.active_states" write_workflow_file!(Workflow.workflow_file_path(), tracker_api_token: "token", @@ -44,7 +102,13 @@ defmodule SymphonyElixir.CoreTest do codex_command: "" ) + assert {:error, {:invalid_workflow_config, message}} = Config.validate!() + assert message =~ "codex.command" + assert message =~ "can't be blank" + + write_workflow_file!(Workflow.workflow_file_path(), codex_command: " ") assert :ok = Config.validate!() + assert Config.settings!().codex.command == " " write_workflow_file!(Workflow.workflow_file_path(), codex_command: "/bin/sh app-server") assert :ok = Config.validate!() @@ -62,12 +126,14 @@ defmodule SymphonyElixir.CoreTest do assert :ok = Config.validate!() write_workflow_file!(Workflow.workflow_file_path(), codex_approval_policy: 123) - assert {:error, {:invalid_codex_approval_policy, 123}} = Config.validate!() + assert {:error, {:invalid_workflow_config, message}} = Config.validate!() + assert message =~ "codex.approval_policy" write_workflow_file!(Workflow.workflow_file_path(), codex_thread_sandbox: 123) - assert {:error, {:invalid_codex_thread_sandbox, 123}} = Config.validate!() + assert {:error, {:invalid_workflow_config, message}} = Config.validate!() + assert message =~ "codex.thread_sandbox" - write_workflow_file!(Workflow.workflow_file_path(), tracker_kind: 123) + write_workflow_file!(Workflow.workflow_file_path(), tracker_kind: "123") assert {:error, {:unsupported_tracker_kind, "123"}} = Config.validate!() end @@ -91,6 +157,7 @@ defmodule SymphonyElixir.CoreTest do 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, "after_implement") =~ "cd elixir && mise exec -- mix test" assert Map.get(hooks, "before_remove") =~ "cd elixir && mise exec -- mix workspace.before_remove" assert String.trim(prompt) != "" @@ -111,8 +178,8 @@ defmodule SymphonyElixir.CoreTest do codex_command: "/bin/sh app-server" ) - assert Config.linear_api_token() == env_api_key - assert Config.linear_project_slug() == "project" + assert Config.settings!().tracker.api_key == env_api_key + assert Config.settings!().tracker.project_slug == "project" assert :ok = Config.validate!() end @@ -129,7 +196,7 @@ defmodule SymphonyElixir.CoreTest do codex_command: "/bin/sh app-server" ) - assert Config.linear_assignee() == env_assignee + assert Config.settings!().tracker.assignee == env_assignee end test "workflow file path defaults to WORKFLOW.md in the current working directory when app env is unset" do @@ -333,6 +400,84 @@ defmodule SymphonyElixir.CoreTest do end end + test "missing running issues stop active agents without cleaning the workspace" do + test_root = + Path.join( + System.tmp_dir!(), + "symphony-elixir-missing-running-reconcile-#{System.unique_integer([:positive])}" + ) + + previous_memory_issues = Application.get_env(:symphony_elixir, :memory_tracker_issues) + issue_id = "issue-missing" + issue_identifier = "MT-557" + + try do + write_workflow_file!(Workflow.workflow_file_path(), + tracker_kind: "memory", + workspace_root: test_root, + tracker_active_states: ["Todo", "In Progress", "In Review"], + tracker_terminal_states: ["Closed", "Cancelled", "Canceled", "Duplicate"], + poll_interval_ms: 30_000 + ) + + Application.put_env(:symphony_elixir, :memory_tracker_issues, []) + + orchestrator_name = Module.concat(__MODULE__, :MissingRunningIssueOrchestrator) + {:ok, pid} = Orchestrator.start_link(name: orchestrator_name) + + on_exit(fn -> + restore_app_env(:memory_tracker_issues, previous_memory_issues) + + if Process.alive?(pid) do + Process.exit(pid, :normal) + end + end) + + Process.sleep(50) + + assert {:ok, workspace} = + SymphonyElixir.PathSafety.canonicalize(Path.join(test_root, issue_identifier)) + + File.mkdir_p!(workspace) + + agent_pid = + spawn(fn -> + receive do + :stop -> :ok + end + end) + + initial_state = :sys.get_state(pid) + + running_entry = %{ + pid: agent_pid, + ref: nil, + identifier: issue_identifier, + issue: %Issue{id: issue_id, state: "In Progress", identifier: issue_identifier}, + 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, :tick) + Process.sleep(100) + state = :sys.get_state(pid) + + refute Map.has_key?(state.running, issue_id) + refute MapSet.member?(state.claimed, issue_id) + refute Process.alive?(agent_pid) + assert File.exists?(workspace) + after + restore_app_env(:memory_tracker_issues, previous_memory_issues) + File.rm_rf(test_root) + end + end + test "reconcile updates running issue state for active issues" do issue_id = "issue-3" @@ -459,6 +604,331 @@ defmodule SymphonyElixir.CoreTest do assert_due_in_range(due_at_ms, 500, 1_100) end + test "orchestrator evaluates next stage before dispatching a ticket" do + previous_memory_issues = Application.get_env(:symphony_elixir, :memory_tracker_issues) + previous_stage_orchestrator = Application.get_env(:symphony_elixir, :stage_orchestrator_module) + previous_stage_closeout = Application.get_env(:symphony_elixir, :stage_closeout_module) + previous_agent_runner = Application.get_env(:symphony_elixir, :orchestrator_agent_runner_module) + previous_stage_recipient = Application.get_env(:symphony_elixir, :fake_stage_test_recipient) + previous_next_stage = Application.get_env(:symphony_elixir, :fake_stage_next_stage_result) + + on_exit(fn -> + restore_app_env(:memory_tracker_issues, previous_memory_issues) + restore_app_env(:stage_orchestrator_module, previous_stage_orchestrator) + restore_app_env(:stage_closeout_module, previous_stage_closeout) + restore_app_env(:orchestrator_agent_runner_module, previous_agent_runner) + restore_app_env(:fake_stage_test_recipient, previous_stage_recipient) + restore_app_env(:fake_stage_next_stage_result, previous_next_stage) + end) + + issue = %Issue{ + id: "issue-stage-dispatch", + identifier: "MT-562", + title: "Dispatch through stage orchestrator", + description: "## Workpad\n", + state: "In Progress" + } + + Application.put_env(:symphony_elixir, :memory_tracker_issues, [issue]) + Application.put_env(:symphony_elixir, :stage_orchestrator_module, FakeStageOrchestrator) + Application.put_env(:symphony_elixir, :stage_closeout_module, FakeStageCloseout) + Application.put_env(:symphony_elixir, :orchestrator_agent_runner_module, FakeAgentRunner) + Application.put_env(:symphony_elixir, :fake_stage_test_recipient, self()) + Application.put_env(:symphony_elixir, :fake_stage_next_stage_result, :review) + + write_workflow_file!(Workflow.workflow_file_path(), tracker_kind: "memory") + + orchestrator_name = Module.concat(__MODULE__, :StageDispatchOrchestrator) + {:ok, pid} = Orchestrator.start_link(name: orchestrator_name) + + on_exit(fn -> + if Process.alive?(pid) do + Process.exit(pid, :normal) + end + end) + + assert_receive {:stage_next_stage, "## Workpad\n", "In Progress", workspace_path}, 1_000 + assert {:ok, ^workspace_path} = Workspace.path_for_issue(issue) + assert_receive {:agent_runner_run, ^issue, ^pid, opts}, 1_000 + assert Keyword.get(opts, :attempt) == nil + assert Keyword.get(opts, :worker_host) == nil + assert Keyword.get(opts, :max_turns) == 1 + + assert Keyword.get(opts, :workflow_path) == + Path.join(Path.dirname(Workflow.workflow_file_path()), "WORKFLOW-review.md") + end + + test "closeout failure moves the issue to Rework and appends narration to the workpad" do + previous_memory_issues = Application.get_env(:symphony_elixir, :memory_tracker_issues) + previous_memory_recipient = Application.get_env(:symphony_elixir, :memory_tracker_recipient) + previous_stage_closeout = Application.get_env(:symphony_elixir, :stage_closeout_module) + previous_stage_recipient = Application.get_env(:symphony_elixir, :fake_stage_test_recipient) + previous_closeout_result = Application.get_env(:symphony_elixir, :fake_stage_closeout_review_result) + + on_exit(fn -> + restore_app_env(:memory_tracker_issues, previous_memory_issues) + restore_app_env(:memory_tracker_recipient, previous_memory_recipient) + restore_app_env(:stage_closeout_module, previous_stage_closeout) + restore_app_env(:fake_stage_test_recipient, previous_stage_recipient) + restore_app_env(:fake_stage_closeout_review_result, previous_closeout_result) + end) + + refreshed_issue = %Issue{ + id: "issue-closeout", + identifier: "MT-563", + title: "Handle closeout failure", + description: "Fresh workpad\n\n\n", + state: "In Progress" + } + + Application.put_env(:symphony_elixir, :memory_tracker_issues, [refreshed_issue]) + Application.put_env(:symphony_elixir, :memory_tracker_recipient, self()) + Application.put_env(:symphony_elixir, :stage_closeout_module, FakeStageCloseout) + Application.put_env(:symphony_elixir, :fake_stage_test_recipient, self()) + + Application.put_env( + :symphony_elixir, + :fake_stage_closeout_review_result, + {:error, {:working_tree_dirty, ["?? scratch.txt"]}} + ) + + write_workflow_file!(Workflow.workflow_file_path(), tracker_kind: "memory") + + issue_id = refreshed_issue.id + ref = make_ref() + orchestrator_name = Module.concat(__MODULE__, :CloseoutFailureOrchestrator) + {: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: refreshed_issue.identifier, + issue: %{refreshed_issue | description: "stale workpad"}, + stage: :review, + dispatch_head_sha: String.duplicate("a", 40), + workspace_path: "/tmp/stage-workspace", + 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}) + + assert_receive {:stage_closeout_review, "/tmp/stage-workspace", workpad_text, dispatch_head_sha, "MT-563"}, 1_000 + assert workpad_text =~ "Fresh workpad" + assert dispatch_head_sha == String.duplicate("a", 40) + assert_receive {:memory_tracker_state_update, "issue-closeout", "Rework"}, 1_000 + assert_receive {:memory_tracker_description_update, "issue-closeout", updated_workpad}, 1_000 + assert updated_workpad =~ "Fresh workpad" + assert updated_workpad =~ "Symphony closeout failure" + assert updated_workpad =~ "stage: review" + assert updated_workpad =~ "gate_failed: working_tree_dirty" + assert updated_workpad =~ "reason: {:working_tree_dirty, [\"?? scratch.txt\"]}" + + state = :sys.get_state(pid) + + assert MapSet.member?(state.completed, issue_id) + assert %{attempt: 1, due_at_ms: due_at_ms} = state.retry_attempts[issue_id] + assert_due_in_range(due_at_ms, 500, 1_100) + end + + test "implement success runs after_implement hook and appends review-request marker" do + previous_memory_issues = Application.get_env(:symphony_elixir, :memory_tracker_issues) + previous_memory_recipient = Application.get_env(:symphony_elixir, :memory_tracker_recipient) + previous_stage_closeout = Application.get_env(:symphony_elixir, :stage_closeout_module) + previous_stage_recipient = Application.get_env(:symphony_elixir, :fake_stage_test_recipient) + + on_exit(fn -> + restore_app_env(:memory_tracker_issues, previous_memory_issues) + restore_app_env(:memory_tracker_recipient, previous_memory_recipient) + restore_app_env(:stage_closeout_module, previous_stage_closeout) + restore_app_env(:fake_stage_test_recipient, previous_stage_recipient) + end) + + test_root = + Path.join( + System.tmp_dir!(), + "symphony-elixir-implement-handoff-success-#{System.unique_integer([:positive])}" + ) + + issue_id = "issue-implement-success" + + try do + workspace_path = init_git_repo!(test_root) + head = git!(workspace_path, ["rev-parse", "HEAD"]) |> String.trim() + + refreshed_issue = %Issue{ + id: issue_id, + identifier: "MT-700", + title: "Successful implement handoff", + description: workpad_fixture("Fresh workpad"), + state: "In Progress" + } + + Application.put_env(:symphony_elixir, :memory_tracker_issues, [refreshed_issue]) + Application.put_env(:symphony_elixir, :memory_tracker_recipient, self()) + Application.put_env(:symphony_elixir, :stage_closeout_module, FakeStageCloseout) + Application.put_env(:symphony_elixir, :fake_stage_test_recipient, self()) + + write_workflow_file!(Workflow.workflow_file_path(), + tracker_kind: "memory", + hook_after_implement: "git rev-parse HEAD >/dev/null" + ) + + orchestrator_name = Module.concat(__MODULE__, :ImplementHandoffSuccessOrchestrator) + {: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: make_ref(), + identifier: refreshed_issue.identifier, + issue: %{refreshed_issue | description: "stale workpad"}, + stage: :implement, + workspace_path: workspace_path, + 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, running_entry.ref, :process, self(), :normal}) + + assert_receive {:stage_closeout_implement, ^workspace_path, workpad_text, "MT-700"}, 1_000 + assert workpad_text =~ "Fresh workpad" + assert_receive {:memory_tracker_description_update, ^issue_id, updated_workpad}, 1_000 + assert updated_workpad =~ "Fresh workpad" + assert updated_workpad =~ "kind: review-request" + assert updated_workpad =~ "round_id: 1" + assert updated_workpad =~ "stage_round: 1" + assert updated_workpad =~ "reviewed_sha: #{head}" + assert updated_workpad =~ "issue_identifier: MT-700" + refute_received {:memory_tracker_state_update, ^issue_id, _} + + state = :sys.get_state(pid) + assert MapSet.member?(state.completed, issue_id) + assert %{attempt: 1, due_at_ms: due_at_ms} = state.retry_attempts[issue_id] + assert_due_in_range(due_at_ms, 500, 1_100) + after + File.rm_rf(test_root) + end + end + + test "implement handoff failure appends narration and leaves the issue active" do + previous_memory_issues = Application.get_env(:symphony_elixir, :memory_tracker_issues) + previous_memory_recipient = Application.get_env(:symphony_elixir, :memory_tracker_recipient) + previous_stage_closeout = Application.get_env(:symphony_elixir, :stage_closeout_module) + previous_stage_recipient = Application.get_env(:symphony_elixir, :fake_stage_test_recipient) + + on_exit(fn -> + restore_app_env(:memory_tracker_issues, previous_memory_issues) + restore_app_env(:memory_tracker_recipient, previous_memory_recipient) + restore_app_env(:stage_closeout_module, previous_stage_closeout) + restore_app_env(:fake_stage_test_recipient, previous_stage_recipient) + end) + + test_root = + Path.join( + System.tmp_dir!(), + "symphony-elixir-implement-handoff-failure-#{System.unique_integer([:positive])}" + ) + + issue_id = "issue-implement-failure" + + try do + workspace_path = init_git_repo!(test_root) + + refreshed_issue = %Issue{ + id: issue_id, + identifier: "MT-701", + title: "Failed implement handoff", + description: workpad_fixture("Fresh workpad"), + state: "In Progress" + } + + Application.put_env(:symphony_elixir, :memory_tracker_issues, [refreshed_issue]) + Application.put_env(:symphony_elixir, :memory_tracker_recipient, self()) + Application.put_env(:symphony_elixir, :stage_closeout_module, FakeStageCloseout) + Application.put_env(:symphony_elixir, :fake_stage_test_recipient, self()) + + write_workflow_file!(Workflow.workflow_file_path(), + tracker_kind: "memory", + hook_after_implement: "echo test-failed && exit 17" + ) + + orchestrator_name = Module.concat(__MODULE__, :ImplementHandoffFailureOrchestrator) + {: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: make_ref(), + identifier: refreshed_issue.identifier, + issue: %{refreshed_issue | description: "stale workpad"}, + stage: :implement, + workspace_path: workspace_path, + 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, running_entry.ref, :process, self(), :normal}) + + assert_receive {:stage_closeout_implement, ^workspace_path, workpad_text, "MT-701"}, 1_000 + assert workpad_text =~ "Fresh workpad" + assert_receive {:memory_tracker_description_update, ^issue_id, updated_workpad}, 1_000 + assert updated_workpad =~ "Fresh workpad" + assert updated_workpad =~ "Symphony implement handoff blocked" + assert updated_workpad =~ "stage: implement" + assert updated_workpad =~ ~s(reason: {:workspace_hook_failed, "after_implement", 17, "test-failed\\n"}) + refute updated_workpad =~ "kind: review-request" + refute_received {:memory_tracker_state_update, ^issue_id, _} + + state = :sys.get_state(pid) + assert MapSet.member?(state.completed, issue_id) + assert %{attempt: 1, due_at_ms: due_at_ms} = state.retry_attempts[issue_id] + assert_due_in_range(due_at_ms, 500, 1_100) + after + File.rm_rf(test_root) + end + end + test "abnormal worker exit increments retry attempt progressively" do issue_id = "issue-crash" ref = make_ref() @@ -538,6 +1008,123 @@ defmodule SymphonyElixir.CoreTest do assert_due_in_range(due_at_ms, 9_000, 10_500) end + test "stale retry timer messages do not consume newer retry entries" do + issue_id = "issue-stale-retry" + orchestrator_name = Module.concat(__MODULE__, :StaleRetryOrchestrator) + {: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) + current_retry_token = make_ref() + stale_retry_token = make_ref() + + :sys.replace_state(pid, fn _ -> + initial_state + |> Map.put(:retry_attempts, %{ + issue_id => %{ + attempt: 2, + timer_ref: nil, + retry_token: current_retry_token, + due_at_ms: System.monotonic_time(:millisecond) + 30_000, + identifier: "MT-561", + error: "agent exited: :boom" + } + }) + end) + + send(pid, {:retry_issue, issue_id, stale_retry_token}) + Process.sleep(50) + + assert %{ + attempt: 2, + retry_token: ^current_retry_token, + identifier: "MT-561", + error: "agent exited: :boom" + } = :sys.get_state(pid).retry_attempts[issue_id] + end + + test "manual refresh coalesces repeated requests and ignores superseded ticks" do + now_ms = System.monotonic_time(:millisecond) + stale_tick_token = make_ref() + + state = %Orchestrator.State{ + poll_interval_ms: 30_000, + max_concurrent_agents: 1, + next_poll_due_at_ms: now_ms + 30_000, + poll_check_in_progress: false, + tick_timer_ref: nil, + tick_token: stale_tick_token, + codex_totals: %{input_tokens: 0, output_tokens: 0, total_tokens: 0, seconds_running: 0}, + codex_rate_limits: nil + } + + assert {:reply, %{queued: true, coalesced: false}, refreshed_state} = + Orchestrator.handle_call(:request_refresh, {self(), make_ref()}, state) + + assert is_reference(refreshed_state.tick_timer_ref) + assert is_reference(refreshed_state.tick_token) + refute refreshed_state.tick_token == stale_tick_token + assert refreshed_state.next_poll_due_at_ms <= System.monotonic_time(:millisecond) + + assert {:reply, %{queued: true, coalesced: true}, coalesced_state} = + Orchestrator.handle_call(:request_refresh, {self(), make_ref()}, refreshed_state) + + assert coalesced_state.tick_token == refreshed_state.tick_token + assert {:noreply, ^coalesced_state} = Orchestrator.handle_info({:tick, stale_tick_token}, coalesced_state) + end + + test "select_worker_host_for_test skips full ssh hosts under the shared per-host cap" do + write_workflow_file!(Workflow.workflow_file_path(), + worker_ssh_hosts: ["worker-a", "worker-b"], + worker_max_concurrent_agents_per_host: 1 + ) + + state = %Orchestrator.State{ + running: %{ + "issue-1" => %{worker_host: "worker-a"} + } + } + + assert Orchestrator.select_worker_host_for_test(state, nil) == "worker-b" + end + + test "select_worker_host_for_test returns no_worker_capacity when every ssh host is full" do + write_workflow_file!(Workflow.workflow_file_path(), + worker_ssh_hosts: ["worker-a", "worker-b"], + worker_max_concurrent_agents_per_host: 1 + ) + + state = %Orchestrator.State{ + running: %{ + "issue-1" => %{worker_host: "worker-a"}, + "issue-2" => %{worker_host: "worker-b"} + } + } + + assert Orchestrator.select_worker_host_for_test(state, nil) == :no_worker_capacity + end + + test "select_worker_host_for_test keeps the preferred ssh host when it still has capacity" do + write_workflow_file!(Workflow.workflow_file_path(), + worker_ssh_hosts: ["worker-a", "worker-b"], + worker_max_concurrent_agents_per_host: 2 + ) + + state = %Orchestrator.State{ + running: %{ + "issue-1" => %{worker_host: "worker-a"}, + "issue-2" => %{worker_host: "worker-b"} + } + } + + assert Orchestrator.select_worker_host_for_test(state, "worker-a") == "worker-a" + end + defp assert_due_in_range(due_at_ms, min_remaining_ms, max_remaining_ms) do remaining_ms = due_at_ms - System.monotonic_time(:millisecond) @@ -545,6 +1132,39 @@ defmodule SymphonyElixir.CoreTest do assert remaining_ms <= max_remaining_ms end + defp restore_app_env(key, nil), do: Application.delete_env(:symphony_elixir, key) + defp restore_app_env(key, value), do: Application.put_env(:symphony_elixir, key, value) + + defp workpad_fixture(note) do + """ + ## Codex Workpad + + #{note} + + + + """ + end + + defp init_git_repo!(test_root) do + workspace_path = Path.join(test_root, "workspace") + File.mkdir_p!(workspace_path) + File.write!(Path.join(workspace_path, "README.md"), "# handoff\n") + git!(workspace_path, ["init", "-b", "main"]) + git!(workspace_path, ["config", "user.name", "Test User"]) + git!(workspace_path, ["config", "user.email", "test@example.com"]) + git!(workspace_path, ["add", "README.md"]) + git!(workspace_path, ["commit", "-m", "initial"]) + workspace_path + end + + defp git!(workspace_path, args) do + case System.cmd("git", args, cd: workspace_path, stderr_to_stdout: true) do + {output, 0} -> output + {output, status} -> flunk("git #{Enum.join(args, " ")} failed with status #{status}: #{output}") + end + end + test "fetch issues by states with empty state set is a no-op" do assert {:ok, []} = Client.fetch_issues_by_states([]) end @@ -949,6 +1569,76 @@ defmodule SymphonyElixir.CoreTest do end end + test "agent runner surfaces ssh startup failures instead of silently hopping hosts" do + test_root = + Path.join( + System.tmp_dir!(), + "symphony-elixir-agent-runner-single-host-#{System.unique_integer([:positive])}" + ) + + previous_path = System.get_env("PATH") + previous_trace = System.get_env("SYMP_TEST_SSH_TRACE") + + on_exit(fn -> + restore_env("PATH", previous_path) + restore_env("SYMP_TEST_SSH_TRACE", previous_trace) + end) + + try do + trace_file = Path.join(test_root, "ssh.trace") + fake_ssh = Path.join(test_root, "ssh") + + File.mkdir_p!(test_root) + System.put_env("SYMP_TEST_SSH_TRACE", trace_file) + System.put_env("PATH", test_root <> ":" <> (previous_path || "")) + + File.write!(fake_ssh, """ + #!/bin/sh + trace_file="${SYMP_TEST_SSH_TRACE:-/tmp/symphony-fake-ssh.trace}" + printf 'ARGV:%s\\n' "$*" >> "$trace_file" + + case "$*" in + *worker-a*"__SYMPHONY_WORKSPACE__"*) + printf '%s\\n' 'worker-a prepare failed' >&2 + exit 75 + ;; + *worker-b*"__SYMPHONY_WORKSPACE__"*) + printf '%s\\t%s\\t%s\\n' '__SYMPHONY_WORKSPACE__' '1' '/remote/home/.symphony-remote-workspaces/MT-SSH-FAILOVER' + exit 0 + ;; + *) + exit 0 + ;; + esac + """) + + File.chmod!(fake_ssh, 0o755) + + write_workflow_file!(Workflow.workflow_file_path(), + workspace_root: "~/.symphony-remote-workspaces", + worker_ssh_hosts: ["worker-a", "worker-b"] + ) + + issue = %Issue{ + id: "issue-ssh-failover", + identifier: "MT-SSH-FAILOVER", + title: "Do not fail over within a single worker run", + description: "Surface the startup failure to the orchestrator", + state: "In Progress" + } + + assert_raise RuntimeError, ~r/workspace_prepare_failed/, fn -> + AgentRunner.run(issue, nil, worker_host: "worker-a") + end + + trace = File.read!(trace_file) + assert trace =~ "worker-a bash -lc" + refute trace =~ "worker-b bash -lc" + after + File.rm_rf(test_root) + end + end + test "agent runner continues with a follow-up turn while the issue remains active" do test_root = Path.join( @@ -1251,6 +1941,7 @@ defmodule SymphonyElixir.CoreTest do } assert {:ok, _result} = AppServer.run(workspace, "Fix workspace start args", issue) + assert {:ok, canonical_workspace} = SymphonyElixir.PathSafety.canonicalize(workspace) trace = File.read!(trace_file) lines = String.split(trace, "\n", trim: true) @@ -1278,7 +1969,7 @@ defmodule SymphonyElixir.CoreTest do payload["method"] == "thread/start" && get_in(payload, ["params", "approvalPolicy"]) == expected_approval_policy && get_in(payload, ["params", "sandbox"]) == "workspace-write" && - get_in(payload, ["params", "cwd"]) == Path.expand(workspace) + get_in(payload, ["params", "cwd"]) == canonical_workspace end) else false @@ -1287,7 +1978,7 @@ defmodule SymphonyElixir.CoreTest do expected_turn_sandbox_policy = %{ "type" => "workspaceWrite", - "writableRoots" => [Path.expand(workspace)], + "writableRoots" => [canonical_workspace], "readOnlyAccess" => %{"type" => "fullAccess"}, "networkAccess" => false, "excludeTmpdirEnvVar" => false, @@ -1309,7 +2000,7 @@ defmodule SymphonyElixir.CoreTest do } payload["method"] == "turn/start" && - get_in(payload, ["params", "cwd"]) == Path.expand(workspace) && + get_in(payload, ["params", "cwd"]) == canonical_workspace && get_in(payload, ["params", "approvalPolicy"]) == expected_approval_policy && get_in(payload, ["params", "sandboxPolicy"]) == expected_turn_sandbox_policy end) @@ -1464,6 +2155,9 @@ defmodule SymphonyElixir.CoreTest do File.chmod!(codex_binary, 0o755) + workspace_cache = Path.join(Path.expand(workspace), ".cache") + File.mkdir_p!(workspace_cache) + write_workflow_file!(Workflow.workflow_file_path(), workspace_root: workspace_root, codex_command: "#{codex_binary} app-server", @@ -1471,7 +2165,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), workspace_cache] } ) @@ -1506,7 +2200,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), workspace_cache] } assert Enum.any?(lines, fn line -> diff --git a/elixir/test/symphony_elixir/dynamic_tool_test.exs b/elixir/test/symphony_elixir/dynamic_tool_test.exs index a5536e033..294471ed9 100644 --- a/elixir/test/symphony_elixir/dynamic_tool_test.exs +++ b/elixir/test/symphony_elixir/dynamic_tool_test.exs @@ -27,19 +27,19 @@ defmodule SymphonyElixir.Codex.DynamicToolTest do assert response["success"] == false - assert [ - %{ - "type" => "inputText", - "text" => text - } - ] = response["contentItems"] - - assert Jason.decode!(text) == %{ + assert Jason.decode!(response["output"]) == %{ "error" => %{ "message" => ~s(Unsupported dynamic tool: "not_a_real_tool".), "supportedTools" => ["linear_graphql"] } } + + assert response["contentItems"] == [ + %{ + "type" => "inputText", + "text" => response["output"] + } + ] end test "linear_graphql returns successful GraphQL responses as tool text" do @@ -61,15 +61,8 @@ defmodule SymphonyElixir.Codex.DynamicToolTest do assert_received {:linear_client_called, "query Viewer { viewer { id } }", %{"includeTeams" => false}, []} assert response["success"] == true - - assert [ - %{ - "type" => "inputText", - "text" => text - } - ] = response["contentItems"] - - assert Jason.decode!(text) == %{"data" => %{"viewer" => %{"id" => "usr_123"}}} + assert Jason.decode!(response["output"]) == %{"data" => %{"viewer" => %{"id" => "usr_123"}}} + assert response["contentItems"] == [%{"type" => "inputText", "text" => response["output"]}] end test "linear_graphql accepts a raw GraphQL query string" do @@ -134,13 +127,7 @@ defmodule SymphonyElixir.Codex.DynamicToolTest do assert response["success"] == false - assert [ - %{ - "text" => text - } - ] = response["contentItems"] - - assert Jason.decode!(text) == %{ + assert Jason.decode!(response["output"]) == %{ "error" => %{ "message" => "`linear_graphql` requires a non-empty `query` string." } @@ -159,14 +146,7 @@ defmodule SymphonyElixir.Codex.DynamicToolTest do assert response["success"] == false - assert [ - %{ - "type" => "inputText", - "text" => text - } - ] = response["contentItems"] - - assert Jason.decode!(text) == %{ + assert Jason.decode!(response["output"]) == %{ "data" => nil, "errors" => [%{"message" => "Unknown field `nope`"}] } @@ -197,14 +177,7 @@ defmodule SymphonyElixir.Codex.DynamicToolTest do assert response["success"] == false - assert [ - %{ - "type" => "inputText", - "text" => text - } - ] = response["contentItems"] - - assert Jason.decode!(text) == %{ + assert Jason.decode!(response["output"]) == %{ "error" => %{ "message" => "`linear_graphql` requires a non-empty `query` string." } @@ -234,13 +207,7 @@ defmodule SymphonyElixir.Codex.DynamicToolTest do assert response["success"] == false - assert [ - %{ - "text" => text - } - ] = response["contentItems"] - - assert Jason.decode!(text) == %{ + assert Jason.decode!(response["output"]) == %{ "error" => %{ "message" => "`linear_graphql` expects either a GraphQL query string or an object with `query` and optional `variables`." } @@ -259,13 +226,7 @@ defmodule SymphonyElixir.Codex.DynamicToolTest do assert response["success"] == false - assert [ - %{ - "text" => text - } - ] = response["contentItems"] - - assert Jason.decode!(text) == %{ + assert Jason.decode!(response["output"]) == %{ "error" => %{ "message" => "`linear_graphql.variables` must be a JSON object when provided." } @@ -282,13 +243,7 @@ defmodule SymphonyElixir.Codex.DynamicToolTest do assert missing_token["success"] == false - assert [ - %{ - "text" => missing_token_text - } - ] = missing_token["contentItems"] - - assert Jason.decode!(missing_token_text) == %{ + assert Jason.decode!(missing_token["output"]) == %{ "error" => %{ "message" => "Symphony is missing Linear auth. Set `linear.api_key` in `WORKFLOW.md` or export `LINEAR_API_KEY`." } @@ -301,13 +256,7 @@ defmodule SymphonyElixir.Codex.DynamicToolTest do linear_client: fn _query, _variables, _opts -> {:error, {:linear_api_status, 503}} end ) - assert [ - %{ - "text" => status_error_text - } - ] = status_error["contentItems"] - - assert Jason.decode!(status_error_text) == %{ + assert Jason.decode!(status_error["output"]) == %{ "error" => %{ "message" => "Linear GraphQL request failed with HTTP 503.", "status" => 503 @@ -321,13 +270,7 @@ defmodule SymphonyElixir.Codex.DynamicToolTest do linear_client: fn _query, _variables, _opts -> {:error, {:linear_api_request, :timeout}} end ) - assert [ - %{ - "text" => request_error_text - } - ] = request_error["contentItems"] - - assert Jason.decode!(request_error_text) == %{ + assert Jason.decode!(request_error["output"]) == %{ "error" => %{ "message" => "Linear GraphQL request failed before receiving a successful response.", "reason" => ":timeout" @@ -345,13 +288,7 @@ defmodule SymphonyElixir.Codex.DynamicToolTest do assert response["success"] == false - assert [ - %{ - "text" => text - } - ] = response["contentItems"] - - assert Jason.decode!(text) == %{ + assert Jason.decode!(response["output"]) == %{ "error" => %{ "message" => "Linear GraphQL tool execution failed.", "reason" => ":boom" @@ -368,11 +305,6 @@ defmodule SymphonyElixir.Codex.DynamicToolTest do ) assert response["success"] == true - - assert [ - %{ - "text" => ":ok" - } - ] = response["contentItems"] + assert response["output"] == ":ok" end end diff --git a/elixir/test/symphony_elixir/extensions_test.exs b/elixir/test/symphony_elixir/extensions_test.exs index 59c8d0580..b7ebdc763 100644 --- a/elixir/test/symphony_elixir/extensions_test.exs +++ b/elixir/test/symphony_elixir/extensions_test.exs @@ -187,18 +187,21 @@ defmodule SymphonyElixir.ExtensionsTest do Application.put_env(:symphony_elixir, :memory_tracker_recipient, self()) write_workflow_file!(Workflow.workflow_file_path(), tracker_kind: "memory") - assert Config.tracker_kind() == "memory" + assert Config.settings!().tracker.kind == "memory" assert SymphonyElixir.Tracker.adapter() == Memory assert {:ok, [^issue]} = SymphonyElixir.Tracker.fetch_candidate_issues() assert {:ok, [^issue]} = SymphonyElixir.Tracker.fetch_issues_by_states([" in progress ", 42]) assert {:ok, [^issue]} = SymphonyElixir.Tracker.fetch_issue_states_by_ids(["issue-1"]) assert :ok = SymphonyElixir.Tracker.create_comment("issue-1", "comment") + assert :ok = SymphonyElixir.Tracker.update_issue_description("issue-1", "updated workpad") assert :ok = SymphonyElixir.Tracker.update_issue_state("issue-1", "Done") assert_receive {:memory_tracker_comment, "issue-1", "comment"} + assert_receive {:memory_tracker_description_update, "issue-1", "updated workpad"} assert_receive {:memory_tracker_state_update, "issue-1", "Done"} Application.delete_env(:symphony_elixir, :memory_tracker_recipient) assert :ok = Memory.create_comment("issue-1", "quiet") + assert :ok = Memory.update_issue_description("issue-1", "quiet workpad") assert :ok = Memory.update_issue_state("issue-1", "Quiet") write_workflow_file!(Workflow.workflow_file_path(), tracker_kind: "linear") @@ -226,6 +229,18 @@ defmodule SymphonyElixir.ExtensionsTest do assert_receive {:graphql_called, create_comment_query, %{body: "hello", issueId: "issue-1"}} assert create_comment_query =~ "commentCreate" + Process.put( + {FakeLinearClient, :graphql_result}, + {:ok, %{"data" => %{"issueUpdate" => %{"success" => true}}}} + ) + + assert :ok = Adapter.update_issue_description("issue-1", "updated workpad") + + assert_receive {:graphql_called, update_description_query, %{description: "updated workpad", issueId: "issue-1"}} + + assert update_description_query =~ "issueUpdate" + assert update_description_query =~ "description" + Process.put( {FakeLinearClient, :graphql_result}, {:ok, %{"data" => %{"commentCreate" => %{"success" => false}}}} @@ -238,12 +253,22 @@ defmodule SymphonyElixir.ExtensionsTest do assert {:error, :boom} = Adapter.create_comment("issue-1", "boom") + Process.put({FakeLinearClient, :graphql_result}, {:error, :boom}) + + assert {:error, :boom} = Adapter.update_issue_description("issue-1", "boom workpad") + Process.put({FakeLinearClient, :graphql_result}, {:ok, %{"data" => %{}}}) assert {:error, :comment_create_failed} = Adapter.create_comment("issue-1", "weird") + Process.put({FakeLinearClient, :graphql_result}, {:ok, %{"data" => %{}}}) + assert {:error, :issue_update_failed} = Adapter.update_issue_description("issue-1", "weird workpad") + Process.put({FakeLinearClient, :graphql_result}, :unexpected) assert {:error, :comment_create_failed} = Adapter.create_comment("issue-1", "odd") + Process.put({FakeLinearClient, :graphql_result}, :unexpected) + assert {:error, :issue_update_failed} = Adapter.update_issue_description("issue-1", "odd workpad") + Process.put( {FakeLinearClient, :graphql_results}, [ @@ -348,6 +373,8 @@ defmodule SymphonyElixir.ExtensionsTest do "issue_id" => "issue-http", "issue_identifier" => "MT-HTTP", "state" => "In Progress", + "worker_host" => nil, + "workspace_path" => nil, "session_id" => "thread-http", "turn_count" => 7, "last_event" => "notification", @@ -363,7 +390,9 @@ defmodule SymphonyElixir.ExtensionsTest do "issue_identifier" => "MT-RETRY", "attempt" => 2, "due_at" => state_payload["retrying"] |> List.first() |> Map.fetch!("due_at"), - "error" => "boom" + "error" => "boom", + "worker_host" => nil, + "workspace_path" => nil } ], "codex_totals" => %{ @@ -382,9 +411,14 @@ defmodule SymphonyElixir.ExtensionsTest do "issue_identifier" => "MT-HTTP", "issue_id" => "issue-http", "status" => "running", - "workspace" => %{"path" => Path.join(Config.workspace_root(), "MT-HTTP")}, + "workspace" => %{ + "path" => Path.join(Config.settings!().workspace.root, "MT-HTTP"), + "host" => nil + }, "attempts" => %{"restart_count" => 0, "current_retry_attempt" => 0}, "running" => %{ + "worker_host" => nil, + "workspace_path" => nil, "session_id" => "thread-http", "turn_count" => 7, "state" => "In Progress", diff --git a/elixir/test/symphony_elixir/live_e2e_test.exs b/elixir/test/symphony_elixir/live_e2e_test.exs new file mode 100644 index 000000000..9bfeced8c --- /dev/null +++ b/elixir/test/symphony_elixir/live_e2e_test.exs @@ -0,0 +1,802 @@ +defmodule SymphonyElixir.LiveE2ETest do + use SymphonyElixir.TestSupport + + require Logger + alias SymphonyElixir.SSH + + @moduletag :live_e2e + @moduletag timeout: 300_000 + + @default_team_key "SYME2E" + @default_docker_auth_json Path.join(System.user_home!(), ".codex/auth.json") + @docker_worker_count 2 + @docker_support_dir Path.expand("../support/live_e2e_docker", __DIR__) + @docker_compose_file Path.join(@docker_support_dir, "docker-compose.yml") + @result_file "LIVE_E2E_RESULT.txt" + @live_e2e_skip_reason if(System.get_env("SYMPHONY_RUN_LIVE_E2E") != "1", + do: "set SYMPHONY_RUN_LIVE_E2E=1 to enable the real Linear/Codex end-to-end test" + ) + + @team_query """ + query SymphonyLiveE2ETeam($key: String!) { + teams(filter: {key: {eq: $key}}, first: 1) { + nodes { + id + key + name + states(first: 50) { + nodes { + id + name + type + } + } + } + } + } + """ + + @create_project_mutation """ + mutation SymphonyLiveE2ECreateProject($name: String!, $teamIds: [String!]!) { + projectCreate(input: {name: $name, teamIds: $teamIds}) { + success + project { + id + name + slugId + url + } + } + } + """ + + @create_issue_mutation """ + mutation SymphonyLiveE2ECreateIssue( + $teamId: String! + $projectId: String! + $title: String! + $description: String! + $stateId: String + ) { + issueCreate( + input: { + teamId: $teamId + projectId: $projectId + title: $title + description: $description + stateId: $stateId + } + ) { + success + issue { + id + identifier + title + description + url + state { + name + } + } + } + } + """ + + @project_statuses_query """ + query SymphonyLiveE2EProjectStatuses { + projectStatuses(first: 50) { + nodes { + id + name + type + } + } + } + """ + + @issue_details_query """ + query SymphonyLiveE2EIssueDetails($id: String!) { + issue(id: $id) { + id + identifier + state { + name + type + } + comments(first: 20) { + nodes { + body + } + } + } + } + """ + + @complete_project_mutation """ + mutation SymphonyLiveE2ECompleteProject($id: String!, $statusId: String!, $completedAt: DateTime!) { + projectUpdate(id: $id, input: {statusId: $statusId, completedAt: $completedAt}) { + success + } + } + """ + + @tag skip: @live_e2e_skip_reason + test "creates a real Linear project and issue with a local worker" do + run_live_issue_flow!(:local) + end + + @tag skip: @live_e2e_skip_reason + test "creates a real Linear project and issue with an ssh worker" do + run_live_issue_flow!(:ssh) + end + + defp fetch_team!(team_key) do + @team_query + |> graphql_data!(%{key: team_key}) + |> get_in(["teams", "nodes"]) + |> case do + [team | _] -> + team + + _ -> + flunk("expected Linear team #{inspect(team_key)} to exist") + end + end + + defp active_state!(%{"states" => %{"nodes" => states}}) when is_list(states) do + Enum.find(states, &(&1["type"] == "started")) || + Enum.find(states, &(&1["type"] == "unstarted")) || + Enum.find(states, &(&1["type"] not in ["completed", "canceled"])) || + flunk("expected team to expose at least one non-terminal workflow state") + end + + defp terminal_state_names(%{"states" => %{"nodes" => states}}) when is_list(states) do + states + |> Enum.filter(&(&1["type"] in ["completed", "canceled"])) + |> Enum.map(& &1["name"]) + |> case do + [] -> ["Done", "Canceled", "Cancelled"] + names -> names + end + end + + defp active_state_names(%{"states" => %{"nodes" => states}}) when is_list(states) do + states + |> Enum.reject(&(&1["type"] in ["completed", "canceled"])) + |> Enum.map(& &1["name"]) + |> case do + [] -> ["Todo", "In Progress", "In Review"] + names -> names + end + end + + defp completed_project_status! do + @project_statuses_query + |> graphql_data!(%{}) + |> get_in(["projectStatuses", "nodes"]) + |> case do + statuses when is_list(statuses) -> + Enum.find(statuses, &(&1["type"] == "completed")) || + flunk("expected workspace to expose a completed project status") + + payload -> + flunk("expected project statuses list, got: #{inspect(payload)}") + end + end + + defp create_project!(team_id, name) do + @create_project_mutation + |> graphql_data!(%{teamIds: [team_id], name: name}) + |> fetch_successful_entity!("projectCreate", "project") + end + + defp create_issue!(team_id, project_id, state_id, title) do + issue = + @create_issue_mutation + |> graphql_data!(%{ + teamId: team_id, + projectId: project_id, + title: title, + description: title, + stateId: state_id + }) + |> fetch_successful_entity!("issueCreate", "issue") + + %Issue{ + id: issue["id"], + identifier: issue["identifier"], + title: issue["title"], + description: issue["description"], + state: get_in(issue, ["state", "name"]), + url: issue["url"], + labels: [], + blocked_by: [] + } + end + + defp complete_project(project_id, completed_status_id) + when is_binary(project_id) and is_binary(completed_status_id) do + update_entity( + @complete_project_mutation, + %{ + id: project_id, + statusId: completed_status_id, + completedAt: DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601() + }, + "projectUpdate", + "project" + ) + end + + defp fetch_issue_details!(issue_id) when is_binary(issue_id) do + @issue_details_query + |> graphql_data!(%{id: issue_id}) + |> get_in(["issue"]) + |> case do + %{} = issue -> issue + payload -> flunk("expected issue details payload, got: #{inspect(payload)}") + end + end + + defp issue_completed?(%{"state" => %{"type" => type}}), do: type in ["completed", "canceled"] + defp issue_completed?(_issue), do: false + + defp issue_has_comment?(%{"comments" => %{"nodes" => comments}}, expected_body) when is_list(comments) do + Enum.any?(comments, &(&1["body"] == expected_body)) + end + + defp issue_has_comment?(_issue, _expected_body), do: false + + defp update_entity(mutation, variables, mutation_name, entity_name) do + case Client.graphql(mutation, variables) do + {:ok, %{"data" => %{^mutation_name => %{"success" => true}}}} -> + :ok + + {:ok, %{"errors" => errors}} -> + Logger.warning("Live e2e finalization failed for #{entity_name}: #{inspect(errors)}") + :ok + + {:ok, payload} -> + Logger.warning("Live e2e finalization failed for #{entity_name}: #{inspect(payload)}") + :ok + + {:error, reason} -> + Logger.warning("Live e2e finalization failed for #{entity_name}: #{inspect(reason)}") + :ok + end + end + + defp graphql_data!(query, variables) when is_binary(query) and is_map(variables) do + case Client.graphql(query, variables) do + {:ok, %{"data" => data, "errors" => errors}} when is_map(data) and is_list(errors) -> + flunk("Linear GraphQL returned partial errors: #{inspect(errors)}") + + {:ok, %{"errors" => errors}} when is_list(errors) -> + flunk("Linear GraphQL failed: #{inspect(errors)}") + + {:ok, %{"data" => data}} when is_map(data) -> + data + + {:ok, payload} -> + flunk("Linear GraphQL returned unexpected payload: #{inspect(payload)}") + + {:error, reason} -> + flunk("Linear GraphQL request failed: #{inspect(reason)}") + end + end + + defp fetch_successful_entity!(data, mutation_name, entity_name) + when is_map(data) and is_binary(mutation_name) and is_binary(entity_name) do + case data do + %{^mutation_name => %{"success" => true, ^entity_name => %{} = entity}} -> + entity + + _ -> + flunk("expected successful #{mutation_name} response, got: #{inspect(data)}") + end + end + + defp live_prompt(project_slug) do + """ + You are running a real Symphony end-to-end test. + + The current working directory is the workspace root. + + Step 1: + Create a file named #{@result_file} in the current working directory by running exactly: + + ```sh + cat > #{@result_file} <<'EOF' + identifier={{ issue.identifier }} + project_slug=#{project_slug} + EOF + ``` + + Then verify it by running: + + ```sh + cat #{@result_file} + ``` + + The file content must be exactly: + identifier={{ issue.identifier }} + project_slug=#{project_slug} + + Step 2: + You must use the `linear_graphql` tool to query the current issue by `{{ issue.id }}` and read: + - existing comments + - team workflow states + + A turn that only creates the file is incomplete. Do not stop after Step 1. + + If the exact comment body below is not already present, post exactly one comment on the current issue with this exact body: + #{expected_comment("{{ issue.identifier }}", project_slug)} + + Use these exact GraphQL operations: + + ```graphql + query IssueContext($id: String!) { + issue(id: $id) { + comments(first: 20) { + nodes { + body + } + } + team { + states(first: 50) { + nodes { + id + name + type + } + } + } + } + } + ``` + + ```graphql + mutation AddComment($issueId: String!, $body: String!) { + commentCreate(input: {issueId: $issueId, body: $body}) { + success + } + } + ``` + + Step 3: + Use the same issue-context query result to choose a workflow state whose `type` is `completed`. + Then move the current issue to that state with this exact mutation: + + ```graphql + mutation CompleteIssue($id: String!, $stateId: String!) { + issueUpdate(id: $id, input: {stateId: $stateId}) { + success + } + } + ``` + + Step 4: + Verify all outcomes with one final `linear_graphql` query against `{{ issue.id }}`: + - the exact comment body is present + - the issue state type is `completed` + + Do not ask for approval. + Stop only after all three conditions are true: + 1. the file exists with the exact contents above + 2. the Linear comment exists with the exact body above + 3. the Linear issue is in a completed terminal state + """ + end + + defp expected_result(issue_identifier, project_slug) do + "identifier=#{issue_identifier}\nproject_slug=#{project_slug}\n" + end + + defp expected_comment(issue_identifier, project_slug) do + "Symphony live e2e comment\nidentifier=#{issue_identifier}\nproject_slug=#{project_slug}" + end + + defp receive_runtime_info!(issue_id) do + receive do + {:worker_runtime_info, ^issue_id, %{workspace_path: workspace_path} = runtime_info} + when is_binary(workspace_path) -> + runtime_info + + {:codex_worker_update, ^issue_id, _message} -> + receive_runtime_info!(issue_id) + after + 5_000 -> + flunk("timed out waiting for worker runtime info for #{inspect(issue_id)}") + end + end + + defp read_worker_result!(%{worker_host: nil, workspace_path: workspace_path}, result_file) + when is_binary(workspace_path) and is_binary(result_file) do + File.read!(Path.join(workspace_path, result_file)) + end + + defp read_worker_result!(%{worker_host: worker_host, workspace_path: workspace_path}, result_file) + when is_binary(worker_host) and is_binary(workspace_path) and is_binary(result_file) do + remote_result_path = Path.join(workspace_path, result_file) + + case SSH.run(worker_host, "cat #{shell_escape(remote_result_path)}", stderr_to_stdout: true) do + {:ok, {output, 0}} -> + output + + {:ok, {output, status}} -> + flunk("failed to read remote result from #{worker_host}:#{remote_result_path} (status #{status}): #{inspect(output)}") + + {:error, reason} -> + flunk("failed to read remote result from #{worker_host}:#{remote_result_path}: #{inspect(reason)}") + end + end + + defp shell_escape(value) when is_binary(value) do + "'" <> String.replace(value, "'", "'\"'\"'") <> "'" + end + + defp run_live_issue_flow!(backend) when backend in [:local, :ssh] do + run_id = "symphony-live-e2e-#{backend}-#{System.unique_integer([:positive])}" + test_root = Path.join(System.tmp_dir!(), run_id) + workflow_root = Path.join(test_root, "workflow") + workflow_file = Path.join(workflow_root, "WORKFLOW.md") + worker_setup = live_worker_setup!(backend, run_id, test_root) + team_key = System.get_env("SYMPHONY_LIVE_LINEAR_TEAM_KEY") || @default_team_key + original_workflow_path = Workflow.workflow_file_path() + orchestrator_pid = Process.whereis(SymphonyElixir.Orchestrator) + + File.mkdir_p!(workflow_root) + + try do + if is_pid(orchestrator_pid) do + assert :ok = Supervisor.terminate_child(SymphonyElixir.Supervisor, SymphonyElixir.Orchestrator) + end + + Workflow.set_workflow_file_path(workflow_file) + + write_workflow_file!(workflow_file, + tracker_api_token: "$LINEAR_API_KEY", + tracker_project_slug: "bootstrap", + workspace_root: worker_setup.workspace_root, + worker_ssh_hosts: worker_setup.ssh_worker_hosts, + codex_command: worker_setup.codex_command, + codex_approval_policy: "never", + observability_enabled: false + ) + + team = fetch_team!(team_key) + active_state = active_state!(team) + completed_project_status = completed_project_status!() + terminal_states = terminal_state_names(team) + + project = + create_project!( + team["id"], + "Symphony Live E2E #{backend} #{System.unique_integer([:positive])}" + ) + + issue = + create_issue!( + team["id"], + project["id"], + active_state["id"], + "Symphony live e2e #{backend} issue for #{project["name"]}" + ) + + write_workflow_file!(workflow_file, + tracker_api_token: "$LINEAR_API_KEY", + tracker_project_slug: project["slugId"], + tracker_active_states: active_state_names(team), + tracker_terminal_states: terminal_states, + workspace_root: worker_setup.workspace_root, + worker_ssh_hosts: worker_setup.ssh_worker_hosts, + codex_command: worker_setup.codex_command, + codex_approval_policy: "never", + codex_turn_timeout_ms: 600_000, + codex_stall_timeout_ms: 600_000, + observability_enabled: false, + prompt: live_prompt(project["slugId"]) + ) + + assert :ok = AgentRunner.run(issue, self(), max_turns: 3) + + runtime_info = receive_runtime_info!(issue.id) + + assert read_worker_result!(runtime_info, @result_file) == + expected_result(issue.identifier, project["slugId"]) + + issue_snapshot = fetch_issue_details!(issue.id) + assert issue_completed?(issue_snapshot) + assert issue_has_comment?(issue_snapshot, expected_comment(issue.identifier, project["slugId"])) + + assert :ok = complete_project(project["id"], completed_project_status["id"]) + after + restart_orchestrator_if_needed() + cleanup_live_worker_setup(worker_setup) + Workflow.set_workflow_file_path(original_workflow_path) + File.rm_rf(test_root) + end + end + + defp live_worker_setup!(:local, _run_id, test_root) when is_binary(test_root) do + %{ + cleanup: fn -> :ok end, + codex_command: "codex app-server", + ssh_worker_hosts: [], + workspace_root: Path.join(test_root, "workspaces") + } + end + + defp live_worker_setup!(:ssh, run_id, test_root) when is_binary(run_id) and is_binary(test_root) do + case live_ssh_worker_hosts() do + [] -> + live_docker_worker_setup!(run_id, test_root) + + _hosts -> + live_ssh_worker_setup!(run_id) + end + end + + defp cleanup_live_worker_setup(%{cleanup: cleanup}) when is_function(cleanup, 0) do + cleanup.() + end + + defp cleanup_live_worker_setup(_worker_setup), do: :ok + + defp restart_orchestrator_if_needed do + if is_nil(Process.whereis(SymphonyElixir.Orchestrator)) do + case Supervisor.restart_child(SymphonyElixir.Supervisor, SymphonyElixir.Orchestrator) do + {:ok, _pid} -> :ok + {:error, {:already_started, _pid}} -> :ok + end + end + end + + defp live_ssh_worker_setup!(run_id) when is_binary(run_id) do + ssh_worker_hosts = live_ssh_worker_hosts() + remote_test_root = Path.join(shared_remote_home!(ssh_worker_hosts), ".#{run_id}") + remote_workspace_root = "~/.#{run_id}/workspaces" + + %{ + cleanup: fn -> cleanup_remote_test_root(remote_test_root, ssh_worker_hosts) end, + codex_command: "codex app-server", + ssh_worker_hosts: ssh_worker_hosts, + workspace_root: remote_workspace_root + } + end + + defp live_docker_worker_setup!(run_id, test_root) when is_binary(run_id) and is_binary(test_root) do + ssh_root = Path.join(test_root, "live-docker-ssh") + key_path = Path.join(ssh_root, "id_ed25519") + config_path = Path.join(ssh_root, "config") + auth_json_path = @default_docker_auth_json + worker_ports = reserve_tcp_ports(@docker_worker_count) + worker_hosts = Enum.map(worker_ports, &"localhost:#{&1}") + project_name = docker_project_name(run_id) + previous_ssh_config = System.get_env("SYMPHONY_SSH_CONFIG") + + base_cleanup = fn -> + restore_env("SYMPHONY_SSH_CONFIG", previous_ssh_config) + docker_compose_down(project_name, docker_compose_env(worker_ports, auth_json_path, key_path <> ".pub")) + end + + result = + try do + File.mkdir_p!(ssh_root) + generate_ssh_keypair!(key_path) + write_docker_ssh_config!(config_path, key_path) + System.put_env("SYMPHONY_SSH_CONFIG", config_path) + + docker_compose_up!(project_name, docker_compose_env(worker_ports, auth_json_path, key_path <> ".pub")) + wait_for_ssh_hosts!(worker_hosts) + remote_test_root = Path.join(shared_remote_home!(worker_hosts), ".#{run_id}") + remote_workspace_root = "~/.#{run_id}/workspaces" + + %{ + cleanup: fn -> + cleanup_remote_test_root(remote_test_root, worker_hosts) + base_cleanup.() + end, + codex_command: "codex app-server", + ssh_worker_hosts: worker_hosts, + workspace_root: remote_workspace_root + } + rescue + error -> + {:error, error, __STACKTRACE__} + catch + kind, reason -> + {:caught, kind, reason, __STACKTRACE__} + end + + case result do + %{ssh_worker_hosts: _hosts} = worker_setup -> + worker_setup + + {:error, error, stacktrace} -> + base_cleanup.() + reraise(error, stacktrace) + + {:caught, kind, reason, stacktrace} -> + base_cleanup.() + :erlang.raise(kind, reason, stacktrace) + end + end + + defp live_ssh_worker_hosts do + System.get_env("SYMPHONY_LIVE_SSH_WORKER_HOSTS", "") + |> String.split(",", trim: true) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + end + + defp cleanup_remote_test_root(test_root, ssh_worker_hosts) + when is_binary(test_root) and is_list(ssh_worker_hosts) do + Enum.each(ssh_worker_hosts, fn worker_host -> + _ = SSH.run(worker_host, "rm -rf #{shell_escape(test_root)}", stderr_to_stdout: true) + end) + end + + defp shared_remote_home!([first_host | rest] = worker_hosts) when is_binary(first_host) and rest != [] do + homes = + worker_hosts + |> Enum.map(fn worker_host -> {worker_host, remote_home!(worker_host)} end) + + [{_host, home} | _remaining] = homes + + if Enum.all?(homes, fn {_host, other_home} -> other_home == home end) do + home + else + flunk("expected all live SSH workers to share one home directory, got: #{inspect(homes)}") + end + end + + defp shared_remote_home!([worker_host]) when is_binary(worker_host), do: remote_home!(worker_host) + defp shared_remote_home!(_worker_hosts), do: flunk("expected at least one live SSH worker host") + + defp remote_home!(worker_host) when is_binary(worker_host) do + case SSH.run(worker_host, "printf '%s\\n' \"$HOME\"", stderr_to_stdout: true) do + {:ok, {output, 0}} -> + output + |> String.trim() + |> case do + "" -> flunk("expected non-empty remote home for #{worker_host}") + home -> home + end + + {:ok, {output, status}} -> + flunk("failed to resolve remote home for #{worker_host} (status #{status}): #{inspect(output)}") + + {:error, reason} -> + flunk("failed to resolve remote home for #{worker_host}: #{inspect(reason)}") + end + end + + defp reserve_tcp_ports(count) when is_integer(count) and count > 0 do + reserve_tcp_ports(count, MapSet.new(), []) + end + + defp reserve_tcp_ports(0, _seen, ports), do: Enum.reverse(ports) + + defp reserve_tcp_ports(remaining, seen, ports) do + port = reserve_tcp_port!() + + if MapSet.member?(seen, port) do + reserve_tcp_ports(remaining, seen, ports) + else + reserve_tcp_ports(remaining - 1, MapSet.put(seen, port), [port | ports]) + end + end + + defp reserve_tcp_port! do + {:ok, socket} = :gen_tcp.listen(0, [:binary, {:active, false}, {:reuseaddr, true}]) + {:ok, port} = :inet.port(socket) + :ok = :gen_tcp.close(socket) + port + end + + defp generate_ssh_keypair!(key_path) when is_binary(key_path) do + case System.find_executable("ssh-keygen") do + nil -> + flunk("docker worker mode requires `ssh-keygen` on PATH") + + executable -> + key_dir = Path.dirname(key_path) + File.mkdir_p!(key_dir) + File.rm_rf(key_path) + File.rm_rf(key_path <> ".pub") + + case System.cmd(executable, ["-q", "-t", "ed25519", "-N", "", "-f", key_path], stderr_to_stdout: true) do + {_output, 0} -> :ok + {output, status} -> flunk("failed to generate live docker ssh key (status #{status}): #{inspect(output)}") + end + end + end + + defp write_docker_ssh_config!(config_path, key_path) + when is_binary(config_path) and is_binary(key_path) do + config_contents = """ + Host localhost 127.0.0.1 + User root + IdentityFile #{key_path} + IdentitiesOnly yes + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + LogLevel ERROR + """ + + File.mkdir_p!(Path.dirname(config_path)) + File.write!(config_path, config_contents) + end + + defp docker_project_name(run_id) when is_binary(run_id) do + run_id + |> String.downcase() + |> String.replace(~r/[^a-z0-9_-]/, "-") + end + + defp docker_compose_env(worker_ports, auth_json_path, authorized_key_path) + when is_list(worker_ports) and is_binary(auth_json_path) and is_binary(authorized_key_path) do + [ + {"SYMPHONY_LIVE_DOCKER_AUTH_JSON", auth_json_path}, + {"SYMPHONY_LIVE_DOCKER_AUTHORIZED_KEY", authorized_key_path}, + {"SYMPHONY_LIVE_DOCKER_WORKER_1_PORT", Integer.to_string(Enum.at(worker_ports, 0))}, + {"SYMPHONY_LIVE_DOCKER_WORKER_2_PORT", Integer.to_string(Enum.at(worker_ports, 1))} + ] + end + + defp docker_compose_up!(project_name, env) when is_binary(project_name) and is_list(env) do + args = ["compose", "-f", @docker_compose_file, "-p", project_name, "up", "-d", "--build"] + + case System.cmd("docker", args, cd: @docker_support_dir, env: env, stderr_to_stdout: true) do + {_output, 0} -> + :ok + + {output, status} -> + flunk("failed to start live docker workers (status #{status}): #{inspect(output)}") + end + end + + defp docker_compose_down(project_name, env) when is_binary(project_name) and is_list(env) do + _ = + System.cmd( + "docker", + ["compose", "-f", @docker_compose_file, "-p", project_name, "down", "-v", "--remove-orphans"], + cd: @docker_support_dir, + env: env, + stderr_to_stdout: true + ) + + :ok + end + + defp wait_for_ssh_hosts!(worker_hosts) when is_list(worker_hosts) do + deadline = System.monotonic_time(:millisecond) + 60_000 + + Enum.each(worker_hosts, fn worker_host -> + wait_for_ssh_host!(worker_host, deadline) + end) + end + + defp wait_for_ssh_host!(worker_host, deadline_ms) when is_binary(worker_host) do + case SSH.run(worker_host, "printf ready", stderr_to_stdout: true) do + {:ok, {"ready", 0}} -> + :ok + + {:ok, {_output, _status}} -> + retry_or_flunk_ssh_host(worker_host, deadline_ms) + + {:error, _reason} -> + retry_or_flunk_ssh_host(worker_host, deadline_ms) + end + end + + defp retry_or_flunk_ssh_host(worker_host, deadline_ms) do + if System.monotonic_time(:millisecond) < deadline_ms do + Process.sleep(1_000) + wait_for_ssh_host!(worker_host, deadline_ms) + else + flunk("timed out waiting for SSH worker #{worker_host} to accept connections") + end + end +end diff --git a/elixir/test/symphony_elixir/marker_parser_test.exs b/elixir/test/symphony_elixir/marker_parser_test.exs new file mode 100644 index 000000000..cbdc5584f --- /dev/null +++ b/elixir/test/symphony_elixir/marker_parser_test.exs @@ -0,0 +1,266 @@ +defmodule SymphonyElixir.MarkerParserTest do + use ExUnit.Case, async: true + + alias SymphonyElixir.MarkerParser + alias SymphonyElixir.MarkerParser.Marker + + @issue "ENT-187" + @other_issue "ENT-999" + @sha1 String.duplicate("a", 40) + @sha2 String.duplicate("b", 40) + @sha3 String.duplicate("c", 40) + + test "no BEGIN/END region returns empty list" do + assert MarkerParser.parse(marker_block(kind: "review-request"), @issue) == [] + end + + test "empty BEGIN/END region returns empty list" do + assert MarkerParser.parse(wrap_region(""), @issue) == [] + end + + test "single valid review-request is parsed" do + markers = + wrap_region( + marker_block( + kind: "review-request", + round_id: 1, + stage_round: 1, + reviewed_sha: @sha1, + issue_identifier: @issue + ) + ) + |> MarkerParser.parse(@issue) + + assert markers == [ + %Marker{ + kind: :review_request, + round_id: 1, + stage_round: 1, + reviewed_sha: @sha1, + issue_identifier: @issue, + verdict: nil, + findings: nil, + docfix_outcome: nil + } + ] + end + + test "code-review missing verdict is dropped" do + assert parse_single(""" + kind: code-review + round_id: 1 + stage_round: 1 + reviewed_sha: #{@sha1} + issue_identifier: #{@issue} + """) == [] + end + + test "docs-checked missing docfix_outcome is dropped" do + assert parse_single(""" + kind: docs-checked + round_id: 1 + stage_round: 1 + reviewed_sha: #{@sha1} + issue_identifier: #{@issue} + """) == [] + end + + test "invalid YAML is dropped" do + assert parse_single(""" + kind: [oops + round_id: 1 + """) == [] + end + + test "issue_identifier mismatch is dropped" do + assert parse_single(""" + kind: review-request + round_id: 1 + stage_round: 1 + reviewed_sha: #{@sha1} + issue_identifier: #{@other_issue} + """) == [] + end + + test "archived fenced block is ignored" do + workpad = + wrap_region(""" + ```symphony-marker-archived + kind: review-request + round_id: 1 + stage_round: 1 + reviewed_sha: #{@sha1} + issue_identifier: #{@issue} + ``` + """) + + assert MarkerParser.parse(workpad, @issue) == [] + end + + test "fenced block outside BEGIN/END is ignored" do + workpad = """ + #{marker_block(kind: "review-request", round_id: 1, stage_round: 1, reviewed_sha: @sha1, issue_identifier: @issue)} + + #{wrap_region("")} + """ + + assert MarkerParser.parse(workpad, @issue) == [] + end + + test "multiple markers are returned in text order" do + workpad = + wrap_region(""" + #{marker_block(kind: "review-request", round_id: 1, stage_round: 1, reviewed_sha: @sha1, issue_identifier: @issue)} + #{marker_block(kind: "code-review", round_id: 1, stage_round: 1, reviewed_sha: @sha1, issue_identifier: @issue, verdict: "findings")} + #{marker_block(kind: "docs-checked", round_id: 1, stage_round: 1, reviewed_sha: @sha1, issue_identifier: @issue, docfix_outcome: "updated")} + """) + + markers = MarkerParser.parse(workpad, @issue) + + assert Enum.map(markers, & &1.kind) == [:review_request, :code_review, :docs_checked] + assert Enum.map(markers, & &1.stage_round) == [1, 1, 1] + end + + test "review_pending? is true when the last review marker is a review-request" do + markers = + parse_markers(""" + #{marker_block(kind: "code-review", round_id: 1, stage_round: 1, reviewed_sha: @sha1, issue_identifier: @issue, verdict: "clean")} + #{marker_block(kind: "review-request", round_id: 2, stage_round: 1, reviewed_sha: @sha2, issue_identifier: @issue)} + """) + + assert MarkerParser.review_pending?(markers) + end + + test "review_pending? is false when the last review marker is a code-review" do + markers = + parse_markers(""" + #{marker_block(kind: "review-request", round_id: 2, stage_round: 1, reviewed_sha: @sha2, issue_identifier: @issue)} + #{marker_block(kind: "code-review", round_id: 2, stage_round: 1, reviewed_sha: @sha2, issue_identifier: @issue, verdict: "findings")} + """) + + refute MarkerParser.review_pending?(markers) + end + + test "latest_code_review returns the current round findings verdict" do + markers = + parse_markers(""" + #{marker_block(kind: "code-review", round_id: 1, stage_round: 1, reviewed_sha: @sha1, issue_identifier: @issue, verdict: "clean")} + #{marker_block(kind: "review-request", round_id: 2, stage_round: 1, reviewed_sha: @sha2, issue_identifier: @issue)} + #{marker_block(kind: "code-review", round_id: 2, stage_round: 1, reviewed_sha: @sha2, issue_identifier: @issue, verdict: "findings")} + """) + + assert %Marker{verdict: :findings, reviewed_sha: @sha2} = MarkerParser.latest_code_review(markers) + end + + test "latest_review_sha returns only for a clean code-review in the current round" do + clean_markers = + parse_markers(""" + #{marker_block(kind: "review-request", round_id: 2, stage_round: 1, reviewed_sha: @sha2, issue_identifier: @issue)} + #{marker_block(kind: "code-review", round_id: 2, stage_round: 1, reviewed_sha: @sha2, issue_identifier: @issue, verdict: "clean")} + """) + + findings_markers = + parse_markers(""" + #{marker_block(kind: "review-request", round_id: 2, stage_round: 1, reviewed_sha: @sha2, issue_identifier: @issue)} + #{marker_block(kind: "code-review", round_id: 2, stage_round: 1, reviewed_sha: @sha2, issue_identifier: @issue, verdict: "findings")} + """) + + assert MarkerParser.latest_review_sha(clean_markers) == @sha2 + assert MarkerParser.latest_review_sha(findings_markers) == nil + end + + test "docs_checked_matches_review? is true when docs-checked sha matches latest clean review sha" do + markers = + parse_markers(""" + #{marker_block(kind: "review-request", round_id: 1, stage_round: 1, reviewed_sha: @sha1, issue_identifier: @issue)} + #{marker_block(kind: "code-review", round_id: 1, stage_round: 1, reviewed_sha: @sha1, issue_identifier: @issue, verdict: "clean")} + #{marker_block(kind: "docs-checked", round_id: 1, stage_round: 1, reviewed_sha: @sha1, issue_identifier: @issue, docfix_outcome: "no-updates")} + """) + + assert MarkerParser.docs_checked_matches_review?(markers) + end + + test "docs_checked_matches_review? is false when docs-checked sha differs from latest clean review sha" do + markers = + parse_markers(""" + #{marker_block(kind: "review-request", round_id: 1, stage_round: 1, reviewed_sha: @sha1, issue_identifier: @issue)} + #{marker_block(kind: "code-review", round_id: 1, stage_round: 1, reviewed_sha: @sha1, issue_identifier: @issue, verdict: "clean")} + #{marker_block(kind: "docs-checked", round_id: 1, stage_round: 1, reviewed_sha: @sha3, issue_identifier: @issue, docfix_outcome: "updated")} + """) + + refute MarkerParser.docs_checked_matches_review?(markers) + end + + test "docs_checked_matches_review? looks at last docs-checked, not any matching older one" do + # older docs-checked matches clean review sha, newer one does not (stale after extra commit) + markers = + parse_markers(""" + #{marker_block(kind: "review-request", round_id: 1, stage_round: 1, reviewed_sha: @sha1, issue_identifier: @issue)} + #{marker_block(kind: "code-review", round_id: 1, stage_round: 1, reviewed_sha: @sha1, issue_identifier: @issue, verdict: "clean")} + #{marker_block(kind: "docs-checked", round_id: 1, stage_round: 1, reviewed_sha: @sha1, issue_identifier: @issue, docfix_outcome: "no-updates")} + #{marker_block(kind: "docs-checked", round_id: 1, stage_round: 2, reviewed_sha: @sha3, issue_identifier: @issue, docfix_outcome: "updated")} + """) + + refute MarkerParser.docs_checked_matches_review?(markers) + end + + test "code-review with valid verdict but malformed findings still parses with findings: nil" do + workpad = + wrap_region(""" + ```symphony-marker + kind: code-review + round_id: 1 + stage_round: 1 + reviewed_sha: #{@sha1} + issue_identifier: #{@issue} + verdict: findings + findings: "this should be a list" + ``` + """) + + assert [%Marker{kind: :code_review, verdict: :findings, findings: nil}] = + MarkerParser.parse(workpad, @issue) + end + + defp parse_single(yaml_body) do + yaml_body + |> marker_block() + |> wrap_region() + |> MarkerParser.parse(@issue) + end + + defp parse_markers(blocks) do + blocks + |> wrap_region() + |> MarkerParser.parse(@issue) + end + + defp wrap_region(inner) do + """ + intro text + + + #{String.trim(inner)} + + + footer text + """ + end + + defp marker_block(fields) when is_binary(fields) do + """ + ```symphony-marker + #{String.trim(fields)} + ``` + """ + end + + defp marker_block(fields) when is_list(fields) do + fields + |> Enum.map_join("\n", fn {key, value} -> "#{key}: #{yaml_value(value)}" end) + |> marker_block() + end + + defp yaml_value(value) when is_binary(value), do: value + defp yaml_value(value) when is_integer(value), do: Integer.to_string(value) +end diff --git a/elixir/test/symphony_elixir/orchestrator_status_test.exs b/elixir/test/symphony_elixir/orchestrator_status_test.exs index 14c3e1bb2..4326b80ce 100644 --- a/elixir/test/symphony_elixir/orchestrator_status_test.exs +++ b/elixir/test/symphony_elixir/orchestrator_status_test.exs @@ -767,6 +767,8 @@ defmodule SymphonyElixir.OrchestratorStatusTest do %{ state | poll_interval_ms: 30_000, + tick_timer_ref: nil, + tick_token: make_ref(), next_poll_due_at_ms: now_ms + 4_000, poll_check_in_progress: false } diff --git a/elixir/test/symphony_elixir/prompt_builder_test.exs b/elixir/test/symphony_elixir/prompt_builder_test.exs new file mode 100644 index 000000000..83626e904 --- /dev/null +++ b/elixir/test/symphony_elixir/prompt_builder_test.exs @@ -0,0 +1,74 @@ +defmodule SymphonyElixir.PromptBuilderTest do + use ExUnit.Case + + alias SymphonyElixir.Linear.Issue + alias SymphonyElixir.PromptBuilder + alias SymphonyElixir.Workflow + + import SymphonyElixir.TestSupport, only: [write_workflow_file!: 2] + + setup do + workflow_root = + Path.join( + System.tmp_dir!(), + "symphony-elixir-prompt-builder-#{System.unique_integer([:positive])}" + ) + + File.mkdir_p!(workflow_root) + workflow_file = Path.join(workflow_root, "WORKFLOW.md") + write_workflow_file!(workflow_file, prompt: "Default {{ issue.identifier }}") + Workflow.set_workflow_file_path(workflow_file) + + on_exit(fn -> + Workflow.clear_workflow_file_path() + File.rm_rf(workflow_root) + end) + + {:ok, workflow_root: workflow_root} + end + + test "uses current workflow and default fallback when workflow_path is absent" do + write_workflow_file!(Workflow.workflow_file_path(), prompt: " \n") + + prompt = PromptBuilder.build_prompt(issue_fixture()) + + assert prompt =~ "You are working on a Linear issue." + assert prompt =~ "Identifier: MT-781" + end + + test "loads prompt from the provided workflow_path", %{workflow_root: workflow_root} do + stage_workflow_path = Path.join(workflow_root, "WORKFLOW-review.md") + File.write!(stage_workflow_path, "Stage {{ issue.identifier }} attempt={{ attempt }}\n") + + assert PromptBuilder.build_prompt(issue_fixture(), attempt: 2, workflow_path: stage_workflow_path) == + "Stage MT-781 attempt=2" + end + + test "raises when workflow_path resolves to a blank prompt body", %{workflow_root: workflow_root} do + stage_workflow_path = Path.join(workflow_root, "WORKFLOW-review.md") + File.write!(stage_workflow_path, "---\ntracker:\n kind: linear\n---\n \n") + + assert_raise RuntimeError, ~r/stage_workflow_empty_body/, fn -> + PromptBuilder.build_prompt(issue_fixture(), workflow_path: stage_workflow_path) + end + end + + test "raises cleanly when workflow_path does not exist", %{workflow_root: workflow_root} do + missing_path = Path.join(workflow_root, "MISSING_STAGE_WORKFLOW.md") + + assert_raise RuntimeError, ~r/workflow_unavailable: \{:missing_workflow_file, ".*MISSING_STAGE_WORKFLOW\.md", :enoent\}/, fn -> + PromptBuilder.build_prompt(issue_fixture(), workflow_path: missing_path) + end + end + + defp issue_fixture do + %Issue{ + identifier: "MT-781", + title: "Stage workflow prompts", + description: "Use an explicit workflow path when requested.", + state: "In Progress", + url: "https://example.org/issues/MT-781", + labels: ["prompt"] + } + end +end diff --git a/elixir/test/symphony_elixir/ssh_test.exs b/elixir/test/symphony_elixir/ssh_test.exs new file mode 100644 index 000000000..9edc94f3a --- /dev/null +++ b/elixir/test/symphony_elixir/ssh_test.exs @@ -0,0 +1,199 @@ +defmodule SymphonyElixir.SSHTest do + use ExUnit.Case, async: false + + alias SymphonyElixir.SSH + + test "run/3 keeps bracketed IPv6 host:port targets intact" do + test_root = Path.join(System.tmp_dir!(), "symphony-ssh-ipv6-test-#{System.unique_integer([:positive])}") + trace_file = Path.join(test_root, "ssh.trace") + previous_path = System.get_env("PATH") + + on_exit(fn -> + restore_env("PATH", previous_path) + File.rm_rf(test_root) + end) + + install_fake_ssh!(test_root, trace_file) + + assert {:ok, {"", 0}} = + SSH.run("root@[::1]:2200", "printf ok", stderr_to_stdout: true) + + trace = File.read!(trace_file) + assert trace =~ "-T -p 2200 root@[::1] bash -lc" + assert trace =~ "printf ok" + end + + test "run/3 leaves unbracketed IPv6-style targets unchanged" do + test_root = Path.join(System.tmp_dir!(), "symphony-ssh-ipv6-raw-test-#{System.unique_integer([:positive])}") + trace_file = Path.join(test_root, "ssh.trace") + previous_path = System.get_env("PATH") + + on_exit(fn -> + restore_env("PATH", previous_path) + File.rm_rf(test_root) + end) + + install_fake_ssh!(test_root, trace_file) + + assert {:ok, {"", 0}} = + SSH.run("::1:2200", "printf ok", stderr_to_stdout: true) + + trace = File.read!(trace_file) + assert trace =~ "-T ::1:2200 bash -lc" + refute trace =~ "-p 2200" + end + + test "run/3 passes host:port targets through ssh -p" do + test_root = Path.join(System.tmp_dir!(), "symphony-ssh-test-#{System.unique_integer([:positive])}") + trace_file = Path.join(test_root, "ssh.trace") + previous_path = System.get_env("PATH") + previous_ssh_config = System.get_env("SYMPHONY_SSH_CONFIG") + + on_exit(fn -> + restore_env("PATH", previous_path) + restore_env("SYMPHONY_SSH_CONFIG", previous_ssh_config) + File.rm_rf(test_root) + end) + + install_fake_ssh!(test_root, trace_file) + System.put_env("SYMPHONY_SSH_CONFIG", "/tmp/symphony-test-ssh-config") + + assert {:ok, {"", 0}} = + SSH.run("localhost:2222", "echo ready", stderr_to_stdout: true) + + trace = File.read!(trace_file) + assert trace =~ "-F /tmp/symphony-test-ssh-config" + assert trace =~ "-T -p 2222 localhost bash -lc" + assert trace =~ "echo ready" + end + + test "run/3 keeps the user prefix when parsing user@host:port targets" do + test_root = Path.join(System.tmp_dir!(), "symphony-ssh-user-test-#{System.unique_integer([:positive])}") + trace_file = Path.join(test_root, "ssh.trace") + previous_path = System.get_env("PATH") + + on_exit(fn -> + restore_env("PATH", previous_path) + File.rm_rf(test_root) + end) + + install_fake_ssh!(test_root, trace_file) + + assert {:ok, {"", 0}} = + SSH.run("root@127.0.0.1:2200", "printf ok", stderr_to_stdout: true) + + trace = File.read!(trace_file) + assert trace =~ "-T -p 2200 root@127.0.0.1 bash -lc" + assert trace =~ "printf ok" + end + + test "run/3 returns an error when ssh is unavailable" do + test_root = Path.join(System.tmp_dir!(), "symphony-ssh-missing-test-#{System.unique_integer([:positive])}") + previous_path = System.get_env("PATH") + + on_exit(fn -> + restore_env("PATH", previous_path) + File.rm_rf(test_root) + end) + + File.mkdir_p!(test_root) + System.put_env("PATH", test_root) + + assert {:error, :ssh_not_found} = SSH.run("localhost", "printf ok") + end + + test "start_port/3 supports binary output without line mode" do + test_root = Path.join(System.tmp_dir!(), "symphony-ssh-port-test-#{System.unique_integer([:positive])}") + trace_file = Path.join(test_root, "ssh.trace") + previous_path = System.get_env("PATH") + previous_ssh_config = System.get_env("SYMPHONY_SSH_CONFIG") + + on_exit(fn -> + restore_env("PATH", previous_path) + restore_env("SYMPHONY_SSH_CONFIG", previous_ssh_config) + File.rm_rf(test_root) + end) + + install_fake_ssh!(test_root, trace_file, """ + #!/bin/sh + printf 'ARGV:%s\\n' "$*" >> "#{trace_file}" + printf 'ready\\n' + exit 0 + """) + + System.delete_env("SYMPHONY_SSH_CONFIG") + + assert {:ok, port} = SSH.start_port("localhost", "printf ok") + assert is_port(port) + wait_for_trace!(trace_file) + + trace = File.read!(trace_file) + assert trace =~ "-T localhost bash -lc" + refute trace =~ " -F " + end + + test "start_port/3 supports line mode" do + test_root = Path.join(System.tmp_dir!(), "symphony-ssh-line-port-test-#{System.unique_integer([:positive])}") + trace_file = Path.join(test_root, "ssh.trace") + previous_path = System.get_env("PATH") + + on_exit(fn -> + restore_env("PATH", previous_path) + File.rm_rf(test_root) + end) + + install_fake_ssh!(test_root, trace_file, """ + #!/bin/sh + printf 'ARGV:%s\\n' "$*" >> "#{trace_file}" + printf 'ready\\n' + exit 0 + """) + + assert {:ok, port} = SSH.start_port("localhost:2222", "printf ok", line: 256) + assert is_port(port) + wait_for_trace!(trace_file) + + trace = File.read!(trace_file) + assert trace =~ "-T -p 2222 localhost bash -lc" + end + + test "remote_shell_command/1 escapes embedded single quotes" do + assert SSH.remote_shell_command("printf 'hello'") == + "bash -lc 'printf '\"'\"'hello'\"'\"''" + end + + defp install_fake_ssh!(test_root, trace_file, script \\ nil) do + fake_bin_dir = Path.join(test_root, "bin") + fake_ssh = Path.join(fake_bin_dir, "ssh") + + File.mkdir_p!(fake_bin_dir) + + File.write!( + fake_ssh, + script || + """ + #!/bin/sh + printf 'ARGV:%s\\n' "$*" >> "#{trace_file}" + exit 0 + """ + ) + + File.chmod!(fake_ssh, 0o755) + System.put_env("PATH", fake_bin_dir <> ":" <> (System.get_env("PATH") || "")) + end + + defp wait_for_trace!(trace_file, attempts \\ 20) + defp wait_for_trace!(trace_file, 0), do: flunk("timed out waiting for fake ssh trace at #{trace_file}") + + defp wait_for_trace!(trace_file, attempts) do + if File.exists?(trace_file) and File.read!(trace_file) != "" do + :ok + else + Process.sleep(25) + wait_for_trace!(trace_file, attempts - 1) + end + end + + defp restore_env(key, nil), do: System.delete_env(key) + defp restore_env(key, value), do: System.put_env(key, value) +end diff --git a/elixir/test/symphony_elixir/stage_closeout_test.exs b/elixir/test/symphony_elixir/stage_closeout_test.exs new file mode 100644 index 000000000..818b9b92a --- /dev/null +++ b/elixir/test/symphony_elixir/stage_closeout_test.exs @@ -0,0 +1,322 @@ +defmodule SymphonyElixir.StageCloseoutTest do + use ExUnit.Case, async: true + + alias SymphonyElixir.StageCloseout + + @moduletag :tmp_dir + + @issue "ENT-187" + @other_issue "ENT-999" + + setup context do + if context[:tmp_dir] do + tmp_dir = + Path.join( + System.tmp_dir!(), + "symphony-stage-closeout-test-#{System.unique_integer([:positive, :monotonic])}" + ) + + File.mkdir_p!(tmp_dir) + on_exit(fn -> File.rm_rf(tmp_dir) end) + {:ok, tmp_dir: tmp_dir} + else + :ok + end + end + + test "check_review passes when dispatch head, code-review marker, and tree are clean", %{tmp_dir: tmp_dir} do + repo = init_repo!(tmp_dir) + head = head!(repo) + + workpad = + workpad([ + marker(kind: "review-request", round_id: 1, stage_round: 1, reviewed_sha: head), + marker( + kind: "code-review", + round_id: 1, + stage_round: 1, + reviewed_sha: head, + verdict: "clean" + ) + ]) + + assert StageCloseout.check_review(repo, workpad, head, @issue) == :ok + end + + test "check_doc_fix passes for no-updates when docs-checked matches clean review head", %{tmp_dir: tmp_dir} do + repo = init_repo!(tmp_dir) + head = head!(repo) + + workpad = + workpad([ + marker(kind: "review-request", round_id: 1, stage_round: 1, reviewed_sha: head), + marker( + kind: "code-review", + round_id: 1, + stage_round: 1, + reviewed_sha: head, + verdict: "clean" + ), + marker( + kind: "docs-checked", + round_id: 1, + stage_round: 1, + reviewed_sha: head, + docfix_outcome: "no-updates" + ) + ]) + + assert StageCloseout.check_doc_fix(repo, workpad, @issue) == :ok + end + + test "check_doc_fix passes when only docs paths changed", %{tmp_dir: tmp_dir} do + repo = init_repo!(tmp_dir) + review_sha = head!(repo) + + File.mkdir_p!(Path.join(repo, "docs")) + File.write!(Path.join(repo, "docs/guide.txt"), "updated docs\n") + commit_all!(repo, "docs update") + head = head!(repo) + + workpad = + workpad([ + marker(kind: "review-request", round_id: 1, stage_round: 1, reviewed_sha: review_sha), + marker( + kind: "code-review", + round_id: 1, + stage_round: 1, + reviewed_sha: review_sha, + verdict: "clean" + ), + marker( + kind: "docs-checked", + round_id: 1, + stage_round: 1, + reviewed_sha: head, + docfix_outcome: "updated" + ) + ]) + + assert StageCloseout.check_doc_fix(repo, workpad, @issue) == :ok + end + + test "check_review fails when reviewer moved HEAD after dispatch", %{tmp_dir: tmp_dir} do + repo = init_repo!(tmp_dir) + dispatch_head = head!(repo) + + File.write!(Path.join(repo, "review.txt"), "reviewer committed\n") + commit_all!(repo, "reviewer commit") + head = head!(repo) + + workpad = + workpad([ + marker( + kind: "code-review", + round_id: 1, + stage_round: 1, + reviewed_sha: head, + verdict: "clean" + ) + ]) + + assert StageCloseout.check_review(repo, workpad, dispatch_head, @issue) == + {:error, {:reviewer_committed, old: dispatch_head, new: head}} + end + + test "check_review fails when the current round has no code-review marker", %{tmp_dir: tmp_dir} do + repo = init_repo!(tmp_dir) + head = head!(repo) + + workpad = workpad([marker(kind: "review-request", round_id: 1, stage_round: 1, reviewed_sha: head)]) + + assert StageCloseout.check_review(repo, workpad, head, @issue) == + {:error, {:missing_marker, :code_review}} + end + + test "check_doc_fix fails when the current round has no docs-checked marker", %{tmp_dir: tmp_dir} do + repo = init_repo!(tmp_dir) + head = head!(repo) + + workpad = + workpad([ + marker(kind: "review-request", round_id: 1, stage_round: 1, reviewed_sha: head), + marker( + kind: "code-review", + round_id: 1, + stage_round: 1, + reviewed_sha: head, + verdict: "clean" + ) + ]) + + assert StageCloseout.check_doc_fix(repo, workpad, @issue) == + {:error, {:missing_marker, :docs_checked}} + end + + test "check_review fails when latest code-review reviewed_sha differs from HEAD", %{tmp_dir: tmp_dir} do + repo = init_repo!(tmp_dir) + reviewed_sha = head!(repo) + + File.write!(Path.join(repo, "lib.txt"), "new head\n") + commit_all!(repo, "advance head") + head = head!(repo) + + workpad = + workpad([ + marker(kind: "review-request", round_id: 1, stage_round: 1, reviewed_sha: reviewed_sha), + marker( + kind: "code-review", + round_id: 1, + stage_round: 1, + reviewed_sha: reviewed_sha, + verdict: "clean" + ) + ]) + + assert StageCloseout.check_review(repo, workpad, head, @issue) == + {:error, {:reviewed_sha_mismatch, marker: :code_review, reviewed_sha: reviewed_sha, head: head}} + end + + test "check_review fails when the working tree is dirty", %{tmp_dir: tmp_dir} do + repo = init_repo!(tmp_dir) + head = head!(repo) + File.write!(Path.join(repo, "scratch.txt"), "dirty\n") + + workpad = + workpad([ + marker(kind: "review-request", round_id: 1, stage_round: 1, reviewed_sha: head), + marker( + kind: "code-review", + round_id: 1, + stage_round: 1, + reviewed_sha: head, + verdict: "clean" + ) + ]) + + assert StageCloseout.check_review(repo, workpad, head, @issue) == + {:error, {:working_tree_dirty, ["?? scratch.txt"]}} + end + + test "check_review rejects a findings-to-clean flip at the same HEAD", %{tmp_dir: tmp_dir} do + repo = init_repo!(tmp_dir) + head = head!(repo) + + workpad = + workpad([ + marker(kind: "review-request", round_id: 1, stage_round: 1, reviewed_sha: head), + marker( + kind: "code-review", + round_id: 1, + stage_round: 1, + reviewed_sha: head, + verdict: "findings" + ), + marker( + kind: "code-review", + round_id: 1, + stage_round: 2, + reviewed_sha: head, + verdict: "clean" + ) + ]) + + assert StageCloseout.check_review(repo, workpad, head, @issue) == + {:error, {:findings_to_clean_flip_same_head, head}} + end + + test "check_doc_fix rejects non-doc paths in the diff since latest clean review", %{tmp_dir: tmp_dir} do + repo = init_repo!(tmp_dir) + review_sha = head!(repo) + + File.mkdir_p!(Path.join(repo, "lib")) + File.write!(Path.join(repo, "lib/code.ex"), "defmodule Code do\nend\n") + File.write!(Path.join(repo, "README.md"), "# updated\n") + commit_all!(repo, "mixed docfix commit") + head = head!(repo) + + workpad = + workpad([ + marker(kind: "review-request", round_id: 1, stage_round: 1, reviewed_sha: review_sha), + marker( + kind: "code-review", + round_id: 1, + stage_round: 1, + reviewed_sha: review_sha, + verdict: "clean" + ), + marker( + kind: "docs-checked", + round_id: 1, + stage_round: 1, + reviewed_sha: head, + docfix_outcome: "updated" + ) + ]) + + assert StageCloseout.check_doc_fix(repo, workpad, @issue) == + {:error, {:non_docs_paths, ["lib/code.ex"]}} + end + + test "check_implement always returns :ok" do + assert StageCloseout.check_implement(:anything, @other_issue, nil) == :ok + end + + defp init_repo!(tmp_dir) do + repo = Path.join(tmp_dir, "repo") + File.mkdir_p!(repo) + File.write!(Path.join(repo, "README.md"), "# test\n") + git!(repo, ["init", "-b", "main"]) + git!(repo, ["config", "user.name", "Test User"]) + git!(repo, ["config", "user.email", "test@example.com"]) + git!(repo, ["add", "README.md"]) + git!(repo, ["commit", "-m", "initial"]) + repo + end + + defp commit_all!(repo, message) do + git!(repo, ["add", "-A"]) + git!(repo, ["commit", "-m", message]) + end + + defp head!(repo) do + git!(repo, ["rev-parse", "HEAD"]) |> String.trim() + end + + defp git!(repo, args) do + case System.cmd("git", args, cd: repo, stderr_to_stdout: true) do + {output, 0} -> output + {output, status} -> flunk("git #{Enum.join(args, " ")} failed (#{status}): #{output}") + end + end + + defp workpad(markers) do + """ + Notes outside marker region. + + + #{Enum.map_join(markers, "\n", &marker_block/1)} + + """ + end + + defp marker(fields) do + Keyword.put_new(fields, :issue_identifier, @issue) + end + + defp marker_block(fields) do + body = + Enum.map_join(fields, "\n", fn {key, value} -> + "#{key}: #{yaml_value(value)}" + end) + + """ + ```symphony-marker + #{body} + ``` + """ + end + + defp yaml_value(value) when is_binary(value), do: value + defp yaml_value(value) when is_integer(value), do: Integer.to_string(value) +end diff --git a/elixir/test/symphony_elixir/stage_orchestrator_test.exs b/elixir/test/symphony_elixir/stage_orchestrator_test.exs new file mode 100644 index 000000000..a7622ac04 --- /dev/null +++ b/elixir/test/symphony_elixir/stage_orchestrator_test.exs @@ -0,0 +1,294 @@ +defmodule SymphonyElixir.StageOrchestratorTestAgentRunner do + def run(issue, recipient, opts) do + send(Process.get(:stage_orchestrator_test_pid), {:agent_runner_run, issue, recipient, opts}) + :ok + end +end + +defmodule SymphonyElixir.StageOrchestratorTest do + use SymphonyElixir.TestSupport + + alias SymphonyElixir.Linear.Issue + alias SymphonyElixir.MarkerParser + alias SymphonyElixir.StageOrchestrator + alias SymphonyElixir.Workflow + + @moduletag :tmp_dir + + @issue "ENT-187" + + setup context do + tmp_dir = + if context[:tmp_dir] do + dir = + Path.join( + System.tmp_dir!(), + "symphony-stage-orchestrator-test-#{System.unique_integer([:positive, :monotonic])}" + ) + + File.mkdir_p!(dir) + on_exit(fn -> File.rm_rf(dir) end) + dir + end + + previous_runner_module = + Application.get_env(:symphony_elixir, :stage_orchestrator_agent_runner_module) + + Application.put_env( + :symphony_elixir, + :stage_orchestrator_agent_runner_module, + SymphonyElixir.StageOrchestratorTestAgentRunner + ) + + Process.put(:stage_orchestrator_test_pid, self()) + + on_exit(fn -> + Process.delete(:stage_orchestrator_test_pid) + + if is_nil(previous_runner_module) do + Application.delete_env(:symphony_elixir, :stage_orchestrator_agent_runner_module) + else + Application.put_env( + :symphony_elixir, + :stage_orchestrator_agent_runner_module, + previous_runner_module + ) + end + end) + + {:ok, tmp_dir: tmp_dir} + end + + test "next_stage short-circuits Rework to implement before other clauses" do + workpad = + workpad([ + marker(kind: "review-request", round_id: 1, stage_round: 1, reviewed_sha: sha("a")) + ]) + + assert StageOrchestrator.next_stage(workpad, "Rework", "/definitely/missing") == :implement + end + + test "next_stage returns stop for non-active states before inspecting markers" do + workpad = + workpad([ + marker(kind: "review-request", round_id: 1, stage_round: 1, reviewed_sha: sha("a")) + ]) + + assert StageOrchestrator.next_stage(workpad, "Done", "/definitely/missing") == :stop + end + + test "next_stage returns review when a review-request is pending" do + workpad = + workpad([ + marker(kind: "review-request", round_id: 1, stage_round: 1, reviewed_sha: sha("a")) + ]) + + assert StageOrchestrator.next_stage(workpad, "In Progress", "/definitely/missing") == :review + end + + test "next_stage returns implement when latest code-review verdict is findings" do + workpad = + workpad([ + marker(kind: "review-request", round_id: 1, stage_round: 1, reviewed_sha: sha("a")), + marker( + kind: "code-review", + round_id: 1, + stage_round: 1, + reviewed_sha: sha("a"), + verdict: "findings" + ) + ]) + + assert StageOrchestrator.next_stage(workpad, "In Progress", "/definitely/missing") == :implement + end + + test "next_stage returns review when latest clean review is stale", %{tmp_dir: tmp_dir} do + repo = init_repo!(tmp_dir) + reviewed_sha = head!(repo) + + File.write!(Path.join(repo, "lib.txt"), "new implementation\n") + commit_all!(repo, "advance head") + + workpad = + workpad([ + marker(kind: "review-request", round_id: 1, stage_round: 1, reviewed_sha: reviewed_sha), + marker( + kind: "code-review", + round_id: 1, + stage_round: 1, + reviewed_sha: reviewed_sha, + verdict: "clean" + ) + ]) + + assert StageOrchestrator.next_stage(workpad, "In Progress", repo) == :review + end + + test "next_stage returns doc_fix when clean review matches HEAD but docs are stale", %{tmp_dir: tmp_dir} do + repo = init_repo!(tmp_dir) + head = head!(repo) + + workpad = + workpad([ + marker(kind: "review-request", round_id: 1, stage_round: 1, reviewed_sha: head), + marker( + kind: "code-review", + round_id: 1, + stage_round: 1, + reviewed_sha: head, + verdict: "clean" + ) + ]) + + assert StageOrchestrator.next_stage(workpad, "In Progress", repo) == :doc_fix + end + + test "next_stage returns implement when docs-checked matches the current clean review", %{tmp_dir: tmp_dir} do + repo = init_repo!(tmp_dir) + head = head!(repo) + + workpad = + workpad([ + marker(kind: "review-request", round_id: 1, stage_round: 1, reviewed_sha: head), + marker( + kind: "code-review", + round_id: 1, + stage_round: 1, + reviewed_sha: head, + verdict: "clean" + ), + marker( + kind: "docs-checked", + round_id: 1, + stage_round: 1, + reviewed_sha: head, + docfix_outcome: "no-updates" + ) + ]) + + assert StageOrchestrator.next_stage(workpad, "In Progress", repo) == :implement + end + + test "next_stage falls back to implement for active issues without current-round review markers" do + assert StageOrchestrator.next_stage(workpad([]), "Todo", "/definitely/missing") == :implement + end + + test "dispatch uses the default workflow path for implement" do + issue = issue_fixture() + + assert StageOrchestrator.dispatch(issue, :implement) == :ok + + assert_received {:agent_runner_run, ^issue, nil, opts} + assert Keyword.get(opts, :workflow_path) == Workflow.workflow_file_path() + assert Keyword.get(opts, :max_turns) == 1 + end + + test "dispatch uses WORKFLOW-review.md for review" do + issue = issue_fixture() + + assert StageOrchestrator.dispatch(issue, :review) == :ok + + assert_received {:agent_runner_run, ^issue, nil, opts} + + assert Keyword.get(opts, :workflow_path) == + Path.join(Path.dirname(Workflow.workflow_file_path()), "WORKFLOW-review.md") + + assert Keyword.get(opts, :max_turns) == 1 + end + + test "dispatch uses WORKFLOW-docfix.md for doc_fix" do + issue = issue_fixture() + + assert StageOrchestrator.dispatch(issue, :doc_fix) == :ok + + assert_received {:agent_runner_run, ^issue, nil, opts} + + assert Keyword.get(opts, :workflow_path) == + Path.join(Path.dirname(Workflow.workflow_file_path()), "WORKFLOW-docfix.md") + + assert Keyword.get(opts, :max_turns) == 1 + end + + test "dispatch does nothing for stop" do + assert StageOrchestrator.dispatch(issue_fixture(), :stop) == :ok + refute_received {:agent_runner_run, _, _, _} + end + + defp init_repo!(tmp_dir) do + repo = Path.join(tmp_dir, "repo") + File.mkdir_p!(repo) + File.write!(Path.join(repo, "README.md"), "# test\n") + git!(repo, ["init", "-b", "main"]) + git!(repo, ["config", "user.name", "Test User"]) + git!(repo, ["config", "user.email", "test@example.com"]) + git!(repo, ["add", "README.md"]) + git!(repo, ["commit", "-m", "initial"]) + repo + end + + defp commit_all!(repo, message) do + git!(repo, ["add", "-A"]) + git!(repo, ["commit", "-m", message]) + end + + defp head!(repo) do + git!(repo, ["rev-parse", "HEAD"]) |> String.trim() + end + + defp git!(repo, args) do + case System.cmd("git", args, cd: repo, stderr_to_stdout: true) do + {output, 0} -> output + {output, status} -> flunk("git #{Enum.join(args, " ")} failed (#{status}): #{output}") + end + end + + defp issue_fixture do + %Issue{ + id: "issue-1", + identifier: @issue, + title: "Stage orchestration", + description: "Route the next workflow stage.", + state: "In Progress" + } + end + + defp workpad(markers) do + workpad = """ + Notes outside marker region. + + + #{Enum.map_join(markers, "\n", &marker_block/1)} + + """ + + if length(MarkerParser.parse(workpad, @issue)) != length(markers) do + flunk("invalid marker fixture: #{inspect(markers)}") + end + + workpad + end + + defp marker(fields) do + Keyword.put_new(fields, :issue_identifier, @issue) + end + + defp marker_block(fields) do + body = + Enum.map_join(fields, "\n", fn {key, value} -> + "#{key}: #{yaml_value(value)}" + end) + + """ + ```symphony-marker + #{body} + ``` + """ + end + + defp yaml_value(value) when is_binary(value), do: value + defp yaml_value(value) when is_integer(value), do: Integer.to_string(value) + + defp sha(char) when is_binary(char) and byte_size(char) == 1 do + String.duplicate(char, 40) + end +end diff --git a/elixir/test/symphony_elixir/workspace_and_config_test.exs b/elixir/test/symphony_elixir/workspace_and_config_test.exs index 10f9f524a..59ff0850b 100644 --- a/elixir/test/symphony_elixir/workspace_and_config_test.exs +++ b/elixir/test/symphony_elixir/workspace_and_config_test.exs @@ -1,5 +1,8 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do use SymphonyElixir.TestSupport + alias Ecto.Changeset + alias SymphonyElixir.Config.Schema + alias SymphonyElixir.Config.Schema.{Codex, StringOrMap} alias SymphonyElixir.Linear.Client test "workspace bootstrap can be implemented in after_create hook" do @@ -83,7 +86,7 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do assert File.read!(Path.join(second_workspace, "local-progress.txt")) == "in progress\n" assert File.read!(Path.join([second_workspace, "deps", "cache.txt"])) == "cached deps\n" assert File.read!(Path.join([second_workspace, "_build", "artifact.txt"])) == "compiled artifact\n" - refute File.exists?(Path.join([second_workspace, "tmp", "scratch.txt"])) + assert File.read!(Path.join([second_workspace, "tmp", "scratch.txt"])) == "remove me\n" after File.rm_rf(workspace_root) end @@ -103,8 +106,9 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do write_workflow_file!(Workflow.workflow_file_path(), workspace_root: workspace_root) + assert {:ok, canonical_workspace} = SymphonyElixir.PathSafety.canonicalize(stale_workspace) assert {:ok, workspace} = Workspace.create_for_issue("MT-STALE") - assert workspace == stale_workspace + assert workspace == canonical_workspace assert File.dir?(workspace) after File.rm_rf(workspace_root) @@ -129,13 +133,43 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do write_workflow_file!(Workflow.workflow_file_path(), workspace_root: workspace_root) - assert {:error, {:workspace_symlink_escape, ^symlink_path, ^workspace_root}} = + assert {:ok, canonical_outside_root} = SymphonyElixir.PathSafety.canonicalize(outside_root) + assert {:ok, canonical_workspace_root} = SymphonyElixir.PathSafety.canonicalize(workspace_root) + + assert {:error, {:workspace_outside_root, ^canonical_outside_root, ^canonical_workspace_root}} = Workspace.create_for_issue("MT-SYM") after File.rm_rf(test_root) end end + test "workspace canonicalizes symlinked workspace roots before creating issue directories" do + test_root = + Path.join( + System.tmp_dir!(), + "symphony-elixir-workspace-root-symlink-#{System.unique_integer([:positive])}" + ) + + try do + actual_root = Path.join(test_root, "actual-workspaces") + linked_root = Path.join(test_root, "linked-workspaces") + + File.mkdir_p!(actual_root) + File.ln_s!(actual_root, linked_root) + + write_workflow_file!(Workflow.workflow_file_path(), workspace_root: linked_root) + + assert {:ok, canonical_workspace} = + SymphonyElixir.PathSafety.canonicalize(Path.join(actual_root, "MT-LINK")) + + assert {:ok, workspace} = Workspace.create_for_issue("MT-LINK") + assert workspace == canonical_workspace + assert File.dir?(workspace) + after + File.rm_rf(test_root) + end + end + test "workspace remove rejects the workspace root itself with a distinct error" do workspace_root = Path.join( @@ -147,7 +181,10 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do File.mkdir_p!(workspace_root) write_workflow_file!(Workflow.workflow_file_path(), workspace_root: workspace_root) - assert {:error, {:workspace_equals_root, ^workspace_root, ^workspace_root}, ""} = + assert {:ok, canonical_workspace_root} = + SymphonyElixir.PathSafety.canonicalize(workspace_root) + + assert {:error, {:workspace_equals_root, ^canonical_workspace_root, ^canonical_workspace_root}, ""} = Workspace.remove(workspace_root) after File.rm_rf(workspace_root) @@ -206,8 +243,9 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do write_workflow_file!(Workflow.workflow_file_path(), workspace_root: workspace_root) workspace = Path.join(workspace_root, "MT-608") + assert {:ok, canonical_workspace} = SymphonyElixir.PathSafety.canonicalize(workspace) - assert {:ok, ^workspace} = Workspace.create_for_issue("MT-608") + assert {:ok, ^canonical_workspace} = Workspace.create_for_issue("MT-608") assert File.dir?(workspace) assert {:ok, []} = File.ls(workspace) after @@ -348,6 +386,49 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do assert Enum.map(merged, & &1.identifier) == ["MT-1", "MT-2", "MT-3"] end + test "linear client paginates issue state fetches by id beyond one page" do + issue_ids = Enum.map(1..55, &"issue-#{&1}") + first_batch_ids = Enum.take(issue_ids, 50) + second_batch_ids = Enum.drop(issue_ids, 50) + + raw_issue = fn issue_id -> + suffix = String.replace_prefix(issue_id, "issue-", "") + + %{ + "id" => issue_id, + "identifier" => "MT-#{suffix}", + "title" => "Issue #{suffix}", + "description" => "Description #{suffix}", + "state" => %{"name" => "In Progress"}, + "labels" => %{"nodes" => []}, + "inverseRelations" => %{"nodes" => []} + } + end + + graphql_fun = fn query, variables -> + send(self(), {:fetch_issue_states_page, query, variables}) + + body = %{ + "data" => %{ + "issues" => %{ + "nodes" => Enum.map(variables.ids, raw_issue) + } + } + } + + {:ok, body} + end + + assert {:ok, issues} = Client.fetch_issue_states_by_ids_for_test(issue_ids, graphql_fun) + + assert Enum.map(issues, & &1.id) == issue_ids + + assert_receive {:fetch_issue_states_page, query, %{ids: ^first_batch_ids, first: 50, relationFirst: 50}} + assert query =~ "SymphonyLinearIssuesById" + + assert_receive {:fetch_issue_states_page, ^query, %{ids: ^second_batch_ids, first: 5, relationFirst: 50}} + end + test "linear client logs response bodies for non-200 graphql responses" do log = ExUnit.CaptureLog.capture_log(fn -> @@ -533,8 +614,9 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do hook_before_remove: "echo before_remove > \"#{before_remove_marker}\"" ) - assert Config.workspace_hooks().after_create =~ "echo after_create > after_create.log" - assert Config.workspace_hooks().before_remove =~ "echo before_remove >" + config = Config.settings!() + assert config.hooks.after_create =~ "echo after_create > after_create.log" + assert config.hooks.before_remove =~ "echo before_remove >" assert {:ok, workspace} = Workspace.create_for_issue("MT-HOOKS") assert File.read!(Path.join(workspace, "after_create.log")) == "after_create\n" @@ -655,14 +737,16 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do tracker_project_slug: nil ) - assert Config.linear_endpoint() == "https://api.linear.app/graphql" - assert Config.linear_api_token() == nil - assert Config.linear_project_slug() == nil - assert Config.workspace_root() == Path.join(System.tmp_dir!(), "symphony_workspaces") - assert Config.max_concurrent_agents() == 10 - assert Config.codex_command() == "codex app-server" + config = Config.settings!() + assert config.tracker.endpoint == "https://api.linear.app/graphql" + assert config.tracker.api_key == nil + assert config.tracker.project_slug == nil + assert config.workspace.root == Path.join(System.tmp_dir!(), "symphony_workspaces") + assert config.worker.max_concurrent_agents_per_host == nil + assert config.agent.max_concurrent_agents == 10 + assert config.codex.command == "codex app-server" - assert Config.codex_approval_policy() == %{ + assert config.codex.approval_policy == %{ "reject" => %{ "sandbox_approval" => true, "rules" => true, @@ -670,52 +754,81 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do } } - assert Config.codex_thread_sandbox() == "workspace-write" + assert config.codex.thread_sandbox == "workspace-write" + + assert {:ok, canonical_default_workspace_root} = + SymphonyElixir.PathSafety.canonicalize(Path.join(System.tmp_dir!(), "symphony_workspaces")) assert Config.codex_turn_sandbox_policy() == %{ "type" => "workspaceWrite", - "writableRoots" => [Path.expand(Path.join(System.tmp_dir!(), "symphony_workspaces"))], + "writableRoots" => [canonical_default_workspace_root], "readOnlyAccess" => %{"type" => "fullAccess"}, "networkAccess" => false, "excludeTmpdirEnvVar" => false, "excludeSlashTmp" => false } - assert Config.codex_turn_timeout_ms() == 3_600_000 - assert Config.codex_read_timeout_ms() == 5_000 - assert Config.codex_stall_timeout_ms() == 300_000 + assert config.codex.turn_timeout_ms == 3_600_000 + assert config.codex.read_timeout_ms == 5_000 + assert config.codex.stall_timeout_ms == 300_000 write_workflow_file!(Workflow.workflow_file_path(), codex_command: "codex app-server --model gpt-5.3-codex") - assert Config.codex_command() == "codex app-server --model gpt-5.3-codex" + assert Config.settings!().codex.command == "codex app-server --model gpt-5.3-codex" + + explicit_root = + Path.join( + System.tmp_dir!(), + "symphony-elixir-explicit-sandbox-root-#{System.unique_integer([:positive])}" + ) + + explicit_workspace = Path.join(explicit_root, "MT-EXPLICIT") + explicit_cache = Path.join(explicit_workspace, "cache") + File.mkdir_p!(explicit_cache) + + on_exit(fn -> File.rm_rf(explicit_root) end) write_workflow_file!(Workflow.workflow_file_path(), + workspace_root: explicit_root, codex_approval_policy: "on-request", codex_thread_sandbox: "workspace-write", - codex_turn_sandbox_policy: %{type: "workspaceWrite", writableRoots: ["/tmp/workspace", "/tmp/cache"]} + codex_turn_sandbox_policy: %{ + type: "workspaceWrite", + writableRoots: [explicit_workspace, explicit_cache] + } ) - assert Config.codex_approval_policy() == "on-request" - assert Config.codex_thread_sandbox() == "workspace-write" + config = Config.settings!() + assert config.codex.approval_policy == "on-request" + assert config.codex.thread_sandbox == "workspace-write" - assert Config.codex_turn_sandbox_policy() == %{ + assert Config.codex_turn_sandbox_policy(explicit_workspace) == %{ "type" => "workspaceWrite", - "writableRoots" => ["/tmp/workspace", "/tmp/cache"] + "writableRoots" => [explicit_workspace, explicit_cache] } write_workflow_file!(Workflow.workflow_file_path(), tracker_active_states: ",") - assert Config.linear_active_states() == ["Todo", "In Progress"] + assert {:error, {:invalid_workflow_config, message}} = Config.validate!() + assert message =~ "tracker.active_states" write_workflow_file!(Workflow.workflow_file_path(), max_concurrent_agents: "bad") - assert Config.max_concurrent_agents() == 10 + assert {:error, {:invalid_workflow_config, message}} = Config.validate!() + assert message =~ "agent.max_concurrent_agents" + + write_workflow_file!(Workflow.workflow_file_path(), worker_max_concurrent_agents_per_host: 0) + assert {:error, {:invalid_workflow_config, message}} = Config.validate!() + assert message =~ "worker.max_concurrent_agents_per_host" write_workflow_file!(Workflow.workflow_file_path(), codex_turn_timeout_ms: "bad") - assert Config.codex_turn_timeout_ms() == 3_600_000 + assert {:error, {:invalid_workflow_config, message}} = Config.validate!() + assert message =~ "codex.turn_timeout_ms" write_workflow_file!(Workflow.workflow_file_path(), codex_read_timeout_ms: "bad") - assert Config.codex_read_timeout_ms() == 5_000 + assert {:error, {:invalid_workflow_config, message}} = Config.validate!() + assert message =~ "codex.read_timeout_ms" write_workflow_file!(Workflow.workflow_file_path(), codex_stall_timeout_ms: "bad") - assert Config.codex_stall_timeout_ms() == 300_000 + assert {:error, {:invalid_workflow_config, message}} = Config.validate!() + assert message =~ "codex.stall_timeout_ms" write_workflow_file!(Workflow.workflow_file_path(), tracker_active_states: %{todo: true}, @@ -732,49 +845,19 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do server_host: 123 ) - assert Config.linear_active_states() == ["Todo", "In Progress"] - assert Config.linear_terminal_states() == ["Closed", "Cancelled", "Canceled", "Duplicate", "Done"] - 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.max_concurrent_agents_for_state("Todo") == 1 - assert Config.max_concurrent_agents_for_state("Review") == 10 - assert Config.hook_timeout_ms() == 60_000 - assert Config.observability_enabled?() - assert Config.observability_refresh_ms() == 1_000 - assert Config.observability_render_interval_ms() == 16 - assert Config.server_port() == nil - assert Config.server_host() == "123" + assert {:error, {:invalid_workflow_config, _message}} = Config.validate!() write_workflow_file!(Workflow.workflow_file_path(), codex_approval_policy: "") - - assert Config.codex_approval_policy() == %{ - "reject" => %{ - "sandbox_approval" => true, - "rules" => true, - "mcp_elicitations" => true - } - } - - assert {:error, {:invalid_codex_approval_policy, ""}} = Config.validate!() + assert :ok = Config.validate!() + assert Config.settings!().codex.approval_policy == "" write_workflow_file!(Workflow.workflow_file_path(), codex_thread_sandbox: "") - assert Config.codex_thread_sandbox() == "workspace-write" - assert {:error, {:invalid_codex_thread_sandbox, ""}} = Config.validate!() + assert :ok = Config.validate!() + assert Config.settings!().codex.thread_sandbox == "" write_workflow_file!(Workflow.workflow_file_path(), codex_turn_sandbox_policy: "bad") - - assert Config.codex_turn_sandbox_policy() == %{ - "type" => "workspaceWrite", - "writableRoots" => [Path.expand(Path.join(System.tmp_dir!(), "symphony_workspaces"))], - "readOnlyAccess" => %{"type" => "fullAccess"}, - "networkAccess" => false, - "excludeTmpdirEnvVar" => false, - "excludeSlashTmp" => false - } - - assert {:error, {:invalid_codex_turn_sandbox_policy, {:unsupported_value, "bad"}}} = - Config.validate!() + assert {:error, {:invalid_workflow_config, message}} = Config.validate!() + assert message =~ "codex.turn_sandbox_policy" write_workflow_file!(Workflow.workflow_file_path(), codex_approval_policy: "future-policy", @@ -785,18 +868,19 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do } ) - assert Config.codex_approval_policy() == "future-policy" - assert Config.codex_thread_sandbox() == "future-sandbox" + config = Config.settings!() + assert config.codex.approval_policy == "future-policy" + assert config.codex.thread_sandbox == "future-sandbox" + + assert :ok = Config.validate!() assert Config.codex_turn_sandbox_policy() == %{ "type" => "futureSandbox", "nested" => %{"flag" => true} } - assert :ok = Config.validate!() - write_workflow_file!(Workflow.workflow_file_path(), codex_command: "codex app-server") - assert Config.codex_command() == "codex app-server" + assert Config.settings!().codex.command == "codex app-server" end test "config resolves $VAR references for env-backed secret and path values" do @@ -823,9 +907,10 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do codex_command: "#{codex_bin} app-server" ) - assert Config.linear_api_token() == api_key - assert Config.workspace_root() == Path.expand(workspace_root) - assert Config.codex_command() == "#{codex_bin} app-server" + config = Config.settings!() + assert config.tracker.api_key == api_key + assert config.workspace.root == Path.expand(workspace_root) + assert config.codex.command == "#{codex_bin} app-server" end test "config no longer resolves legacy env: references" do @@ -850,8 +935,9 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do workspace_root: "env:#{workspace_env_var}" ) - assert Config.linear_api_token() == "env:#{api_key_env_var}" - assert Config.workspace_root() == "env:#{workspace_env_var}" + config = Config.settings!() + assert config.tracker.api_key == "env:#{api_key_env_var}" + assert config.workspace.root == "env:#{workspace_env_var}" end test "config supports per-state max concurrent agent overrides" do @@ -868,12 +954,272 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do File.write!(Workflow.workflow_file_path(), workflow) - assert Config.max_concurrent_agents() == 10 + assert Config.settings!().agent.max_concurrent_agents == 10 assert Config.max_concurrent_agents_for_state("Todo") == 1 assert Config.max_concurrent_agents_for_state("In Progress") == 4 assert Config.max_concurrent_agents_for_state("In Review") == 2 assert Config.max_concurrent_agents_for_state("Closed") == 10 assert Config.max_concurrent_agents_for_state(:not_a_string) == 10 + + write_workflow_file!(Workflow.workflow_file_path(), worker_max_concurrent_agents_per_host: 2) + assert :ok = Config.validate!() + assert Config.settings!().worker.max_concurrent_agents_per_host == 2 + end + + test "schema helpers cover custom type and state limit validation" do + assert StringOrMap.type() == :map + assert StringOrMap.embed_as(:json) == :self + assert StringOrMap.equal?(%{"a" => 1}, %{"a" => 1}) + refute StringOrMap.equal?(%{"a" => 1}, %{"a" => 2}) + + assert {:ok, "value"} = StringOrMap.cast("value") + assert {:ok, %{"a" => 1}} = StringOrMap.cast(%{"a" => 1}) + assert :error = StringOrMap.cast(123) + + assert {:ok, "value"} = StringOrMap.load("value") + assert :error = StringOrMap.load(123) + + assert {:ok, %{"a" => 1}} = StringOrMap.dump(%{"a" => 1}) + assert :error = StringOrMap.dump(123) + + assert Schema.normalize_state_limits(nil) == %{} + + assert Schema.normalize_state_limits(%{"In Progress" => 2, todo: 1}) == %{ + "todo" => 1, + "in progress" => 2 + } + + changeset = + {%{}, %{limits: :map}} + |> Changeset.cast(%{limits: %{"" => 1, "todo" => 0}}, [:limits]) + |> Schema.validate_state_limits(:limits) + + assert changeset.errors == [ + limits: {"state names must not be blank", []}, + limits: {"limits must be positive integers", []} + ] + end + + test "schema parse normalizes policy keys and env-backed fallbacks" do + missing_workspace_env = "SYMP_MISSING_WORKSPACE_#{System.unique_integer([:positive])}" + empty_secret_env = "SYMP_EMPTY_SECRET_#{System.unique_integer([:positive])}" + missing_secret_env = "SYMP_MISSING_SECRET_#{System.unique_integer([:positive])}" + + previous_missing_workspace_env = System.get_env(missing_workspace_env) + previous_empty_secret_env = System.get_env(empty_secret_env) + previous_missing_secret_env = System.get_env(missing_secret_env) + previous_linear_api_key = System.get_env("LINEAR_API_KEY") + + System.delete_env(missing_workspace_env) + System.put_env(empty_secret_env, "") + System.delete_env(missing_secret_env) + System.put_env("LINEAR_API_KEY", "fallback-linear-token") + + on_exit(fn -> + restore_env(missing_workspace_env, previous_missing_workspace_env) + restore_env(empty_secret_env, previous_empty_secret_env) + restore_env(missing_secret_env, previous_missing_secret_env) + restore_env("LINEAR_API_KEY", previous_linear_api_key) + end) + + assert {:ok, settings} = + Schema.parse(%{ + tracker: %{api_key: "$#{empty_secret_env}"}, + workspace: %{root: "$#{missing_workspace_env}"}, + codex: %{approval_policy: %{reject: %{sandbox_approval: true}}} + }) + + assert settings.tracker.api_key == nil + assert settings.workspace.root == Path.join(System.tmp_dir!(), "symphony_workspaces") + + assert settings.codex.approval_policy == %{ + "reject" => %{"sandbox_approval" => true} + } + + assert {:ok, settings} = + Schema.parse(%{ + tracker: %{api_key: "$#{missing_secret_env}"}, + workspace: %{root: ""} + }) + + assert settings.tracker.api_key == "fallback-linear-token" + assert settings.workspace.root == Path.join(System.tmp_dir!(), "symphony_workspaces") + end + + test "schema resolves sandbox policies from explicit and default workspaces" do + explicit_policy = %{"type" => "workspaceWrite", "writableRoots" => ["/tmp/explicit"]} + + assert Schema.resolve_turn_sandbox_policy(%Schema{ + codex: %Codex{turn_sandbox_policy: explicit_policy}, + workspace: %Schema.Workspace{root: "/tmp/ignored"} + }) == explicit_policy + + assert Schema.resolve_turn_sandbox_policy(%Schema{ + codex: %Codex{turn_sandbox_policy: nil}, + workspace: %Schema.Workspace{root: ""} + }) == %{ + "type" => "workspaceWrite", + "writableRoots" => [Path.expand(Path.join(System.tmp_dir!(), "symphony_workspaces"))], + "readOnlyAccess" => %{"type" => "fullAccess"}, + "networkAccess" => false, + "excludeTmpdirEnvVar" => false, + "excludeSlashTmp" => false + } + + assert Schema.resolve_turn_sandbox_policy( + %Schema{ + codex: %Codex{turn_sandbox_policy: nil}, + workspace: %Schema.Workspace{root: "/tmp/ignored"} + }, + "/tmp/workspace" + ) == %{ + "type" => "workspaceWrite", + "writableRoots" => [Path.expand("/tmp/workspace")], + "readOnlyAccess" => %{"type" => "fullAccess"}, + "networkAccess" => false, + "excludeTmpdirEnvVar" => false, + "excludeSlashTmp" => false + } + end + + test "schema keeps workspace roots raw while sandbox helpers expand only for local use" do + assert {:ok, settings} = + Schema.parse(%{ + workspace: %{root: "~/.symphony-workspaces"}, + codex: %{} + }) + + assert settings.workspace.root == "~/.symphony-workspaces" + + assert Schema.resolve_turn_sandbox_policy(settings) == %{ + "type" => "workspaceWrite", + "writableRoots" => [Path.expand("~/.symphony-workspaces")], + "readOnlyAccess" => %{"type" => "fullAccess"}, + "networkAccess" => false, + "excludeTmpdirEnvVar" => false, + "excludeSlashTmp" => false + } + + assert {:ok, remote_policy} = + Schema.resolve_runtime_turn_sandbox_policy(settings, nil, remote: true) + + assert remote_policy == %{ + "type" => "workspaceWrite", + "writableRoots" => ["~/.symphony-workspaces"], + "readOnlyAccess" => %{"type" => "fullAccess"}, + "networkAccess" => false, + "excludeTmpdirEnvVar" => false, + "excludeSlashTmp" => false + } + end + + test "runtime sandbox policy resolution passes explicit policies through unchanged" do + test_root = + Path.join( + System.tmp_dir!(), + "symphony-elixir-runtime-sandbox-#{System.unique_integer([:positive])}" + ) + + try do + workspace_root = Path.join(test_root, "workspaces") + issue_workspace = Path.join(workspace_root, "MT-100") + File.mkdir_p!(issue_workspace) + + write_workflow_file!(Workflow.workflow_file_path(), + workspace_root: workspace_root, + codex_turn_sandbox_policy: %{ + type: "workspaceWrite", + writableRoots: ["relative/path"], + networkAccess: true + } + ) + + assert {:ok, runtime_settings} = Config.codex_runtime_settings(issue_workspace) + + assert runtime_settings.turn_sandbox_policy == %{ + "type" => "workspaceWrite", + "writableRoots" => ["relative/path"], + "networkAccess" => true + } + + write_workflow_file!(Workflow.workflow_file_path(), + workspace_root: workspace_root, + codex_turn_sandbox_policy: %{ + type: "futureSandbox", + nested: %{flag: true} + } + ) + + assert {:ok, runtime_settings} = Config.codex_runtime_settings(issue_workspace) + + assert runtime_settings.turn_sandbox_policy == %{ + "type" => "futureSandbox", + "nested" => %{"flag" => true} + } + after + File.rm_rf(test_root) + end + end + + test "path safety returns errors for invalid path segments" do + invalid_segment = String.duplicate("a", 300) + path = Path.join(System.tmp_dir!(), invalid_segment) + expanded_path = Path.expand(path) + + assert {:error, {:path_canonicalize_failed, ^expanded_path, :enametoolong}} = + SymphonyElixir.PathSafety.canonicalize(path) + end + + test "runtime sandbox policy resolution defaults when omitted and ignores workspace for explicit policies" do + test_root = + Path.join( + System.tmp_dir!(), + "symphony-elixir-runtime-sandbox-branches-#{System.unique_integer([:positive])}" + ) + + try do + workspace_root = Path.join(test_root, "workspaces") + issue_workspace = Path.join(workspace_root, "MT-101") + + File.mkdir_p!(issue_workspace) + + write_workflow_file!(Workflow.workflow_file_path(), workspace_root: workspace_root) + + settings = Config.settings!() + + assert {:ok, canonical_workspace_root} = + SymphonyElixir.PathSafety.canonicalize(workspace_root) + + assert {:ok, default_policy} = Schema.resolve_runtime_turn_sandbox_policy(settings) + assert default_policy["type"] == "workspaceWrite" + assert default_policy["writableRoots"] == [canonical_workspace_root] + + assert {:ok, blank_workspace_policy} = + Schema.resolve_runtime_turn_sandbox_policy(settings, "") + + assert blank_workspace_policy == default_policy + + read_only_settings = %{ + settings + | codex: %{settings.codex | turn_sandbox_policy: %{"type" => "readOnly", "networkAccess" => true}} + } + + assert {:ok, %{"type" => "readOnly", "networkAccess" => true}} = + Schema.resolve_runtime_turn_sandbox_policy(read_only_settings, 123) + + future_settings = %{ + settings + | codex: %{settings.codex | turn_sandbox_policy: %{"type" => "futureSandbox", "nested" => %{"flag" => true}}} + } + + assert {:ok, %{"type" => "futureSandbox", "nested" => %{"flag" => true}}} = + Schema.resolve_runtime_turn_sandbox_policy(future_settings, 123) + + assert {:error, {:unsafe_turn_sandbox_policy, {:invalid_workspace_root, 123}}} = + Schema.resolve_runtime_turn_sandbox_policy(settings, 123) + after + File.rm_rf(test_root) + end end test "workflow prompt is used when building base prompt" do @@ -882,4 +1228,75 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do write_workflow_file!(Workflow.workflow_file_path(), prompt: workflow_prompt) assert Config.workflow_prompt() == workflow_prompt end + + test "remote workspace lifecycle uses ssh host aliases from worker config" do + test_root = + Path.join( + System.tmp_dir!(), + "symphony-elixir-remote-workspace-#{System.unique_integer([:positive])}" + ) + + previous_path = System.get_env("PATH") + previous_trace = System.get_env("SYMP_TEST_SSH_TRACE") + + on_exit(fn -> + restore_env("PATH", previous_path) + restore_env("SYMP_TEST_SSH_TRACE", previous_trace) + end) + + try do + trace_file = Path.join(test_root, "ssh.trace") + fake_ssh = Path.join(test_root, "ssh") + workspace_root = "~/.symphony-remote-workspaces" + workspace_path = "/remote/home/.symphony-remote-workspaces/MT-SSH-WS" + + File.mkdir_p!(test_root) + System.put_env("SYMP_TEST_SSH_TRACE", trace_file) + System.put_env("PATH", test_root <> ":" <> (previous_path || "")) + + File.write!(fake_ssh, """ + #!/bin/sh + trace_file="${SYMP_TEST_SSH_TRACE:-/tmp/symphony-fake-ssh.trace}" + printf 'ARGV:%s\\n' "$*" >> "$trace_file" + + case "$*" in + *"__SYMPHONY_WORKSPACE__"*) + printf '%s\\t%s\\t%s\\n' '__SYMPHONY_WORKSPACE__' '1' '#{workspace_path}' + ;; + esac + + exit 0 + """) + + File.chmod!(fake_ssh, 0o755) + + write_workflow_file!(Workflow.workflow_file_path(), + workspace_root: workspace_root, + worker_ssh_hosts: ["worker-01:2200"], + hook_before_run: "echo before-run", + hook_after_run: "echo after-run", + hook_before_remove: "echo before-remove" + ) + + assert Config.settings!().worker.ssh_hosts == ["worker-01:2200"] + assert Config.settings!().workspace.root == workspace_root + assert {:ok, ^workspace_path} = Workspace.create_for_issue("MT-SSH-WS", "worker-01:2200") + assert :ok = Workspace.run_before_run_hook(workspace_path, "MT-SSH-WS", "worker-01:2200") + assert :ok = Workspace.run_after_run_hook(workspace_path, "MT-SSH-WS", "worker-01:2200") + assert :ok = Workspace.remove_issue_workspaces("MT-SSH-WS", "worker-01:2200") + + trace = File.read!(trace_file) + assert trace =~ "-p 2200 worker-01 bash -lc" + assert trace =~ "__SYMPHONY_WORKSPACE__" + assert trace =~ "~/.symphony-remote-workspaces/MT-SSH-WS" + assert trace =~ "${workspace#~/}" + assert trace =~ "echo before-run" + assert trace =~ "echo after-run" + assert trace =~ "echo before-remove" + assert trace =~ "rm -rf" + assert trace =~ workspace_path + after + File.rm_rf(test_root) + end + end end