@saadnvd1/agent-os 0.3.836 → 0.3.845
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/fresh-start/route.test.ts +33 -0
- package/app/api/fresh-start/route.ts +3 -0
- package/app/api/health/route.ts +4 -2
- package/components/Devices/DeviceRow.tsx +83 -69
- package/components/Devices/PasskeysSection.test.ts +89 -0
- package/components/Devices/PasskeysSection.tsx +36 -26
- package/components/Health/HealthPanel.tsx +41 -16
- package/components/Merge/GlobalMergeSection.tsx +2 -5
- package/components/Merge/MergeSettingsFields.tsx +39 -77
- package/components/Merge/ProjectMergeSection.tsx +2 -6
- package/components/Merge/WorktreeSweepSection.tsx +40 -14
- package/components/Models/AgentRoleCard.tsx +61 -66
- package/components/Models/AgentRolesSection.tsx +5 -8
- package/components/Models/ModelsSection.tsx +7 -18
- package/components/Models/PriceInput.tsx +59 -0
- package/components/PhoneNotify/PhoneNotifyForm.tsx +37 -58
- 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 +61 -0
- package/components/Settings/ContextSection.tsx +96 -0
- package/components/Settings/ExperimentsSection.test.ts +9 -7
- package/components/Settings/ExperimentsSection.tsx +22 -23
- package/components/Settings/InlineConfirm.tsx +51 -0
- package/components/Settings/OwnerNameRow.tsx +10 -40
- package/components/Settings/PickerSetting.tsx +59 -0
- package/components/Settings/SettingRow.tsx +168 -0
- package/components/Settings/SettingsDialog.test.ts +1 -0
- package/components/Settings/SettingsDialog.tsx +47 -2
- package/components/Settings/settings-fields.test.ts +103 -0
- package/components/Settings/settings-fields.tsx +428 -0
- package/components/Shares/SharedLinksPanel.tsx +62 -53
- package/components/Workspaces/HqFolderForm.tsx +22 -53
- package/components/Workspaces/McpServersForm.tsx +21 -28
- package/components/Workspaces/ProjectTiersForm.tsx +28 -13
- package/components/Workspaces/RuleEditor.tsx +86 -0
- package/components/Workspaces/RulesForm.test.ts +154 -0
- package/components/Workspaces/RulesForm.tsx +116 -117
- package/components/Workspaces/WorkspaceModeForm.tsx +3 -3
- package/components/Workspaces/WorkspaceModelsForm.tsx +36 -36
- 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 +63 -52
- package/components/Workspaces/useSettingsWorkspace.ts +47 -0
- package/content/whats-new.json +40 -0
- package/data/fresh-start/index.ts +1 -1
- package/data/workspaces/index.ts +9 -0
- package/hooks/useAutoSave.test.ts +116 -0
- package/hooks/useAutoSave.ts +98 -0
- package/lib/away/deploy.ts +34 -7
- package/lib/away/window-limits.ts +3 -1
- package/lib/chat/refresh.ts +10 -6
- package/lib/chat/stall-limits.ts +3 -1
- package/lib/models/plan-fresh.ts +61 -0
- package/lib/models/plan-step.ts +40 -24
- package/lib/orchestrator/scope.ts +10 -0
- package/lib/settings/defaults.ts +27 -0
- package/lib/settings/parse.ts +17 -0
- package/lib/settings-url.ts +1 -0
- 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/worktree-sweep/limits.ts +3 -0
- package/lib/worktree-sweep/settings.ts +4 -2
- 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/Away/AwayWindowForm.tsx +0 -91
- package/components/Workspaces/FreshStartForm.tsx +0 -177
- package/components/Workspaces/StallForm.tsx +0 -101
- package/components/Workspaces/TaskLimitForm.tsx +0 -84
- package/components/Workspaces/fresh-start.ts +0 -19
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
|
}
|
|
@@ -5,10 +5,12 @@
|
|
|
5
5
|
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
import { db } from "../db";
|
|
8
|
+
import { SETTING_DEFAULTS } from "../settings/defaults";
|
|
9
|
+
import { MAX_SWEEP_DAYS } from "./limits";
|
|
8
10
|
import type { ResolvedDays } from "./types";
|
|
9
11
|
|
|
10
|
-
export const DEFAULT_SWEEP_DAYS =
|
|
11
|
-
export
|
|
12
|
+
export const DEFAULT_SWEEP_DAYS = SETTING_DEFAULTS.sweepDays;
|
|
13
|
+
export { MAX_SWEEP_DAYS } from "./limits";
|
|
12
14
|
|
|
13
15
|
const GLOBAL_KEY = "worktree_sweep";
|
|
14
16
|
const projectKey = (projectId: string) => `worktree_sweep.project.${projectId}`;
|
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",
|
|
@@ -78,20 +78,27 @@ describe("scripts/update-run.mjs", () => {
|
|
|
78
78
|
|
|
79
79
|
// A stand-in for the restarted server: its build comes from a file the fake
|
|
80
80
|
// redeploy writes, and its startedAt can be made to change (a crash loop).
|
|
81
|
-
async function fakeServer(
|
|
81
|
+
async function fakeServer(
|
|
82
|
+
opts: { restarts?: boolean; switchAt?: number } = {}
|
|
83
|
+
) {
|
|
82
84
|
let n = 0;
|
|
85
|
+
let calls = 0;
|
|
83
86
|
const server = http.createServer((req, res) => {
|
|
84
|
-
|
|
87
|
+
let build = fs.readFileSync(path.join(dir, "build"), "utf8").trim();
|
|
85
88
|
res.setHeader("content-type", "application/json");
|
|
86
|
-
if (req.url === "/api/updates/health")
|
|
89
|
+
if (req.url === "/api/updates/health") {
|
|
90
|
+
// Something else (a deploy job) restarts it into another build.
|
|
91
|
+
const other = opts.switchAt !== undefined && calls++ >= opts.switchAt;
|
|
92
|
+
if (other) build = "other";
|
|
87
93
|
return res.end(
|
|
88
94
|
JSON.stringify({
|
|
89
95
|
ok: true,
|
|
90
96
|
build,
|
|
91
|
-
startedAt: opts.restarts ? `s${n++}` : "s",
|
|
97
|
+
startedAt: opts.restarts ? `s${n++}` : other ? "s-other" : "s",
|
|
92
98
|
migrations: { known: 3, applied: 3 },
|
|
93
99
|
})
|
|
94
100
|
);
|
|
101
|
+
}
|
|
95
102
|
res.end("{}");
|
|
96
103
|
});
|
|
97
104
|
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
|
|
@@ -170,6 +177,57 @@ describe("scripts/update-run.mjs checks the new server", () => {
|
|
|
170
177
|
}
|
|
171
178
|
});
|
|
172
179
|
|
|
180
|
+
it("a restart into a different build is left alone: no rollback, nothing marked", async () => {
|
|
181
|
+
fs.writeFileSync(path.join(dir, "next-build"), "new");
|
|
182
|
+
fs.writeFileSync(path.join(dir, "rollback-works"), "");
|
|
183
|
+
const srv = await fakeServer({ switchAt: 2 });
|
|
184
|
+
try {
|
|
185
|
+
const s = await go(REDEPLOY, "x", verifying(srv.port, "new"));
|
|
186
|
+
expect(s).toMatchObject({ phase: "failed", outcome: "interrupted" });
|
|
187
|
+
expect(s.error).toContain("something else restarted it");
|
|
188
|
+
expect(rolled()).toBeNull();
|
|
189
|
+
} finally {
|
|
190
|
+
await srv.close();
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("a rollback records the build it went back to as autodeploy's deployed marker", async () => {
|
|
195
|
+
fs.writeFileSync(path.join(dir, "next-build"), "stale");
|
|
196
|
+
fs.writeFileSync(path.join(dir, "rollback-works"), "");
|
|
197
|
+
const state = path.join(dir, "autodeploy");
|
|
198
|
+
fs.mkdirSync(state);
|
|
199
|
+
fs.writeFileSync(path.join(state, "deployed"), "f5054a0\n");
|
|
200
|
+
const srv = await fakeServer();
|
|
201
|
+
try {
|
|
202
|
+
const s = await go(REDEPLOY, "x", {
|
|
203
|
+
...verifying(srv.port, "new"),
|
|
204
|
+
AGENTOS_AUTODEPLOY_DIR: state,
|
|
205
|
+
});
|
|
206
|
+
expect(s.outcome).toBe("rolled_back");
|
|
207
|
+
expect(fs.readFileSync(path.join(state, "deployed"), "utf8").trim()).toBe(
|
|
208
|
+
"a"
|
|
209
|
+
);
|
|
210
|
+
} finally {
|
|
211
|
+
await srv.close();
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("a rollback on a machine without autodeploy creates no marker", async () => {
|
|
216
|
+
fs.writeFileSync(path.join(dir, "next-build"), "stale");
|
|
217
|
+
fs.writeFileSync(path.join(dir, "rollback-works"), "");
|
|
218
|
+
const state = path.join(dir, "autodeploy");
|
|
219
|
+
const srv = await fakeServer();
|
|
220
|
+
try {
|
|
221
|
+
await go(REDEPLOY, "x", {
|
|
222
|
+
...verifying(srv.port, "new"),
|
|
223
|
+
AGENTOS_AUTODEPLOY_DIR: state,
|
|
224
|
+
});
|
|
225
|
+
expect(fs.existsSync(state)).toBe(false);
|
|
226
|
+
} finally {
|
|
227
|
+
await srv.close();
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
173
231
|
it("when the rollback doesn't come up either, it ends broken", async () => {
|
|
174
232
|
fs.writeFileSync(path.join(dir, "next-build"), "stale");
|
|
175
233
|
const srv = await fakeServer();
|
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { useState } from "react";
|
|
4
|
-
import { toast } from "sonner";
|
|
5
|
-
import { Button } from "@/components/ui/button";
|
|
6
|
-
import { Input } from "@/components/ui/input";
|
|
7
|
-
import {
|
|
8
|
-
useAwaySettingsQuery,
|
|
9
|
-
useUpdateAwaySettings,
|
|
10
|
-
type AwaySettings,
|
|
11
|
-
} from "@/data/away";
|
|
12
|
-
import {
|
|
13
|
-
MAX_WINDOW_MINUTES,
|
|
14
|
-
MIN_WINDOW_MINUTES,
|
|
15
|
-
parseWindow,
|
|
16
|
-
} from "@/lib/away/window-limits";
|
|
17
|
-
|
|
18
|
-
// Settings → Workspaces: how recent a session's work must be for Away mode
|
|
19
|
-
// to touch it. App-wide.
|
|
20
|
-
export function AwayWindowForm() {
|
|
21
|
-
const { data, isPending, isError, error } = useAwaySettingsQuery();
|
|
22
|
-
if (isPending)
|
|
23
|
-
return <div className="bg-muted/40 h-24 animate-pulse rounded-lg" />;
|
|
24
|
-
if (isError)
|
|
25
|
-
return <p className="text-destructive text-sm">{error.message}</p>;
|
|
26
|
-
return <Form key={data.windowMinutes} saved={data} />;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function Form({ saved }: { saved: AwaySettings }) {
|
|
30
|
-
const update = useUpdateAwaySettings();
|
|
31
|
-
const [text, setText] = useState(String(saved.windowMinutes));
|
|
32
|
-
const minutes = parseWindow(text);
|
|
33
|
-
const error =
|
|
34
|
-
minutes === null
|
|
35
|
-
? `A whole number from ${MIN_WINDOW_MINUTES} to ${MAX_WINDOW_MINUTES}`
|
|
36
|
-
: null;
|
|
37
|
-
return (
|
|
38
|
-
<form
|
|
39
|
-
onSubmit={(e) => {
|
|
40
|
-
e.preventDefault();
|
|
41
|
-
if (minutes === null) return;
|
|
42
|
-
update.mutate(
|
|
43
|
-
{ windowMinutes: minutes },
|
|
44
|
-
{
|
|
45
|
-
onSuccess: () => toast.success("Away mode settings saved"),
|
|
46
|
-
onError: (err) => toast.error(err.message),
|
|
47
|
-
}
|
|
48
|
-
);
|
|
49
|
-
}}
|
|
50
|
-
className="bg-muted/40 space-y-3 rounded-lg px-3 py-3"
|
|
51
|
-
>
|
|
52
|
-
<h3 className="text-sm font-medium">Away mode</h3>
|
|
53
|
-
<p className="text-muted-foreground text-sm">
|
|
54
|
-
Away mode works through orchestrators that were busy in this window, and
|
|
55
|
-
only lists sessions with a real turn inside it. Older ones are left
|
|
56
|
-
alone: a message wakes an agent and re-reads its whole conversation.
|
|
57
|
-
</p>
|
|
58
|
-
<label className="flex items-center justify-between gap-3 text-sm">
|
|
59
|
-
<span className="text-muted-foreground">Recent means the last</span>
|
|
60
|
-
<span className="flex items-center gap-2">
|
|
61
|
-
<Input
|
|
62
|
-
type="number"
|
|
63
|
-
inputMode="numeric"
|
|
64
|
-
min={MIN_WINDOW_MINUTES}
|
|
65
|
-
max={MAX_WINDOW_MINUTES}
|
|
66
|
-
step={1}
|
|
67
|
-
value={text}
|
|
68
|
-
onChange={(e) => setText(e.target.value)}
|
|
69
|
-
aria-label="Away window, minutes"
|
|
70
|
-
aria-invalid={!!error}
|
|
71
|
-
className="h-11 w-24 text-base sm:h-9 md:text-sm"
|
|
72
|
-
/>
|
|
73
|
-
<span className="text-muted-foreground">min</span>
|
|
74
|
-
</span>
|
|
75
|
-
</label>
|
|
76
|
-
{error && <p className="text-destructive text-xs">{error}</p>}
|
|
77
|
-
<div className="flex justify-end">
|
|
78
|
-
<Button
|
|
79
|
-
type="submit"
|
|
80
|
-
size="sm"
|
|
81
|
-
className="h-11 sm:h-8"
|
|
82
|
-
disabled={
|
|
83
|
-
!!error || minutes === saved.windowMinutes || update.isPending
|
|
84
|
-
}
|
|
85
|
-
>
|
|
86
|
-
Save
|
|
87
|
-
</Button>
|
|
88
|
-
</div>
|
|
89
|
-
</form>
|
|
90
|
-
);
|
|
91
|
-
}
|
|
@@ -1,177 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { useState } from "react";
|
|
4
|
-
import { toast } from "sonner";
|
|
5
|
-
import { Button } from "@/components/ui/button";
|
|
6
|
-
import { Input } from "@/components/ui/input";
|
|
7
|
-
import { Switch } from "@/components/ui/switch";
|
|
8
|
-
import type { RefreshSettings } from "@/lib/chat/refresh";
|
|
9
|
-
import { useFreshStartQuery, useUpdateFreshStart } from "@/data/fresh-start";
|
|
10
|
-
import { parseFreshStart } from "./fresh-start";
|
|
11
|
-
|
|
12
|
-
// When a chat's agent starts a fresh conversation instead of carrying a
|
|
13
|
-
// long one: app-wide, one threshold for orchestrators and one for tasks.
|
|
14
|
-
export function FreshStartForm() {
|
|
15
|
-
const { data, isPending, isError, error } = useFreshStartQuery();
|
|
16
|
-
if (isPending)
|
|
17
|
-
return <div className="bg-muted/40 h-24 animate-pulse rounded-lg" />;
|
|
18
|
-
if (isError)
|
|
19
|
-
return <p className="text-destructive text-sm">{error.message}</p>;
|
|
20
|
-
return (
|
|
21
|
-
<Form
|
|
22
|
-
key={`${data.orchestratorTokens}:${data.taskTokens}:${data.compactTokens}:${data.autoCompactTokens}:${data.interactiveAutoCompactTokens}`}
|
|
23
|
-
saved={data}
|
|
24
|
-
/>
|
|
25
|
-
);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function Form({ saved }: { saved: RefreshSettings }) {
|
|
29
|
-
const update = useUpdateFreshStart();
|
|
30
|
-
const orchestrator = useThreshold(saved.orchestratorTokens, 200);
|
|
31
|
-
const task = useThreshold(saved.taskTokens, 200);
|
|
32
|
-
const compact = useThreshold(saved.compactTokens, 150);
|
|
33
|
-
const midTurn = useThreshold(saved.autoCompactTokens, 300);
|
|
34
|
-
const interactive = useThreshold(saved.interactiveAutoCompactTokens, 200);
|
|
35
|
-
const error =
|
|
36
|
-
orchestrator.error ??
|
|
37
|
-
task.error ??
|
|
38
|
-
compact.error ??
|
|
39
|
-
midTurn.error ??
|
|
40
|
-
interactive.error;
|
|
41
|
-
const dirty =
|
|
42
|
-
!error &&
|
|
43
|
-
(orchestrator.tokens !== saved.orchestratorTokens ||
|
|
44
|
-
task.tokens !== saved.taskTokens ||
|
|
45
|
-
compact.tokens !== saved.compactTokens ||
|
|
46
|
-
midTurn.tokens !== saved.autoCompactTokens ||
|
|
47
|
-
interactive.tokens !== saved.interactiveAutoCompactTokens);
|
|
48
|
-
|
|
49
|
-
return (
|
|
50
|
-
<form
|
|
51
|
-
onSubmit={(e) => {
|
|
52
|
-
e.preventDefault();
|
|
53
|
-
if (error) return;
|
|
54
|
-
update.mutate(
|
|
55
|
-
{
|
|
56
|
-
orchestratorTokens: orchestrator.tokens,
|
|
57
|
-
taskTokens: task.tokens,
|
|
58
|
-
compactTokens: compact.tokens,
|
|
59
|
-
autoCompactTokens: midTurn.tokens,
|
|
60
|
-
interactiveAutoCompactTokens: interactive.tokens,
|
|
61
|
-
},
|
|
62
|
-
{
|
|
63
|
-
onSuccess: () => toast.success("Context settings saved"),
|
|
64
|
-
onError: (err) => toast.error(err.message),
|
|
65
|
-
}
|
|
66
|
-
);
|
|
67
|
-
}}
|
|
68
|
-
className="bg-muted/40 space-y-3 rounded-lg px-3 py-3"
|
|
69
|
-
>
|
|
70
|
-
<h3 className="text-sm font-medium">Fresh starts</h3>
|
|
71
|
-
<p className="text-muted-foreground text-sm">
|
|
72
|
-
When a conversation's context passes the threshold, its agent
|
|
73
|
-
starts a new one at the next turn boundary, with its brief. An
|
|
74
|
-
orchestrator also gets its rules and the workspace's current state,
|
|
75
|
-
so nothing is lost. A task keeps only its brief and its branch.
|
|
76
|
-
</p>
|
|
77
|
-
<ThresholdRow label="Orchestrators" field={orchestrator} />
|
|
78
|
-
<ThresholdRow label="Tasks" field={task} />
|
|
79
|
-
<h3 className="pt-2 text-sm font-medium">Compact</h3>
|
|
80
|
-
<p className="text-muted-foreground text-sm">
|
|
81
|
-
Chats and tasks compact twice over. Mid-turn, Claude Code summarizes its
|
|
82
|
-
own context when a job runs long: set this high, since it happens while
|
|
83
|
-
the agent is holding its files. Between turns, nothing is in flight, so
|
|
84
|
-
AgentOS compacts a chat over the smaller size, or idle past its prompt
|
|
85
|
-
cache, before the next message; the composer offers to first. Either way
|
|
86
|
-
AgentOS hands the agent its task, scope changes, files and todos back.
|
|
87
|
-
Your own sessions, in a chat or a terminal, have their own mid-turn
|
|
88
|
-
size; a terminal can't be compacted between turns, so that is the
|
|
89
|
-
only compaction it gets. Orchestrators start fresh instead.
|
|
90
|
-
</p>
|
|
91
|
-
<ThresholdRow
|
|
92
|
-
label="Mid-turn, tasks"
|
|
93
|
-
field={midTurn}
|
|
94
|
-
switchLabel="Let a task's agent compact itself mid-turn"
|
|
95
|
-
/>
|
|
96
|
-
<ThresholdRow
|
|
97
|
-
label="Mid-turn, your sessions"
|
|
98
|
-
field={interactive}
|
|
99
|
-
switchLabel="Let your own sessions compact themselves mid-turn"
|
|
100
|
-
/>
|
|
101
|
-
<ThresholdRow
|
|
102
|
-
label="Between turns"
|
|
103
|
-
field={compact}
|
|
104
|
-
switchLabel="Compact chats and tasks between turns"
|
|
105
|
-
/>
|
|
106
|
-
{error && <p className="text-destructive text-xs">{error}</p>}
|
|
107
|
-
<div className="flex justify-end">
|
|
108
|
-
<Button
|
|
109
|
-
type="submit"
|
|
110
|
-
size="sm"
|
|
111
|
-
className="h-11 sm:h-8"
|
|
112
|
-
disabled={!!error || !dirty || update.isPending}
|
|
113
|
-
>
|
|
114
|
-
Save
|
|
115
|
-
</Button>
|
|
116
|
-
</div>
|
|
117
|
-
</form>
|
|
118
|
-
);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function useThreshold(tokens: number, fallbackK: number) {
|
|
122
|
-
const [on, setOn] = useState(tokens > 0);
|
|
123
|
-
const [text, setText] = useState(
|
|
124
|
-
String(tokens > 0 ? tokens / 1000 : fallbackK)
|
|
125
|
-
);
|
|
126
|
-
const parsed = parseFreshStart(on, text);
|
|
127
|
-
return {
|
|
128
|
-
on,
|
|
129
|
-
setOn,
|
|
130
|
-
text,
|
|
131
|
-
setText,
|
|
132
|
-
tokens: "tokens" in parsed ? parsed.tokens : tokens,
|
|
133
|
-
error: "error" in parsed ? parsed.error : null,
|
|
134
|
-
};
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function ThresholdRow({
|
|
138
|
-
label,
|
|
139
|
-
field,
|
|
140
|
-
switchLabel,
|
|
141
|
-
}: {
|
|
142
|
-
label: string;
|
|
143
|
-
field: ReturnType<typeof useThreshold>;
|
|
144
|
-
switchLabel?: string;
|
|
145
|
-
}) {
|
|
146
|
-
return (
|
|
147
|
-
<div className="space-y-2">
|
|
148
|
-
<label className="flex min-h-11 items-center justify-between gap-3 text-sm md:min-h-9">
|
|
149
|
-
{label}
|
|
150
|
-
<Switch
|
|
151
|
-
checked={field.on}
|
|
152
|
-
onCheckedChange={field.setOn}
|
|
153
|
-
aria-label={switchLabel ?? `Fresh starts for ${label.toLowerCase()}`}
|
|
154
|
-
/>
|
|
155
|
-
</label>
|
|
156
|
-
{field.on && (
|
|
157
|
-
<label className="flex items-center justify-between gap-3 text-sm">
|
|
158
|
-
<span className="text-muted-foreground">At</span>
|
|
159
|
-
<span className="flex items-center gap-2">
|
|
160
|
-
<Input
|
|
161
|
-
type="number"
|
|
162
|
-
inputMode="numeric"
|
|
163
|
-
min={50}
|
|
164
|
-
step={10}
|
|
165
|
-
value={field.text}
|
|
166
|
-
onChange={(e) => field.setText(e.target.value)}
|
|
167
|
-
aria-label={`${label} threshold, thousands of tokens`}
|
|
168
|
-
aria-invalid={!!field.error}
|
|
169
|
-
className="h-11 w-24 text-base sm:h-9 md:text-sm"
|
|
170
|
-
/>
|
|
171
|
-
<span className="text-muted-foreground">k tokens</span>
|
|
172
|
-
</span>
|
|
173
|
-
</label>
|
|
174
|
-
)}
|
|
175
|
-
</div>
|
|
176
|
-
);
|
|
177
|
-
}
|