Skip to content

Commit 03e8fc9

Browse files
chitcommitclaude
andauthored
feat(meta,daemon): meta-orchestrator + cluster-leader foundation skeleton (#101)
Implements the foundation PR scope from ADR-001 (chittycanon://docs/architecture/chittycommand/ADR-001): - meta/intent.ts — Goal/Plan/Intent ladder with real Drizzle-backed CRUD - meta/sovereignty.ts — Trust-gated decision via live ChittyTrust reckon - meta/channels.ts — Real list of registered channels (throws on unreachable) - meta/context.ts — Forever-context wrapper with primary+fallback pattern - daemon/leader.ts — cc_node_leases atomic claim / heartbeat / release - daemon/loop.ts — Persistent leader loop skeleton with injected executor - daemon/supervisor.md — launchd + systemd supervision plan (doc only) - migrations/0002_naive_mac_gargan.sql — Drizzle-generated DDL - tests/daemon/leader.spec.ts — Real Neon integration test (skips without DATABASE_URL) No mocks, no fake data, no placeholder endpoints. Schema validated against a disposable Neon branch (br-misty-lake-aklabdcz on project cool-bar-13270800); lease claim/reject/release semantics validated end-to-end via SQL on the same branch. Existing src/, agents/, ui/ unchanged. Tier-5 surface untouched. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d19c22e commit 03e8fc9

15 files changed

Lines changed: 5145 additions & 2 deletions

daemon/leader.ts

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
/**
2+
* Cluster daemon — leader election via Neon `cc_node_leases`.
3+
*
4+
* Mirrors the lease pattern in
5+
* chittyentity/workers/shared/agent-tasks.ts (`task_leases`):
6+
* - atomic claim via UPDATE ... RETURNING with expired-lease takeover
7+
* - heartbeat extends lease_expires_at
8+
* - explicit release nullifies the holder
9+
*
10+
* Schema: see `cc_node_leases` in src/db/schema.ts.
11+
*
12+
* @canonical-uri chittycanon://docs/architecture/chittycommand/ADR-001
13+
*/
14+
15+
import { neon, type NeonQueryFunction } from '@neondatabase/serverless';
16+
17+
export interface LeaderEnv {
18+
DATABASE_URL?: string;
19+
HYPERDRIVE?: { connectionString: string };
20+
}
21+
22+
/** The canonical foundation-PR role. New roles can be added later. */
23+
export const META_LEADER_ROLE = 'meta-orchestrator-leader' as const;
24+
25+
export interface NodeLease {
26+
role: string;
27+
nodeId: string;
28+
nodeDescriptor: string | null;
29+
sessionId: string | null;
30+
claimedAt: Date;
31+
heartbeatAt: Date;
32+
leaseExpiresAt: Date;
33+
metadata: Record<string, unknown>;
34+
}
35+
36+
export interface ClaimOptions {
37+
/** ChittyID of the node attempting to claim (Location type — L). */
38+
nodeId: string;
39+
/** Free-form descriptor for ops (e.g. "chittymini-03"). */
40+
nodeDescriptor?: string;
41+
/** Process/session id for this attempt. */
42+
sessionId?: string;
43+
/** Lease length in seconds. Defaults to 30s. */
44+
leaseSeconds?: number;
45+
/** The role to claim. Defaults to META_LEADER_ROLE. */
46+
role?: string;
47+
/** Optional metadata persisted with the lease. */
48+
metadata?: Record<string, unknown>;
49+
}
50+
51+
function getSql(env: LeaderEnv): NeonQueryFunction<false, false> {
52+
const conn = env.DATABASE_URL || env.HYPERDRIVE?.connectionString;
53+
if (!conn) {
54+
throw new Error('[daemon/leader] No DATABASE_URL or HYPERDRIVE connection string');
55+
}
56+
return neon(conn);
57+
}
58+
59+
function normalizeLeaseSeconds(input: number | undefined): number {
60+
if (!input || !Number.isFinite(input) || input <= 0) return 30;
61+
// Clamp to 1s .. 1h to avoid pathological leases.
62+
return Math.max(1, Math.min(3600, Math.floor(input)));
63+
}
64+
65+
/**
66+
* Atomically claim leadership for `role`.
67+
*
68+
* Logic:
69+
* - Insert the role row if missing (idempotent via ON CONFLICT DO NOTHING).
70+
* - UPDATE the row to set node_id/sessionId IFF the current holder is the
71+
* same node (re-claim) OR the lease is unset/expired.
72+
* - Returns the new lease if the UPDATE affected a row, else null.
73+
*
74+
* No other Neon round-trip happens between the conditional SELECT and the
75+
* UPDATE — the WHERE clause inside UPDATE is itself the gate, so concurrent
76+
* claimers from different nodes will see exactly one winner.
77+
*/
78+
export async function claimLeadership(
79+
env: LeaderEnv,
80+
options: ClaimOptions,
81+
): Promise<NodeLease | null> {
82+
if (!options.nodeId) throw new Error('[daemon/leader] nodeId is required');
83+
84+
const sql = getSql(env);
85+
const role = options.role ?? META_LEADER_ROLE;
86+
const leaseSeconds = normalizeLeaseSeconds(options.leaseSeconds);
87+
const sessionId = options.sessionId ?? null;
88+
const descriptor = options.nodeDescriptor ?? null;
89+
const metadata = JSON.stringify(options.metadata ?? {});
90+
91+
// 1. Ensure a row exists for this role. Idempotent.
92+
await sql`
93+
INSERT INTO cc_node_leases (role, metadata)
94+
VALUES (${role}, ${metadata}::jsonb)
95+
ON CONFLICT (role) DO NOTHING`;
96+
97+
// 2. Atomic claim.
98+
const rows = await sql`
99+
UPDATE cc_node_leases
100+
SET node_id = ${options.nodeId},
101+
node_descriptor = ${descriptor},
102+
session_id = ${sessionId},
103+
claimed_at = COALESCE(claimed_at, NOW()),
104+
heartbeat_at = NOW(),
105+
lease_expires_at = NOW() + (${leaseSeconds} * INTERVAL '1 second'),
106+
metadata = ${metadata}::jsonb,
107+
updated_at = NOW()
108+
WHERE role = ${role}
109+
AND (
110+
node_id IS NULL
111+
OR node_id = ${options.nodeId}
112+
OR lease_expires_at IS NULL
113+
OR lease_expires_at < NOW()
114+
)
115+
RETURNING *`;
116+
117+
if (rows.length === 0) return null;
118+
return rowToLease(rows[0]);
119+
}
120+
121+
/**
122+
* Extend the lease. Returns null if this node is no longer the holder
123+
* (another node took over).
124+
*/
125+
export async function heartbeat(
126+
env: LeaderEnv,
127+
nodeId: string,
128+
options: { role?: string; leaseSeconds?: number } = {},
129+
): Promise<NodeLease | null> {
130+
if (!nodeId) throw new Error('[daemon/leader] nodeId is required for heartbeat');
131+
const sql = getSql(env);
132+
const role = options.role ?? META_LEADER_ROLE;
133+
const leaseSeconds = normalizeLeaseSeconds(options.leaseSeconds);
134+
135+
const rows = await sql`
136+
UPDATE cc_node_leases
137+
SET heartbeat_at = NOW(),
138+
lease_expires_at = NOW() + (${leaseSeconds} * INTERVAL '1 second'),
139+
updated_at = NOW()
140+
WHERE role = ${role} AND node_id = ${nodeId}
141+
RETURNING *`;
142+
return rows[0] ? rowToLease(rows[0]) : null;
143+
}
144+
145+
/**
146+
* Release leadership. Only this node can release — if a different node
147+
* holds the role, this is a no-op (returns false).
148+
*/
149+
export async function releaseLeadership(
150+
env: LeaderEnv,
151+
nodeId: string,
152+
options: { role?: string } = {},
153+
): Promise<boolean> {
154+
if (!nodeId) throw new Error('[daemon/leader] nodeId is required for release');
155+
const sql = getSql(env);
156+
const role = options.role ?? META_LEADER_ROLE;
157+
const rows = await sql`
158+
UPDATE cc_node_leases
159+
SET node_id = NULL,
160+
session_id = NULL,
161+
node_descriptor = NULL,
162+
claimed_at = NULL,
163+
heartbeat_at = NULL,
164+
lease_expires_at = NULL,
165+
updated_at = NOW()
166+
WHERE role = ${role} AND node_id = ${nodeId}
167+
RETURNING role`;
168+
return rows.length > 0;
169+
}
170+
171+
/**
172+
* Inspect the current lease row (no mutation). Useful for diagnostics.
173+
*/
174+
export async function describeLease(
175+
env: LeaderEnv,
176+
options: { role?: string } = {},
177+
): Promise<NodeLease | null> {
178+
const sql = getSql(env);
179+
const role = options.role ?? META_LEADER_ROLE;
180+
const rows = await sql`SELECT * FROM cc_node_leases WHERE role = ${role} LIMIT 1`;
181+
if (!rows[0] || !rows[0].node_id) return null;
182+
return rowToLease(rows[0]);
183+
}
184+
185+
function rowToLease(row: Record<string, unknown>): NodeLease {
186+
return {
187+
role: String(row.role),
188+
nodeId: String(row.node_id),
189+
nodeDescriptor: (row.node_descriptor as string) ?? null,
190+
sessionId: (row.session_id as string) ?? null,
191+
claimedAt: new Date(row.claimed_at as string),
192+
heartbeatAt: new Date(row.heartbeat_at as string),
193+
leaseExpiresAt: new Date(row.lease_expires_at as string),
194+
metadata: (row.metadata as Record<string, unknown>) ?? {},
195+
};
196+
}

0 commit comments

Comments
 (0)