Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 181 additions & 0 deletions packages/cli/__tests__/commands/start.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1633,6 +1633,186 @@ describe("start command — orchestrator session strategy display", () => {
expect(mockClearLastStop).toHaveBeenCalled();
});

// Regression for issue #1743: when last-stop.json is missing or empty
// (e.g. because `ao stop` crashed before writing it, or `ao update`
// wiped state), `ao start` must still offer to restore recently
// `manually_killed` sessions by scanning the session manager.
it("falls back to recently manually-killed sessions when last-stop.json is missing (issue #1743)", async () => {
// Force getGlobalConfigPath() to a non-existent path so the fallback's
// global-config load is a no-op and the test does not read the host's
// real ~/.agent-orchestrator/config.yaml.
const origGlobalEnv = process.env["AO_GLOBAL_CONFIG"];
process.env["AO_GLOBAL_CONFIG"] = join(tmpDir, "no-such-global.yaml");

try {
mockReadLastStop.mockResolvedValue(null);

mockConfigRef.current = makeConfig({ "my-app": makeProject() });
const { findWebDir } = await import("../../src/lib/web-dir.js");
vi.mocked(findWebDir).mockReturnValue(tmpDir);
writeFileSync(join(tmpDir, "package.json"), "{}");

const fakeDashboard = { on: vi.fn(), kill: vi.fn(), emit: vi.fn() };
mockSpawn.mockReturnValue(fakeDashboard);

const recentTerminatedAt = new Date(Date.now() - 60_000).toISOString();
mockSessionManager.list.mockResolvedValue([
{
id: "app-1",
projectId: "my-app",
status: "killed",
activity: "exited",
metadata: {},
lastActivityAt: new Date(),
lifecycle: {
version: 2,
session: {
kind: "worker",
state: "terminated",
reason: "manually_killed",
startedAt: null,
completedAt: null,
terminatedAt: recentTerminatedAt,
lastTransitionAt: recentTerminatedAt,
},
pr: { state: "none", reason: "not_created", number: null, url: null, lastObservedAt: null },
runtime: { state: "missing", reason: "manual_kill_requested", lastObservedAt: null, handle: null, tmuxName: null },
},
},
]);
mockSessionManager.restore.mockResolvedValue(undefined);

await program.parseAsync(["node", "test", "start", "--no-orchestrator"]);

expect(mockSessionManager.restore).toHaveBeenCalledWith("app-1");
} finally {
if (origGlobalEnv === undefined) delete process.env["AO_GLOBAL_CONFIG"];
else process.env["AO_GLOBAL_CONFIG"] = origGlobalEnv;
}
});

it("does not surface fallback candidates older than the recent window (issue #1743)", async () => {
const origGlobalEnv = process.env["AO_GLOBAL_CONFIG"];
process.env["AO_GLOBAL_CONFIG"] = join(tmpDir, "no-such-global.yaml");

try {
mockReadLastStop.mockResolvedValue(null);

mockConfigRef.current = makeConfig({ "my-app": makeProject() });
const { findWebDir } = await import("../../src/lib/web-dir.js");
vi.mocked(findWebDir).mockReturnValue(tmpDir);
writeFileSync(join(tmpDir, "package.json"), "{}");

const fakeDashboard = { on: vi.fn(), kill: vi.fn(), emit: vi.fn() };
mockSpawn.mockReturnValue(fakeDashboard);

// Terminated 30 minutes ago — beyond the 10-minute fallback window.
const oldTerminatedAt = new Date(Date.now() - 30 * 60 * 1000).toISOString();
mockSessionManager.list.mockResolvedValue([
{
id: "app-1",
projectId: "my-app",
status: "killed",
activity: "exited",
metadata: {},
lastActivityAt: new Date(),
lifecycle: {
version: 2,
session: {
kind: "worker",
state: "terminated",
reason: "manually_killed",
startedAt: null,
completedAt: null,
terminatedAt: oldTerminatedAt,
lastTransitionAt: oldTerminatedAt,
},
pr: { state: "none", reason: "not_created", number: null, url: null, lastObservedAt: null },
runtime: { state: "missing", reason: "manual_kill_requested", lastObservedAt: null, handle: null, tmuxName: null },
},
},
]);

await program.parseAsync(["node", "test", "start", "--no-orchestrator"]);

expect(mockSessionManager.restore).not.toHaveBeenCalled();
expect(mockPromptConfirm).not.toHaveBeenCalled();
} finally {
if (origGlobalEnv === undefined) delete process.env["AO_GLOBAL_CONFIG"];
else process.env["AO_GLOBAL_CONFIG"] = origGlobalEnv;
}
});

