From eb4bce3edaa40d6330cc90d219da0add5b21ac90 Mon Sep 17 00:00:00 2001 From: Bede Carroll Date: Fri, 15 May 2026 19:47:46 -0700 Subject: [PATCH 1/2] fix(orchestrator): reconcile stale supervisor snapshots --- elixir/README.md | 22 ++ elixir/lib/symphony_elixir/orchestrator.ex | 16 +- .../symphony_elixir/supervisor_snapshot.ex | 120 +++++++ elixir/lib/symphony_elixir/workspace.ex | 15 + .../workspace_and_config_test.exs | 332 ++++++++++++++++++ 5 files changed, 498 insertions(+), 7 deletions(-) create mode 100644 elixir/lib/symphony_elixir/supervisor_snapshot.ex diff --git a/elixir/README.md b/elixir/README.md index 6cb3ea98fe..9e4ab24327 100644 --- a/elixir/README.md +++ b/elixir/README.md @@ -160,6 +160,28 @@ The observability UI now runs on a minimal Phoenix stack: - Bandit as the HTTP server - Phoenix dependency static assets for the LiveView client bootstrap +## Supervisor Snapshot Reconciliation + +Symphony treats Linear as the source of truth for issue workflow state. On every +poll, the orchestrator checks existing workspace-local supervisor snapshots at +`.artifacts/symphony/supervisor-status.json` for candidate issues returned by +Linear. If a snapshot still says `human-review` or `blocked` while the fresh +Linear issue is now in an active dispatch state such as `Rework`, Symphony +rewrites the snapshot to `idle`, records the current `linear_state`, clears the +old failure text, and adds `reconciliation_reason: +linear-state-newer-than-supervisor-snapshot`. + +Operator check: + +1. Read the current Linear state for the issue. +2. Read `//.artifacts/symphony/supervisor-status.json`. +3. If Linear says `Human Review` and the snapshot says `human-review`, that is a + real pause. +4. If Linear says `Rework`, `Todo`, `In Progress`, or `Merging` and the snapshot + still says `human-review` or `blocked`, wait for one poll or call + `POST /api/v1/refresh`; the snapshot should change to `idle` with the + reconciliation reason above. + ## Project Layout - `lib/`: application code and Mix tasks diff --git a/elixir/lib/symphony_elixir/orchestrator.ex b/elixir/lib/symphony_elixir/orchestrator.ex index 3cd814829b..0d92febaa8 100644 --- a/elixir/lib/symphony_elixir/orchestrator.ex +++ b/elixir/lib/symphony_elixir/orchestrator.ex @@ -7,7 +7,7 @@ defmodule SymphonyElixir.Orchestrator do require Logger import Bitwise, only: [<<<: 2] - alias SymphonyElixir.{AgentRunner, Config, StatusDashboard, Tracker, Workspace} + alias SymphonyElixir.{AgentRunner, Config, StatusDashboard, SupervisorSnapshot, Tracker, Workspace} alias SymphonyElixir.Linear.Issue @continuation_retry_delay_ms 1_000 @@ -225,9 +225,14 @@ defmodule SymphonyElixir.Orchestrator do state = reconcile_running_issues(state) with :ok <- Config.validate!(), - {:ok, issues} <- Tracker.fetch_candidate_issues(), - true <- available_slots(state) > 0 do - choose_issues(issues, state) + {:ok, issues} <- Tracker.fetch_candidate_issues() do + issues = SupervisorSnapshot.reconcile_candidate_snapshots(issues) + + if available_slots(state) > 0 do + choose_issues(issues, state) + else + state + end else {:error, :missing_linear_api_token} -> Logger.error("Linear API token missing in WORKFLOW.md") @@ -266,9 +271,6 @@ defmodule SymphonyElixir.Orchestrator do {:error, reason} -> Logger.error("Failed to fetch from Linear: #{inspect(reason)}") state - - false -> - state end end diff --git a/elixir/lib/symphony_elixir/supervisor_snapshot.ex b/elixir/lib/symphony_elixir/supervisor_snapshot.ex new file mode 100644 index 0000000000..018c9793b1 --- /dev/null +++ b/elixir/lib/symphony_elixir/supervisor_snapshot.ex @@ -0,0 +1,120 @@ +defmodule SymphonyElixir.SupervisorSnapshot do + @moduledoc """ + Reconciles workspace-local supervisor status snapshots with fresh tracker state. + """ + + require Logger + + alias SymphonyElixir.{Config, Linear.Issue, Workspace} + + @status_relative_path Path.join([".artifacts", "symphony", "supervisor-status.json"]) + @stale_hold_states MapSet.new(["blocked", "human-review"]) + + @spec reconcile_candidate_snapshots([Issue.t()]) :: [Issue.t()] + def reconcile_candidate_snapshots(issues) when is_list(issues) do + Enum.each(issues, &reconcile_candidate_snapshot/1) + issues + end + + @spec status_relative_path() :: Path.t() + def status_relative_path, do: @status_relative_path + + defp reconcile_candidate_snapshot(%Issue{} = issue) do + with {:ok, workspace} <- Workspace.existing_issue_workspace(issue), + status_path <- Path.join(workspace, @status_relative_path), + {:ok, snapshot} <- read_snapshot(status_path), + true <- stale_hold_snapshot?(snapshot, issue) do + write_reconciled_snapshot(status_path, snapshot, issue) + else + _ -> :ok + end + end + + defp reconcile_candidate_snapshot(_issue), do: :ok + + defp read_snapshot(status_path) when is_binary(status_path) do + with {:ok, body} <- File.read(status_path), + {:ok, snapshot} when is_map(snapshot) <- Jason.decode(body) do + {:ok, snapshot} + else + {:error, :enoent} -> :missing + {:error, reason} -> {:error, reason} + _ -> {:error, :invalid_snapshot} + end + end + + defp stale_hold_snapshot?(snapshot, %Issue{} = issue) when is_map(snapshot) do + hold_snapshot_state?(Map.get(snapshot, "state")) and issue_dispatchable?(issue) + end + + defp hold_snapshot_state?(state) when is_binary(state) do + MapSet.member?(@stale_hold_states, normalize_state(state)) + end + + defp hold_snapshot_state?(_state), do: false + + defp issue_dispatchable?(%Issue{state: state, blocked_by: blockers}) when is_binary(state) do + active_issue_state?(state) and !blocked_by_non_terminal?(blockers) + end + + defp issue_dispatchable?(_issue), do: false + + defp active_issue_state?(state) when is_binary(state) do + Config.settings!().tracker.active_states + |> Enum.map(&normalize_state/1) + |> MapSet.new() + |> MapSet.member?(normalize_state(state)) + end + + defp blocked_by_non_terminal?(blockers) when is_list(blockers) do + terminal_states = + Config.settings!().tracker.terminal_states + |> Enum.map(&normalize_state/1) + |> MapSet.new() + + Enum.any?(blockers, fn + %{state: blocker_state} when is_binary(blocker_state) -> + !MapSet.member?(terminal_states, normalize_state(blocker_state)) + + _ -> + true + end) + end + + defp blocked_by_non_terminal?(_blockers), do: false + + defp write_reconciled_snapshot(status_path, snapshot, %Issue{} = issue) do + now = DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601() + + payload = + snapshot + |> Map.merge(%{ + "state" => "idle", + "linear_state" => issue.state, + "active_worker" => nil, + "last_heartbeat" => now, + "recent_failure" => "none", + "status_artifact" => @status_relative_path, + "reconciliation_reason" => "linear-state-newer-than-supervisor-snapshot", + "reconciled_from" => %{ + "state" => Map.get(snapshot, "state"), + "linear_state" => Map.get(snapshot, "linear_state"), + "recent_failure" => Map.get(snapshot, "recent_failure") + }, + "reconciled_at" => now + }) + + File.mkdir_p!(Path.dirname(status_path)) + File.write!(status_path, Jason.encode!(payload, pretty: true) <> "\n") + + Logger.info("Reconciled stale supervisor snapshot issue_id=#{issue.id} issue_identifier=#{issue.identifier} linear_state=#{issue.state} status_path=#{status_path}") + + :ok + end + + defp normalize_state(state) when is_binary(state) do + state + |> String.trim() + |> String.downcase() + end +end diff --git a/elixir/lib/symphony_elixir/workspace.ex b/elixir/lib/symphony_elixir/workspace.ex index 14e29da402..51a0005426 100644 --- a/elixir/lib/symphony_elixir/workspace.ex +++ b/elixir/lib/symphony_elixir/workspace.ex @@ -31,6 +31,21 @@ defmodule SymphonyElixir.Workspace do end end + @spec existing_issue_workspace(map() | String.t() | nil) :: + {:ok, Path.t()} | :missing | {:error, term()} + def existing_issue_workspace(issue_or_identifier) do + issue_context = issue_context(issue_or_identifier) + safe_id = safe_identifier(issue_context.issue_identifier) + + with {:ok, workspace} <- workspace_path_for_issue(safe_id, nil), + :ok <- validate_workspace_path(workspace, nil) do + if File.dir?(workspace), do: {:ok, workspace}, else: :missing + end + rescue + error in [ArgumentError, ErlangError, File.Error] -> + {:error, error} + end + defp ensure_workspace(workspace, nil) do cond do File.dir?(workspace) -> diff --git a/elixir/test/symphony_elixir/workspace_and_config_test.exs b/elixir/test/symphony_elixir/workspace_and_config_test.exs index faa52899cf..d74e7ccb1a 100644 --- a/elixir/test/symphony_elixir/workspace_and_config_test.exs +++ b/elixir/test/symphony_elixir/workspace_and_config_test.exs @@ -4,6 +4,7 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do alias SymphonyElixir.Config.Schema alias SymphonyElixir.Config.Schema.{Codex, StringOrMap} alias SymphonyElixir.Linear.Client + alias SymphonyElixir.SupervisorSnapshot test "workspace bootstrap can be implemented in after_create hook" do test_root = @@ -584,6 +585,337 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do assert skipped_issue.blocked_by == [%{id: "blocker-3", identifier: "MT-1006", state: "In Progress"}] end + test "candidate reconciliation invalidates stale hold supervisor snapshot for actionable issue" do + workspace_root = + Path.join( + System.tmp_dir!(), + "symphony-elixir-supervisor-snapshot-stale-#{System.unique_integer([:positive])}" + ) + + try do + write_workflow_file!(Workflow.workflow_file_path(), + workspace_root: workspace_root, + tracker_active_states: ["Todo", "In Progress", "Rework"] + ) + + workspace = Path.join(workspace_root, "NETSEC-1128") + status_path = Path.join(workspace, SupervisorSnapshot.status_relative_path()) + File.mkdir_p!(Path.dirname(status_path)) + + File.write!( + status_path, + Jason.encode!(%{ + "state" => "human-review", + "active_worker" => nil, + "last_heartbeat" => "2026-05-11T08:12:54Z", + "recent_failure" => "waiting for human review" + }) + ) + + issue = %Issue{ + id: "issue-1128", + identifier: "NETSEC-1128", + title: "Hosted handoff", + state: "Rework", + blocked_by: [] + } + + assert [^issue] = SupervisorSnapshot.reconcile_candidate_snapshots([issue]) + + snapshot = status_path |> File.read!() |> Jason.decode!() + assert snapshot["state"] == "idle" + assert snapshot["linear_state"] == "Rework" + assert snapshot["recent_failure"] == "none" + assert snapshot["reconciliation_reason"] == "linear-state-newer-than-supervisor-snapshot" + assert snapshot["reconciled_from"]["state"] == "human-review" + assert is_binary(snapshot["reconciled_at"]) + after + File.rm_rf(workspace_root) + end + end + + test "candidate reconciliation preserves real hold supervisor snapshot for non-actionable issue" do + workspace_root = + Path.join( + System.tmp_dir!(), + "symphony-elixir-supervisor-snapshot-hold-#{System.unique_integer([:positive])}" + ) + + try do + write_workflow_file!(Workflow.workflow_file_path(), + workspace_root: workspace_root, + tracker_active_states: ["Todo", "In Progress", "Rework"] + ) + + workspace = Path.join(workspace_root, "NETSEC-1128") + status_path = Path.join(workspace, SupervisorSnapshot.status_relative_path()) + File.mkdir_p!(Path.dirname(status_path)) + + File.write!( + status_path, + Jason.encode!(%{ + "state" => "human-review", + "active_worker" => nil, + "last_heartbeat" => "2026-05-11T08:12:54Z", + "recent_failure" => "waiting for human review" + }) + ) + + issue = %Issue{ + id: "issue-1128", + identifier: "NETSEC-1128", + title: "Hosted handoff", + state: "Human Review", + blocked_by: [] + } + + SupervisorSnapshot.reconcile_candidate_snapshots([issue]) + + snapshot = status_path |> File.read!() |> Jason.decode!() + assert snapshot["state"] == "human-review" + refute Map.has_key?(snapshot, "reconciliation_reason") + after + File.rm_rf(workspace_root) + end + end + + test "candidate reconciliation leaves malformed or absent supervisor snapshots alone" do + workspace_root = + Path.join( + System.tmp_dir!(), + "symphony-elixir-supervisor-snapshot-invalid-#{System.unique_integer([:positive])}" + ) + + try do + write_workflow_file!(Workflow.workflow_file_path(), + workspace_root: workspace_root, + tracker_active_states: ["Todo", "In Progress", "Rework"] + ) + + issue_missing = %Issue{ + id: "issue-missing", + identifier: "NETSEC-MISSING", + title: "Missing status", + state: "Rework", + blocked_by: [] + } + + issue_invalid = %Issue{ + id: "issue-invalid", + identifier: "NETSEC-INVALID", + title: "Invalid status", + state: "Rework", + blocked_by: [] + } + + issue_array = %Issue{ + id: "issue-array", + identifier: "NETSEC-ARRAY", + title: "Array status", + state: "Rework", + blocked_by: [] + } + + File.mkdir_p!(Path.join(workspace_root, "NETSEC-MISSING")) + + invalid_status_path = + Path.join([ + workspace_root, + "NETSEC-INVALID", + SupervisorSnapshot.status_relative_path() + ]) + + array_status_path = + Path.join([ + workspace_root, + "NETSEC-ARRAY", + SupervisorSnapshot.status_relative_path() + ]) + + File.mkdir_p!(Path.dirname(invalid_status_path)) + File.mkdir_p!(Path.dirname(array_status_path)) + File.write!(invalid_status_path, "{not json") + File.write!(array_status_path, Jason.encode!(["human-review"])) + + assert [^issue_missing, ^issue_invalid, ^issue_array] = + SupervisorSnapshot.reconcile_candidate_snapshots([ + issue_missing, + issue_invalid, + issue_array + ]) + + assert File.read!(invalid_status_path) == "{not json" + assert array_status_path |> File.read!() |> Jason.decode!() == ["human-review"] + after + File.rm_rf(workspace_root) + end + end + + test "candidate reconciliation does not clear blocked actionable snapshots" do + workspace_root = + Path.join( + System.tmp_dir!(), + "symphony-elixir-supervisor-snapshot-blocked-#{System.unique_integer([:positive])}" + ) + + try do + write_workflow_file!(Workflow.workflow_file_path(), + workspace_root: workspace_root, + tracker_active_states: ["Todo", "In Progress", "Rework"] + ) + + issue_non_terminal = %Issue{ + id: "issue-non-terminal", + identifier: "NETSEC-NONTERMINAL", + title: "Blocked by active work", + state: "Rework", + blocked_by: [%{state: "In Progress"}] + } + + issue_malformed_blocker = %Issue{ + id: "issue-malformed-blocker", + identifier: "NETSEC-MALFORMED", + title: "Blocked by unknown work", + state: "Rework", + blocked_by: [%{}] + } + + for issue <- [issue_non_terminal, issue_malformed_blocker] do + status_path = + Path.join([ + workspace_root, + issue.identifier, + SupervisorSnapshot.status_relative_path() + ]) + + File.mkdir_p!(Path.dirname(status_path)) + File.write!(status_path, Jason.encode!(%{"state" => "blocked"})) + end + + SupervisorSnapshot.reconcile_candidate_snapshots([ + issue_non_terminal, + issue_malformed_blocker + ]) + + for issue <- [issue_non_terminal, issue_malformed_blocker] do + snapshot = + [workspace_root, issue.identifier, SupervisorSnapshot.status_relative_path()] + |> Path.join() + |> File.read!() + |> Jason.decode!() + + assert snapshot["state"] == "blocked" + refute Map.has_key?(snapshot, "reconciliation_reason") + end + after + File.rm_rf(workspace_root) + end + end + + test "candidate reconciliation handles non-issue inputs and incomplete issue snapshots" do + workspace_root = + Path.join( + System.tmp_dir!(), + "symphony-elixir-supervisor-snapshot-incomplete-#{System.unique_integer([:positive])}" + ) + + try do + write_workflow_file!(Workflow.workflow_file_path(), + workspace_root: workspace_root, + tracker_active_states: ["Todo", "In Progress", "Rework"] + ) + + issue_no_state = %Issue{ + id: "issue-no-state", + identifier: "NETSEC-NOSTATE", + title: "No state", + state: nil, + blocked_by: nil + } + + issue_no_snapshot_state = %Issue{ + id: "issue-no-snapshot-state", + identifier: "NETSEC-NOSNAPSHOTSTATE", + title: "No snapshot state", + state: "Rework", + blocked_by: nil + } + + no_state_path = + Path.join([ + workspace_root, + "NETSEC-NOSTATE", + SupervisorSnapshot.status_relative_path() + ]) + + no_snapshot_state_path = + Path.join([ + workspace_root, + "NETSEC-NOSNAPSHOTSTATE", + SupervisorSnapshot.status_relative_path() + ]) + + File.mkdir_p!(Path.dirname(no_state_path)) + File.mkdir_p!(Path.dirname(no_snapshot_state_path)) + File.write!(no_state_path, Jason.encode!(%{"state" => "human-review"})) + File.write!(no_snapshot_state_path, Jason.encode!(%{"state" => nil})) + + assert [:not_an_issue, ^issue_no_state, ^issue_no_snapshot_state] = + SupervisorSnapshot.reconcile_candidate_snapshots([ + :not_an_issue, + issue_no_state, + issue_no_snapshot_state + ]) + + assert no_state_path |> File.read!() |> Jason.decode!() == %{"state" => "human-review"} + assert no_snapshot_state_path |> File.read!() |> Jason.decode!() == %{"state" => nil} + after + File.rm_rf(workspace_root) + end + end + + test "candidate reconciliation treats missing blocker metadata as dispatchable" do + workspace_root = + Path.join( + System.tmp_dir!(), + "symphony-elixir-supervisor-snapshot-nil-blockers-#{System.unique_integer([:positive])}" + ) + + try do + write_workflow_file!(Workflow.workflow_file_path(), + workspace_root: workspace_root, + tracker_active_states: ["Todo", "In Progress", "Rework"] + ) + + issue = %Issue{ + id: "issue-nil-blockers", + identifier: "NETSEC-NILBLOCKERS", + title: "Nil blockers", + state: "Rework", + blocked_by: nil + } + + status_path = + Path.join([ + workspace_root, + "NETSEC-NILBLOCKERS", + SupervisorSnapshot.status_relative_path() + ]) + + File.mkdir_p!(Path.dirname(status_path)) + File.write!(status_path, Jason.encode!(%{"state" => "blocked"})) + + SupervisorSnapshot.reconcile_candidate_snapshots([issue]) + + snapshot = status_path |> File.read!() |> Jason.decode!() + assert snapshot["state"] == "idle" + assert snapshot["linear_state"] == "Rework" + assert snapshot["reconciliation_reason"] == "linear-state-newer-than-supervisor-snapshot" + after + File.rm_rf(workspace_root) + end + end + test "workspace remove returns error information for missing directory" do random_path = Path.join( From 097198761794aeb9c14a4ddca9e0ee6676c9799e Mon Sep 17 00:00:00 2001 From: Bede Carroll Date: Sat, 16 May 2026 04:57:20 +0000 Subject: [PATCH 2/2] fix(orchestrator): align snapshot reconciliation blockers Co-authored-by: Codex --- elixir/README.md | 8 ++- .../symphony_elixir/supervisor_snapshot.ex | 11 ++- .../workspace_and_config_test.exs | 68 ++++++++++++++++++- 3 files changed, 78 insertions(+), 9 deletions(-) diff --git a/elixir/README.md b/elixir/README.md index 9e4ab24327..de915854c9 100644 --- a/elixir/README.md +++ b/elixir/README.md @@ -177,10 +177,12 @@ Operator check: 2. Read `//.artifacts/symphony/supervisor-status.json`. 3. If Linear says `Human Review` and the snapshot says `human-review`, that is a real pause. -4. If Linear says `Rework`, `Todo`, `In Progress`, or `Merging` and the snapshot - still says `human-review` or `blocked`, wait for one poll or call +4. If Linear says `Rework`, `In Progress`, or `Merging` and the snapshot still + says `human-review` or `blocked`, wait for one poll or call `POST /api/v1/refresh`; the snapshot should change to `idle` with the - reconciliation reason above. + reconciliation reason above. `Todo` follows the normal dispatcher dependency + gate: non-terminal blockers remain a real hold, while unblocked `Todo` + snapshots reconcile the same way. ## Project Layout diff --git a/elixir/lib/symphony_elixir/supervisor_snapshot.ex b/elixir/lib/symphony_elixir/supervisor_snapshot.ex index 018c9793b1..ba024b2476 100644 --- a/elixir/lib/symphony_elixir/supervisor_snapshot.ex +++ b/elixir/lib/symphony_elixir/supervisor_snapshot.ex @@ -54,7 +54,7 @@ defmodule SymphonyElixir.SupervisorSnapshot do defp hold_snapshot_state?(_state), do: false defp issue_dispatchable?(%Issue{state: state, blocked_by: blockers}) when is_binary(state) do - active_issue_state?(state) and !blocked_by_non_terminal?(blockers) + active_issue_state?(state) and !todo_blocked_by_non_terminal?(state, blockers) end defp issue_dispatchable?(_issue), do: false @@ -66,6 +66,13 @@ defmodule SymphonyElixir.SupervisorSnapshot do |> MapSet.member?(normalize_state(state)) end + defp todo_blocked_by_non_terminal?(state, blockers) + when is_binary(state) and is_list(blockers) do + normalize_state(state) == "todo" and blocked_by_non_terminal?(blockers) + end + + defp todo_blocked_by_non_terminal?(_state, _blockers), do: false + defp blocked_by_non_terminal?(blockers) when is_list(blockers) do terminal_states = Config.settings!().tracker.terminal_states @@ -81,8 +88,6 @@ defmodule SymphonyElixir.SupervisorSnapshot do end) end - defp blocked_by_non_terminal?(_blockers), do: false - defp write_reconciled_snapshot(status_path, snapshot, %Issue{} = issue) do now = DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601() diff --git a/elixir/test/symphony_elixir/workspace_and_config_test.exs b/elixir/test/symphony_elixir/workspace_and_config_test.exs index d74e7ccb1a..712fe5298f 100644 --- a/elixir/test/symphony_elixir/workspace_and_config_test.exs +++ b/elixir/test/symphony_elixir/workspace_and_config_test.exs @@ -751,7 +751,7 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do end end - test "candidate reconciliation does not clear blocked actionable snapshots" do + test "candidate reconciliation keeps Todo snapshots blocked by active dependencies" do workspace_root = Path.join( System.tmp_dir!(), @@ -768,7 +768,7 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do id: "issue-non-terminal", identifier: "NETSEC-NONTERMINAL", title: "Blocked by active work", - state: "Rework", + state: "Todo", blocked_by: [%{state: "In Progress"}] } @@ -776,7 +776,7 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do id: "issue-malformed-blocker", identifier: "NETSEC-MALFORMED", title: "Blocked by unknown work", - state: "Rework", + state: "Todo", blocked_by: [%{}] } @@ -812,6 +812,68 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do end end + test "candidate reconciliation clears Rework snapshots with stale dependency metadata" do + workspace_root = + Path.join( + System.tmp_dir!(), + "symphony-elixir-supervisor-snapshot-rework-blocker-#{System.unique_integer([:positive])}" + ) + + try do + write_workflow_file!(Workflow.workflow_file_path(), + workspace_root: workspace_root, + tracker_active_states: ["Todo", "In Progress", "Rework"] + ) + + issue_non_terminal = %Issue{ + id: "issue-rework-non-terminal", + identifier: "NETSEC-REWORK-NONTERMINAL", + title: "Rework with stale dependency metadata", + state: "Rework", + blocked_by: [%{state: "In Progress"}] + } + + issue_malformed_blocker = %Issue{ + id: "issue-rework-malformed-blocker", + identifier: "NETSEC-REWORK-MALFORMED", + title: "Rework with stale malformed dependency metadata", + state: "Rework", + blocked_by: [%{}] + } + + for issue <- [issue_non_terminal, issue_malformed_blocker] do + status_path = + Path.join([ + workspace_root, + issue.identifier, + SupervisorSnapshot.status_relative_path() + ]) + + File.mkdir_p!(Path.dirname(status_path)) + File.write!(status_path, Jason.encode!(%{"state" => "blocked"})) + end + + SupervisorSnapshot.reconcile_candidate_snapshots([ + issue_non_terminal, + issue_malformed_blocker + ]) + + for issue <- [issue_non_terminal, issue_malformed_blocker] do + snapshot = + [workspace_root, issue.identifier, SupervisorSnapshot.status_relative_path()] + |> Path.join() + |> File.read!() + |> Jason.decode!() + + assert snapshot["state"] == "idle" + assert snapshot["linear_state"] == "Rework" + assert snapshot["reconciliation_reason"] == "linear-state-newer-than-supervisor-snapshot" + end + after + File.rm_rf(workspace_root) + end + end + test "candidate reconciliation handles non-issue inputs and incomplete issue snapshots" do workspace_root = Path.join(