Self-heal Symphony: Symphony API is unreachable on port 8765: {:error, {:failed_connect, [{:to_addre...<truncated>#8
Conversation
…, :socket_closed_remote...<truncated>
…symphony-api-is-unreachable-on-port-8765-error-socket_closed_rem' into dev
…, {:failed_connect, [{:...<truncated>
WalkthroughSymphony is being completely migrated from Python to Elixir. The codebase is rewritten with equivalent functionality across orchestration, agent execution, tracker integration, self-healing, and workspace management. Python source files and tests are replaced with Elixir implementations; build configuration shifts from Hatch to Mix; and documentation is updated to reflect Elixir tooling and new operational modes (watchdog, self-heal, managed launcher). Changes
Sequence DiagramssequenceDiagram
actor User
participant CLI as CLI
participant Orchestrator as Orchestrator
participant Tracker as Tracker
participant AgentRunner as Agent Runner
participant CodexClient as Codex Client
participant Workspace as Workspace
User->>CLI: symphony run
CLI->>Orchestrator: start_link(config)
Orchestrator->>Orchestrator: startup reconciliation
Orchestrator->>Tracker: poll issues by state
Tracker-->>Orchestrator: issues list
loop per tick
Orchestrator->>Tracker: refresh issue states
Tracker-->>Orchestrator: updated issues
Orchestrator->>Orchestrator: sort for dispatch
Orchestrator->>AgentRunner: run_issue(issue, config)
AgentRunner->>Workspace: create workspace
AgentRunner->>CodexClient: start_session(config)
CodexClient->>CodexClient: spawn Codex subprocess
loop until done
AgentRunner->>CodexClient: run_turn(prompt)
CodexClient->>CodexClient: JSONL protocol
CodexClient-->>AgentRunner: TurnResult
AgentRunner->>AgentRunner: analyze output
AgentRunner->>Tracker: refresh issue state
end
CodexClient->>CodexClient: stop_session
AgentRunner->>Tracker: save state/comment if needed
AgentRunner-->>Orchestrator: AgentRunResult
Orchestrator->>Orchestrator: update runtime state
end
User->>CLI: interrupt
CLI->>Orchestrator: stop
Orchestrator-->>CLI: cleanup complete
sequenceDiagram
actor Watchdog
participant WatchdogMod as Watchdog
participant WatchdogTriage as Triage
participant SelfHeal as Self-Heal
participant CodexClient as Codex
participant Workspace as Workspace
participant GitHub as GitHub
participant Tmux as Tmux
Watchdog->>WatchdogMod: run_once(config)
WatchdogMod->>WatchdogMod: fetch local state snapshot
WatchdogMod->>WatchdogMod: classify(snapshot)
alt Healthy
WatchdogMod-->>Watchdog: health=ok
else Requires Triage
WatchdogMod->>WatchdogTriage: triage(payload)
WatchdogTriage->>CodexClient: start_session
WatchdogTriage->>CodexClient: run_turn(triage prompt)
CodexClient-->>WatchdogTriage: JSON decision
WatchdogTriage-->>WatchdogMod: self_heal?/decision
alt Triage Approves
WatchdogMod->>SelfHeal: run_once(config)
SelfHeal->>Workspace: create worktree
SelfHeal->>CodexClient: run repair agent
CodexClient-->>SelfHeal: artifacts
loop validate
SelfHeal->>SelfHeal: run validation commands
end
SelfHeal->>Workspace: commit repair
SelfHeal->>Workspace: deploy to current
SelfHeal->>Tmux: stop old session
SelfHeal->>Tmux: start new session with artifact
SelfHeal->>GitHub: push branch
SelfHeal->>GitHub: create PR
SelfHeal->>GitHub: auto-merge --squash
SelfHeal-->>WatchdogMod: success/failure
else Triage Rejects
WatchdogMod->>WatchdogMod: escalate(rejected reason)
end
end
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related PRs
Suggested reviewers
Poem
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
|
There was a problem hiding this comment.
Actionable comments posted: 16
🧹 Nitpick comments (7)
test/agent_runner_test.exs (1)
196-246: 💤 Low valueEmpty tracker map works here because the error occurs before issue refresh.
The test expects a
thread/starterror response, which meansrun_turnnever completes successfully andfetch_issue_states_by_idsis never called. If the test setup changes to allow a successful turn, this would break. A comment noting this dependency would help future maintainers.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@test/agent_runner_test.exs` around lines 196 - 246, Add a clarifying comment in the test "run_issue preserves response error detail for retry health checks" explaining that leaving the tracker map empty is intentional because the simulated "thread/start" error causes run_turn (invoked via AgentRunner.run_issue) to fail before any issue refresh or call to fetch_issue_states_by_ids occurs; place the comment near the workflow/ConfigManager.new setup (around workflow_path, manager, and runner creation) and reference AgentRunner.run_issue, run_turn, and fetch_issue_states_by_ids so future maintainers understand the dependency.lib/symphony/cli.ex (1)
93-107: 💤 Low valueDefault mode structure is correct, though line 106 is unreachable.
Process.sleep(:infinity)never returns, so the0on line 106 is dead code. This isn't a bug - the process will be killed by Ctrl+C and the exit code doesn't matter. But it's slightly misleading to have a return value that can never be reached.Could use a
receive do endblock or just remove the trailing0since it's unreachable anyway.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@lib/symphony/cli.ex` around lines 93 - 107, The trailing literal return value `0` after Process.sleep(:infinity) is unreachable; update the block that starts the orchestrator (Orchestrator.start_link/1) and optional HTTP server (HTTPServer.start/2) to remove the dead code by either replacing Process.sleep(:infinity) with a blocking receive do end or simply deleting the unreachable `0` expression so the clause no longer contains misleading unreachable return value; ensure the log message and server assignment (server = if port do HTTPServer.start(...) end) remain unchanged.lib/symphony/blocker_diagnosis.ex (2)
364-398: ⚡ Quick win
parse_json_objectandextract_json_objectare duplicated fromdashboard_summary.ex.Nearly identical implementations exist in both files. Worth extracting to a shared utility in
Symphony.Utilsto avoid drift.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@lib/symphony/blocker_diagnosis.ex` around lines 364 - 398, The parse_json_object/1 and extract_json_object/1 implementations in blocker_diagnosis.ex are duplicates of the ones in dashboard_summary.ex; extract these functions into a shared utility module (e.g., Symphony.Utils) and replace the local definitions with calls to that module. Create functions Symphony.Utils.parse_json_object/1 and Symphony.Utils.extract_json_object/1 (preserving behavior: trimming, empty check, Jason.decode fallback to extract_json_object, maps-only check, and the binary search logic), update blocker_diagnosis.ex to call Symphony.Utils.parse_json_object(text) (and remove the local defs), and update dashboard_summary.ex to use the same Symphony.Utils functions so both files reference the single shared implementation.
306-330: ⚡ Quick winBlanket rescue in
workspace_facts/1could mask bugs.The
rescue _ -> %{}at line 328-329 swallows all exceptions, including programming errors. IfFile.ls!/1fails due to a permissions issue you'd want to know about, this silently returns empty facts instead.Consider rescuing specific exceptions or at minimum logging when the rescue triggers.
♻️ Suggested refinement
defp workspace_facts(workspace_path) do # ... rescue - _ -> %{} + error -> + Logger.debug("workspace_facts failed: #{inspect(error)}") + %{} end🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@lib/symphony/blocker_diagnosis.ex` around lines 306 - 330, The blanket rescue in workspace_facts/1 hides all exceptions; change it to only handle expected file-system errors and surface or log others: replace the rescue that wraps workspace_facts/1 with targeted handling (e.g. use File.ls/1 instead of File.ls!/1 and pattern-match the {:ok, list} or {:error, reason} results, or rescue only File.Error or Erlang :enoent/:eacces-like errors), and call Logger.error/1 or Logger.warn/2 with the error reason when returning an empty map so unexpected exceptions in workspace_facts/1 (and calls like repo_facts/1) are not silently swallowed.WORKFLOW.md (1)
17-21: 💤 Low valueMachine-specific absolute paths limit portability.
This workflow file contains hardcoded paths like
/Users/caretta/Documents/repos/.... That's fine for a single-operator self-heal setup, but if anyone else clones this repo or runs CI, these paths won't exist.Consider either:
- Moving this to a gitignored
WORKFLOW.local.md- Using environment variable interpolation if the template supports it
- Adding a comment noting this is intentionally machine-specific
Not blocking since this appears to be the intended design for the self-heal sync path.
Also applies to: 43-69, 78-78, 101-209
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@WORKFLOW.md` around lines 17 - 21, The WORKFLOW.md contains machine-specific absolute paths (e.g., workspace.root and hook commands after_run and before_remove referencing /Users/caretta/... scripts) which break portability; fix by either moving these entries into a gitignored WORKFLOW.local.md, or replace hardcoded paths with environment-variable interpolation (e.g., use ${WORKSPACE_ROOT} and ${SCRIPTS_DIR} if supported) for workspace.root and the after_run/before_remove hook commands, and/or add a clear top-of-file comment stating these entries are intentionally machine-specific; update all similar occurrences referenced in the comment (lines indicated around 43–69, 78, 101–209) so no absolute paths remain in the committed shared WORKFLOW.md.lib/symphony/dashboard_summary.ex (1)
132-143: 💤 Low value
extract_json_objectassumes no nested braces in surrounding text.The approach of finding the first
{and last}works for clean LLM output but would produce invalid JSON if the model wraps the response in prose containing braces (e.g., "Here's your JSON {as requested}:"). Given this is a fallback for malformed output and the prompt explicitly asks for "no markdown and no prose", the risk is low - but worth noting.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@lib/symphony/dashboard_summary.ex` around lines 132 - 143, extract_json_object currently takes the first "{" and the last "}" which breaks when surrounding prose contains braces; modify extract_json_object to locate the first "{" (as currently found) then iterate byte-by-byte forward counting open vs closed braces to find the matching closing "}" (respecting nested braces) and return the slice between those indices, raising the same ArgumentError if no matching close is found; refer to the existing extract_json_object function and use the start index from :binary.match(text, "{") and a looping counter to compute the correct end index instead of :binary.matches(... ) |> List.last().test/codex_client_test.exs (1)
205-233: 💤 Low valueTimeout cleanup test has a subtle race condition risk.
The test writes the PID to a marker file, then immediately checks if the process is dead after the timeout. There's a brief window where the file exists but the process hasn't been killed yet. The
wait_untilonly waits for the file, not for the kill signal to propagate.In practice this is unlikely to flake because the timeout handling should be synchronous, but if it ever does, adding a small sleep after
wait_untilor checking process death in the polling loop would fix it.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@test/codex_client_test.exs` around lines 205 - 233, The test has a race where it only waits for the marker file (wait_until) but not for the child process to be actually reaped; update the assertion after CodexClient.start_session so it waits for process death too: after ensuring the marker exists (marker / wait_until), poll the pid read from the marker with wait_until (or add a short sleep) checking System.cmd("ps", ["-p", pid], stderr_to_stdout: true) returns non-zero, and only then assert the process is gone; modify the test around wait_until, pid, and the System.cmd("ps", ...) check to perform that polling so the kill has time to propagate.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@launchd/com.caretta.symphony.watchdog.plist`:
- Around line 10-16: The plist currently hard-codes user-specific absolute paths
(the standalone working-directory string and the ProgramArguments entry for
/Users/caretta/Library/.../run-symphony-watchdog.sh); update it to remove
workstation-specific paths by replacing them with environment-driven or portable
references (e.g., use ${HOME}/... or move the script to a system-wide location
like /usr/local/bin and reference that), or set a WorkingDirectory key relative
to the user's home instead of the literal "/Users/caretta/…"; update the
ProgramArguments entry referencing run-symphony-watchdog.sh to use the portable
path approach (or invoke /bin/zsh -c with
"${HOME}/path/to/run-symphony-watchdog.sh") so ProgramArguments and the working
directory are not tied to a single user.
In `@lib/symphony/codex_client.ex`:
- Around line 323-331: The current check in read_message only tests
byte_size(data) per chunk, allowing session.buffer to grow past the cap; instead
after appending data to session.buffer (i.e., the updated buffer passed into
read_message), verify byte_size(updated_buffer) <=
Utils.jsonl_read_limit_bytes() and raise the same Error (code: :response_error,
message: "app-server JSONL message exceeded reader limit") if it exceeds; update
the receive clause that handles {port, {:data, data}} (and references
session.port and session.buffer) to perform this buffer-size check on the
concatenated buffer before calling read_message.
In `@lib/symphony/coding_context.ex`:
- Around line 13-14: The is_coding_issue/2 function currently calls
rules_classify/2 unconditionally which misreports when coding context is
disabled/forced or LLM-classified; change is_coding_issue(%Issue{} = issue,
%CodingContextConfig{} = config) to call classify_coding_issue(issue, config,
false) (or appropriate default for the third arg) and return the is_coding_task
field from that result instead of delegating to rules_classify/2; update
references to rules_classify/2 so only classify_coding_issue/3 controls the
behaviour.
In `@lib/symphony/config.ex`:
- Around line 814-821: resolve_path currently converts a nil result from
resolve_env_reference into an empty string and silently returns workflow_dir;
instead, after computing resolved = if is_nil(value), do: default, else:
resolve_env_reference(value, environ), check whether resolved is nil (meaning an
unresolved env var) and fail fast with a clear error (e.g., raise ArgumentError)
that includes the original value and environ context; update resolve_path to
raise on resolved == nil before calling to_string/Path.expand so missing env
refs don't silently resolve to the workflow directory.
In `@lib/symphony/http_server.ex`:
- Around line 97-121: The code currently reads the request into request_line
with :gen_tcp.recv and then unconditionally calls drain_headers, which can block
up to 1s even when the initial recv already contained headers; fix by treating
the initial recv buffer as the start of the stream: change handle to capture the
full initial buffer (e.g., initial = :gen_tcp.recv(socket, 0, 5_000)), parse the
request line and headers from that buffer (only routing if you find "\r\n\r\n"
or enough data to extract the request line), and update drain_headers(socket,
buffer \\ "") to accept and prepend the initial buffer and loop reading more
data until the header terminator "\r\n\r\n" is found (using
:gen_tcp.recv(socket, 0, 1_000) iteratively). Ensure references to handle/2,
drain_headers/1 (now drain_headers/2 or with default arg), and :gen_tcp.recv are
updated accordingly so ordinary requests that arrive complete in the first recv
are parsed immediately without the extra 1s wait.
In `@lib/symphony/models.ex`:
- Around line 30-38: IssueAssignee.to_map/1 is currently including the "email"
field which then gets forwarded by Issue.to_template_data/1 into template
payloads, leaking PII; remove the "email" key from IssueAssignee.to_map/1 (and
any similar mapping functions referenced around lines 60-76) so the returned map
no longer contains email, and update Issue.to_template_data/1 to rely on those
maps (or explicitly filter out "email") so template payloads and persisted
snapshots never include assignee emails; ensure you keep other keys ("id",
"name", "display_name", "url", "mention") intact.
In `@lib/symphony/orchestrator.ex`:
- Around line 1672-1679: dispatch_issue_sync/4 currently instantiates the runner
with the concrete Symphony.AgentRunner which bypasses the injected
orchestrator.agent_runner used by the async path; change dispatch_issue_sync/4
to call the injected module stored at orchestrator.agent_runner (e.g.,
orchestrator.agent_runner.new(orchestrator.config_manager, tracker) |>
orchestrator.agent_runner.run_issue(...)) so both sync and async paths use the
same injected runner implementation (this will ensure tick/1 and tests honoring
runner substitution behave identically).
In `@lib/symphony/repo_planner.ex`:
- Around line 455-459: The bug is that string booleans like "false" are treated
as truthy by truthy?(Utils.map_get(...)), so update the parsing before
permission enforcement: for assignments like coding_task, needs_human (and
edit_allowed where present) call a boolean-coercion helper (e.g., a new or
existing Utils.parse_bool/parse_boolean) on Utils.map_get(...) to convert
"true"/"false" strings to actual booleans, then pass that result into truthy? or
use the boolean directly; ensure you apply the same fix to the other occurrences
mentioned (the blocks around the existing coding_task/needs_human lines and the
similar code at the other locations referenced).
In `@lib/symphony/self_heal.ex`:
- Around line 211-220: The code currently binds restart_managed_status(...) to
restart_status without pattern-matching its success tuple, so an {:error, ...}
result still falls through and the function writes the cooldown and returns
success; change the with clauses to pattern-match the restart as {:ok,
restart_status} <- restart_managed_status(config, artifact_path, opts) (and
likewise in the second occurrence spanning lines 222-235), so any error from
restart_managed_status will short-circuit the with and return that {:error, ...}
instead of calling write_cooldown; ensure the final return path forwards the
restart error tuple rather than returning {:ok, ...}.
In `@lib/symphony/tracker.ex`:
- Around line 29-55: The query in fetch_issue_states_by_ids/2 is capped at
first: 100 causing missed issues when >100; replace the single query with a
paginated fetch loop that uses a cursor (add variable "after"), requests
pageInfo { endCursor hasNextPage } alongside nodes, and repeatedly call
graphql(client, query, %{"ids" => issue_ids, "after" => cursor}) until
hasNextPage is false; accumulate all nodes, keep the existing get_in/body checks
and then Enum.map the collected nodes through normalize_issue/1 (use the same
graphql/2 helper and normalize_issue/1).
In `@lib/symphony/watchdog_escalation.ex`:
- Around line 45-57: The current flow calls call_tracker(tracker,
:save_issue_comment, args) but ignores its return value and then uses
comment_id_from_response/1 which doesn't handle {:ok, comment} or error tuples,
causing successes to lose the new id and failures to be treated as success;
update the code in watchdog_escalation (around find_escalation_comment_id,
call_tracker and comment_id_from_response) to pattern-match the response from
save_issue_comment (handle {:ok, comment} to extract comment.id and {:error,
reason} to propagate or log an error), only call Logging.log_event(:info,
"watchdog_issue_escalated", ...) after a confirmed {:ok, comment} (use
saved_comment_id from the matched response), and return {:ok, ...} or {:error,
reason} accordingly so failures aren't reported as successful escalations.
In `@lib/symphony/watchdog_triage.ex`:
- Around line 191-198: run_agent_triage is currently reusing
config.self_healing.repair_codex when calling CodexClient.start_session, which
grants workspace-write and network access; change it to construct and pass a
stripped-down, read-only triage session config (e.g., clone/derive a session
config from config.self_healing but override policy to read-only/no-network and
remove any sandbox that allows writes) instead of using repair_codex, then call
CodexClient.start_session with that read-only config (keep workspace_path and
the same on_event/tracker_config args) so the triage turn cannot mutate the
self-heal tree.
In `@lib/symphony/watchdog.ex`:
- Around line 278-290: The current self_heal_incomplete?/1 in SelfHeal.RunResult
treats unknown statuses (e.g., :failed) as complete; change it to whitelist only
explicit success states: return false for %SelfHeal.RunResult{status: :ok} and
for the specific skipped case (%SelfHeal.RunResult{status: :skipped, error:
"another self-heal run is active"}), and return true for any other
%SelfHeal.RunResult{} (including :failed and other failure-like statuses); keep
the fallback for non-RunResult values returning false. Update the clauses in
self_heal_incomplete? to match these symbols so real failures no longer slip
through as complete.
In `@lib/symphony/workflow.ex`:
- Around line 11-13: The resolve_workflow_path/2 clause that handles a non-nil
path currently calls Path.expand/1 which ignores the supplied cwd; update the
clause in resolve_workflow_path (the head def resolve_workflow_path(path, _cwd)
-> ...) to call Path.expand(path |> to_string(), cwd) so relative paths are
expanded against the provided cwd argument rather than the process working
directory, preserving callers that pass a custom workspace via load_workflow/2.
In `@lib/symphony/workspace.ex`:
- Around line 241-277: When reusing an existing repo we record current_branch
but never switch to or prepare expected_branch, so update the existing-repo
branch flow (after git_output(..., "branch --show-current") and before
install_pre_push_guard/return) to invoke the same branch preparation logic used
for fresh checkouts (call the function that prepares/switches to the expected
branch — e.g., prepare_branch or the module method used elsewhere — passing
repo_path, expected_branch and timeout_ms and using the same error handling
codes), set "branch_prepared" => true when that succeeds, and keep
"pre_push_guard" => true; ensure you still call
install_pre_push_guard(repo_path, expected_branch) after preparing the branch.
In `@scripts/symphony-workspace-hook.sh`:
- Around line 1-2: Replace the hard /bin/zsh shebang with a portable one and
make the shell options POSIX-safe: change "#!/bin/zsh" to "#!/bin/sh" and update
"set -euo pipefail" to a POSIX-compatible form (e.g., "set -eu" and remove "o
pipefail" or implement a portable pipefail workaround) so the script uses only
POSIX features and won't fail on systems without /bin/zsh; target the shebang
line and the "set -euo pipefail" invocation in the script.
---
Nitpick comments:
In `@lib/symphony/blocker_diagnosis.ex`:
- Around line 364-398: The parse_json_object/1 and extract_json_object/1
implementations in blocker_diagnosis.ex are duplicates of the ones in
dashboard_summary.ex; extract these functions into a shared utility module
(e.g., Symphony.Utils) and replace the local definitions with calls to that
module. Create functions Symphony.Utils.parse_json_object/1 and
Symphony.Utils.extract_json_object/1 (preserving behavior: trimming, empty
check, Jason.decode fallback to extract_json_object, maps-only check, and the
binary search logic), update blocker_diagnosis.ex to call
Symphony.Utils.parse_json_object(text) (and remove the local defs), and update
dashboard_summary.ex to use the same Symphony.Utils functions so both files
reference the single shared implementation.
- Around line 306-330: The blanket rescue in workspace_facts/1 hides all
exceptions; change it to only handle expected file-system errors and surface or
log others: replace the rescue that wraps workspace_facts/1 with targeted
handling (e.g. use File.ls/1 instead of File.ls!/1 and pattern-match the {:ok,
list} or {:error, reason} results, or rescue only File.Error or Erlang
:enoent/:eacces-like errors), and call Logger.error/1 or Logger.warn/2 with the
error reason when returning an empty map so unexpected exceptions in
workspace_facts/1 (and calls like repo_facts/1) are not silently swallowed.
In `@lib/symphony/cli.ex`:
- Around line 93-107: The trailing literal return value `0` after
Process.sleep(:infinity) is unreachable; update the block that starts the
orchestrator (Orchestrator.start_link/1) and optional HTTP server
(HTTPServer.start/2) to remove the dead code by either replacing
Process.sleep(:infinity) with a blocking receive do end or simply deleting the
unreachable `0` expression so the clause no longer contains misleading
unreachable return value; ensure the log message and server assignment (server =
if port do HTTPServer.start(...) end) remain unchanged.
In `@lib/symphony/dashboard_summary.ex`:
- Around line 132-143: extract_json_object currently takes the first "{" and the
last "}" which breaks when surrounding prose contains braces; modify
extract_json_object to locate the first "{" (as currently found) then iterate
byte-by-byte forward counting open vs closed braces to find the matching closing
"}" (respecting nested braces) and return the slice between those indices,
raising the same ArgumentError if no matching close is found; refer to the
existing extract_json_object function and use the start index from
:binary.match(text, "{") and a looping counter to compute the correct end index
instead of :binary.matches(... ) |> List.last().
In `@test/agent_runner_test.exs`:
- Around line 196-246: Add a clarifying comment in the test "run_issue preserves
response error detail for retry health checks" explaining that leaving the
tracker map empty is intentional because the simulated "thread/start" error
causes run_turn (invoked via AgentRunner.run_issue) to fail before any issue
refresh or call to fetch_issue_states_by_ids occurs; place the comment near the
workflow/ConfigManager.new setup (around workflow_path, manager, and runner
creation) and reference AgentRunner.run_issue, run_turn, and
fetch_issue_states_by_ids so future maintainers understand the dependency.
In `@test/codex_client_test.exs`:
- Around line 205-233: The test has a race where it only waits for the marker
file (wait_until) but not for the child process to be actually reaped; update
the assertion after CodexClient.start_session so it waits for process death too:
after ensuring the marker exists (marker / wait_until), poll the pid read from
the marker with wait_until (or add a short sleep) checking System.cmd("ps",
["-p", pid], stderr_to_stdout: true) returns non-zero, and only then assert the
process is gone; modify the test around wait_until, pid, and the
System.cmd("ps", ...) check to perform that polling so the kill has time to
propagate.
In `@WORKFLOW.md`:
- Around line 17-21: The WORKFLOW.md contains machine-specific absolute paths
(e.g., workspace.root and hook commands after_run and before_remove referencing
/Users/caretta/... scripts) which break portability; fix by either moving these
entries into a gitignored WORKFLOW.local.md, or replace hardcoded paths with
environment-variable interpolation (e.g., use ${WORKSPACE_ROOT} and
${SCRIPTS_DIR} if supported) for workspace.root and the after_run/before_remove
hook commands, and/or add a clear top-of-file comment stating these entries are
intentionally machine-specific; update all similar occurrences referenced in the
comment (lines indicated around 43–69, 78, 101–209) so no absolute paths remain
in the committed shared WORKFLOW.md.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: f7b7bfc1-78bb-4c6e-bf60-fd05b6d1acf9
⛔ Files ignored due to path filters (1)
mix.lockis excluded by!**/*.lock
📒 Files selected for processing (80)
.formatter.exs.gitignoreREADME.mdWORKFLOW.linear-mcp.example.mdWORKFLOW.mddocs/IMPLEMENTATION.mdlaunchd/com.caretta.symphony.local.plistlaunchd/com.caretta.symphony.watchdog.plistlaunchd/com.symphony.linear-mcp.example.plistlib/symphony.exlib/symphony/agent_runner.exlib/symphony/blocker_diagnosis.exlib/symphony/cli.exlib/symphony/codex_client.exlib/symphony/coding_context.exlib/symphony/config.exlib/symphony/dashboard_summary.exlib/symphony/error.exlib/symphony/http_server.exlib/symphony/logging.exlib/symphony/models.exlib/symphony/orchestrator.exlib/symphony/repo_planner.exlib/symphony/review.exlib/symphony/self_heal.exlib/symphony/templating.exlib/symphony/tracker.exlib/symphony/utils.exlib/symphony/watchdog.exlib/symphony/watchdog_escalation.exlib/symphony/watchdog_triage.exlib/symphony/workflow.exlib/symphony/workspace.exmix.exspyproject.tomlscripts/symphony-managed.shscripts/symphony-workspace-hook.shsymphony/__init__.pysymphony/__main__.pysymphony/agent_runner.pysymphony/cli.pysymphony/codex_client.pysymphony/coding_context.pysymphony/config.pysymphony/dashboard_summary.pysymphony/errors.pysymphony/http_server.pysymphony/logging.pysymphony/models.pysymphony/orchestrator.pysymphony/repo_planner.pysymphony/review.pysymphony/templating.pysymphony/tracker.pysymphony/utils.pysymphony/workflow.pysymphony/workspace.pytest/agent_runner_test.exstest/blocker_diagnosis_test.exstest/cli_test.exstest/codex_client_test.exstest/http_server_test.exstest/managed_script_test.exstest/orchestrator_test.exstest/repo_planner_http_test.exstest/review_test.exstest/self_heal_test.exstest/test_helper.exstest/tracker_test.exstest/watchdog_test.exstest/watchdog_triage_test.exstest/workflow_config_template_test.exstest/workspace_test.exstests/test_agent_runner.pytests/test_codex_client.pytests/test_orchestrator.pytests/test_review.pytests/test_tracker.pytests/test_workflow_config_template.pytests/test_workspace.py
💤 Files with no reviewable changes (21)
- symphony/init.py
- symphony/main.py
- symphony/logging.py
- pyproject.toml
- symphony/templating.py
- symphony/errors.py
- symphony/repo_planner.py
- symphony/agent_runner.py
- symphony/models.py
- symphony/workflow.py
- symphony/cli.py
- symphony/dashboard_summary.py
- symphony/http_server.py
- symphony/workspace.py
- symphony/config.py
- symphony/review.py
- symphony/tracker.py
- symphony/utils.py
- symphony/codex_client.py
- symphony/orchestrator.py
- symphony/coding_context.py
| <string>/Users/caretta/Documents/repos/caretta-symphony</string> | ||
|
|
||
| <key>ProgramArguments</key> | ||
| <array> | ||
| <string>/bin/zsh</string> | ||
| <string>/Users/caretta/Library/Application Support/CarettaSymphony/run-symphony-watchdog.sh</string> | ||
| </array> |
There was a problem hiding this comment.
Remove workstation-specific absolute paths.
Line 10 and Line 15 hard-code one user’s filesystem. This job will not run anywhere else, which defeats self-heal automation.
Suggested fix
- <string>/Users/caretta/Documents/repos/caretta-symphony</string>
+ <string>/opt/symphony</string>
...
- <string>/Users/caretta/Library/Application Support/CarettaSymphony/run-symphony-watchdog.sh</string>
+ <string>/opt/symphony/run-symphony-watchdog.sh</string>📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <string>/Users/caretta/Documents/repos/caretta-symphony</string> | |
| <key>ProgramArguments</key> | |
| <array> | |
| <string>/bin/zsh</string> | |
| <string>/Users/caretta/Library/Application Support/CarettaSymphony/run-symphony-watchdog.sh</string> | |
| </array> | |
| <string>/opt/symphony</string> | |
| <key>ProgramArguments</key> | |
| <array> | |
| <string>/bin/zsh</string> | |
| <string>/opt/symphony/run-symphony-watchdog.sh</string> | |
| </array> |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@launchd/com.caretta.symphony.watchdog.plist` around lines 10 - 16, The plist
currently hard-codes user-specific absolute paths (the standalone
working-directory string and the ProgramArguments entry for
/Users/caretta/Library/.../run-symphony-watchdog.sh); update it to remove
workstation-specific paths by replacing them with environment-driven or portable
references (e.g., use ${HOME}/... or move the script to a system-wide location
like /usr/local/bin and reference that), or set a WorkingDirectory key relative
to the user's home instead of the literal "/Users/caretta/…"; update the
ProgramArguments entry referencing run-symphony-watchdog.sh to use the portable
path approach (or invoke /bin/zsh -c with
"${HOME}/path/to/run-symphony-watchdog.sh") so ProgramArguments and the working
directory are not tied to a single user.
| receive do | ||
| {port, {:data, data}} when port == session.port -> | ||
| if byte_size(data) > Utils.jsonl_read_limit_bytes() do | ||
| raise Error, | ||
| code: :response_error, | ||
| message: "app-server JSONL message exceeded reader limit" | ||
| end | ||
|
|
||
| read_message(%{session | buffer: session.buffer <> data}, timeout_ms) |
There was a problem hiding this comment.
The reader limit is checked per chunk, not per message.
A large JSONL line split across many small port reads will grow session.buffer without ever tripping this guard. That defeats the size cap and can exhaust memory on malformed app-server output. Check the accumulated buffer size after appending data, not just byte_size(data).
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@lib/symphony/codex_client.ex` around lines 323 - 331, The current check in
read_message only tests byte_size(data) per chunk, allowing session.buffer to
grow past the cap; instead after appending data to session.buffer (i.e., the
updated buffer passed into read_message), verify byte_size(updated_buffer) <=
Utils.jsonl_read_limit_bytes() and raise the same Error (code: :response_error,
message: "app-server JSONL message exceeded reader limit") if it exceeds; update
the receive clause that handles {port, {:data, data}} (and references
session.port and session.buffer) to perform this buffer-size check on the
concatenated buffer before calling read_message.
| def is_coding_issue(%Issue{} = issue, %CodingContextConfig{} = config), | ||
| do: rules_classify(issue, config) |
There was a problem hiding this comment.
is_coding_issue/2 is now lying about the configured behaviour.
It still hardwires rules_classify/2, so callers get the wrong answer whenever coding context is disabled, forced on, or LLM-classified. Make this delegate to classify_coding_issue/3 and return is_coding_task.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@lib/symphony/coding_context.ex` around lines 13 - 14, The is_coding_issue/2
function currently calls rules_classify/2 unconditionally which misreports when
coding context is disabled/forced or LLM-classified; change
is_coding_issue(%Issue{} = issue, %CodingContextConfig{} = config) to call
classify_coding_issue(issue, config, false) (or appropriate default for the
third arg) and return the is_coding_task field from that result instead of
delegating to rules_classify/2; update references to rules_classify/2 so only
classify_coding_issue/3 controls the behaviour.
| defp resolve_path(value, opts) do | ||
| default = Keyword.fetch!(opts, :default) | ||
| workflow_dir = Keyword.fetch!(opts, :workflow_dir) | ||
| environ = Keyword.fetch!(opts, :environ) | ||
| resolved = if is_nil(value), do: default, else: resolve_env_reference(value, environ) | ||
| path = resolved |> to_string() |> Path.expand(workflow_dir) | ||
| Path.expand(path) | ||
| end |
There was a problem hiding this comment.
Unresolved path env vars silently turn into the workflow directory.
If a path field uses $VARNAME and that variable is missing, resolve_env_reference/2 returns nil, to_string(nil) becomes "", and Path.expand("", workflow_dir) resolves to the workflow dir. That is the wrong failure mode. Fail fast on missing env refs, or fall back before converting to a string.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@lib/symphony/config.ex` around lines 814 - 821, resolve_path currently
converts a nil result from resolve_env_reference into an empty string and
silently returns workflow_dir; instead, after computing resolved = if
is_nil(value), do: default, else: resolve_env_reference(value, environ), check
whether resolved is nil (meaning an unresolved env var) and fail fast with a
clear error (e.g., raise ArgumentError) that includes the original value and
environ context; update resolve_path to raise on resolved == nil before calling
to_string/Path.expand so missing env refs don't silently resolve to the workflow
directory.
| defp handle(socket, orchestrator) do | ||
| with {:ok, request_line} <- :gen_tcp.recv(socket, 0, 5_000), | ||
| [method, target | _] <- | ||
| request_line |> String.split("\r\n", parts: 2) |> hd() |> String.split(" ", parts: 3) do | ||
| drain_headers(socket) | ||
| route(socket, method, URI.parse(target).path || "/", orchestrator) | ||
| else | ||
| _ -> | ||
| send_json(socket, 500, %{ | ||
| "error" => %{"code" => "internal_error", "message" => "invalid request"} | ||
| }) | ||
| end | ||
| after | ||
| :gen_tcp.close(socket) | ||
| end | ||
|
|
||
| defp drain_headers(socket) do | ||
| case :gen_tcp.recv(socket, 0, 1_000) do | ||
| {:ok, data} -> | ||
| unless String.contains?(data, "\r\n\r\n"), do: drain_headers(socket) | ||
|
|
||
| _ -> | ||
| :ok | ||
| end | ||
| end |
There was a problem hiding this comment.
The request parser can add a 1s tax to every normal request.
The first :gen_tcp.recv/3 can already contain the request line and all headers. drain_headers/1 then waits for more bytes anyway, so ordinary requests sit until the header timeout expires before routing. Parse the initial buffer, or keep reading until \r\n\r\n in one loop.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@lib/symphony/http_server.ex` around lines 97 - 121, The code currently reads
the request into request_line with :gen_tcp.recv and then unconditionally calls
drain_headers, which can block up to 1s even when the initial recv already
contained headers; fix by treating the initial recv buffer as the start of the
stream: change handle to capture the full initial buffer (e.g., initial =
:gen_tcp.recv(socket, 0, 5_000)), parse the request line and headers from that
buffer (only routing if you find "\r\n\r\n" or enough data to extract the
request line), and update drain_headers(socket, buffer \\ "") to accept and
prepend the initial buffer and loop reading more data until the header
terminator "\r\n\r\n" is found (using :gen_tcp.recv(socket, 0, 1_000)
iteratively). Ensure references to handle/2, drain_headers/1 (now
drain_headers/2 or with default arg), and :gen_tcp.recv are updated accordingly
so ordinary requests that arrive complete in the first recv are parsed
immediately without the extra 1s wait.
| defp run_agent_triage(%ServiceConfig{} = config, payload, opts) do | ||
| workspace_path = Keyword.get(opts, :workspace_path, repo_root(config)) | ||
|
|
||
| session = | ||
| CodexClient.start_session(config.self_healing.repair_codex, workspace_path, | ||
| tracker_config: config.tracker, | ||
| on_event: Keyword.get(opts, :on_event, fn _event -> :ok end) | ||
| ) |
There was a problem hiding this comment.
Triage should not inherit the repair sandbox.
This is a classification-only turn, but it reuses config.self_healing.repair_codex, whose default policy in lib/symphony/config.ex:173-179 is workspace-write with network access. One bad turn and the triage step can mutate the self-heal tree before any decision is made. Start a stripped-down read-only session config here.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@lib/symphony/watchdog_triage.ex` around lines 191 - 198, run_agent_triage is
currently reusing config.self_healing.repair_codex when calling
CodexClient.start_session, which grants workspace-write and network access;
change it to construct and pass a stripped-down, read-only triage session config
(e.g., clone/derive a session config from config.self_healing but override
policy to read-only/no-network and remove any sandbox that allows writes)
instead of using repair_codex, then call CodexClient.start_session with that
read-only config (keep workspace_path and the same on_event/tracker_config args)
so the triage turn cannot mutate the self-heal tree.
| defp self_heal_incomplete?(%SelfHeal.RunResult{status: :ok}), do: false | ||
|
|
||
| defp self_heal_incomplete?(%SelfHeal.RunResult{ | ||
| status: :skipped, | ||
| error: "another self-heal run is active" | ||
| }), | ||
| do: false | ||
|
|
||
| defp self_heal_incomplete?(%SelfHeal.RunResult{status: status}) | ||
| when status in [:error, :skipped], | ||
| do: true | ||
|
|
||
| defp self_heal_incomplete?(_result), do: false |
There was a problem hiding this comment.
Real self-heal failures can slip through as “complete”.
The run contract in lib/symphony/self_heal.ex:17-45 includes failure-like statuses beyond :error and :skipped — for example :failed. Those currently fall through to false, so watchdog can skip escalation after an actual self-heal failure. Whitelist success states instead.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@lib/symphony/watchdog.ex` around lines 278 - 290, The current
self_heal_incomplete?/1 in SelfHeal.RunResult treats unknown statuses (e.g.,
:failed) as complete; change it to whitelist only explicit success states:
return false for %SelfHeal.RunResult{status: :ok} and for the specific skipped
case (%SelfHeal.RunResult{status: :skipped, error: "another self-heal run is
active"}), and return true for any other %SelfHeal.RunResult{} (including
:failed and other failure-like statuses); keep the fallback for non-RunResult
values returning false. Update the clauses in self_heal_incomplete? to match
these symbols so real failures no longer slip through as complete.
| def resolve_workflow_path(path, cwd \\ File.cwd!()) | ||
| def resolve_workflow_path(nil, cwd), do: cwd |> default_workflow_path() |> Path.expand() | ||
| def resolve_workflow_path(path, _cwd), do: path |> to_string() |> Path.expand() |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Find call sites where a custom cwd is passed, to validate impact.
ast-grep --pattern 'Symphony.Workflow.load_workflow($_, $_)'
ast-grep --pattern 'Symphony.Workflow.resolve_workflow_path($_, $_)'Repository: CarettaAI/caretta-symphony
Length of output: 52
🏁 Script executed:
# Examine the full workflow.ex file to understand context
cat lib/symphony/workflow.exRepository: CarettaAI/caretta-symphony
Length of output: 2535
🌐 Web query:
Elixir Path.expand/1 Path.expand/2 documentation difference relative path
💡 Result:
In Elixir, Path.expand/1 and Path.expand/2 differ in what they use as the “base” for expanding relative paths. 1) Path.expand/1 - Converts the given path to an absolute one by expanding any . and .. components and a leading ~. - If the input path is relative, it is expanded relative to the current working directory ($PWD). - Example behavior (from the docs): - Path.expand("/foo/bar/../baz") #=> "/foo/baz" - Path.expand("foo/bar/../baz") #=> "$PWD/foo/baz". 2) Path.expand/2 - Expands the path relative to the second argument (relative_to). - It also expands any . and .. components in the final result. - If the first argument path is already absolute, relative_to is ignored. - ~ in the path is treated as absolute. - The second argument is first expanded to an absolute path. - Example behavior (from the docs, assuming the absolute path to baz is /quux/baz): - Path.expand("foo/bar/../bar", "baz") #=> "/quux/baz/foo/bar" - Path.expand("foo/bar/../bar", "/baz") #=> "/baz/foo/bar" - Path.expand("/foo/bar/../bar", "/baz") #=> "/foo/bar" (relative_to ignored since first arg is absolute). Practical rule of thumb - Use Path.expand/1 when you mean “make this path absolute using the current working directory.” - Use Path.expand/2 when you need “make this path absolute relative to some other base path you provide.”
Citations:
- 1: https://hexdocs.pm/elixir/Path.html
- 2: https://github.com/elixir-lang/elixir/blob/main/lib/elixir/lib/path.ex
Use Path.expand/2 to respect the supplied cwd argument.
Line 13 uses Path.expand/1, which expands relative paths against the process working directory rather than the function's cwd parameter. This breaks callers that pass a custom workspace root through load_workflow/2, since the cwd gets discarded.
Suggested patch
def resolve_workflow_path(path, cwd \\ File.cwd!())
def resolve_workflow_path(nil, cwd), do: cwd |> default_workflow_path() |> Path.expand()
-def resolve_workflow_path(path, _cwd), do: path |> to_string() |> Path.expand()
+def resolve_workflow_path(path, cwd), do: path |> to_string() |> Path.expand(cwd)📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| def resolve_workflow_path(path, cwd \\ File.cwd!()) | |
| def resolve_workflow_path(nil, cwd), do: cwd |> default_workflow_path() |> Path.expand() | |
| def resolve_workflow_path(path, _cwd), do: path |> to_string() |> Path.expand() | |
| def resolve_workflow_path(path, cwd \\ File.cwd!()) | |
| def resolve_workflow_path(nil, cwd), do: cwd |> default_workflow_path() |> Path.expand() | |
| def resolve_workflow_path(path, cwd), do: path |> to_string() |> Path.expand(cwd) |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@lib/symphony/workflow.ex` around lines 11 - 13, The resolve_workflow_path/2
clause that handles a non-nil path currently calls Path.expand/1 which ignores
the supplied cwd; update the clause in resolve_workflow_path (the head def
resolve_workflow_path(path, _cwd) -> ...) to call Path.expand(path |>
to_string(), cwd) so relative paths are expanded against the provided cwd
argument rather than the process working directory, preserving callers that pass
a custom workspace via load_workflow/2.
| if File.exists?(repo_path) do | ||
| unless File.dir?(repo_path), | ||
| do: | ||
| raise(Error, | ||
| code: :repo_path_not_directory, | ||
| message: "repo path exists and is not a directory: #{repo_path}" | ||
| ) | ||
|
|
||
| unless repo_checkout_matches?(repo_path, repo_config) do | ||
| raise Error, | ||
| code: :repo_checkout_mismatch, | ||
| message: | ||
| "repo path exists but remote does not match #{repo_config.slug}: #{repo_path}" | ||
| end | ||
|
|
||
| current_branch = | ||
| git_output( | ||
| repo_path, | ||
| ["branch", "--show-current"], | ||
| timeout_ms, | ||
| :repo_branch_read_failed, | ||
| "failed reading current branch for #{repo_config.slug}" | ||
| ) | ||
|
|
||
| install_pre_push_guard(repo_path, expected_branch) | ||
|
|
||
| %{ | ||
| "base_branch" => base_branch, | ||
| "base_ref" => "origin/#{base_branch}", | ||
| "base_sha" => nil, | ||
| "expected_branch" => expected_branch, | ||
| "expected_ref" => "refs/heads/#{expected_branch}", | ||
| "current_branch" => current_branch, | ||
| "branch_prepared" => false, | ||
| "pre_push_guard" => true, | ||
| "remote_url" => git_remote(repo_path) | ||
| } |
There was a problem hiding this comment.
Reused checkouts never move onto the expected branch.
In the existing-repo path you record both current_branch and expected_branch, but you never prepare the expected branch. So a reused workspace can stay on an inherited branch until the pre-push hook rejects it, which is late and noisy.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@lib/symphony/workspace.ex` around lines 241 - 277, When reusing an existing
repo we record current_branch but never switch to or prepare expected_branch, so
update the existing-repo branch flow (after git_output(..., "branch
--show-current") and before install_pre_push_guard/return) to invoke the same
branch preparation logic used for fresh checkouts (call the function that
prepares/switches to the expected branch — e.g., prepare_branch or the module
method used elsewhere — passing repo_path, expected_branch and timeout_ms and
using the same error handling codes), set "branch_prepared" => true when that
succeeds, and keep "pre_push_guard" => true; ensure you still call
install_pre_push_guard(repo_path, expected_branch) after preparing the branch.
| #!/bin/zsh | ||
| set -euo pipefail |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Verify zsh-only syntax is not used before switching to /bin/sh.
rg -nP '\[\[|typeset|setopt|autoload|emulate|function\s+[A-Za-z_]\w*\s*\{' scripts/symphony-workspace-hook.shRepository: CarettaAI/caretta-symphony
Length of output: 52
Remove the hard /bin/zsh dependency.
The script uses only POSIX constructs; the shebang creates an unnecessary and fragile dependency. On systems without /bin/zsh, the hook fails immediately. Use #!/bin/sh instead.
Suggested patch
-#!/bin/zsh
+#!/bin/sh
set -euo pipefail📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| #!/bin/zsh | |
| set -euo pipefail | |
| #!/bin/sh | |
| set -euo pipefail |
🧰 Tools
🪛 Shellcheck (0.11.0)
[error] 1-1: ShellCheck only supports sh/bash/dash/ksh/'busybox sh' scripts. Sorry!
(SC1071)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@scripts/symphony-workspace-hook.sh` around lines 1 - 2, Replace the hard
/bin/zsh shebang with a portable one and make the shell options POSIX-safe:
change "#!/bin/zsh" to "#!/bin/sh" and update "set -euo pipefail" to a
POSIX-compatible form (e.g., "set -eu" and remove "o pipefail" or implement a
portable pipefail workaround) so the script uses only POSIX features and won't
fail on systems without /bin/zsh; target the shebang line and the "set -euo
pipefail" invocation in the script.
Self-Heal Summary
Symphony detected a local failure and produced a validated generic repair.
Trigger:
Symphony API is unreachable on port 8765: {:error, {:failed_connect, [{:to_address, {~c"127.0.0.1", 8765}}, {:inet, [:inet], :econnrefused}]}}
Local validated commit:
f5d1791
Evidence artifact:
/Users/caretta/Documents/repos/caretta-symphony/.symphony-self-heal/runs/20260430T160349-symphony-api-is-unreachable-on-port-8765-error-failed_connect-to/evidence.json
Validation:
mix format --check-formattedmix testmix escript.buildNotes:
main.main.Summary by CodeRabbit
New Features
Documentation
Chores