@saadnvd1/agent-os 0.3.839 → 0.3.848

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.
Files changed (100) hide show
  1. package/app/api/approvals/route.ts +5 -1
  2. package/app/api/health/config/route.ts +2 -0
  3. package/app/api/health/route.ts +4 -2
  4. package/app/globals.css +6 -0
  5. package/components/Audits/AuditsPanel.tsx +42 -35
  6. package/components/Chat/quote-selection.test.ts +6 -0
  7. package/components/Chat/quote-selection.ts +13 -10
  8. package/components/Devices/ConnectCard.tsx +52 -56
  9. package/components/Devices/DeviceGroupRow.tsx +65 -0
  10. package/components/Devices/DeviceRow.tsx +2 -2
  11. package/components/Devices/DevicesPanel.tsx +63 -49
  12. package/components/Devices/NetworkSection.tsx +29 -26
  13. package/components/Devices/TailscaleCard.tsx +9 -10
  14. package/components/Devices/group.test.ts +66 -0
  15. package/components/Devices/group.ts +67 -0
  16. package/components/Health/HealthPanel.tsx +5 -81
  17. package/components/Health/MachineChecks.tsx +20 -0
  18. package/components/Health/SweepRuns.tsx +46 -51
  19. package/components/Health/Thresholds.tsx +50 -0
  20. package/components/Merge/GlobalMergeSection.tsx +5 -6
  21. package/components/Merge/MergeApprovalToggle.tsx +20 -14
  22. package/components/Merge/MergeSettingsFields.tsx +0 -2
  23. package/components/Merge/WorktreeSweepSection.tsx +15 -34
  24. package/components/Models/AgentRoleCard.tsx +7 -7
  25. package/components/Models/ModelSelect.tsx +14 -3
  26. package/components/Models/ModelsSection.tsx +42 -37
  27. package/components/Models/ProviderCard.tsx +1 -1
  28. package/components/Models/RoleModelSelect.tsx +15 -3
  29. package/components/Models/SubagentModels.tsx +60 -45
  30. package/components/PhoneNotify/PhoneNotifyForm.tsx +6 -13
  31. package/components/Recipes/RecipesPanel.tsx +20 -9
  32. package/components/Recipes/RunHistory.tsx +1 -1
  33. package/components/Schedules/SchedulesPanel.tsx +12 -10
  34. package/components/SessionList/GroupShelves.tsx +8 -3
  35. package/components/SessionList/OrchestratorPinRow.tsx +2 -2
  36. package/components/SessionList/RowParts.tsx +39 -35
  37. package/components/SessionList/RowRename.test.ts +86 -17
  38. package/components/SessionList/RowShell.tsx +2 -5
  39. package/components/SessionList/SessionRow.tsx +0 -1
  40. package/components/Settings/AppWideLimits.tsx +2 -2
  41. package/components/Settings/{NoApprovalsSection.test.ts → ApprovalsSection.test.ts} +61 -22
  42. package/components/Settings/ApprovalsSection.tsx +93 -0
  43. package/components/Settings/ExperimentsSection.test.ts +20 -4
  44. package/components/Settings/ExperimentsSection.tsx +54 -34
  45. package/components/Settings/GeneralSection.tsx +39 -0
  46. package/components/Settings/ListSection.test.ts +85 -0
  47. package/components/Settings/ListSection.tsx +132 -0
  48. package/components/Settings/NotificationsSection.tsx +31 -29
  49. package/components/Settings/PickerSetting.tsx +5 -1
  50. package/components/Settings/SettingRow.tsx +91 -49
  51. package/components/Settings/SettingsDialog.test.ts +31 -4
  52. package/components/Settings/SettingsDialog.tsx +22 -17
  53. package/components/Settings/ValueLabel.tsx +31 -0
  54. package/components/Settings/settings-fields.test.ts +81 -2
  55. package/components/Settings/settings-fields.tsx +28 -27
  56. package/components/Shares/SharedLinksPanel.tsx +11 -14
  57. package/components/Updates/AutoUpdates.tsx +12 -8
  58. package/components/Updates/UpdatesSection.tsx +18 -17
  59. package/components/Workspaces/HqFolderForm.tsx +21 -19
  60. package/components/Workspaces/McpServersForm.tsx +20 -28
  61. package/components/Workspaces/ProjectTiersForm.tsx +25 -12
  62. package/components/Workspaces/RuleEditor.tsx +86 -0
  63. package/components/Workspaces/RulesForm.test.ts +58 -31
  64. package/components/Workspaces/RulesForm.tsx +129 -58
  65. package/components/Workspaces/WorkspaceModeForm.tsx +3 -3
  66. package/components/Workspaces/WorkspaceModelsForm.tsx +40 -41
  67. package/components/Workspaces/WorkspacePanel.tsx +73 -0
  68. package/components/Workspaces/WorkspacePicker.tsx +44 -0
  69. package/components/Workspaces/WorkspaceSettings.test.ts +153 -0
  70. package/components/Workspaces/WorkspaceSettings.tsx +51 -79
  71. package/components/Workspaces/useSettingsWorkspace.ts +47 -0
  72. package/components/ui/switch.tsx +1 -1
  73. package/content/whats-new.json +40 -0
  74. package/data/approvals/index.ts +2 -2
  75. package/data/health/index.ts +3 -0
  76. package/hooks/useAutoSave.ts +18 -5
  77. package/hooks/useSettingsUrl.ts +6 -3
  78. package/lib/approvals/describe.ts +64 -0
  79. package/lib/away/deploy.ts +34 -7
  80. package/lib/health/config.ts +70 -17
  81. package/lib/models/plan-fresh.ts +61 -0
  82. package/lib/models/plan-step.ts +40 -24
  83. package/lib/models/providers.ts +1 -1
  84. package/lib/orchestrator/scope.ts +10 -0
  85. package/lib/recipes/settle.ts +22 -0
  86. package/lib/settings-url.ts +14 -2
  87. package/lib/updates/banner.ts +2 -0
  88. package/lib/updates/run.ts +9 -0
  89. package/lib/updates/view.ts +28 -6
  90. package/lib/worktree-deps/paths.ts +9 -4
  91. package/lib/worktrees.ts +3 -0
  92. package/package.json +1 -1
  93. package/scripts/autodeploy +11 -0
  94. package/scripts/autodeploy.test.ts +30 -0
  95. package/scripts/lib/update-verify.mjs +11 -0
  96. package/scripts/update-run.mjs +29 -0
  97. package/scripts/update-run.test.ts +62 -4
  98. package/components/Settings/NoApprovalsSection.tsx +0 -103
  99. package/components/Settings/SettingToggle.tsx +0 -36
  100. package/components/Settings/SetupSection.tsx +0 -46
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Whether the plan file in a task's worktree was written by this task's
3
+ * planning turn. A plan committed to the base branch is checked out with the
4
+ * worktree and reads exactly like a fresh one, so it is refused: tracked by
5
+ * git, older than the worktree's plan folder, or identical to the base's copy.
6
+ */
7
+
8
+ import fs from "fs";
9
+ import path from "path";
10
+ import { git } from "../git-exec";
11
+
12
+ export type PlanVerdict =
13
+ { ok: true; text: string } | { ok: false; why: string };
14
+
15
+ const PLAN_FILE = ".agentos/plan.md";
16
+
17
+ export async function checkPlanFile(
18
+ worktree: string,
19
+ base: string | null | undefined
20
+ ): Promise<PlanVerdict | null> {
21
+ const file = path.join(worktree, PLAN_FILE);
22
+ let text: string;
23
+ let mtime: number;
24
+ try {
25
+ text = fs.readFileSync(file, "utf8");
26
+ mtime = fs.statSync(file).mtimeMs;
27
+ } catch {
28
+ return null;
29
+ }
30
+ const quiet = (args: string[]) =>
31
+ git(worktree, args, { timeout: 15000 }).catch(() => null);
32
+ const tracked = await quiet(["ls-files", "--", PLAN_FILE]);
33
+ // Tracked and untouched is the base's copy; a tracked file the planner
34
+ // rewrote differs from HEAD and is its own.
35
+ if (
36
+ tracked?.trim() &&
37
+ (await quiet(["diff", "--quiet", "HEAD", "--", PLAN_FILE])) !== null
38
+ )
39
+ return { ok: false, why: "it is tracked by git and unchanged" };
40
+ // The plan folder's own .gitignore is written when the task starts.
41
+ const started = (() => {
42
+ try {
43
+ return fs.statSync(
44
+ path.join(worktree, path.dirname(PLAN_FILE), ".gitignore")
45
+ ).mtimeMs;
46
+ } catch {
47
+ return 0;
48
+ }
49
+ })();
50
+ if (started && mtime < started)
51
+ return { ok: false, why: "it was not written since the task started" };
52
+ if (base) {
53
+ for (const ref of [`origin/${base}`, base]) {
54
+ const theirs = await quiet(["show", `${ref}:${PLAN_FILE}`]);
55
+ if (theirs !== null && theirs === text)
56
+ return { ok: false, why: `it is identical to ${ref}'s copy` };
57
+ if (theirs !== null) break;
58
+ }
59
+ }
60
+ return { ok: true, text };
61
+ }
@@ -36,6 +36,7 @@ import {
36
36
  } from "../plans/features";
