@triflux/remote 10.43.0 → 10.45.0

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.
Files changed (48) hide show
  1. package/cto/hygiene.mjs +1 -1
  2. package/hub/lib/cto-env.mjs +1 -0
  3. package/hub/pipe.mjs +10 -9
  4. package/hub/schema.sql +62 -2
  5. package/hub/server.mjs +276 -132
  6. package/hub/store.mjs +699 -41
  7. package/hub/team/backend.mjs +4 -2
  8. package/hub/team/claude-daemon-control.mjs +2 -0
  9. package/hub/team/cli/commands/start/start-headless.mjs +2 -0
  10. package/hub/team/cli/commands/start/start-mux.mjs +24 -3
  11. package/hub/team/cli/services/runtime-mode.mjs +23 -9
  12. package/hub/team/conductor.mjs +101 -24
  13. package/hub/team/cto-auto-collect.mjs +2 -6
  14. package/hub/team/cto-pull.mjs +4 -0
  15. package/hub/team/dashboard-open.mjs +1 -0
  16. package/hub/team/execution-mode.mjs +50 -9
  17. package/hub/team/headless.mjs +539 -92
  18. package/hub/team/interactive-tui-transport.mjs +15 -1
  19. package/hub/team/intervention.mjs +1 -1
  20. package/hub/team/launcher-template.mjs +17 -1
  21. package/hub/team/notify.mjs +31 -11
  22. package/hub/team/orchestrator.mjs +36 -9
  23. package/hub/team/pane.mjs +176 -14
  24. package/hub/team/psmux.mjs +79 -70
  25. package/hub/team/retry-state-machine.mjs +41 -13
  26. package/hub/team/session.mjs +57 -0
  27. package/hub/team/swarm-hypervisor.mjs +4 -2
  28. package/hub/team/swarm-preflight.mjs +47 -0
  29. package/hub/team/synapse-http.mjs +149 -4
  30. package/hub/team/terminal-opener.mjs +68 -13
  31. package/hub/team/tui-viewer.mjs +65 -14
  32. package/hub/team/worktree-lifecycle.mjs +56 -59
  33. package/hub/tools.mjs +22 -1
  34. package/hub/workers/codex-mcp.mjs +26 -5
  35. package/hub/workers/delegator-mcp.mjs +3 -49
  36. package/package.json +1 -1
  37. package/scripts/lib/agent-route-policy.mjs +367 -0
  38. package/scripts/lib/cli-agy.mjs +24 -4
  39. package/scripts/lib/cli-codex.mjs +22 -160
  40. package/scripts/lib/codex-profile-config.mjs +48 -4
  41. package/scripts/lib/codex-recovery.sh +19 -0
  42. package/scripts/lib/gemini-profiles.mjs +113 -29
  43. package/scripts/lib/keyword-rules.mjs +24 -0
  44. package/scripts/lib/logger.mjs +60 -43
  45. package/scripts/lib/machine-profile.mjs +293 -0
  46. package/scripts/lib/mcp-health.mjs +44 -18
  47. package/cto/steward.mjs +0 -362
  48. package/hub/log-retention.mjs +0 -726
package/hub/store.mjs CHANGED
@@ -1,13 +1,19 @@
1
- // hub/store.mjs — SQLite 감사 로그/메타데이터 저장소
2
- // 실시간 배달 큐는 router/pipe가 담당하고, SQLite는 재생/감사 용도로만 유지한다.
1
+ // hub/store.mjs — SQLite 감사 로그/메타데이터/역할 레지스트리 저장소
2
+ // 실시간 배달은 router/pipe가 담당하고, 역할 권한과 복구 상태는 SQLite가 보존한다.
3
3
 
4
+ import { createHash } from "node:crypto";
4
5
  import { mkdirSync, readFileSync } from "node:fs";
5
6
  import { createRequire } from "node:module";
6
7
  import { dirname, join } from "node:path";
7
8
  import { fileURLToPath } from "node:url";
8
- import { clampDurationMs, DEFAULT_ASSIGN_TIMEOUT_MS, DEFAULT_ASSIGN_TTL_MS } from "@triflux/core/hub/lib/timeout-defaults.mjs";
9
+ import {
10
+ clampDurationMs,
11
+ DEFAULT_ASSIGN_TIMEOUT_MS,
12
+ DEFAULT_ASSIGN_TTL_MS,
13
+ } from "@triflux/core/hub/lib/timeout-defaults.mjs";
9
14
  import { uuidv7 } from "@triflux/core/hub/lib/uuidv7.mjs";
10
15
  import { recalcConfidence } from "@triflux/core/hub/reflexion.mjs";
16
+ import { decodeRoleKey, ROLE_REACHABILITY_STATES } from "@triflux/core/hub/role-contract.mjs";
11
17
 
12
18
  export { uuidv7 };
13
19
 
@@ -72,6 +78,21 @@ function parseReflexionRow(row) {
72
78
  };
73
79
  }
74
80
 
