agent-working-memory 0.7.2 → 0.7.4

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.
@@ -9,6 +9,31 @@
9
9
  * - Thresholds are tunable per agent
10
10
  * - Deterministic heuristics first, LLM augmentation optional
11
11
  */
12
+ /**
13
+ * Auto-detect user-feedback memories: content that begins with a known user's
14
+ * name + a feedback verb. These memories represent direct human decisions and
15
+ * must never be discarded. Examples:
16
+ * "Robert verbatim: 'LMS programs-first like CRM'"
17
+ * "Katherine said the CEC cycle resets on promotion"
18
+ * "Nancy directed Tier 1 has no grace period"
19
+ *
20
+ * Why this exists: the BM25 novelty check collapses near-duplicates regardless
21
+ * of whether the content is a NEW decision or a repeat observation. User
22
+ * feedback often shares terminology with prior memories ("LMS", "ECP",
23
+ * "officials") and gets discarded at salience 0.14 (verified in activity log
24
+ * 2026-05-06T19:08:47). Detecting "Robert said X" → canonical class bypasses
25
+ * the salience filter entirely.
26
+ *
27
+ * Tune the name list as new staff join. Pattern requires word boundary at
28
+ * start so "Roberta" or "Hannahs" don't match.
29
+ */
30
+ const USER_FEEDBACK_PATTERN = /^(Robert|Katherine|Catherine|Nancy|Brandy|Brandi|Hannah|Marilyn|Kaylee|Pete|Abby|Tom|Wendy|Sita|Nick|Rob|Joan|Jennifer|Cindy|Jason|Alex|Molly)\s+(said|verbatim|feedback|asked|wants|prefers|requested|requested|directed|decided|confirmed|clarified|chose|specified|explained)\b/i;
31
+ /** Returns true if the content looks like direct user feedback that should auto-promote to canonical. */
32
+ export function detectUserFeedback(content) {
33
+ if (typeof content !== 'string' || content.length === 0)
34
+ return false;
35
+ return USER_FEEDBACK_PATTERN.test(content.trim());
36
+ }
12
37
  /**
13
38
  * Weights for the salience scoring formula.
14
39
  * Novelty is the strongest signal — new information should always be stored.
@@ -25,14 +50,27 @@ const WEIGHTS = {
25
50
  * Rule-based salience scorer with full audit trail.
26
51
  */
