Skip to content
Merged
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
5 changes: 3 additions & 2 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,7 @@ agentsync upgrade --check # report only, install nothing
```bash
agentsync daemon install # write the OS service descriptor
agentsync daemon start # idempotent
agentsync daemon status # IPC ping
agentsync daemon status # last-sync health (last success, failures, stuck)
agentsync daemon stop
agentsync daemon uninstall
```
Expand All @@ -325,7 +325,8 @@ agentsync daemon uninstall

- Only one daemon runs per user. A second-instance check exits cleanly if a daemon is already up.
- A transient sync failure triggers one automatic retry. If the retry also fails, the error is recorded but the daemon stays alive so the next change can trigger a fresh push.
- See [Daemon](operations.md#daemon) for install paths per OS, lifecycle, and log locations.
- `daemon status` reports the last successful sync time, consecutive failures, and a **stuck** flag (vault diverged). When the daemon is **not** running it falls back to the durable `daemon-state.json`, so you still see when sync last succeeded. `doctor` surfaces a stale or stuck last-sync as a dedicated health row — a silent backup failure is loud, not invisible.
- See [Daemon](operations.md#daemon) for install paths per OS, lifecycle, health/durable-state, and log locations.

## key

Expand Down
12 changes: 11 additions & 1 deletion docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,21 @@ The daemon moves through a small number of phases. Each phase has a well-defined
|---|---|
| Validating | Verifies the vault directory, the config file, and the encryption key are reachable. Exits immediately on failure. |
| Second-instance check | Sends an IPC `status` ping. If a daemon is already running, exits. Unlinks a stale socket if the prior daemon died ungracefully. |
| Running | IPC server is listening and file watchers are active (push-only; no pull timer). `status` returns the current pid, consecutive-failure counter, and last error. |
| Running | IPC server is listening and file watchers are active (push-only; no pull timer). `status` returns pid, last successful sync time, consecutive-failure counter, last error, and whether the daemon is stuck. |
| Syncing | A push is executing inside the sync queue. Only one sync runs at a time. |
| Retry once | Automatic single retry after a transient failure. If both attempts fail, the error is recorded but the daemon stays alive so the next change can still trigger a push. |
| Stuck | The vault diverged (`DIVERGED_HISTORY`). A divergence cannot heal on its own, so the daemon latches a `stuck` flag, fires one desktop notification, and backs off watcher-driven retries to once every 5 minutes instead of hammering a doomed push. A manual `agentsync push` is never throttled. The next success (after you reset the vault) clears it. |
| Shutting down | Drains the sync queue with a hard ten-second timeout, closes IPC, stops watchers, unlinks the socket, exits cleanly. |

### Health and durable state

The daemon persists its health to `<AGENTSYNC_DIR>/daemon-state.json` (last successful sync, last error, consecutive failures, and the stuck flag) and updates it on every success and failure. Because it is on disk, the state survives a crash or restart:

- `agentsync daemon status` reports the live state when the daemon is up, and **falls back to the durable file when it is down** — so you can still see when sync last succeeded and whether it died stuck.
- `agentsync doctor` reads the same file and adds a **Daemon sync health** row: it **fails** when stuck, **warns** when the daemon is installed but the last success is older than 24 hours (or has never succeeded), and **passes** on a recent success. This is the loud signal that a *silent* backup failure has been happening — the worst failure mode for a backup tool. (The installed-but-stale/never warning is derived from the service file, which doctor only detects on macOS and Linux today; on Windows the row still **fails** on a stuck vault but does not yet warn on staleness.)

The file watcher pre-filters never-sync paths (`sessions/`, `history.jsonl`, `*.local.md`, …) at the watch layer, so a high-churn agent directory no longer wakes a push that would snapshot nothing.

### Configuration

Daemon behaviour is driven by the `[sync]` table in `agentsync.toml`:
Expand Down
77 changes: 77 additions & 0 deletions src/commands/__tests__/daemon.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@
* Covers getExecutableArgs() and the daemon subcommands (install, start, stop, status, uninstall).
*/
import { afterAll, beforeAll, beforeEach, describe, expect, mock, spyOn, test } from "bun:test";
import { rm } from "node:fs/promises";
import { log } from "@clack/prompts";
import { IpcClient } from "../../core/ipc";
import { __setInstallerLinuxOverridesForTests } from "../../daemon/installer-linux";
import { __setInstallerMacOsOverridesForTests } from "../../daemon/installer-macos";
import { __setInstallerWindowsOverridesForTests } from "../../daemon/installer-windows";
import { writeDaemonState } from "../../daemon/state";
import { createTmpDir } from "../../test-helpers/fixtures";

// ── getExecutableArgs tests ────────────────────────────────────────────────────

Expand Down Expand Up @@ -112,10 +115,12 @@ __setInstallerWindowsOverridesForTests({
const successLogs: string[] = [];
const errorLogs: string[] = [];
const warnLogs: string[] = [];
const infoLogs: string[] = [];

let successSpy: ReturnType<typeof spyOn>;
let errorSpy: ReturnType<typeof spyOn>;
let warnSpy: ReturnType<typeof spyOn>;
let infoSpy: ReturnType<typeof spyOn>;
let ipcClientSendSpy: ReturnType<typeof spyOn>;

beforeAll(() => {
Expand All @@ -128,13 +133,17 @@ beforeAll(() => {
warnSpy = spyOn(log, "warn").mockImplementation((msg: string) => {
warnLogs.push(msg);
});
infoSpy = spyOn(log, "info").mockImplementation((msg: string) => {
infoLogs.push(msg);
});
ipcClientSendSpy = spyOn(IpcClient.prototype, "send");
});

afterAll(() => {
successSpy.mockRestore();
errorSpy.mockRestore();
warnSpy.mockRestore();
infoSpy.mockRestore();
ipcClientSendSpy.mockRestore();
// Clear the globalThis-backed override slots so any later test file that
// exercises the real installer functions sees clean defaults.
Expand All @@ -147,6 +156,7 @@ afterAll(() => {
beforeEach(() => {
successLogs.length = 0;
errorLogs.length = 0;
infoLogs.length = 0;
warnLogs.length = 0;
mockInstall.mockClear();
mockUninstall.mockClear();
Expand Down Expand Up @@ -246,6 +256,73 @@ describe("daemonCommand subcommands", () => {
expect(process.exitCode).toBe(1);
});

test("status falls back to the durable state file when the daemon is down", async () => {
const tmp = await createTmpDir();
const saved = process.env.AGENTSYNC_DIR;
process.env.AGENTSYNC_DIR = tmp;
try {
await writeDaemonState({
pid: null,
startedAt: null,
lastSuccessAt: new Date().toISOString(),
lastErrorAt: new Date().toISOString(),
lastError: "[push] Vault history diverged",
consecutiveFailures: 3,
stuck: true,
});
ipcClientSendSpy.mockRejectedValueOnce(new Error("ENOENT"));
const cmd = await getSubCmd("status");
await cmd.run();

expect(errorLogs.some((m) => m.includes("not running"))).toBe(true);
expect(errorLogs.some((m) => m.includes("STUCK"))).toBe(true);
expect(process.exitCode).toBe(1);
} finally {
if (saved === undefined) delete process.env.AGENTSYNC_DIR;
else process.env.AGENTSYNC_DIR = saved;
await rm(tmp, { recursive: true, force: true });
}
});

test("status subcommand escalates a stuck daemon and exits 1", async () => {
ipcClientSendSpy.mockResolvedValueOnce({
id: "test",
ok: true,
data: {
pid: 12345,
consecutiveFailures: 7,
lastError: "[push] Vault history diverged",
lastSuccessAt: null,
startedAt: new Date().toISOString(),
stuck: true,
},
});
const cmd = await getSubCmd("status");
await cmd.run();

expect(errorLogs.some((m) => m.includes("STUCK"))).toBe(true);
expect(process.exitCode).toBe(1);
});

test("status subcommand reports the last successful sync age", async () => {
ipcClientSendSpy.mockResolvedValueOnce({
id: "test",
ok: true,
data: {
pid: 12345,
consecutiveFailures: 0,
lastError: null,
lastSuccessAt: new Date(Date.now() - 5 * 60_000).toISOString(),
startedAt: new Date().toISOString(),
stuck: false,
},
});
const cmd = await getSubCmd("status");
await cmd.run();

expect(infoLogs.some((m) => m.includes("Last successful sync"))).toBe(true);
});

test("status subcommand handles error response from daemon", async () => {
ipcClientSendSpy.mockResolvedValueOnce({
id: "test",
Expand Down
63 changes: 62 additions & 1 deletion src/commands/__tests__/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ import { mkdirSync, writeFileSync } from "node:fs";
import { rm, symlink } from "node:fs/promises";
import { join } from "node:path";
import { AgentPaths } from "../../config/paths";
import { type DaemonState, writeDaemonState } from "../../daemon/state";
import { createTmpDir } from "../../test-helpers/fixtures";
import { buildSkillsDirChecks } from "../doctor";
import { buildDaemonHealthCheck, buildSkillsDirChecks } from "../doctor";

type MutablePaths = {
claude: { skillsDir: string };
Expand Down Expand Up @@ -129,3 +130,63 @@ describe("buildSkillsDirChecks", () => {
expect(claudeRow?.detail).toContain("Symlinked skills root");
});
});

describe("buildDaemonHealthCheck", () => {
let tmpDir: string;
let savedDir: string | undefined;

const base: DaemonState = {
pid: null,
startedAt: null,
lastSuccessAt: null,
lastErrorAt: null,
lastError: null,
consecutiveFailures: 0,
stuck: false,
};

beforeEach(async () => {
tmpDir = await createTmpDir();
savedDir = process.env.AGENTSYNC_DIR;
process.env.AGENTSYNC_DIR = tmpDir;
});

afterEach(async () => {
if (savedDir === undefined) delete process.env.AGENTSYNC_DIR;
else process.env.AGENTSYNC_DIR = savedDir;
await rm(tmpDir, { recursive: true, force: true });
});

test("fails when the daemon is stuck on a divergence", async () => {
await writeDaemonState({ ...base, stuck: true, lastError: "[push] diverged" });
const row = await buildDaemonHealthCheck(true);
expect(row.status).toBe("fail");
expect(row.detail).toContain("STUCK");
});

test("warns when installed but no sync has ever succeeded", async () => {
await writeDaemonState(base);
const row = await buildDaemonHealthCheck(true);
expect(row.status).toBe("warn");
});

test("passes when not installed and no sync has run", async () => {
await writeDaemonState(base);
const row = await buildDaemonHealthCheck(false);
expect(row.status).toBe("pass");
});

test("warns when the last success is stale", async () => {
const old = new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString();
await writeDaemonState({ ...base, lastSuccessAt: old });
const row = await buildDaemonHealthCheck(true);
expect(row.status).toBe("warn");
expect(row.detail).toContain("stale");
});

test("passes on a recent successful sync", async () => {
await writeDaemonState({ ...base, lastSuccessAt: new Date().toISOString() });
const row = await buildDaemonHealthCheck(true);
expect(row.status).toBe("pass");
});
});
53 changes: 42 additions & 11 deletions src/commands/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ import { defineCommand } from "citty";
import { resolveDaemonSocketPath } from "../config/paths";
import { DaemonStatusSchema } from "../config/schema";
import { IpcClient } from "../core/ipc";
import { type DaemonState, formatAge, readDaemonState } from "../daemon/state";

/** Render the "last successful sync" line shared by the live and offline paths. */
function lastSyncLine(lastSuccessAt: string | null): string {
if (!lastSuccessAt) return "Last successful sync: never";
return `Last successful sync: ${formatAge(Date.now() - Date.parse(lastSuccessAt))} ago`;
}

/**
* Detect whether a file path resolves into a session-scoped temporary directory
Expand Down Expand Up @@ -144,26 +151,50 @@ export const daemonCommand = defineCommand({
}),

status: defineCommand({
meta: { description: "Show daemon status via IPC" },
meta: { description: "Show daemon status and last-sync health" },
async run() {
const client = new IpcClient();
try {
const response = await client.send("status", {}, resolveDaemonSocketPath());
if (response.ok) {
const parsed = DaemonStatusSchema.safeParse(response.data);
const pid = parsed.success ? parsed.data.pid : (response.data as { pid: number }).pid;
const failures = parsed.success ? parsed.data.consecutiveFailures : 0;
const lastErr = parsed.success ? parsed.data.lastError : null;
log.success(`Daemon is running (pid: ${pid})`);
if (failures > 0) {
log.warn(`Consecutive failures: ${failures}. Last error: ${lastErr ?? "unknown"}`);
}
} else {
if (!response.ok) {
log.error(`Daemon error: ${response.error}`);
process.exitCode = 1;
return;
}
const parsed = DaemonStatusSchema.safeParse(response.data);
if (!parsed.success) {
const pid = (response.data as { pid?: number }).pid ?? "unknown";
log.success(`Daemon is running (pid: ${pid}).`);
return;
}
const s = parsed.data;
log.success(`Daemon is running (pid: ${s.pid}).`);
log.info(lastSyncLine(s.lastSuccessAt));
if (s.stuck) {
log.error(
"STUCK: vault history diverged — auto-sync is paused until you reset the vault. See `agentsync doctor`.",
);
process.exitCode = 1;
} else if (s.consecutiveFailures > 0) {
log.warn(
`Consecutive failures: ${s.consecutiveFailures}. Last error: ${s.lastError ?? "unknown"}`,
);
}
} catch {
// Daemon is down — fall back to the durable state file so the user
// still sees when sync last succeeded and whether it died stuck.
const s: DaemonState = await readDaemonState();
log.error("Daemon is not running.");
if (s.lastSuccessAt || s.lastErrorAt) {
log.info(lastSyncLine(s.lastSuccessAt));
if (s.stuck) {
log.error(
"Last run was STUCK (vault diverged). Reset the vault, then start the daemon.",
);
} else if (s.lastError) {
log.warn(`Last error: ${s.lastError}`);
}
}
process.exitCode = 1;
}
},
Expand Down
Loading
Loading