@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.
- package/app/api/approvals/route.ts +5 -1
- package/app/api/health/config/route.ts +2 -0
- package/app/api/health/route.ts +4 -2
- package/app/globals.css +6 -0
- package/components/Audits/AuditsPanel.tsx +42 -35
- package/components/Chat/quote-selection.test.ts +6 -0
- package/components/Chat/quote-selection.ts +13 -10
- package/components/Devices/ConnectCard.tsx +52 -56
- package/components/Devices/DeviceGroupRow.tsx +65 -0
- package/components/Devices/DeviceRow.tsx +2 -2
- package/components/Devices/DevicesPanel.tsx +63 -49
- package/components/Devices/NetworkSection.tsx +29 -26
- package/components/Devices/TailscaleCard.tsx +9 -10
- package/components/Devices/group.test.ts +66 -0
- package/components/Devices/group.ts +67 -0
- package/components/Health/HealthPanel.tsx +5 -81
- package/components/Health/MachineChecks.tsx +20 -0
- package/components/Health/SweepRuns.tsx +46 -51
- package/components/Health/Thresholds.tsx +50 -0
- package/components/Merge/GlobalMergeSection.tsx +5 -6
- package/components/Merge/MergeApprovalToggle.tsx +20 -14
- package/components/Merge/MergeSettingsFields.tsx +0 -2
- package/components/Merge/WorktreeSweepSection.tsx +15 -34
- package/components/Models/AgentRoleCard.tsx +7 -7
- package/components/Models/ModelSelect.tsx +14 -3
- package/components/Models/ModelsSection.tsx +42 -37
- package/components/Models/ProviderCard.tsx +1 -1
- package/components/Models/RoleModelSelect.tsx +15 -3
- package/components/Models/SubagentModels.tsx +60 -45
- package/components/PhoneNotify/PhoneNotifyForm.tsx +6 -13
- package/components/Recipes/RecipesPanel.tsx +20 -9
- package/components/Recipes/RunHistory.tsx +1 -1
- package/components/Schedules/SchedulesPanel.tsx +12 -10
- package/components/SessionList/GroupShelves.tsx +8 -3
- package/components/SessionList/OrchestratorPinRow.tsx +2 -2
- package/components/SessionList/RowParts.tsx +39 -35
- package/components/SessionList/RowRename.test.ts +86 -17
- package/components/SessionList/RowShell.tsx +2 -5
- package/components/SessionList/SessionRow.tsx +0 -1
- package/components/Settings/AppWideLimits.tsx +2 -2
- package/components/Settings/{NoApprovalsSection.test.ts → ApprovalsSection.test.ts} +61 -22
- package/components/Settings/ApprovalsSection.tsx +93 -0
- package/components/Settings/ExperimentsSection.test.ts +20 -4
- package/components/Settings/ExperimentsSection.tsx +54 -34
- package/components/Settings/GeneralSection.tsx +39 -0
- package/components/Settings/ListSection.test.ts +85 -0
- package/components/Settings/ListSection.tsx +132 -0
- package/components/Settings/NotificationsSection.tsx +31 -29
- package/components/Settings/PickerSetting.tsx +5 -1
- package/components/Settings/SettingRow.tsx +91 -49
- package/components/Settings/SettingsDialog.test.ts +31 -4
- package/components/Settings/SettingsDialog.tsx +22 -17
- package/components/Settings/ValueLabel.tsx +31 -0
- package/components/Settings/settings-fields.test.ts +81 -2
- package/components/Settings/settings-fields.tsx +28 -27
- package/components/Shares/SharedLinksPanel.tsx +11 -14
- package/components/Updates/AutoUpdates.tsx +12 -8
- package/components/Updates/UpdatesSection.tsx +18 -17
- package/components/Workspaces/HqFolderForm.tsx +21 -19
- package/components/Workspaces/McpServersForm.tsx +20 -28
- package/components/Workspaces/ProjectTiersForm.tsx +25 -12
- package/components/Workspaces/RuleEditor.tsx +86 -0
- package/components/Workspaces/RulesForm.test.ts +58 -31
- package/components/Workspaces/RulesForm.tsx +129 -58
- package/components/Workspaces/WorkspaceModeForm.tsx +3 -3
- package/components/Workspaces/WorkspaceModelsForm.tsx +40 -41
- package/components/Workspaces/WorkspacePanel.tsx +73 -0
- package/components/Workspaces/WorkspacePicker.tsx +44 -0
- package/components/Workspaces/WorkspaceSettings.test.ts +153 -0
- package/components/Workspaces/WorkspaceSettings.tsx +51 -79
- package/components/Workspaces/useSettingsWorkspace.ts +47 -0
- package/components/ui/switch.tsx +1 -1
- package/content/whats-new.json +40 -0
- package/data/approvals/index.ts +2 -2
- package/data/health/index.ts +3 -0
- package/hooks/useAutoSave.ts +18 -5
- package/hooks/useSettingsUrl.ts +6 -3
- package/lib/approvals/describe.ts +64 -0
- package/lib/away/deploy.ts +34 -7
- package/lib/health/config.ts +70 -17
- package/lib/models/plan-fresh.ts +61 -0
- package/lib/models/plan-step.ts +40 -24
- package/lib/models/providers.ts +1 -1
- package/lib/orchestrator/scope.ts +10 -0
- package/lib/recipes/settle.ts +22 -0
- package/lib/settings-url.ts +14 -2
- package/lib/updates/banner.ts +2 -0
- package/lib/updates/run.ts +9 -0
- package/lib/updates/view.ts +28 -6
- package/lib/worktree-deps/paths.ts +9 -4
- package/lib/worktrees.ts +3 -0
- package/package.json +1 -1
- package/scripts/autodeploy +11 -0
- package/scripts/autodeploy.test.ts +30 -0
- package/scripts/lib/update-verify.mjs +11 -0
- package/scripts/update-run.mjs +29 -0
- package/scripts/update-run.test.ts +62 -4
- package/components/Settings/NoApprovalsSection.tsx +0 -103
- package/components/Settings/SettingToggle.tsx +0 -36
- 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
|
+
}
|
package/lib/models/plan-step.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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,
|
package/lib/models/providers.ts
CHANGED
|
@@ -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
|
|
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
|
: "";
|
package/lib/recipes/settle.ts
CHANGED
|
@@ -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") {
|
package/lib/settings-url.ts
CHANGED
|
@@ -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
|
-
|
|
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. */
|
package/lib/updates/banner.ts
CHANGED
|
@@ -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 &&
|
package/lib/updates/run.ts
CHANGED
|
@@ -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 {
|
package/lib/updates/view.ts
CHANGED
|
@@ -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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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}${
|
|
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
package/scripts/autodeploy
CHANGED
|
@@ -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 (
|
package/scripts/update-run.mjs
CHANGED
|
@@ -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",
|