Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,28 @@ jobs:
- name: Test
run: bun run test

- name: Setup Elixir
uses: erlef/setup-beam@v1
with:
otp-version: "27"
elixir-version: "1.18"

- name: Cache Elixir deps
uses: actions/cache@v5
with:
path: |
apps/harness/_build
apps/harness/deps
key: ${{ runner.os }}-mix-${{ hashFiles('apps/harness/mix.lock') }}
restore-keys: |
${{ runner.os }}-mix-

- name: Elixir test
run: |
cd apps/harness
mix deps.get
mix test

- name: Install browser test runtime
run: |
cd apps/web
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main.ts
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Resolved
Original file line number Diff line number Diff line change
Expand Up @@ -1550,6 +1550,7 @@ if (process.platform !== "win32") {
isQuitting = true;
writeDesktopLogHeader("SIGINT received");
clearUpdatePollTimer();
stopHarness();
stopBackend();
Comment on lines +1553 to 1554

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 SIGINT/SIGTERM handlers stop harness before backend, opposite of before-quit handler

The new stopHarness() calls in the SIGINT and SIGTERM handlers are placed before stopBackend(), but the existing before-quit handler at apps/desktop/src/main.ts:1514-1515 uses the correct order: stopBackend() first, then stopHarness(). Since the backend is a client of the harness (it receives harnessPort and harnessSecret during bootstrap at line 1103-1105 and connects to it), killing the harness first leaves the backend with a dead upstream connection, potentially causing error storms, reconnect attempts, or unclean shutdown of provider sessions before the backend itself is terminated.

Suggested change
stopHarness();
stopBackend();
stopBackend();
stopHarness();
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

restoreStdIoCapture?.();
app.quit();
Expand All @@ -1560,6 +1561,7 @@ if (process.platform !== "win32") {
isQuitting = true;
writeDesktopLogHeader("SIGTERM received");
clearUpdatePollTimer();
stopHarness();
stopBackend();
Comment on lines +1564 to 1565

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 SIGTERM handler also stops harness before backend (same issue as SIGINT)

Same ordering bug as in the SIGINT handler — stopHarness() is called before stopBackend(), inconsistent with the before-quit handler at apps/desktop/src/main.ts:1514-1515 which correctly stops the backend (the harness client) before stopping the harness (the upstream service).

Suggested change
stopHarness();
stopBackend();
stopBackend();
stopHarness();
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

restoreStdIoCapture?.();
app.quit();
Expand Down
2 changes: 1 addition & 1 deletion apps/harness/lib/harness/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ defmodule Harness.Application do
HarnessWeb.Endpoint
]

opts = [strategy: :one_for_one, name: Harness.Supervisor]
opts = [strategy: :rest_for_one, name: Harness.Supervisor]
Supervisor.start_link(children, opts)
end

Expand Down
1 change: 1 addition & 0 deletions apps/harness/lib/harness/providers/claude_session.ex
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,7 @@ defmodule Harness.Providers.ClaudeSession do
@impl true
def terminate(_reason, state) do
state = %{state | stopped: true}
emit_event(state, :session, "session/closed", %{})

if state.port do
try do
Expand Down
1 change: 1 addition & 0 deletions apps/harness/lib/harness/providers/codex_session.ex
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,7 @@ defmodule Harness.Providers.CodexSession do
@impl true
def terminate(_reason, state) do
state = %{state | stopping: true}
emit_event(state, :session, "session/closed", %{})

if state.port do
try do
Expand Down
1 change: 1 addition & 0 deletions apps/harness/lib/harness/providers/cursor_session.ex
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,7 @@ defmodule Harness.Providers.CursorSession do
state = %{state | stopped: true}
maybe_complete_turn(state, "completed")
cancel_all_pending(state)
emit_event(state, :session, "session/closed", %{})

if port do
try do
Expand Down
1 change: 1 addition & 0 deletions apps/harness/lib/harness/providers/mock_session.ex
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ defmodule Harness.Providers.MockSession do
@impl true
def terminate(_reason, state) do
state = %{state | stopping: true}
emit_event(state, :session, "session/closed", %{})

if state.port do
try do
Expand Down
1 change: 1 addition & 0 deletions apps/harness/lib/harness/providers/opencode_session.ex
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,7 @@ defmodule Harness.Providers.OpenCodeSession do
@impl true
def terminate(_reason, state) do
state = %{state | stopped: true}
emit_event(state, :session, "session/closed", %{})

# Cancel pending approvals/elicitations so SQLite rows are cleaned up
cancel_all_pending(state)
Expand Down
10 changes: 6 additions & 4 deletions apps/harness/lib/harness/snapshot_server.ex
Original file line number Diff line number Diff line change
Expand Up @@ -376,12 +376,14 @@ defmodule Harness.SnapshotServer do
{:ok, events} ->
{:reply, {:ok, seq, events}, state}

{:error, _reason} ->
{:reply, {:ok, seq, []}, state}
{:error, reason} ->
Logger.warning("SQL replay failed (after_seq=#{after_seq}, seq=#{seq}): #{inspect(reason)}")
{:reply, {:gap, seq, after_seq}, state}
end
catch
:exit, _ ->
{:reply, {:ok, seq, []}, state}
:exit, reason ->
Logger.warning("SQL replay crashed (after_seq=#{after_seq}, seq=#{seq}): #{inspect(reason)}")
{:reply, {:gap, seq, after_seq}, state}
end
end

Expand Down
24 changes: 24 additions & 0 deletions apps/harness/lib/harness_web/endpoint.ex
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,32 @@ defmodule HarnessWeb.Endpoint do
pass: ["application/json"],
json_decoder: Phoenix.json_library()

plug :authenticate
plug :route

defp authenticate(%{request_path: "/api/" <> _} = conn, _opts) do
expected = Application.get_env(:harness, :harness_secret, "dev-harness-secret")
conn = Plug.Conn.fetch_query_params(conn)

secret =
case Plug.Conn.get_req_header(conn, "authorization") do
["Bearer " <> token] -> token
_ -> conn.query_params["secret"]
end

if is_binary(secret) and byte_size(secret) == byte_size(expected) and
Plug.Crypto.secure_compare(secret, expected) do
conn
else
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(401, Jason.encode!(%{ok: false, error: "Unauthorized"}))
|> Plug.Conn.halt()
end
end
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

defp authenticate(conn, _opts), do: conn

defp route(%{request_path: "/api/snapshot"} = conn, _opts) do
snapshot = Harness.SnapshotServer.get_snapshot()
json_response(conn, 200, snapshot)
Expand Down
4 changes: 3 additions & 1 deletion scripts/lib/t3-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,9 @@ export class T3Client {
if (!settled) {
settled = true;
this.ws?.close();
reject(new Error(`Timed out waiting for server.welcome (${this.requestTimeout / 1000}s)`));
reject(
new Error(`Timed out waiting for server.welcome (${this.requestTimeout / 1000}s)`),
);
}
}, this.requestTimeout);

Expand Down
Loading