From 3ac07531d1c4493b19af9f6c8057db5a3f3163c9 Mon Sep 17 00:00:00 2001 From: Young Joon Lee Date: Sun, 25 Oct 2026 00:02:21 +0900 Subject: [PATCH] feat(doctor): amem doctor report and sync cadence without a daemon (Refs #40 F15, F16) --- CHANGELOG.md | 2 + README.md | 6 + docs/contracts.md | 33 +++ src/cli.ts | 51 ++++- src/commands-table.ts | 4 +- src/memory/doctor.ts | 336 +++++++++++++++++++++++++++ src/memory/sync-cadence.ts | 131 +++++++++++ src/memory/sync.ts | 38 +++- src/session-brief.ts | 31 ++- test/completion.test.ts | 3 +- test/doctor-snapshot.test.ts | 68 ++++++ test/doctor.test.ts | 236 +++++++++++++++++++ test/fakes/foreign-segment.ts | 20 ++ test/fakes/pty.ts | 11 + test/fixtures/doctor-snapshot.json | 229 +++++++++++++++++++ test/review-pending.test.ts | 13 +- test/sync-cadence.test.ts | 353 +++++++++++++++++++++++++++++ 17 files changed, 1534 insertions(+), 31 deletions(-) create mode 100644 src/memory/doctor.ts create mode 100644 src/memory/sync-cadence.ts create mode 100644 test/doctor-snapshot.test.ts create mode 100644 test/doctor.test.ts create mode 100644 test/fakes/foreign-segment.ts create mode 100644 test/fakes/pty.ts create mode 100644 test/fixtures/doctor-snapshot.json create mode 100644 test/sync-cadence.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ef6664e..6cbbc20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- `amem doctor [--json]` and the sync cadence (agent-mem#40 F15, F16, AC8, task #424): one read-only report of duplicates and stale notes, pending candidates, capture freshness and hook registration per CLI, the brief's token cost, index equals rebuild, reader leases, the policy in effect and the ingest and sync cadence, with a stable `amem.doctor/v1` JSON shape pinned by a snapshot. `amem sync --if-due [--force]` syncs only when an hourly schedule and a failure backoff (15 minutes doubling to a day) allow it, only with a configured remote, bounded at 30 seconds, and every sync stamps `sync-state.json` (times and a count, never an error text). amem registers no sync hook and no resident process (owner decision O3). Doctor writes nothing into the home: every check runs on a scratch copy of the logs (the live index is only snapshotted read-only), and `sessionStartBrief` gains `measureOnly` for it. `sync --if-due` also stops git's transport children, stamps only real transport failures (also under `--json`) and ignores a future stamp. See `docs/contracts.md`. + - Lexical recall gate (agent-mem#40 F14, AC7, task #423): the F14 candidates (query expansion from `about`-node words, 1-hop graph neighbours by append and by score blend, an `about`-node boost, pseudo-relevance feedback) were each scored with `amem eval` on the built-in set and on a new held-out set (`test/fixtures/eval-heldout.json`, written before any candidate ran). None raised MRR or hit@1 on both, so none is adopted and `store.search` is unchanged. Added the gate (`scripts/f14-gate.ts`, the pure `adopts()` rule, `test/f14-gate.test.ts` pinning the record) so a future candidate is judged the same way, and an optional `rank` on `evalSeeded` to score one. Recorded in `docs/contracts.md`. - Recall evaluation (agent-mem#40 F13, AC7, task #421): `amem eval [--set ] [--k 1,5,10] [--json]` replays a question set against the ranked `store.search` (what MCP and hub recall use, not the `amem search` substring filter) and reports recall@k, hit@k and MRR, with an `answerable` split, scored over the entries the store holds, that separates ranking from coverage. No model judge, no network, no embedding. The built-in synthetic fixture (`src/memory/eval-set.json`, 26 invented entries, long and short tier a day apart, and 32 questions) runs in a throwaway store and never touches the memory home; its scores (MRR 0.9063, hit@10 0.9375, recall@1 0.8281) are pinned in `test/eval.test.ts`, so a lexical change (F14) has to move them on purpose. diff --git a/README.md b/README.md index e2e6e3b..847771a 100644 --- a/README.md +++ b/README.md @@ -278,6 +278,12 @@ A private admission cursor rotates bounded inspection across pending records (at Successful native project identities have a private, bounded derived cache (128 cwd entries, 4 KiB each). A hit must match the current project scope, stricter Git-validity metadata and executable revision; a miss still uses the shared hardened Git resolver. It stores no capture or context, and does not guess an identity when Git fails. The hook uses the selected developer Git directly only when PATH selects Apple's root-owned shim; custom PATH binaries remain selected. Cache ceilings and invalidation are in [the P3 contracts](docs/contracts.md#hook-project-identity-cache). +## Doctor and the sync cadence + +`amem doctor` prints one read-only report of this project's memory: duplicate and stale notes, pending candidates, capture freshness and hook registration for each CLI (a CLI with no hook is `manual`), the SessionStart brief's token cost, whether the index equals a rebuild of the logs, reader leases and the policy in effect. `amem doctor --json` is a stable machine-readable form (`amem.doctor/v1`). It writes nothing into the memory home (every check runs on a temporary copy of the logs) and creates nothing in a home you never used. + +Intake runs at the end of each Codex, Kimi and Pi session once `amem setup --apply` has registered it. Sync is off unless you ask for it: amem registers no sync hook and runs nothing between sessions. To sync on your own schedule, call `amem sync --if-due` from your shell (for example an EXIT trap in `~/.zshrc`; it needs a terminal). It syncs at most hourly, backs off 15 minutes doubling to a day after failures, stops after 30 seconds, and does nothing without a remote set by `amem sync --remote `. `--force` skips the schedule and the backoff. + ## Native transcript intake `amem ingest` reads existing Codex, Kimi or Pi transcripts and writes screened deterministic capture rows, never curated notes. Run it from the project to bind intake to that working directory; the CLI exposes no project/home override or policy bypass: diff --git a/docs/contracts.md b/docs/contracts.md index b30f73a..32e6543 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -355,3 +355,36 @@ agent-mem#40 F14 asks for query expansion from aliases and tags and for BM25 plu - **AC4, no candidate expires unseen.** `candidates.db` gains a nullable `shown_at` column, added in place when a `CandidateStore` opens, inside one immediate transaction that rechecks the column (several processes open the store at once, every `amem mcp` among them; and the switch to WAL is retried for a bounded time on lock contention). An older database keeps its rows, all unshown; a reader without the column treats every row as unshown and the stamp is a no-op, which only keeps candidates longer. A pending candidate is stamped only by `amem review walk`, which refuses to run unless stdout is a terminal (a redirect to a file or a pipe would stamp candidates nobody saw), and only AFTER the candidate's header and body were written to it (a write that fails stamps nothing and asks nothing, so a candidate the person could not read is never counted as shown). The SessionStart hook, the brief and `amem review list` never stamp: the hook cannot know the person read the context, and `list` prints an id, a kind and a date (no content), is run by scripts and cron, and `--json` may be piped to a program, so a stamp there would start the 7-day clock with nobody reading. The first value is kept. The 30-day age expiry deletes a decided (accepted or rejected) candidate, or a pending one shown at least 7 days ago; an unshown pending candidate is kept however old. The 500-per-project cap keeps rows in three tiers, unshown pending first, then shown pending (owed its grace), then decided, so it evicts decided rows before a shown one and shown ones before an unseen one. `put` prunes, counts, inserts and prunes again in ONE immediate transaction, so concurrent proposers cannot all pass the count and then let the cap evict an unseen candidate; when 500 unseen ones wait it throws `CandidateQueueFull` (`code: "candidate_queue_full"`, no candidate text, worded for an agent: ask the person to review), which the MCP server maps to one fixed sentence instead of its generic refusal. Re-proposing an existing candidate is not new. The existing retention test is amended to this behavior. - **`amem status`** adds `candidates: { count, oldestExpiresInDays, dueWithin7Days, unshown }` and a `warning` when any candidate is within 7 days of its 30-day age, or `candidates: { unavailable }` when `candidates.db` exists but cannot be read (the reader tries a plain read-only open, then an immutable one for a WAL database whose `-wal` and `-shm` are gone, and each form is proven with a query; the brief line fails open to no line). **`amem review walk`** writes each pending candidate (oldest first) to the terminal, stamps it, and only then asks accept, edit, reject, skip or quit, writing every outcome as it happens so an interrupted walk still shows what it did: accept runs the ordinary `review accept` (current policy, host shard checks), reject the ordinary `review reject`, edit opens the text (a `fail` candidate, and a general memory candidate proposed as a fail note, as the labelled template; a loose sentence is pre-filled under `Approach:`, while text that already carries any of the five labels opens exactly as written, so the edit cannot repeat a label), writes it through `note add`'s path as the person's own long-term note and rejects the candidate, skip leaves it pending, quit or end of input stops. Candidate text is printed with control and format characters flattened and bounded at 600 characters. A bare `amem review` still lists, so existing scripts hold. + +## Doctor and ingest/sync cadence (F15, F16, AC8) + +**`amem doctor [--json]`** is one report over the memory home, person-only like every other command (the agent-marker refusal runs first, so an agent shell never opens the home). Its sections, in this fixed order, each carry `status` `ok`, `attention` or `unavailable` (with a `reason`: a section that cannot be checked says so, never a silent zero), and the report carries the worst of them: + +| section | what it says | +| --- | --- | +| `duplicates` | notes of this project whose kind, title and text are identical after case and whitespace folding (ids only, never text), plus the imported-duplicate groups `amem dedupe` previews | +| `stale` | notes whose every `path:` reference is gone from the repository (needs doctor run inside this project's repository, else `skipped`) | +| `candidates` | the pending summary `amem status` shows; `attention` when one is within 7 days of expiry. Doctor never stamps a candidate as shown | +| `capture` | per CLI (`claude`, `codex`, `kimi`, `pi`): `hook` `registered` (a setup receipt in the home's `setup/` directory, phase `applied`, naming that CLI) or `manual`; `capture` `fresh`, `stale` (older than 7 days) or `never`; the last capture time and row count. A registered hook that is not fresh, and any CLI that went stale, is `attention`; a CLI with no hook that never captured is reported, not flagged | +| `brief` | the SessionStart brief's real size for this project (`tokens`, `budget`, coverage), `attention` when partial or over 90% of the budget | +| `index` | the live index, snapshotted and brought level with the logs, equals a full rebuild of the same logs (both in the scratch copy). A curated split or generation home is `skipped` (its build already proves equality) | +| `readers` | reader leases of live processes, of processes that are gone, and unreadable ones. Doctor takes none itself | +| `policy` | the configuration in effect: which files, deny-path and PII-pattern counts, the byte budgets | +| `cadence` | per CLI how intake runs (`hooks`, `session end` or `manual`) and the sync schedule (below) | + +**What "read-only" means.** Doctor writes nothing into the memory home: no file is created, changed, checkpointed or removed there, and it takes no reader lease. `test/doctor.test.ts` hashes every file of a populated home before and after the FIRST run (no warm-up), `-wal` files included; it normalises two kinds of SQLite bookkeeping and nothing else: a `-shm` file (wal-index scratch memory, which any read-only open changes) and an EMPTY `-wal` file. On Linux a database closed cleanly leaves neither, and a read-only open of it (the snapshot of the live index, the candidate and capture reads) creates them again, empty; macOS leaves them in place (Ubuntu CI showed this). A `-wal` holding data, every database, log, lock and sidecar are compared byte for byte, and the test first removes both so macOS takes the same path as Linux. A home nobody used stays absent, and so does a project the home has not seen. How: + +- Every check that needs a store runs on a private scratch copy in a temporary directory, removed afterwards: the project's (and its aliases') curated logs, the lifecycle and alias logs, and the project's work logs in a base of their own. The first version opened the live store through `curatedStore()`; a review probe showed that rewrites `index.db` and `search.db`, checkpoints both WALs and writes a reader lease, and that the brief's work list recreated its lock file and re-ran its migration. +- The live shared-v1 index is only snapshotted (a read-only connection and `VACUUM INTO`), brought level with the copied logs the way the next reader would, and compared with a full rebuild of the same logs. A curated split or generation home builds and seals its index when it is created, so that section is `skipped` there (`amem rebuild` repeats the proof). +- The brief is measured with `sessionStartBrief({ measureOnly })`, used by doctor alone: it takes no reader lease, reads the curated rows from the scratch store and the work list from the scratch work base (a work log with no index is still counted, which the first version under-reported), and opens this machine's capture database read-only and only when it exists. Without a scratch work base the work section is `unavailable`, never `complete`. It measures the brief the SessionStart worker builds; the native parent's deferred brief is smaller and is not what is measured. The hook path does not set `measureOnly`. +- Duplicates and stale notes read at most the newest 100 000 notes of the project; a project with more says `truncated`. More than 64 MiB of logs is `unavailable`, not a partial answer. + +`skipped` means nothing to check here, with the reason (no curated notes yet, not run inside the repository, a split home); it does not degrade the overall status. `unavailable` means a check could not run. + +**`--json` is stable (AC8).** `amem.doctor/v1`. `test/doctor-snapshot.test.ts` pins every key, the type of every value and the fixed vocabulary against `test/fixtures/doctor-snapshot.json`, for a populated and for an unused home. Adding a key or changing a type or a word changes the fixture, so a reviewer sees it. + +**F16, cadence without a daemon.** + +- *Ingest.* Already done by `amem setup` (#417): the SessionEnd hook of Codex and Kimi, and Pi's `session_shutdown` extension, run `amem ingest ` within the hook budget (3 s, 2.5 s for Pi) as a short-lived process with no watcher. Claude captures through its seven bounded hooks. Doctor reads the setup receipts and reports a CLI with no registered hook as `manual`, with the command to register it. "Registered" is evidence from the receipt (`hookEvidence: "setup receipt"`): a hook the person removed by hand afterwards still shows as registered until `amem setup --apply` or `--undo` runs again. +- *Sync (owner decision O3: off by default).* amem registers **no** sync hook and starts **no** resident process. `amem sync --if-due` is the opt-in entry the person calls from their own shell (for example an EXIT trap); it needs a terminal like every mutating command, so an unattended scheduler is not a caller. **A CLI's SessionEnd hook cannot run it, deliberately (claude's F16 ruling, final):** a hook has no terminal and may carry an agent marker, and the person-only gate on a network and git write is not weakened for one flag. The person who wants sync at the end of a session puts the command in their own shell, not in a hook amem registers. It does nothing without a configured remote (no `.git` is created), nothing when it is not due, and it stops a running git step after 30 seconds: git runs in its own process group for that call, so the stop reaches the transport children git started (ssh, a credential helper) and not git's pid alone. Because that group no longer receives what the terminal sends to amem, amem turns Ctrl-C (SIGINT), SIGTERM, SIGHUP (the terminal closed, which is also how an EXIT trap's shell goes away) and SIGQUIT into the stop (`on`, not `once`, so a second Ctrl-C is not the default action that would end amem before git), and a process that exits for any other reason signals every live git group first (an exit handler, installed when the first deadline git starts). Tested with the real binary under a pty for each of the four signals, three Ctrl-Cs in a row and a group SIGHUP, and with a process that dies by an uncaught throw. The one thing nobody can handle is SIGKILL of amem itself (or a power loss): the step timer dies with amem, so a git already running is left with nothing to stop it and runs until it finishes or its transport gives up. The next sync takes over `sync.lock` once amem's pid is gone and removes `index.lock`, which can then land under that live git. This is the existing hazard of any killed sync, and `--if-due` does not widen it; it is not closed here (`ponytail`: a group-aware stale-lock check in `sync.ts`, if a killed sync is ever seen to corrupt the index). The 30 seconds bound the git steps. It does not bound the `gh` visibility lookup (its own 15 s limit) or the cleanup `git merge --abort` after an interrupted merge, which deliberately ignore the stop so the repository is left clean (up to 60 s). After a success the next attempt is due an hour later; after a failure the wait is 15 minutes and doubles with each further failure up to a day. `--force` (only with `--if-due`) ignores the schedule and the backoff. The only state is `sync-state.json` beside the logs: two times and a failure count, never an error text (a git error can carry a remote URL), mode 0600, written by every `amem sync`, plain or scheduled. A damaged or hand-edited stamp, and one dated in the future, read as no history. Only a transport failure counts: the real result is read (also under `--json`, which returns a failed sync instead of throwing), and a refusal no remote ever saw (another sync or mutation holds the lock, no remote is configured, the policy declines a note, the remote is public, a usage error) is not stamped, so it neither backs the schedule off nor makes a contended run look like a failed one. The read-modify-write of the stamp is not locked: two syncs finishing together can lose one count, which at worst delays a backoff by one step (`ponytail`: a lock if a scheduler ever runs several at once). Doctor reports the remote as `configured` or `none` (never the URL; read from the active profile's own repository: the shard cache of a split home, `generations//.git` of a generation home, `.git` of shared-v1), the last attempt and success, the failure count and the next attempt, and is `attention` when a remote has had no success for 7 days or has failed 3 times in a row. +- *Option A, rejected.* A detached bounded `amem sync` child started from the SessionEnd hook would hand network and git work to a hook. `AGENTS.md` forbids a model or network call on the capture path and a resident process, and `test/hooks-network.test.ts` runs the executable hooks, the SessionStart brief, native ingest and the capture append under network tripwires and observes no attempt, so a sync wired into them would have to change that contract. A detached child outlives the hook's own budget and cannot be accounted to it. The choice stays with the person: a hook is not asked to do it. `test/sync-cadence.test.ts` greps the setup, hook and ingest sources for any sync wiring and the cadence module for any process or timer. diff --git a/src/cli.ts b/src/cli.ts index 08f1247..c13acb5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,7 +4,7 @@ import { createStandalonePolicy } from "./memory/policy.ts"; import { configuredPolicy, configuredPolicyOptions, loadAmemConfig, repoConfigRoot } from "./memory/config.ts"; import { vaultCandidates } from "./memory/vault.ts"; import { importClaudeMemWorkState } from "./memory/import.ts"; -import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, readSync, rmSync, writeFileSync } from "node:fs"; +import { closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readFileSync, readSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { MemoryStore, looksSecret, machineId, openShardStore, type StoreOptions } from "./memory/store.ts"; import { rawMemoryCommand, rawGoalCommand } from "./memory/commands.ts"; @@ -36,6 +36,8 @@ import { evalSeeded, evalStore, FIXTURE_PATH, readEvalSet } from "./memory/eval- import { markShown, pendingSummary } from "./memory/candidate-pending.ts"; import { buildNote, FAIL_TEMPLATE, hasFailLabel, noteAdd, parseFailTemplate, type FailFields } from "./memory/note-add.ts"; import { runEditor } from "./memory/editor.ts"; +import { doctorReport, doctorText } from "./memory/doctor.ts"; +import { nextSyncAt, readSyncState, recordSyncAttempt, remoteConfigured, syncDue, stopSignal, syncOutcome, usableState, SYNC_IF_DUE_DEADLINE_MS } from "./memory/sync-cadence.ts"; export const PERSON_ONLY = "amem is for the person: an agent reads memory through its `amem mcp` server (see `amem --help`); the person reviews what agents propose with `amem review`"; const VERSION = (JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string }).version; @@ -126,6 +128,13 @@ function curatedStore(project: string, opts: StoreOptions, reconciled = true): M return new MemoryStore(withAliases); } +/** The same store `curatedStore` opens, on another base: doctor's private scratch copy. No lifecycle gate here, the copy carries the home's lifecycle log and doctor read it already. */ +function curatedStoreAt(base: string, project: string, opts: StoreOptions): MemoryStore { + const aliases = aliasedKeys(base, project), withAliases: StoreOptions = aliases.length ? { ...opts, reconcile: { canonical: project, aliases } } : opts; + if (existsSync(join(base, "shards.json"))) { readShardedSelection(base); return openShardStore({ ...withAliases, memoryDir: base, project }); } + return new MemoryStore({ ...withAliases, memoryDir: base }); +} + export async function runCli(argv: readonly string[], context: CliContext = {}): Promise { if (argv.length === 1 && argv[0] === "mcp") { const memory = createMemoryMcp({ cwd: context.cwd ?? process.cwd(), base: memoryHome(context.env ?? process.env) }); @@ -212,6 +221,13 @@ export async function runCli(argv: readonly string[], context: CliContext = {}): return { ...summary, ...(summary.dueWithin7Days ? { warning: `${summary.dueWithin7Days} candidate${summary.dueWithin7Days === 1 ? "" : "s"} within 7 days of expiry: run \`amem review walk\`` } : {}) }; })(), ...shardMigrationStatus(where.home) }, null, 2); } + if (sub === "doctor") { + // F15: one read-only report; see src/memory/doctor.ts for exactly what "read-only" covers. + const where = resolveMemoryHome(), project = boundProject(); + const report = await doctorReport({ home: where.home, homeSource: where.source, cwd: policyCwd, project, aliases: aliasedKeys(where.home, project), now: Date.now(), + openCurated: base => curatedStoreAt(base, project, { deferPromotions: true }) }); + return options["--json"] ? JSON.stringify(report, null, 2) : doctorText(report); + } if (sub === "prune") { const applying = options["--apply"] === true; if (applying ? options["--dry-run"] || options["--cutoff"] || !options["--plan"] : !options["--dry-run"] || !options["--cutoff"] || options["--plan"]) throw new Error("usage: amem prune [--user] --cutoff --dry-run | [--user] --apply --plan "); @@ -542,9 +558,36 @@ export async function runCli(argv: readonly string[], context: CliContext = {}): // A person's reads are unscreened. A write is screened by the configured policy, which includes the built-in secret screen. if (goal) return rawGoalCommand(args, cwd, { project: boundProject(), open, clean: (title, milestones) => !mutating || configured.screenStandalone({ title, milestones }) }); const receipt: { bridgeStatus?: { skippedRows: number } } = {}; - const output = await withRepoRows(await rawMemoryCommand(args, cwd, { project: boundProject(), open, readable: notes => notes, importPolicy: () => text => configured.declinesPersonal(text), - sync: { screen: row => configured.screenStandalone(row) }, // the repository-narrowed policy binds what sync publishes too - onBridgeStatus: status => { receipt.bridgeStatus = status; } })); + // F16: `sync --if-due` is the entry for the person's own shell (an EXIT trap; it needs a terminal like every mutating command). amem registers no sync hook and runs nothing between sessions. It does nothing without a + // configured remote, nothing when the schedule or the failure backoff says it is not due (`--force` skips that), and it stops a running git step after 30 seconds. + const ifDue = sub === "sync" && options["--if-due"] === true, syncHome = memoryHome(); + if (options["--force"] === true && !ifDue) throw new Error("--force only applies with sync --if-due"); + if (ifDue) { + if (options["--remote"] === undefined && !remoteConfigured(syncHome)) return options["--json"] ? JSON.stringify({ ok: true, skipped: "no sync remote configured" }) : "no sync remote configured: nothing to do"; + const state = usableState(readSyncState(syncHome), Date.now()); + if (!syncDue(state, Date.now(), options["--force"] === true)) { + const next = nextSyncAt(state); + return options["--json"] ? JSON.stringify({ ok: true, skipped: "not due", nextAttemptAt: next === null ? null : new Date(next).toISOString(), failures: state.failures }) + : `sync not due until ${next === null ? "now" : new Date(next).toISOString()}${state.failures ? ` (${state.failures} failure${state.failures === 1 ? "" : "s"} in a row: the wait doubles up to a day; --force skips it)` : ""}`; + } + } + // Only a real attempt is stamped: a local refusal (lock held, no remote, policy, usage) is not, and a future stamp is no history (src/memory/sync-cadence.ts). + const stampSync = (error: string | undefined) => { if (syncOutcome(error) !== "local") recordSyncAttempt(syncHome, error === undefined, Date.now()); }; + const syncArgs = args.filter(arg => arg !== "--if-due" && arg !== "--force"); + let output: string; + const stop = ifDue ? stopSignal(SYNC_IF_DUE_DEADLINE_MS, process) : undefined; + try { + output = await withRepoRows(await rawMemoryCommand(syncArgs, cwd, { project: boundProject(), open, readable: notes => notes, importPolicy: () => text => configured.declinesPersonal(text), + sync: { screen: row => configured.screenStandalone(row), ...(stop ? { signal: stop.signal } : {}) }, // the repository-narrowed policy binds what sync publishes too + onBridgeStatus: status => { receipt.bridgeStatus = status; } })); + } catch (error) { if (sub === "sync") stampSync((error as Error).message); throw error; } + finally { stop?.dispose(); } + if (sub === "sync") { + // `--json` returns the failed result instead of throwing: read the real outcome from it, never assume a returned string means the sync worked. + let failure: string | undefined; + if (options["--json"]) { try { const result = JSON.parse(output) as { ok?: unknown; error?: unknown }; if (result.ok === false) failure = typeof result.error === "string" ? result.error : "sync failed"; } catch { /* not a sync result */ } } + stampSync(failure); + } if (sub === "import" && parsed.path[1] === "claude-mem") { const policy = configured; const work = await importClaudeMemWorkState({ base: memoryHome(), project: boundProject(), policy: row => policy.screen(row), ...(options["--db"] ? { db: String(options["--db"]) } : {}), ...(options["--limit"] ? { limit: Number(options["--limit"]) } : {}), restart: options["--restart"] === true }); diff --git a/src/commands-table.ts b/src/commands-table.ts index 87ca26e..c283258 100644 --- a/src/commands-table.ts +++ b/src/commands-table.ts @@ -68,7 +68,8 @@ export const COMMANDS: Record = { supersede: { description: "Replace a note", options: json, args: [{ name: "old-id" }, { name: "new-id" }], mutates: true }, rebuild: { description: "Rebuild the derived index", options: json, mutates: true }, dedupe: { description: "Preview duplicate reconciliation", options: { ...json, "--apply": flag("Append reconciliation operations") }, mutates: "apply" }, - sync: { description: "Sync a private remote", options: { ...json, ...policy, "--remote": text("Private git URL") }, mutates: true, policyFree: true }, + sync: { description: "Sync a private remote (--if-due: only when the schedule and the failure backoff allow it; --force ignores both)", options: { ...json, ...policy, "--remote": text("Private git URL"), + "--if-due": flag("Sync only when due (hourly after a success, 15 minutes doubling to a day after failures); stops after 30 seconds"), "--force": flag("With --if-due: ignore the schedule and the backoff") }, mutates: true, policyFree: true }, machine: { description: "Show or label this machine", options: { ...json, "--label": text("Machine label") }, mutates: "label" }, import: { description: "Import external memory", options: { ...json, ...policy, "--db": { description: "SQLite database file", value: "path" }, "--limit": text("Maximum observations"), "--restart": flag("Restart import cursor") }, mutates: true, policyFree: true, commands: { "claude-mem": { description: "Import claude-mem observations" } } }, @@ -96,6 +97,7 @@ export const COMMANDS: Record = { "--shards": flag("Migrate the curated home into project and user shards"), } }, status: { description: "Report shard migration and pending old-layout appends", options: json }, + doctor: { description: "One read-only report (F15): duplicates and stale notes, pending candidates, capture freshness per CLI, the brief's token cost, index equals rebuild, reader leases, the policy in effect, ingest and sync cadence", options: json }, prune: { description: "Plan or apply exact-entry durable expiry in a generation", mutates: true, options: { ...json, "--cutoff": text("Canonical UTC expiry cutoff"), "--dry-run": flag("Print a local sealed plan without writes"), "--user": flag("Select the user shard explicitly after a curated split (default: current project)"), diff --git a/src/memory/doctor.ts b/src/memory/doctor.ts new file mode 100644 index 0000000..8d9b6c0 --- /dev/null +++ b/src/memory/doctor.ts @@ -0,0 +1,336 @@ +import { copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Database } from "bun:sqlite"; +import { sessionStartBrief } from "../session-brief.ts"; +import { pendingSummary } from "./candidate-pending.ts"; +import { walkShards } from "./catalog.ts"; +import { loadAmemConfig, repoConfigRoot } from "./config.ts"; +import { readSelector } from "./generation.ts"; +import { processLiveness } from "./platform.ts"; +import { boundRepoRoot } from "./repo-scope.ts"; +import { shardDir } from "./shard.ts"; +import type { MemoryStore, View } from "./store.ts"; +import { nextSyncAt, readSyncState, remoteConfigured, syncDue, usableState } from "./sync-cadence.ts"; + +/** + * F15, `amem doctor`: one report over the memory home. It writes nothing into the home. Every check that needs a store (the notes, the brief, the index proof) runs on a + * private scratch copy of the logs in a temporary directory that is removed afterwards: opening the live store would take a reader lease, refresh and checkpoint the live + * index, and the live work list would run its migration. The live index is only snapshotted (a read-only connection and `VACUUM INTO`), so "index equals rebuild" compares the + * index the way a reader would refresh it with a full rebuild, without anyone's index being touched. A home nobody used, and a project the home has not seen, stay absent. + * Every section is `ok`, `attention` (something to look at), `skipped` (nothing to check here, with the reason: not a problem) or `unavailable` (it could not be + * checked, with the reason), never a silent zero. + */ +export type SectionStatus = "ok" | "attention" | "skipped" | "unavailable"; +export type Section> = { status: SectionStatus } & T; +export const DOCTOR_SCHEMA = "amem.doctor/v1"; +export const CLIS = ["claude", "codex", "kimi", "pi"] as const; +export type Cli = typeof CLIS[number]; +/** A CLI whose last capture is older than this is called stale. */ +export const CAPTURE_STALE_MS = 7 * 86400_000; +/** A sync remote whose last success is older than this needs attention. */ +export const SYNC_STALE_MS = 7 * 86400_000; +const DEFAULT_BRIEF_TOKENS = 1200; +const COPY_LIMIT_BYTES = 64 * 1024 * 1024; +/** Notes read per project for the duplicate and stale checks; a report says when a project has more. */ +export const LIST_ALL = 100_000; +const MAX_IDS = 10; + +export interface DoctorOptions { + home: string; homeSource: string; cwd: string; project: string; aliases: readonly string[]; now: number; + /** Opens this project's curated store on `base`, the way the other person reads do (a split home through its project shard). Doctor passes a private scratch base, never the live home. */ + openCurated: (base: string) => MemoryStore; + /** The cadence command the person runs from their own shell; shown in the report, never installed. */ + syncCommand?: string; +} +export interface DoctorReport { + schema: typeof DOCTOR_SCHEMA; + status: SectionStatus; + home: { path: string; source: string }; + project: string; + sections: { + duplicates: Section; stale: Section; candidates: Section; capture: Section; brief: Section; index: Section; readers: Section; policy: Section; cadence: Section; + }; +} + +const worst = (values: SectionStatus[]): SectionStatus => values.includes("attention") ? "attention" : values.includes("unavailable") ? "unavailable" : "ok"; +const message = (error: unknown): string => (error instanceof Error ? error.message : String(error)).split("\n")[0]!.slice(0, 200); +const unavailable = (reason: string): Section => ({ status: "unavailable", reason }); +const skipped = (reason: string): Section => ({ status: "skipped", reason }); +const ids = (list: string[]): string[] => list.slice(0, MAX_IDS).map(id => id.slice(0, 8)); +const regularDir = (path: string): boolean => { try { const stat = lstatSync(path); return stat.isDirectory() && !stat.isSymbolicLink(); } catch { return false; } }; +const regularFile = (path: string): boolean => { try { const stat = lstatSync(path); return stat.isFile() && !stat.isSymbolicLink(); } catch { return false; } }; + +type Layout = "shared-v1" | "generation" | "split"; +function layoutOf(home: string): Layout { + if (existsSync(join(home, "shards.json"))) return "split"; + return readSelector(home).selector?.kind === "generation" ? "generation" : "shared-v1"; +} + +/** Whether this project's curated notes already exist, so there is something to copy. */ +function curatedPresent(home: string, project: string, layout: Layout): boolean { + if (layout === "split") return existsSync(join(shardDir(home, project), "generation.json")); + if (layout === "generation") return true; + return regularDir(join(home, "log")); +} + +/** What a scratch copy leaves out: databases, locks, repositories, the machine-local capture and work trees, spools and ingest cursors. */ +const SKIP = /\.db(-wal|-shm)?$|\.lock$|\.tmp$|^\.git$|\.git$|^spool$|^ingest$|^readers$/; +class TooBig extends Error {} +function copyTree(from: string, to: string, budget: { bytes: number }, skipNames: ReadonlySet = new Set()): void { + mkdirSync(to, { recursive: true, mode: 0o700 }); + for (const name of readdirSync(from)) { + if (SKIP.test(name) || skipNames.has(name)) continue; + const source = join(from, name), stat = lstatSync(source); + if (stat.isSymbolicLink()) throw new Error("the memory home holds a link where a plain file or directory is expected"); + if (stat.isDirectory()) copyTree(source, join(to, name), budget, skipNames); + else if (stat.isFile()) { budget.bytes += stat.size; if (budget.bytes > COPY_LIMIT_BYTES) throw new TooBig(); copyFileSync(source, join(to, name)); } + } +} + +/** The curated side of the home, without any database: the logs of this project (and its aliases), and the small files that bind them. */ +function copyCurated(options: DoctorOptions, layout: Layout, scratch: string, budget: { bytes: number }): void { + const home = options.home; + for (const name of readdirSync(home)) { // top-level plain files: machine.json, shards.json, profile.json, aliases.jsonl, lifecycle.jsonl, the configuration + if (SKIP.test(name) || !regularFile(join(home, name))) continue; + const stat = statSync(join(home, name)); budget.bytes += stat.size; if (budget.bytes > COPY_LIMIT_BYTES) throw new TooBig(); + copyFileSync(join(home, name), join(scratch, name)); + } + if (layout === "split") { + for (const key of [options.project, ...options.aliases]) { + const dir = shardDir(home, key); + if (existsSync(join(dir, "generation.json"))) copyTree(dir, join(scratch, "projects", dir.split("/").pop()!), budget, new Set(["machine", "work"])); + } + if (regularDir(join(home, "user"))) copyTree(join(home, "user"), join(scratch, "user"), budget); + } else { + if (layout === "generation") copyTree(join(home, "generations"), join(scratch, "generations"), budget); + for (const folder of ["log", "local"]) if (regularDir(join(home, folder))) copyTree(join(home, folder), join(scratch, folder), budget); // shared-v1's logs; a generation's legacy input + } +} + +/** The work logs of this project and its aliases, in a private base of their own: the brief opens a work list read-write and migrates its index. */ +function copyWork(options: DoctorOptions, base: string, budget: { bytes: number }): boolean { + let any = false; + for (const name of ["machine.json", "aliases.jsonl", "lifecycle.jsonl"]) if (regularFile(join(options.home, name))) { mkdirSync(base, { recursive: true, mode: 0o700 }); copyFileSync(join(options.home, name), join(base, name)); } + for (const key of [options.project, ...options.aliases]) { + const dir = shardDir(options.home, key), log = join(dir, "work", "log"); + if (!regularDir(log)) continue; + copyTree(log, join(base, "projects", dir.split("/").pop()!, "work", "log"), budget); + if (regularFile(join(dir, "project.json"))) copyFileSync(join(dir, "project.json"), join(base, "projects", dir.split("/").pop()!, "project.json")); + any = true; + } + return any; +} + +/** A snapshot of the live shared-v1 index (a read-only connection, so no checkpoint, no lease), or false when there is none. */ +function snapshotIndex(live: string, to: string): boolean { + const file = join(live, "index.db"); + if (!regularFile(file)) return false; + const forms: (() => Database)[] = [ + () => new Database(file, { readonly: true }), + () => new Database(`file:${encodeURI(file).replace(/\?/g, "%3F").replace(/#/g, "%23")}?immutable=1`, { readonly: true } as ConstructorParameters[1]), + ]; + for (const open of forms) { + let db: Database | undefined; + try { db = open(); db.run("PRAGMA busy_timeout = 1000"); db.run(`VACUUM INTO '${to.replaceAll("'", "''")}'`); return true; } + catch { /* the next form */ } finally { try { db?.close(); } catch { /* nothing to close */ } } + } + throw new Error("the live index could not be snapshotted"); +} + +function duplicatesAndStale(options: DoctorOptions, rows: View[], store: MemoryStore): { duplicates: Section; stale: Section } { + const groups = new Map(); + for (const row of rows) { + const key = JSON.stringify([row.kind, (row.title ?? "").trim().toLowerCase(), row.text.replace(/\s+/g, " ").trim().toLowerCase()]); + groups.set(key, [...(groups.get(key) ?? []), row.id]); + } + const same = [...groups.values()].filter(group => group.length > 1).map(group => group.sort()); + let legacy = 0; + try { legacy = store.dedupe(options.project, false).groups.length; } catch { /* the legacy preview is optional */ } + const truncated = rows.length >= LIST_ALL; + const duplicates: Section = { status: same.length || legacy || truncated ? "attention" : "ok", notes: rows.length, identicalGroups: same.length, + ...(same.length ? { examples: same.slice(0, MAX_IDS).map(group => group.map(id => id.slice(0, 8))) } : {}), importedGroups: legacy, + ...(truncated ? { truncated: `only the newest ${LIST_ALL} notes were read` } : {}), + ...(legacy ? { hint: "amem dedupe previews the imported duplicates; amem dedupe --apply reconciles them" } : {}) }; + const root = boundRepoRoot(options.cwd, options.home, options.project); + let stale: Section; + if (!root) stale = skipped("run doctor from inside this project's repository to check its path references"); + else { + const missing = rows.filter(row => { const paths = row.about.filter(node => node.startsWith("path:")).map(node => node.slice(5)); return paths.length > 0 && paths.every(path => !existsSync(join(root, path))); }); + stale = { status: missing.length ? "attention" : "ok", checked: rows.length, missingReferences: missing.length, ...(missing.length ? { examples: ids(missing.map(row => row.id)) } : {}) }; + } + return { duplicates, stale }; +} + +/** The incremental index, as a reader would refresh it from the snapshot of the live one, against a full rebuild of the same logs. Both live in the scratch copy. */ +function indexSection(store: MemoryStore, layout: Layout, snapshotted: boolean): Section { + if (layout !== "shared-v1") return skipped("a curated split or generation home builds and seals its index when it is created; amem rebuild repeats it"); + try { + store.list({ limit: 1 }); // brings the snapshot level with the logs, as the next reader would + const incremental = store.dump(); store.rebuild(); + return incremental === store.dump() ? { status: "ok", liveIndex: snapshotted ? "snapshotted" : "absent" } + : { status: "attention", liveIndex: snapshotted ? "snapshotted" : "absent", hint: "the index differs from a rebuild of the logs: run amem rebuild" }; + } catch (error) { return unavailable(message(error)); } +} + +function receiptPhase(home: string, cli: Cli): "applied" | "prepared" | null { + try { + const file = join(home, "setup", `${cli}.json`), stat = lstatSync(file); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 4 * 1024 * 1024) return null; + const value = JSON.parse(readFileSync(file, "utf8")) as { target?: unknown; phase?: unknown }; + return value.target === cli && (value.phase === "applied" || value.phase === "prepared") ? value.phase : null; + } catch { return null; } +} + +/** + * Per CLI: is a capture hook registered, and when did it last capture. "Registered" is what the setup receipt says (`hookEvidence`): a hook the person removed by hand + * afterwards still shows as registered until `amem setup --apply` or `--undo` is run again. A CLI without a receipt is `manual`. + */ +function captureSection(options: DoctorOptions): Section { + const keys = [options.project, ...options.aliases]; + const seen = new Map(); + let readable = true; + for (const shard of walkShards(options.home)) { + if (!keys.includes(shard.key)) continue; + for (const dir of shard.machineDirs) { + const file = join(dir, "capture.db"); + if (!existsSync(file)) continue; + let db: Database | undefined; + try { + db = new Database(file, { readonly: true }); db.run("PRAGMA busy_timeout = 1000"); + for (const row of db.query("SELECT agent, COUNT(*) AS n, MAX(at) AS last FROM rows GROUP BY agent").all() as { agent: string; n: number; last: string | null }[]) { + const prior = seen.get(row.agent); + seen.set(row.agent, { rows: (prior?.rows ?? 0) + row.n, last: prior?.last && prior.last > (row.last ?? "") ? prior.last : row.last }); + } + } catch { readable = false; } finally { db?.close(); } + } + } + const clis: Record = {}; + const attention: string[] = []; + for (const cli of CLIS) { + const phase = receiptPhase(options.home, cli), registered = phase === "applied", entry = seen.get(cli); + const last = entry?.last ?? null, ageMs = last === null ? null : options.now - Date.parse(last); + const state = last === null ? "never" : ageMs! > CAPTURE_STALE_MS ? "stale" : "fresh"; + // A registered hook that is not fresh, or any CLI whose capture went stale, needs a look. A CLI without a hook that never captured is just not used here: reported, not flagged. + const needs = registered ? state !== "fresh" : state === "stale"; + if (needs) attention.push(cli); + clis[cli] = { hook: registered ? "registered" : "manual", ...(registered ? { hookEvidence: "setup receipt" } : {}), ...(phase === "prepared" ? { setup: "prepared, not applied" } : {}), capture: state, lastCaptureAt: last, rows: entry?.rows ?? 0, + ...(needs || (!registered && state === "never") ? { hint: registered ? `the ${cli} hook is registered but its capture is ${state}: open a session${cli === "claude" ? "" : `, or run amem ingest ${cli}`}` : cli === "claude" ? "run amem setup claude --apply to register the capture hooks" : `run amem setup ${cli} --apply, or amem ingest ${cli} by hand` } : {}) }; + } + return { status: !readable ? "unavailable" : attention.length ? "attention" : "ok", ...(readable ? {} : { reason: "a capture database could not be read" }), clis }; +} + +/** + * The SessionStart brief's size for this project: the brief the SessionStart worker builds (the native parent's deferred brief is smaller and is not what is measured). + * It is built from the scratch copy of the curated logs and of the work logs; the capture section reads this machine's capture database read-only and only when it exists. + */ +async function briefSection(options: DoctorOptions, curated: MemoryStore | undefined, workBase: string | undefined, copyFailed: string | undefined): Promise
{ + if (copyFailed) return unavailable(copyFailed); + let configured: number | undefined; + try { configured = loadAmemConfig(options.home, repoConfigRoot(options.cwd)).config.briefBytes; } catch { /* the policy section reports it */ } + const budget = Math.min(DEFAULT_BRIEF_TOKENS, configured ?? Number.MAX_SAFE_INTEGER); + try { + const result = await sessionStartBrief({ cwd: options.cwd, base: options.home, project: options.project, now: () => options.now, + measureOnly: { ...(curated ? { curated } : {}), ...(workBase ? { workBase } : {}) } }); + if (result.context === "") return unavailable("the brief is empty for this project (archived, forgotten or its binding changed)"); + const tight = result.tokens > budget * 0.9; + return { status: result.partial || tight ? "attention" : "ok", tokens: result.tokens, budget, coverage: result.coverage, + ...(result.partial ? { hint: "a section is partial or unavailable in the brief: see coverage" } : tight ? { hint: "the brief uses over 90% of its budget" } : {}) }; + } catch (error) { return unavailable(message(error)); } +} + +function readersSection(options: DoctorOptions): Section { + const dir = join(options.home, "readers"); + if (!existsSync(dir)) return { status: "ok", live: 0, stale: 0, unreadable: 0 }; + if (!regularDir(dir)) return unavailable("the readers directory is not a plain directory"); + let live = 0, stale = 0, unreadable = 0; + try { + for (const name of readdirSync(dir).filter(file => file.endsWith(".json")).sort()) { + try { + const stat = lstatSync(join(dir, name)); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 4096) throw new Error(); + const value = JSON.parse(readFileSync(join(dir, name), "utf8")) as { pid?: unknown; signature?: unknown }; + const liveness = processLiveness(value.pid, typeof value.signature === "string" ? value.signature : undefined); + if (liveness === "gone") stale++; else live++; + } catch { unreadable++; } + } + } catch (error) { return unavailable(message(error)); } + return { status: stale || unreadable ? "attention" : "ok", live, stale, unreadable, ...(stale || unreadable ? { hint: "a reader lease of a process that is gone is cleared by the next reader; an unreadable one needs a look" } : {}) }; +} + +function policySection(options: DoctorOptions): Section { + try { + const { config, source } = loadAmemConfig(options.home, repoConfigRoot(options.cwd)); + return { status: "ok", machine: source.machine, repo: source.repo, denyPaths: config.denyPaths.length, piiPatterns: config.piiPatterns.length, + ...(config.briefBytes !== undefined ? { briefBytes: config.briefBytes } : {}), ...(config.excerptBytes !== undefined ? { excerptBytes: config.excerptBytes } : {}), + ...(config.capturePaths !== undefined ? { capturePaths: config.capturePaths } : {}) }; + } catch (error) { return unavailable(message(error)); } +} + +/** F16: which CLIs ingest at SessionEnd (setup receipts), and the person-owned sync cadence. amem registers no sync hook (a hook may not do network work). */ +function cadenceSection(options: DoctorOptions): Section { + const ingest: Record = {}; + for (const cli of CLIS) ingest[cli] = receiptPhase(options.home, cli) === "applied" ? (cli === "claude" ? "hooks" : "session end") : "manual"; + const remote = remoteConfigured(options.home), state = usableState(readSyncState(options.home), options.now), next = nextSyncAt(state); + const command = options.syncCommand ?? "amem sync --if-due"; + const stale = remote && (state.lastOkAt === null || options.now - Date.parse(state.lastOkAt) > SYNC_STALE_MS || state.failures >= 3); + const sync = { remote: remote ? "configured" : "none", lastAttemptAt: state.lastAttemptAt, lastOkAt: state.lastOkAt, failures: state.failures, + nextAttemptAt: next === null ? null : new Date(next).toISOString(), due: remote ? syncDue(state, options.now) : false, + ...(remote ? { schedule: `run \`${command}\` from your own shell (for example a zshexit or bash EXIT trap): it needs a terminal like every mutating command; amem registers no sync hook and runs nothing between sessions` } : {}) }; + const manual = CLIS.filter(cli => ingest[cli] === "manual"); + return { status: stale ? "attention" : "ok", ingest, ...(manual.length ? { manual } : {}), sync, + ...(stale ? { hint: state.failures >= 3 ? "sync has failed repeatedly; amem sync --force shows why" : "no recent successful sync: run amem sync" } : {}) }; +} + +export async function doctorReport(options: DoctorOptions): Promise { + let scratch: string | undefined; + let duplicates: Section, stale: Section, index: Section; + let curated: MemoryStore | undefined, workBase: string | undefined, copyFailed: string | undefined; + try { + let layout: Layout | undefined; + try { layout = layoutOf(options.home); } catch (error) { throw new Error(`the memory profile cannot be read: ${message(error)}`); } + scratch = mkdtempSync(join(tmpdir(), "amem-doctor-")); // a scratch directory, never inside the home: a home nobody used stays absent + const budget = { bytes: 0 }; + try { + // The work logs go to a private base of their own, whether or not the project has curated notes: the brief's work section opens a list read-write. + workBase = join(scratch, "work-base"); copyWork(options, workBase, budget); + if (!curatedPresent(options.home, options.project, layout)) { + duplicates = stale = index = skipped("no curated notes for this project yet"); + } else { + const home = join(scratch, "home"); mkdirSync(home, { mode: 0o700 }); + copyCurated(options, layout, home, budget); + const snapshotted = layout === "shared-v1" && snapshotIndex(options.home, join(home, "index.db")); + curated = options.openCurated(home); + const rows = curated.list({ project: options.project, limit: LIST_ALL }); + ({ duplicates, stale } = duplicatesAndStale(options, rows, curated)); + // The brief is measured after this and before the index proof's rebuild only because the brief reads rows, not the index tables. + index = indexSection(curated, layout, snapshotted); + } + } catch (error) { + copyFailed = error instanceof TooBig ? "the logs are over 64 MiB: the brief and the checks that need a copy were not run" : message(error); + workBase = undefined; duplicates = stale = index = unavailable(copyFailed); + } + } catch (error) { copyFailed = message(error); duplicates = stale = index = unavailable(copyFailed); } + let candidates: Section; + try { + const summary = pendingSummary(options.home, [options.project, ...options.aliases], options.now, true); + candidates = summary === null ? unavailable("candidates.db exists but cannot be read") + : { status: summary.dueWithin7Days ? "attention" : "ok", ...summary, ...(summary.dueWithin7Days ? { hint: "run amem review walk" } : {}) }; + } catch (error) { candidates = unavailable(message(error)); } + let sections: DoctorReport["sections"]; + try { + sections = { duplicates, stale, candidates, capture: captureSection(options), brief: await briefSection(options, curated, workBase, copyFailed), index, readers: readersSection(options), policy: policySection(options), cadence: cadenceSection(options) }; + } finally { try { curated?.close(); } finally { if (scratch) rmSync(scratch, { recursive: true, force: true }); } } + return { schema: DOCTOR_SCHEMA, status: worst(Object.values(sections).map(section => section.status)), home: { path: options.home, source: options.homeSource }, project: options.project, sections }; +} + +export function doctorText(report: DoctorReport): string { + const mark = (status: SectionStatus) => status === "ok" ? "ok " : status === "attention" ? "ATTENTION " : status === "skipped" ? "skipped " : "unavailable"; + const lines = [`amem doctor: ${report.status}`, `home ${report.home.path} (${report.home.source}), project ${report.project}`]; + for (const [name, section] of Object.entries(report.sections)) { + const { status, hint, reason, ...detail } = section as Section & { hint?: string; reason?: string }; + lines.push(`${mark(status)} ${name}: ${JSON.stringify(detail)}${reason ? ` (${reason})` : ""}`); + if (hint) lines.push(` -> ${hint}`); + } + return lines.join("\n"); +} diff --git a/src/memory/sync-cadence.ts b/src/memory/sync-cadence.ts new file mode 100644 index 0000000..e54354e --- /dev/null +++ b/src/memory/sync-cadence.ts @@ -0,0 +1,131 @@ +import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { generationDir, readSelector } from "./generation.ts"; + +/** + * F16, `amem sync --if-due`: the person's own shell (an EXIT trap) asks amem to sync when it is due; amem itself runs nothing between sessions and never + * registers a hook for it. Like every mutating command it needs a terminal, so an unattended scheduler (cron, launchd) is not a caller (a hook may not do network work: AGENTS.md, test/hooks-network.test.ts). The only state is one small stamp beside the logs. + * The stamp holds times and a failure count, never an error text (a git error can carry a remote URL). + */ +export interface SyncState { lastAttemptAt: string | null; lastOkAt: string | null; failures: number } +export const SYNC_STATE_FILE = "sync-state.json"; +/** After a success, the next attempt is due this long after the last one. */ +export const SYNC_INTERVAL_MS = 60 * 60_000; +/** After a failure the wait starts here and doubles with each further failure. */ +export const SYNC_BACKOFF_BASE_MS = 15 * 60_000; +export const SYNC_BACKOFF_CAP_MS = 24 * 60 * 60_000; +const MAX_FAILURES = 30, MAX_BYTES = 4096; +const EMPTY: SyncState = { lastAttemptAt: null, lastOkAt: null, failures: 0 }; +const iso = (value: unknown): string | null => typeof value === "string" && Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value ? value : null; + +/** Never throws: a missing, oversized or malformed stamp is "no history", which makes a sync due. */ +export function readSyncState(home: string): SyncState { + let fd: number | undefined; + try { + fd = openSync(join(home, SYNC_STATE_FILE), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + const stat = fstatSync(fd); + if (!stat.isFile() || stat.size > MAX_BYTES) return { ...EMPTY }; + const buffer = Buffer.alloc(stat.size), size = readSync(fd, buffer, 0, buffer.length, 0); + const value = JSON.parse(buffer.subarray(0, size).toString("utf8")) as Record; + const failures = Number.isSafeInteger(value.failures) && (value.failures as number) >= 0 ? Math.min(value.failures as number, MAX_FAILURES) : 0; + return { lastAttemptAt: iso(value.lastAttemptAt), lastOkAt: iso(value.lastOkAt), failures }; + } catch { return { ...EMPTY }; } + finally { if (fd !== undefined) try { closeSync(fd); } catch { /* nothing to close */ } } +} + +/** The wait after the last attempt: the plain interval after a success, 15 minutes doubling to a day after failures. */ +export function syncWaitMs(failures: number): number { + return failures <= 0 ? SYNC_INTERVAL_MS : Math.min(SYNC_BACKOFF_BASE_MS * 2 ** (failures - 1), SYNC_BACKOFF_CAP_MS); +} + +/** The earliest next attempt, or null when none was recorded (due now). */ +export function nextSyncAt(state: SyncState): number | null { + return state.lastAttemptAt === null ? null : Date.parse(state.lastAttemptAt) + syncWaitMs(state.failures); +} + +/** `force` (`--force`) ignores the schedule and the backoff. */ +export function syncDue(state: SyncState, now: number, force = false): boolean { + const next = nextSyncAt(state); + return force || next === null || now >= next; +} + +/** `--if-due` stops a running git step after this long, so a shell that runs it on exit is held for a bounded time. */ +export const SYNC_IF_DUE_DEADLINE_MS = 30_000; + +/** + * The stop signal of one `--if-due` run: the 30 second deadline, or any way the person ends it: Ctrl-C (SIGINT), SIGTERM, SIGHUP (the terminal closed, which is also how an + * EXIT trap's shell can go away) and SIGQUIT. git runs in its own process group while a stop signal is given (so the stop reaches its transport children), which also means a + * terminal's signals no longer reach git by themselves: amem turns each of them into the abort. `on`, not `once`: a second Ctrl-C must not fall back to the default action + * (which would end amem and strand git) while the first is still stopping git; the handlers stay until `dispose`. A process that dies some other way is covered by the exit + * handler in sync.ts, which signals every live git group. The emitter is a seam for tests; the CLI passes `process`. + */ +export const STOP_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP", "SIGQUIT"] as const; +export function stopSignal(ms: number, emitter: { on(name: string, fn: () => void): unknown; off(name: string, fn: () => void): unknown }, names: readonly string[] = STOP_SIGNALS): { signal: AbortSignal; dispose: () => void } { + const controller = new AbortController(), stop = () => controller.abort(); + for (const name of names) emitter.on(name, stop); + return { signal: AbortSignal.any([controller.signal, AbortSignal.timeout(ms)]), dispose: () => { for (const name of names) emitter.off(name, stop); } }; +} + +/** + * The repository the active profile syncs through: a curated split keeps a private cache in `shard-sync/objects.git`, a generation home keeps its own `.git` inside the + * generation directory (the memory home's own `.git` is then only the legacy input), and shared-v1 keeps `.git` in the memory home. Undefined when the profile cannot be + * resolved (an unreadable selector): no remote is then reported rather than a guess. + */ +function syncRepository(home: string): string | undefined { + try { + if (lstatSync(join(home, "shards.json"), { throwIfNoEntry: false })) return join(home, "shard-sync", "objects.git", "config"); + const { selector } = readSelector(home); + if (selector?.kind === "generation") return join(generationDir(home, selector.generation!), ".git", "config"); + return join(home, ".git", "config"); + } catch { return undefined; } +} + +/** + * Whether the active profile has a sync remote configured, from its repository's own config file: no git process, and never the URL (it can carry a credential). + */ +export function remoteConfigured(home: string): boolean { + const file = syncRepository(home); + if (file === undefined) return false; + let fd: number | undefined; + try { + fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + const stat = fstatSync(fd); + if (!stat.isFile() || stat.size > 65536) return false; + const buffer = Buffer.alloc(stat.size), size = readSync(fd, buffer, 0, buffer.length, 0); + return /^\s*\[remote "origin"\]/m.test(buffer.subarray(0, size).toString("utf8")); + } catch { return false; } // no such repository + finally { if (fd !== undefined) try { closeSync(fd); } catch { /* nothing to close */ } } +} + +/** A stamp from the future (a clock set back, a hand edit) would block the sync until then plus the wait: it counts as no history, and so does a success it claims. */ +export function usableState(state: SyncState, now: number): SyncState { + const future = (value: string | null) => value !== null && Date.parse(value) > now; + if (future(state.lastAttemptAt)) return { lastAttemptAt: null, lastOkAt: future(state.lastOkAt) ? null : state.lastOkAt, failures: 0 }; + return future(state.lastOkAt) ? { ...state, lastOkAt: null } : state; +} + +/** + * What a finished sync means for the schedule. `ok` clears the backoff. `local` is a refusal that the schedule cannot fix and that no remote ever saw (another sync or + * mutation holds the lock, no remote is configured, the configured policy declines a note, the remote is public, a usage error): it is not stamped, so it neither + * backs the schedule off nor makes a contended run look like a failed one. Everything else is a `failed` transport or git step, and an error this module does not + * recognise counts as failed (the safe side: it waits instead of retrying every minute). + */ +export type SyncOutcome = "ok" | "failed" | "local"; +const LOCAL = /^(usage:|another (sync|memory mutation|generation|shard sync)|no (generation )?sync remote|the configured privacy policy|the sync remote is public|this (machine's|store's) long-term|a line of this machine's long-term|more than \d+ long-term)/; +export function syncOutcome(error: string | undefined): SyncOutcome { + if (error === undefined) return "ok"; + return LOCAL.test(error.replace(/^amem sync: /, "")) ? "local" : "failed"; +} + +/** Records one attempt. A stamp that cannot be written never fails the sync it describes. */ +export function recordSyncAttempt(home: string, ok: boolean, at: number): SyncState { + const prior = readSyncState(home), when = new Date(at).toISOString(); + const next: SyncState = { lastAttemptAt: when, lastOkAt: ok ? when : prior.lastOkAt, failures: ok ? 0 : Math.min(prior.failures + 1, MAX_FAILURES) }; + const file = join(home, SYNC_STATE_FILE), temp = `${file}.${process.pid}.tmp`; + try { + mkdirSync(home, { recursive: true, mode: 0o700 }); + writeFileSync(temp, JSON.stringify(next) + "\n", { mode: 0o600 }); + renameSync(temp, file); + } catch { try { rmSync(temp, { force: true }); } catch { /* best effort */ } } + return next; +} diff --git a/src/memory/sync.ts b/src/memory/sync.ts index 525c4e5..231b440 100644 --- a/src/memory/sync.ts +++ b/src/memory/sync.ts @@ -17,18 +17,41 @@ export interface SyncResult { ok: boolean; pushed: boolean; entries: number; war const GITIGNORE = "/*\n!/.gitignore\n!/log/\n/log/*\n!/log/*.jsonl\n"; const STEP_MS = 60_000; +/** + * Git runs in its own process group when the caller passes a stop signal (see `git` below), so that group no longer receives what the terminal sends to amem's own. Every + * such group is registered here while it lives, and a process that exits for any reason (a second Ctrl-C, SIGHUP when the terminal closes, SIGQUIT, a throw) signals each one + * before it goes: a git left at ppid 1 with no deadline would hold `sync.lock`'s repository while the next sync removes its `index.lock`. The exit handler is synchronous and + * installed once, only when the first group is registered, so a process that never runs a deadline git does not get one. SIGKILL of amem itself cannot be handled by anyone. + */ +const liveGroups = new Set(); +let exitHandler = false; +function signalGroups(name: "SIGTERM" | "SIGKILL"): void { for (const pid of liveGroups) { try { process.kill(-pid, name); } catch { /* the group is already gone */ } } } +function register(pid: number): () => void { + if (!exitHandler) { exitHandler = true; process.on("exit", () => signalGroups("SIGKILL")); } + liveGroups.add(pid); + return () => { liveGroups.delete(pid); }; +} +/** Test seam: how many deadline-git groups are alive right now, and a way to stop them as the exit handler would. */ +export const syncGroupsForTest = { live: () => liveGroups.size, stopAll: () => signalGroups("SIGKILL") }; + async function git(dir: string, args: string[], signal?: AbortSignal, ms = STEP_MS): Promise<{ code: number; out: string; err: string }> { if (signal?.aborted) return { code: -1, out: "", err: "stopped" }; - const p = Bun.spawn(memoryGitArgv(["-C", dir, ...args], { credentials: true }), { stdout: "pipe", stderr: "pipe", stdin: "ignore", env: memoryGitEnv() }); + // A caller that passes a stop signal (`amem sync --if-due`, a stopping hub) gets git in its own process group, so a stop reaches the transport children git started (ssh, a + // credential helper): killing only git's pid left them running. The price is that a terminal Ctrl-C no longer reaches git by itself, so such a caller must turn its own + // interrupt into the signal (`stopSignal` in sync-cadence.ts does). A call with no signal keeps the unchanged spawn. + const group = signal !== undefined; + const p = Bun.spawn(memoryGitArgv(["-C", dir, ...args], { credentials: true }), { stdout: "pipe", stderr: "pipe", stdin: "ignore", env: memoryGitEnv(), ...(group ? { detached: true } : {}) }); + const send = (name: "SIGTERM" | "SIGKILL") => { if (group) { try { process.kill(-p.pid, name); return; } catch { /* no group left: fall through to the pid */ } } p.kill(name); }; + const unregister = group ? register(p.pid) : () => {}; // SIGTERM first, so git removes its lock files; SIGKILL only if it is still there after a grace period. let hard: ReturnType | undefined; - const kill = () => { p.kill("SIGTERM"); hard ??= setTimeout(() => p.kill("SIGKILL"), 2_000); }; + const kill = () => { send("SIGTERM"); hard ??= setTimeout(() => send("SIGKILL"), 2_000); }; const timer = setTimeout(kill, ms); signal?.addEventListener("abort", kill, { once: true }); try { const [code, out, err] = await Promise.all([p.exited, new Response(p.stdout).text(), new Response(p.stderr).text()]); return { code, out: out.trim(), err: err.trim() }; - } finally { clearTimeout(timer); if (hard) clearTimeout(hard); signal?.removeEventListener("abort", kill); } + } finally { unregister(); clearTimeout(timer); if (hard) clearTimeout(hard); signal?.removeEventListener("abort", kill); } } export { git as syncGit }; @@ -38,16 +61,19 @@ export async function generationSyncGit(dir: string, args: string[], signal?: Ab if (signal?.aborted) return { code: -1, out: "", err: "stopped" }; // A fixed child-only umask keeps Git-created metadata private without changing the daemon's process umask. // Args are positional shell parameters, never interpolated or evaluated as shell code. - const p = Bun.spawn(["/bin/sh", "-c", 'umask 077; exec "$@"', "amem-git", ...memoryGitArgv(["-C", dir, ...args], { credentials: true })], { stdout: "pipe", stderr: "pipe", stdin: "ignore", env: memoryGitEnv(process.env, { repositoryIsolation: true }) }); + const group = signal !== undefined; // see `git` above: own process group when the caller has a deadline + const p = Bun.spawn(["/bin/sh", "-c", 'umask 077; exec "$@"', "amem-git", ...memoryGitArgv(["-C", dir, ...args], { credentials: true })], { stdout: "pipe", stderr: "pipe", stdin: "ignore", env: memoryGitEnv(process.env, { repositoryIsolation: true }), ...(group ? { detached: true } : {}) }); + const send = (name: "SIGTERM" | "SIGKILL") => { if (group) { try { process.kill(-p.pid, name); return; } catch { /* no group left: fall through to the pid */ } } p.kill(name); }; + const unregister = group ? register(p.pid) : () => {}; // SIGTERM first, so git removes its lock files; SIGKILL only if it is still there after a grace period. let hard: ReturnType | undefined; - const kill = () => { p.kill("SIGTERM"); hard ??= setTimeout(() => p.kill("SIGKILL"), 2_000); }; + const kill = () => { send("SIGTERM"); hard ??= setTimeout(() => send("SIGKILL"), 2_000); }; const timer = setTimeout(kill, ms); signal?.addEventListener("abort", kill, { once: true }); try { const [code, out, err] = await Promise.all([p.exited, new Response(p.stdout).text(), new Response(p.stderr).text()]); return { code, out: out.trim(), err: err.trim() }; - } finally { clearTimeout(timer); if (hard) clearTimeout(hard); signal?.removeEventListener("abort", kill); } + } finally { unregister(); clearTimeout(timer); if (hard) clearTimeout(hard); signal?.removeEventListener("abort", kill); } } diff --git a/src/session-brief.ts b/src/session-brief.ts index aad6db3..d2fcf7e 100644 --- a/src/session-brief.ts +++ b/src/session-brief.ts @@ -1,4 +1,4 @@ -import { lstatSync } from "node:fs"; +import { lstatSync, readFileSync } from "node:fs"; import { join } from "node:path"; import type { CaptureRow } from "./memory/capture.ts"; import { readSelector } from "./memory/generation.ts"; @@ -10,10 +10,10 @@ import { pendingLine, pendingSummary } from "./memory/candidate-pending.ts"; import { memoryProjectKey } from "./memory/project.ts"; import { registerReaderLease } from "./memory/reader-lease.ts"; import { type ReadRef, type ReadWindow } from "./memory/search-fts.ts"; -import { aliasedKeys, readShardedSelection, resolveAlias } from "./memory/shard.ts"; +import { aliasedKeys, captureShardDir, readShardedSelection, resolveAlias, shardDir } from "./memory/shard.ts"; import { projectState } from "./memory/lifecycle.ts"; import { boundRepoRoot, nearestRepoRootWithLog, readRepoScope, repoLogNearby, repoLogPresent, repoReadPolicy, type RepoView } from "./memory/repo-scope.ts"; -import type { View } from "./memory/store.ts"; +import type { MemoryStore, View } from "./memory/store.ts"; import { WorkListStore, type WorkListFields, type WorkListView } from "./memory/work-lists.ts"; export interface BriefOptions { cwd: string; base?: string; project?: string; maxTokens?: number; policy?: StandalonePolicyOptions; @@ -25,6 +25,13 @@ export interface BriefOptions { cwd: string; base?: string; project?: string; ma projectFromCwd?: true; /** Test seam for the pending-candidate line's clock. */ now?: () => number; + /** + * `amem doctor` only (F15): size the brief without writing to the memory home. No reader lease is taken. The caller supplies the two sections that would otherwise + * open a store read-write: `curated` is a store opened on a private copy of the logs (absent: the project has no curated notes, an empty complete section) and + * `workBase` is a private base holding a copy of the project's and its aliases' work logs (absent: the work section is `unavailable`, never claimed complete). + * The capture section opens this machine's capture database read-only, and only when it exists. + */ + measureOnly?: { curated?: MemoryStore; workBase?: string }; } type Coverage = "complete" | "partial" | "unavailable"; export interface BriefResult { @@ -105,7 +112,7 @@ export async function sessionStartBrief(options: BriefOptions): Promise {}, { verify: () => {} }) : registerReaderLease(base, options.nativeSession ? { signature: options.nativeSession.signature, busyTimeoutMs: 0 } : undefined); closers.push(release); const selection = readSelector(base).revision; const sharded = !!lstatSync(join(base, "shards.json"), { throwIfNoEntry: false }); @@ -170,7 +177,10 @@ export async function sessionStartBrief(options: BriefOptions): Promise { - const work = new WorkListStore({ base, project, aliases, policy: screen, ...(options.nativeSession ? { busyTimeoutMs: 0, timeRemaining: options.timeRemaining } : {}) }); closers.push(() => work.close()); + const measured = options.measureOnly; + if (measured && !measured.workBase) { coverage.work = "unavailable"; return; } + if (measured && !lstatSync(join(shardDir(measured.workBase!, project), "work", "log"), { throwIfNoEntry: false })) return; // no work log: the real brief shows an empty list + const work = new WorkListStore({ base: measured?.workBase ?? base, project, aliases, policy: screen, ...(options.nativeSession ? { busyTimeoutMs: 0, timeRemaining: options.timeRemaining } : {}) }); closers.push(() => work.close()); // Complete operation rows AND the composed view are screened before closed-item filtering or display caps. const views = await work.read(undefined, true); guard(); let selected = 0; @@ -204,7 +214,10 @@ export async function sessionStartBrief(options: BriefOptions): Promise { + } else if (options.measureOnly) await run("curated", async () => { + const store = options.measureOnly!.curated; + if (store) await recent("curated", window => store.readPage(window), noteDisplay); // no store: no curated notes, an empty and complete section + }); else await run("curated", async () => { const { MemoryStore, openShardStore } = await import("./memory/store.ts"); const store = sharded ? openShardStore({ memoryDir: base, project, deferPromotions: true, ...(reconcile ? { reconcile } : {}) }) : new MemoryStore({ memoryDir: base, deferPromotions: true, ...(reconcile ? { reconcile } : {}) }); @@ -226,7 +239,11 @@ export async function sessionStartBrief(options: BriefOptions): Promise { const { CaptureStore } = await import("./memory/capture.ts"); - const capture = new CaptureStore({ memoryDir: base, project, cwd, ...(options.nativeSession ? { readOnly: true, busyTimeoutMs: 0, maxReadRows: 128, recentReadRows: true } : {}) }); closers.push(() => capture.close()); + if (options.measureOnly) { // this machine's capture database, only when it exists: a project never captured has an empty section and nothing is created + const machine = (() => { try { return (JSON.parse(readFileSync(join(base, "machine.json"), "utf8")) as { id?: string }).id ?? ""; } catch { return ""; } })(); + if (!/^[a-f0-9]{16}$/.test(machine) || !lstatSync(join(captureShardDir(base, project, machine), "capture.db"), { throwIfNoEntry: false })) return; + } + const capture = new CaptureStore({ memoryDir: base, project, cwd, ...(options.nativeSession || options.measureOnly ? { readOnly: true, busyTimeoutMs: options.measureOnly ? 1000 : 0, maxReadRows: 128, recentReadRows: true } : {}) }); closers.push(() => capture.close()); if (capture.recentReadTruncated) coverage.capture = "partial"; await recent("capture", window => capture.readPage(window), captureDisplay); }); diff --git a/test/completion.test.ts b/test/completion.test.ts index f2f362f..c76062d 100644 --- a/test/completion.test.ts +++ b/test/completion.test.ts @@ -100,8 +100,9 @@ test("completion metadata and CLI parser cover the retained raw command cases an for (const flag of flags) expect(Object.keys(row.options)).toContain(flag); // #15 T5: `search --all-projects` and `--include-archived` are the person's cross-project read, handled by src/cli.ts before the raw command (like --policy-free); // the raw command, which hosts share, refuses them (test/cli-projects.test.ts). The exemption is these two flags on `search` only. + // #40 F16: `sync --if-due --force` are the person's schedule gate, handled by src/cli.ts before the raw command, which never sees them (test/sync-cadence.test.ts); the exemption is these two flags on `sync` only. // #15 T4: `promote --to repo --confirm --confirm-public` are the person's repo-scope promotion, handled by src/cli.ts before the raw command; the raw command refuses them (test/repo-scope.test.ts). The exemption is these three flags on `promote` only. - for (const flag of Object.keys(row.options)) expect(flags.has(flag) || ["--help", "--json", "--policy-free"].includes(flag) || name === "search" && ["--all-projects", "--include-archived"].includes(flag) || name === "promote" && ["--to", "--confirm", "--confirm-public"].includes(flag)).toBe(true); + for (const flag of Object.keys(row.options)) expect(flags.has(flag) || ["--help", "--json", "--policy-free"].includes(flag) || name === "search" && ["--all-projects", "--include-archived"].includes(flag) || name === "promote" && ["--to", "--confirm", "--confirm-public"].includes(flag) || name === "sync" && ["--if-due", "--force"].includes(flag)).toBe(true); } } } diff --git a/test/doctor-snapshot.test.ts b/test/doctor-snapshot.test.ts new file mode 100644 index 0000000..9624191 --- /dev/null +++ b/test/doctor-snapshot.test.ts @@ -0,0 +1,68 @@ +import { afterEach, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runCli } from "../src/cli.ts"; +import { CandidateStore } from "../src/memory/candidates.ts"; +import { CaptureStore } from "../src/memory/capture.ts"; +import { memoryGitSync, realPath } from "../src/memory/platform.ts"; +import { memoryProjectKey } from "../src/memory/project.ts"; +import { MemoryStore } from "../src/memory/store.ts"; +import { applySetup, planSetup } from "../src/setup.ts"; + +/** + * AC8: `amem doctor --json` is stable. A note id and a time change on every run, so the snapshot fixes what a consumer can rely on: every key, the type of every value, + * and the fixed vocabulary (the schema string, the three statuses, the hook and capture states, the remote states). Arrays are described by their first element. + * `UPDATE_DOCTOR_SNAPSHOT=1 bun test test/doctor-snapshot.test.ts` rewrites the fixture; a reviewer reads the diff of the fixture. + */ +const FIXTURE = new URL("./fixtures/doctor-snapshot.json", import.meta.url); +const VOCABULARY = new Set(["ok", "attention", "skipped", "unavailable", "amem.doctor/v1", "setup receipt", "snapshotted", "absent", "registered", "manual", "fresh", "stale", "never", "configured", "none", "hooks", "session end", "AMEM_HOME"]); +export function shape(value: unknown): unknown { + if (value === null) return "null"; + if (Array.isArray(value)) return value.length ? [shape(value[0])] : []; + if (typeof value === "object") return Object.fromEntries(Object.entries(value as Record).sort(([a], [b]) => a.localeCompare(b)).map(([key, inner]) => [key, shape(inner)])); + return typeof value === "string" && VOCABULARY.has(value) ? value : typeof value; +} + +const cleanup: (() => void)[] = []; +afterEach(() => { for (const fn of cleanup.splice(0).reverse()) fn(); }); +function rig() { + const root = realPath(mkdtempSync(join(tmpdir(), "amem-doctor-snap-"))); cleanup.push(() => rmSync(root, { recursive: true, force: true })); + const memory = join(root, "memory"), cwd = join(root, "repo"); + mkdirSync(cwd, { recursive: true }); expect(memoryGitSync(["init", "-q", cwd], { encoding: "utf8" }).status).toBe(0); + return { root, memory, cwd, project: memoryProjectKey(cwd) }; +} +async function report(r: ReturnType): Promise { + const saved = process.env.AMEM_HOME; process.env.AMEM_HOME = r.memory; + try { return JSON.parse(await runCli(["doctor", "--json"], { cwd: r.cwd, env: {}, stdinTTY: false })); } + finally { if (saved === undefined) delete process.env.AMEM_HOME; else process.env.AMEM_HOME = saved; } +} +const note = (r: ReturnType, text: string, about: string[] = [], peer = "me") => { const s = new MemoryStore({ memoryDir: r.memory, deferPromotions: true }); try { s.note({ op: "note", project: r.project, scope: "project", tier: "long", kind: "finding", peer, source: "person", text, about } as never); } finally { s.close(); } }; + +function rich() { + const r = rig(); writeFileSync(join(r.cwd, "keep.ts"), "x"); + note(r, "duplicate words"); note(r, "Duplicate words", [], "other"); note(r, "points nowhere", ["path:gone.ts"]); + const c = new CaptureStore({ memoryDir: r.memory, project: r.project, cwd: r.cwd, now: () => Date.now() - 10 * 86400_000 }); c.append({ project: r.project, agent: "codex", event: "Stop", excerpt: "old" }); c.close(); + const pending = new CandidateStore({ memoryDir: r.memory }); pending.put({ project: r.project, kind: "memory", text: "candidate" } as never, "public"); pending.close(); + const env = { HOME: r.root, AMEM_HOME: r.memory, CODEX_HOME: join(r.root, ".codex"), KIMI_CODE_HOME: join(r.root, ".kimi-code"), PI_CODING_AGENT_DIR: join(r.root, ".pi", "agent") }; + const native = (argv: string[], staged: Record) => { const launch = argv.slice(argv.indexOf("--") + 1); mkdirSync(staged.CODEX_HOME!, { recursive: true }); writeFileSync(join(staged.CODEX_HOME!, "config.toml"), Bun.TOML.stringify({ mcp_servers: { amem: { command: launch[0], args: launch.slice(1) } } })); }; + const options = { env, entry: join(r.root, "installed", "bin", "amem.js"), native } as never; applySetup(planSetup("codex", "apply", options), options); + mkdirSync(join(r.memory, ".git"), { recursive: true }); writeFileSync(join(r.memory, ".git", "config"), '[remote "origin"]\n\turl = x\n'); + const old = new Date(Date.now() - 10 * 86400_000).toISOString(); writeFileSync(join(r.memory, "sync-state.json"), JSON.stringify({ lastAttemptAt: old, lastOkAt: old, failures: 0 })); + return r; +} + +test("doctor --json keeps its documented shape (snapshot of keys, types and vocabulary)", async () => { + const snapshot = { schema: "amem.doctor/v1", populated: shape(await report(rich())), unused: shape(await report(rig())) }; + if (process.env.UPDATE_DOCTOR_SNAPSHOT === "1") writeFileSync(FIXTURE, JSON.stringify(snapshot, null, 2) + "\n"); + expect(existsSync(FIXTURE)).toBe(true); + expect(snapshot).toEqual(JSON.parse(readFileSync(FIXTURE, "utf8"))); +}); + +test("every section a consumer reads is present with a status, in a fixed order", async () => { + const body = await report(rich()) as { schema: string; status: string; sections: Record }; + expect(body.schema).toBe("amem.doctor/v1"); + expect(Object.keys(body.sections)).toEqual(["duplicates", "stale", "candidates", "capture", "brief", "index", "readers", "policy", "cadence"]); + for (const section of Object.values(body.sections)) expect(["ok", "attention", "unavailable"]).toContain(section.status); + expect(body.status).toBe("attention"); +}); diff --git a/test/doctor.test.ts b/test/doctor.test.ts new file mode 100644 index 0000000..e85fec1 --- /dev/null +++ b/test/doctor.test.ts @@ -0,0 +1,236 @@ +import { afterEach, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runCli } from "../src/cli.ts"; +import { CandidateStore } from "../src/memory/candidates.ts"; +import { CaptureStore } from "../src/memory/capture.ts"; +import { memoryGitSync, realPath } from "../src/memory/platform.ts"; +import { memoryProjectKey } from "../src/memory/project.ts"; +import { MemoryStore } from "../src/memory/store.ts"; + +const cleanup: (() => void)[] = []; +afterEach(() => { for (const fn of cleanup.splice(0).reverse()) fn(); }); +function rig() { + const root = realPath(mkdtempSync(join(tmpdir(), "amem-doctor-test-"))); cleanup.push(() => rmSync(root, { recursive: true, force: true })); + const memory = join(root, "memory"), cwd = join(root, "repo"); + mkdirSync(cwd, { recursive: true }); + expect(memoryGitSync(["init", "-q", cwd], { encoding: "utf8" }).status).toBe(0); // a real repository: the stale check resolves its root with git + const project = memoryProjectKey(cwd); + const note = (text: string, extra: Record = {}) => { const s = new MemoryStore({ memoryDir: memory, deferPromotions: true }); try { return s.note({ op: "note", project, scope: "project", tier: "long", kind: "finding", peer: "me", source: "person", text, about: [], ...extra } as never); } finally { s.close(); } }; + const capture = (agent: string, at: string) => { const c = new CaptureStore({ memoryDir: memory, project, cwd, now: () => Date.parse(at) }); try { c.append({ project, agent, event: "PostToolUse", excerpt: `from ${agent}` }); } finally { c.close(); } }; + return { root, memory, cwd, project, note, capture }; +} +async function doctor(r: ReturnType, args: string[] = ["doctor", "--json"], env: Record = {}): Promise { + const saved = process.env.AMEM_HOME; process.env.AMEM_HOME = r.memory; + try { const out = await runCli(args, { cwd: r.cwd, env, stdinTTY: false }); return args.includes("--json") ? JSON.parse(out) : out; } + finally { if (saved === undefined) delete process.env.AMEM_HOME; else process.env.AMEM_HOME = saved; } +} +/** Content hash and size of every file, so a before and after comparison sees any byte that changed or any file that appeared. */ +function tree(dir: string, rel = ""): Record { + const out: Record = {}; + if (!existsSync(join(dir, rel))) return out; + for (const name of readdirSync(join(dir, rel)).sort()) { + const path = join(rel, name), stat = statSync(join(dir, path)); + if (stat.isDirectory()) Object.assign(out, tree(dir, path)); + else out[path] = `${createHash("sha1").update(readFileSync(join(dir, path))).digest("hex").slice(0, 10)}:${stat.size}`; + } + return out; +} + +/** + * Every file, byte for byte. Two kinds of SQLite bookkeeping are normalised, nothing else: a `-shm` file (wal-index scratch memory, changed by any read-only open) and an EMPTY + * `-wal` file. Linux removes both when the last connection closes cleanly and a read-only open of that database creates them again empty (macOS leaves them in place), which + * Ubuntu CI showed; neither holds data. A `-wal` that holds data is still compared byte for byte, and so is every database, log, lock and sidecar: the reviewer's probe found + * doctor checkpointing both WALs (53592 to 0 bytes), which this still catches. + */ +function exact(r: ReturnType) { + return Object.fromEntries(Object.entries(tree(r.memory)).filter(([file, value]) => !/-shm$/.test(file) && !(/-wal$/.test(file) && value.endsWith(":0")))); +} +/** What a clean close leaves on Linux: no -wal and no -shm beside any database. Applying it on every platform makes macOS take the same path Ubuntu does. */ +function cleanClose(r: ReturnType) { for (const file of Object.keys(tree(r.memory))) if (/-(wal|shm)$/.test(file)) rmSync(join(r.memory, file), { force: true }); } + +test("doctor is read-only: a home that was never used stays absent, and the FIRST run on a populated home changes no byte (-wal included)", async () => { + const r = rig(); + const empty = await doctor(r); + expect(existsSync(r.memory)).toBe(false); // nothing created for a home nobody used + for (const name of ["duplicates", "stale", "index"]) expect(empty.sections[name].status).toBe("skipped"); + expect(empty.status).not.toBe("attention"); // nothing to check is not a problem + r.note("a decision about caching"); r.capture("claude", new Date(Date.now() - 86400_000).toISOString()); + const pending = new CandidateStore({ memoryDir: r.memory }); pending.put({ project: r.project, kind: "memory", text: "a candidate" } as never, "public"); pending.close(); + cleanClose(r); // the state a normal close leaves on Linux, where a read-only open recreates -wal and -shm + const before = exact(r); // no warm-up run: the first doctor is the one measured + const report = await doctor(r); + expect(exact(r)).toEqual(before); + const created = Object.keys(tree(r.memory)).filter(file => !/-(wal|shm)$/.test(file)); + expect(created.sort()).toEqual(Object.keys(before).sort()); // no file other than SQLite's own bookkeeping appeared + expect(report.sections.candidates).toMatchObject({ status: "ok", count: 1, unshown: 1 }); // doctor never stamps a candidate as shown + expect(readdirSync(join(r.memory)).includes("readers") ? readdirSync(join(r.memory, "readers")) : []).toEqual([]); // and takes no reader lease + const check = new CandidateStore({ memoryDir: r.memory }); try { expect(check.list({ project: r.project, status: "pending" })).toHaveLength(1); } finally { check.close(); } +}); + +test("doctor leaves a live index that is behind the logs untouched: a foreign machine segment and a writer that died before closing", async () => { + const r = rig(); r.note("indexed by this machine"); + // Another machine's segment arrives (a sync pull) after this machine's index was last refreshed, so the live index is behind the logs. + const { machineSegment } = await import("./fakes/foreign-segment.ts"); + machineSegment(r.memory, r.project, "another machine's note"); + const before = exact(r); + const report = await doctor(r); + expect(exact(r)).toEqual(before); // not refreshed, not checkpointed, no lease + expect(report.sections.index.status).toBe("ok"); // the snapshot is brought level in the scratch copy, as a reader would + const again = await doctor(r); + expect(exact(r)).toEqual(before); expect(again.sections.duplicates.notes).toBe(2); // the foreign note is read from the copy +}); + +test("doctor never migrates or recreates a work list: a deleted lock file stays deleted, and a log with no index still counts in the brief", async () => { + const r = rig(); r.note("n"); + const { WorkListStore } = await import("../src/memory/work-lists.ts"); + const work = new WorkListStore({ base: r.memory, project: r.project, policy: () => true }); + await work.write("todo", { task: "a work item that must show in the brief" }); work.close(); + const dir = join(r.memory, "projects", readdirSync(join(r.memory, "projects"))[0]!, "work"); + rmSync(join(dir, "mutation-lock.db"), { force: true }); + const before = exact(r); + const withIndex = await doctor(r); + expect(exact(r)).toEqual(before); // the lock file was not recreated, nothing migrated + expect(existsSync(join(dir, "mutation-lock.db"))).toBe(false); + expect(withIndex.sections.brief.coverage.work).toBe("complete"); + const small = withIndex.sections.brief.tokens; + rmSync(join(dir, "index.db"), { force: true }); rmSync(join(dir, "index.db-wal"), { force: true }); rmSync(join(dir, "index.db-shm"), { force: true }); + const noIndex = await doctor(r); + expect(existsSync(join(dir, "index.db"))).toBe(false); // not rebuilt in the home + expect(noIndex.sections.brief.tokens).toBe(small); // the log alone gives the same brief: the work item is not dropped (review finding 3) + expect(noIndex.sections.brief.coverage.work).toBe("complete"); +}); + +test("measureOnly never reaches the live work list: without a scratch work base the work section is unavailable, not claimed complete", async () => { + const r = rig(); r.note("n"); + const { WorkListStore } = await import("../src/memory/work-lists.ts"); + const work = new WorkListStore({ base: r.memory, project: r.project, policy: () => true }); + await work.write("todo", { task: "a work item" }); work.close(); + const { sessionStartBrief } = await import("../src/session-brief.ts"); + const before = exact(r); + const none = await sessionStartBrief({ cwd: r.cwd, base: r.memory, project: r.project, measureOnly: {} }); + expect(none.coverage.work).toBe("unavailable"); // never a silent "complete" for a section that was not read + expect(none.context).not.toContain("a work item"); + expect(exact(r)).toEqual(before); // and the live work list was not opened (no migration, no lock file, no lease) + const real = await sessionStartBrief({ cwd: r.cwd, base: r.memory, project: r.project }); + expect(real.coverage.work).toBe("complete"); expect(real.context).toContain("a work item"); // the ordinary brief is unchanged +}); + +test("doctor creates no project, work-list, capture or lease file for a project the home has never seen, while a note-only home still gets its brief cost", async () => { + const r = rig(); r.note("only a curated note"); + const before = tree(r.memory); + const report = await doctor(r); + const created = Object.keys(tree(r.memory)).filter(file => !(file in before)); + expect(created.filter(file => /^projects\/|work|readers\//.test(file))).toEqual([]); + expect(report.sections.brief).toMatchObject({ status: "ok", coverage: { curated: "complete" } }); + expect(report.sections.brief.tokens).toBeGreaterThan(0); +}); + +test("doctor reports notes with identical text as duplicates, a note about a deleted path as stale, and an index that equals a rebuild", async () => { + const r = rig(); writeFileSync(join(r.cwd, "keep.ts"), "x"); + r.note("same words"); + const single = await doctor(r); + expect(single.sections.duplicates).toMatchObject({ status: "ok", notes: 1, identicalGroups: 0 }); + const s = new MemoryStore({ memoryDir: r.memory }); try { s.note({ op: "note", project: r.project, scope: "project", tier: "long", kind: "finding", peer: "other", source: "person", text: " Same words ", about: [] } as never); } finally { s.close(); } + const twice = await doctor(r); + expect(twice.sections.duplicates).toMatchObject({ status: "attention", notes: 2, identicalGroups: 1 }); // case and spacing do not hide a duplicate + expect(twice.sections.duplicates.examples[0]).toHaveLength(2); + expect(twice.sections.duplicates.examples[0].every((id: string) => /^[a-f0-9]{8}$/.test(id))).toBe(true); // ids only, never note text + expect(JSON.stringify(twice)).not.toContain("same words"); + r.note("this refers to a file", { about: ["path:gone/missing.ts"] }); r.note("this refers to a kept file", { about: ["path:keep.ts"] }); + const stale = await doctor(r); + expect(stale.sections.stale).toMatchObject({ status: "attention", missingReferences: 1 }); + expect(stale.sections.index).toMatchObject({ status: "ok" }); +}); + +test("doctor detects an index that differs from a rebuild of the logs", async () => { + const r = rig(); r.note("one"); r.note("two"); + expect((await doctor(r)).sections.index.status).toBe("ok"); + const { Database } = await import("bun:sqlite"); + const db = new Database(join(r.memory, "index.db")); db.run("DELETE FROM entries WHERE rowid = (SELECT MIN(rowid) FROM entries)"); db.close(); + const sick = await doctor(r); + expect(sick.sections.index.status).toBe("attention"); expect(sick.sections.index.hint).toContain("amem rebuild"); + expect(sick.status).toBe("attention"); +}); + +test("capture freshness is reported per CLI, from the hook receipts and the capture rows, and a CLI without a hook is manual", async () => { + const r = rig(); r.note("n"); + const { applySetup, planSetup } = await import("../src/setup.ts"); + const calls: string[][] = []; + const entry = join(r.root, "installed", "bin", "amem.js"); + const env = { HOME: r.root, AMEM_HOME: r.memory, CODEX_HOME: join(r.root, ".codex"), KIMI_CODE_HOME: join(r.root, ".kimi-code"), PI_CODING_AGENT_DIR: join(r.root, ".pi", "agent") }; + // The stand-in for `codex mcp add amem -- mcp`: what the real CLI would write into the staged CODEX_HOME. + const native = (argv: string[], staged: Record) => { + calls.push(argv); const marker = argv.indexOf("--"), launch = argv.slice(marker + 1); + mkdirSync(staged.CODEX_HOME!, { recursive: true }); + writeFileSync(join(staged.CODEX_HOME!, "config.toml"), Bun.TOML.stringify({ mcp_servers: { amem: { command: launch[0], args: launch.slice(1) } } })); + }; + const options = { env, entry, native } as never; + applySetup(planSetup("codex", "apply", options), options); + const tenDaysAgo = new Date(Date.now() - 10 * 86400_000).toISOString(); + r.capture("codex", tenDaysAgo); r.capture("claude", new Date().toISOString()); + const report = await doctor(r); + const clis = report.sections.capture.clis; + expect(clis.codex).toMatchObject({ hook: "registered", capture: "stale", rows: 1 }); // registered by a real setup receipt, last capture older than 7 days + expect(clis.claude).toMatchObject({ hook: "manual", capture: "fresh", rows: 1 }); + expect(clis.kimi).toMatchObject({ hook: "manual", capture: "never", lastCaptureAt: null, rows: 0 }); + expect(clis.pi).toMatchObject({ hook: "manual", capture: "never" }); + expect(clis.codex.hint).toContain("amem ingest codex"); + expect(report.sections.capture.status).toBe("attention"); // a registered hook whose capture went stale + expect(report.sections.cadence.ingest).toMatchObject({ codex: "session end", claude: "manual", kimi: "manual", pi: "manual" }); + expect(report.sections.cadence.manual).toEqual(["claude", "kimi", "pi"]); + expect(calls.length).toBeGreaterThan(0); +}); + +test("a receipt that is not a setup receipt of that CLI is not a registered hook", async () => { + const r = rig(); r.note("n"); mkdirSync(join(r.memory, "setup"), { recursive: true }); + writeFileSync(join(r.memory, "setup", "codex.json"), JSON.stringify({ target: "kimi", phase: "applied" })); + writeFileSync(join(r.memory, "setup", "kimi.json"), "{ not json"); + symlinkSync(join(r.memory, "setup", "codex.json"), join(r.memory, "setup", "pi.json")); + const report = await doctor(r); + for (const cli of ["codex", "kimi", "pi"]) expect(report.sections.capture.clis[cli].hook).toBe("manual"); +}); + +test("a setup that was prepared but not applied is not a registered hook, and says so", async () => { + const r = rig(); r.note("n"); mkdirSync(join(r.memory, "setup"), { recursive: true }); + writeFileSync(join(r.memory, "setup", "kimi.json"), JSON.stringify({ target: "kimi", phase: "prepared" })); + const report = await doctor(r); + expect(report.sections.capture.clis.kimi).toMatchObject({ hook: "manual", setup: "prepared, not applied" }); + expect(report.sections.cadence.ingest.kimi).toBe("manual"); +}); + +test("reader leases of a live and of a gone process are counted, and doctor itself takes none", async () => { + const r = rig(); r.note("n"); + mkdirSync(join(r.memory, "readers"), { recursive: true }); + const lease = (pid: number, signature: string) => writeFileSync(join(r.memory, "readers", `${pid}-${signature}.json`), JSON.stringify({ schema: "agent-mem.reader-lease/v1", pid, signature, renewedAt: new Date().toISOString() })); + const { processSignature } = await import("../src/memory/platform.ts"); + const parentSig = processSignature(process.ppid) ?? "a".repeat(64); + lease(process.ppid, parentSig); lease(99999999, "b".repeat(64)); + writeFileSync(join(r.memory, "readers", "junk.json"), "{ nope"); + const report = await doctor(r); + expect(report.sections.readers).toMatchObject({ status: "attention", stale: 1, unreadable: 1 }); + expect(report.sections.readers.live).toBe(1); // the parent's lease only: doctor opens no live store, so it holds none +}); + +test("the policy section names what is in effect and the brief reports its budget", async () => { + const r = rig(); r.note("n"); + writeFileSync(join(r.memory, "amem.config.json"), JSON.stringify({ denyPaths: ["secret/"], piiPatterns: ["alice"], briefBytes: 900 })); + const report = await doctor(r); + expect(report.sections.policy).toMatchObject({ status: "ok", denyPaths: 1, piiPatterns: 1, briefBytes: 900 }); + expect(report.sections.brief.budget).toBe(900); +}); + +test("the human report names every section, marks attention and shows the hint", async () => { + const r = rig(); r.note("n"); + const text = await doctor(r, ["doctor"]); + for (const name of ["duplicates", "stale", "candidates", "capture", "brief", "index", "readers", "policy", "cadence"]) expect(text).toContain(`${name}:`); + expect(text).toMatch(/^amem doctor: (ok|attention|unavailable)/); +}); + +test("doctor is person-only like every other command: an agent marker is refused before the home is opened", async () => { + const r = rig(); + await expect(runCli(["doctor"], { cwd: r.cwd, env: { CODEX_THREAD_ID: "x" }, stdinTTY: false })).rejects.toThrow(/amem is for the person/); + expect(existsSync(r.memory)).toBe(false); +}); diff --git a/test/fakes/foreign-segment.ts b/test/fakes/foreign-segment.ts new file mode 100644 index 0000000..3efe46f --- /dev/null +++ b/test/fakes/foreign-segment.ts @@ -0,0 +1,20 @@ +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { MemoryStore } from "../../src/memory/store.ts"; + +/** + * A note authored on ANOTHER machine, delivered the way a sync pull delivers it: one more `log/.jsonl` segment in the home, with this machine's index not told. + * The row is written by a real store of a throwaway home (so it is a valid, correctly hashed row for a different machine id), then its segment is copied in. + */ +export function machineSegment(home: string, project: string, text: string): void { + const other = mkdtempSync(join(tmpdir(), "amem-foreign-machine-")); + try { + const store = new MemoryStore({ memoryDir: other, deferPromotions: true }); + try { store.note({ op: "note", project, scope: "project", tier: "long", kind: "finding", peer: "other", source: "person", text, about: [] } as never); } finally { store.close(); } + for (const name of readdirSync(join(other, "log"))) { + mkdirSync(join(home, "log"), { recursive: true }); + writeFileSync(join(home, "log", name), readFileSync(join(other, "log", name))); + } + } finally { rmSync(other, { recursive: true, force: true }); } +} diff --git a/test/fakes/pty.ts b/test/fakes/pty.ts new file mode 100644 index 0000000..0ba573e --- /dev/null +++ b/test/fakes/pty.ts @@ -0,0 +1,11 @@ +/** + * Argument vector that runs `command` (a plain shell command line) with a pseudo-terminal as its stdin and stdout. The two `script` implementations take different + * arguments: BSD/macOS `script -q /dev/null sh -c CMD` and util-linux `script -q -e -c CMD /dev/null` (`-e` returns the child's exit status, `-c` names the command; the BSD + * form on Linux silently runs nothing useful). Undefined when `script` is not installed, so a caller can skip with a stated reason; a platform this does not know fails loudly. + */ +export function ptyArgv(command: string, platform: string = process.platform, which: (name: string) => string | null = name => Bun.which(name)): string[] | undefined { + if (which("script") === null) return undefined; + if (platform === "darwin") return ["script", "-q", "/dev/null", "sh", "-c", command]; + if (platform === "linux") return ["script", "-q", "-e", "-c", command, "/dev/null"]; + throw new Error(`no known \`script\` argument form for platform ${platform}`); +} diff --git a/test/fixtures/doctor-snapshot.json b/test/fixtures/doctor-snapshot.json new file mode 100644 index 0000000..a6b9238 --- /dev/null +++ b/test/fixtures/doctor-snapshot.json @@ -0,0 +1,229 @@ +{ + "schema": "amem.doctor/v1", + "populated": { + "home": { + "path": "string", + "source": "AMEM_HOME" + }, + "project": "string", + "schema": "amem.doctor/v1", + "sections": { + "brief": { + "budget": "number", + "coverage": { + "capture": "string", + "curated": "string", + "work": "string" + }, + "status": "ok", + "tokens": "number" + }, + "cadence": { + "hint": "string", + "ingest": { + "claude": "manual", + "codex": "session end", + "kimi": "manual", + "pi": "manual" + }, + "manual": [ + "string" + ], + "status": "attention", + "sync": { + "due": "boolean", + "failures": "number", + "lastAttemptAt": "string", + "lastOkAt": "string", + "nextAttemptAt": "string", + "remote": "configured", + "schedule": "string" + } + }, + "candidates": { + "count": "number", + "dueWithin7Days": "number", + "oldestExpiresInDays": "number", + "status": "ok", + "unshown": "number" + }, + "capture": { + "clis": { + "claude": { + "capture": "never", + "hint": "string", + "hook": "manual", + "lastCaptureAt": "null", + "rows": "number" + }, + "codex": { + "capture": "stale", + "hint": "string", + "hook": "registered", + "hookEvidence": "setup receipt", + "lastCaptureAt": "string", + "rows": "number" + }, + "kimi": { + "capture": "never", + "hint": "string", + "hook": "manual", + "lastCaptureAt": "null", + "rows": "number" + }, + "pi": { + "capture": "never", + "hint": "string", + "hook": "manual", + "lastCaptureAt": "null", + "rows": "number" + } + }, + "status": "attention" + }, + "duplicates": { + "examples": [ + [ + "string" + ] + ], + "identicalGroups": "number", + "importedGroups": "number", + "notes": "number", + "status": "attention" + }, + "index": { + "liveIndex": "snapshotted", + "status": "ok" + }, + "policy": { + "denyPaths": "number", + "machine": "null", + "piiPatterns": "number", + "repo": "null", + "status": "ok" + }, + "readers": { + "live": "number", + "stale": "number", + "status": "ok", + "unreadable": "number" + }, + "stale": { + "checked": "number", + "examples": [ + "string" + ], + "missingReferences": "number", + "status": "attention" + } + }, + "status": "attention" + }, + "unused": { + "home": { + "path": "string", + "source": "AMEM_HOME" + }, + "project": "string", + "schema": "amem.doctor/v1", + "sections": { + "brief": { + "budget": "number", + "coverage": { + "capture": "string", + "curated": "string", + "work": "string" + }, + "status": "ok", + "tokens": "number" + }, + "cadence": { + "ingest": { + "claude": "manual", + "codex": "manual", + "kimi": "manual", + "pi": "manual" + }, + "manual": [ + "string" + ], + "status": "ok", + "sync": { + "due": "boolean", + "failures": "number", + "lastAttemptAt": "null", + "lastOkAt": "null", + "nextAttemptAt": "null", + "remote": "none" + } + }, + "candidates": { + "count": "number", + "dueWithin7Days": "number", + "oldestExpiresInDays": "null", + "status": "ok", + "unshown": "number" + }, + "capture": { + "clis": { + "claude": { + "capture": "never", + "hint": "string", + "hook": "manual", + "lastCaptureAt": "null", + "rows": "number" + }, + "codex": { + "capture": "never", + "hint": "string", + "hook": "manual", + "lastCaptureAt": "null", + "rows": "number" + }, + "kimi": { + "capture": "never", + "hint": "string", + "hook": "manual", + "lastCaptureAt": "null", + "rows": "number" + }, + "pi": { + "capture": "never", + "hint": "string", + "hook": "manual", + "lastCaptureAt": "null", + "rows": "number" + } + }, + "status": "ok" + }, + "duplicates": { + "reason": "string", + "status": "skipped" + }, + "index": { + "reason": "string", + "status": "skipped" + }, + "policy": { + "denyPaths": "number", + "machine": "null", + "piiPatterns": "number", + "repo": "null", + "status": "ok" + }, + "readers": { + "live": "number", + "stale": "number", + "status": "ok", + "unreadable": "number" + }, + "stale": { + "reason": "string", + "status": "skipped" + } + }, + "status": "ok" + } +} diff --git a/test/review-pending.test.ts b/test/review-pending.test.ts index 14b2e20..81e78aa 100644 --- a/test/review-pending.test.ts +++ b/test/review-pending.test.ts @@ -6,6 +6,7 @@ import { join } from "node:path"; import { runCli, type CliContext } from "../src/cli.ts"; import { runHook } from "../src/hooks.ts"; import { sessionStartBrief } from "../src/session-brief.ts"; +import { ptyArgv } from "./fakes/pty.ts"; import { CandidateStore, CANDIDATE_RETENTION, type CandidateInput } from "../src/memory/candidates.ts"; import { CANDIDATE_AGE_MS, CANDIDATE_SHOWN_GRACE_MS, markShown, pendingLine, pendingSummary } from "../src/memory/candidate-pending.ts"; import { MemoryStore } from "../src/memory/store.ts"; @@ -413,18 +414,6 @@ test("a read-only open that cannot attach to missing -wal/-shm falls back to an // ---- claude's round 2 nits on #418 ---- -/** - * argv that runs `command` (a shell command line) with a pseudo-terminal as its stdin, stdout and stderr, through the `script` utility. The two implementations take different - * arguments: BSD/macOS `script -q /dev/null sh -c CMD` and util-linux `script -q -e -c CMD /dev/null` (`-e` returns the child's exit status, `-c` names the command; the BSD - * form on Linux silently runs nothing useful). Undefined when `script` is not installed, so a caller can skip with a stated reason; a platform this does not know fails loudly. - */ -export function ptyArgv(command: string, platform: string = process.platform, which: (name: string) => string | null = name => Bun.which(name)): string[] | undefined { - if (which("script") === null) return undefined; - if (platform === "darwin") return ["script", "-q", "/dev/null", "sh", "-c", command]; - if (platform === "linux") return ["script", "-q", "-e", "-c", command, "/dev/null"]; - throw new Error(`no known \`script\` argument form for platform ${platform}`); -} - test("review walk refuses to run with stdout redirected: nothing is printed, nothing is stamped, nothing is asked (review nit)", async () => { const r = rig(); const a = r.put("would be stamped unread"); diff --git a/test/sync-cadence.test.ts b/test/sync-cadence.test.ts new file mode 100644 index 0000000..112f3fd --- /dev/null +++ b/test/sync-cadence.test.ts @@ -0,0 +1,353 @@ +import { afterEach, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runCli } from "../src/cli.ts"; +import { realPath } from "../src/memory/platform.ts"; +import { memoryProjectKey } from "../src/memory/project.ts"; +import { MemoryStore } from "../src/memory/store.ts"; +import { SYNC_BACKOFF_BASE_MS, SYNC_BACKOFF_CAP_MS, SYNC_INTERVAL_MS, SYNC_STATE_FILE, nextSyncAt, readSyncState, recordSyncAttempt, remoteConfigured, STOP_SIGNALS, stopSignal, syncDue, syncOutcome, syncWaitMs, usableState } from "../src/memory/sync-cadence.ts"; +import { memoryGitSync } from "../src/memory/platform.ts"; +import { ptyArgv } from "./fakes/pty.ts"; + +const cleanup: (() => void)[] = []; +afterEach(() => { for (const fn of cleanup.splice(0).reverse()) fn(); }); +const T0 = Date.parse("2026-10-24T10:00:00.000Z"), MIN = 60_000; +function temp(): string { const dir = realPath(mkdtempSync(join(tmpdir(), "amem-cadence-"))); cleanup.push(() => rmSync(dir, { recursive: true, force: true })); return dir; } + +test("the wait is the hourly interval after a success and doubles from 15 minutes to a one-day cap after failures", () => { + expect(syncWaitMs(0)).toBe(SYNC_INTERVAL_MS); + expect([1, 2, 3, 4, 5, 6, 7, 8].map(syncWaitMs)).toEqual([15, 30, 60, 120, 240, 480, 960, 1440].map(m => m * MIN)); + expect(syncWaitMs(30)).toBe(SYNC_BACKOFF_CAP_MS); expect(syncWaitMs(1)).toBe(SYNC_BACKOFF_BASE_MS); +}); + +test("due follows the stamp: no history is due, a recent attempt is not, and --force always is", () => { + const home = temp(); + expect(syncDue(readSyncState(home), T0)).toBe(true); + recordSyncAttempt(home, true, T0); + const ok = readSyncState(home); + expect(ok).toEqual({ lastAttemptAt: new Date(T0).toISOString(), lastOkAt: new Date(T0).toISOString(), failures: 0 }); + expect(syncDue(ok, T0 + SYNC_INTERVAL_MS - 1)).toBe(false); expect(syncDue(ok, T0 + SYNC_INTERVAL_MS)).toBe(true); + expect(syncDue(ok, T0 + 1, true)).toBe(true); + recordSyncAttempt(home, false, T0 + 2 * SYNC_INTERVAL_MS); + const failed = readSyncState(home); + expect(failed.failures).toBe(1); expect(failed.lastOkAt).toBe(new Date(T0).toISOString()); // a failure keeps the last success + expect(nextSyncAt(failed)).toBe(T0 + 2 * SYNC_INTERVAL_MS + 15 * MIN); + recordSyncAttempt(home, false, T0 + 3 * SYNC_INTERVAL_MS); + expect(readSyncState(home).failures).toBe(2); + recordSyncAttempt(home, true, T0 + 4 * SYNC_INTERVAL_MS); + expect(readSyncState(home).failures).toBe(0); // a success clears the backoff +}); + +test("a damaged, oversized, linked or hand-edited stamp is no history (due now), never a crash, and the stamp never holds an error text", () => { + const home = temp(); const file = join(home, SYNC_STATE_FILE); + for (const bad of ["{ nope", "[]", JSON.stringify({ lastAttemptAt: "yesterday", lastOkAt: 5, failures: -3 }), "x".repeat(10_000)]) { + writeFileSync(file, bad); + const state = readSyncState(home); + expect(state.failures).toBe(0); expect(syncDue(state, T0)).toBe(true); + } + writeFileSync(file, JSON.stringify({ lastAttemptAt: new Date(T0).toISOString(), lastOkAt: null, failures: 1e9 })); + expect(readSyncState(home).failures).toBeLessThanOrEqual(30); + rmSync(file); symlinkSync(join(home, "elsewhere"), file); + expect(readSyncState(home).lastAttemptAt).toBeNull(); + rmSync(file); recordSyncAttempt(home, false, T0); + expect(Object.keys(JSON.parse(readFileSync(file, "utf8"))).sort()).toEqual(["failures", "lastAttemptAt", "lastOkAt"]); + expect(statSync(file).mode & 0o777).toBe(0o600); +}); + +test("recording an attempt in a home that cannot be written never throws", () => { + const home = temp(); writeFileSync(join(home, "blocked"), "a file, not a directory"); + expect(() => recordSyncAttempt(join(home, "blocked", "inside"), true, T0)).not.toThrow(); +}); + +test("a configured remote is read from the repository config without a git process and without ever returning the URL", () => { + const home = temp(); + expect(remoteConfigured(home)).toBe(false); + mkdirSync(join(home, ".git"), { recursive: true }); writeFileSync(join(home, ".git", "config"), "[core]\n\tbare = false\n"); + expect(remoteConfigured(home)).toBe(false); + writeFileSync(join(home, ".git", "config"), '[remote "origin"]\n\turl = https://token@example.invalid/x.git\n'); + expect(remoteConfigured(home)).toBe(true); + const other = temp(); mkdirSync(join(other, "shard-sync", "objects.git"), { recursive: true }); + writeFileSync(join(other, "shard-sync", "objects.git", "config"), '[remote "origin"]\n\turl = x\n'); + expect(remoteConfigured(other)).toBe(false); // a shard cache left behind in a home that is not split is not the active repository + writeFileSync(join(other, "shards.json"), "{}"); // only the file's presence selects the split layout (its content is validated elsewhere) + expect(remoteConfigured(other)).toBe(true); +}); + +test("a generation home is read at its own repository: its remote counts, the legacy repository beside it does not (review: P2)", async () => { + const { migrate } = await import("../src/memory/migrate.ts"); + const { syncGenerationMemory } = await import("../src/memory/generation-sync.ts"); + const G = "c".repeat(32), root = temp(), url = join(root, "remote.git"); + expect(memoryGitSync(["init", "--bare", "-q", url], { encoding: "utf8" }).status).toBe(0); + const legacy = new MemoryStore({ home: root, profile: "shared-v1", deferPromotions: true }), memoryDir = legacy.dir; + legacy.note({ op: "note", project: "p", scope: "project", tier: "long", kind: "decision", peer: "me", source: "person", text: "legacy input", about: [] }); + legacy.close(); + expect(remoteConfigured(memoryDir)).toBe(false); + await migrate({ memoryDir, generation: G, mode: "apply", authorizedPerson: true, tty: true }); + const store = new MemoryStore({ home: root, profile: { generation: G }, deferPromotions: true }); + try { + expect(remoteConfigured(memoryDir)).toBe(false); // active generation, no remote yet + mkdirSync(join(memoryDir, ".git"), { recursive: true }); writeFileSync(join(memoryDir, ".git", "config"), '[remote "origin"]\n\turl = x\n'); + expect(remoteConfigured(memoryDir)).toBe(false); // the legacy repository's remote is not the generation's + expect((await syncGenerationMemory(store, { remote: url, visibility: async () => "private" })).ok).toBe(true); + expect(remoteConfigured(memoryDir)).toBe(true); // the generation's own repository now has one + } finally { store.close(); } +}); + +// ---- the CLI, against a real local bare remote ---- +function rig() { + const root = temp(), memory = join(root, "memory"), cwd = join(root, "repo"), remote = join(root, "remote.git"); + mkdirSync(join(cwd, ".git"), { recursive: true }); + expect(memoryGitSync(["init", "--bare", "-q", remote], { encoding: "utf8" }).status).toBe(0); + const project = memoryProjectKey(cwd); + const s = new MemoryStore({ memoryDir: memory, deferPromotions: true }); + try { s.note({ op: "note", project, scope: "project", tier: "long", kind: "decision", peer: "me", source: "person", text: "a public decision", about: [] } as never); } finally { s.close(); } + const run = async (args: string[], tty = true): Promise => { + const saved = process.env.AMEM_HOME; process.env.AMEM_HOME = memory; + try { return await runCli(args, { cwd, env: {}, stdinTTY: tty, visibility: async () => "private" }); } + finally { if (saved === undefined) delete process.env.AMEM_HOME; else process.env.AMEM_HOME = saved; } + }; + return { root, memory, cwd, remote, run, stamp: () => readSyncState(memory) }; +} + +test("sync --if-due without a configured remote does nothing, creates nothing and stamps nothing", async () => { + const r = rig(); + expect(await r.run(["sync", "--if-due"])).toBe("no sync remote configured: nothing to do"); + expect(JSON.parse(await r.run(["sync", "--if-due", "--json"]))).toEqual({ ok: true, skipped: "no sync remote configured" }); + expect(existsSync(join(r.memory, SYNC_STATE_FILE))).toBe(false); expect(existsSync(join(r.memory, ".git"))).toBe(false); +}); + +test("sync --if-due syncs when due, stamps the success, and is then skipped until the interval has passed", async () => { + const r = rig(); + expect(await r.run(["sync", "--remote", r.remote])).toContain("synced"); // a person sets the remote once: a plain sync, which stamps too + const first = r.stamp(); expect(first.failures).toBe(0); expect(first.lastOkAt).not.toBeNull(); + const skipped = JSON.parse(await r.run(["sync", "--if-due", "--json"])); + expect(skipped).toMatchObject({ ok: true, skipped: "not due", failures: 0 }); + expect(Date.parse(skipped.nextAttemptAt)).toBe(Date.parse(first.lastAttemptAt!) + SYNC_INTERVAL_MS); + expect(r.stamp()).toEqual(first); // a skipped run neither syncs nor re-stamps + expect(await r.run(["sync", "--if-due"])).toContain("sync not due until"); + const old = new Date(Date.now() - 2 * SYNC_INTERVAL_MS).toISOString(); + writeFileSync(join(r.memory, SYNC_STATE_FILE), JSON.stringify({ lastAttemptAt: old, lastOkAt: old, failures: 0 })); + expect(await r.run(["sync", "--if-due"])).toContain("synced"); + expect(Date.parse(r.stamp().lastAttemptAt!)).toBeGreaterThan(Date.parse(old)); +}); + +test("a failed sync stamps a failure and backs off; --force overrides the backoff; a success clears it", async () => { + const r = rig(); + await r.run(["sync", "--remote", r.remote]); + rmSync(r.remote, { recursive: true, force: true }); // the remote vanishes: every sync now fails + const old = new Date(Date.now() - 2 * SYNC_INTERVAL_MS).toISOString(); + writeFileSync(join(r.memory, SYNC_STATE_FILE), JSON.stringify({ lastAttemptAt: old, lastOkAt: old, failures: 0 })); + await expect(r.run(["sync", "--if-due"])).rejects.toThrow(/sync/); + expect(r.stamp().failures).toBe(1); + const wait = JSON.parse(await r.run(["sync", "--if-due", "--json"])); + expect(wait).toMatchObject({ skipped: "not due", failures: 1 }); + expect(Date.parse(wait.nextAttemptAt) - Date.parse(r.stamp().lastAttemptAt!)).toBe(15 * MIN); + await expect(r.run(["sync", "--if-due", "--force"])).rejects.toThrow(/sync/); // --force ignores the backoff and fails again + expect(r.stamp().failures).toBe(2); + expect(memoryGitSync(["init", "--bare", "-q", r.remote], { encoding: "utf8" }).status).toBe(0); + expect(await r.run(["sync", "--if-due", "--force"])).toContain("synced"); + expect(r.stamp()).toMatchObject({ failures: 0 }); +}); + +test("--force and --if-due are refused where they do not belong, and a scheduled sync still needs a terminal", async () => { + const r = rig(); + await expect(r.run(["sync", "--force"])).rejects.toThrow("--force only applies with sync --if-due"); + await expect(r.run(["sync", "--if-due"], false)).rejects.toThrow("terminal on stdin"); + await expect(r.run(["list", "--if-due"])).rejects.toThrow(/unknown option/); + expect(existsSync(join(r.memory, SYNC_STATE_FILE))).toBe(false); +}); + +test("doctor reports the sync cadence: no remote, then a due remote, stale and failing ones, never a URL", async () => { + const r = rig(); + const report = async () => JSON.parse(await r.run(["doctor", "--json"], false)).sections.cadence; + expect((await report()).sync).toMatchObject({ remote: "none", due: false }); + await r.run(["sync", "--remote", r.remote]); + const ok = await report(); + expect(ok.sync).toMatchObject({ remote: "configured", failures: 0, due: false }); expect(ok.status).toBe("ok"); + expect(ok.sync.schedule).toContain("amem sync --if-due"); expect(ok.sync.schedule).toContain("registers no sync hook"); + expect(JSON.stringify(ok)).not.toContain(r.remote); // the remote URL never reaches the report + const old = new Date(Date.now() - 10 * 86400_000).toISOString(); + writeFileSync(join(r.memory, SYNC_STATE_FILE), JSON.stringify({ lastAttemptAt: old, lastOkAt: old, failures: 0 })); + expect(await report()).toMatchObject({ status: "attention", hint: expect.stringContaining("no recent successful sync") }); + const now = new Date().toISOString(); + writeFileSync(join(r.memory, SYNC_STATE_FILE), JSON.stringify({ lastAttemptAt: now, lastOkAt: now, failures: 3 })); + expect(await report()).toMatchObject({ status: "attention", hint: expect.stringContaining("failed repeatedly") }); +}); + +test("a failed sync reported through --json is stamped as a failure, not a success (review finding 4)", async () => { + const r = rig(); + await r.run(["sync", "--remote", r.remote]); + rmSync(r.remote, { recursive: true, force: true }); // the remote vanishes: sync returns {ok:false} under --json instead of throwing + const out = JSON.parse(await r.run(["sync", "--if-due", "--force", "--json"])); + expect(out.ok).toBe(false); + const stamp = r.stamp(); + expect(stamp.failures).toBe(1); + expect(stamp.lastOkAt).not.toBe(stamp.lastAttemptAt); // a failure never becomes the last success + expect(JSON.parse(await r.run(["sync", "--if-due", "--json"]))).toMatchObject({ skipped: "not due", failures: 1 }); // so the backoff holds for a JSON caller too + const doc = JSON.parse(await r.run(["doctor", "--json"], false)).sections.cadence; + expect(doc.sync.failures).toBe(1); expect(doc.sync.lastOkAt).not.toBe(doc.sync.lastAttemptAt); +}); + +test("a future stamp is no history: it neither blocks --if-due nor claims a success, and doctor shows no negative age (nit)", async () => { + const r = rig(); + await r.run(["sync", "--remote", r.remote]); + const future = new Date(Date.now() + 5 * 86400_000).toISOString(); + writeFileSync(join(r.memory, SYNC_STATE_FILE), JSON.stringify({ lastAttemptAt: future, lastOkAt: future, failures: 4 })); + expect(usableState(readSyncState(r.memory), Date.now())).toEqual({ lastAttemptAt: null, lastOkAt: null, failures: 0 }); + expect(await r.run(["sync", "--if-due"])).toContain("synced"); // due, not blocked for five days + const doc = JSON.parse(await r.run(["doctor", "--json"], false)).sections.cadence; + writeFileSync(join(r.memory, SYNC_STATE_FILE), JSON.stringify({ lastAttemptAt: future, lastOkAt: future, failures: 4 })); + const shown = JSON.parse(await r.run(["doctor", "--json"], false)).sections.cadence.sync; + expect(shown.lastOkAt).toBeNull(); expect(shown.failures).toBe(0); expect(doc.sync.remote).toBe("configured"); +}); + +test("only a transport failure backs the schedule off: a refusal no remote ever saw is not stamped (nit)", async () => { + expect(syncOutcome(undefined)).toBe("ok"); + for (const local of ["another sync is running on this machine", "another memory mutation is running on this machine", "another generation sync is running or its owner is unknown", "no sync remote: a person sets one", "the sync remote is public; memory syncs only to a private remote", "the configured privacy policy declines 1 long-term entry: abcd1234", "amem sync: usage: amem sync [--remote ]", "this machine's long-term segment cannot be read, so it was not screened"]) expect(syncOutcome(local)).toBe("local"); + for (const failed of ["fetch failed: could not read from remote", "push failed: rejected", "merge failed: conflict", "commit failed: x", "something nobody has heard of"]) expect(syncOutcome(failed)).toBe("failed"); + const r = rig(); + await r.run(["sync", "--remote", r.remote]); + const before = r.stamp(); + writeFileSync(join(r.memory, "amem.config.json"), JSON.stringify({ piiPatterns: ["public decision"] })); // the policy now declines the one note: a local refusal + await expect(r.run(["sync", "--force", "--if-due"])).rejects.toThrow(/privacy policy/); + expect(r.stamp()).toEqual(before); // not stamped: no remote was reached, nothing to back off from +}); + +test("the --if-due deadline reaches git's transport children: an ssh stand-in that sleeps is gone after the abort (review finding 5)", async () => { + const { syncGit } = await import("../src/memory/sync.ts"); + const root = temp(), pidFile = join(root, "child.pid"), fakeSsh = join(root, "fake-ssh"); + writeFileSync(fakeSsh, `#!/bin/sh\necho $$ > ${JSON.stringify(pidFile)}\nexec sleep 60\n`, { mode: 0o755 }); + const repo = join(root, "repo"); mkdirSync(repo); + expect(memoryGitSync(["init", "-q", repo], { encoding: "utf8" }).status).toBe(0); + expect(memoryGitSync(["-C", repo, "remote", "add", "origin", "ssh://example.invalid/x.git"], { encoding: "utf8" }).status).toBe(0); + const saved = process.env.GIT_SSH_COMMAND; process.env.GIT_SSH_COMMAND = fakeSsh; + const controller = new AbortController(); + try { + const running = syncGit(repo, ["fetch", "-q", "origin"], controller.signal); + for (let i = 0; i < 100 && !existsSync(pidFile); i++) await Bun.sleep(50); + expect(existsSync(pidFile)).toBe(true); // git started its transport child + const child = Number(readFileSync(pidFile, "utf8").trim()); + controller.abort(); + await running; + for (let i = 0; i < 100; i++) { try { process.kill(child, 0); } catch { break; } await Bun.sleep(50); } + let alive = true; try { process.kill(child, 0); } catch { alive = false; } + expect(alive).toBe(false); // the transport child died with git, not 60 seconds later + } finally { if (saved === undefined) delete process.env.GIT_SSH_COMMAND; else process.env.GIT_SSH_COMMAND = saved; } +}); + +test("every way a terminal ends an --if-due run (Ctrl-C, SIGTERM, SIGHUP, SIGQUIT) stops it, a second one is still handled, and the handlers are removed afterwards (review: sighup orphans git)", async () => { + const { EventEmitter } = await import("node:events"); + expect([...STOP_SIGNALS]).toEqual(["SIGINT", "SIGTERM", "SIGHUP", "SIGQUIT"]); + for (const name of STOP_SIGNALS) { + const emitter = new EventEmitter(), stop = stopSignal(60_000, emitter); + expect(stop.signal.aborted).toBe(false); expect(emitter.listenerCount(name)).toBe(1); + emitter.emit(name); + expect(stop.signal.aborted).toBe(true); + expect(emitter.listenerCount(name)).toBe(1); // `on`, not `once`: still installed, so a second signal is not the default action + emitter.emit(name); emitter.emit(name); + stop.dispose(); for (const other of STOP_SIGNALS) expect(emitter.listenerCount(other)).toBe(0); + } + const quiet = new EventEmitter(), idle = stopSignal(60_000, quiet); idle.dispose(); + expect(idle.signal.aborted).toBe(false); expect(quiet.listenerCount("SIGHUP")).toBe(0); + const soon = stopSignal(30, new EventEmitter()); + await Bun.sleep(80); expect(soon.signal.aborted).toBe(true); soon.dispose(); // the deadline still fires +}); + +/** + * The real binary under a pty, syncing through a fake ssh that records its own pid and amem's, then sleeps like a stalled network. `end` ends the run the way a person (or a + * closing terminal) would; the answer is what is still alive afterwards. git runs in its own process group during --if-due, so none of this reaches git by itself. + */ +async function endedRun(end: (amem: number, group: number) => void | Promise): Promise<{ sshAlive: boolean; gitAlive: boolean; status: string; message: string; stamp: ReturnType; skipped?: string }> { + const root = temp(), memory = join(root, "memory"), cwd = join(root, "repo"), sshFile = join(root, "ssh.pid"), amemFile = join(root, "amem.pid"), gitFile = join(root, "git.pid"), fakeSsh = join(root, "fake-ssh"); + mkdirSync(cwd, { recursive: true }); expect(memoryGitSync(["init", "-q", cwd], { encoding: "utf8" }).status).toBe(0); + // ssh <- git <- amem: record all three, then stall. + writeFileSync(fakeSsh, `#!/bin/sh\necho $$ > ${JSON.stringify(sshFile)}\nps -o ppid= -p $$ | tr -d ' ' > ${JSON.stringify(gitFile)}\nps -o ppid= -p $(cat ${JSON.stringify(gitFile)}) | tr -d ' ' > ${JSON.stringify(amemFile)}\nexec sleep 120\n`, { mode: 0o755 }); + const s = new MemoryStore({ memoryDir: memory, deferPromotions: true }); + try { s.note({ op: "note", project: memoryProjectKey(cwd), scope: "project", tier: "long", kind: "decision", peer: "me", source: "person", text: "a public decision", about: [] } as never); } finally { s.close(); } + const bin = join(import.meta.dir, "../bin/amem.js"), out = join(root, "out.txt"); + const argv = ptyArgv(`cd ${JSON.stringify(cwd)} && ${JSON.stringify(process.execPath)} ${JSON.stringify(bin)} sync --if-due --force --remote ssh://example.invalid/x.git > ${JSON.stringify(out)} 2>&1; echo status:$?`); + if (argv === undefined) return { sshAlive: false, gitAlive: false, status: "", message: "", stamp: readSyncState(memory), skipped: "the `script` utility is not installed, so no pty could be driven" }; + const env = { ...process.env, AMEM_HOME: memory, GIT_SSH_COMMAND: fakeSsh }; for (const key of ["CLAUDECODE", "CODEX_THREAD_ID", "AGENTHUB_PEER_ID", "CLAUDE_CODE_SESSION_ID", "PI_SESSION_ID"]) delete (env as Record)[key]; + const child = Bun.spawn(argv, { env, stdin: "ignore", stdout: "pipe", stderr: "pipe" }); + const alive = (pid: number) => { try { process.kill(pid, 0); return true; } catch { return false; } }; + let ssh = 0, git = 0, amem = 0, status = ""; + try { + for (let i = 0; i < 200 && !(existsSync(amemFile) && readFileSync(amemFile, "utf8").trim()); i++) await Bun.sleep(50); + expect(existsSync(sshFile)).toBe(true); // the sync reached git's transport + ssh = Number(readFileSync(sshFile, "utf8").trim()); git = Number(readFileSync(gitFile, "utf8").trim()); amem = Number(readFileSync(amemFile, "utf8").trim()); + expect(alive(ssh) && alive(git)).toBe(true); expect(amem).toBeGreaterThan(1); expect(git).not.toBe(amem); + await end(amem, amem); + for (let i = 0; i < 120 && (alive(ssh) || alive(git)); i++) await Bun.sleep(50); + const result = { sshAlive: alive(ssh), gitAlive: alive(git) }; + await Promise.race([child.exited, Bun.sleep(3000)]); + status = await Promise.race([new Response(child.stdout).text(), Bun.sleep(1000).then(() => "")]); + return { ...result, status, message: existsSync(out) ? readFileSync(out, "utf8") : "", stamp: readSyncState(memory) }; + } finally { for (const pid of [ssh, git]) if (pid && alive(pid)) process.kill(pid, "SIGKILL"); child.kill(); } +} +const signalAmem = (name: NodeJS.Signals) => (amem: number) => { process.kill(amem, name); }; + +test("the real binary: Ctrl-C during `sync --if-due --force` stops git and its ssh transport child, and stamps a failure it can explain (review finding 5, end to end)", async () => { + const r = await endedRun(signalAmem("SIGINT")); + if (r.skipped) { console.log(`SKIPPED: ${r.skipped}`); return; } + expect(r.sshAlive).toBe(false); expect(r.gitAlive).toBe(false); // the transport child and git are gone, not 120 seconds later + expect(r.status).toMatch(/status:1/); expect(r.message).toContain("fetch failed"); // amem exits 1 and says why (never a URL) + expect(r.stamp.failures).toBe(1); // an interrupted transport is a failure to back off from +}); + +for (const name of ["SIGHUP", "SIGQUIT", "SIGTERM"] as const) test(`the real binary: ${name} during --if-due leaves no git and no ssh behind (review: sighup orphans git)`, async () => { + const r = await endedRun(signalAmem(name)); + if (r.skipped) { console.log(`SKIPPED: ${r.skipped}`); return; } + expect(r.sshAlive).toBe(false); expect(r.gitAlive).toBe(false); +}); + +test("the real binary: a second Ctrl-C while git is being stopped still leaves nothing behind", async () => { + // Three Ctrl-Cs in a row, as a person hammering the key sends them. amem may already have exited when a later one arrives (that is the point: it stops quickly), so a gone + // process is not an error here. What must hold is that none of them ever strands git: with `once` the second one fell to the default action and ended amem before git. + const r = await endedRun(amem => { for (let i = 0; i < 3; i++) { try { process.kill(amem, "SIGINT"); } catch { /* already exited */ } } }); + if (r.skipped) { console.log(`SKIPPED: ${r.skipped}`); return; } + expect(r.sshAlive).toBe(false); expect(r.gitAlive).toBe(false); +}); + +test("the real binary: SIGHUP to the whole foreground group, as a closing terminal sends it, leaves nothing behind (review: sighup orphans git)", async () => { + // A terminal close signals amem's foreground group; git is in its own group, so only amem (and the shell around it) receive this. + const r = await endedRun((amem) => { try { process.kill(-amem, "SIGHUP"); } catch { process.kill(amem, "SIGHUP"); } }); + if (r.skipped) { console.log(`SKIPPED: ${r.skipped}`); return; } + expect(r.sshAlive).toBe(false); expect(r.gitAlive).toBe(false); +}); + +test("a process that exits while a deadline git is running takes git and its ssh child with it, whatever ended it (the exit handler; review: sighup orphans git)", async () => { + const root = temp(), sshFile = join(root, "ssh.pid"), fakeSsh = join(root, "fake-ssh"), script = join(root, "dies.ts"); + writeFileSync(fakeSsh, `#!/bin/sh\necho $$ > ${JSON.stringify(sshFile)}\nexec sleep 120\n`, { mode: 0o755 }); + const repo = join(root, "repo"); mkdirSync(repo); + expect(memoryGitSync(["init", "-q", repo], { encoding: "utf8" }).status).toBe(0); + expect(memoryGitSync(["-C", repo, "remote", "add", "origin", "ssh://example.invalid/x.git"], { encoding: "utf8" }).status).toBe(0); + // A caller that has a stop signal, runs git, and is then ended by something none of the four handlers sees: an uncaught throw. + writeFileSync(script, `const { syncGit } = await import(${JSON.stringify(join(import.meta.dir, "../src/memory/sync.ts"))}); +const controller = new AbortController(); +syncGit(${JSON.stringify(repo)}, ["fetch", "-q", "origin"], controller.signal); +await Bun.sleep(1500); +throw new Error("amem died for some other reason"); +`); + const child = Bun.spawn([process.execPath, script], { env: { ...process.env, GIT_SSH_COMMAND: fakeSsh }, stdin: "ignore", stdout: "ignore", stderr: "ignore" }); + const alive = (pid: number) => { try { process.kill(pid, 0); return true; } catch { return false; } }; + let ssh = 0; + try { + for (let i = 0; i < 100 && !existsSync(sshFile); i++) await Bun.sleep(50); + expect(existsSync(sshFile)).toBe(true); + ssh = Number(readFileSync(sshFile, "utf8").trim()); + expect(alive(ssh)).toBe(true); + await child.exited; + for (let i = 0; i < 60 && alive(ssh); i++) await Bun.sleep(50); + expect(alive(ssh)).toBe(false); // not left at ppid 1 for two minutes + } finally { if (ssh && alive(ssh)) process.kill(ssh, "SIGKILL"); child.kill(); } +}); + +test("amem never registers a sync hook or a resident process: no setup, hook or ingest source mentions sync, and the cadence module starts no process or timer", () => { + for (const file of ["../src/setup.ts", "../src/setup-owned.ts", "../src/setup-toml.ts", "../src/hooks.ts", "../src/hook-native.ts", "../src/hook-spool.ts", "../src/ingest.ts"]) { + const text = readFileSync(new URL(file, import.meta.url), "utf8"); + expect(text, file).not.toMatch(/\bsync\b.*\b(SessionEnd|session_shutdown)\b|\b(SessionEnd|session_shutdown)\b.*\bsync\b|amem sync|syncMemory|sync-cadence/); + } + const cadence = readFileSync(new URL("../src/memory/sync-cadence.ts", import.meta.url), "utf8"); + expect(cadence).not.toMatch(/Bun\.spawn|child_process|setInterval|setTimeout|detached/); +});