@saadnvd1/agent-os 0.3.555 → 0.3.556

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,70 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const doneSession = vi.hoisted(() => vi.fn());
4
+ const deadline = vi.hoisted(() => vi.fn());
5
+ vi.mock("@/lib/done", () => ({
6
+ doneSession,
7
+ getDoneTarget: (id: string) => {
8
+ if (id === "gone") throw new Error("Session not found");
9
+ return { id, name: "Fix pair page menu label" };
10
+ },
11
+ }));
12
+ vi.mock("@/lib/done/deadline", () => ({
13
+ withinDeadline: (...a: unknown[]) => {
14
+ deadline(...a);
15
+ return a[0];
16
+ },
17
+ }));
18
+
19
+ const { POST } = await import("./route");
20
+ const post = (id: string) =>
21
+ POST(new Request("http://x", { method: "POST" }), {
22
+ params: Promise.resolve({ id }),
23
+ });
24
+
25
+ const unhandled: unknown[] = [];
26
+ process.on("unhandledRejection", (e) => unhandled.push(e));
27
+
28
+ beforeEach(() => {
29
+ vi.clearAllMocks();
30
+ unhandled.length = 0;
31
+ });
32
+
33
+ describe("POST /api/sessions/:id/done", () => {
34
+ it("answers 404 for an unknown id, without starting anything or leaving a rejection", async () => {
35
+ const res = await post("gone");
36
+ expect(res.status).toBe(404);
37
+ expect(doneSession).not.toHaveBeenCalled();
38
+ await new Promise((r) => setTimeout(r, 10));
39
+ expect(unhandled).toEqual([]);
40
+ });
41
+
42
+ it("runs a quick change's done through the deadline wrapper", async () => {
43
+ doneSession.mockResolvedValue({ text: "Merged." });
44
+ const res = await post("q1");
45
+ expect(res.status).toBe(200);
46
+ expect(await res.json()).toEqual({ outcome: { text: "Merged." } });
47
+ expect(deadline).toHaveBeenCalledWith(
48
+ expect.any(Promise),
49
+ "Fix pair page menu label"
50
+ );
51
+ });
52
+
53
+ it("returns the refusal, and a second done while one runs is refused", async () => {
54
+ let finish!: (v: unknown) => void;
55
+ doneSession.mockReturnValueOnce(new Promise((r) => (finish = r)));
56
+ const first = post("q2");
57
+ await new Promise((r) => setTimeout(r, 0));
58
+ const second = await post("q2");
59
+ expect(second.status).toBe(409);
60
+ expect((await second.json()).error).toMatch(/already being finished/);
61
+ expect(doneSession).toHaveBeenCalledTimes(1);
62
+ finish({ text: "ok" });
63
+ expect((await first).status).toBe(200);
64
+
65
+ doneSession.mockRejectedValueOnce(new Error("Not done. ci failed"));
66
+ const refused = await post("q2");
67
+ expect(refused.status).toBe(409);
68
+ expect((await refused.json()).error).toMatch(/ci failed/);
69
+ });
70
+ });
@@ -1,19 +1,45 @@
1
1
  import { NextResponse } from "next/server";
2
- import { doneSession } from "@/lib/done";
2
+ import { doneSession, getDoneTarget } from "@/lib/done";
3
+ import { withinDeadline } from "@/lib/done/deadline";
4
+
5
+ // Sessions with a done running. Released when the work ends, not when the
6
+ // request's deadline does, so a retry can't start a parallel run beside one
7
+ // that is still going.
8
+ const g = globalThis as unknown as { __agentosDoneRunning?: Set<string> };
9
+ const running: Set<string> = (g.__agentosDoneRunning ??= new Set());
3
10
 
4
11
  // POST /api/sessions/:id/done - finished work: merge through the gates if
