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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/sync-agent-serve.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@bounded-systems/prx": minor
---

Add the sync agent (`prx sync serve`, prx-697): a long-running daemon that every
`--interval` seconds runs the existing cross-repo reconcile orchestrators
(`runBeadsSyncAcrossRepos` beads↔GH + `runDoltReconcileAcrossRepos` dolt
push/pull) over the repo inventory — so beads durability no longer depends on a
hand-run sync. Best-effort per tick (per-repo failures self-isolate; a pass-level
throw is swallowed + logged, like beadsd's refresh). The blocking prerequisite
for prx-82b (remove host bd). No socket in v1 — it's a periodic orchestrator, not
a request daemon.
3 changes: 2 additions & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

<!-- GENERATED by `bun run cli:render` (the `prx docs --only cli` verb) from the prx command registry (src/cli/registry.data.ts). Do not edit by hand — edit the registry and regenerate (`bun run cli:render`). Not a hard CI gate (the command set grows on main faster than a branch can track); regenerated on main by the auto-docs-PR workflow and unit-tested by test/cli/docs.test.ts. -->

`prx` exposes **242** commands across 4 domains,
`prx` exposes **243** commands across 4 domains,
owned by **16** actors. Each command is a registry entry; the
CLI, MCP toolset, help text, and this page are all projections of it.

Expand Down Expand Up @@ -175,6 +175,7 @@ CLI, MCP toolset, help text, and this page are all projections of it.
| `sync` | Canonical actor surface for issue reconcile | domain_sync | — |
| `sync backfill` | Backfill cursor-skipped external records over a range | domain_sync | — |
| `sync issues` | Reconcile pinned pairs; --limit caps push only | domain_sync | — |
| `sync serve` | Run the sync agent: periodic cross-repo reconcile daemon | domain_sync | — |
| `transcripts digest` | Digest transcripts into durable memory shards | transcripts | — |
| `transcripts status` | Report transcript TTL pressure and candidates | transcripts | — |

Expand Down
62 changes: 62 additions & 0 deletions packages/prx/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1079,6 +1079,35 @@
}
}
},
"/sync/serve": {
"post": {
"operationId": "sync_serve",
"summary": "Run the sync agent: periodically reconcile every inventory repo (beads + dolt).",
"x-prx-actor": "work",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/sync_serveInput"
}
}
}
},
"responses": {
"200": {
"description": "ok",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/sync_serveOutput"
}
}
}
}
}
}
},
"/transition": {
"post": {
"operationId": "transition",
Expand Down Expand Up @@ -3406,6 +3435,39 @@
],
"additionalProperties": false
},
"sync_serveInput": {
"type": "object",
"properties": {
"interval": {
"description": "seconds between cross-repo reconcile passes (default 300)",
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
},
"pidfile": {
"description": "write the daemon pid here (removed on close)",
"type": "string"
},
"cwd": {
"description": "ignored — the sync agent is not repo-bound",
"type": "string"
}
},
"additionalProperties": false
},
"sync_serveOutput": {
"type": "object",
"properties": {
"intervalSeconds": {
"type": "number",
"description": "the cross-repo reconcile interval the agent ran at"
}
},
"required": [
"intervalSeconds"
],
"additionalProperties": false
},
"transitionInput": {
"type": "object",
"properties": {
Expand Down
1 change: 1 addition & 0 deletions packages/prx/scripts/coverage-summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ const PER_FILE_BASELINE = new Set<string>([
"packages/prx/src/room/pod-secrets-verb.ts", // 79% — run() calls live podman (ensurePodSecrets); logic covered in pod-secrets.test.ts, not unit-tested here
"packages/prx/src/room/pod-down-verb.ts", // run() calls live downPod (podman); covered by live e2e, not unit test (mirrors pod-up-verb)
"packages/prx/src/builder/verb.ts", // run() calls live podman + ssh-keygen (the nix-builder container); render core (container-builder.ts) is unit-tested, not the live verb (mirrors pod-up-verb)
"packages/prx/src/sync/serve-verb.ts", // wraps the live cross-repo orchestrators (real bd/gh/dolt); the loop (serve.ts) is unit-tested, not the live wiring (mirrors pod-up-verb)
]);

type Totals = {
Expand Down
11 changes: 11 additions & 0 deletions packages/prx/src/cli/registry.data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1927,6 +1927,17 @@ const RAW_REGISTRY: z.input<typeof CommandSpec>[] = [
domain: "state",
actor: "domain_sync",
},
{
// prx-697: the SYNC AGENT daemon. A long-running loop that every --interval
// seconds runs the cross-repo reconcile orchestrators (beads↔GH + dolt
// push/pull) over the repo inventory, so beads durability doesn't depend on
// a hand-run sync. The blocking prerequisite for prx-82b (remove host bd).
name: "sync serve",
parent: "sync",
description: "Run the sync agent: periodic cross-repo reconcile daemon",
domain: "state",
actor: "domain_sync",
},
{
// GH-1513: bd-side memory-decay policy (GH-1500 ADR §3b; capability
// split 4/4 of GH-298). Operator-triggered tick that classifies closed
Expand Down
2 changes: 2 additions & 0 deletions packages/prx/src/cli/verb-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { podDownVerb } from "../room/pod-down-verb.ts";
import { podUpVerb } from "../room/pod-up-verb.ts";
import { builderUpVerb, builderRegisterVerb } from "../builder/verb.ts";
import { forgeServeVerb } from "../forge-d/serve-verb.ts";
import { syncServeVerb } from "../sync/serve-verb.ts";
import { doorBridgeVerb } from "../door/bridge-verb.ts";
import { doorGrantVerb, doorIssuerKeysVerb } from "../door/grant-verb.ts";
import { conciergeServeVerb } from "../concierge/serve-verb.ts";
Expand Down Expand Up @@ -73,6 +74,7 @@ export const verbRegistry: Registry = {
[builderUpVerb.id]: builderUpVerb,
[builderRegisterVerb.id]: builderRegisterVerb,
[forgeServeVerb.id]: forgeServeVerb,
[syncServeVerb.id]: syncServeVerb,
[doorBridgeVerb.id]: doorBridgeVerb,
[doorGrantVerb.id]: doorGrantVerb,
[doorIssuerKeysVerb.id]: doorIssuerKeysVerb,
Expand Down
13 changes: 12 additions & 1 deletion packages/prx/src/pr-state/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4148,14 +4148,19 @@ export function normalizeNamespaceArgv(argv: string[]): string[] {
// parse time (see the executor branch for `sync-issues-pair`).
if (c0 === "sync") {
if (!c1 || c1.startsWith("-")) {
throw new CliError("sync requires a subcommand: issues, backfill");
throw new CliError("sync requires a subcommand: issues, backfill, serve");
}
if (c1 === "issues") {
return ["sync-issues-pair", ...tail];
}
if (c1 === "backfill") {
return ["sync-backfill", ...tail];
}
// prx-697: `sync serve` (the sync agent) is a spec-driven verb dispatched
// ahead of the legacy parser (runSpecVerb); pass it through unchanged.
if (c1 === "serve") {
return argv;
}
throw new CliError(`Unknown sync subcommand: ${c1}`);
}

Expand Down Expand Up @@ -14858,6 +14863,12 @@ export function runCli(
if (orchestratorVerb === "builder" && orchestratorRest[0] === "register") {
return runSpecVerb("builder register", orchestratorRest.slice(1), output);
}
// `sync serve` — the sync agent (prx-697): periodic cross-repo beads+dolt
// reconcile. Only `serve` routes here; `prx sync issues` falls through to the
// legacy sync parser below.
if (orchestratorVerb === "sync" && orchestratorRest[0] === "serve") {
return runSpecVerb("sync serve", orchestratorRest.slice(1), output);
}
// The `contract <sub>` namespace reroutes several subcommands to verbs that
// are now spec-driven. The early dispatch keys off the raw `argv[0]`
// (`contract`), not the normalized rewrite, so those aliases would miss the
Expand Down
88 changes: 88 additions & 0 deletions packages/prx/src/sync/serve-verb.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// `prx sync serve` — run the SYNC AGENT (prx-697): a long-running daemon that
// every `--interval` seconds reconciles every inventory repo (domain↔GH + dolt
// push/pull) so beads durability doesn't depend on anyone running a sync by hand.
// Authored once as a VerbSpec (projected to CLI / MCP / OpenAPI). Mirrors
// forgeServeVerb's infra shape; the reconcile logic lives in runSyncServe (the
// loop) over the existing, tested cross-repo orchestrators.

import { z } from "zod";

import { defineVerb } from "@bounded-systems/verbspec";

import {
runSyncServe,
DEFAULT_SYNC_INTERVAL_MS,
type SyncServeHandle,
type SyncServeOutput,
} from "./serve.ts";
import { runBeadsSyncAcrossRepos } from "./run-cross-repo.ts";
import { runDoltReconcileAcrossRepos } from "./run-dolt-reconcile-cross-repo.ts";
import { DEFAULT_SYNC_LIMIT } from "./limits.ts";

/** The real domain↔GH pass: reconcile every inventory repo against GitHub. */
async function beadsSyncPass(output: SyncServeOutput): Promise<{ exitCode: number }> {
const r = await runBeadsSyncAcrossRepos(
{ dryRun: false, domain: "gh", limit: DEFAULT_SYNC_LIMIT, format: "plain" },
output,
);
return { exitCode: r.exitCode };
}

/** The real dolt pass: full commit→pull→push reconcile of every eligible repo. */
async function doltReconcilePass(output: SyncServeOutput): Promise<{ exitCode: number }> {
const { exitCode } = await runDoltReconcileAcrossRepos(
{ mode: "full", dryRun: false, format: "plain" },
output,
);
return { exitCode };
}

export const SyncServeResult = z
.object({
intervalSeconds: z.number().describe("the cross-repo reconcile interval the agent ran at"),
})
.strict();
export type SyncServeResult = z.infer<typeof SyncServeResult>;

export type SyncServeVerbDeps = {
serve: typeof runSyncServe;
log: (line: string) => void;
};

const realSyncServeDeps = (): SyncServeVerbDeps => ({
serve: runSyncServe,
log: (line) => console.error(line),
});

export const syncServeVerb = defineVerb({
id: "sync serve",
summary: "Run the sync agent: periodically reconcile every inventory repo (beads + dolt).",
actor: "work",
input: z.object({
interval: z
.number()
.int()
.positive()
.optional()
.describe(`seconds between cross-repo reconcile passes (default ${DEFAULT_SYNC_INTERVAL_MS / 1000})`),
pidfile: z.string().optional().describe("write the daemon pid here (removed on close)"),
// Accepted for daemon-lifecycle uniformity (the generic launcher passes --cwd
// to every serve command). The sync agent is host-global / cross-repo, not
// repo-bound, so this is ignored.
cwd: z.string().optional().describe("ignored — the sync agent is not repo-bound"),
}),
output: SyncServeResult,
deps: realSyncServeDeps,
run: async (input, deps: SyncServeVerbDeps = realSyncServeDeps()): Promise<SyncServeResult> => {
const intervalSeconds = input.interval ?? DEFAULT_SYNC_INTERVAL_MS / 1000;
const handle: SyncServeHandle = await deps.serve({
intervalMs: intervalSeconds * 1000,
...(input.pidfile ? { pidfile: input.pidfile } : {}),
deps: { beadsSyncPass, doltReconcilePass },
});
deps.log(`sync agent: reconciling every inventory repo every ${intervalSeconds}s`);
// Block until terminated — the daemon runs until killed (SIGTERM/SIGINT).
await handle.closed;
return { intervalSeconds };
},
});
Loading
Loading