@saadnvd1/agent-os 0.3.592 → 0.3.594
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.
- package/app/api/bus/resurrect/route.test.ts +47 -0
- package/app/api/bus/resurrect/route.ts +36 -0
- package/app/api/sessions/[id]/pause/route.ts +5 -2
- package/app/api/sessions/[id]/resurrect/route.test.ts +32 -0
- package/app/api/sessions/[id]/resurrect/route.ts +24 -0
- package/app/api/sessions/ended/route.ts +19 -0
- package/components/Archived/ArchivedRow.tsx +11 -7
- package/components/Chat/PausedBanner.tsx +1 -1
- package/components/SessionList/RowSubtitle.tsx +1 -0
- package/components/SessionList/SessionRowMenu.tsx +12 -1
- package/components/SessionList/useDoneAction.ts +14 -0
- package/components/SessionList/useResurrectAction.test.ts +96 -0
- package/components/Tasks/TaskRow.tsx +18 -0
- package/data/done/index.ts +17 -0
- package/lib/db/migrations.ts +11 -0
- package/lib/db/types.ts +2 -0
- package/lib/done/worktree.ts +18 -2
- package/lib/orchestrator/act.ts +44 -5
- package/lib/orchestrator/brief.ts +2 -1
- package/lib/orchestrator/digest.ts +3 -2
- package/lib/orchestrator/serve.ts +6 -0
- package/lib/orchestrator/shell-guard.ts +2 -1
- package/lib/orchestrator/tool-names.ts +1 -0
- package/lib/orchestrator/tool-schemas.ts +6 -0
- package/lib/orchestrator/tools.ts +3 -1
- package/lib/resurrect/find.ts +111 -0
- package/lib/resurrect/index.ts +422 -0
- package/lib/resurrect/plan.ts +61 -0
- package/lib/resurrect/snapshot.ts +229 -0
- package/lib/security/demo.ts +1 -0
- package/lib/sidebar/paused.ts +8 -1
- package/lib/sidebar/row-actions.ts +7 -3
- package/lib/sidebar/shelves.ts +7 -1
- package/lib/tasks/auto-hold.ts +17 -0
- package/lib/tasks/finish.ts +9 -0
- package/lib/tasks/pause-hold.ts +1 -1
- package/lib/tasks/pause.ts +76 -8
- package/lib/tasks/queue.ts +39 -12
- package/package.json +1 -1
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { NextRequest } from "next/server";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
const resurrect = vi.hoisted(() => vi.fn());
|
|
5
|
+
vi.mock("@/lib/resurrect", () => ({ resurrectSession: resurrect }));
|
|
6
|
+
|
|
7
|
+
const { db } = await import("@/lib/db");
|
|
8
|
+
const { seedWorkspace } = await import("@/lib/orchestrator/testing");
|
|
9
|
+
const { POST } = await import("./route");
|
|
10
|
+
|
|
11
|
+
const call = (body: object) =>
|
|
12
|
+
POST(
|
|
13
|
+
new NextRequest("http://localhost/api/bus/resurrect", {
|
|
14
|
+
method: "POST",
|
|
15
|
+
body: JSON.stringify(body),
|
|
16
|
+
})
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
describe("POST /api/bus/resurrect (aos resurrect)", () => {
|
|
20
|
+
it("runs only from inside a session", async () => {
|
|
21
|
+
const res = await call({ session: "x" });
|
|
22
|
+
expect(res.status).toBe(409);
|
|
23
|
+
expect((await res.json()).error).toMatch(/inside an AgentOS session/);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("reaches only the caller's workspace, and lists what ended with no session", async () => {
|
|
27
|
+
const mine = seedWorkspace("Bus mine");
|
|
28
|
+
const other = seedWorkspace("Bus other");
|
|
29
|
+
db.prepare(`UPDATE sessions SET task_status = 'dropped' WHERE id = ?`).run(
|
|
30
|
+
mine.task
|
|
31
|
+
);
|
|
32
|
+
db.prepare(`UPDATE sessions SET task_status = 'dropped' WHERE id = ?`).run(
|
|
33
|
+
other.task
|
|
34
|
+
);
|
|
35
|
+
const list = await (await call({ from: mine.chat })).json();
|
|
36
|
+
expect(list.sessions.map((s: { id: string }) => s.id)).toEqual([mine.task]);
|
|
37
|
+
|
|
38
|
+
const refused = await call({ from: mine.chat, session: other.task });
|
|
39
|
+
expect(refused.status).toBe(409);
|
|
40
|
+
expect(resurrect).not.toHaveBeenCalled();
|
|
41
|
+
|
|
42
|
+
resurrect.mockResolvedValueOnce({ text: "back" });
|
|
43
|
+
const ok = await call({ from: mine.chat, session: mine.task });
|
|
44
|
+
expect((await ok.json()).summary).toBe("back");
|
|
45
|
+
expect(resurrect).toHaveBeenCalledWith(mine.task);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { getDoneTarget } from "@/lib/done";
|
|
3
|
+
import { scopeOf } from "@/lib/done/bulk";
|
|
4
|
+
import { resurrectSession } from "@/lib/resurrect";
|
|
5
|
+
import { endedSessions, resolveEnded } from "@/lib/resurrect/find";
|
|
6
|
+
|
|
7
|
+
// `aos resurrect [session]`, from inside a session: reaches only the
|
|
8
|
+
// caller's workspace (or its project when it has none). Without a session it
|
|
9
|
+
// lists the dropped and done ones that can come back.
|
|
10
|
+
export async function POST(request: NextRequest) {
|
|
11
|
+
try {
|
|
12
|
+
const { from = null, session } = await request.json();
|
|
13
|
+
if (typeof from !== "string" || !from)
|
|
14
|
+
throw new Error("aos resurrect runs from inside an AgentOS session");
|
|
15
|
+
const scope = scopeOf(getDoneTarget(from));
|
|
16
|
+
if (typeof session !== "string" || !session.trim()) {
|
|
17
|
+
const ended = endedSessions(scope, { limit: 15 });
|
|
18
|
+
return NextResponse.json({
|
|
19
|
+
sessions: ended,
|
|
20
|
+
summary: ended.length
|
|
21
|
+
? ended
|
|
22
|
+
.map(
|
|
23
|
+
(e) =>
|
|
24
|
+
`${e.id.slice(0, 8)} ${e.kind.padEnd(8)} ${e.projectName ? `${e.projectName}/` : ""}${e.name}`
|
|
25
|
+
)
|
|
26
|
+
.join("\n")
|
|
27
|
+
: "Nothing dropped, done or archived to restore.",
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
const outcome = await resurrectSession(resolveEnded(session, scope));
|
|
31
|
+
return NextResponse.json({ summary: outcome.text, outcome });
|
|
32
|
+
} catch (error) {
|
|
33
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
34
|
+
return NextResponse.json({ error: message }, { status: 409 });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -89,8 +89,11 @@ export async function DELETE(
|
|
|
89
89
|
) {
|
|
90
90
|
const { id } = await params;
|
|
91
91
|
try {
|
|
92
|
-
await resumeNow(id, "person");
|
|
93
|
-
return NextResponse.json({
|
|
92
|
+
const out = await resumeNow(id, "person");
|
|
93
|
+
return NextResponse.json({
|
|
94
|
+
pause: pauseViewOf(id),
|
|
95
|
+
...(out.outcome === "held" && { note: out.note }),
|
|
96
|
+
});
|
|
94
97
|
} catch (error) {
|
|
95
98
|
return fail(error);
|
|
96
99
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { NextRequest } from "next/server";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
const resurrect = vi.hoisted(() => vi.fn());
|
|
5
|
+
vi.mock("@/lib/resurrect", () => ({ resurrectSession: resurrect }));
|
|
6
|
+
|
|
7
|
+
const { POST } = await import("./route");
|
|
8
|
+
|
|
9
|
+
const call = (id: string) =>
|
|
10
|
+
POST(
|
|
11
|
+
new NextRequest(`http://localhost/api/sessions/${id}/resurrect`, {
|
|
12
|
+
method: "POST",
|
|
13
|
+
}),
|
|
14
|
+
{ params: Promise.resolve({ id }) }
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
describe("POST /api/sessions/:id/resurrect", () => {
|
|
18
|
+
it("answers with the outcome", async () => {
|
|
19
|
+
resurrect.mockResolvedValueOnce({ id: "s1", text: "back" });
|
|
20
|
+
const res = await call("s1");
|
|
21
|
+
expect(res.status).toBe(200);
|
|
22
|
+
expect((await res.json()).outcome).toEqual({ id: "s1", text: "back" });
|
|
23
|
+
expect(resurrect).toHaveBeenCalledWith("s1", { device: null });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("answers a refusal with 409 and the reason", async () => {
|
|
27
|
+
resurrect.mockRejectedValueOnce(new Error("its PR #3 was merged"));
|
|
28
|
+
const res = await call("s2");
|
|
29
|
+
expect(res.status).toBe(409);
|
|
30
|
+
expect(await res.json()).toEqual({ error: "its PR #3 was merged" });
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { resurrectSession } from "@/lib/resurrect";
|
|
3
|
+
import { callingDevice } from "@/lib/security/device-me";
|
|
4
|
+
import { db, queries, type Session } from "@/lib/db";
|
|
5
|
+
|
|
6
|
+
// POST /api/sessions/:id/resurrect - bring a dropped, done or archived
|
|
7
|
+
// session back: same id, same conversation, its status as it was. A refusal
|
|
8
|
+
// (merged PR, conversation gone, project removed) is a 409 with the reason.
|
|
9
|
+
export async function POST(
|
|
10
|
+
request: NextRequest,
|
|
11
|
+
{ params }: { params: Promise<{ id: string }> }
|
|
12
|
+
) {
|
|
13
|
+
try {
|
|
14
|
+
const id = (await params).id;
|
|
15
|
+
const outcome = await resurrectSession(id, {
|
|
16
|
+
device: callingDevice(request),
|
|
17
|
+
});
|
|
18
|
+
const session = queries.getSession(db).get(id) as Session | undefined;
|
|
19
|
+
return NextResponse.json({ outcome, session });
|
|
20
|
+
} catch (error) {
|
|
21
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
22
|
+
return NextResponse.json({ error: message }, { status: 409 });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { endedSessions, type EndedKind } from "@/lib/resurrect/find";
|
|
3
|
+
|
|
4
|
+
const KINDS = new Set(["dropped", "done", "archived"]);
|
|
5
|
+
|
|
6
|
+
// GET /api/sessions/ended[?workspace=id][&kind=dropped|done|archived][&limit=n]
|
|
7
|
+
// - what can be restored, most recently ended first.
|
|
8
|
+
export async function GET(request: NextRequest) {
|
|
9
|
+
const q = request.nextUrl.searchParams;
|
|
10
|
+
const workspace = q.get("workspace");
|
|
11
|
+
const kind = q.get("kind");
|
|
12
|
+
const limit = Math.min(Math.max(Number(q.get("limit")) || 50, 1), 200);
|
|
13
|
+
return NextResponse.json({
|
|
14
|
+
sessions: endedSessions(workspace ? { workspaceId: workspace } : null, {
|
|
15
|
+
limit,
|
|
16
|
+
kind: kind && KINDS.has(kind) ? (kind as EndedKind) : undefined,
|
|
17
|
+
}),
|
|
18
|
+
});
|
|
19
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { ArchiveRestore, ExternalLink, Loader2 } from "lucide-react";
|
|
4
4
|
import { toast } from "sonner";
|
|
5
5
|
import { Button } from "@/components/ui/button";
|
|
6
|
-
import {
|
|
6
|
+
import { useResurrectSession } from "@/data/done";
|
|
7
7
|
import type { ArchivedView } from "@/lib/done/archive";
|
|
8
8
|
import { fromSqliteTime } from "@/lib/session-meta";
|
|
9
9
|
|
|
@@ -24,7 +24,7 @@ function when(at: string): string {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
export function ArchivedRow({ session }: { session: ArchivedView }) {
|
|
27
|
-
const
|
|
27
|
+
const restore = useResurrectSession();
|
|
28
28
|
const kind = session.taskStatus ? STATUS[session.taskStatus] : "Session";
|
|
29
29
|
|
|
30
30
|
return (
|
|
@@ -70,20 +70,24 @@ export function ArchivedRow({ session }: { session: ArchivedView }) {
|
|
|
70
70
|
? "Puts the entry back in the sidebar; its worktree was removed"
|
|
71
71
|
: undefined
|
|
72
72
|
}
|
|
73
|
-
disabled={
|
|
73
|
+
disabled={restore.isPending}
|
|
74
74
|
onClick={() =>
|
|
75
|
-
|
|
76
|
-
onSuccess: () => toast.success(
|
|
75
|
+
restore.mutate(session.id, {
|
|
76
|
+
onSuccess: (outcome) => toast.success(outcome.text),
|
|
77
77
|
onError: (e) => toast.error(e.message),
|
|
78
78
|
})
|
|
79
79
|
}
|
|
80
80
|
>
|
|
81
|
-
{
|
|
81
|
+
{restore.isPending ? (
|
|
82
82
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
83
83
|
) : (
|
|
84
84
|
<ArchiveRestore className="h-3.5 w-3.5" />
|
|
85
85
|
)}
|
|
86
|
-
{session.
|
|
86
|
+
{session.taskStatus === "dropped"
|
|
87
|
+
? "Restore"
|
|
88
|
+
: session.worktreeRemoved
|
|
89
|
+
? "Restore entry"
|
|
90
|
+
: "Unarchive"}
|
|
87
91
|
</Button>
|
|
88
92
|
)}
|
|
89
93
|
</div>
|
|
@@ -28,7 +28,7 @@ export function PausedBanner({ sessionId }: { sessionId: string }) {
|
|
|
28
28
|
className="h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400"
|
|
29
29
|
/>
|
|
30
30
|
<p className="min-w-0 flex-1 text-sm">
|
|
31
|
-
{pauseSubtitle({ state, until })}
|
|
31
|
+
{pauseSubtitle({ state, until, note: session.pause_note })}
|
|
32
32
|
<span className="text-muted-foreground block text-xs">
|
|
33
33
|
Messages you send now are held and delivered together when it resumes.
|
|
34
34
|
</span>
|
|
@@ -34,7 +34,11 @@ import { schedulesUiActions } from "@/stores/schedulesUi";
|
|
|
34
34
|
import { recipesUiActions } from "@/stores/recipesUi";
|
|
35
35
|
import { rowGroup } from "@/lib/recipes/chip";
|
|
36
36
|
import { finishActions } from "@/lib/sidebar/row-actions";
|
|
37
|
-
import {
|
|
37
|
+
import {
|
|
38
|
+
useArchiveAction,
|
|
39
|
+
useDoneAction,
|
|
40
|
+
useResurrectAction,
|
|
41
|
+
} from "./useDoneAction";
|
|
38
42
|
import { useShelfActions } from "./useShelfActions";
|
|
39
43
|
import {
|
|
40
44
|
PAUSE_PRESETS,
|
|
@@ -106,6 +110,7 @@ function FinishItems({
|
|
|
106
110
|
}) {
|
|
107
111
|
const markDone = useDoneAction();
|
|
108
112
|
const archive = useArchiveAction();
|
|
113
|
+
const restore = useResurrectAction();
|
|
109
114
|
const actions = finishActions(session, plain);
|
|
110
115
|
return (
|
|
111
116
|
<>
|
|
@@ -115,6 +120,12 @@ function FinishItems({
|
|
|
115
120
|
Done
|
|
116
121
|
</Item>
|
|
117
122
|
)}
|
|
123
|
+
{actions.includes("restore") && (
|
|
124
|
+
<Item onClick={() => restore(session)}>
|
|
125
|
+
<RotateCcw className={icon} />
|
|
126
|
+
Restore
|
|
127
|
+
</Item>
|
|
128
|
+
)}
|
|
118
129
|
{actions.includes("archive") && (
|
|
119
130
|
<Item onClick={() => archive(session)}>
|
|
120
131
|
<Archive className={icon} />
|
|
@@ -3,6 +3,7 @@ import type { Session } from "@/lib/db";
|
|
|
3
3
|
import {
|
|
4
4
|
useArchiveSession,
|
|
5
5
|
useDoneSession,
|
|
6
|
+
useResurrectSession,
|
|
6
7
|
useUnarchiveSession,
|
|
7
8
|
} from "@/data/done";
|
|
8
9
|
import { useSettleSession } from "@/data/sessions/shelf";
|
|
@@ -68,3 +69,16 @@ export function useArchiveAction() {
|
|
|
68
69
|
onError: (e) => toast.error(e.message, { duration: 12000 }),
|
|
69
70
|
});
|
|
70
71
|
}
|
|
72
|
+
|
|
73
|
+
// Restore from a session's menu: the same conversation, its worktree back.
|
|
74
|
+
export function useResurrectAction() {
|
|
75
|
+
const resurrect = useResurrectSession();
|
|
76
|
+
return (session: Session) => {
|
|
77
|
+
const id = toast.loading(`Restoring ${session.name}…`);
|
|
78
|
+
resurrect.mutate(session.id, {
|
|
79
|
+
onSuccess: (outcome) =>
|
|
80
|
+
toast.success(outcome.text, { id, duration: 8000 }),
|
|
81
|
+
onError: (e) => toast.error(e.message, { id, duration: 12000 }),
|
|
82
|
+
});
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
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 { useResurrectAction } = 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 fetchSpy = vi.fn();
|
|
27
|
+
|
|
28
|
+
function Probe() {
|
|
29
|
+
const restore = useResurrectAction();
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
hook.current = restore;
|
|
32
|
+
});
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
vi.clearAllMocks();
|
|
38
|
+
vi.stubGlobal("fetch", fetchSpy);
|
|
39
|
+
host = document.createElement("div");
|
|
40
|
+
document.body.append(host);
|
|
41
|
+
root = createRoot(host);
|
|
42
|
+
act(() =>
|
|
43
|
+
root.render(
|
|
44
|
+
createElement(
|
|
45
|
+
QueryClientProvider,
|
|
46
|
+
{ client: new QueryClient() },
|
|
47
|
+
createElement(Probe)
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
act(() => root.unmount());
|
|
54
|
+
host.remove();
|
|
55
|
+
vi.unstubAllGlobals();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const task = {
|
|
59
|
+
id: "s1",
|
|
60
|
+
name: "Bookmark plan",
|
|
61
|
+
task_status: "done",
|
|
62
|
+
} as Session;
|
|
63
|
+
const respond = (status: number, body: object) =>
|
|
64
|
+
fetchSpy.mockResolvedValue({
|
|
65
|
+
ok: status < 400,
|
|
66
|
+
status,
|
|
67
|
+
json: async () => body,
|
|
68
|
+
});
|
|
69
|
+
const restore = async () => {
|
|
70
|
+
await act(async () => {
|
|
71
|
+
hook.current(task);
|
|
72
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
73
|
+
});
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
describe("Restore from a session's menu", () => {
|
|
77
|
+
it("posts to the resurrect route and shows what came back", async () => {
|
|
78
|
+
respond(200, { outcome: { text: "Bookmark plan is back" } });
|
|
79
|
+
await restore();
|
|
80
|
+
expect(fetchSpy.mock.calls[0][0]).toBe("/api/sessions/s1/resurrect");
|
|
81
|
+
expect(fetchSpy.mock.calls[0][1]).toMatchObject({ method: "POST" });
|
|
82
|
+
expect(toast.success).toHaveBeenCalledWith(
|
|
83
|
+
"Bookmark plan is back",
|
|
84
|
+
expect.objectContaining({ id: "t1" })
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("shows a refusal's reason", async () => {
|
|
89
|
+
respond(409, { error: "its PR #3 was merged" });
|
|
90
|
+
await restore();
|
|
91
|
+
expect(toast.error).toHaveBeenCalledWith(
|
|
92
|
+
"its PR #3 was merged",
|
|
93
|
+
expect.objectContaining({ id: "t1" })
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
ChefHat,
|
|
6
6
|
ExternalLink,
|
|
7
7
|
GitMerge,
|
|
8
|
+
RotateCcw,
|
|
8
9
|
KanbanSquare,
|
|
9
10
|
SquareTerminal,
|
|
10
11
|
} from "lucide-react";
|
|
@@ -12,6 +13,7 @@ import { Button } from "@/components/ui/button";
|
|
|
12
13
|
import type { TaskView } from "@/lib/tasks";
|
|
13
14
|
import { isFinished } from "@/lib/tasks/state";
|
|
14
15
|
import { useArmTask, useDropTask, useSignOffTask } from "@/data/tasks";
|
|
16
|
+
import { useResurrectSession } from "@/data/done";
|
|
15
17
|
import { tmuxAttachActions } from "@/stores/tmuxAttach";
|
|
16
18
|
import { tasksUiActions } from "@/stores/tasksUi";
|
|
17
19
|
import { recipesUiActions } from "@/stores/recipesUi";
|
|
@@ -27,6 +29,7 @@ export function TaskRow({ task }: { task: TaskView }) {
|
|
|
27
29
|
const signOff = useSignOffTask();
|
|
28
30
|
const drop = useDropTask();
|
|
29
31
|
const arm = useArmTask();
|
|
32
|
+
const restore = useResurrectSession();
|
|
30
33
|
const [confirmDrop, setConfirmDrop] = useState(false);
|
|
31
34
|
const [moveError, setMoveError] = useState<string | null>(null);
|
|
32
35
|
const live = !isFinished(task.state);
|
|
@@ -34,6 +37,7 @@ export function TaskRow({ task }: { task: TaskView }) {
|
|
|
34
37
|
signOff.error?.message ||
|
|
35
38
|
drop.error?.message ||
|
|
36
39
|
arm.error?.message ||
|
|
40
|
+
restore.error?.message ||
|
|
37
41
|
moveError ||
|
|
38
42
|
task.hostError;
|
|
39
43
|
const settingUp = live && task.setup?.status === "running";
|
|
@@ -165,6 +169,20 @@ export function TaskRow({ task }: { task: TaskView }) {
|
|
|
165
169
|
</div>
|
|
166
170
|
)}
|
|
167
171
|
{live && task.state === "plan-ready" && <PlanReview task={task} />}
|
|
172
|
+
{!live && task.state !== "merged" && (
|
|
173
|
+
<div>
|
|
174
|
+
<Button
|
|
175
|
+
size="sm"
|
|
176
|
+
variant="outline"
|
|
177
|
+
className="h-11 md:h-8"
|
|
178
|
+
disabled={restore.isPending}
|
|
179
|
+
onClick={() => restore.mutate(task.id)}
|
|
180
|
+
>
|
|
181
|
+
<RotateCcw className="h-3.5 w-3.5" />
|
|
182
|
+
{restore.isPending ? "Restoring..." : "Restore"}
|
|
183
|
+
</Button>
|
|
184
|
+
</div>
|
|
185
|
+
)}
|
|
168
186
|
{!live && !task.hostId && (
|
|
169
187
|
<div>
|
|
170
188
|
<Button
|
package/data/done/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
|
2
2
|
import type { DoneOutcome } from "@/lib/done";
|
|
3
3
|
import type { BulkResult, PreviewRow } from "@/lib/done/bulk";
|
|
4
4
|
import type { ArchivedView } from "@/lib/done/archive";
|
|
5
|
+
import type { ResurrectOutcome } from "@/lib/resurrect";
|
|
5
6
|
import { sessionKeys } from "../sessions/keys";
|
|
6
7
|
import { taskKeys } from "../tasks";
|
|
7
8
|
|
|
@@ -90,6 +91,22 @@ export function useUnarchiveSession() {
|
|
|
90
91
|
);
|
|
91
92
|
}
|
|
92
93
|
|
|
94
|
+
// Brings a dropped, done or archived session back (lib/resurrect).
|
|
95
|
+
export function useResurrectSession() {
|
|
96
|
+
return useDoneMutation(
|
|
97
|
+
async (id: string) =>
|
|
98
|
+
(
|
|
99
|
+
await json<{ outcome: ResurrectOutcome }>(
|
|
100
|
+
await post(
|
|
101
|
+
`/api/sessions/${id}/resurrect`,
|
|
102
|
+
undefined,
|
|
103
|
+
DONE_REQUEST_TIMEOUT_MS
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
).outcome
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
93
110
|
export function useArchivedQuery(workspaceId?: string, enabled = true) {
|
|
94
111
|
return useQuery({
|
|
95
112
|
queryKey: archivedKeys.list(workspaceId),
|
package/lib/db/migrations.ts
CHANGED
|
@@ -2298,6 +2298,17 @@ export const migrations: Migration[] = [
|
|
|
2298
2298
|
`);
|
|
2299
2299
|
},
|
|
2300
2300
|
},
|
|
2301
|
+
{
|
|
2302
|
+
key: "20261014T1900_pause_note",
|
|
2303
|
+
up: (db) => {
|
|
2304
|
+
// Why a resume that was asked for hasn't happened yet (waiting for a
|
|
2305
|
+
// slot, held by load), shown on the task row. Additive.
|
|
2306
|
+
const has = (
|
|
2307
|
+
db.prepare(`PRAGMA table_info(sessions)`).all() as { name: string }[]
|
|
2308
|
+
).some((c) => c.name === "pause_note");
|
|
2309
|
+
if (!has) db.exec(`ALTER TABLE sessions ADD COLUMN pause_note TEXT`);
|
|
2310
|
+
},
|
|
2311
|
+
},
|
|
2301
2312
|
{
|
|
2302
2313
|
key: "20261014T2000_chat_bookmarks",
|
|
2303
2314
|
up: (db) => {
|
package/lib/db/types.ts
CHANGED
|
@@ -92,6 +92,8 @@ export interface Session {
|
|
|
92
92
|
pause_state?: "pausing" | "paused" | "resuming" | null;
|
|
93
93
|
paused_until?: string | null;
|
|
94
94
|
pause_id?: number | null;
|
|
95
|
+
// Why a requested resume hasn't happened yet.
|
|
96
|
+
pause_note?: string | null;
|
|
95
97
|
placed_note?: string | null;
|
|
96
98
|
// Set by done: hidden from the sidebar and the orchestrator, never deleted.
|
|
97
99
|
archived_at: string | null;
|
package/lib/done/worktree.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { getProject } from "../projects";
|
|
|
13
13
|
import { deleteWorktree, isAgentOSWorktree } from "../worktrees";
|
|
14
14
|
import { removalRunning, removalSettled } from "../worktree-removal";
|
|
15
15
|
import { describeChanges, unsavedChanges } from "../worktree-placed";
|
|
16
|
+
import { dropSnapshot, snapshotWorktree } from "../resurrect/snapshot";
|
|
16
17
|
|
|
17
18
|
export type WorktreeFate =
|
|
18
19
|
| { action: "none" }
|
|
@@ -100,7 +101,7 @@ export type FatePreview =
|
|
|
100
101
|
| { action: "remove"; why: string; repo: string };
|
|
101
102
|
|
|
102
103
|
type Judged = Pick<Session, "worktree_path" | "base_branch"> &
|
|
103
|
-
Partial<Pick<Session, "project_id">>;
|
|
104
|
+
Partial<Pick<Session, "project_id" | "id">>;
|
|
104
105
|
|
|
105
106
|
// Gone, or still going: removed by whatever else got to it first.
|
|
106
107
|
const gone = (merged: MergedAs | null, still = false) => ({
|
|
@@ -215,8 +216,23 @@ export async function settleWorktree(
|
|
|
215
216
|
merged: MergedAs | null,
|
|
216
217
|
movedAway = false
|
|
217
218
|
): Promise<WorktreeFate> {
|
|
219
|
+
// Unmerged work is saved for a restore while the fate is judged, and kept
|
|
220
|
+
// only if the worktree is really removed.
|
|
221
|
+
const snapshot =
|
|
222
|
+
!merged && !movedAway && session.id && session.worktree_path
|
|
223
|
+
? snapshotWorktree(session.worktree_path, session.id)
|
|
224
|
+
: null;
|
|
218
225
|
const fate = await worktreeFate(session, merged, undefined, movedAway);
|
|
219
|
-
if (fate.action !== "remove")
|
|
226
|
+
if (fate.action !== "remove") {
|
|
227
|
+
if (snapshot && fate.action === "kept") {
|
|
228
|
+
const { path } = fate;
|
|
229
|
+
void snapshot.then((made) => {
|
|
230
|
+
if (made) void dropSnapshot(path, session.id!);
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
return fate;
|
|
234
|
+
}
|
|
235
|
+
await snapshot;
|
|
220
236
|
const path = session.worktree_path!;
|
|
221
237
|
await deleteWorktree(path, fate.repo, true).catch(() => {});
|
|
222
238
|
if (fs.existsSync(path))
|
package/lib/orchestrator/act.ts
CHANGED
|
@@ -36,7 +36,9 @@ import type { StackItemView } from "../stacks/types";
|
|
|
36
36
|
import { doneSession } from "../done";
|
|
37
37
|
import { archiveChecked } from "../done/archive";
|
|
38
38
|
import { amendBrief, amendmentsOf } from "./amendments";
|
|
39
|
-
import type
|
|
39
|
+
import { db, type Session } from "../db";
|
|
40
|
+
import { resurrectSession } from "../resurrect";
|
|
41
|
+
import { resolveEnded } from "../resurrect/find";
|
|
40
42
|
import { approvePlanNow } from "../models/plan-step";
|
|
41
43
|
import { braked } from "./brakes";
|
|
42
44
|
import { getOrchestrator } from "./home";
|
|
@@ -54,7 +56,7 @@ import {
|
|
|
54
56
|
PauseRefused,
|
|
55
57
|
activePause,
|
|
56
58
|
requestPause,
|
|
57
|
-
|
|
59
|
+
resumeNow,
|
|
58
60
|
} from "../tasks/pause";
|
|
59
61
|
import { pauseUntil, type PausePreset } from "../sidebar/snooze";
|
|
60
62
|
import type { Priority } from "../tasks/schedule-types";
|
|
@@ -411,6 +413,30 @@ export async function archive(
|
|
|
411
413
|
return `Archived ${s.name}: out of the sidebar, in Archived.`;
|
|
412
414
|
}
|
|
413
415
|
|
|
416
|
+
// Brings a dropped, done or archived session back, same id and conversation.
|
|
417
|
+
// A task takes a running slot again, so it goes through the brakes like a
|
|
418
|
+
// start; an unarchive or reopen starts nothing.
|
|
419
|
+
export async function resurrect(
|
|
420
|
+
workspaceId: string,
|
|
421
|
+
ref: string,
|
|
422
|
+
reason: string
|
|
423
|
+
): Promise<string> {
|
|
424
|
+
if (!reason.trim()) throw new Error("Say why it's restored");
|
|
425
|
+
const id = resolveEnded(ref, { workspaceId });
|
|
426
|
+
const row = db
|
|
427
|
+
.prepare(`SELECT task_status, archived_at FROM sessions WHERE id = ?`)
|
|
428
|
+
.get(id) as { task_status: string | null; archived_at: string | null };
|
|
429
|
+
const task =
|
|
430
|
+
row.task_status === "dropped" ||
|
|
431
|
+
(row.task_status === "done" && !row.archived_at);
|
|
432
|
+
const go = () => resurrectSession(id);
|
|
433
|
+
const out = task
|
|
434
|
+
? await braked(workspaceId, "task", go, (o) => o.id)
|
|
435
|
+
: await go();
|
|
436
|
+
addNote(workspaceId, `Restored ${out.name}: ${reason.trim()}`);
|
|
437
|
+
return out.text;
|
|
438
|
+
}
|
|
439
|
+
|
|
414
440
|
// Stops the agent and keeps everything else: a chat's turn is interrupted,
|
|
415
441
|
// a terminal's tmux session ends. Its worktree and branch stay.
|
|
416
442
|
export async function stop(workspaceId: string, ref: string): Promise<string> {
|
|
@@ -507,14 +533,27 @@ export function pauseTask(
|
|
|
507
533
|
return `Pausing ${task.name} ${now ? "now" : "at the end of its step"}. Its slot frees once it has stopped; an event says when.`;
|
|
508
534
|
}
|
|
509
535
|
|
|
510
|
-
export function resumeTask(
|
|
536
|
+
export async function resumeTask(
|
|
537
|
+
workspaceId: string,
|
|
538
|
+
ref: string
|
|
539
|
+
): Promise<string> {
|
|
511
540
|
const task = workspaceTask(workspaceId, ref);
|
|
512
541
|
if (!activePause(task.id)) throw new Error(`${task.name} isn't paused`);
|
|
542
|
+
const slots = task.project_id ? slotState(task.project_id) : null;
|
|
543
|
+
let out;
|
|
513
544
|
try {
|
|
514
|
-
|
|
545
|
+
out = await resumeNow(task.id, "orchestrator");
|
|
515
546
|
} catch (error) {
|
|
516
547
|
if (error instanceof PauseRefused) throw new Error(error.message);
|
|
517
548
|
throw error;
|
|
518
549
|
}
|
|
519
|
-
|
|
550
|
+
if (out.outcome === "held")
|
|
551
|
+
return `${task.name} is ${out.note}. It resumes by itself when that clears.`;
|
|
552
|
+
if (out.outcome === "cancelled")
|
|
553
|
+
return `${task.name} keeps running: its pause was cancelled.`;
|
|
554
|
+
const past = slots?.full
|
|
555
|
+
? ` past the running-task limit (${slots.running} of ${slots.limit} running)`
|
|
556
|
+
: "";
|
|
557
|
+
addNote(workspaceId, `Resumed ${task.name} now${past}.`);
|
|
558
|
+
return `Resumed ${task.name} now${past}. It carries on with what was held for it.`;
|
|
520
559
|
}
|
|
@@ -137,10 +137,11 @@ Acting:
|
|
|
137
137
|
- \`${TOOL_NAMES.set_priority}\` (task, priority): re-rank a queued task (urgent, blocking, normal, background), or tag a running one. Urgent is a bug Saad hit; background starts last.
|
|
138
138
|
- \`${TOOL_NAMES.start_now}\` (task, reason): start a queued task now, past the running-task limit and its after. Still refused while paused or braked, and a Mac-only task stays queued. Use it sparingly, when Saad asks or it unblocks others; the reason is logged for Saad.
|
|
139
139
|
- \`${TOOL_NAMES.pause_task}\` (task, for, now?, reason): pause a running task for 1h, 2h, until tomorrow 9 AM or until resumed. Its agent ends at the end of its step (now interrupts the step); its worktree and conversation stay and its slot frees for the next queued task. It resumes by itself, ahead of new arrivals, with what was held (CI and review notes, messages) in one prompt. Use it to make room for urgent work or to wait out a loaded machine. Refused for a task that is merging, moving, setting up or waiting on Saad.
|
|
140
|
-
- \`${TOOL_NAMES.resume_task}\` (task): resume a pause you set now. A pause Saad set is his to lift.
|
|
140
|
+
- \`${TOOL_NAMES.resume_task}\` (task): resume a pause you set now, past the running-task limit unless the machine load holds it. A pause Saad set is his to lift.
|
|
141
141
|
- \`${TOOL_NAMES.stop}\` (session): stop a session's agent, keeping its work.
|
|
142
142
|
- \`${TOOL_NAMES.done}\` (session): finish a session whose work is complete. A task with an open PR merges through the same gates as sign_off first (refused naming the gate otherwise); then the agent stops, the worktree goes only if nothing in it would be lost, and the session lands on the sidebar's Done shelf (it is not archived). Not a rejection: use drop for that.
|
|
143
143
|
- \`${TOOL_NAMES.archive}\` (session): put a session away, out of the sidebar, when Saad asks. Nothing is merged or cleaned up; refused while it works or has an open PR.
|
|
144
|
+
- \`${TOOL_NAMES.resurrect}\` (session, reason): bring a dropped, done or archived session back with its conversation and worktree, in the state it had (a plan-only task waits for approval again). Use it when a drop or done was a mistake, or when Saad asks; refused for a merged PR, a missing conversation or a removed project. A restored task counts against the running limit and the brakes like a start.
|
|
144
145
|
- \`${TOOL_NAMES.add_project}\` (path, name?): make a git repository on this machine a project of this workspace, or move the project already at that path in when it's in no workspace. Use it rather than sending Saad steps. It refuses a folder that isn't a git repository or holds secrets, and a project another workspace has: moving that is Saad's call, so ask_saad.
|
|
145
146
|
- \`${TOOL_NAMES.note}\` (text): a line in this workspace's decision log, shown in your chat. Note each decision that matters, with why.
|
|
146
147
|
- \`${TOOL_NAMES.rule_add}\` (text): keep a standing rule for this workspace, in your brief on every start. Saad sees and edits them in Settings → Workspaces.
|