37
37
  import { advancePlan, setPlan } from "../db/session-parts";
38
38
  import { Owner } from "../owner";
39
+ import { checkPlanFile } from "./plan-fresh";
39
40
 
40
41
  export const PLAN_FILE = ".agentos/plan.md";
41
42
 
@@ -134,9 +135,13 @@ export function planNoteFor(
134
135
  file: string,
135
136
  next: string,
136
137
  again = "",
137
- after = ""
138
+ after = "",
139
+ stale = ""
138
140
  ): string {
139
141
  let head = `Task "${s.name}": no plan file was written${again}; ${next}${after}.`;
142
+ // A plan file left over from the base branch is not this task's plan.
143
+ if (stale)
144
+ return `Task "${s.name}": no usable plan was written (the plan file is stale: ${stale})${again}; ${next}${after}.`;
140
145
  try {
141
146
  const text = fs.readFileSync(file, "utf8");
142
147
  const summary = planSummary(text);
@@ -153,7 +158,12 @@ export function planNoteFor(
153
158
  }
154
159
 
155
160
  // Tells the orchestrator that started a task its plan is written, once.
156
- function announcePlan(s: Session, ref: string | null, retried = false): void {
161
+ function announcePlan(
162
+ s: Session,
163
+ ref: string | null,
164
+ retried = false,
165
+ stale = ""
166
+ ): void {
157
167
  // The machine that placed it announces the plan, from its own copy.
158
168
  if (s.plan_waits_for_caller) return;
159
169
  if (!orchestratorOf(s.started_by) && !s.held_by_device) return;
@@ -167,7 +177,7 @@ function announcePlan(s: Session, ref: string | null, retried = false): void {
167
177
  ? `implementing on ${ref}`
168
178
  : "waiting for approval, not implementing (approve_plan to go ahead, drop to reject)";
169
179
  const again = retried ? " (asked once more for it first)" : "";
170
- const head = planNoteFor(s, file, next, again, ref ? " anyway" : "");
180
+ const head = planNoteFor(s, file, next, again, ref ? " anyway" : "", stale);
171
181
  if (s.held_by_device) {
172
182
  setPlan(db, s.id, { plan_note: head });
173
183
  return;
@@ -215,12 +225,14 @@ async function handOffToPieces(s: Session): Promise<void> {
215
225
  }
216
226
  }
217
227
 
218
- function readPlan(s: Session): string | null {
219
- try {
220
- return fs.readFileSync(path.join(s.worktree_path!, PLAN_FILE), "utf8");
221
- } catch {
222
- return null;
223
- }
228
+ // The plan this task's planning turn wrote: null when there is none, and
229
+ // `stale` says why a file that is there was refused.
230
+ async function readPlan(
231
+ s: Session
232
+ ): Promise<{ text: string | null; stale: string }> {
233
+ const v = await checkPlanFile(s.worktree_path!, s.base_branch);
234
+ if (!v) return { text: null, stale: "" };
235
+ return v.ok ? { text: v.text, stale: "" } : { text: null, stale: v.why };
224
236
  }
225
237
 
226
238
  export const implementMessageId = (sessionId: string) =>
@@ -243,7 +255,8 @@ async function startImplementing(
243
255
  from: "planning" | "awaiting",
244
256
  send: Send,
245
257
  amendment?: string,
246
- retried = false
258
+ retried = false,
259
+ stale = ""
247
260
  ): Promise<string | null> {
248
261
  const sessionId = s.id;
249
262
  const routed = resolveRole("implement", {
@@ -251,7 +264,14 @@ async function startImplementing(
251
264
  override: taskOverride(s),
252
265
  });
253
266
  const id = implementMessageId(sessionId);
254
- const planText = readPlan(s);
267
+ // Only the planning turn's own file is checked for being this task's: a
268
+ // plan waiting for approval was checked when its turn ended.
269
+ const planText =
270
+ from === "planning"
271
+ ? (await readPlan(s)).text
272
+ : fs.existsSync(path.join(s.worktree_path!, PLAN_FILE))
273
+ ? fs.readFileSync(path.join(s.worktree_path!, PLAN_FILE), "utf8")
274
+ : null;
255
275
  // The branch was cut when the task was created; the plan may have waited.
256
276
  const fresh = await refreshBase({
257
277
  name: s.name,
@@ -313,12 +333,7 @@ async function startImplementing(
313
333
  if (nothingOwn) return true;
314
334
  enqueue(sessionId, {
315
335
  id,
316
- text: implementPrompt(
317
- fs.existsSync(path.join(s.worktree_path!, PLAN_FILE)),
318
- note,
319
- piece,
320
- baseNote
321
- ),
336
+ text: implementPrompt(planText !== null, note, piece, baseNote),
322
337
  origin: { kind: "decision", label: "Plan step" },
323
338
  });
324
339
  return true;
@@ -326,7 +341,7 @@ async function startImplementing(
326
341
  if (!claimed) return null;
327
342
  if (from === "planning") {
328
343
  try {
329
- announcePlan(s, routed.ref, retried);
344
+ announcePlan(s, routed.ref, retried, stale);
330
345
  } catch (e) {
331
346
  console.error("plan-done event failed:", e);
332
347
  }
@@ -411,7 +426,8 @@ export async function handoffAfterPlan(
411
426
  }
412
427
  // Mid-turn, the turn's own end decides.
413
428
  if (env.turnRunning?.()) return false;
414
- if (!fs.existsSync(path.join(s.worktree_path, PLAN_FILE))) {
429
+ const found = await readPlan(s);
430
+ if (found.text === null) {
415
431
  const w = stateOf(sessionId);
416
432
  const now = (env.now ?? Date.now)();
417
433
  const limit = env.waitMs ?? PLAN_BACKGROUND_WAIT_MS;
@@ -458,7 +474,7 @@ export async function handoffAfterPlan(
458
474
  // A plan-only task never goes on by itself, in No approvals mode too: it
459
475
  // was asked to stop and show the plan. Pieces that couldn't be registered
460
476
  // always hold a plan-first one: building on a guess is the bug.
461
- const planText = readPlan(s);
477
+ const planText = found.text;
462
478
  const pieceErrors =
463
479
  (planText && !s.plan_waits_for_caller
464
480
  ? splitOf(s, planText)?.errors
@@ -469,13 +485,14 @@ export async function handoffAfterPlan(
469
485
  "planning",
470
486
  send,
471
487
  undefined,
472
- retried
488
+ retried,
489
+ found.stale
473
490
  );
474
491
  return ref !== null;
475
492
  }
476
493
  if (!advancePlan(db, sessionId, "planning", "awaiting")) return false;
477
494
  try {
478
- announcePlan(s, null, retried);
495
+ announcePlan(s, null, retried, found.stale);
479
496
  } catch (e) {
480
497
  console.error("plan-done event failed:", e);
481
498
  }
@@ -490,8 +507,7 @@ export async function handoffAfterPlan(
490
507
  // The machine that placed a plan sends its own push.
491
508
  try {
492
509
  if (!s.plan_waits_for_caller) {
493
- const text = readPlan(s);
494
- const planned = text ? planFor(s, text) : null;
510
+ const planned = planText ? planFor(s, planText) : null;
495
511
  (await import("../notify/push/events")).pushPlanAwaiting(
496
512
  s.id,
497
513
  s.name,
@@ -190,5 +190,5 @@ export function describeSecretRef(ref: SecretRef | undefined): string {
190
190
  return `tokenvault ${ref.project}${ref.desc ? ` "${ref.desc}"` : ""}`;
191
191
  if (ref.kind === "keychain")
192
192
  return `Keychain ${ref.service}${ref.account ? ` (${ref.account})` : ""}`;
193
- return `$${ref.name}`;
193
+ return "an environment variable";
194
194
  }
@@ -36,6 +36,9 @@ async function cardText(task: Session): Promise<string> {
36
36
  return task.task_prompt ?? task.name;
37
37
  }
38
38
 
39
+ export const isTaskStatePath = (p: string) =>
40
+ p === ".agentos" || p.startsWith(".agentos/");
41
+
39
42
  export async function checkScope(input: {
40
43
  workspaceId: string;
41
44
  task: Session;
@@ -53,6 +56,13 @@ export async function checkScope(input: {
53
56
  // Claimed before the first await: written in the same tick as the
54
57
  // review's verdict, so no review call sees neither.
55
58
  putCheck({ ...base, status: "running", detail: null });
59
+ const taskState = input.files.filter((f) => isTaskStatePath(f.path));
60
+ if (taskState.length)
61
+ return putCheck({
62
+ ...base,
63
+ status: "block",
64
+ detail: `Commits task state under .agentos/ (${taskState.map((f) => f.path).join(", ")}); the plan folder is never part of a change.`,
65
+ });
56
66
  const amended = input.amendments?.length
57
67
  ? `\n\nScope changes recorded since the task started, oldest first; they win over the card, and a later one over an earlier one:\n${fence("amendments", input.amendments.map((a, i) => `${i + 1}. ${a}`).join("\n"))}`
58
68
  : "";
@@ -24,6 +24,8 @@ import {
24
24
  import { getRecipe, toRun } from "./store";
25
25
  import type { Recipe, RecipeRun, RunStatus } from "./types";
26
26
 
27
+ // A live run with nothing running behind it for this long is given up on.
28
+ export const LOST_RUN_MS = 24 * 60 * 60_000;
27
29
  // A session run that stops without a RESULT line settles from the model's
28
30
  // reading after it has been idle this long.
29
31
  export const IDLE_SETTLE_MS = 30 * 60_000;
@@ -379,6 +381,26 @@ async function settleOne(
379
381
  session,
380
382
  now
381
383
  );
384
+ // Nothing live behind it for a day (a terminal session, an archived one, a
385
+ // worker that's gone): it isn't still running, so it must not say so.
386
+ if (
387
+ Number.isFinite(startedMs) &&
388
+ now - startedMs > LOST_RUN_MS &&
389
+ state !== "running" &&
390
+ state !== "waiting"
391
+ )
392
+ return void finish(
393
+ run,
394
+ {
395
+ status: "failed",
396
+ result: line,
397
+ source: "state",
398
+ error: "Lost track of it",
399
+ summary: "Lost track of it",
400
+ },
401
+ session,
402
+ now
403
+ );
382
404
  const idle =
383
405
  state === "idle" || session.archived_at != null || state === null;
384
406
  if (idle && session.view === "chat") {
@@ -5,6 +5,7 @@
5
5
  export const SETTINGS_PARAM = "settings";
6
6
 
7
7
  export const SETTINGS_SECTIONS = [
8
+ "general",
8
9
  "merging",
9
10
  "approvals",
10
11
  "workspaces",
@@ -18,13 +19,19 @@ export const SETTINGS_SECTIONS = [
18
19
  "schedules",
19
20
  "audits",
20
21
  "health",
21
- "setup",
22
22
  "updates",
23
23
  "experiments",
24
24
  ] as const;
25
25
 
26
26
  export type SettingsSection = (typeof SETTINGS_SECTIONS)[number];
27
27
 
28
+ // Sections that were folded into another: an old link still lands in the
29
+ // right place.
30
+ export const SECTION_REDIRECTS: Record<string, SettingsSection> = {
31
+ setup: "health",
32
+ "no-approvals": "approvals",
33
+ };
34
+
28
35
  export interface SettingsLocation {
29
36
  open: boolean;
30
37
  // null: the list on a phone, the first section on a wider screen.
@@ -40,7 +47,12 @@ export const isSettingsSection = (v: unknown): v is SettingsSection =>
40
47
  export function settingsFromHref(href: string): SettingsLocation {
41
48
  const value = new URL(href, BASE).searchParams.get(SETTINGS_PARAM);
42
49
  if (value === null) return { open: false, section: null };
43
- return { open: true, section: isSettingsSection(value) ? value : null };
50
+ const section = isSettingsSection(value)
51
+ ? value
52
+ : value !== null && Object.hasOwn(SECTION_REDIRECTS, value)
53
+ ? SECTION_REDIRECTS[value]
54
+ : null;
55
+ return { open: true, section };
44
56
  }
45
57
 
46
58
  /** The same address with Settings set (or removed), as path+query+hash. */
@@ -94,6 +94,7 @@ export type UpdateRefusal =
94
94
  | "up_to_date"
95
95
  | "unknown"
96
96
  | "no_restart"
97
+ | "managed"
97
98
  | "disk"
98
99
  | "forbidden";
99
100
 
@@ -173,6 +174,7 @@ export function bannerState({
173
174
  return "updated";
174
175
  }
175
176
  if (view.pending) return "pending";
177
+ if (view.canUpdate.code === "managed") return "hidden";
176
178
  if (
177
179
  view.available &&
178
180
  view.bannerEnabled &&
@@ -85,6 +85,8 @@ export interface RunDeps {
85
85
  freeBytes: () => number | null;
86
86
  /** True when scripts/autodeploy deploys this checkout. */
87
87
  managed: () => boolean;
88
+ /** True while scripts/autodeploy is deploying (its lock directory exists). */
89
+ deployLocked: () => boolean;
88
90
  /** What a restart would interrupt, as short labels; empty when nothing. */
89
91
  busy: () => Promise<string[]>;
90
92
  /** The database the runner backs up first, and where to. */
@@ -214,6 +216,7 @@ export function realRunDeps(): RunDeps {
214
216
  return false;
215
217
  }
216
218
  },
219
+ deployLocked: () => fs.existsSync(agentosHome("autodeploy", "lock")),
217
220
  busy: busyLabels,
218
221
  dbFile: path.resolve(dbPath()),
219
222
  backupDir: agentosHome("backups"),
@@ -355,6 +358,12 @@ export async function preflight(
355
358
  error:
356
359
  "AgentOS isn't running from a git checkout, so it can't update itself.",
357
360
  };
361
+ if (deps.managed() || deps.deployLocked())
362
+ 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.",
366
+ };
358
367
  const run = readRun(deps);
359
368
  if (run && isActivePhase(run.phase))
360
369
  return {
@@ -1,7 +1,7 @@
1
1
  // The /api/updates payload: the cached check, the run, what's pending,
2
2
  // whether the caller may start an update, and what automatic updates are doing.
3
3
 
4
- import { isActivePhase, type UpdateView } from "./banner";
4
+ import { isActivePhase, type UpdateRun, type UpdateView } from "./banner";
5
5
  import { cachedCheck, checkForUpdate, reconcile } from "./check";
6
6
  import {
7
7
  autoView,
@@ -26,6 +26,27 @@ const redact = (lines: string[]) =>
26
26
  .replace(/\b[A-Za-z0-9_-]{32,}\b/g, "***")
27
27
  );
28
28
 
29
+ /**
30
+ * A failed or rolled-back run stops mattering once the running build has the
31
+ * commit it tried (autodeploy, or someone, put it or something newer there).
32
+ */
33
+ export async function runSuperseded(
34
+ run: UpdateRun | null,
35
+ deps: Pick<RunDeps, "git" | "buildId">
36
+ ): Promise<boolean> {
37
+ if (!run?.to || (run.phase !== "failed" && run.phase !== "rolled_back"))
38
+ return false;
39
+ const build = deps.buildId();
40
+ if (!/^[0-9a-f]{7,40}$/.test(build)) return false;
41
+ if (build === run.to) return true;
42
+ try {
43
+ await deps.git(["merge-base", "--is-ancestor", run.to, build]);
44
+ return true;
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
49
+
29
50
  export async function updateView(
30
51
  { mayUpdate }: { mayUpdate: boolean },
31
52
  deps: RunDeps = realRunDeps()
@@ -45,11 +66,12 @@ export async function updateView(
45
66
  .result;
46
67
  check = await reconcile(check, deps.check);
47
68
  // The log can carry paths and remote URLs; only a device that may update sees it.
48
- const run =
49
- stored &&
50
- (stored.phase === "failed" ||
51
- stored.phase === "built" ||
52
- stored.phase === "rolled_back")
69
+ const run = (await runSuperseded(stored, deps))
70
+ ? null
71
+ : stored &&
72
+ (stored.phase === "failed" ||
73
+ stored.phase === "built" ||
74
+ stored.phase === "rolled_back")
53
75
  ? {
54
76
  ...stored,
55
77
  log: mayUpdate
@@ -22,13 +22,16 @@ export async function realExisting(p: string): Promise<string> {
22
22
 
23
23
  export const STAGING_SUFFIX = ".aos-partial";
24
24
 
25
- const EXCLUDE_LINE = `*${STAGING_SUFFIX}`;
25
+ const STAGING_LINE = `*${STAGING_SUFFIX}`;
26
26
 
27
27
  // Staging dirs (`node_modules.aos-partial`) sit beside their target, where git
28
28
  // would list them as untracked work. Written once to the repo's shared
29
29
  // info/exclude, so it covers every worktree and no .gitignore is touched.
30
30
  // Best effort: outside a repository there is nothing to ignore it in.
31
- export async function ignoreStaging(dir: string): Promise<void> {
31
+ export const ignoreStaging = (dir: string) => addExcludes(dir, [STAGING_LINE]);
32
+
33
+ // Lines written once to the shared info/exclude of the repo `dir` is in.
34
+ export async function addExcludes(dir: string, lines: string[]): Promise<void> {
32
35
  try {
33
36
  const common = (
34
37
  await git(dir, ["rev-parse", "--git-common-dir"], { timeout: 5000 })
@@ -36,10 +39,12 @@ export async function ignoreStaging(dir: string): Promise<void> {
36
39
  if (!common) return;
37
40
  const file = path.join(path.resolve(dir, common), "info", "exclude");
38
41
  const current = await fs.promises.readFile(file, "utf8").catch(() => "");
39
- if (current.split(/\r?\n/).some((l) => l.trim() === EXCLUDE_LINE)) return;
42
+ const have = new Set(current.split(/\r?\n/).map((l) => l.trim()));
43
+ const missing = lines.filter((l) => !have.has(l));
44
+ if (!missing.length) return;
40
45
  await fs.promises.mkdir(path.dirname(file), { recursive: true });
41
46
  const lead = current && !current.endsWith("\n") ? "\n" : "";
42
- await fs.promises.appendFile(file, `${lead}${EXCLUDE_LINE}\n`);
47
+ await fs.promises.appendFile(file, `${lead}${missing.join("\n")}\n`);
43
48
  } catch {
44
49
  // never fails a setup
45
50
  }
package/lib/worktrees.ts CHANGED
@@ -17,6 +17,7 @@ import { refuseRealStateUnderTest } from "./test-home-guard";
17
17
  import { installWorktreeHooks } from "./worktree-hooks";
18
18
  import { gitResult, isGitError, worktreeList } from "./git-exec";
19
19
  import { addWorktree } from "./worktree-add";
20
+ import { addExcludes } from "./worktree-deps/paths";
20
21
  import {
21
22
  markRemoving,
22
23
  removalKey,
@@ -172,6 +173,8 @@ export async function createWorktree(
172
173
  err
173
174
  )
174
175
  );
176
+ // Task state (the plan) must never be committed from a worktree.
177
+ await addExcludes(worktreePath, [".agentos/"]);
175
178
  return {
176
179
  worktreePath,
177
180
  branchName,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.839",
3
+ "version": "0.3.848",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
@@ -33,6 +33,17 @@ mkdir -p "$state"
33
33
  # updates stand aside (lib/updates/run.ts managedByAutodeploy). Before the
34
34
  # lock: a long deploy still counts as managing it.
35
35
  touch "$state/heartbeat"
36
+ # An in-app update (lib/updates/run.ts) in progress owns this checkout: its
37
+ # pull would meet ours mid-way. Its state file is rewritten every 15 seconds,
38
+ # so an old one is a run that died. Quiet exit; the next minute retries.
39
+ for f in "${AGENTOS_UPDATE_STATE:-}" "${DB_PATH:+$(dirname "$DB_PATH")/agent-os-update.json}" \
40
+ "$HOME/.agent-os/agent-os-update.json" "$PWD/agent-os-update.json"; do
41
+ [ -n "$f" ] && [ -f "$f" ] || continue
42
+ [ -z "$(find "$f" -maxdepth 0 -mmin +30)" ] || continue
43
+ if grep -Eq '"phase" *: *"(starting|backing_up|pulling|installing|building|restarting|verifying|rolling_back)"' "$f"; then
44
+ exit 0
45
+ fi
46
+ done
36
47
  # A lock older than 30 minutes is from a run that died.
37
48
  for lock in lock publish.lock; do
38
49
  if [ -d "$state/$lock" ] && [ -n "$(find "$state/$lock" -maxdepth 0 -mmin +30)" ]; then
@@ -241,3 +241,33 @@ describe("autodeploy publish", () => {
241
241
  expect(fs.existsSync(lock)).toBe(false);
242
242
  });
243
243
  });
244
+
245
+ describe("autodeploy and an in-app update", () => {
246
+ it("exits quietly while an update is in progress, and deploys once it's done", () => {
247
+ const t = setup();
248
+ t.commit("a");
249
+ fs.mkdirSync(path.join(t.home, ".agent-os"), { recursive: true });
250
+ const file = path.join(t.home, ".agent-os/agent-os-update.json");
251
+ fs.writeFileSync(file, JSON.stringify({ id: "r", phase: "pulling" }));
252
+ expect(t.run()).toBe("");
253
+ expect(t.restarts()).toBe(0);
254
+ expect(fs.existsSync(path.join(t.state, "deployed"))).toBe(false);
255
+ // The heartbeat still says this machine is managed.
256
+ expect(fs.existsSync(path.join(t.state, "heartbeat"))).toBe(true);
257
+ fs.writeFileSync(file, JSON.stringify({ id: "r", phase: "done" }));
258
+ t.run();
259
+ expect(t.restarts()).toBe(1);
260
+ });
261
+
262
+ it("ignores the state file of a run that died long ago", () => {
263
+ const t = setup();
264
+ t.commit("a");
265
+ fs.mkdirSync(path.join(t.home, ".agent-os"), { recursive: true });
266
+ const file = path.join(t.home, ".agent-os/agent-os-update.json");
267
+ fs.writeFileSync(file, JSON.stringify({ id: "r", phase: "building" }));
268
+ const old = new Date(Date.now() - 60 * 60_000);
269
+ fs.utimesSync(file, old, old);
270
+ t.run();
271
+ expect(t.restarts()).toBe(1);
272
+ });
273
+ });
@@ -39,6 +39,9 @@ async function health(base, expect) {
39
39
  if (expect && build && build !== expect && !build.startsWith("v"))
40
40
  return {
41
41
  ok: false,
42
+ wrongBuild: true,
43
+ build,
44
+ startedAt,
42
45
  why: `it is running ${String(build).slice(0, 7)}, not ${expect.slice(0, 7)}`,
43
46
  };
44
47
  if (migrations && migrations.applied < migrations.known)
@@ -77,6 +80,14 @@ export async function verifyServer({ port, expect, env = process.env }) {
77
80
  for (let i = 0; i < checks; i++) {
78
81
  await sleep(every);
79
82
  const again = await health(base, expect);
83
+ // The expected build was up; now another one answers. Something else
84
+ // (a deploy job) restarted it: not a crash, and not ours to roll back over.
85
+ if (!again.ok && again.wrongBuild)
86
+ return {
87
+ ok: false,
88
+ otherRestart: true,
89
+ why: `something else restarted it into ${String(again.build).slice(0, 7)}, not ${expect.slice(0, 7)}`,
90
+ };
80
91
  if (!again.ok)
81
92
  return { ok: false, why: `it stopped answering: ${again.why}` };
82
93
  if (
@@ -9,11 +9,13 @@
9
9
  // AGENTOS_UPDATE_DB the database to back up first, into AGENTOS_UPDATE_BACKUPS
10
10
  // AGENTOS_UPDATE_VERIFY=0 skip the health check (tests)
11
11
  // AGENTOS_UPDATE_REDEPLOY the script to run (tests); default scripts/redeploy
12
+ // AGENTOS_AUTODEPLOY_DIR autodeploy's state dir (tests); default ~/.agent-os/autodeploy
12
13
  // AGENTOS_UPDATE_ROOT the checkout (tests); default this script's
13
14
  // Outcomes: done, built, build_failed (live server untouched), rolled_back,
14
15
  // broken (the rollback didn't come up either).
15
16
  import { execFileSync, spawn } from "node:child_process";
16
17
  import fs from "node:fs";
18
+ import os from "node:os";
17
19
  import path from "node:path";
18
20
  import readline from "node:readline";
19
21
  import { fileURLToPath } from "node:url";
@@ -149,6 +151,24 @@ function resetSource(from) {
149
151
  }
150
152
  }
151
153
 
154
+ /**
155
+ * scripts/autodeploy's "deployed" marker must not claim a build that isn't
156
+ * running: after a rollback it names the build that is. Only where autodeploy
157
+ * runs (its state directory exists).
158
+ */
159
+ function markDeployed(sha) {
160
+ const dir =
161
+ env.AGENTOS_AUTODEPLOY_DIR ||
162
+ path.join(os.homedir(), ".agent-os", "autodeploy");
163
+ if (!sha || !fs.existsSync(dir)) return;
164
+ try {
165
+ fs.writeFileSync(path.join(dir, "deployed"), `${sha}\n`);
166
+ say(`Recorded ${sha.slice(0, 7)} as the deployed build.`);
167
+ } catch (error) {
168
+ say(`Couldn't record the deployed build: ${error.message}`);
169
+ }
170
+ }
171
+
152
172
  async function main() {
153
173
  const heartbeat = setInterval(() => write({}), 15_000);
154
174
  write({ pid: process.pid });
@@ -184,6 +204,14 @@ async function main() {
184
204
  expect: env.AGENTOS_UPDATE_TO || head(),
185
205
  env,
186
206
  });
207
+ if (ok.otherRestart) {
208
+ say(`Not rolling back: ${ok.why}.`);
209
+ return finish(
210
+ "failed",
211
+ "interrupted",
212
+ `The update was built, but ${ok.why}. Nothing was rolled back; check which version is running.`
213
+ );
214
+ }
187
215
  if (!ok.ok) why = ok.why;
188
216
  }
189
217
  if (!why) return finish("done", "done");
@@ -205,6 +233,7 @@ async function main() {
205
233
  say(`The rolled-back version isn't healthy either: ${ok.why}`);
206
234
  }
207
235
  }
236
+ if (backOk) markDeployed(from);
208
237
  return backOk
209
238
  ? finish(
210
239
  "rolled_back",