// Regression for Greptile P1 on PR #1780. Before the fix, the fallback's
// session manager was built from the project-scoped config, so `sm.list()`
// only saw the current project's sessions and `otherProjects` in the
// synthesized LastStopState was always empty — defeating the cross-project
// restore that `readLastStop()` already supports.
it("fallback uses the global config so cross-project sessions appear in otherProjects (PR #1780)", async () => {
const origGlobalEnv = process.env["AO_GLOBAL_CONFIG"];
const globalPath = join(tmpDir, "global-config.yaml");
// Real, parseable global config so loadConfig(globalPath) succeeds and
// existsSync(globalPath) returns true. Contents don't have to match the
// mocked sessions — getSessionManager is mocked to ignore config.
writeFileSync(
globalPath,
"version: 1\nport: 3000\nprojects:\n my-app:\n name: My App\n path: /tmp/my-app\n sessionPrefix: app\n other-app:\n name: Other App\n path: /tmp/other-app\n sessionPrefix: other\n",
);
process.env["AO_GLOBAL_CONFIG"] = globalPath;

try {
mockReadLastStop.mockResolvedValue(null);
mockConfigRef.current = makeConfig({ "my-app": makeProject() });
const { findWebDir } = await import("../../src/lib/web-dir.js");
vi.mocked(findWebDir).mockReturnValue(tmpDir);
writeFileSync(join(tmpDir, "package.json"), "{}");

const fakeDashboard = { on: vi.fn(), kill: vi.fn(), emit: vi.fn() };
mockSpawn.mockReturnValue(fakeDashboard);
mockPromptConfirm.mockResolvedValue(true);

const recent = new Date(Date.now() - 60_000).toISOString();
const terminated = (id: string, projectId: string) => ({
id,
projectId,
status: "killed",
activity: "exited",
metadata: {},
lastActivityAt: new Date(),
lifecycle: {
version: 2,
session: {
kind: "worker",
state: "terminated",
reason: "manually_killed",
startedAt: null,
completedAt: null,
terminatedAt: recent,
lastTransitionAt: recent,
},
pr: { state: "none", reason: "not_created", number: null, url: null, lastObservedAt: null },
runtime: { state: "missing", reason: "manual_kill_requested", lastObservedAt: null, handle: null, tmuxName: null },
},
});

mockSessionManager.list.mockResolvedValue([
terminated("app-1", "my-app"),
terminated("other-1", "other-app"),
]);
mockSessionManager.restore.mockResolvedValue(undefined);

await program.parseAsync(["node", "test", "start", "--no-orchestrator"]);

// Both the in-project session AND the cross-project session must be
// routed to restore. Pre-fix, only "app-1" would have been seen.
expect(mockSessionManager.restore).toHaveBeenCalledWith("app-1");
expect(mockSessionManager.restore).toHaveBeenCalledWith("other-1");
} finally {
if (origGlobalEnv === undefined) delete process.env["AO_GLOBAL_CONFIG"];
else process.env["AO_GLOBAL_CONFIG"] = origGlobalEnv;
}
});

it("opens the bare dashboard URL when --no-orchestrator skips the orchestrator block", async () => {
mockConfigRef.current = makeConfig({ "my-app": makeProject() });

Expand Down Expand Up @@ -2204,6 +2384,7 @@ describe("start command — platform-aware runtime fallback", () => {
else process.env["AO_GLOBAL_CONFIG"] = origGlobalEnv;
}
});

});

// ---------------------------------------------------------------------------
Expand Down
42 changes: 42 additions & 0 deletions packages/cli/__tests__/commands/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,48 @@ describe("update command", () => {
await program.parseAsync(["node", "test", "update"]);
expect(mockInvalidateCache).toHaveBeenCalledTimes(1);
});

// Regression for issue #1743: `ao update` must NOT touch
// ~/.agent-orchestrator/last-stop.json. Without this guarantee,
// the `ao stop` → `ao update` → `ao start` flow loses the restore
// record. We exercise the full update code path with a real
// last-stop.json on disk in a temp HOME and assert it survives.
it("does not touch ~/.agent-orchestrator/last-stop.json (issue #1743)", async () => {
const { mkdirSync, mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync } =
await import("node:fs");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");

const fakeHome = mkdtempSync(join(tmpdir(), "ao-update-home-"));
const stateDir = join(fakeHome, ".agent-orchestrator");
mkdirSync(stateDir, { recursive: true });
const lastStopPath = join(stateDir, "last-stop.json");
const before = JSON.stringify({
stoppedAt: "2026-05-08T17:53:15.909Z",
projectId: "my-app",
sessionIds: ["app-1", "app-2"],
});
writeFileSync(lastStopPath, before, "utf-8");

const origHome = process.env["HOME"];
const origUserprofile = process.env["USERPROFILE"];
process.env["HOME"] = fakeHome;
process.env["USERPROFILE"] = fakeHome;

try {
await program.parseAsync(["node", "test", "update"]);

expect(existsSync(lastStopPath)).toBe(true);
expect(readFileSync(lastStopPath, "utf-8")).toBe(before);
expect(mockRunRepoScript).toHaveBeenCalledWith("ao-update.sh", []);
} finally {
if (origHome === undefined) delete process.env["HOME"];
else process.env["HOME"] = origHome;
if (origUserprofile === undefined) delete process.env["USERPROFILE"];
else process.env["USERPROFILE"] = origUserprofile;
rmSync(fakeHome, { recursive: true, force: true });
}
});
});

// -----------------------------------------------------------------------
Expand Down
Loading
Loading