@saadnvd1/agent-os 0.3.606 → 0.3.608

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.
@@ -8,5 +8,6 @@ export async function GET(request: NextRequest) {
8
8
  const refused = requireLocalTrust(request);
9
9
  if (refused) return refused;
10
10
  const work = inFlight();
11
- return NextResponse.json({ ...work, busy: work.reviews + work.setups > 0 });
11
+ const busy = work.reviews + work.setups + work.simulatorStarts > 0;
12
+ return NextResponse.json({ ...work, busy });
12
13
  }
@@ -15,7 +15,9 @@ vi.mock("@/lib/updates/check", async (orig) => ({
15
15
  cachedCheck: () => check,
16
16
  checkForUpdate: async () => ({ result: refreshed ?? check, throttled }),
17
17
  }));
18
- vi.mock("@/lib/busy", () => ({ inFlight: () => ({ reviews: 0, setups: 0 }) }));
18
+ vi.mock("@/lib/busy", () => ({
19
+ inFlight: () => ({ reviews: 0, setups: 0, simulatorStarts: 0 }),
20
+ }));
19
21
  vi.mock("@/lib/status/collect", () => ({
20
22
  collectStatuses: async () => ({
21
23
  statuses: {
@@ -13,7 +13,7 @@ import { cn } from "@/lib/utils";
13
13
  import { chatMeta, chatMetaActions } from "@/stores/chatMeta";
14
14
 
15
15
  const LEVEL_COLOR = {
16
- ok: "text-primary",
16
+ ok: "text-foreground/40",
17
17
  warn: "text-amber-500",
18
18
  high: "text-destructive",
19
19
  } as const;
@@ -94,7 +94,7 @@ export function ContextMeter({
94
94
  className={cn(
95
95
  "h-full rounded-full",
96
96
  level === "ok"
97
- ? "bg-primary"
97
+ ? "bg-foreground/40"
98
98
  : level === "warn"
99
99
  ? "bg-amber-500"
100
100
  : "bg-destructive"
@@ -13,12 +13,12 @@ import {
13
13
  import { cn } from "@/lib/utils";
14
14
 
15
15
  const TONE: Record<PrStageName, string> = {
16
- merged: "bg-purple-500/15 text-purple-700 dark:text-purple-400",
16
+ merged: "bg-green-500/15 text-green-700 dark:text-green-400",
17
17
  conflicts: "bg-amber-500/15 text-amber-700 dark:text-amber-400",
18
18
  "ci-failed": "bg-red-500/15 text-red-700 dark:text-red-400",
19
- "ci-running": "bg-blue-500/15 text-blue-700 dark:text-blue-400",
20
- "ci-settling": "bg-blue-500/15 text-blue-700 dark:text-blue-400",
21
- "review-running": "bg-blue-500/15 text-blue-700 dark:text-blue-400",
19
+ "ci-running": "bg-foreground/[0.06] text-muted-foreground",
20
+ "ci-settling": "bg-foreground/[0.06] text-muted-foreground",
21
+ "review-running": "bg-foreground/[0.06] text-muted-foreground",
22
22
  "review-blocked": "bg-red-500/15 text-red-700 dark:text-red-400",
23
23
  waiting: "bg-amber-500/15 text-amber-700 dark:text-amber-400",
24
24
  armed: "bg-green-500/15 text-green-700 dark:text-green-400",
@@ -15,21 +15,17 @@ export interface LiveInput {
15
15
 
16
16
  // A status badge's pill; the failed need is red text alone.
17
17
  const PILL: Record<LiveTone, string> = {
18
- blue: "bg-blue-500/15",
19
18
  amber: "bg-amber-500/15",
20
19
  red: "bg-red-500/15",
21
20
  green: "bg-green-500/15",
22
- purple: "bg-purple-500/15",
23
21
  primary: "bg-primary/15",
24
22
  muted: "bg-muted",
25
23
  };
26
24
 
27
25
  const TONE: Record<LiveTone, string> = {
28
- blue: "text-blue-700 dark:text-blue-400",
29
26
  amber: "text-amber-700 dark:text-amber-400",
30
27
  red: "text-red-700 dark:text-red-400",
31
28
  green: "text-green-700 dark:text-green-400",
32
- purple: "text-purple-700 dark:text-purple-400",
33
29
  primary: "text-primary",
34
30
  muted: "text-muted-foreground",
35
31
  };
@@ -1,52 +1,28 @@
1
- import type { StackItemView, StackStatus } from "@/lib/stacks/types";
1
+ import { GitPullRequest, Loader2 } from "lucide-react";
2
2
  import { cn } from "@/lib/utils";
3
-
4
- type Status = StackItemView["status"] | StackStatus;
5
-
6
- const amber = "bg-amber-500/15 text-amber-700 dark:text-amber-300";
7
- const green = "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300";
8
- const muted = "bg-foreground/[0.06] text-muted-foreground";
9
- const purple = "bg-primary/15 text-primary";
10
- const red = "bg-destructive/15 text-destructive";
11
-
12
- const CHIP: Record<Status, { label: string; tone: string }> = {
13
- planned: { label: "Planned", tone: muted },
14
- held: { label: "Held", tone: amber },
15
- starting: { label: "Starting", tone: purple },
16
- running: { label: "Working", tone: purple },
17
- pr: { label: "PR open", tone: purple },
18
- merged: { label: "Merged", tone: green },
19
- failed: { label: "Failed", tone: red },
20
- dropped: { label: "Dropped", tone: muted },
21
- done: { label: "Done", tone: green },
22
- excluded: { label: "Left out", tone: muted },
23
- paused: { label: "Paused", tone: amber },
24
- landing: { label: "Landing", tone: purple },
25
- landed: { label: "Landed", tone: green },
26
- };
3
+ import { chipFor, type ChipStatus } from "./status-chip";
27
4
 
28
5
  export function StatusChip({
29
6
  status,
30
7
  attention,
31
8
  stack,
32
9
  }: {
33
- status: Status;
10
+ status: ChipStatus;
34
11
  attention?: boolean;
35
- // A whole stack: "running" reads as Running, not Working.
36
12
  stack?: boolean;
37
13
  }) {
38
- const chip = attention
39
- ? { label: "Needs you", tone: amber }
40
- : stack && status === "running"
41
- ? { label: "Running", tone: purple }
42
- : CHIP[status];
14
+ const chip = chipFor(status, { attention, stack });
43
15
  return (
44
16
  <span
45
17
  className={cn(
46
- "inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-[11px] font-medium",
18
+ "inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium",
47
19
  chip.tone
48
20
  )}
49
21
  >
22
+ {chip.icon === "spinner" && (
23
+ <Loader2 className="h-3 w-3 animate-spin" aria-hidden />
24
+ )}
25
+ {chip.icon === "pr" && <GitPullRequest className="h-3 w-3" aria-hidden />}
50
26
  {chip.label}
51
27
  </span>
52
28
  );
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { CHIP, chipFor, type ChipStatus } from "./status-chip";
3
+
4
+ const coloured = (tone: string) => /amber|emerald|destructive/.test(tone);
5
+
6
+ describe("status chip colour", () => {
7
+ it.each(["starting", "running", "pr", "landing"] as ChipStatus[])(
8
+ "%s is neutral with an icon",
9
+ (s) => {
10
+ expect(coloured(CHIP[s].tone)).toBe(false);
11
+ expect(CHIP[s].tone).not.toContain("primary");
12
+ expect(CHIP[s].icon).not.toBeNull();
13
+ }
14
+ );
15
+
16
+ it.each(["merged", "done", "landed"] as ChipStatus[])("%s is green", (s) =>
17
+ expect(CHIP[s].tone).toContain("emerald")
18
+ );
19
+ it("failed is red", () => expect(CHIP.failed.tone).toContain("destructive"));
20
+ it.each(["held", "paused"] as ChipStatus[])("%s is amber", (s) =>
21
+ expect(CHIP[s].tone).toContain("amber")
22
+ );
23
+
24
+ it("needs-you overrides status with amber", () => {
25
+ const c = chipFor("running", { attention: true });
26
+ expect(c.label).toBe("Needs you");
27
+ expect(c.tone).toContain("amber");
28
+ });
29
+
30
+ it("a running stack reads Running, neutral", () => {
31
+ const c = chipFor("running", { stack: true });
32
+ expect(c.label).toBe("Running");
33
+ expect(coloured(c.tone)).toBe(false);
34
+ });
35
+
36
+ it("no status uses the accent", () => {
37
+ for (const c of Object.values(CHIP))
38
+ expect(c.tone).not.toContain("primary");
39
+ });
40
+ });
@@ -0,0 +1,44 @@
1
+ import type { StackItemView, StackStatus } from "@/lib/stacks/types";
2
+
3
+ export type ChipStatus = StackItemView["status"] | StackStatus;
4
+ export type ChipIcon = "spinner" | "pr" | null;
5
+
6
+ // Colour means "look at this": amber needs you, red failed, green final.
7
+ // Everything still moving on its own is neutral, and an icon carries the meaning.
8
+ const amber = "bg-amber-500/15 text-amber-700 dark:text-amber-300";
9
+ const green = "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300";
10
+ const muted = "bg-foreground/[0.06] text-muted-foreground";
11
+ const red = "bg-destructive/15 text-destructive";
12
+
13
+ interface Chip {
14
+ label: string;
15
+ tone: string;
16
+ icon: ChipIcon;
17
+ }
18
+
19
+ export const CHIP: Record<ChipStatus, Chip> = {
20
+ planned: { label: "Planned", tone: muted, icon: null },
21
+ held: { label: "Held", tone: amber, icon: null },
22
+ starting: { label: "Starting", tone: muted, icon: "spinner" },
23
+ running: { label: "Working", tone: muted, icon: "spinner" },
24
+ pr: { label: "PR open", tone: muted, icon: "pr" },
25
+ merged: { label: "Merged", tone: green, icon: null },
26
+ failed: { label: "Failed", tone: red, icon: null },
27
+ dropped: { label: "Dropped", tone: muted, icon: null },
28
+ done: { label: "Done", tone: green, icon: null },
29
+ excluded: { label: "Left out", tone: muted, icon: null },
30
+ paused: { label: "Paused", tone: amber, icon: null },
31
+ landing: { label: "Landing", tone: muted, icon: "spinner" },
32
+ landed: { label: "Landed", tone: green, icon: null },
33
+ };
34
+
35
+ export function chipFor(
36
+ status: ChipStatus,
37
+ opts: { attention?: boolean; stack?: boolean } = {}
38
+ ): Chip {
39
+ if (opts.attention) return { label: "Needs you", tone: amber, icon: null };
40
+ // A whole stack: "running" reads as Running, not Working.
41
+ if (opts.stack && status === "running")
42
+ return { label: "Running", tone: muted, icon: "spinner" };
43
+ return CHIP[status];
44
+ }
package/lib/busy.ts CHANGED
@@ -1,9 +1,14 @@
1
1
  import { db } from "./db";
2
+ import { runningStarts } from "./simulator";
2
3
 
3
- // What a restart would cut off and have to start over: reviews and task
4
- // setups run in the server. (Chat turns run in their own workers and
4
+ // What a restart would cut off and have to start over: reviews, task
5
+ // setups and simulator starts run in the server. (Chat turns run in their own workers and
5
6
  // outlive it.) Older than half an hour is stuck, not in flight.
6
- export function inFlight(): { reviews: number; setups: number } {
7
+ export function inFlight(): {
8
+ reviews: number;
9
+ setups: number;
10
+ simulatorStarts: number;
11
+ } {
7
12
  const count = (sql: string) => (db.prepare(sql).get() as { n: number }).n;
8
13
  return {
9
14
  reviews: count(
@@ -16,5 +21,6 @@ export function inFlight(): { reviews: number; setups: number } {
16
21
  WHERE setup_status = 'running' AND archived_at IS NULL
17
22
  AND created_at > datetime('now', '-30 minutes')`
18
23
  ),
24
+ simulatorStarts: runningStarts(),
19
25
  };
20
26
  }
@@ -8,8 +8,8 @@ export const RUN_STATUS_LABELS: Record<
8
8
  { label: string; tone: Tone }
9
9
  > = {
10
10
  queued: { label: "Queued", tone: "muted" },
11
- starting: { label: "Starting", tone: "primary" },
12
- running: { label: "Running", tone: "primary" },
11
+ starting: { label: "Starting", tone: "muted" },
12
+ running: { label: "Running", tone: "muted" },
13
13
  succeeded: { label: "Succeeded", tone: "success" },
14
14
  failed: { label: "Failed", tone: "destructive" },
15
15
  blocked: { label: "Needs you", tone: "warning" },
@@ -29,7 +29,7 @@ import {
29
29
  transcriptPath,
30
30
  writeTranscript,
31
31
  } from "../tasks/transcript";
32
- import { buildTaskBrief } from "../tasks/brief";
32
+ import { buildTaskBrief, declaredTest } from "../tasks/brief";
33
33
  import { syncTaskCardInBackground } from "../lumifyhub/task-cards";
34
34
  import { allocatePorts } from "../ports";
35
35
  import { loadProjectConfig, portBases } from "../project-config";
@@ -416,6 +416,7 @@ async function relaunch(s: Session, cwd: string): Promise<void> {
416
416
  ? buildTaskBrief({
417
417
  branch: s.branch_name,
418
418
  baseBranch: s.base_branch ?? "main",
419
+ test: declaredTest(cwd),
419
420
  })
420
421
  : undefined,
421
422
  });
@@ -6,14 +6,7 @@ import {
6
6
  } from "../tasks/pr-stage";
7
7
  import type { StatusBadge } from "./row-badge";
8
8
 
9
- export type LiveTone =
10
- | "blue"
11
- | "amber"
12
- | "red"
13
- | "green"
14
- | "purple"
15
- | "primary"
16
- | "muted";
9
+ export type LiveTone = "amber" | "red" | "green" | "primary" | "muted";
17
10
 
18
11
  export interface LivePart {
19
12
  text: string;
@@ -31,17 +24,17 @@ export const BADGE_TONE: Record<StatusBadge["kind"], LiveTone> = {
31
24
  failed: "red",
32
25
  stalled: "amber",
33
26
  starting: "muted",
34
- planning: "blue",
27
+ planning: "muted",
35
28
  "plan-ready": "primary",
36
29
  };
37
30
 
38
31
  export const PR_STAGE_TONE: Record<PrStageName, LiveTone> = {
39
- merged: "purple",
32
+ merged: "green",
40
33
  conflicts: "amber",
41
34
  "ci-failed": "red",
42
- "ci-running": "blue",
43
- "ci-settling": "blue",
44
- "review-running": "blue",
35
+ "ci-running": "muted",
36
+ "ci-settling": "muted",
37
+ "review-running": "muted",
45
38
  "review-blocked": "red",
46
39
  waiting: "amber",
47
40
  armed: "green",
@@ -58,7 +51,7 @@ export function liveParts(input: {
58
51
  }): LivePart[] {
59
52
  const parts: LivePart[] = [];
60
53
  if (input.compacting)
61
- parts.push({ text: "Compacting", tone: "blue", busy: true });
54
+ parts.push({ text: "Compacting", tone: "muted", busy: true });
62
55
  const b = input.badge;
63
56
  if (b)
64
57
  parts.push({
@@ -144,6 +144,11 @@ const tail = (file: string, lines = 15) => {
144
144
  // simulator can stop it.
145
145
  const starts = new Map<string, ChildProcess>();
146
146
 
147
+ // Simulator starts running now, for lib/busy: a restart kills them.
148
+ export function runningStarts(): number {
149
+ return starts.size;
150
+ }
151
+
147
152
  // The whole group: the project's build and Metro are children of the shell.
148
153
  export function killGroup(child: ChildProcess, signal: NodeJS.Signals) {
149
154
  try {
@@ -153,7 +158,7 @@ export function killGroup(child: ChildProcess, signal: NodeJS.Signals) {
153
158
  }
154
159
  }
155
160
 
156
- function runStart(
161
+ export function runStart(
157
162
  sessionId: string,
158
163
  command: string,
159
164
  cwd: string,
@@ -1,3 +1,5 @@
1
+ import { loadProjectConfig } from "../project-config";
2
+
1
3
  // A task stacked on another task's unmerged branch.
2
4
  export interface StackedOn {
3
5
  // The parent's PR number, when it has one.
@@ -19,17 +21,30 @@ function stackedLines(baseBranch: string, stack: StackedOn): string {
19
21
  - Never merge or rebase onto the default branch yourself. When the parent merges, AgentOS rebases your branch and retargets your PR, then tells you; re-run your checks when it does. AgentOS reviews your PR itself; a review of your head still counts for the head its restack left, and a push after that gets a new one.${also}`;
20
22
  }
21
23
 
24
+ // The project's declared test command, read from the checkout the task runs in.
25
+ export function declaredTest(cwd: string): string | undefined {
26
+ return loadProjectConfig(cwd).config.test || undefined;
27
+ }
28
+
29
+ function checksLine(test?: string): string {
30
+ return test
31
+ ? `Run exactly this to check your work: \`${test}\`. CI runs the full suite on your pull request, so don't run it locally.`
32
+ : "Run the project's own checks (typecheck, tests, lint) when they exist and keep them passing.";
33
+ }
34
+
22
35
  // The system brief every task agent runs under. It finishes by opening a PR
23
36
  // and stopping: merging is the human's sign-off, never the agent's.
24
37
  export function buildTaskBrief(opts: {
25
38
  branch: string;
26
39
  baseBranch: string;
27
40
  stack?: StackedOn;
41
+ // The project's `test` from agentos.json, when it declares one.
42
+ test?: string;
28
43
  }): string {
29
44
  return `You are running as an AgentOS background task, alone, in an isolated git worktree.
30
45
 
31
46
  - Branch: ${opts.branch} (based on ${opts.baseBranch}). Stay in this directory and on this branch.
32
- - Do the task fully. Run the project's own checks (typecheck, tests, lint) when they exist and keep them passing.
47
+ - Do the task fully. ${checksLine(opts.test)}
33
48
  - When the work is done, commit with a conventional commit message, run \`git push -u origin ${opts.branch}\` and open a pull request against ${opts.baseBranch} with \`gh pr create\`, using a clear title and a short body that says what changed and how you verified it. Then stop and wait.
34
49
  - You don't need to review it yourself: AgentOS runs an independent review of the PR's exact head commit before it can merge, and the orchestrator sends you any blocking findings to fix. For a risky change you may still run \`/do-code-review\` (or \`/code-review\`) before the PR.
35
50
  - Never merge the pull request, never push to ${opts.baseBranch}, and never delete branches or worktrees. A human reviews and merges.${opts.stack ? stackedLines(opts.baseBranch, opts.stack) : ""}
@@ -23,7 +23,7 @@ import { resolveModelForAgent } from "../model-catalog";
23
23
  import { providerFor } from "../models/resolve";
24
24
  import { preparePlanDir, PLAN_FILE } from "../models/plan-step";
25
25
  import { carryTaskState } from "./move-carry";
26
- import { buildTaskBrief } from "./brief";
26
+ import { buildTaskBrief, declaredTest } from "./brief";
27
27
  import { expandHome } from "./session";
28
28
  import { ensureProject } from "./project-ref";
29
29
  import { checkoutBranchWorktree } from "./branch-worktree";
@@ -136,7 +136,11 @@ async function arrive(
136
136
  ? bundle.model
137
137
  : resolveModelForAgent("claude", bundle.model || project.default_model);
138
138
  const baseBranch = bundle.baseBranch ?? "main";
139
- const brief = buildTaskBrief({ branch: bundle.branch, baseBranch });
139
+ const brief = buildTaskBrief({
140
+ branch: bundle.branch,
141
+ baseBranch,
142
+ test: declaredTest(worktreePath),
143
+ });
140
144
  // Checked and written with no await between, so two can't both pass.
141
145
  db.transaction(() => {
142
146
  refuseBusyBranch(project.id, bundle.branch);
@@ -33,7 +33,7 @@ import { agentLabel } from "../agent-options";
33
33
  import { supportsChat } from "../chat/capabilities";
34
34
  import { AGENT_DEFAULT_MODEL } from "../providers/registry";
35
35
  import { statusDetector } from "../status-detector";
36
- import { buildTaskBrief, type StackedOn } from "./brief";
36
+ import { buildTaskBrief, declaredTest, type StackedOn } from "./brief";
37
37
  import {
38
38
  attachTaskCard,
39
39
  inBackground,
@@ -354,6 +354,7 @@ async function startTask(
354
354
  branch: wt.branchName,
355
355
  baseBranch,
356
356
  stack: opts.base?.stack,
357
+ test: declaredTest(wt.worktreePath),
357
358
  }),
358
359
  naming.source,
359
360
  view,
package/lib/tasks/move.ts CHANGED
@@ -13,7 +13,7 @@ import { db, type Session } from "../db";
13
13
  import { getProject } from "../projects";
14
14
  import { endTmuxSession } from "../tmux/end";
15
15
  import { launchClaude } from "../agents/launch";
16
- import { buildTaskBrief } from "./brief";
16
+ import { buildTaskBrief, declaredTest } from "./brief";
17
17
  import { git as runGit } from "../git-exec";
18
18
  import { getTaskSession, isoUTC } from "./session";
19
19
  import { projectRef } from "./project-ref";
@@ -308,6 +308,7 @@ async function relaunch(session: Session, note?: string): Promise<void> {
308
308
  ? buildTaskBrief({
309
309
  branch: session.branch_name,
310
310
  baseBranch: session.base_branch ?? "main",
311
+ test: declaredTest(session.working_directory),
311
312
  })
312
313
  : undefined,
313
314
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.606",
3
+ "version": "0.3.608",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
@@ -4,6 +4,11 @@
4
4
  # background. Chat turns live in workers and outlive the restart; reviews
5
5
  # and task setups don't, so the restart waits up to 10 minutes for them.
6
6
  # sm add agentos-autodeploy "scripts/autodeploy" --cron "* * * * *"
7
+ # Restarts are spaced AGENTOS_MIN_GAP seconds apart (10 minutes; 0 turns it
8
+ # off): a head that lands inside the gap waits and is deployed, whatever it
9
+ # has become by then, when the gap ends. A commit message with [deploy-now]
10
+ # (in any commit the deploy would take) skips the gap. Each hold logs one line:
11
+ # HELD <sha> <seconds> left in the restart gap (since <last restart sha>)
7
12
  # Another machine (a server) runs it with AGENTOS_PUBLISH=0: deploy
8
13
  # only, and its own AGENTOS_RESTART. Only one machine publishes to npm.
9
14
  set -euo pipefail
@@ -44,6 +49,28 @@ if [ "$head" != "$deployed" ] && [ "$settle" -gt 0 ] &&
44
49
  exit 0
45
50
  fi
46
51
 
52
+ # Restarts kill simulator starts and reviews in flight, so they are spaced
53
+ # apart. The head isn't recorded as deployed while it waits, so it (or a
54
+ # newer one) goes out as soon as the gap is over; nothing is dropped.
55
+ gap="${AGENTOS_MIN_GAP:-600}"
56
+ if [ "$head" != "$deployed" ] && [ "$gap" -gt 0 ]; then
57
+ last=$(cat "$state/restarted" 2>/dev/null || echo 0)
58
+ left=$(( last + gap - $(date +%s) ))
59
+ if [ "$left" -gt 0 ]; then
60
+ msgs=$(git log --format=%B "${deployed:+$deployed..}$head" 2>/dev/null || git log -1 --format=%B "$head")
61
+ if printf '%s' "$msgs" | grep -qF '[deploy-now]'; then
62
+ log "Restart gap skipped for ${head:0:7}: [deploy-now]"
63
+ else
64
+ # Once per head, not once per minute, so the log counts holds.
65
+ if [ "$(cat "$state/held" 2>/dev/null)" != "$head" ]; then
66
+ echo "$head" > "$state/held"
67
+ log "HELD ${head:0:7} ${left}s left in the ${gap}s restart gap"
68
+ fi
69
+ exit 0
70
+ fi
71
+ fi
72
+ fi
73
+
47
74
  # A deploy that can't land says so once per commit, rather than retrying
48
75
  # quietly every minute (uncommitted edits in this checkout block the pull).
49
76
  alert() {
@@ -70,6 +97,7 @@ if [ "$head" != "$deployed" ]; then
70
97
  fi
71
98
  # The pull takes what landed while it waited, too.
72
99
  git rev-parse HEAD > "$state/deployed"
100
+ date +%s > "$state/restarted"
73
101
  fi
74
102
 
75
103
  # Publishing waits minutes for npm to serve a version, so it runs on its own
@@ -0,0 +1,106 @@
1
+ import { execFileSync } from "child_process";
2
+ import * as fs from "fs";
3
+ import * as os from "os";
4
+ import * as path from "path";
5
+ import { describe, expect, it } from "vitest";
6
+
7
+ // Runs the real scripts/autodeploy in a scratch clone with a stub redeploy
8
+ // (a pull) and wait-quiet, against a scratch origin and HOME.
9
+ const script = path.resolve(__dirname, "autodeploy");
10
+ const sh = (cwd: string, ...args: string[]) =>
11
+ execFileSync("git", args, { cwd, encoding: "utf-8" }).trim();
12
+
13
+ function setup() {
14
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "aos-autodeploy-"));
15
+ const origin = path.join(root, "origin.git");
16
+ const work = path.join(root, "work");
17
+ const clone = path.join(root, "live");
18
+ const home = path.join(root, "home");
19
+ fs.mkdirSync(home);
20
+ sh(root, "init", "-q", "--bare", "-b", "main", origin);
21
+ sh(root, "clone", "-q", origin, work);
22
+ fs.mkdirSync(path.join(work, "scripts"));
23
+ fs.copyFileSync(script, path.join(work, "scripts/autodeploy"));
24
+ fs.writeFileSync(path.join(work, "scripts/wait-quiet"), "#!/bin/sh\n");
25
+ fs.writeFileSync(
26
+ path.join(work, "scripts/redeploy"),
27
+ "#!/bin/sh\ngit pull -q origin main && echo x >> ../restarts\n"
28
+ );
29
+ fs.chmodSync(path.join(work, "scripts/autodeploy"), 0o755);
30
+ fs.chmodSync(path.join(work, "scripts/wait-quiet"), 0o755);
31
+ fs.chmodSync(path.join(work, "scripts/redeploy"), 0o755);
32
+ const commit = (msg: string) => {
33
+ fs.appendFileSync(path.join(work, "f"), msg + "\n");
34
+ sh(work, "add", "-A");
35
+ sh(work, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", msg);
36
+ sh(work, "push", "-q", "origin", "main");
37
+ return sh(work, "rev-parse", "HEAD");
38
+ };
39
+ commit("first");
40
+ sh(root, "clone", "-q", origin, clone);
41
+ const state = path.join(home, ".agent-os/autodeploy");
42
+ const run = () =>
43
+ execFileSync(path.join(clone, "scripts/autodeploy"), [], {
44
+ cwd: clone,
45
+ encoding: "utf-8",
46
+ env: {
47
+ ...process.env,
48
+ HOME: home,
49
+ AGENTOS_PUBLISH: "0",
50
+ AGENTOS_SETTLE: "0",
51
+ AGENTOS_RESTART: "true",
52
+ },
53
+ });
54
+ const restarts = () => {
55
+ try {
56
+ return (
57
+ fs.readFileSync(path.join(root, "restarts"), "utf-8").split("\n")
58
+ .length - 1
59
+ );
60
+ } catch {
61
+ return 0;
62
+ }
63
+ };
64
+ const deployed = () =>
65
+ fs.readFileSync(path.join(state, "deployed"), "utf-8").trim();
66
+ return { commit, run, restarts, deployed, state };
67
+ }
68
+
69
+ describe("autodeploy restart gap", () => {
70
+ it("holds a second deploy inside the gap, then deploys the newest head once it ends", () => {
71
+ const t = setup();
72
+ const a = t.commit("a");
73
+ expect(t.run()).toContain("Deploying");
74
+ expect(t.deployed()).toBe(a);
75
+ expect(t.restarts()).toBe(1);
76
+
77
+ t.commit("b");
78
+ const c = t.commit("c");
79
+ const held = t.run();
80
+ expect(held).toMatch(/HELD [0-9a-f]{7} \d+s left in the 600s restart gap/);
81
+ expect(t.deployed()).toBe(a);
82
+ expect(t.restarts()).toBe(1);
83
+ // Still waiting a minute later: no second log line for the same head.
84
+ expect(t.run()).toBe("");
85
+
86
+ // The gap ends: the head that was waiting goes out, once.
87
+ fs.writeFileSync(
88
+ path.join(t.state, "restarted"),
89
+ `${Math.floor(Date.now() / 1000) - 601}\n`
90
+ );
91
+ expect(t.run()).toContain("Deploying");
92
+ expect(t.deployed()).toBe(c);
93
+ expect(t.restarts()).toBe(2);
94
+ expect(t.run()).toBe("");
95
+ });
96
+
97
+ it("skips the gap for a [deploy-now] commit", () => {
98
+ const t = setup();
99
+ t.commit("a");
100
+ t.run();
101
+ const urgent = t.commit("fix: login loop [deploy-now]");
102
+ expect(t.run()).toContain("[deploy-now]");
103
+ expect(t.deployed()).toBe(urgent);
104
+ expect(t.restarts()).toBe(2);
105
+ });
106
+ });
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bash
2
2
  # Waits until the AgentOS server on $PORT has nothing in flight that a
3
- # restart would cut off (reviews, task setups: GET /api/busy), for at most
3
+ # restart would cut off (reviews, task setups, simulator starts: GET /api/busy), for at most
4
4
  # AGENTOS_QUIET_WAIT seconds; then returns, so a deploy always lands. A
5
5
  # server that doesn't answer (down, or a build without /api/busy) is quiet.
6
6
  set -uo pipefail