27
52
  export function evaluateSalience(input, activeThreshold = 0.4, stagingThreshold = 0.2) {
53
+ // Auto-detect user feedback before scoring. If content matches the pattern,
54
+ // force eventType='user_feedback' and memoryClass='canonical'. This bypasses
55
+ // the BM25 novelty floor that was discarding pivotal user decisions at 0.14.
56
+ let resolvedEventType = input.eventType ?? 'observation';
57
+ let resolvedMemoryClass = input.memoryClass ?? 'working';
58
+ let autoPromoted = false;
59
+ if (detectUserFeedback(input.content)) {
60
+ resolvedEventType = 'user_feedback';
61
+ resolvedMemoryClass = 'canonical';
62
+ autoPromoted = true;
63
+ }
28
64
  const features = {
29
65
  surprise: input.surprise ?? 0,
30
66
  decisionMade: input.decisionMade ?? false,
31
67
  causalDepth: input.causalDepth ?? 0,
32
68
  resolutionEffort: input.resolutionEffort ?? 0,
33
- eventType: input.eventType ?? 'observation',
69
+ eventType: resolvedEventType,
34
70
  };
35
71
  const reasonCodes = [];
72
+ if (autoPromoted)
73
+ reasonCodes.push('auto:user_feedback');
36
74
  // Novelty: 1.0 = completely new info, 0 = exact duplicate exists
37
75
  // Default to 0.8 (assume mostly novel) when caller doesn't check
38
76
  const novelty = input.novelty ?? 0.8;
@@ -73,11 +111,15 @@ export function evaluateSalience(input, activeThreshold = 0.4, stagingThreshold
73
111
  typeBonus = 0.2;
74
112
  reasonCodes.push('event:causal');
75
113
  break;
114
+ case 'user_feedback':
115
+ typeBonus = 0.3;
116
+ reasonCodes.push('event:user_feedback');
117
+ break;
76
118
  case 'observation': break;
77
119
  }
78
120
  let score = Math.min(surpriseScore + decisionScore + causalScore + effortScore + noveltyScore + typeBonus, 1.0);
79
121
  // Memory class overrides
80
- const memoryClass = input.memoryClass ?? 'working';
122
+ const memoryClass = resolvedMemoryClass;
81
123
  if (memoryClass === 'canonical') {
82
124
  // Canonical memories: salience floor of 0.7, never go to staging
83
125
  score = Math.max(score, 0.7);
@@ -128,16 +170,35 @@ export function computeNovelty(store, agentId, concept, content) {
128
170
  // searchBM25WithRank normalizes scores to 0..1 via |rank|/(1+|rank|).
129
171
  // Higher score = stronger match = less novel.
130
172
  const topScore = results[0].bm25Score;
131
- // Penalize exact concept string duplicates if any result has the same concept,
132
- // heavily reduce novelty to prevent hub toxicity from repeated task_end summaries
173
+ // Quadratic dampening (1 - topScore²) so mid-range matches don't kill novelty.
174
+ // Old curve was linear (1 - topScore) which floored at 0.1 for almost any match
175
+ // in a populated DB, killing the salience signal.
176
+ // Curve comparison (topScore → novelty):
177
+ // 0.30 → 0.91 (different topic — strong novelty)
178
+ // 0.60 → 0.64 (loosely related — partial credit)
179
+ // 0.80 → 0.36 (related but distinct — meaningful signal)
180
+ // 0.95 → 0.10 (near-dupe — still suppress)
181
+ const baseNovelty = 1.0 - topScore * topScore;
182
+ // Concept penalty scoped to recent matches only — re-using the same concept
183
+ // string for a NEW topic months later shouldn't be punished. Penalty was 0.4
184
+ // (too harsh); now 0.3 and only applies if any matched result is < 30 days old.
133
185
  const conceptLower = conceptStr.toLowerCase().trim();
134
- const exactConceptMatch = results.some(r => r.engram?.concept?.toLowerCase().trim() === conceptLower);
135
- const conceptPenalty = exactConceptMatch ? 0.4 : 0;
136
- // Continuous novelty: inversely proportional to BM25 similarity
137
- // Maps topScore (0..1) → novelty (0.1..0.95) using a smooth curve
138
- // Floor at 0.1 (never zero — even duplicates might have new context)
139
- // Ceiling at 0.95 (never 1.0 — always a tiny chance of overlap)
140
- return Math.max(0.1, Math.min(0.95, 1.0 - topScore - conceptPenalty));
186
+ const cutoffMs = Date.now() - 30 * 24 * 60 * 60 * 1000;
187
+ const exactConceptRecent = results.some(r => {
188
+ if (r.engram?.concept?.toLowerCase().trim() !== conceptLower)
189
+ return false;
190
+ const created = r.engram?.createdAt;
191
+ if (!created)
192
+ return true; // No timestamp treat as recent (conservative)
193
+ const createdMs = created instanceof Date
194
+ ? created.getTime()
195
+ : typeof created === 'number' ? created : Date.parse(created);
196
+ return Number.isFinite(createdMs) && createdMs >= cutoffMs;
197
+ });
198
+ const conceptPenalty = exactConceptRecent ? 0.3 : 0;
199
+ // Floor lowered to 0.05 (was 0.10) so true duplicates can score near-zero
200
+ // and clearly stay below stagingThreshold (0.2). Ceiling unchanged.
201
+ return Math.max(0.05, Math.min(0.95, baseNovelty - conceptPenalty));
141
202
  }
142
203
  catch {
143
204
  // If BM25 search fails (e.g., FTS not ready), assume novel
@@ -167,11 +228,25 @@ export function computeNoveltyWithMatch(store, agentId, concept, content, worksp
167
228
  allResults.sort((a, b) => b.bm25Score - a.bm25Score);
168
229
  const top = allResults[0];
169
230
  const topScore = top.bm25Score;
170
- // Concept penalty for exact duplicates
231
+ // Quadratic dampening — see computeNovelty for curve rationale
232
+ const baseNovelty = 1.0 - topScore * topScore;
233
+ // Recent-only concept penalty (30d window)
171
234
  const conceptLower = conceptStr.toLowerCase().trim();
172
- const exactMatch = allResults.some(r => r.engram?.concept?.toLowerCase().trim() === conceptLower);
173
- const conceptPenalty = exactMatch ? 0.4 : 0;
174
- const novelty = Math.max(0.1, Math.min(0.95, 1.0 - topScore - conceptPenalty));
235
+ const cutoffMs = Date.now() - 30 * 24 * 60 * 60 * 1000;
236
+ const exactConceptRecent = allResults.some(r => {
237
+ const eng = r.engram;
238
+ if (eng?.concept?.toLowerCase().trim() !== conceptLower)
239
+ return false;
240
+ const created = eng?.createdAt;
241
+ if (!created)
242
+ return true;
243
+ const createdMs = created instanceof Date
244
+ ? created.getTime()
245
+ : typeof created === 'number' ? created : Date.parse(created);
246
+ return Number.isFinite(createdMs) && createdMs >= cutoffMs;
247
+ });
248
+ const conceptPenalty = exactConceptRecent ? 0.3 : 0;
249
+ const novelty = Math.max(0.05, Math.min(0.95, baseNovelty - conceptPenalty));
175
250
  return { novelty, matchedEngramId: top.engram.id, matchScore: topScore };
176
251
  }
177
252
  catch {
@@ -1 +1 @@
1
- {"version":3,"file":"salience.js","sourceRoot":"","sources":["../../src/core/salience.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"salience.js","sourceRoot":"","sources":["../../src/core/salience.ts"],"names":[],"mappings":"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"}
package/dist/index.js CHANGED
@@ -175,7 +175,7 @@ async function main() {
175
175
  getExpander().catch(err => console.warn('Query expander model unavailable:', err.message));
176
176
  // Start server
177
177
  await app.listen({ port: PORT, host: '0.0.0.0' });
178
- console.log(`AgentWorkingMemory v0.7.2 listening on port ${PORT}`);
178
+ console.log(`AgentWorkingMemory v0.7.4 listening on port ${PORT}`);
179
179
  // Graceful shutdown
180
180
  const shutdown = async () => {
181
181
  clearInterval(backupTimer);
package/dist/mcp.js CHANGED
@@ -71,7 +71,7 @@ import { queryPeerDecisions, formatPeerDecisions } from './coordination/peer-dec
71
71
  const INCOGNITO = process.env.AWM_INCOGNITO === '1' || process.env.AWM_INCOGNITO === 'true';
72
72
  if (INCOGNITO) {
73
73
  console.error('AWM: incognito mode — all memory tools disabled, nothing will be recorded');
74
- const server = new McpServer({ name: 'agent-working-memory', version: '0.7.2' });
74
+ const server = new McpServer({ name: 'agent-working-memory', version: '0.7.4' });
75
75
  const transport = new StdioServerTransport();
76
76
  server.connect(transport).catch(err => {
77
77
  console.error('MCP server failed:', err);
@@ -102,7 +102,7 @@ else {
102
102
  let coordDb = null;
103
103
  const server = new McpServer({
104
104
  name: 'agent-working-memory',
105
- version: '0.7.2',
105
+ version: '0.7.4',
106
106
  });
107
107
  server.registerResource('awm-overview', 'awm://server/overview', {
108
108
  title: 'AWM Overview',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-working-memory",
3
- "version": "0.7.2",
3
+ "version": "0.7.4",
4
4
  "description": "Cognitive memory layer for AI agents — activation-based retrieval, salience filtering, associative connections",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/src/api/routes.ts CHANGED
@@ -705,7 +705,7 @@ export function registerRoutes(app: FastifyInstance, deps: MemoryDeps): void {
705
705
  const base: Record<string, unknown> = {
706
706
  status: 'ok',
707
707
  timestamp: new Date().toISOString(),
708
- version: '0.7.2',
708
+ version: '0.7.4',
709
709
  coordination: coordEnabled,
710
710
  };
711
711
  if (coordEnabled) {
package/src/cli.ts CHANGED
@@ -334,7 +334,7 @@ async function exportMemories() {
334
334
  const agents = [...new Set(memories.map((m: any) => m.agent_id))];
335
335
 
336
336
  const exportData = {
337
- version: '0.7.2',
337
+ version: '0.7.4',
338
338
  exported_at: new Date().toISOString(),
339
339
  source_db: dbPath,
340
340
  agent_filter: agentFilter,
@@ -11,7 +11,7 @@ import type { EngramStore } from '../storage/sqlite.js';
11
11
  import { ZodError } from 'zod';
12
12
  import { initCoordinationTables } from './schema.js';
13
13
  import { registerCoordinationRoutes } from './routes.js';
14
- import { cleanSlate, pruneOldHeartbeats, purgeDeadAgents } from './stale.js';
14
+ import { cleanSlate, pruneOldHeartbeats, purgeDeadAgents, cleanupStale } from './stale.js';
15
15
  import { createWriteMutex, needsWriteLock } from './write-mutex.js';
16
16
  import { createEventBus, type CoordinationEventBus } from './events.js';
17
17
  import { loadPlugins, teardownPlugins } from './plugin-loader.js';
@@ -106,6 +106,25 @@ export function initCoordination(app: FastifyInstance, db: Database.Database, st
106
106
  }, 60 * 60 * 1000),
107
107
  );
108
108
 
109
+ // Periodic stale-agent cleanup every 5 min with 600s threshold (10 min idle).
110
+ // Forgiving for long-running edits — workers should pulse every 60s during active
111
+ // work, so 10 min without a pulse is genuinely dead. This catches the
112
+ // "alive but not seeing each other" pattern where workers' processes persist
113
+ // but their heartbeats stop. Without this scheduled, only an explicit
114
+ // POST /stale/cleanup call (made by the coordinator agent on startup) ever
115
+ // fires cleanupStale, leaving zombie agents accumulating between coordinator
116
+ // sessions.
117
+ cleanupIntervals.push(
118
+ setInterval(() => {
119
+ try {
120
+ const result = cleanupStale(db, 600);
121
+ if (result.cleaned > 0) {
122
+ console.log(` [stale-cleanup] auto-cleaned ${result.stale.length} stale agent(s), ${result.cleaned} resource(s) released`);
123
+ }
124
+ } catch { /* db may be closed */ }
125
+ }, 5 * 60 * 1000),
126
+ );
127
+
109
128
  // Periodic channel liveness probe every 60s — mark unreachable sessions as disconnected
110
129
  cleanupIntervals.push(
111
130
  setInterval(async () => {
@@ -33,6 +33,45 @@ function coordLog(msg: string): void {
33
33
  console.log(`${ts()} [coord] ${msg}`);
34
34
  }
35
35
 
36
+ /**
37
+ * In-process counters for channel push telemetry.
38
+ * Reset on coordinator restart — intended for short-window observability
39
+ * ("ship it, watch numbers for a day"). Persistent counters would need a
40
+ * coord_metrics table; deferred until we know what's worth keeping.
41
+ *
42
+ * Fields:
43
+ * attempts — every call to deliverToChannel (HTTP push to worker)
44
+ * delivered — fetch returned 2xx
45
+ * failed_http — fetch returned non-2xx (worker reachable but rejected)
46
+ * failed_unreachable — fetch threw (timeout, ECONNREFUSED, etc.)
47
+ * no_session — push intent existed but no connected session
48
+ * fallback_mailbox — push failed, message queued to mailbox instead
49
+ * session_disconnects — session marked 'disconnected' after delivery failure
50
+ */
51
+ interface ChannelMetrics {
52
+ attempts: number;
53
+ delivered: number;
54
+ failed_http: number;
55
+ failed_unreachable: number;
56
+ no_session: number;
57
+ fallback_mailbox: number;
58
+ session_disconnects: number;
59
+ started_at: number;
60
+ }
61
+
62
+ function createChannelMetrics(): ChannelMetrics {
63
+ return {
64
+ attempts: 0,
65
+ delivered: 0,
66
+ failed_http: 0,
67
+ failed_unreachable: 0,
68
+ no_session: 0,
69
+ fallback_mailbox: 0,
70
+ session_disconnects: 0,
71
+ started_at: Date.now(),
72
+ };
73
+ }
74
+
36
75
  /**
37
76
  * Optional session-token check.
38
77
  * If X-Session-Token header is present and doesn't match the stored token → returns false (caller should 403).
@@ -47,6 +86,9 @@ function sessionTokenOk(db: Database.Database, agentId: string, req: import('fas
47
86
  }
48
87
 
49
88
  export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Database, store?: EngramStore, eventBus?: import('./events.js').CoordinationEventBus): void {
89
+ // Channel push telemetry — process-scoped counters. See ChannelMetrics docs above.
90
+ const channelMetrics = createChannelMetrics();
91
+
50
92
 
51
93
  // Request logging — one line per request with method, url, status, response time
52
94
  app.addHook('onRequest', async (request) => {
@@ -132,9 +174,14 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
132
174
  const sessionToken = wasDead ? randomUUID() : (
133
175
  (db.prepare(`SELECT session_token FROM coord_agents WHERE id = ?`).get(existing.id) as { session_token: string | null }).session_token ?? randomUUID()
134
176
  );
177
+ // role IS updated on every checkin — agents know their own role and
178
+ // re-registrations may correct stale role values (e.g., when an old
179
+ // coord_agents row was inserted with role='orchestrator' before the
180
+ // 'coordinator' role was canonical, or when the channel-server's
181
+ // hardcoded role='worker' overwrote a real role).
135
182
  db.prepare(
136
- `UPDATE coord_agents SET last_seen = datetime('now'), status = CASE WHEN status = 'dead' THEN 'idle' ELSE status END, pid = COALESCE(?, pid), capabilities = COALESCE(?, capabilities), workspace = COALESCE(?, workspace), session_token = ? WHERE id = ?`
137
- ).run(pid ?? null, capsJson, workspace ?? null, sessionToken, existing.id);
183
+ `UPDATE coord_agents SET last_seen = datetime('now'), status = CASE WHEN status = 'dead' THEN 'idle' ELSE status END, role = ?, pid = COALESCE(?, pid), capabilities = COALESCE(?, capabilities), workspace = COALESCE(?, workspace), session_token = ? WHERE id = ?`
184
+ ).run(role, pid ?? null, capsJson, workspace ?? null, sessionToken, existing.id);
138
185
 
139
186
  const eventType = wasDead ? 'reconnected' : 'heartbeat';
140
187
  const detail = wasDead ? `${name} reconnected (was dead)` : `heartbeat from ${name}`;
@@ -544,6 +591,9 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
544
591
  `UPDATE coord_channel_sessions SET last_push_at = datetime('now'), push_count = push_count + 1 WHERE agent_id = ?`
545
592
  ).run(agentId);
546
593
  }
594
+ } else {
595
+ // Session disappeared between intent record and delivery — race or rapid disconnect
596
+ channelMetrics.no_session++;
547
597
  }
548
598
  }
549
599
 
@@ -1359,28 +1409,41 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
1359
1409
  const q = workersQuerySchema.safeParse(req.query);
1360
1410
  const { capability, status: filterStatus, workspace } = q.success ? q.data : { capability: undefined, status: undefined, workspace: undefined };
1361
1411
 
1412
+ // Join with coord_channel_sessions so the coordinator agent can compute
1413
+ // alive=true for workers that have a connected channel session even when
1414
+ // their /pulse is stale. Without this, /workers under-reports liveness
1415
+ // during long tool-call sequences where the worker is processing but
1416
+ // hasn't called /pulse for >5min — leading to false-positive duplicate
1417
+ // spawns. Channel sessions get probed every 60s (coordination/index.ts:111),
1418
+ // so a stale channel-server.js gets status='disconnected' within 60-120s.
1362
1419
  let workers = workspace
1363
1420
  ? db.prepare(
1364
- `SELECT id, name, role, status, current_task, capabilities, workspace, last_seen,
1365
- ROUND((julianday('now') - julianday(last_seen)) * 86400) AS seconds_since_seen
1366
- FROM coord_agents
1367
- WHERE status != 'dead' AND role NOT IN ('orchestrator', 'coordinator') AND workspace = ?
1368
- ORDER BY name LIMIT 200`
1421
+ `SELECT a.id, a.name, a.role, a.status, a.current_task, a.capabilities, a.workspace, a.last_seen,
1422
+ ROUND((julianday('now') - julianday(a.last_seen)) * 86400) AS seconds_since_seen,
1423
+ cs.status AS channel_status, cs.last_push_at AS channel_last_push
1424
+ FROM coord_agents a
1425
+ LEFT JOIN coord_channel_sessions cs ON cs.agent_id = a.id
1426
+ WHERE a.status != 'dead' AND a.role NOT IN ('orchestrator', 'coordinator') AND a.workspace = ?
1427
+ ORDER BY a.name LIMIT 200`
1369
1428
  ).all(workspace) as Array<{
1370
1429
  id: string; name: string; role: string; status: string;
1371
1430
  current_task: string | null; capabilities: string | null;
1372
1431
  workspace: string | null; last_seen: string; seconds_since_seen: number;
1432
+ channel_status: string | null; channel_last_push: string | null;
1373
1433
  }>
1374
1434
  : db.prepare(
1375
- `SELECT id, name, role, status, current_task, capabilities, workspace, last_seen,
1376
- ROUND((julianday('now') - julianday(last_seen)) * 86400) AS seconds_since_seen
1377
- FROM coord_agents
1378
- WHERE status != 'dead' AND role NOT IN ('orchestrator', 'coordinator')
1379
- ORDER BY name LIMIT 200`
1435
+ `SELECT a.id, a.name, a.role, a.status, a.current_task, a.capabilities, a.workspace, a.last_seen,
1436
+ ROUND((julianday('now') - julianday(a.last_seen)) * 86400) AS seconds_since_seen,
1437
+ cs.status AS channel_status, cs.last_push_at AS channel_last_push
1438
+ FROM coord_agents a
1439
+ LEFT JOIN coord_channel_sessions cs ON cs.agent_id = a.id
1440
+ WHERE a.status != 'dead' AND a.role NOT IN ('orchestrator', 'coordinator')
1441
+ ORDER BY a.name LIMIT 200`
1380
1442
  ).all() as Array<{
1381
1443
  id: string; name: string; role: string; status: string;
1382
1444
  current_task: string | null; capabilities: string | null;
1383
1445
  workspace: string | null; last_seen: string; seconds_since_seen: number;
1446
+ channel_status: string | null; channel_last_push: string | null;
1384
1447
  }>;
1385
1448
 
1386
1449
  if (capability) {
@@ -1409,7 +1472,14 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
1409
1472
  workspace: w.workspace,
1410
1473
  lastSeen: w.last_seen,
1411
1474
  secondsSinceSeen: w.seconds_since_seen,
1412
- alive: w.seconds_since_seen < 300,
1475
+ // alive = recent /pulse OR connected channel session.
1476
+ // Channel sessions get probed every 60s and marked 'disconnected'
1477
+ // when unreachable, so a connected session is reliable proof of life
1478
+ // even during long tool sequences where the worker hasn't pulsed.
1479
+ // Prevents duplicate worker spawns when /pulse is stale but worker is busy.
1480
+ alive: w.seconds_since_seen < 300 || w.channel_status === 'connected',
1481
+ channelStatus: w.channel_status,
1482
+ channelLastPush: w.channel_last_push,
1413
1483
  }));
1414
1484
 
1415
1485
  return reply.send({
@@ -1691,6 +1761,32 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
1691
1761
  lines.push('# TYPE coord_uptime_seconds gauge');
1692
1762
  lines.push(`coord_uptime_seconds ${uptime}`);
1693
1763
 
1764
+ // ─── Channel push telemetry (process-scoped, reset on restart) ───
1765
+ lines.push('# HELP coord_channel_push_attempts_total Total channel push attempts since coordinator startup');
1766
+ lines.push('# TYPE coord_channel_push_attempts_total counter');
1767
+ lines.push(`coord_channel_push_attempts_total ${channelMetrics.attempts}`);
1768
+
1769
+ lines.push('# HELP coord_channel_push_delivered_total Successful channel deliveries');
1770
+ lines.push('# TYPE coord_channel_push_delivered_total counter');
1771
+ lines.push(`coord_channel_push_delivered_total ${channelMetrics.delivered}`);
1772
+
1773
+ lines.push('# HELP coord_channel_push_failed_total Failed channel deliveries by reason');
1774
+ lines.push('# TYPE coord_channel_push_failed_total counter');
1775
+ lines.push(`coord_channel_push_failed_total{reason="http"} ${channelMetrics.failed_http}`);
1776
+ lines.push(`coord_channel_push_failed_total{reason="unreachable"} ${channelMetrics.failed_unreachable}`);
1777
+
1778
+ lines.push('# HELP coord_channel_no_session_total Push attempts where agent had no connected session');
1779
+ lines.push('# TYPE coord_channel_no_session_total counter');
1780
+ lines.push(`coord_channel_no_session_total ${channelMetrics.no_session}`);
1781
+
1782
+ lines.push('# HELP coord_channel_fallback_mailbox_total Pushes that fell back to mailbox after delivery failure');
1783
+ lines.push('# TYPE coord_channel_fallback_mailbox_total counter');
1784
+ lines.push(`coord_channel_fallback_mailbox_total ${channelMetrics.fallback_mailbox}`);
1785
+
1786
+ lines.push('# HELP coord_channel_session_disconnects_total Sessions marked disconnected after delivery failure');
1787
+ lines.push('# TYPE coord_channel_session_disconnects_total counter');
1788
+ lines.push(`coord_channel_session_disconnects_total ${channelMetrics.session_disconnects}`);
1789
+
1694
1790
  return reply.type('text/plain; version=0.0.4; charset=utf-8').send(lines.join('\n') + '\n');
1695
1791
  });
1696
1792
 
@@ -1800,6 +1896,7 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
1800
1896
  async function deliverToChannel(
1801
1897
  agentId: string, channelUrl: string, content: string, meta?: Record<string, string>
1802
1898
  ): Promise<{ delivered: boolean; error?: string }> {
1899
+ channelMetrics.attempts++;
1803
1900
  try {
1804
1901
  const res = await fetch(`${channelUrl}/push`, {
1805
1902
  method: 'POST',
@@ -1808,11 +1905,15 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
1808
1905
  signal: AbortSignal.timeout(5000),
1809
1906
  });
1810
1907
  if (!res.ok) {
1908
+ channelMetrics.failed_http++;
1811
1909
  return { delivered: false, error: `channel returned ${res.status}` };
1812
1910
  }
1911
+ channelMetrics.delivered++;
1813
1912
  return { delivered: true };
1814
1913
  } catch (err) {
1815
1914
  // Connection refused / timeout → worker process is dead, mark session disconnected
1915
+ channelMetrics.failed_unreachable++;
1916
+ channelMetrics.session_disconnects++;
1816
1917
  db.prepare(
1817
1918
  `UPDATE coord_channel_sessions SET status = 'disconnected' WHERE agent_id = ?`
1818
1919
  ).run(agentId);
@@ -1822,11 +1923,40 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
1822
1923
  }
1823
1924
  }
1824
1925
 
1825
- /** POST /channel/push — Push a message to an agent. Tries live delivery first, falls back to mailbox queue. */
1926
+ /** POST /channel/push — Push a message to an agent. Tries live delivery first, falls back to mailbox queue.
1927
+ *
1928
+ * Two addressing modes:
1929
+ * - {agentId, message} — direct UUID
1930
+ * - {role, workspace, message} — server resolves to most-recently-seen alive agent
1931
+ * matching role+workspace. Used by workers to notify
1932
+ * coordinator (whose UUID changes across restarts).
1933
+ */
1826
1934
  app.post('/channel/push', async (request, reply) => {
1827
1935
  const parsed = channelPushSchema.safeParse(request.body);
1828
1936
  if (!parsed.success) return reply.status(400).send({ error: parsed.error.flatten() });
1829
- const { agentId, message } = parsed.data;
1937
+ const { message } = parsed.data;
1938
+ let { agentId } = parsed.data;
1939
+
1940
+ // Role-based addressing — resolve to a concrete agentId
1941
+ if (!agentId && parsed.data.role && parsed.data.workspace) {
1942
+ const resolved = db.prepare(
1943
+ `SELECT id FROM coord_agents
1944
+ WHERE role = ? AND workspace = ? AND status != 'dead'
1945
+ ORDER BY last_seen DESC
1946
+ LIMIT 1`
1947
+ ).get(parsed.data.role, parsed.data.workspace) as { id: string } | undefined;
1948
+ if (!resolved) {
1949
+ return reply.status(404).send({
1950
+ error: `No alive agent found for role='${parsed.data.role}' workspace='${parsed.data.workspace}'`,
1951
+ });
1952
+ }
1953
+ agentId = resolved.id;
1954
+ }
1955
+
1956
+ // Type narrowing — Zod refine guarantees agentId is set by this point,
1957
+ // but TypeScript can't see through the refine. This guard is unreachable
1958
+ // in practice (would have 400'd earlier).
1959
+ if (!agentId) return reply.status(400).send({ error: 'Internal: agentId resolution failed' });
1830
1960
 
1831
1961
  const agent = db.prepare(`SELECT name, workspace FROM coord_agents WHERE id = ?`).get(agentId) as { name: string; workspace: string | null } | undefined;
1832
1962
  if (!agent) return reply.status(404).send({ error: 'Agent not found' });
@@ -1853,6 +1983,10 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
1853
1983
  return reply.send({ ok: true, delivered: true, channelId: session.channel_id });
1854
1984
  }
1855
1985
  // Live delivery failed — fall through to mailbox
1986
+ channelMetrics.fallback_mailbox++;
1987
+ } else {
1988
+ // No connected session — push went straight to mailbox
1989
+ channelMetrics.no_session++;
1856
1990
  }
1857
1991
 
1858
1992
  // Queue to mailbox (delivered on next /next poll)
@@ -1914,4 +2048,48 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
1914
2048
 
1915
2049
  return reply.send({ probed: results.length, alive, dead, results });
1916
2050
  });
2051
+
2052
+ /**
2053
+ * GET /telemetry/channels — Channel push delivery telemetry.
2054
+ *
2055
+ * Counters reset on coordinator restart (in-process). Use this to answer:
2056
+ * "Are channels reliable enough to depend on, or do we need a polling fallback?"
2057
+ *
2058
+ * Response shape:
2059
+ * {
2060
+ * since: ISO timestamp of when counters started,
2061
+ * uptime_seconds: number,
2062
+ * attempts, delivered, failed_http, failed_unreachable,
2063
+ * no_session, fallback_mailbox, session_disconnects: number,
2064
+ * delivery_rate: 0..1 (delivered / attempts) or null if zero attempts,
2065
+ * per_agent: [{ agent_name, push_count, last_push_at, status }]
2066
+ * }
2067
+ */
2068
+ app.get('/telemetry/channels', async (_request, reply) => {
2069
+ const perAgent = db.prepare(`
2070
+ SELECT a.name AS agent_name, cs.push_count, cs.last_push_at, cs.status,
2071
+ cs.connected_at
2072
+ FROM coord_channel_sessions cs
2073
+ JOIN coord_agents a ON a.id = cs.agent_id
2074
+ ORDER BY cs.push_count DESC, cs.connected_at DESC
2075
+ `).all();
2076
+
2077
+ const deliveryRate = channelMetrics.attempts > 0
2078
+ ? channelMetrics.delivered / channelMetrics.attempts
2079
+ : null;
2080
+
2081
+ return reply.send({
2082
+ since: new Date(channelMetrics.started_at).toISOString(),
2083
+ uptime_seconds: Math.round((Date.now() - channelMetrics.started_at) / 1000),
2084
+ attempts: channelMetrics.attempts,
2085
+ delivered: channelMetrics.delivered,
2086
+ failed_http: channelMetrics.failed_http,
2087
+ failed_unreachable: channelMetrics.failed_unreachable,
2088
+ no_session: channelMetrics.no_session,
2089
+ fallback_mailbox: channelMetrics.fallback_mailbox,
2090
+ session_disconnects: channelMetrics.session_disconnects,
2091
+ delivery_rate: deliveryRate,
2092
+ per_agent: perAgent,
2093
+ });
2094
+ });
1917
2095
  }
@@ -210,10 +210,25 @@ export const channelDeregisterSchema = z.object({
210
210
  agentId: z.string().uuid(),
211
211
  });
212
212
 
213
+ /**
214
+ * Push to an agent's channel session. Accepts either:
215
+ * - agentId (direct addressing — caller knows the UUID)
216
+ * - role + workspace (role-based addressing — server resolves to the most
217
+ * recently-seen alive agent matching that role and workspace)
218
+ *
219
+ * Role-based addressing is the right choice when a worker wants to notify the
220
+ * coordinator: workers don't know the coordinator's UUID (it changes across
221
+ * coordinator restarts) but they do know the role and their own workspace.
222
+ */
213
223
  export const channelPushSchema = z.object({
214
- agentId: z.string().uuid(),
224
+ agentId: z.string().uuid().optional(),
225
+ role: agentRoleEnum.optional(),
226
+ workspace: z.string().min(1).max(50).optional(),
215
227
  message: z.string().min(1).max(10000),
216
- });
228
+ }).refine(
229
+ (d) => d.agentId !== undefined || (d.role !== undefined && d.workspace !== undefined),
230
+ { message: 'Must provide either agentId, or both role and workspace' }
231
+ );
217
232
 
218
233
  // ─── Stats ─────────────────────────────────────────────────────
219
234