5
- // its PR is open, clean up, archive. 409 with the reason when refused.
12
+ // its PR is open, clean up, archive. 404 for an unknown session, 409 with
13
+ // the reason when refused or already running.
6
14
  export async function POST(
7
15
  _request: Request,
8
16
  { params }: { params: Promise<{ id: string }> }
9
17
  ) {
18
+ const id = (await params).id;
19
+ let name: string;
20
+ try {
21
+ name = getDoneTarget(id).name;
22
+ } catch {
23
+ return NextResponse.json({ error: "Session not found" }, { status: 404 });
24
+ }
25
+ if (running.has(id))
26
+ return NextResponse.json(
27
+ { error: `${name} is already being finished: wait for it to end.` },
28
+ { status: 409 }
29
+ );
30
+ running.add(id);
10
31
  try {
11
- const outcome = await doneSession((await params).id, {
12
- by: "direct",
13
- onPeer: true,
14
- });
32
+ const work = doneSession(id, { by: "direct", onPeer: true });
33
+ work.then(
34
+ () => running.delete(id),
35
+ () => running.delete(id)
36
+ );
37
+ const outcome = await withinDeadline(work, name);
15
38
  return NextResponse.json({ outcome });
16
39
  } catch (error) {
40
+ // A done that threw before it started has nothing left to hold.
41
+ if (!(error instanceof Error) || !/taking longer/.test(error.message))
42
+ running.delete(id);
17
43
  const message = error instanceof Error ? error.message : String(error);
18
44
  return NextResponse.json({ error: message }, { status: 409 });
19
45
  }
@@ -0,0 +1,149 @@
1
+ import "@/components/Chat/composer/test-dom";
2
+ import { act, createElement, useEffect } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+ import type { Session } from "@/lib/db/types";
7
+
8
+ const toast = vi.hoisted(() =>
9
+ Object.assign(vi.fn(), {
10
+ loading: vi.fn(() => "t1"),
11
+ success: vi.fn(),
12
+ error: vi.fn(),
13
+ })
14
+ );
15
+ vi.mock("sonner", () => ({ toast }));
16
+
17
+ const { useDoneAction } = await import("./useDoneAction");
18
+
19
+ (
20
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
21
+ ).IS_REACT_ACT_ENVIRONMENT = true;
22
+
23
+ let root: Root;
24
+ let host: HTMLElement;
25
+ const hook: { current: (s: Session) => void } = { current: () => {} };
26
+ const confirmSpy = vi.fn(() => false);
27
+ const fetchSpy = vi.fn();
28
+
29
+ function Probe() {
30
+ const markDone = useDoneAction();
31
+ useEffect(() => {
32
+ hook.current = markDone;
33
+ });
34
+ return null;
35
+ }
36
+
37
+ beforeEach(() => {
38
+ vi.clearAllMocks();
39
+ vi.stubGlobal("confirm", confirmSpy);
40
+ vi.stubGlobal("fetch", fetchSpy);
41
+ host = document.createElement("div");
42
+ document.body.append(host);
43
+ root = createRoot(host);
44
+ act(() =>
45
+ root.render(
46
+ createElement(
47
+ QueryClientProvider,
48
+ { client: new QueryClient() },
49
+ createElement(Probe)
50
+ )
51
+ )
52
+ );
53
+ });
54
+ afterEach(() => {
55
+ act(() => root.unmount());
56
+ host.remove();
57
+ vi.unstubAllGlobals();
58
+ });
59
+
60
+ const quick = {
61
+ id: "q1",
62
+ name: "Fix pair page menu label",
63
+ quick_of: "orch",
64
+ task_status: "running",
65
+ pr_status: "open",
66
+ pr_number: 281,
67
+ } as Session;
68
+
69
+ const respond = (status: number, body: object) =>
70
+ fetchSpy.mockResolvedValue({
71
+ ok: status < 400,
72
+ status,
73
+ json: async () => body,
74
+ });
75
+ // Done on an open PR asks first, in a toast; Merge is its action.
76
+ const confirmMerge = () => {
77
+ const [, opts] = toast.mock.calls.at(-1) as unknown as [
78
+ string,
79
+ { action: { onClick: () => void } },
80
+ ];
81
+ act(() => opts.action.onClick());
82
+ };
83
+ const settle = () => act(() => new Promise((r) => setTimeout(r, 0)));
84
+
85
+ describe("Done from a row's menu", () => {
86
+ it("asks in the app, not with confirm(), and does nothing until Merge is chosen", async () => {
87
+ respond(200, {
88
+ outcome: {
89
+ id: "q1",
90
+ name: quick.name,
91
+ merged: "Merged.",
92
+ worktree: { action: "none" },
93
+ text: "Merged. Agent stopped, no worktree.",
94
+ },
95
+ });
96
+ act(() => hook.current(quick));
97
+ expect(fetchSpy).not.toHaveBeenCalled();
98
+ confirmMerge();
99
+ await settle();
100
+ expect(confirmSpy).not.toHaveBeenCalled();
101
+ expect(fetchSpy).toHaveBeenCalledWith(
102
+ "/api/sessions/q1/done",
103
+ expect.objectContaining({ method: "POST" })
104
+ );
105
+ expect(toast.success).toHaveBeenCalledWith(
106
+ "Merged. Agent stopped, no worktree.",
107
+ expect.objectContaining({ id: "t1" })
108
+ );
109
+ });
110
+
111
+ it("shows a refusal as a toast naming the gate, for a quick change and a task", async () => {
112
+ respond(409, {
113
+ error: "Not done, nothing merged.\n- ci failed: CI failed",
114
+ });
115
+ act(() => hook.current(quick));
116
+ confirmMerge();
117
+ await settle();
118
+ act(() => hook.current({ ...quick, id: "t2", quick_of: null } as Session));
119
+ confirmMerge();
120
+ await settle();
121
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
122
+ expect(toast.error).toHaveBeenCalledTimes(2);
123
+ expect(toast.error).toHaveBeenCalledWith(
124
+ expect.stringContaining("ci failed"),
125
+ expect.objectContaining({ id: "t1" })
126
+ );
127
+ });
128
+
129
+ it("ends the Merging toast with an error when the server never answers", async () => {
130
+ vi.useFakeTimers();
131
+ fetchSpy.mockImplementation(
132
+ (_url: string, init: { signal?: AbortSignal }) =>
133
+ new Promise((_, reject) =>
134
+ init.signal?.addEventListener("abort", () =>
135
+ reject(init.signal?.reason)
136
+ )
137
+ )
138
+ );
139
+ act(() => hook.current(quick));
140
+ confirmMerge();
141
+ expect(toast.error).not.toHaveBeenCalled();
142
+ await act(() => vi.advanceTimersByTimeAsync(181_000));
143
+ vi.useRealTimers();
144
+ expect(toast.error).toHaveBeenCalledWith(
145
+ expect.any(String),
146
+ expect.objectContaining({ id: "t1" })
147
+ );
148
+ });
149
+ });
@@ -7,21 +7,12 @@ import {
7
7
  } from "@/data/done";
8
8
  import { useSettleSession } from "@/data/sessions/shelf";
9
9
 
10
- // Done from a session's menu: an open PR merges through the gates, so that
11
- // one asks first; the rest can be undone from the toast.
10
+ // Done from a session's menu: an open PR merges through the gates, after an
11
+ // in-app confirmation; a failing gate comes back as an error toast naming it.
12
12
  export function useDoneAction() {
13
13
  const done = useDoneSession();
14
14
  const settle = useSettleSession();
15
- return (session: Session) => {
16
- const merges =
17
- session.task_status === "running" && session.pr_status === "open";
18
- if (
19
- merges &&
20
- !confirm(
21
- `Merge ${session.name}'s PR through the gates and finish it? A failing gate refuses and nothing merges.`
22
- )
23
- )
24
- return;
15
+ const run = (session: Session, merges: boolean) => {
25
16
  const id = toast.loading(
26
17
  merges ? `Merging ${session.name}…` : `Finishing ${session.name}…`
27
18
  );
@@ -45,6 +36,20 @@ export function useDoneAction() {
45
36
  onError: (e) => toast.error(e.message, { id, duration: 12000 }),
46
37
  });
47
38
  };
39
+ return (session: Session) => {
40
+ const merges =
41
+ session.task_status === "running" && session.pr_status === "open";
42
+ if (!merges) return run(session, false);
43
+ // Merging is the one Done that can't be undone: asked in the app, not
44
+ // with confirm(), which a webview drops.
45
+ toast(`Merge ${session.name}'s PR and finish it?`, {
46
+ description:
47
+ "It merges only through the orchestrator's gates; a failing gate refuses and nothing merges.",
48
+ duration: 15000,
49
+ action: { label: "Merge", onClick: () => run(session, true) },
50
+ cancel: { label: "Cancel", onClick: () => {} },
51
+ });
52
+ };
48
53
  }
49
54
 
50
55
  // Archive from a session's menu: puts it away; Unarchive brings it back.
@@ -17,12 +17,34 @@ async function json<T>(res: Response): Promise<T> {
17
17
  return data;
18
18
  }
19
19
 
20
- const post = (url: string, body?: object) =>
21
- fetch(url, {
22
- method: "POST",
23
- headers: { "Content-Type": "application/json" },
24
- body: body ? JSON.stringify(body) : undefined,
25
- });
20
+ // Longer than the server's own deadline for done (lib/done/deadline.ts), so
21
+ // the pending toast always ends even if the server never answers.
22
+ export const DONE_REQUEST_TIMEOUT_MS = 180_000;
23
+
24
+ async function post(url: string, body?: object, timeoutMs?: number) {
25
+ const abort = new AbortController();
26
+ const timer = timeoutMs
27
+ ? setTimeout(
28
+ () =>
29
+ abort.abort(
30
+ new Error(
31
+ "The server didn't answer in time. It may still be working: check the row, then try again."
32
+ )
33
+ ),
34
+ timeoutMs
35
+ )
36
+ : undefined;
37
+ try {
38
+ return await fetch(url, {
39
+ signal: abort.signal,
40
+ method: "POST",
41
+ headers: { "Content-Type": "application/json" },
42
+ body: body ? JSON.stringify(body) : undefined,
43
+ });
44
+ } finally {
45
+ clearTimeout(timer);
46
+ }
47
+ }
26
48
 
27
49
  function useDoneMutation<V, R>(fn: (v: V) => Promise<R>) {
28
50
  const queryClient = useQueryClient();
@@ -40,7 +62,11 @@ export function useDoneSession() {
40
62
  async (id: string) =>
41
63
  (
42
64
  await json<{ outcome: DoneOutcome }>(
43
- await post(`/api/sessions/${id}/done`)
65
+ await post(
66
+ `/api/sessions/${id}/done`,
67
+ undefined,
68
+ DONE_REQUEST_TIMEOUT_MS
69
+ )
44
70
  )
45
71
  ).outcome
46
72
  );
@@ -0,0 +1,45 @@
1
+ // Done merges, stops the agent and cleans up; any step can stall on a lock or
2
+ // a slow remote. A request must always end: past this it answers that it is
3
+ // still going, rather than leaving the row on "Merging…".
4
+ export const DONE_DEADLINE_MS = 150_000;
5
+
6
+ export async function withinDeadline<T>(
7
+ work: Promise<T>,
8
+ name: string,
9
+ ms = DONE_DEADLINE_MS
10
+ ): Promise<T> {
11
+ let timer: ReturnType<typeof setTimeout> | undefined;
12
+ const late = new Promise<never>((_, reject) => {
13
+ timer = setTimeout(
14
+ () =>
15
+ reject(
16
+ new Error(
17
+ `Done on ${name} is taking longer than ${Math.round(ms / 1000)}s and is still running in the background. Check the row in a moment; if it hasn't moved, try Done again.`
18
+ )
19
+ ),
20
+ ms
21
+ );
22
+ });
23
+ // The work keeps going; its late failure must not be an unhandled rejection.
24
+ work.catch(() => {});
25
+ try {
26
+ return await Promise.race([work, late]);
27
+ } finally {
28
+ clearTimeout(timer);
29
+ }
30
+ }
31
+
32
+ // Best-effort: waits for `work` up to `ms`, then carries on without it.
33
+ export async function withTimeout(
34
+ work: Promise<unknown>,
35
+ ms: number
36
+ ): Promise<void> {
37
+ let timer: ReturnType<typeof setTimeout> | undefined;
38
+ work.catch(() => {});
39
+ await Promise.race([
40
+ work,
41
+ new Promise((resolve) => {
42
+ timer = setTimeout(resolve, ms);
43
+ }),
44
+ ]).finally(() => clearTimeout(timer));
45
+ }
package/lib/done/index.ts CHANGED
@@ -22,6 +22,7 @@ import { archiveSession } from "./archive";
22
22
  import { settleSession } from "../sidebar/shelf-state";
23
23
  import { demoMode } from "../security/demo";
24
24
  import { planDone } from "./plan";
25
+ import { withTimeout } from "./deadline";
25
26
  import { deleteMergedRemote } from "./remote";
26
27
  import { settleWorktree, type WorktreeFate } from "./worktree";
27
28
  import { sessionMergePolicy } from "../tasks/finish";
@@ -151,7 +152,9 @@ export async function doneSession(
151
152
  // A sign-off just before this (the orchestrator's sign_off, then done)
152
153
  // is still cleaning up in the background: read what it leaves.
153
154
  await mergeSettled(s.id);
154
- await statusDetector.refreshCache();
155
+ // A quick change has no tmux session, and a listing that stalls (a slow
156
+ // ssh host, a loaded tmux) must not hold up a merge that doesn't read it.
157
+ if (!s.quick_of) await withTimeout(statusDetector.refreshCache(), 10_000);
155
158
  const plan = await planDone(s, opts.callerId);
156
159
  if (plan.action === "refuse") throw new Error(plan.reason);
157
160
  if (plan.action === "merge" && opts.noMerge)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.555",
3
+ "version": "0.3.556",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"