@saadnvd1/agent-os 0.3.848 → 0.3.851

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 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(await updateView({ mayUpdate: true }), {
32
- status: 202,
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,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, { onError: toastError });
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 variant="outline" onClick={() => start("now")}>
100
+ <Button
101
+ variant={view.managed ? "default" : "outline"}
102
+ onClick={() => start("now")}
103
+ >
91
104
  Update now
92
105
  </Button>
93
- <Button onClick={() => start("idle")}>Update when idle</Button>
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&apos;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" || state === "pending") && (
142
- <UpdateActions view={view} />
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
- : null;
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.
@@ -2025,6 +2025,14 @@
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"
2028
2036
  }
2029
2037
  ]
2030
2038
  }
@@ -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;
@@ -94,7 +94,7 @@ export type UpdateRefusal =
94
94
  | "up_to_date"
95
95
  | "unknown"
96
96
  | "no_restart"
97
- | "managed"
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
+ }
@@ -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.managed() || deps.deployLocked())
365
+ if (deps.deployLocked())
362
366
  return {
363
- code: "managed",
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 } | ({ ok: false } & Refusal);
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);
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.848",
3
+ "version": "0.3.851",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
@@ -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. Each hold logs one line:
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" ] && [ "$head" = "$published" ] && exit 0
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");