@bivy/bivy 0.0.0 → 0.1.0-staging.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +105 -0
- package/README.md +265 -5
- package/bin/acp-shim.mjs +298 -0
- package/bin/agent-manifest.json +277 -0
- package/bin/bivy.mjs +4100 -0
- package/bin/codex-app-server-shim.mjs +447 -0
- package/bin/patch-pi-dependencies.mjs +44 -0
- package/bin/prune-sessions.mjs +52 -0
- package/bin/sessions-list.mjs +27 -0
- package/bin/shim-path.mjs +126 -0
- package/bin/uninstall-paths.mjs +48 -0
- package/dist/approval.js +87 -0
- package/dist/attach.js +248 -0
- package/dist/auth.js +258 -0
- package/dist/bivy-login.js +180 -0
- package/dist/browser-open.js +50 -0
- package/dist/control-plane-tasks.js +236 -0
- package/dist/data-dir.js +25 -0
- package/dist/device-registry.js +201 -0
- package/dist/e2e.js +70 -0
- package/dist/ephemeral-exec.js +109 -0
- package/dist/exec.js +209 -0
- package/dist/git-auth.js +155 -0
- package/dist/github-app-auth.js +107 -0
- package/dist/github-app-connect.js +235 -0
- package/dist/github-app-manifest.js +82 -0
- package/dist/github-app-sync-cli.js +93 -0
- package/dist/github-app-vault.js +106 -0
- package/dist/github-apps.js +121 -0
- package/dist/github-connect-repo.js +74 -0
- package/dist/github-device-auth.js +109 -0
- package/dist/github-tasks.js +650 -0
- package/dist/guard.js +109 -0
- package/dist/harness/cache-evict.js +88 -0
- package/dist/harness/checkpoint.js +0 -0
- package/dist/harness/cow-clone.js +84 -0
- package/dist/harness/dep-cache.js +78 -0
- package/dist/harness/disk-admission.js +46 -0
- package/dist/harness/egress.js +30 -0
- package/dist/harness/manager.js +97 -0
- package/dist/harness/mcp-config-formats.js +164 -0
- package/dist/harness/mcp-config.js +111 -0
- package/dist/harness/mcp-inject.js +134 -0
- package/dist/harness/mcp-proxy-cli.js +88 -0
- package/dist/harness/mcp-proxy.js +150 -0
- package/dist/harness/net-proxy.js +120 -0
- package/dist/harness/sandbox.js +96 -0
- package/dist/history-sync.js +26 -0
- package/dist/hosted-endpoints.d.mts +14 -0
- package/dist/hosted-endpoints.mjs +35 -0
- package/dist/identity.js +153 -0
- package/dist/integrations/index.js +4 -0
- package/dist/integrations/manager.js +279 -0
- package/dist/integrations/oauth.js +78 -0
- package/dist/integrations/registry.js +239 -0
- package/dist/integrations/store.js +54 -0
- package/dist/integrations/types.js +1 -0
- package/dist/linear-tasks.js +49 -0
- package/dist/metadata.js +226 -0
- package/dist/multiplexer.js +79 -0
- package/dist/native-pi.js +38 -0
- package/dist/node-stats.js +237 -0
- package/dist/pairing-crypto.js +105 -0
- package/dist/policy/conditions.js +103 -0
- package/dist/policy/policy-engine.js +20 -0
- package/dist/policy/risk.js +18 -0
- package/dist/policy/ruleset.js +113 -0
- package/dist/policy/run-policy.js +108 -0
- package/dist/policy/session-reroute.js +96 -0
- package/dist/pty-runner.py +95 -0
- package/dist/question.js +146 -0
- package/dist/redact.js +97 -0
- package/dist/relay-attach.js +345 -0
- package/dist/relay-chunk.js +73 -0
- package/dist/relay-cli-crypto.js +70 -0
- package/dist/relay-client.js +344 -0
- package/dist/relay-setup.js +262 -0
- package/dist/repo-workspace.js +208 -0
- package/dist/runtime/adoption.js +45 -0
- package/dist/runtime/agent-service-bin.js +149 -0
- package/dist/runtime/agent-service.js +439 -0
- package/dist/runtime/ansi.js +27 -0
- package/dist/runtime/anthropic-preflight.js +80 -0
- package/dist/runtime/claude-code.js +1364 -0
- package/dist/runtime/cli-parsers.js +647 -0
- package/dist/runtime/codex-auth.js +168 -0
- package/dist/runtime/codex-preflight.js +60 -0
- package/dist/runtime/codex-sessions.js +229 -0
- package/dist/runtime/control-plane-location.js +74 -0
- package/dist/runtime/credential-ingest.js +122 -0
- package/dist/runtime/credential-provisioning.js +79 -0
- package/dist/runtime/credential-store.js +435 -0
- package/dist/runtime/credentials.js +153 -0
- package/dist/runtime/host.js +153 -0
- package/dist/runtime/index.js +1548 -0
- package/dist/runtime/local-model-store.js +194 -0
- package/dist/runtime/location-registry.js +28 -0
- package/dist/runtime/model-catalog.js +97 -0
- package/dist/runtime/model-namer.js +85 -0
- package/dist/runtime/native-process-scan.js +102 -0
- package/dist/runtime/native-session-discovery.js +103 -0
- package/dist/runtime/normalize.js +75 -0
- package/dist/runtime/oauth/model-oauth-providers.js +75 -0
- package/dist/runtime/oauth/model-oauth.js +324 -0
- package/dist/runtime/opencode-preflight.js +55 -0
- package/dist/runtime/pi-auth.js +82 -0
- package/dist/runtime/pi-oauth.js +52 -0
- package/dist/runtime/pi-session-discovery.js +42 -0
- package/dist/runtime/pi.js +518 -0
- package/dist/runtime/process.js +499 -0
- package/dist/runtime/protocol.js +630 -0
- package/dist/runtime/remote.js +541 -0
- package/dist/runtime/rpc-protocol.js +56 -0
- package/dist/runtime/ruleset-store.js +117 -0
- package/dist/runtime/session-location.js +50 -0
- package/dist/runtime/types.js +17 -0
- package/dist/secrets-cli.js +134 -0
- package/dist/secrets.js +264 -0
- package/dist/server.js +9411 -0
- package/dist/session/bivy-session.js +1 -0
- package/dist/session/checkpoint-pack.js +133 -0
- package/dist/session/event-log.js +340 -0
- package/dist/session/fork-dirty.js +73 -0
- package/dist/session/fork-prereqs.js +61 -0
- package/dist/session/fork.js +57 -0
- package/dist/session/native-import.js +56 -0
- package/dist/session/reconnect.js +168 -0
- package/dist/session/replication-service.js +236 -0
- package/dist/session/replication.js +106 -0
- package/dist/session/replicator.js +140 -0
- package/dist/session/session-new-dedupe.js +42 -0
- package/dist/session/sibling-client.js +201 -0
- package/dist/session/transcript-merge.js +131 -0
- package/dist/session/transcript-normal.js +130 -0
- package/dist/session/workspace-context.js +1 -0
- package/dist/session-event-coalescer.js +50 -0
- package/dist/session-identity.js +34 -0
- package/dist/session-ref.js +65 -0
- package/dist/stt-cli.js +131 -0
- package/dist/stt.js +168 -0
- package/dist/terminal.js +409 -0
- package/dist/wire-format.js +67 -0
- package/dist/worktree-provision.js +118 -0
- package/dist/worktree.js +117 -0
- package/package.json +40 -6
- package/public/qr.js +464 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
// Seeded-continuation prompt for importing a discovered provider-native
|
|
4
|
+
// session (issue #156) when a true native resume isn't available — the
|
|
5
|
+
// session-level sibling of session/fork.ts's cross-runtime seed path. Pure and
|
|
6
|
+
// side-effect-free (no filesystem/runtime access) so it's unit-testable with
|
|
7
|
+
// synthetic transcripts.
|
|
8
|
+
//
|
|
9
|
+
// This is deliberately a THIN, import-specific wrapper around
|
|
10
|
+
// transcript-normal.ts's shared primitives rather than a copy: normalizeMessages
|
|
11
|
+
// flattens the runtime's RuntimeMessage[] into portable turns exactly as fork
|
|
12
|
+
// does, but the prompt wording here is honest about being an IMPORT (same
|
|
13
|
+
// runtime, no resume available) rather than fork's "moved to a different
|
|
14
|
+
// agent" framing — those are different user-facing claims and must not be
|
|
15
|
+
// conflated.
|
|
16
|
+
import { normalizeMessages } from "./transcript-normal.js";
|
|
17
|
+
function truncate(text, max) {
|
|
18
|
+
const compact = String(text || "").replace(/\s+/g, " ").trim();
|
|
19
|
+
return compact.length > max ? `${compact.slice(0, Math.max(0, max - 1))}…` : compact;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Build the first prompt for a seeded import: a compact summary of the
|
|
23
|
+
* discovered session's recent turns, framed honestly as a best-effort
|
|
24
|
+
* continuation rather than a true resume. Bounded (recent turns + a per-turn
|
|
25
|
+
* character cap, same shape as fork's buildSeedPrompt) so this never inlines
|
|
26
|
+
* an entire transcript — the "bounded metadata only" spirit of discovery
|
|
27
|
+
* extends to what a seeded import is willing to carry into the new session.
|
|
28
|
+
*/
|
|
29
|
+
export function buildNativeImportSeedPrompt(transcript, opts) {
|
|
30
|
+
const recentTurns = opts.recentTurns ?? 12;
|
|
31
|
+
const perTurnChars = opts.perTurnChars ?? 700;
|
|
32
|
+
const title = opts.title || transcript.header.title || "Untitled session";
|
|
33
|
+
const recent = transcript.turns
|
|
34
|
+
.filter((t) => t.text || t.toolSummary)
|
|
35
|
+
.slice(-recentTurns)
|
|
36
|
+
.map((t) => {
|
|
37
|
+
const body = t.text || (t.toolSummary ? `[${t.toolName ?? "tool"}] ${t.toolSummary}` : "");
|
|
38
|
+
return `- ${t.role}: ${truncate(body, perTurnChars)}`;
|
|
39
|
+
})
|
|
40
|
+
.join("\n");
|
|
41
|
+
const lines = [
|
|
42
|
+
`I am continuing a ${opts.provider} session that was started outside Bivy and imported here.`,
|
|
43
|
+
`Native resume wasn't available for it, so this is a fresh session seeded with a summary of the prior conversation — not the original session itself.`,
|
|
44
|
+
`Session: ${title}`,
|
|
45
|
+
opts.cwd ? `Working directory: ${opts.cwd}` : null,
|
|
46
|
+
"",
|
|
47
|
+
"Recent conversation (most recent last):",
|
|
48
|
+
recent || "- (no prior turns were available)",
|
|
49
|
+
].filter((line) => line !== null);
|
|
50
|
+
return lines.join("\n");
|
|
51
|
+
}
|
|
52
|
+
/** Convenience: normalize + seed in one call, for the common case of reading a
|
|
53
|
+
* runtime's raw messages straight off disk. */
|
|
54
|
+
export function buildNativeImportSeedFromMessages(messages, header, opts) {
|
|
55
|
+
return buildNativeImportSeedPrompt(normalizeMessages(messages, header), opts);
|
|
56
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
//
|
|
4
|
+
// A supervised, self-healing connection with exponential backoff + jitter — the
|
|
5
|
+
// reliability layer under the sibling replication transport
|
|
6
|
+
// (docs/session-replication.md, follow-up #2). Without it, warm replication is
|
|
7
|
+
// only as reliable as the socket at each individual turn boundary: a relay blip
|
|
8
|
+
// or a standby restart silently pauses replication until the next turn happens to
|
|
9
|
+
// reconnect, and repeated failures surface nothing. This keeps exactly one live
|
|
10
|
+
// connection to the standby, reconnecting on drop so the standby stays warm.
|
|
11
|
+
//
|
|
12
|
+
// Generic + transport-free (the connect/close and the clock are injected), so the
|
|
13
|
+
// backoff/state machine unit-tests without a real socket, mirroring
|
|
14
|
+
// session-event-coalescer.ts. `ReplicationService` wires a `SiblingClient` into it.
|
|
15
|
+
//
|
|
16
|
+
// Backoff shape: a CONNECT failure backs off (base·factor^attempt, capped, with
|
|
17
|
+
// jitter); a DROP after a healthy connection resets the counter so reconnection is
|
|
18
|
+
// prompt (one base delay) rather than treating a long-lived session that finally
|
|
19
|
+
// dropped as if it had been flapping.
|
|
20
|
+
const defaultClock = {
|
|
21
|
+
setTimeout: (fn, ms) => setTimeout(fn, ms),
|
|
22
|
+
clearTimeout: (h) => clearTimeout(h),
|
|
23
|
+
random: () => Math.random(),
|
|
24
|
+
};
|
|
25
|
+
export class ReconnectingConnection {
|
|
26
|
+
opts;
|
|
27
|
+
conn;
|
|
28
|
+
attempt = 0; // consecutive CONNECT failures
|
|
29
|
+
timer;
|
|
30
|
+
connecting = false;
|
|
31
|
+
stopped = false;
|
|
32
|
+
waiters = [];
|
|
33
|
+
base;
|
|
34
|
+
factor;
|
|
35
|
+
max;
|
|
36
|
+
jitter;
|
|
37
|
+
clock;
|
|
38
|
+
constructor(opts) {
|
|
39
|
+
this.opts = opts;
|
|
40
|
+
this.base = opts.backoff?.baseMs ?? 1000;
|
|
41
|
+
this.factor = opts.backoff?.factor ?? 2;
|
|
42
|
+
this.max = opts.backoff?.maxMs ?? 30_000;
|
|
43
|
+
this.jitter = Math.min(1, Math.max(0, opts.backoff?.jitter ?? 0.3));
|
|
44
|
+
this.clock = opts.clock ?? defaultClock;
|
|
45
|
+
}
|
|
46
|
+
/** The live connection, or undefined while (re)connecting. */
|
|
47
|
+
current() {
|
|
48
|
+
return this.conn;
|
|
49
|
+
}
|
|
50
|
+
/** Begin connecting (idempotent). */
|
|
51
|
+
start() {
|
|
52
|
+
if (this.stopped)
|
|
53
|
+
return;
|
|
54
|
+
this.kick();
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Resolve with the live connection, triggering a connect if idle and waiting up
|
|
58
|
+
* to `timeoutMs`. Resolves `undefined` if it isn't up in time — the caller skips
|
|
59
|
+
* this round while the supervisor keeps reconnecting in the background (the
|
|
60
|
+
* log-based replicator simply ships the accumulated delta on a later turn).
|
|
61
|
+
*/
|
|
62
|
+
ensure(timeoutMs) {
|
|
63
|
+
if (this.conn)
|
|
64
|
+
return Promise.resolve(this.conn);
|
|
65
|
+
if (this.stopped)
|
|
66
|
+
return Promise.resolve(undefined);
|
|
67
|
+
this.start();
|
|
68
|
+
return new Promise((resolve) => {
|
|
69
|
+
const waiter = {
|
|
70
|
+
resolve,
|
|
71
|
+
timer: this.clock.setTimeout(() => {
|
|
72
|
+
this.removeWaiter(waiter);
|
|
73
|
+
resolve(undefined);
|
|
74
|
+
}, timeoutMs),
|
|
75
|
+
};
|
|
76
|
+
this.waiters.push(waiter);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
/** Stop for good: cancel timers, fail waiters, and close any live connection. */
|
|
80
|
+
stop() {
|
|
81
|
+
this.stopped = true;
|
|
82
|
+
if (this.timer !== undefined) {
|
|
83
|
+
this.clock.clearTimeout(this.timer);
|
|
84
|
+
this.timer = undefined;
|
|
85
|
+
}
|
|
86
|
+
while (this.waiters.length) {
|
|
87
|
+
const w = this.waiters.pop();
|
|
88
|
+
this.clock.clearTimeout(w.timer);
|
|
89
|
+
w.resolve(undefined);
|
|
90
|
+
}
|
|
91
|
+
if (this.conn !== undefined) {
|
|
92
|
+
try {
|
|
93
|
+
this.opts.close(this.conn);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
/* ignore */
|
|
97
|
+
}
|
|
98
|
+
this.conn = undefined;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// --- internals -----------------------------------------------------------
|
|
102
|
+
kick() {
|
|
103
|
+
if (this.stopped || this.connecting || this.conn !== undefined)
|
|
104
|
+
return;
|
|
105
|
+
this.connecting = true;
|
|
106
|
+
this.opts
|
|
107
|
+
.connect((err) => this.handleDrop(err))
|
|
108
|
+
.then((conn) => {
|
|
109
|
+
this.connecting = false;
|
|
110
|
+
if (this.stopped) {
|
|
111
|
+
try {
|
|
112
|
+
this.opts.close(conn);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
/* ignore */
|
|
116
|
+
}
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
this.conn = conn;
|
|
120
|
+
this.attempt = 0; // healthy again → next drop reconnects promptly
|
|
121
|
+
this.opts.onActive?.(conn);
|
|
122
|
+
this.flushWaiters(conn);
|
|
123
|
+
})
|
|
124
|
+
.catch((err) => {
|
|
125
|
+
this.connecting = false;
|
|
126
|
+
this.scheduleRetry(err);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
handleDrop(err) {
|
|
130
|
+
// Only act on the drop of the connection we currently consider live.
|
|
131
|
+
if (this.conn === undefined)
|
|
132
|
+
return;
|
|
133
|
+
this.conn = undefined;
|
|
134
|
+
this.opts.onDrop?.(err);
|
|
135
|
+
// attempt was reset to 0 on the last successful connect, so this schedules one
|
|
136
|
+
// base-delay retry rather than an immediate hot-loop reconnect.
|
|
137
|
+
this.scheduleRetry(err);
|
|
138
|
+
}
|
|
139
|
+
scheduleRetry(err) {
|
|
140
|
+
if (this.stopped || this.timer !== undefined || this.connecting)
|
|
141
|
+
return;
|
|
142
|
+
const delay = this.delayFor(this.attempt);
|
|
143
|
+
this.attempt += 1;
|
|
144
|
+
this.opts.onRetry?.(err, delay);
|
|
145
|
+
this.timer = this.clock.setTimeout(() => {
|
|
146
|
+
this.timer = undefined;
|
|
147
|
+
this.kick();
|
|
148
|
+
}, delay);
|
|
149
|
+
}
|
|
150
|
+
delayFor(attempt) {
|
|
151
|
+
const raw = Math.min(this.max, this.base * Math.pow(this.factor, attempt));
|
|
152
|
+
// Spread ±jitter/2 around the target: with jitter=0.3 the delay is 85%–115%.
|
|
153
|
+
const spread = raw * this.jitter * (this.clock.random() - 0.5);
|
|
154
|
+
return Math.max(0, Math.round(raw + spread));
|
|
155
|
+
}
|
|
156
|
+
flushWaiters(conn) {
|
|
157
|
+
while (this.waiters.length) {
|
|
158
|
+
const w = this.waiters.pop();
|
|
159
|
+
this.clock.clearTimeout(w.timer);
|
|
160
|
+
w.resolve(conn);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
removeWaiter(waiter) {
|
|
164
|
+
const i = this.waiters.indexOf(waiter);
|
|
165
|
+
if (i >= 0)
|
|
166
|
+
this.waiters.splice(i, 1);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
//
|
|
4
|
+
// Daemon integration for warm session replication (docs/session-replication.md):
|
|
5
|
+
// assembles the unit-tested owner/standby orchestration (replicator.ts), the git
|
|
6
|
+
// checkpoint bundle (checkpoint-pack.ts), the sibling relay-client transport
|
|
7
|
+
// (sibling-client.ts), and the control-plane ownership endpoints into one service
|
|
8
|
+
// the node daemon drives through a handful of thin adapters (server.ts owns the
|
|
9
|
+
// daemon-specific accessors, so THIS module has no server.ts coupling).
|
|
10
|
+
//
|
|
11
|
+
// Owner: on each turn boundary, `onTurnComplete(sessionId)` declares the standby
|
|
12
|
+
// (once), gets the supervised standby connection (which reconnects on its own with
|
|
13
|
+
// backoff — reconnect.ts), ships a frame, and advances the cursor on ack (retrying
|
|
14
|
+
// once on a full-resync request). Standby: `handleReplicaFrame` applies an inbound
|
|
15
|
+
// frame; `promote` runs the control-plane compare-and-set and materializes the
|
|
16
|
+
// replica for local resume.
|
|
17
|
+
//
|
|
18
|
+
// Gated entirely on the node's sessionSync setting + a chosen standby, both off by
|
|
19
|
+
// default — so when replication is disabled this service is inert and the daemon
|
|
20
|
+
// behaves exactly as before.
|
|
21
|
+
import { OwnerReplicator, StandbyApplier } from "./replicator.js";
|
|
22
|
+
import { createCheckpointBundle, applyCheckpointBundle, materializeCheckpoint } from "./checkpoint-pack.js";
|
|
23
|
+
import { SiblingClient } from "./sibling-client.js";
|
|
24
|
+
import { ReconnectingConnection } from "./reconnect.js";
|
|
25
|
+
export class ReplicationService {
|
|
26
|
+
deps;
|
|
27
|
+
owner;
|
|
28
|
+
standby;
|
|
29
|
+
// A single supervised connection to the current standby: it reconnects with
|
|
30
|
+
// backoff on drop, so replication survives relay blips / standby restarts
|
|
31
|
+
// instead of silently pausing until the next turn (follow-up #2).
|
|
32
|
+
supervisor;
|
|
33
|
+
supervisorStandbyId;
|
|
34
|
+
declared = new Set();
|
|
35
|
+
epochs = new Map();
|
|
36
|
+
constructor(deps) {
|
|
37
|
+
this.deps = deps;
|
|
38
|
+
this.owner = new OwnerReplicator({
|
|
39
|
+
readRecords: (id) => deps.readRecords(id),
|
|
40
|
+
epochOf: (id) => this.epochs.get(id) ?? 0,
|
|
41
|
+
checkpointHead: (id) => deps.checkpointHead(id),
|
|
42
|
+
bundleCheckpoint: async (id, since) => {
|
|
43
|
+
const dir = deps.repoDirFor(id);
|
|
44
|
+
return dir ? createCheckpointBundle(dir, id, since) : null;
|
|
45
|
+
},
|
|
46
|
+
runtimeSessionRef: (id) => deps.runtimeSessionRef(id),
|
|
47
|
+
worktreeSync: () => deps.settings().worktreeSync === true,
|
|
48
|
+
});
|
|
49
|
+
this.standby = new StandbyApplier({
|
|
50
|
+
persistRecords: (id, records) => deps.persistReplicaRecords(id, records),
|
|
51
|
+
applyBundle: async (id, bundle) => {
|
|
52
|
+
const dir = await deps.replicaRepoDir(id);
|
|
53
|
+
if (!dir)
|
|
54
|
+
return { ok: false, needFull: true };
|
|
55
|
+
return applyCheckpointBundle(dir, id, bundle);
|
|
56
|
+
},
|
|
57
|
+
materialize: async (id) => {
|
|
58
|
+
const dir = await deps.replicaRepoDir(id);
|
|
59
|
+
if (dir)
|
|
60
|
+
await materializeCheckpoint(dir, id);
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
get fetchImpl() {
|
|
65
|
+
return this.deps.fetchImpl ?? fetch;
|
|
66
|
+
}
|
|
67
|
+
ready() {
|
|
68
|
+
const s = this.deps.settings();
|
|
69
|
+
if (!s.sessionSync || !s.standbyNodeId)
|
|
70
|
+
return null;
|
|
71
|
+
const cp = this.deps.controlPlaneUrl();
|
|
72
|
+
const token = this.deps.enrollmentToken();
|
|
73
|
+
if (!cp || !token)
|
|
74
|
+
return null;
|
|
75
|
+
return { cp: cp.replace(/\/$/, ""), token, standbyId: s.standbyNodeId };
|
|
76
|
+
}
|
|
77
|
+
async cpPost(path, token, body) {
|
|
78
|
+
try {
|
|
79
|
+
const res = await this.fetchImpl(`${this.deps.controlPlaneUrl().replace(/\/$/, "")}${path}`, {
|
|
80
|
+
method: "POST",
|
|
81
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
|
82
|
+
body: JSON.stringify(body),
|
|
83
|
+
});
|
|
84
|
+
if (!res.ok)
|
|
85
|
+
return undefined;
|
|
86
|
+
return (await res.json());
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
async cpGet(path, token) {
|
|
93
|
+
try {
|
|
94
|
+
const res = await this.fetchImpl(`${this.deps.controlPlaneUrl().replace(/\/$/, "")}${path}`, {
|
|
95
|
+
headers: { authorization: `Bearer ${token}` },
|
|
96
|
+
});
|
|
97
|
+
if (!res.ok)
|
|
98
|
+
return undefined;
|
|
99
|
+
return (await res.json());
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** Time a turn will wait for the standby connection before skipping this round. */
|
|
106
|
+
static ENSURE_TIMEOUT_MS = 10_000;
|
|
107
|
+
/**
|
|
108
|
+
* The supervised connection to `standbyId`, created (and started) on demand and
|
|
109
|
+
* kept alive across turns. Switching standby tears the old one down. The
|
|
110
|
+
* supervisor owns reconnection: each attempt mints fresh credentials + a fresh
|
|
111
|
+
* `SiblingClient` and wires its close back to the supervisor's drop signal.
|
|
112
|
+
*/
|
|
113
|
+
supervisorFor(standbyId) {
|
|
114
|
+
if (this.supervisor && this.supervisorStandbyId === standbyId)
|
|
115
|
+
return this.supervisor;
|
|
116
|
+
this.supervisor?.stop();
|
|
117
|
+
const sup = new ReconnectingConnection({
|
|
118
|
+
connect: async (onDrop) => {
|
|
119
|
+
const cp = this.deps.controlPlaneUrl();
|
|
120
|
+
const token = this.deps.enrollmentToken();
|
|
121
|
+
if (!cp || !token)
|
|
122
|
+
throw new Error("control plane not configured");
|
|
123
|
+
const client = new SiblingClient({
|
|
124
|
+
controlPlaneUrl: cp,
|
|
125
|
+
enrollmentToken: token,
|
|
126
|
+
siblingNodeId: standbyId,
|
|
127
|
+
relayUrl: this.deps.relayUrl(),
|
|
128
|
+
label: "Bivy replica",
|
|
129
|
+
fetchImpl: this.deps.fetchImpl,
|
|
130
|
+
onClose: () => onDrop(),
|
|
131
|
+
});
|
|
132
|
+
await client.connect();
|
|
133
|
+
return client;
|
|
134
|
+
},
|
|
135
|
+
close: (client) => client.close(),
|
|
136
|
+
onActive: () => this.deps.log?.(`replication: connected to standby ${standbyId}`),
|
|
137
|
+
onDrop: () => this.deps.log?.(`replication: standby ${standbyId} connection dropped; reconnecting`),
|
|
138
|
+
onRetry: (err, ms) => this.deps.log?.(`replication: standby ${standbyId} unreachable (${err?.message ?? err}); retrying in ${ms}ms`),
|
|
139
|
+
backoff: { baseMs: 1000, factor: 2, maxMs: 30_000, jitter: 0.3 },
|
|
140
|
+
});
|
|
141
|
+
this.supervisor = sup;
|
|
142
|
+
this.supervisorStandbyId = standbyId;
|
|
143
|
+
sup.start();
|
|
144
|
+
return sup;
|
|
145
|
+
}
|
|
146
|
+
/** Tear down the standby connection (sync disabled, or shutting down). */
|
|
147
|
+
stopSupervisor() {
|
|
148
|
+
this.supervisor?.stop();
|
|
149
|
+
this.supervisor = undefined;
|
|
150
|
+
this.supervisorStandbyId = undefined;
|
|
151
|
+
}
|
|
152
|
+
/** OWNER: called after a turn completes for a session. Ships one frame. */
|
|
153
|
+
async onTurnComplete(sessionId) {
|
|
154
|
+
const r = this.ready();
|
|
155
|
+
if (!r) {
|
|
156
|
+
// Sync turned off (or standby cleared) → drop the connection.
|
|
157
|
+
this.stopSupervisor();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
// Declare the standby + learn our epoch once per session.
|
|
161
|
+
if (!this.declared.has(sessionId)) {
|
|
162
|
+
const res = await this.cpPost(`/node/sessions/${encodeURIComponent(sessionId)}/standby`, r.token, { standbyNodeId: r.standbyId });
|
|
163
|
+
const ownership = res?.ownership;
|
|
164
|
+
if (ownership && typeof ownership.ownerEpoch === "number")
|
|
165
|
+
this.epochs.set(sessionId, ownership.ownerEpoch);
|
|
166
|
+
this.declared.add(sessionId);
|
|
167
|
+
}
|
|
168
|
+
// Wait briefly for the (supervised, self-reconnecting) standby connection. If
|
|
169
|
+
// it isn't up yet, skip this round — the replicator is log-based, so the next
|
|
170
|
+
// successful turn ships the accumulated delta.
|
|
171
|
+
const client = await this.supervisorFor(r.standbyId).ensure(ReplicationService.ENSURE_TIMEOUT_MS);
|
|
172
|
+
if (!client)
|
|
173
|
+
return;
|
|
174
|
+
await this.shipOnce(sessionId, client, true);
|
|
175
|
+
}
|
|
176
|
+
async shipOnce(sessionId, client, allowRetry) {
|
|
177
|
+
const frame = await this.owner.buildTurnFrame(sessionId);
|
|
178
|
+
if (!frame)
|
|
179
|
+
return;
|
|
180
|
+
try {
|
|
181
|
+
const reply = await client.request({ kind: "session.replica.frame", frame });
|
|
182
|
+
const ack = (reply.ack ?? reply);
|
|
183
|
+
const resend = this.owner.applyAck(sessionId, ack);
|
|
184
|
+
if (resend && allowRetry)
|
|
185
|
+
await this.shipOnce(sessionId, client, false);
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
this.deps.log?.(`replication: ship failed for ${sessionId}: ${err.message}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/** STANDBY: apply an inbound replication frame and return the ack. */
|
|
192
|
+
async handleReplicaFrame(frame, ownerNodeId) {
|
|
193
|
+
const ack = await this.standby.receive(frame);
|
|
194
|
+
if (ack.status === "applied") {
|
|
195
|
+
this.deps.upsertReplicaMeta(frame.sessionId, { runtimeSessionRef: frame.runtimeSessionRef, ownerNodeId });
|
|
196
|
+
}
|
|
197
|
+
return ack;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* STANDBY: promote this node to owner of a replicated session. Reads the current
|
|
201
|
+
* epoch, runs the control-plane compare-and-set, and (on success) materializes
|
|
202
|
+
* the replica working tree so the session can resume locally. Returns the new
|
|
203
|
+
* owner epoch, or undefined if the promotion lost the race.
|
|
204
|
+
*/
|
|
205
|
+
async promote(sessionId, thisNodeId) {
|
|
206
|
+
const cp = this.deps.controlPlaneUrl();
|
|
207
|
+
const token = this.deps.enrollmentToken();
|
|
208
|
+
if (!cp || !token)
|
|
209
|
+
return undefined;
|
|
210
|
+
const owned = await this.cpGet(`/node/sessions/${encodeURIComponent(sessionId)}/ownership`, token);
|
|
211
|
+
const ownership = owned?.ownership;
|
|
212
|
+
const expectedEpoch = ownership && typeof ownership.ownerEpoch === "number" ? ownership.ownerEpoch : 0;
|
|
213
|
+
const res = await this.cpPost(`/node/sessions/${encodeURIComponent(sessionId)}/promote`, token, {
|
|
214
|
+
toNodeId: thisNodeId,
|
|
215
|
+
expectedEpoch,
|
|
216
|
+
});
|
|
217
|
+
const promoted = res?.ownership;
|
|
218
|
+
if (!promoted)
|
|
219
|
+
return undefined;
|
|
220
|
+
const dir = await this.deps.replicaRepoDir(sessionId);
|
|
221
|
+
if (dir)
|
|
222
|
+
await materializeCheckpoint(dir, sessionId).catch(() => { });
|
|
223
|
+
this.standby.forget(sessionId);
|
|
224
|
+
return promoted.ownerEpoch;
|
|
225
|
+
}
|
|
226
|
+
/** Tear down (session closed / node shutdown). */
|
|
227
|
+
forget(sessionId) {
|
|
228
|
+
this.owner.forget(sessionId);
|
|
229
|
+
this.standby.forget(sessionId);
|
|
230
|
+
this.declared.delete(sessionId);
|
|
231
|
+
this.epochs.delete(sessionId);
|
|
232
|
+
}
|
|
233
|
+
close() {
|
|
234
|
+
this.stopSupervisor();
|
|
235
|
+
}
|
|
236
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
//
|
|
4
|
+
// Warm session replication — the transport-free DECISION core (Phase 1 of
|
|
5
|
+
// docs/session-replication.md).
|
|
6
|
+
//
|
|
7
|
+
// The goal: a standby node continuously mirrors a session's state so that if the
|
|
8
|
+
// owning node goes offline, the session can be *manually* promoted and continued
|
|
9
|
+
// elsewhere WITHOUT fetching anything from the (possibly dead) owner. Both halves
|
|
10
|
+
// of a session's state are already append-only, cursor-friendly logs, so warm
|
|
11
|
+
// replication is "tail two logs and ship their deltas":
|
|
12
|
+
//
|
|
13
|
+
// - Transcript: the per-session `EventLog` (event-log.ts) is append-only JSONL.
|
|
14
|
+
// We ship appended `LogRecord`s using the SAME count+hash cursor as the
|
|
15
|
+
// client-facing incremental sync (history-sync.ts) — self-healing on a gap.
|
|
16
|
+
// - Workspace: the Universal Agent Harness commits a git checkpoint per turn
|
|
17
|
+
// (harness/checkpoint.ts). We ship the checkpoint commit sha; the standby
|
|
18
|
+
// fetches the objects (git's own negotiation is idempotent + self-healing).
|
|
19
|
+
//
|
|
20
|
+
// Replication is anchored to the TURN/CHECKPOINT boundary so the two halves stay
|
|
21
|
+
// mutually consistent: every replicated frame carries the transcript tail AND the
|
|
22
|
+
// checkpoint that closed the same turn, and the standby applies BOTH or NEITHER.
|
|
23
|
+
// Recovery point = "the last completed turn"; a crash mid-turn loses at most the
|
|
24
|
+
// in-flight turn (the standby re-runs the last prompt on promotion).
|
|
25
|
+
//
|
|
26
|
+
// This module is the pure core — no relay, no disk, no git. Side effects (persist
|
|
27
|
+
// the tail, fetch the checkpoint objects) are injected, exactly like adoption.ts
|
|
28
|
+
// injects `attach`/`forget`, so the fencing/cursor/consistency logic unit-tests
|
|
29
|
+
// without a daemon (test/replication.test.ts). Transport, the control-plane epoch
|
|
30
|
+
// column, and the promotion command are layered on top (see the doc).
|
|
31
|
+
import { historyDelta } from "../history-sync.js";
|
|
32
|
+
/** A fresh, empty standby state (before the first frame). */
|
|
33
|
+
export function initialReplState() {
|
|
34
|
+
return { epoch: 0, records: [], historyHash: "" };
|
|
35
|
+
}
|
|
36
|
+
/** The cursor a standby advertises to its owner, derived from its state. */
|
|
37
|
+
export function cursorOf(state) {
|
|
38
|
+
return { count: state.records.length, historyHash: state.historyHash, checkpointCommit: state.checkpointCommit };
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* OWNER side: build the frame to send a standby, given the cursor the standby last
|
|
42
|
+
* advertised. Returns `null` when the standby is already up to date (no new records
|
|
43
|
+
* AND the same checkpoint) so the owner skips an empty send. The transcript delta
|
|
44
|
+
* reuses `historyDelta`, so a diverged/behind standby self-heals: a matching prefix
|
|
45
|
+
* yields an `append`, anything else a `full`.
|
|
46
|
+
*/
|
|
47
|
+
export function buildReplFrame(input) {
|
|
48
|
+
const { sessionId, epoch, records, checkpointCommit, runtimeSessionRef, cursor } = input;
|
|
49
|
+
const delta = historyDelta(records, { have: cursor?.count, haveToken: cursor?.historyHash });
|
|
50
|
+
const checkpointUnchanged = (checkpointCommit ?? undefined) === (cursor?.checkpointCommit ?? undefined);
|
|
51
|
+
// Nothing new: an append that carries no records and no newer checkpoint.
|
|
52
|
+
if (delta.mode === "append" && delta.messages.length === 0 && checkpointUnchanged)
|
|
53
|
+
return null;
|
|
54
|
+
return {
|
|
55
|
+
sessionId,
|
|
56
|
+
epoch,
|
|
57
|
+
runtimeSessionRef,
|
|
58
|
+
checkpointCommit,
|
|
59
|
+
mode: delta.mode,
|
|
60
|
+
baseCount: delta.baseCount,
|
|
61
|
+
records: delta.messages,
|
|
62
|
+
count: delta.count,
|
|
63
|
+
historyHash: delta.historyHash,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* STANDBY side: apply one frame, mutating `state` in place ONLY after every injected
|
|
68
|
+
* side effect has succeeded (so a git/persist failure leaves the replica unchanged
|
|
69
|
+
* and the frame is safely retried). Never advances past a gap — it asks the owner to
|
|
70
|
+
* re-send instead, which `buildReplFrame` resolves to an `append` or a `full`.
|
|
71
|
+
*
|
|
72
|
+
* Fencing (the split-brain guard): a frame whose epoch is BELOW the state's is a
|
|
73
|
+
* write from an owner that has already been superseded by a promotion — rejected.
|
|
74
|
+
* A frame at or above the state's epoch is honored, and applying it adopts the
|
|
75
|
+
* (possibly higher) epoch, so a promoted owner's first frame transfers ownership.
|
|
76
|
+
*/
|
|
77
|
+
export async function applyReplFrame(state, frame, deps) {
|
|
78
|
+
if (frame.epoch < state.epoch)
|
|
79
|
+
return { status: "stale", ownerEpoch: state.epoch };
|
|
80
|
+
// Decide the next record list without mutating state yet.
|
|
81
|
+
let nextRecords;
|
|
82
|
+
if (frame.mode === "full") {
|
|
83
|
+
nextRecords = [...frame.records];
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
// append — only valid when the frame continues exactly where we are.
|
|
87
|
+
if (frame.baseCount !== state.records.length) {
|
|
88
|
+
return { status: "resync", cursor: cursorOf(state) };
|
|
89
|
+
}
|
|
90
|
+
nextRecords = state.records.concat(frame.records);
|
|
91
|
+
}
|
|
92
|
+
// Git first: if it throws, we abort before touching the transcript replica.
|
|
93
|
+
const nextCheckpoint = frame.checkpointCommit ?? state.checkpointCommit;
|
|
94
|
+
if (frame.checkpointCommit && frame.checkpointCommit !== state.checkpointCommit && deps.fetchCheckpoint) {
|
|
95
|
+
await deps.fetchCheckpoint(frame.sessionId, frame.checkpointCommit);
|
|
96
|
+
}
|
|
97
|
+
await deps.persist(frame.sessionId, nextRecords);
|
|
98
|
+
// Commit the new view atomically now that all effects have landed.
|
|
99
|
+
state.records = nextRecords;
|
|
100
|
+
state.historyHash = frame.historyHash;
|
|
101
|
+
state.checkpointCommit = nextCheckpoint;
|
|
102
|
+
if (frame.runtimeSessionRef !== undefined)
|
|
103
|
+
state.runtimeSessionRef = frame.runtimeSessionRef;
|
|
104
|
+
state.epoch = Math.max(state.epoch, frame.epoch);
|
|
105
|
+
return { status: "applied", cursor: cursorOf(state) };
|
|
106
|
+
}
|