81
+ function parseRoleCandidateRow(row) {
82
+ if (!row) return null;
83
+ const { transport_locators_json, ...rest } = row;
84
+ return {
85
+ ...rest,
86
+ transport_locators: parseJson(transport_locators_json, []),
87
+ };
88
+ }
89
+
90
+ function hasTable(db, tableName) {
91
+ return !!db
92
+ .prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?")
93
+ .get(tableName);
94
+ }
95
+
75
96
  function hasColumn(db, tableName, columnName) {
76
97
  const rows = db.prepare(`PRAGMA table_info(${tableName})`).all();
77
98
  return rows.some((row) => row.name === columnName);
@@ -82,6 +103,38 @@ function ensureColumn(db, tableName, columnName, definition) {
82
103
  db.exec(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${definition}`);
83
104
  }
84
105
 
106
+ function ensureV6Columns(db) {
107
+ if (hasTable(db, "agents")) {
108
+ ensureColumn(db, "agents", "project_id", "TEXT");
109
+ ensureColumn(db, "agents", "session_id", "TEXT");
110
+ ensureColumn(db, "agents", "host_id", "TEXT");
111
+ ensureColumn(
112
+ db,
113
+ "agents",
114
+ "transport_locators_json",
115
+ "TEXT NOT NULL DEFAULT '[]'",
116
+ );
117
+ }
118
+ if (hasTable(db, "messages")) {
119
+ ensureColumn(db, "messages", "role_key_wire", "TEXT");
120
+ }
121
+ }
122
+
123
+ function ensureV7Columns(db) {
124
+ if (hasTable(db, "agents")) {
125
+ ensureColumn(
126
+ db,
127
+ "agents",
128
+ "presence_generation",
129
+ "INTEGER NOT NULL DEFAULT 0",
130
+ );
131
+ }
132
+ }
133
+
134
+ function storeFingerprint(dbPath) {
135
+ return `sqlite:${createHash("sha256").update(String(dbPath)).digest("hex")}`;
136
+ }
137
+
85
138
  /**
86
139
  * 저장소 생성
87
140
  * @param {string} dbPath
@@ -112,7 +165,7 @@ export function createStore(dbPath, options = {}) {
112
165
  db.exec(
113
166
  "CREATE TABLE IF NOT EXISTS _meta (key TEXT PRIMARY KEY, value TEXT)",
114
167
  );
115
- const SCHEMA_VERSION = "5";
168
+ const SCHEMA_VERSION = "7";
116
169
  const curVer = (() => {
117
170
  try {
118
171
  return db
@@ -126,6 +179,10 @@ export function createStore(dbPath, options = {}) {
126
179
  // 마이그레이션 전략: 스키마 버전이 다르면 schema.sql을 재실행한다.
127
180
  // schema.sql은 CREATE TABLE IF NOT EXISTS 패턴을 사용하므로 멱등하게 적용된다.
128
181
  // 비파괴적 컬럼 추가는 자동으로 처리되지만, 컬럼 제거/이름 변경은 수동 마이그레이션이 필요하다.
182
+ // v5 테이블에는 schema.sql의 v6 인덱스가 참조하는 컬럼이 없으므로
183
+ // CREATE IF NOT EXISTS 재실행 전에 멱등 컬럼 seam을 먼저 적용한다.
184
+ ensureV6Columns(db);
185
+ ensureV7Columns(db);
129
186
  if (curVer !== SCHEMA_VERSION) {
130
187
  if (curVer != null) {
131
188
  // 이미 버전이 기록된 DB에서 버전 불일치가 발생한 경우 경고한다.
@@ -138,6 +195,9 @@ export function createStore(dbPath, options = {}) {
138
195
  "INSERT OR REPLACE INTO _meta (key, value) VALUES ('schema_version', ?)",
139
196
  ).run(SCHEMA_VERSION);
140
197
  }
198
+ // 매 부팅 실행해 부분 적용된 마이그레이션도 안전하게 복구한다.
199
+ ensureV6Columns(db);
200
+ ensureV7Columns(db);
141
201
  ensureColumn(
142
202
  db,
143
203
  "reflexion_entries",
@@ -154,8 +214,8 @@ export function createStore(dbPath, options = {}) {
154
214
 
155
215
  const S = {
156
216
  upsertAgent: db.prepare(`
157
- INSERT INTO agents (agent_id, cli, pid, capabilities_json, topics_json, last_seen_ms, lease_expires_ms, status, metadata_json)
158
- VALUES (@agent_id, @cli, @pid, @capabilities_json, @topics_json, @last_seen_ms, @lease_expires_ms, @status, @metadata_json)
217
+ INSERT INTO agents (agent_id, cli, pid, capabilities_json, topics_json, last_seen_ms, lease_expires_ms, presence_generation, status, metadata_json)
218
+ VALUES (@agent_id, @cli, @pid, @capabilities_json, @topics_json, @last_seen_ms, @lease_expires_ms, 1, @status, @metadata_json)
159
219
  ON CONFLICT(agent_id) DO UPDATE SET
160
220
  cli=excluded.cli,
161
221
  pid=excluded.pid,
@@ -163,6 +223,7 @@ export function createStore(dbPath, options = {}) {
163
223
  topics_json=excluded.topics_json,
164
224
  last_seen_ms=excluded.last_seen_ms,
165
225
  lease_expires_ms=excluded.lease_expires_ms,
226
+ presence_generation=agents.presence_generation + 1,
166
227
  status=excluded.status,
167
228
  metadata_json=excluded.metadata_json`),
168
229
  getAgent: db.prepare("SELECT * FROM agents WHERE agent_id = ?"),
@@ -170,24 +231,183 @@ export function createStore(dbPath, options = {}) {
170
231
  "UPDATE agents SET topics_json=?, last_seen_ms=? WHERE agent_id=?",
171
232
  ),
172
233
  heartbeat: db.prepare(
234
+ "UPDATE agents SET last_seen_ms=?, lease_expires_ms=?, status='online' WHERE agent_id=? AND presence_generation=?",
235
+ ),
236
+ heartbeatWithoutGeneration: db.prepare(
173
237
  "UPDATE agents SET last_seen_ms=?, lease_expires_ms=?, status='online' WHERE agent_id=?",
174
238
  ),
175
- setAgentStatus: db.prepare("UPDATE agents SET status=? WHERE agent_id=?"),
239
+ setAgentStatus: db.prepare(
240
+ "UPDATE agents SET status=? WHERE agent_id=? AND presence_generation=?",
241
+ ),
242
+ setAgentStatusWithoutGeneration: db.prepare(
243
+ "UPDATE agents SET status=? WHERE agent_id=?",
244
+ ),
176
245
  onlineAgents: db.prepare("SELECT * FROM agents WHERE status != 'offline'"),
177
246
  allAgents: db.prepare("SELECT * FROM agents"),
178
247
  agentsByTopic: db.prepare(
179
248
  "SELECT a.* FROM agents a, json_each(a.topics_json) t WHERE t.value=? AND a.status != 'offline'",
180
249
  ),
181
250
  markStale: db.prepare(
182
- "UPDATE agents SET status='stale' WHERE status='online' AND lease_expires_ms < ?",
251
+ "UPDATE agents SET status='stale' WHERE agent_id=? AND presence_generation=? AND status='online' AND lease_expires_ms < ?",
183
252
  ),
184
253
  markOffline: db.prepare(
185
- "UPDATE agents SET status='offline' WHERE status='stale' AND lease_expires_ms < ? - 300000",
254
+ "UPDATE agents SET status='offline' WHERE agent_id=? AND presence_generation=? AND status='stale' AND lease_expires_ms < ? - 300000",
186
255
  ),
187
256
 
257
+ getRole: db.prepare("SELECT * FROM role_registry WHERE role_key_wire = ?"),
258
+ insertRoleReservation: db.prepare(`
259
+ INSERT INTO role_registry (
260
+ role_key_wire, project_id, role_kind, scope_id, state,
261
+ holder_agent_id, previous_holder_agent_id, epoch, activation_seq,
262
+ activation_id, activation_deadline_ms, holder_lease_expires_ms,
263
+ charter_version, charter_acked_epoch, retry_count, next_probe_ms,
264
+ blocked_reason, last_transition_ms, last_reason, version
265
+ ) VALUES (
266
+ @role_key_wire, @project_id, @role_kind, @scope_id, @state,
267
+ @holder_agent_id, @previous_holder_agent_id, @epoch, @activation_seq,
268
+ @activation_id, @activation_deadline_ms, @holder_lease_expires_ms,
269
+ @charter_version, @charter_acked_epoch, @retry_count, @next_probe_ms,
270
+ @blocked_reason, @last_transition_ms, @last_reason, @version
271
+ )`),
272
+ updateRoleReservation: db.prepare(`
273
+ UPDATE role_registry SET
274
+ state=@state,
275
+ holder_agent_id=@holder_agent_id,
276
+ previous_holder_agent_id=@previous_holder_agent_id,
277
+ epoch=@epoch,
278
+ activation_seq=@activation_seq,
279
+ activation_id=@activation_id,
280
+ activation_deadline_ms=@activation_deadline_ms,
281
+ holder_lease_expires_ms=@holder_lease_expires_ms,
282
+ charter_version=@charter_version,
283
+ charter_acked_epoch=@charter_acked_epoch,
284
+ retry_count=@retry_count,
285
+ next_probe_ms=@next_probe_ms,
286
+ blocked_reason=@blocked_reason,
287
+ last_transition_ms=@last_transition_ms,
288
+ last_reason=@last_reason,
289
+ version=version + 1
290
+ WHERE role_key_wire=@role_key_wire
291
+ AND version=@expected_version
292
+ AND epoch=@expected_epoch`),
293
+ compareAndSetRole: db.prepare(`
294
+ UPDATE role_registry SET
295
+ state=@state,
296
+ holder_agent_id=@holder_agent_id,
297
+ previous_holder_agent_id=@previous_holder_agent_id,
298
+ activation_id=@activation_id,
299
+ activation_deadline_ms=@activation_deadline_ms,
300
+ holder_lease_expires_ms=@holder_lease_expires_ms,
301
+ charter_version=@charter_version,
302
+ charter_acked_epoch=@charter_acked_epoch,
303
+ retry_count=@retry_count,
304
+ next_probe_ms=@next_probe_ms,
305
+ blocked_reason=@blocked_reason,
306
+ last_transition_ms=@last_transition_ms,
307
+ last_reason=@last_reason,
308
+ version=version + 1
309
+ WHERE role_key_wire=@role_key_wire
310
+ AND epoch=@expected_epoch
311
+ AND activation_seq=@expected_activation_seq
312
+ AND activation_id IS @expected_activation_id
313
+ AND version=@expected_version`),
314
+ listRoles: db.prepare("SELECT * FROM role_registry ORDER BY role_key_wire"),
315
+ expiredHolderRoles: db.prepare(`
316
+ SELECT * FROM role_registry
317
+ WHERE holder_agent_id IS NOT NULL
318
+ AND (holder_lease_expires_ms IS NULL OR holder_lease_expires_ms <= ?)
319
+ ORDER BY role_key_wire`),
320
+ listRoleCandidates: db.prepare(`
321
+ SELECT * FROM role_candidates
322
+ WHERE role_key_wire = ?
323
+ ORDER BY priority DESC, updated_at_ms DESC, agent_id`),
324
+ getRoleCandidate: db.prepare(`
325
+ SELECT * FROM role_candidates
326
+ WHERE role_key_wire = ? AND agent_id = ?`),
327
+ upsertRoleCandidate: db.prepare(`
328
+ INSERT INTO role_candidates (
329
+ role_key_wire, agent_id, source, priority, transport_locators_json,
330
+ consecutive_probe_failures, excluded_until_ms, last_probe_ms,
331
+ last_probe_code, updated_at_ms
332
+ ) VALUES (
333
+ @role_key_wire, @agent_id, @source, @priority,
334
+ @transport_locators_json, @consecutive_probe_failures,
335
+ @excluded_until_ms, @last_probe_ms, @last_probe_code, @updated_at_ms
336
+ )
337
+ ON CONFLICT(role_key_wire, agent_id) DO UPDATE SET
338
+ source=excluded.source,
339
+ priority=excluded.priority,
340
+ transport_locators_json=excluded.transport_locators_json,
341
+ consecutive_probe_failures=excluded.consecutive_probe_failures,
342
+ excluded_until_ms=excluded.excluded_until_ms,
343
+ last_probe_ms=excluded.last_probe_ms,
344
+ last_probe_code=excluded.last_probe_code,
345
+ updated_at_ms=excluded.updated_at_ms`),
346
+ updateCandidateReachability: db.prepare(`
347
+ UPDATE role_candidates SET
348
+ consecutive_probe_failures=@consecutive_probe_failures,
349
+ excluded_until_ms=@excluded_until_ms,
350
+ last_probe_ms=@last_probe_ms,
351
+ last_probe_code=@last_probe_code,
352
+ updated_at_ms=@updated_at_ms
353
+ WHERE role_key_wire=@role_key_wire AND agent_id=@agent_id`),
354
+ pendingRoleMessages: db.prepare(`
355
+ SELECT * FROM messages
356
+ WHERE role_key_wire=?
357
+ AND status IN ('queued','delivered')
358
+ AND expires_at_ms > ?
359
+ ORDER BY priority DESC, created_at_ms ASC, id`),
360
+ recoverExpiredRole: db.prepare(`
361
+ UPDATE role_registry SET
362
+ state='electable',
363
+ previous_holder_agent_id=holder_agent_id,
364
+ holder_agent_id=NULL,
365
+ epoch=epoch + 1,
366
+ activation_seq=activation_seq + 1,
367
+ activation_id=NULL,
368
+ activation_deadline_ms=NULL,
369
+ holder_lease_expires_ms=NULL,
370
+ charter_acked_epoch=NULL,
371
+ blocked_reason=NULL,
372
+ last_transition_ms=@now,
373
+ last_reason='restart_holder_lease_expired',
374
+ version=version + 1
375
+ WHERE role_key_wire=@role_key_wire
376
+ AND version=@expected_version
377
+ AND epoch=@expected_epoch`),
378
+ recoverLiveRole: db.prepare(`
379
+ UPDATE role_registry SET
380
+ state='elected-probing',
381
+ epoch=epoch + 1,
382
+ activation_seq=activation_seq + 1,
383
+ activation_id=@activation_id,
384
+ activation_deadline_ms=NULL,
385
+ charter_acked_epoch=NULL,
386
+ blocked_reason=NULL,
387
+ last_transition_ms=@now,
388
+ last_reason='restart_reprobe_required',
389
+ version=version + 1
390
+ WHERE role_key_wire=@role_key_wire
391
+ AND version=@expected_version
392
+ AND epoch=@expected_epoch`),
393
+ recoverOrphanedActivation: db.prepare(`
394
+ UPDATE role_registry SET
395
+ state='electable',
396
+ epoch=epoch + 1,
397
+ activation_seq=activation_seq + 1,
398
+ activation_id=NULL,
399
+ activation_deadline_ms=NULL,
400
+ charter_acked_epoch=NULL,
401
+ last_transition_ms=@now,
402
+ last_reason='restart_activation_fenced',
403
+ version=version + 1
404
+ WHERE role_key_wire=@role_key_wire
405
+ AND version=@expected_version
406
+ AND epoch=@expected_epoch`),
407
+
188
408
  insertAuditMessage: db.prepare(`
189
- INSERT INTO messages (id, type, from_agent, to_agent, topic, priority, ttl_ms, created_at_ms, expires_at_ms, correlation_id, trace_id, payload_json, status)
190
- VALUES (@id, @type, @from_agent, @to_agent, @topic, @priority, @ttl_ms, @created_at_ms, @expires_at_ms, @correlation_id, @trace_id, @payload_json, @status)`),
409
+ INSERT INTO messages (id, type, from_agent, to_agent, topic, priority, ttl_ms, created_at_ms, expires_at_ms, correlation_id, trace_id, payload_json, status, role_key_wire)
410
+ VALUES (@id, @type, @from_agent, @to_agent, @topic, @priority, @ttl_ms, @created_at_ms, @expires_at_ms, @correlation_id, @trace_id, @payload_json, @status, @role_key_wire)`),
191
411
  getMsg: db.prepare("SELECT * FROM messages WHERE id=?"),
192
412
  getResponse: db.prepare(
193
413
  "SELECT * FROM messages WHERE correlation_id=? AND type='response' ORDER BY created_at_ms DESC LIMIT 1",
@@ -219,12 +439,9 @@ export function createStore(dbPath, options = {}) {
219
439
  "UPDATE human_requests SET state=?, response_json=? WHERE request_id=?",
220
440
  ),
221
441
  pendingHR: db.prepare("SELECT * FROM human_requests WHERE state='pending'"),
222
- expireHR: db.prepare(
223
- "UPDATE human_requests SET state='timed_out' WHERE state='pending' AND deadline_ms < ?",
224
- ),
225
442
 
226
443
  insertDL: db.prepare(
227
- "INSERT OR REPLACE INTO dead_letters (message_id, reason, failed_at_ms, last_error) VALUES (?,?,?,?)",
444
+ "INSERT INTO dead_letters (message_id, reason, failed_at_ms, last_error) VALUES (?,?,?,?) ON CONFLICT(message_id) DO NOTHING",
228
445
  ),
229
446
  getDL: db.prepare(
230
447
  "SELECT * FROM dead_letters ORDER BY failed_at_ms DESC LIMIT ?",
@@ -271,7 +488,13 @@ export function createStore(dbPath, options = {}) {
271
488
  WHERE job_id=@job_id`),
272
489
 
273
490
  findExpired: db.prepare(
274
- "SELECT id FROM messages WHERE status='queued' AND expires_at_ms < ?",
491
+ "SELECT * FROM messages WHERE status IN ('queued', 'delivered') AND expires_at_ms <= ?",
492
+ ),
493
+ claimExpired: db.prepare(
494
+ "UPDATE messages SET status='dead_letter' WHERE id=? AND status IN ('queued', 'delivered') AND expires_at_ms <= ?",
495
+ ),
496
+ claimDeadLetter: db.prepare(
497
+ "UPDATE messages SET status='dead_letter' WHERE id=? AND status != 'dead_letter'",
275
498
  ),
276
499
  urgentDepth: db.prepare(
277
500
  "SELECT COUNT(*) as cnt FROM messages WHERE status='queued' AND priority >= 7",
@@ -359,8 +582,161 @@ export function createStore(dbPath, options = {}) {
359
582
  return Math.max(1, Math.min(Math.trunc(num), 9));
360
583
  }
361
584
 
585
+ function roleIdentity(input) {
586
+ const roleKeyWire = input?.role_key_wire ?? input?.roleKeyWire;
587
+ const decoded = decodeRoleKey(roleKeyWire);
588
+ const identity = {
589
+ role_key_wire: roleKeyWire,
590
+ project_id: decoded.project_id,
591
+ role_kind: decoded.role_kind,
592
+ scope_id: decoded.scope_id ?? "",
593
+ };
594
+ for (const field of ["project_id", "role_kind", "scope_id"]) {
595
+ if (Object.hasOwn(input, field) && input[field] !== identity[field]) {
596
+ throw new Error(`role key identity mismatch: ${field}`);
597
+ }
598
+ }
599
+ return identity;
600
+ }
601
+
602
+ function roleFailureReason(current, expected) {
603
+ if (!current) return "not_found";
604
+ if (current.version !== expected.version) return "stale_version";
605
+ if (current.epoch !== expected.epoch) return "stale_epoch";
606
+ if (current.activation_seq !== expected.activation_seq) {
607
+ return "stale_activation_seq";
608
+ }
609
+ if (current.activation_id !== expected.activation_id) {
610
+ return "stale_activation_id";
611
+ }
612
+ return "cas_conflict";
613
+ }
614
+
615
+ const reserveRole = db.transaction((input) => {
616
+ const identity = roleIdentity(input);
617
+ const current = S.getRole.get(identity.role_key_wire) ?? null;
618
+ const expectedVersion =
619
+ input.expected_version ?? input.expectedVersion ?? current?.version;
620
+ const expectedEpoch =
621
+ input.expected_epoch ?? input.expectedEpoch ?? current?.epoch;
622
+
623
+ if (current && expectedVersion !== current.version) {
624
+ return { ok: false, reason: "stale_version", role: current };
625
+ }
626
+ if (current && expectedEpoch !== current.epoch) {
627
+ return { ok: false, reason: "stale_epoch", role: current };
628
+ }
629
+
630
+ const nextEpoch =
631
+ input.epoch ?? input.next_epoch ?? (current ? current.epoch + 1 : 0);
632
+ if (current && nextEpoch !== current.epoch + 1) {
633
+ return { ok: false, reason: "stale_epoch", role: current };
634
+ }
635
+ const nextActivationSeq =
636
+ input.activation_seq ??
637
+ input.next_activation_seq ??
638
+ (current ? current.activation_seq + 1 : 0);
639
+ if (current && nextActivationSeq !== current.activation_seq + 1) {
640
+ return { ok: false, reason: "stale_activation_seq", role: current };
641
+ }
642
+
643
+ const state =
644
+ input.state ?? (input.holder_agent_id ? "elected-probing" : "electable");
645
+ if (!ROLE_REACHABILITY_STATES.includes(state)) {
646
+ throw new Error(`invalid role reachability state: ${state}`);
647
+ }
648
+ const row = {
649
+ ...identity,
650
+ state,
651
+ holder_agent_id: input.holder_agent_id ?? null,
652
+ previous_holder_agent_id:
653
+ input.previous_holder_agent_id ??
654
+ (current?.holder_agent_id !== input.holder_agent_id
655
+ ? (current?.holder_agent_id ?? null)
656
+ : (current?.previous_holder_agent_id ?? null)),
657
+ epoch: nextEpoch,
658
+ activation_seq: nextActivationSeq,
659
+ activation_id:
660
+ input.activation_id ??
661
+ (input.holder_agent_id ? uuidv7() : (current?.activation_id ?? null)),
662
+ activation_deadline_ms:
663
+ input.activation_deadline_ms ?? current?.activation_deadline_ms ?? null,
664
+ holder_lease_expires_ms:
665
+ input.holder_lease_expires_ms ??
666
+ current?.holder_lease_expires_ms ??
667
+ null,
668
+ charter_version:
669
+ input.charter_version ?? current?.charter_version ?? null,
670
+ charter_acked_epoch:
671
+ input.charter_acked_epoch ?? current?.charter_acked_epoch ?? null,
672
+ retry_count: input.retry_count ?? current?.retry_count ?? 0,
673
+ next_probe_ms: input.next_probe_ms ?? current?.next_probe_ms ?? null,
674
+ blocked_reason:
675
+ input.blocked_reason === undefined
676
+ ? (current?.blocked_reason ?? null)
677
+ : input.blocked_reason,
678
+ last_transition_ms: input.last_transition_ms ?? Date.now(),
679
+ last_reason: input.last_reason ?? "role_reserved",
680
+ version: 0,
681
+ expected_version: current?.version,
682
+ expected_epoch: current?.epoch,
683
+ };
684
+
685
+ if (!current) {
686
+ S.insertRoleReservation.run(row);
687
+ } else if (S.updateRoleReservation.run(row).changes !== 1) {
688
+ const latest = S.getRole.get(identity.role_key_wire) ?? null;
689
+ return {
690
+ ok: false,
691
+ reason: roleFailureReason(latest, {
692
+ version: current.version,
693
+ epoch: current.epoch,
694
+ activation_seq: current.activation_seq,
695
+ activation_id: current.activation_id,
696
+ }),
697
+ role: latest,
698
+ };
699
+ }
700
+ return { ok: true, role: S.getRole.get(identity.role_key_wire) };
701
+ });
702
+
703
+ const recoverRegistry = db.transaction((now) => {
704
+ for (const role of S.listRoles.all()) {
705
+ const guard = {
706
+ role_key_wire: role.role_key_wire,
707
+ expected_version: role.version,
708
+ expected_epoch: role.epoch,
709
+ now,
710
+ };
711
+ if (
712
+ role.holder_agent_id &&
713
+ (role.holder_lease_expires_ms == null ||
714
+ role.holder_lease_expires_ms <= now)
715
+ ) {
716
+ S.recoverExpiredRole.run(guard);
717
+ } else if (role.holder_agent_id) {
718
+ S.recoverLiveRole.run({ ...guard, activation_id: uuidv7() });
719
+ } else if (role.activation_id || role.state === "elected-probing") {
720
+ S.recoverOrphanedActivation.run(guard);
721
+ }
722
+ }
723
+
724
+ const roles = S.listRoles.all();
725
+ return {
726
+ roles,
727
+ candidates: roles.flatMap((role) =>
728
+ S.listRoleCandidates.all(role.role_key_wire).map(parseRoleCandidateRow),
729
+ ),
730
+ pending_messages: roles.flatMap((role) =>
731
+ S.pendingRoleMessages.all(role.role_key_wire, now).map(parseMessageRow),
732
+ ),
733
+ };
734
+ });
735
+
362
736
  const store = {
363
737
  db,
738
+ type: "sqlite",
739
+ fingerprint: storeFingerprint(dbPath),
364
740
  uuidv7,
365
741
 
366
742
  close() {
@@ -394,6 +770,7 @@ export function createStore(dbPath, options = {}) {
394
770
  return {
395
771
  agent_id,
396
772
  lease_id: leaseId,
773
+ presence_generation: persisted?.presence_generation ?? null,
397
774
  lease_expires_ms: leaseExpires,
398
775
  server_time_ms: now,
399
776
  effective: {
@@ -406,6 +783,7 @@ export function createStore(dbPath, options = {}) {
406
783
  lease_expires_ms: persisted?.lease_expires_ms ?? null,
407
784
  store_type: "sqlite",
408
785
  db_path: dbPath ?? null,
786
+ store_fingerprint: storeFingerprint(dbPath),
409
787
  },
410
788
  };
411
789
  },
@@ -414,14 +792,22 @@ export function createStore(dbPath, options = {}) {
414
792
  return parseAgentRow(S.getAgent.get(id));
415
793
  },
416
794
 
417
- refreshLease(agentId, ttlMs = 30000) {
795
+ refreshLease(agentId, ttlMs = 30000, presenceGeneration) {
418
796
  const now = Date.now();
419
- const write = S.heartbeat.run(now, now + ttlMs, agentId);
797
+ const write =
798
+ presenceGeneration == null
799
+ ? S.heartbeatWithoutGeneration.run(now, now + ttlMs, agentId)
800
+ : S.heartbeat.run(now, now + ttlMs, agentId, presenceGeneration);
420
801
  return {
421
802
  agent_id: agentId,
422
803
  lease_expires_ms: now + ttlMs,
423
804
  server_time_ms: now,
424
- effective: { updated: write.changes > 0, store_type: "sqlite", db_path: dbPath ?? null },
805
+ effective: {
806
+ updated: write.changes > 0,
807
+ store_type: "sqlite",
808
+ db_path: dbPath ?? null,
809
+ store_fingerprint: storeFingerprint(dbPath),
810
+ },
425
811
  };
426
812
  },
427
813
 
@@ -446,14 +832,270 @@ export function createStore(dbPath, options = {}) {
446
832
 
447
833
  sweepStaleAgents() {
448
834
  const now = Date.now();
835
+ const candidates = S.allAgents.all();
836
+ let stale = 0;
837
+ let offline = 0;
838
+ for (const candidate of candidates) {
839
+ if (candidate.status === "online" && candidate.lease_expires_ms < now) {
840
+ stale += S.markStale.run(
841
+ candidate.agent_id,
842
+ candidate.presence_generation,
843
+ now,
844
+ ).changes;
845
+ } else if (
846
+ candidate.status === "stale" &&
847
+ candidate.lease_expires_ms < now - 300000
848
+ ) {
849
+ offline += S.markOffline.run(
850
+ candidate.agent_id,
851
+ candidate.presence_generation,
852
+ now,
853
+ ).changes;
854
+ }
855
+ }
449
856
  return {
450
- stale: S.markStale.run(now).changes,
451
- offline: S.markOffline.run(now).changes,
857
+ stale,
858
+ offline,
452
859
  };
453
860
  },
454
861
 
455
- updateAgentStatus(agentId, status) {
456
- return S.setAgentStatus.run(status, agentId).changes > 0;
862
+ updateAgentStatus(agentId, status, presenceGeneration) {
863
+ return (
864
+ (presenceGeneration == null
865
+ ? S.setAgentStatusWithoutGeneration.run(status, agentId)
866
+ : S.setAgentStatus.run(status, agentId, presenceGeneration)
867
+ ).changes > 0
868
+ );
869
+ },
870
+
871
+ getRole(roleKeyWire) {
872
+ decodeRoleKey(roleKeyWire);
873
+ return S.getRole.get(roleKeyWire) ?? null;
874
+ },
875
+
876
+ upsertRoleReservation(input) {
877
+ return reserveRole.immediate(input);
878
+ },
879
+
880
+ compareAndSetRole(input) {
881
+ const identity = roleIdentity(input);
882
+ const current = S.getRole.get(identity.role_key_wire) ?? null;
883
+ if (!current) return { ok: false, reason: "not_found", role: null };
884
+
885
+ const hasExpectedActivationSeq =
886
+ Object.hasOwn(input, "expected_activation_seq") ||
887
+ Object.hasOwn(input, "expectedActivationSeq") ||
888
+ Object.hasOwn(input, "activation_seq");
889
+ const hasExpectedActivationId =
890
+ Object.hasOwn(input, "expected_activation_id") ||
891
+ Object.hasOwn(input, "expectedActivationId") ||
892
+ Object.hasOwn(input, "activation_id");
893
+ const expected = {
894
+ version:
895
+ input.expected_version ?? input.expectedVersion ?? input.version,
896
+ epoch: input.expected_epoch ?? input.expectedEpoch ?? input.epoch,
897
+ activation_seq:
898
+ input.expected_activation_seq ??
899
+ input.expectedActivationSeq ??
900
+ input.activation_seq,
901
+ activation_id: Object.hasOwn(input, "expected_activation_id")
902
+ ? input.expected_activation_id
903
+ : Object.hasOwn(input, "expectedActivationId")
904
+ ? input.expectedActivationId
905
+ : input.activation_id,
906
+ };
907
+ if (
908
+ expected.version == null ||
909
+ expected.epoch == null ||
910
+ !hasExpectedActivationSeq ||
911
+ !hasExpectedActivationId
912
+ ) {
913
+ return { ok: false, reason: "invalid_cas", role: current };
914
+ }
915
+
916
+ const reason = roleFailureReason(current, expected);
917
+ if (reason !== "cas_conflict") {
918
+ return { ok: false, reason, role: current };
919
+ }
920
+
921
+ const patch = input.patch ?? input.changes ?? {};
922
+ const state = patch.state ?? current.state;
923
+ if (!ROLE_REACHABILITY_STATES.includes(state)) {
924
+ throw new Error(`invalid role reachability state: ${state}`);
925
+ }
926
+ const next = {
927
+ role_key_wire: identity.role_key_wire,
928
+ expected_version: expected.version,
929
+ expected_epoch: expected.epoch,
930
+ expected_activation_seq: expected.activation_seq,
931
+ expected_activation_id: expected.activation_id,
932
+ state,
933
+ holder_agent_id:
934
+ patch.holder_agent_id === undefined
935
+ ? current.holder_agent_id
936
+ : patch.holder_agent_id,
937
+ previous_holder_agent_id:
938
+ patch.previous_holder_agent_id === undefined
939
+ ? current.previous_holder_agent_id
940
+ : patch.previous_holder_agent_id,
941
+ activation_id:
942
+ patch.activation_id === undefined
943
+ ? current.activation_id
944
+ : patch.activation_id,
945
+ activation_deadline_ms:
946
+ patch.activation_deadline_ms === undefined
947
+ ? current.activation_deadline_ms
948
+ : patch.activation_deadline_ms,
949
+ holder_lease_expires_ms:
950
+ patch.holder_lease_expires_ms === undefined
951
+ ? current.holder_lease_expires_ms
952
+ : patch.holder_lease_expires_ms,
953
+ charter_version:
954
+ patch.charter_version === undefined
955
+ ? current.charter_version
956
+ : patch.charter_version,
957
+ charter_acked_epoch:
958
+ patch.charter_acked_epoch === undefined
959
+ ? current.charter_acked_epoch
960
+ : patch.charter_acked_epoch,
961
+ retry_count: patch.retry_count ?? current.retry_count,
962
+ next_probe_ms:
963
+ patch.next_probe_ms === undefined
964
+ ? current.next_probe_ms
965
+ : patch.next_probe_ms,
966
+ blocked_reason:
967
+ patch.blocked_reason === undefined
968
+ ? current.blocked_reason
969
+ : patch.blocked_reason,
970
+ last_transition_ms: patch.last_transition_ms ?? Date.now(),
971
+ last_reason: patch.last_reason ?? current.last_reason,
972
+ };
973
+ if (S.compareAndSetRole.run(next).changes !== 1) {
974
+ const latest = S.getRole.get(identity.role_key_wire) ?? null;
975
+ return {
976
+ ok: false,
977
+ reason: roleFailureReason(latest, expected),
978
+ role: latest,
979
+ };
980
+ }
981
+ return { ok: true, role: S.getRole.get(identity.role_key_wire) };
982
+ },
983
+
984
+ listRoleKeys(filter = {}) {
985
+ const clauses = [];
986
+ const params = {};
987
+ for (const field of ["project_id", "role_kind", "scope_id", "state"]) {
988
+ if (filter[field] !== undefined) {
989
+ clauses.push(`${field}=@${field}`);
990
+ params[field] = filter[field];
991
+ }
992
+ }
993
+ if (Array.isArray(filter.states) && filter.states.length > 0) {
994
+ clauses.push(
995
+ `state IN (${filter.states.map((_, index) => `@state_${index}`).join(",")})`,
996
+ );
997
+ filter.states.forEach((state, index) => {
998
+ params[`state_${index}`] = state;
999
+ });
1000
+ }
1001
+ const where = clauses.length ? ` WHERE ${clauses.join(" AND ")}` : "";
1002
+ return db
1003
+ .prepare(
1004
+ `SELECT role_key_wire FROM role_registry${where} ORDER BY role_key_wire`,
1005
+ )
1006
+ .pluck()
1007
+ .all(params);
1008
+ },
1009
+
1010
+ listRolesWithExpiredHolder(now) {
1011
+ return S.expiredHolderRoles.all(now);
1012
+ },
1013
+
1014
+ listRoleCandidates(roleKeyWire) {
1015
+ decodeRoleKey(roleKeyWire);
1016
+ return S.listRoleCandidates.all(roleKeyWire).map(parseRoleCandidateRow);
1017
+ },
1018
+
1019
+ upsertRoleCandidate(input) {
1020
+ const { role_key_wire } = roleIdentity(input);
1021
+ const current = S.getRoleCandidate.get(role_key_wire, input.agent_id);
1022
+ S.upsertRoleCandidate.run({
1023
+ role_key_wire,
1024
+ agent_id: input.agent_id,
1025
+ source: input.source ?? current?.source ?? "operator",
1026
+ priority: input.priority ?? current?.priority ?? 0,
1027
+ transport_locators_json: JSON.stringify(
1028
+ input.transport_locators ??
1029
+ parseJson(current?.transport_locators_json, []),
1030
+ ),
1031
+ consecutive_probe_failures:
1032
+ input.consecutive_probe_failures ??
1033
+ current?.consecutive_probe_failures ??
1034
+ 0,
1035
+ excluded_until_ms:
1036
+ input.excluded_until_ms === undefined
1037
+ ? (current?.excluded_until_ms ?? null)
1038
+ : input.excluded_until_ms,
1039
+ last_probe_ms:
1040
+ input.last_probe_ms === undefined
1041
+ ? (current?.last_probe_ms ?? null)
1042
+ : input.last_probe_ms,
1043
+ last_probe_code:
1044
+ input.last_probe_code === undefined
1045
+ ? (current?.last_probe_code ?? null)
1046
+ : input.last_probe_code,
1047
+ updated_at_ms: input.updated_at_ms ?? Date.now(),
1048
+ });
1049
+ return parseRoleCandidateRow(
1050
+ S.getRoleCandidate.get(role_key_wire, input.agent_id),
1051
+ );
1052
+ },
1053
+
1054
+ updateCandidateReachability(input) {
1055
+ const { role_key_wire } = roleIdentity(input);
1056
+ const current = S.getRoleCandidate.get(role_key_wire, input.agent_id);
1057
+ if (!current) return { ok: false, reason: "not_found" };
1058
+
1059
+ let failures =
1060
+ input.consecutive_probe_failures ?? current.consecutive_probe_failures;
1061
+ if (
1062
+ input.probe_failed === true ||
1063
+ input.increment_probe_failures === true
1064
+ ) {
1065
+ failures = current.consecutive_probe_failures + 1;
1066
+ } else if (input.probe_succeeded === true) {
1067
+ failures = 0;
1068
+ }
1069
+ S.updateCandidateReachability.run({
1070
+ role_key_wire,
1071
+ agent_id: input.agent_id,
1072
+ consecutive_probe_failures: failures,
1073
+ excluded_until_ms:
1074
+ input.excluded_until_ms === undefined
1075
+ ? current.excluded_until_ms
1076
+ : input.excluded_until_ms,
1077
+ last_probe_ms: input.last_probe_ms ?? Date.now(),
1078
+ last_probe_code:
1079
+ input.last_probe_code === undefined
1080
+ ? current.last_probe_code
1081
+ : input.last_probe_code,
1082
+ updated_at_ms: input.updated_at_ms ?? Date.now(),
1083
+ });
1084
+ return {
1085
+ ok: true,
1086
+ candidate: parseRoleCandidateRow(
1087
+ S.getRoleCandidate.get(role_key_wire, input.agent_id),
1088
+ ),
1089
+ };
1090
+ },
1091
+
1092
+ listPendingRoleMessages(roleKeyWire, now) {
1093
+ decodeRoleKey(roleKeyWire);
1094
+ return S.pendingRoleMessages.all(roleKeyWire, now).map(parseMessageRow);
1095
+ },
1096
+
1097
+ recoverRoleRegistry(now) {
1098
+ return recoverRegistry.immediate(now);
457
1099
  },
458
1100
 
459
1101
  auditLog({
@@ -467,6 +1109,7 @@ export function createStore(dbPath, options = {}) {
467
1109
  trace_id,
468
1110
  correlation_id,
469
1111
  status = "queued",
1112
+ role_key_wire = null,
470
1113
  }) {
471
1114
  const now = Date.now();
472
1115
  const row = {
@@ -483,6 +1126,7 @@ export function createStore(dbPath, options = {}) {
483
1126
  trace_id: trace_id || uuidv7(),
484
1127
  payload_json: JSON.stringify(payload),
485
1128
  status,
1129
+ role_key_wire,
486
1130
  };
487
1131
  S.insertAuditMessage.run(row);
488
1132
  return { ...row, payload };
@@ -599,16 +1243,14 @@ export function createStore(dbPath, options = {}) {
599
1243
  return S.pendingHR.all().map(parseHumanRequestRow);
600
1244
  },
601
1245
 
602
- expireHumanRequests() {
603
- return S.expireHR.run(Date.now()).changes;
604
- },
605
-
606
1246
  moveToDeadLetter(messageId, reason, lastError = null) {
607
- db.transaction(() => {
608
- S.setMsgStatus.run("dead_letter", messageId);
609
- S.insertDL.run(messageId, reason, Date.now(), lastError);
1247
+ const failedAt = Date.now();
1248
+ return db.transaction(() => {
1249
+ const changed = S.claimDeadLetter.run(messageId).changes;
1250
+ if (!changed) return false;
1251
+ S.insertDL.run(messageId, reason, failedAt, lastError);
1252
+ return true;
610
1253
  })();
611
- return true;
612
1254
  },
613
1255
 
614
1256
  getDeadLetters(limit = 50) {
@@ -638,7 +1280,10 @@ export function createStore(dbPath, options = {}) {
638
1280
  error = null,
639
1281
  }) {
640
1282
  const now = Date.now();
641
- const normalizedTimeout = clampDurationMs(timeout_ms, DEFAULT_ASSIGN_TIMEOUT_MS);
1283
+ const normalizedTimeout = clampDurationMs(
1284
+ timeout_ms,
1285
+ DEFAULT_ASSIGN_TIMEOUT_MS,
1286
+ );
642
1287
  const row = {
643
1288
  job_id: job_id || uuidv7(),
644
1289
  supervisor_agent,
@@ -761,7 +1406,9 @@ export function createStore(dbPath, options = {}) {
761
1406
  : nextStatus === "running"
762
1407
  ? current.started_at_ms || now
763
1408
  : current.started_at_ms,
764
- dispatched_at_ms: Object.hasOwn(patch, "dispatched_at_ms") ? patch.dispatched_at_ms : current.dispatched_at_ms,
1409
+ dispatched_at_ms: Object.hasOwn(patch, "dispatched_at_ms")
1410
+ ? patch.dispatched_at_ms
1411
+ : current.dispatched_at_ms,
765
1412
  completed_at_ms: Object.hasOwn(patch, "completed_at_ms")
766
1413
  ? patch.completed_at_ms
767
1414
  : isTerminal
@@ -873,16 +1520,27 @@ export function createStore(dbPath, options = {}) {
873
1520
  });
874
1521
  },
875
1522
 
876
- sweepExpired() {
877
- const now = Date.now();
1523
+ sweepExpired({ now_ms = Date.now() } = {}) {
878
1524
  return db.transaction(() => {
879
- const expired = S.findExpired.all(now);
880
- for (const { id } of expired) {
881
- S.setMsgStatus.run("dead_letter", id);
882
- S.insertDL.run(id, "ttl_expired", now, null);
1525
+ const transitions = [];
1526
+ for (const row of S.findExpired.all(now_ms)) {
1527
+ if (!S.claimExpired.run(row.id, now_ms).changes) continue;
1528
+ options.beforeDeadLetterInsert?.({
1529
+ message_id: row.id,
1530
+ reason: "ttl_expired",
1531
+ failed_at_ms: now_ms,
1532
+ });
1533
+ const dlqInserted =
1534
+ S.insertDL.run(row.id, "ttl_expired", now_ms, null).changes === 1;
1535
+ transitions.push({
1536
+ message: parseMessageRow(row),
1537
+ previous_status: row.status,
1538
+ reason: "ttl_expired",
1539
+ failed_at_ms: now_ms,
1540
+ dlq_inserted: dlqInserted,
1541
+ });
883
1542
  }
884
- const humanRequests = S.expireHR.run(now).changes;
885
- return { messages: expired.length, human_requests: humanRequests };
1543
+ return { now_ms, messages: transitions.length, transitions };
886
1544
  })();
887
1545
  },
888
1546