@saadnvd1/agent-os 0.3.848 → 0.3.853
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/README.md +6 -0
- package/app/api/updates/run/route.ts +7 -3
- package/components/Chat/Forks.tsx +24 -2
- package/components/SessionList/useDoneAction.ts +8 -2
- package/components/Updates/UpdateActions.tsx +18 -3
- package/components/Updates/UpdateToast.test.ts +17 -0
- package/components/Updates/UpdateToast.tsx +15 -5
- package/components/Updates/UpdatesSection.tsx +9 -1
- package/content/whats-new.json +16 -0
- package/data/done/index.ts +7 -1
- package/hooks/useReloadWhenUpdated.ts +9 -0
- package/lib/fork/types.ts +2 -0
- package/lib/fork/view.ts +10 -0
- package/lib/updates/banner.ts +12 -2
- package/lib/updates/request.ts +29 -0
- package/lib/updates/run.ts +24 -5
- package/lib/updates/view.ts +3 -0
- package/package.json +1 -1
- package/scripts/autodeploy +25 -3
- package/scripts/autodeploy.test.ts +47 -0
package/README.md
CHANGED
|
@@ -10,6 +10,12 @@ and check on all of it from your phone.
|
|
|
10
10
|
<img alt="AgentOS: sessions grouped by state, a chat with its plan, steps and diffs, and the git panel" src="screenshots/hero.png">
|
|
11
11
|
</picture>
|
|
12
12
|
|
|
13
|
+
<p align="center">
|
|
14
|
+
<a href="https://youtu.be/JAukzHLTM5Y"><img alt="Watch the AgentOS video: You're the boss. They do the work." src="screenshots/video-boss.jpg" width="640"></a>
|
|
15
|
+
<br>
|
|
16
|
+
<a href="https://youtu.be/JAukzHLTM5Y">▶ Watch: what AgentOS does, in about a minute</a>
|
|
17
|
+
</p>
|
|
18
|
+
|
|
13
19
|
## What it does
|
|
14
20
|
|
|
15
21
|
- **An orchestrator for each workspace.** A chat that starts tasks, answers blocked agents, and merges pull requests only through gates. Anything that is yours to decide waits as an ask. [More](docs/reference/orchestrator.md)
|
|
@@ -28,9 +28,13 @@ export async function POST(request: NextRequest) {
|
|
|
28
28
|
{ code: started.code, error: started.error },
|
|
29
29
|
{ status: 409 }
|
|
30
30
|
);
|
|
31
|
-
return NextResponse.json(
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
return NextResponse.json(
|
|
32
|
+
{
|
|
33
|
+
...(await updateView({ mayUpdate: true })),
|
|
34
|
+
...(started.message ? { message: started.message } : {}),
|
|
35
|
+
},
|
|
36
|
+
{ status: 202 }
|
|
37
|
+
);
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
// DELETE /api/updates/run - cancel a requested "update when idle". A started
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useState } from "react";
|
|
4
|
-
import { GitFork, Square } from "lucide-react";
|
|
4
|
+
import { CircleCheck, GitFork, Square } from "lucide-react";
|
|
5
5
|
import { toast } from "sonner";
|
|
6
6
|
import { useExperiment } from "@/data/experiments";
|
|
7
7
|
import {
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
import { formatElapsed } from "@/lib/chat/elapsed";
|
|
14
14
|
import { Button } from "@/components/ui/button";
|
|
15
15
|
import { StatusPill } from "@/components/ui/status-pill";
|
|
16
|
+
import { useDoneAction } from "@/components/SessionList/useDoneAction";
|
|
16
17
|
import { SessionAnchor } from "./SessionAnchor";
|
|
17
18
|
|
|
18
19
|
type Tone = "amber" | "muted" | "green" | "primary" | "red";
|
|
@@ -73,12 +74,14 @@ export const runningNote = (c: ChildView, now = Date.now()): string | null => {
|
|
|
73
74
|
function Row({
|
|
74
75
|
c,
|
|
75
76
|
onStop,
|
|
77
|
+
onDone,
|
|
76
78
|
stopping,
|
|
77
79
|
again,
|
|
78
80
|
}: {
|
|
79
81
|
c: ChildView;
|
|
80
82
|
again: ReturnType<typeof useRunAgain>;
|
|
81
83
|
onStop: () => void;
|
|
84
|
+
onDone: () => void;
|
|
82
85
|
stopping: boolean;
|
|
83
86
|
}) {
|
|
84
87
|
const { text, tone } = stateLabel(c);
|
|
@@ -144,6 +147,16 @@ function Row({
|
|
|
144
147
|
<Square aria-hidden className="size-4" />
|
|
145
148
|
</Button>
|
|
146
149
|
)}
|
|
150
|
+
<Button
|
|
151
|
+
size="sm"
|
|
152
|
+
variant="ghost"
|
|
153
|
+
aria-label={`Mark fork ${c.n} done`}
|
|
154
|
+
data-tip="Done"
|
|
155
|
+
className="size-11 shrink-0"
|
|
156
|
+
onClick={onDone}
|
|
157
|
+
>
|
|
158
|
+
<CircleCheck aria-hidden className="size-4" />
|
|
159
|
+
</Button>
|
|
147
160
|
</li>
|
|
148
161
|
);
|
|
149
162
|
}
|
|
@@ -154,10 +167,11 @@ export function ForksStrip({ sessionId }: { sessionId: string }) {
|
|
|
154
167
|
const { data } = useForkChildren(sessionId, on);
|
|
155
168
|
const cancel = useCancelForks(sessionId);
|
|
156
169
|
const again = useRunAgain(sessionId);
|
|
170
|
+
const markDone = useDoneAction();
|
|
157
171
|
const [collapsed, setCollapsed] = useState(false);
|
|
158
172
|
if (!on || !data) return null;
|
|
159
173
|
if (data.asChild) return <ForkOfBanner info={data.asChild} />;
|
|
160
|
-
const
|
|
174
|
+
const children = data.children.filter((c) => !c.done);
|
|
161
175
|
if (!children.length) return null;
|
|
162
176
|
const open = children.filter((c) => c.state !== "settled").length;
|
|
163
177
|
const stop = (ref?: string) =>
|
|
@@ -206,6 +220,14 @@ export function ForksStrip({ sessionId }: { sessionId: string }) {
|
|
|
206
220
|
stopping={cancel.isPending}
|
|
207
221
|
again={again}
|
|
208
222
|
onStop={() => stop(String(c.n))}
|
|
223
|
+
onDone={() =>
|
|
224
|
+
markDone({
|
|
225
|
+
id: c.id,
|
|
226
|
+
name: c.label,
|
|
227
|
+
task_status: null,
|
|
228
|
+
pr_status: null,
|
|
229
|
+
})
|
|
230
|
+
}
|
|
209
231
|
/>
|
|
210
232
|
))}
|
|
211
233
|
</ul>
|
|
@@ -9,12 +9,18 @@ import {
|
|
|
9
9
|
} from "@/data/done";
|
|
10
10
|
import { useSettleSession } from "@/data/sessions/shelf";
|
|
11
11
|
|
|
12
|
+
export type DoneTarget = Pick<
|
|
13
|
+
Session,
|
|
14
|
+
"id" | "name" | "task_status" | "pr_status"
|
|
15
|
+
> &
|
|
16
|
+
Partial<Pick<Session, "settled_at">>;
|
|
17
|
+
|
|
12
18
|
// Done from a session's menu: an open PR merges through the gates, after an
|
|
13
19
|
// in-app confirmation; a failing gate comes back as an error toast naming it.
|
|
14
20
|
export function useDoneAction() {
|
|
15
21
|
const done = useDoneSession();
|
|
16
22
|
const settle = useSettleSession();
|
|
17
|
-
const run = (session:
|
|
23
|
+
const run = (session: DoneTarget, merges: boolean) => {
|
|
18
24
|
const id = toast.loading(
|
|
19
25
|
merges ? `Merging ${session.name}…` : `Finishing ${session.name}…`
|
|
20
26
|
);
|
|
@@ -38,7 +44,7 @@ export function useDoneAction() {
|
|
|
38
44
|
onError: (e) => toast.error(e.message, { id, duration: 12000 }),
|
|
39
45
|
});
|
|
40
46
|
};
|
|
41
|
-
return (session:
|
|
47
|
+
return (session: DoneTarget) => {
|
|
42
48
|
const merges =
|
|
43
49
|
session.task_status === "running" && session.pr_status === "open";
|
|
44
50
|
if (!merges) return run(session, false);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useState } from "react";
|
|
4
|
+
import { Loader2 } from "lucide-react";
|
|
4
5
|
import { toast } from "sonner";
|
|
5
6
|
import { Button } from "@/components/ui/button";
|
|
6
7
|
import {
|
|
@@ -38,6 +39,12 @@ export function UpdateActions({ view }: { view: UpdateView }) {
|
|
|
38
39
|
const [confirm, setConfirm] = useState(false);
|
|
39
40
|
const busy = workingTotal(view.working) > 0;
|
|
40
41
|
|
|
42
|
+
if (view.queued)
|
|
43
|
+
return (
|
|
44
|
+
<Button size="sm" className="min-h-11 md:min-h-0" disabled>
|
|
45
|
+
<Loader2 className="h-4 w-4 animate-spin" /> Updating…
|
|
46
|
+
</Button>
|
|
47
|
+
);
|
|
41
48
|
if (view.pending)
|
|
42
49
|
return (
|
|
43
50
|
<Button
|
|
@@ -60,7 +67,10 @@ export function UpdateActions({ view }: { view: UpdateView }) {
|
|
|
60
67
|
|
|
61
68
|
const start = (when: "now" | "idle") => {
|
|
62
69
|
setConfirm(false);
|
|
63
|
-
run.mutate(when, {
|
|
70
|
+
run.mutate(when, {
|
|
71
|
+
onError: toastError,
|
|
72
|
+
onSuccess: (v) => v.message && toast.success(v.message),
|
|
73
|
+
});
|
|
64
74
|
};
|
|
65
75
|
|
|
66
76
|
return (
|
|
@@ -87,10 +97,15 @@ export function UpdateActions({ view }: { view: UpdateView }) {
|
|
|
87
97
|
<Button variant="ghost" onClick={() => setConfirm(false)}>
|
|
88
98
|
Cancel
|
|
89
99
|
</Button>
|
|
90
|
-
<Button
|
|
100
|
+
<Button
|
|
101
|
+
variant={view.managed ? "default" : "outline"}
|
|
102
|
+
onClick={() => start("now")}
|
|
103
|
+
>
|
|
91
104
|
Update now
|
|
92
105
|
</Button>
|
|
93
|
-
|
|
106
|
+
{!view.managed && (
|
|
107
|
+
<Button onClick={() => start("idle")}>Update when idle</Button>
|
|
108
|
+
)}
|
|
94
109
|
</DialogFooter>
|
|
95
110
|
</DialogContent>
|
|
96
111
|
</Dialog>
|
|
@@ -96,6 +96,8 @@ const make = (over: Partial<UpdateView> = {}): UpdateView => ({
|
|
|
96
96
|
canUpdate: { ok: true },
|
|
97
97
|
run: null,
|
|
98
98
|
pending: null,
|
|
99
|
+
managed: false,
|
|
100
|
+
queued: null,
|
|
99
101
|
working: { sessions: 0, reviews: 0, setups: 0 },
|
|
100
102
|
...over,
|
|
101
103
|
});
|
|
@@ -212,6 +214,21 @@ describe("UpdateToast", () => {
|
|
|
212
214
|
expect(runMutate).toHaveBeenCalledWith("idle", expect.anything());
|
|
213
215
|
});
|
|
214
216
|
|
|
217
|
+
it("on a machine autodeploy manages, explains and still offers Update now", async () => {
|
|
218
|
+
view = make({ managed: true });
|
|
219
|
+
await render();
|
|
220
|
+
expect(text()).toContain("deployed by autodeploy");
|
|
221
|
+
await act(async () => button("Update now")!.click());
|
|
222
|
+
expect(runMutate).toHaveBeenCalledWith("now", expect.anything());
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("shows a queued request as a disabled Updating… button", async () => {
|
|
226
|
+
view = make({ managed: true, queued: { requestedAt: 1 } });
|
|
227
|
+
await render();
|
|
228
|
+
expect(button("Updating…")?.disabled).toBe(true);
|
|
229
|
+
expect(button("Update now")).toBeUndefined();
|
|
230
|
+
});
|
|
231
|
+
|
|
215
232
|
it("shows a pending update with Cancel", async () => {
|
|
216
233
|
view = make({ pending: { requestedAt: 1 } });
|
|
217
234
|
await render();
|
|
@@ -10,6 +10,7 @@ import { useDismissedUpdate } from "@/hooks/useDismissedUpdate";
|
|
|
10
10
|
import { useReloadWhenUpdated } from "@/hooks/useReloadWhenUpdated";
|
|
11
11
|
import {
|
|
12
12
|
bannerState,
|
|
13
|
+
MANAGED_NOTE,
|
|
13
14
|
type BannerState,
|
|
14
15
|
type UpdateView,
|
|
15
16
|
} from "@/lib/updates/banner";
|
|
@@ -72,6 +73,10 @@ export function UpdateToastView({
|
|
|
72
73
|
if (state === "available") text = <>AgentOS {latest?.version} is available</>;
|
|
73
74
|
else if (state === "pending")
|
|
74
75
|
text = <>AgentOS will update when nothing is working</>;
|
|
76
|
+
else if (state === "queued")
|
|
77
|
+
text = (
|
|
78
|
+
<>Updating AgentOS to {latest?.version}. It deploys within a minute.</>
|
|
79
|
+
);
|
|
75
80
|
else if (state === "built")
|
|
76
81
|
text = <>Built {run?.toVersion}. Restart AgentOS to finish updating.</>;
|
|
77
82
|
else if (failed) text = <>The update didn't finish. {run?.error}</>;
|
|
@@ -110,7 +115,7 @@ export function UpdateToastView({
|
|
|
110
115
|
)}
|
|
111
116
|
>
|
|
112
117
|
<span className="flex h-6 shrink-0 items-center">
|
|
113
|
-
{state === "running" ? (
|
|
118
|
+
{state === "running" || state === "queued" ? (
|
|
114
119
|
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
|
|
115
120
|
) : (
|
|
116
121
|
<ArrowUpCircle
|
|
@@ -130,6 +135,9 @@ export function UpdateToastView({
|
|
|
130
135
|
{view.canUpdate.reason}
|
|
131
136
|
</p>
|
|
132
137
|
)}
|
|
138
|
+
{state === "available" && view.managed && view.canUpdate.ok && (
|
|
139
|
+
<p className="text-muted-foreground text-xs">{MANAGED_NOTE}</p>
|
|
140
|
+
)}
|
|
133
141
|
{notesOpen && latest && (
|
|
134
142
|
<ul className="text-muted-foreground list-disc space-y-0.5 pl-4 text-xs">
|
|
135
143
|
{latest.notes.map((n, i) => (
|
|
@@ -138,9 +146,9 @@ export function UpdateToastView({
|
|
|
138
146
|
</ul>
|
|
139
147
|
)}
|
|
140
148
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
|
141
|
-
{(state === "available" ||
|
|
142
|
-
|
|
143
|
-
|
|
149
|
+
{(state === "available" ||
|
|
150
|
+
state === "pending" ||
|
|
151
|
+
state === "queued") && <UpdateActions view={view} />}
|
|
144
152
|
{state === "running" && elapsed > SLOW_MS && (
|
|
145
153
|
<Button size="sm" onClick={() => window.location.reload()}>
|
|
146
154
|
Reload
|
|
@@ -243,7 +251,9 @@ export function UpdateToast() {
|
|
|
243
251
|
? `running:${view?.run?.id}`
|
|
244
252
|
: computed === "pending"
|
|
245
253
|
? `pending:${view?.pending?.requestedAt}`
|
|
246
|
-
:
|
|
254
|
+
: computed === "queued"
|
|
255
|
+
? `queued:${view?.queued?.requestedAt}`
|
|
256
|
+
: null;
|
|
247
257
|
const state = key && key === closed ? "hidden" : computed;
|
|
248
258
|
|
|
249
259
|
const { dismissVersion, dismissRun } = dismissed;
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
useUpdateBannerSetting,
|
|
12
12
|
useUpdates,
|
|
13
13
|
} from "@/data/updates";
|
|
14
|
-
import { isActivePhase } from "@/lib/updates/banner";
|
|
14
|
+
import { isActivePhase, MANAGED_NOTE } from "@/lib/updates/banner";
|
|
15
15
|
import { AutoUpdates } from "./AutoUpdates";
|
|
16
16
|
import { UpdateActions } from "./UpdateActions";
|
|
17
17
|
|
|
@@ -108,9 +108,17 @@ export function UpdatesSection() {
|
|
|
108
108
|
{view.run?.phase})…
|
|
109
109
|
</p>
|
|
110
110
|
)}
|
|
111
|
+
{view.available && view.managed && view.canUpdate.ok && (
|
|
112
|
+
<p className="text-muted-foreground text-xs">{MANAGED_NOTE}</p>
|
|
113
|
+
)}
|
|
111
114
|
{view.available && !view.canUpdate.ok && view.canUpdate.reason && (
|
|
112
115
|
<p className="text-muted-foreground text-xs">{view.canUpdate.reason}</p>
|
|
113
116
|
)}
|
|
117
|
+
{view.queued && (
|
|
118
|
+
<p className="text-muted-foreground text-sm">
|
|
119
|
+
Queued for autodeploy. It deploys within about a minute.
|
|
120
|
+
</p>
|
|
121
|
+
)}
|
|
114
122
|
{view.pending && (
|
|
115
123
|
<p className="text-muted-foreground text-sm">
|
|
116
124
|
Will update when nothing is working.
|
package/content/whats-new.json
CHANGED
|
@@ -2025,6 +2025,22 @@
|
|
|
2025
2025
|
"area": "Settings",
|
|
2026
2026
|
"text": "Settings is tidier: clearer switches, shorter lists, one Approvals page, and a General page for your name and alerts.",
|
|
2027
2027
|
"url": "https://github.com/saadnvd1/agent-os/pull/570"
|
|
2028
|
+
},
|
|
2029
|
+
{
|
|
2030
|
+
"id": 571,
|
|
2031
|
+
"date": "2026-11-02",
|
|
2032
|
+
"kind": "fixed",
|
|
2033
|
+
"area": "Updates",
|
|
2034
|
+
"text": "You can update AgentOS by hand again on machines that deploy themselves, and Update now skips the hourly wait.",
|
|
2035
|
+
"url": "https://github.com/saadnvd1/agent-os/pull/571"
|
|
2036
|
+
},
|
|
2037
|
+
{
|
|
2038
|
+
"id": 573,
|
|
2039
|
+
"date": "2026-11-02",
|
|
2040
|
+
"kind": "new",
|
|
2041
|
+
"area": "AgentOS",
|
|
2042
|
+
"text": "Mark a fork as done straight from the Forks list above the chat box.",
|
|
2043
|
+
"url": "https://github.com/saadnvd1/agent-os/pull/573"
|
|
2028
2044
|
}
|
|
2029
2045
|
]
|
|
2030
2046
|
}
|
package/data/done/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { ArchivedView } from "@/lib/done/archive";
|
|
|
5
5
|
import type { ResurrectOutcome } from "@/lib/resurrect";
|
|
6
6
|
import { sessionKeys } from "../sessions/keys";
|
|
7
7
|
import { taskKeys } from "../tasks";
|
|
8
|
+
import { forkKeys } from "../fork";
|
|
8
9
|
|
|
9
10
|
export const archivedKeys = {
|
|
10
11
|
all: ["archived-sessions"] as const,
|
|
@@ -52,7 +53,12 @@ function useDoneMutation<V, R>(fn: (v: V) => Promise<R>) {
|
|
|
52
53
|
return useMutation({
|
|
53
54
|
mutationFn: fn,
|
|
54
55
|
onSettled: () => {
|
|
55
|
-
for (const queryKey of [
|
|
56
|
+
for (const queryKey of [
|
|
57
|
+
sessionKeys.all,
|
|
58
|
+
taskKeys.all,
|
|
59
|
+
archivedKeys.all,
|
|
60
|
+
forkKeys.all,
|
|
61
|
+
])
|
|
56
62
|
queryClient.invalidateQueries({ queryKey });
|
|
57
63
|
},
|
|
58
64
|
});
|
|
@@ -9,7 +9,16 @@ import { isActivePhase, type UpdateView } from "@/lib/updates/banner";
|
|
|
9
9
|
*/
|
|
10
10
|
export function useReloadWhenUpdated(view: UpdateView | undefined) {
|
|
11
11
|
const watched = useRef<string | null>(null);
|
|
12
|
+
const queuedFrom = useRef<string | null>(null);
|
|
12
13
|
const run = view?.run;
|
|
14
|
+
// An Update now that autodeploy took: the page was loaded from the old build.
|
|
15
|
+
const sha = view?.current?.sha;
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (!sha) return;
|
|
18
|
+
if (view?.queued) queuedFrom.current = sha;
|
|
19
|
+
else if (queuedFrom.current && queuedFrom.current !== sha)
|
|
20
|
+
window.location.reload();
|
|
21
|
+
}, [view?.queued, sha]);
|
|
13
22
|
useEffect(() => {
|
|
14
23
|
if (!run) return;
|
|
15
24
|
if (isActivePhase(run.phase)) watched.current = run.id;
|
package/lib/fork/types.ts
CHANGED
|
@@ -65,6 +65,8 @@ export interface ChildView {
|
|
|
65
65
|
excerpt: string | null;
|
|
66
66
|
resultKind: ResultKind | null;
|
|
67
67
|
worktree: boolean;
|
|
68
|
+
// Marked done or archived as a session: it leaves the strip.
|
|
69
|
+
done: boolean;
|
|
68
70
|
fellBack: boolean;
|
|
69
71
|
startedAt: number | null;
|
|
70
72
|
settledAt: number | null;
|
package/lib/fork/view.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** What clients and `aos children` show, and the numbers behind them. */
|
|
2
2
|
|
|
3
3
|
import { lastItems } from "../chat/store";
|
|
4
|
+
import { db } from "../db";
|
|
4
5
|
import { childrenOf, getGroup } from "./ledger";
|
|
5
6
|
import type { ChildRow, ChildView, GroupView } from "./types";
|
|
6
7
|
|
|
@@ -17,6 +18,7 @@ export const childView = (c: ChildRow): ChildView => ({
|
|
|
17
18
|
excerpt: c.result ? c.result.slice(0, 600) : null,
|
|
18
19
|
resultKind: c.result_kind,
|
|
19
20
|
worktree: !!c.worktree,
|
|
21
|
+
done: false,
|
|
20
22
|
fellBack: !!c.fell_back,
|
|
21
23
|
startedAt: c.started_at,
|
|
22
24
|
settledAt: c.settled_at,
|
|
@@ -32,6 +34,13 @@ const activityOf = (c: ChildRow): string | null => {
|
|
|
32
34
|
return tool && tool.kind === "tool" ? tool.title : null;
|
|
33
35
|
};
|
|
34
36
|
|
|
37
|
+
const isDone = (id: string): boolean =>
|
|
38
|
+
!!db
|
|
39
|
+
.prepare(
|
|
40
|
+
`SELECT 1 FROM sessions WHERE id = ? AND (settled_at IS NOT NULL OR archived_at IS NOT NULL)`
|
|
41
|
+
)
|
|
42
|
+
.get(id);
|
|
43
|
+
|
|
35
44
|
export function childrenView(parentId: string): {
|
|
36
45
|
children: ChildView[];
|
|
37
46
|
groups: GroupView[];
|
|
@@ -57,6 +66,7 @@ export function childrenView(parentId: string): {
|
|
|
57
66
|
children: rows.map((c, i) => ({
|
|
58
67
|
...childView(c),
|
|
59
68
|
n: i + 1,
|
|
69
|
+
done: isDone(c.child_session_id),
|
|
60
70
|
activity: activityOf(c),
|
|
61
71
|
})),
|
|
62
72
|
groups,
|
package/lib/updates/banner.ts
CHANGED
|
@@ -94,7 +94,7 @@ export type UpdateRefusal =
|
|
|
94
94
|
| "up_to_date"
|
|
95
95
|
| "unknown"
|
|
96
96
|
| "no_restart"
|
|
97
|
-
| "
|
|
97
|
+
| "deploying"
|
|
98
98
|
| "disk"
|
|
99
99
|
| "forbidden";
|
|
100
100
|
|
|
@@ -118,6 +118,12 @@ export interface UpdateView {
|
|
|
118
118
|
canUpdate: { ok: boolean; code?: UpdateRefusal; reason?: string };
|
|
119
119
|
run: UpdateRun | null;
|
|
120
120
|
pending: { requestedAt: number } | null;
|
|
121
|
+
/** autodeploy deploys this checkout (its heartbeat is fresh). */
|
|
122
|
+
managed: boolean;
|
|
123
|
+
/** An Update now click waiting for autodeploy to pick it up. */
|
|
124
|
+
queued: { requestedAt: number } | null;
|
|
125
|
+
/** What the server said about the click that produced this view. */
|
|
126
|
+
message?: string;
|
|
121
127
|
working: { sessions: number; reviews: number; setups: number };
|
|
122
128
|
auto?: AutoUpdateView;
|
|
123
129
|
}
|
|
@@ -126,6 +132,7 @@ export type BannerState =
|
|
|
126
132
|
| "hidden"
|
|
127
133
|
| "available"
|
|
128
134
|
| "pending"
|
|
135
|
+
| "queued"
|
|
129
136
|
| "running"
|
|
130
137
|
| "failed"
|
|
131
138
|
| "built"
|
|
@@ -173,8 +180,8 @@ export function bannerState({
|
|
|
173
180
|
)
|
|
174
181
|
return "updated";
|
|
175
182
|
}
|
|
183
|
+
if (view.queued) return "queued";
|
|
176
184
|
if (view.pending) return "pending";
|
|
177
|
-
if (view.canUpdate.code === "managed") return "hidden";
|
|
178
185
|
if (
|
|
179
186
|
view.available &&
|
|
180
187
|
view.bannerEnabled &&
|
|
@@ -185,5 +192,8 @@ export function bannerState({
|
|
|
185
192
|
return "hidden";
|
|
186
193
|
}
|
|
187
194
|
|
|
195
|
+
export const MANAGED_NOTE =
|
|
196
|
+
"Updates on this machine are deployed by autodeploy. Update now deploys it within a minute.";
|
|
197
|
+
|
|
188
198
|
export const workingTotal = (w: UpdateView["working"]) =>
|
|
189
199
|
w.sessions + w.reviews + w.setups;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// The "deploy now" request a click leaves for scripts/autodeploy, which owns
|
|
2
|
+
// deploys on its machine: $AGENT_OS_HOME/autodeploy/now. The script skips the
|
|
3
|
+
// restart gap and the settle wait for it and removes it once the deploy ran.
|
|
4
|
+
|
|
5
|
+
import fs from "fs";
|
|
6
|
+
import path from "path";
|
|
7
|
+
|
|
8
|
+
/** autodeploy ignores a request older than this (its find -mmin +30). */
|
|
9
|
+
export const REQUEST_FRESH_MS = 30 * 60_000;
|
|
10
|
+
|
|
11
|
+
export function readDeployRequest(
|
|
12
|
+
file: string,
|
|
13
|
+
now: number
|
|
14
|
+
): { requestedAt: number } | null {
|
|
15
|
+
try {
|
|
16
|
+
const at = Number(fs.readFileSync(file, "utf8").trim());
|
|
17
|
+
if (Number.isFinite(at) && now - at < REQUEST_FRESH_MS)
|
|
18
|
+
return { requestedAt: at };
|
|
19
|
+
} catch {}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Written to a temp name and renamed, so the script never sees half a file. */
|
|
24
|
+
export function writeDeployRequest(file: string, now: number): void {
|
|
25
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
26
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
27
|
+
fs.writeFileSync(tmp, `${now}\n`);
|
|
28
|
+
fs.renameSync(tmp, file);
|
|
29
|
+
}
|
package/lib/updates/run.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { git } from "@/lib/git-exec";
|
|
|
15
15
|
import { buildId } from "@/lib/build";
|
|
16
16
|
import { agentosHome } from "@/lib/agentos-home";
|
|
17
17
|
import { dbPath } from "@/lib/db/path";
|
|
18
|
+
import { readDeployRequest, writeDeployRequest } from "./request";
|
|
18
19
|
import {
|
|
19
20
|
isActivePhase,
|
|
20
21
|
workingTotal,
|
|
@@ -87,6 +88,8 @@ export interface RunDeps {
|
|
|
87
88
|
managed: () => boolean;
|
|
88
89
|
/** True while scripts/autodeploy is deploying (its lock directory exists). */
|
|
89
90
|
deployLocked: () => boolean;
|
|
91
|
+
/** The file an Update now click leaves for scripts/autodeploy. */
|
|
92
|
+
requestFile: string;
|
|
90
93
|
/** What a restart would interrupt, as short labels; empty when nothing. */
|
|
91
94
|
busy: () => Promise<string[]>;
|
|
92
95
|
/** The database the runner backs up first, and where to. */
|
|
@@ -217,6 +220,7 @@ export function realRunDeps(): RunDeps {
|
|
|
217
220
|
}
|
|
218
221
|
},
|
|
219
222
|
deployLocked: () => fs.existsSync(agentosHome("autodeploy", "lock")),
|
|
223
|
+
requestFile: agentosHome("autodeploy", "now"),
|
|
220
224
|
busy: busyLabels,
|
|
221
225
|
dbFile: path.resolve(dbPath()),
|
|
222
226
|
backupDir: agentosHome("backups"),
|
|
@@ -358,12 +362,13 @@ export async function preflight(
|
|
|
358
362
|
error:
|
|
359
363
|
"AgentOS isn't running from a git checkout, so it can't update itself.",
|
|
360
364
|
};
|
|
361
|
-
if (deps.
|
|
365
|
+
if (deps.deployLocked())
|
|
362
366
|
return {
|
|
363
|
-
code: "
|
|
364
|
-
error:
|
|
365
|
-
"This machine is updated by its own deploy job, so updating from here would race it. It deploys new versions by itself.",
|
|
367
|
+
code: "deploying",
|
|
368
|
+
error: "A deploy is running on this machine now. Try again when it ends.",
|
|
366
369
|
};
|
|
370
|
+
// autodeploy owns restarts, disk and the restart command on a machine it manages.
|
|
371
|
+
const managed = deps.managed();
|
|
367
372
|
const run = readRun(deps);
|
|
368
373
|
if (run && isActivePhase(run.phase))
|
|
369
374
|
return {
|
|
@@ -404,6 +409,7 @@ export async function preflight(
|
|
|
404
409
|
};
|
|
405
410
|
if (!check.available)
|
|
406
411
|
return { code: "up_to_date", error: "AgentOS is up to date." };
|
|
412
|
+
if (managed) return null;
|
|
407
413
|
if (!(await deps.restartCommand()))
|
|
408
414
|
return { code: "no_restart", error: NO_RESTART };
|
|
409
415
|
const free = deps.freeBytes();
|
|
@@ -418,8 +424,12 @@ export async function preflight(
|
|
|
418
424
|
|
|
419
425
|
// ── starting ─────────────────────────────────────────────────────────────
|
|
420
426
|
|
|
427
|
+
export const QUEUED_MESSAGE =
|
|
428
|
+
"Queued for autodeploy. It will deploy within about a minute.";
|
|
429
|
+
|
|
421
430
|
export type StartResult =
|
|
422
|
-
{ ok: true; pending: boolean
|
|
431
|
+
| { ok: true; pending: boolean; queued?: boolean; message?: string }
|
|
432
|
+
| ({ ok: false } & Refusal);
|
|
423
433
|
|
|
424
434
|
/** Takes the right to start, atomically, so two requests can't both run. */
|
|
425
435
|
function claimStart(deps: Pick<RunDeps, "stateFile" | "now">): boolean {
|
|
@@ -484,6 +494,12 @@ async function begin(
|
|
|
484
494
|
await discardGenerated(deps);
|
|
485
495
|
const refused = await preflight(result, deps);
|
|
486
496
|
if (refused) return { ok: false, ...refused };
|
|
497
|
+
// autodeploy deploys this checkout: starting a second updater would race
|
|
498
|
+
// it, so the click goes to it as a request instead.
|
|
499
|
+
if (deps.managed()) {
|
|
500
|
+
writeDeployRequest(deps.requestFile, deps.now());
|
|
501
|
+
return { ok: true, pending: false, queued: true, message: QUEUED_MESSAGE };
|
|
502
|
+
}
|
|
487
503
|
if (when === "idle") {
|
|
488
504
|
deps.putSetting(
|
|
489
505
|
PENDING_KEY,
|
|
@@ -553,6 +569,9 @@ export function readPending(
|
|
|
553
569
|
return null;
|
|
554
570
|
}
|
|
555
571
|
|
|
572
|
+
export const readQueued = (deps: Pick<RunDeps, "requestFile" | "now">) =>
|
|
573
|
+
readDeployRequest(deps.requestFile, deps.now());
|
|
574
|
+
|
|
556
575
|
export const cancelPending = (
|
|
557
576
|
deps: Pick<RunDeps, "putSetting"> = realRunDeps()
|
|
558
577
|
) => deps.putSetting(PENDING_KEY, null);
|
package/lib/updates/view.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
preflight,
|
|
9
9
|
readLog,
|
|
10
10
|
readPending,
|
|
11
|
+
readQueued,
|
|
11
12
|
readRun,
|
|
12
13
|
realRunDeps,
|
|
13
14
|
recordFinishedRun,
|
|
@@ -102,6 +103,8 @@ export async function updateView(
|
|
|
102
103
|
: { ok: true },
|
|
103
104
|
run,
|
|
104
105
|
pending,
|
|
106
|
+
managed: deps.managed(),
|
|
107
|
+
queued: readQueued(deps),
|
|
105
108
|
auto: await autoView(deps, check, refusal).then((auto) =>
|
|
106
109
|
mayUpdate
|
|
107
110
|
? auto
|
package/package.json
CHANGED
package/scripts/autodeploy
CHANGED
|
@@ -9,7 +9,11 @@
|
|
|
9
9
|
# Restarts are spaced AGENTOS_MIN_GAP seconds apart (an hour; 0 turns it
|
|
10
10
|
# off): a head that lands inside the gap waits and is deployed, whatever it
|
|
11
11
|
# has become by then, when the gap ends. A commit message with [deploy-now]
|
|
12
|
-
# (in any commit the deploy would take) skips the gap
|
|
12
|
+
# (in any commit the deploy would take) skips the gap, and so does the request
|
|
13
|
+
# file $state/now, which the in-app Update now button writes
|
|
14
|
+
# (lib/updates/run.ts requestDeploy): it also skips the settle wait, is removed
|
|
15
|
+
# once the deploy ran or origin/main is already deployed, and is ignored when
|
|
16
|
+
# older than 30 minutes. Each hold logs one line:
|
|
13
17
|
# HELD <sha> <seconds> left in the restart gap (since <last restart sha>)
|
|
14
18
|
# Another machine (a server) runs it with AGENTOS_PUBLISH=0: deploy
|
|
15
19
|
# only, and its own AGENTOS_RESTART. Only one machine publishes to npm.
|
|
@@ -54,12 +58,26 @@ done
|
|
|
54
58
|
mkdir "$state/lock" 2>/dev/null || exit 0
|
|
55
59
|
trap 'rmdir "$state/lock"' EXIT
|
|
56
60
|
|
|
61
|
+
# An "Update now" click (lib/updates/run.ts requestDeploy) leaves this file.
|
|
62
|
+
now_file="$state/now"
|
|
63
|
+
want_now=0
|
|
64
|
+
if [ -f "$now_file" ]; then
|
|
65
|
+
if [ -n "$(find "$now_file" -maxdepth 0 -mmin +30)" ]; then
|
|
66
|
+
rm -f "$now_file"
|
|
67
|
+
else
|
|
68
|
+
want_now=1
|
|
69
|
+
fi
|
|
70
|
+
fi
|
|
71
|
+
|
|
57
72
|
git fetch -q origin main
|
|
58
73
|
head=$(git rev-parse origin/main)
|
|
59
74
|
deployed=$(cat "$state/deployed" 2>/dev/null || true)
|
|
60
75
|
published=$(cat "$state/published" 2>/dev/null || true)
|
|
61
76
|
[ "${AGENTOS_PUBLISH:-1}" = 0 ] && published=$head
|
|
62
|
-
[ "$head" = "$deployed" ]
|
|
77
|
+
if [ "$head" = "$deployed" ]; then
|
|
78
|
+
rm -f "$now_file"
|
|
79
|
+
[ "$head" = "$published" ] && exit 0
|
|
80
|
+
fi
|
|
63
81
|
log() { echo "$(date '+%Y-%m-%d %-I:%M %p') $*"; }
|
|
64
82
|
|
|
65
83
|
# shellcheck source=lib/generated.sh
|
|
@@ -75,6 +93,7 @@ if [ "$head" != "$deployed" ] && [ -n "$deployed" ] &&
|
|
|
75
93
|
git merge -q --ff-only "$head" 2>/dev/null; then
|
|
76
94
|
deployed=$head
|
|
77
95
|
git rev-parse HEAD > "$state/deployed"
|
|
96
|
+
rm -f "$now_file"
|
|
78
97
|
[ "$head" = "$published" ] && exit 0
|
|
79
98
|
fi
|
|
80
99
|
|
|
@@ -82,7 +101,7 @@ fi
|
|
|
82
101
|
# minutes) waits for the next run, so a burst is one build and one restart.
|
|
83
102
|
# Its commit time is the merge's, so this never holds a deploy forever.
|
|
84
103
|
settle="${AGENTOS_SETTLE:-120}"
|
|
85
|
-
if [ "$head" != "$deployed" ] && [ "$settle" -gt 0 ] &&
|
|
104
|
+
if [ "$head" != "$deployed" ] && [ "$settle" -gt 0 ] && [ "$want_now" = 0 ] &&
|
|
86
105
|
[ $(( $(date +%s) - $(git log -1 --format=%ct "$head") )) -lt "$settle" ]; then
|
|
87
106
|
exit 0
|
|
88
107
|
fi
|
|
@@ -98,6 +117,8 @@ if [ "$head" != "$deployed" ] && [ "$gap" -gt 0 ]; then
|
|
|
98
117
|
msgs=$(git log --format=%B "${deployed:+$deployed..}$head" 2>/dev/null || git log -1 --format=%B "$head")
|
|
99
118
|
if printf '%s' "$msgs" | grep -qF '[deploy-now]'; then
|
|
100
119
|
log "Restart gap skipped for ${head:0:7}: [deploy-now]"
|
|
120
|
+
elif [ "$want_now" = 1 ]; then
|
|
121
|
+
log "Restart gap skipped for ${head:0:7}: update requested"
|
|
101
122
|
else
|
|
102
123
|
# Once per head, not once per minute, so the log counts holds.
|
|
103
124
|
if [ "$(cat "$state/held" 2>/dev/null)" != "$head" ]; then
|
|
@@ -136,6 +157,7 @@ if [ "$head" != "$deployed" ]; then
|
|
|
136
157
|
# The pull takes what landed while it waited, too.
|
|
137
158
|
git rev-parse HEAD > "$state/deployed"
|
|
138
159
|
date +%s > "$state/restarted"
|
|
160
|
+
rm -f "$now_file"
|
|
139
161
|
# A link into an older clone would run an old aos on this machine.
|
|
140
162
|
scripts/sync-cli-links || log "CLI link sync failed"
|
|
141
163
|
fi
|
|
@@ -143,6 +143,53 @@ describe("autodeploy restart gap", () => {
|
|
|
143
143
|
});
|
|
144
144
|
});
|
|
145
145
|
|
|
146
|
+
describe("autodeploy update request", () => {
|
|
147
|
+
const request = (t: ReturnType<typeof setup>, ageMs = 0) => {
|
|
148
|
+
fs.mkdirSync(t.state, { recursive: true });
|
|
149
|
+
const file = path.join(t.state, "now");
|
|
150
|
+
fs.writeFileSync(file, `${Date.now()}\n`);
|
|
151
|
+
const at = new Date(Date.now() - ageMs);
|
|
152
|
+
fs.utimesSync(file, at, at);
|
|
153
|
+
return file;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
it("skips the gap and the settle wait, then removes the request", () => {
|
|
157
|
+
const t = setup();
|
|
158
|
+
t.commit("a");
|
|
159
|
+
t.run();
|
|
160
|
+
const b = t.commit("b");
|
|
161
|
+
const file = request(t);
|
|
162
|
+
const out = t.run({ AGENTOS_SETTLE: "3600" });
|
|
163
|
+
expect(out).toMatch(
|
|
164
|
+
/Restart gap skipped for [0-9a-f]{7}: update requested/
|
|
165
|
+
);
|
|
166
|
+
expect(t.deployed()).toBe(b);
|
|
167
|
+
expect(t.restarts()).toBe(2);
|
|
168
|
+
expect(fs.existsSync(file)).toBe(false);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("is removed straight away when origin/main is already deployed", () => {
|
|
172
|
+
const t = setup();
|
|
173
|
+
t.commit("a");
|
|
174
|
+
t.run();
|
|
175
|
+
const file = request(t);
|
|
176
|
+
expect(t.run()).toBe("");
|
|
177
|
+
expect(fs.existsSync(file)).toBe(false);
|
|
178
|
+
expect(t.restarts()).toBe(1);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("ignores, and removes, a request older than 30 minutes", () => {
|
|
182
|
+
const t = setup();
|
|
183
|
+
t.commit("a");
|
|
184
|
+
t.run();
|
|
185
|
+
const b = t.commit("b");
|
|
186
|
+
const file = request(t, 31 * 60_000);
|
|
187
|
+
expect(t.run()).toMatch(/HELD/);
|
|
188
|
+
expect(t.deployed()).not.toBe(b);
|
|
189
|
+
expect(fs.existsSync(file)).toBe(false);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
146
193
|
describe("autodeploy changelog commits", () => {
|
|
147
194
|
const changelog = (t: ReturnType<typeof setup>, also?: string) => {
|
|
148
195
|
const work = path.join(t.root, "work");
|