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.
- package/README.md +20 -2
- package/dist/api/routes.js +1 -1
- package/dist/cli.js +1 -1
- package/dist/coordination/index.d.ts.map +1 -1
- package/dist/coordination/index.js +18 -1
- package/dist/coordination/index.js.map +1 -1
- package/dist/coordination/routes.d.ts.map +1 -1
- package/dist/coordination/routes.js +152 -14
- package/dist/coordination/routes.js.map +1 -1
- package/dist/coordination/schemas.d.ts +18 -1
- package/dist/coordination/schemas.d.ts.map +1 -1
- package/dist/coordination/schemas.js +14 -2
- package/dist/coordination/schemas.js.map +1 -1
- package/dist/core/salience.d.ts +3 -1
- package/dist/core/salience.d.ts.map +1 -1
- package/dist/core/salience.js +90 -15
- package/dist/core/salience.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/mcp.js +2 -2
- package/package.json +1 -1
- package/src/api/routes.ts +1 -1
- package/src/cli.ts +1 -1
- package/src/coordination/index.ts +20 -1
- package/src/coordination/routes.ts +193 -15
- package/src/coordination/schemas.ts +17 -2
- package/src/core/salience.ts +88 -17
- package/src/index.ts +1 -1
- package/src/mcp.ts +2 -2
package/dist/core/salience.js
CHANGED
|
@@ -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:
|
|
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 =
|
|
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
|
-
//
|
|
132
|
-
//
|
|
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
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
//
|
|
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
|
|
173
|
-
const
|
|
174
|
-
|
|
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":"AAAA,gDAAgD;AAChD,sCAAsC;AACtC;;;;;;;;GAQG;
|
|
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.
|
|
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.
|
|
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.
|
|
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
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.
|
|
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.
|
|
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
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
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
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
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
|
|
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 {
|
|
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
|
|