@cgh567/agent 2.4.1 → 2.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/helios +0 -0
- package/bin/helios-rpc-node-wrapper.cjs +0 -0
- package/bin/helios-rpc-wrapper.sh +0 -0
- package/daemon/adapters/helios-rpc-adapter.js +47 -25
- package/daemon/config/com.familiar.helios-daemon.plist +5 -0
- package/daemon/config/helios-daemon.service +4 -0
- package/daemon/context-enrichment.js +59 -21
- package/daemon/helios-api.js +149 -37
- package/daemon/helios-company-daemon.js +516 -124
- package/daemon/lib/harada/cascade-judge.js +12 -50
- package/daemon/lib/harada/mandala.js +20 -0
- package/daemon/lib/harada/pillar-dispatcher.js +1 -1
- package/daemon/lib/harada/project-factory.js +7 -2
- package/daemon/lib/hbo-bridge.js +31 -12
- package/daemon/lib/helios-hitl-host.js +15 -2
- package/daemon/lib/hitl-interaction-service.js +0 -0
- package/daemon/lib/memgraph-verify.js +38 -33
- package/daemon/lib/project-drift-detector.js +7 -17
- package/daemon/lib/project-semantic-updater.js +1 -14
- package/daemon/routes/channels.js +10 -5
- package/daemon/routes/harada-map.js +11 -48
- package/daemon/routes/hbo.js +89 -28
- package/daemon/routes/hitl.js +0 -0
- package/daemon/routes/project.js +4 -3
- package/daemon/routes/wizard.js +11 -4
- package/daemon/schema-migrations-hitl.js +0 -0
- package/extensions/001-tool-output-cap.ts +0 -0
- package/extensions/context-compaction.ts +45 -26
- package/extensions/cortex/activation-bridge.ts +5 -0
- package/extensions/cortex/learn.ts +26 -0
- package/extensions/email/backfill.ts +0 -0
- package/extensions/helios-governance/analysis/ambiguity.ts +0 -0
- package/extensions/helios-governance/analysis/compliance.ts +0 -0
- package/extensions/helios-governance/analysis/long-task-detector.ts +0 -0
- package/extensions/helios-governance/analysis/output-contract.ts +0 -0
- package/extensions/helios-governance/analysis/patterns.ts +0 -0
- package/extensions/helios-governance/analysis/preflight.ts +0 -0
- package/extensions/helios-governance/analysis/recurring-violations.ts +0 -0
- package/extensions/helios-governance/analysis/task-classification.ts +0 -0
- package/extensions/helios-governance/analysis/task-intent.ts +0 -0
- package/extensions/helios-governance/gates/high-impact.ts +1 -1
- package/extensions/helios-governance/handlers/_jiti-require.ts +15 -8
- package/extensions/helios-governance/handlers/proxy-test-detector.ts +0 -0
- package/extensions/hema-dispatch-v3/graph-memory.ts +10 -0
- package/extensions/hema-dispatch-v3/index.ts +59 -40
- package/extensions/lib/elo-engine.js +0 -0
- package/extensions/lib/elo-engine.test.js +0 -0
- package/extensions/memgraph-autostart.ts +13 -0
- package/extensions/neuroplastic-eval.ts +0 -0
- package/extensions/shadow-loop/index.ts +0 -0
- package/lib/brain-v2-budget.js +0 -0
- package/lib/brain-v2-circuit-breaker.js +0 -0
- package/lib/brain-v2.js +0 -0
- package/lib/broker/adaptive-throttle.js +0 -0
- package/lib/broker/batch-coalescer.js +0 -0
- package/lib/broker/bulkhead.js +0 -0
- package/lib/broker/channel-registry.js +0 -0
- package/lib/broker/circuit-breaker.js +0 -0
- package/lib/broker/evidence-cache.js +0 -0
- package/lib/broker/health-monitor.js +0 -0
- package/lib/broker/mage-queue.js +0 -0
- package/lib/broker/priority-queue.js +0 -0
- package/lib/broker/server.js.bak-error2-fix +0 -0
- package/lib/broker/session-registry.js +0 -0
- package/lib/broker/singleton-timers.js +0 -0
- package/lib/broker/types.d.ts +0 -0
- package/lib/broker/vegas-limit.js +0 -0
- package/lib/compression/dist/ccr-store.js +74 -0
- package/lib/compression/dist/content-router.js +115 -0
- package/lib/compression/dist/pipeline.js +113 -0
- package/lib/compression/dist/server.js +265 -0
- package/lib/compression/dist/smart-crusher.js +251 -0
- package/lib/context-budget.ts +0 -0
- package/lib/context-firewall.js +0 -0
- package/lib/crm/integration/triage-bridge.js +0 -0
- package/lib/email-utils.ts +0 -0
- package/lib/eval/__tests__/preflight-checker.test.ts +0 -0
- package/lib/eval/__tests__/task-instruction-parser.test.ts +0 -0
- package/lib/eval/__tests__/verifier-runner.test.ts +0 -0
- package/lib/eval/index.ts +0 -0
- package/lib/eval/preflight-checker.ts +0 -0
- package/lib/eval/task-domain-classifier.ts +0 -0
- package/lib/eval/task-instruction-parser.ts +0 -0
- package/lib/eval/verifier-runner.ts +0 -0
- package/lib/event-bus.d.ts +0 -0
- package/lib/governance-context-selector.ts +0 -0
- package/lib/graph/generate-extension-embeddings.js +0 -0
- package/lib/graph/generate-static-embeddings.js +0 -0
- package/lib/graph/lib/utils.js +1 -1
- package/lib/graph-audit.d.ts +0 -0
- package/lib/mesh-circuit-breaker.js +0 -0
- package/lib/mission-loop/lesson-extractor.ts +0 -0
- package/lib/mission-loop/mental-model-scorer.ts +0 -0
- package/lib/mission-loop/occ-detector.ts +0 -0
- package/lib/mission-loop/query-variants.ts +0 -0
- package/lib/mission-loop/verifier-check.ts +0 -0
- package/lib/skill-reference-builder.ts +0 -0
- package/lib/telemetry/token-breakdown.ts +0 -0
- package/lib/tool-compressor.ts +0 -0
- package/lib/triage-core/legal-routing.ts +0 -0
- package/lib/triage-core/mental-model/dunbar-classifier.ts +0 -0
- package/lib/triage-core/mental-model/enrich-all.ts +0 -0
- package/lib/triage-core/mental-model/identity-resolver.ts +0 -0
- package/lib/triage-core/mental-model/key-facts.ts +0 -0
- package/lib/triage-core/mental-model/model-assembler.ts +0 -0
- package/lib/triage-core/orchestrator.ts +0 -0
- package/lib/triage-core/orchestrator.ts.bak-r005-r006-r008 +0 -0
- package/package.json +10 -4
- package/skills/helios-business-operator/services/signals/upwork-signals.js +0 -0
- package/skills/talisman-ceo/SKILL.md +23 -25
- package/skills/talisman-comms/SKILL.md +5 -5
- package/skills/talisman-engineering/SKILL.md +5 -5
- package/skills/talisman-finance/SKILL.md +10 -8
- package/skills/talisman-marketing/SKILL.md +10 -10
- package/skills/talisman-sales/SKILL.md +12 -15
- package/skills/talisman-support/SKILL.md +5 -5
- package/agents/business/talisman-ceo.md +0 -183
- package/agents/business/talisman-comms.md +0 -257
- package/agents/business/talisman-cto.md +0 -153
- package/agents/business/talisman-finance.md +0 -246
- package/agents/business/talisman-marketing.md +0 -240
- package/agents/business/talisman-sales.md +0 -242
- package/agents/business/talisman-support.md +0 -236
- package/daemon/lib/approval-expiry.js +0 -162
- package/daemon/lib/blast-radius-analyzer.js +0 -75
- package/daemon/lib/domain-bootstrap-orchestrator.js +0 -267
- package/daemon/lib/forensic-log.js +0 -113
- package/daemon/lib/goal-research-pipeline.js +0 -644
- package/daemon/lib/harada/cascade-research-dispatcher.js +0 -261
- package/daemon/lib/headroom-middleware.js +0 -167
- package/daemon/lib/headroom-proxy-manager.js +0 -623
- package/daemon/lib/hed-engine.js +0 -307
- package/daemon/lib/mental-model-cache.js +0 -96
- package/daemon/lib/project-factory.js +0 -47
- package/daemon/lib/session-log-reader.js +0 -93
- package/daemon/routes/hed.js +0 -133
- package/lib/graph/learning/headroom-learn-bridge.js +0 -215
- package/skills/helios-bookkeeping/SKILL.md +0 -321
- package/skills/helios-briefer/SKILL.md +0 -44
- package/skills/helios-client-relations/SKILL.md +0 -322
- package/skills/helios-personal-triager/SKILL.md +0 -45
- package/skills/helios-recruitment/SKILL.md +0 -317
- package/skills/helios-relationship-nudger/SKILL.md +0 -77
- package/skills/helios-researcher/SKILL.md +0 -44
- package/skills/helios-scheduler/SKILL.md +0 -58
- package/skills/helios-tax-analyst/SKILL.md +0 -280
|
@@ -25,6 +25,7 @@ const os = require('os');
|
|
|
25
25
|
const { performance } = require('perf_hooks');
|
|
26
26
|
const { randomUUID } = require('crypto');
|
|
27
27
|
const { buildContextBrief } = require('./context-enrichment');
|
|
28
|
+
const hboStore = require('../lib/hbo-core-store');
|
|
28
29
|
const { runMigrations } = require('./schema-migrations');
|
|
29
30
|
const { runHBOMigrations } = require('./schema-migrations-hbo');
|
|
30
31
|
const { runHaradaMigrations } = require('./schema-migrations-harada');
|
|
@@ -179,6 +180,14 @@ try {
|
|
|
179
180
|
process.stderr.write('[daemon] Generated API token at ' + tokenPath + '\n');
|
|
180
181
|
}
|
|
181
182
|
process.env.HELIOS_API_TOKEN = fs.readFileSync(tokenPath, 'utf-8').trim();
|
|
183
|
+
// Mirror token to data/api-token.txt so harbor tests can find it without
|
|
184
|
+
// knowing the platform-specific HELIOS_DATA path.
|
|
185
|
+
// Primary path: harbor test fixture reads HELIOS_ROOT/data/api-token.txt → sends Bearer token.
|
|
186
|
+
try {
|
|
187
|
+
const _repoTokenDir = path.join(HELIOS_ROOT, 'data');
|
|
188
|
+
if (!fs.existsSync(_repoTokenDir)) fs.mkdirSync(_repoTokenDir, { recursive: true });
|
|
189
|
+
fs.writeFileSync(path.join(_repoTokenDir, 'api-token.txt'), process.env.HELIOS_API_TOKEN, { mode: 0o600 });
|
|
190
|
+
} catch (_) { /* non-fatal: env var HELIOS_AGENT_TOKEN is the fallback */ }
|
|
182
191
|
} catch(e) {
|
|
183
192
|
process.stderr.write('[daemon] Warning: could not create/read API token: ' + e.message + '\n');
|
|
184
193
|
// Fallback: generate in-memory token (not persisted)
|
|
@@ -717,7 +726,22 @@ async function executeQueueAction(item) {
|
|
|
717
726
|
const assignee = (item.payload && item.payload.assigneeAgentId) || 'agent:default';
|
|
718
727
|
const cid = (item.payload && item.payload.companyId) || 'default';
|
|
719
728
|
|
|
720
|
-
|
|
729
|
+
// SQLite-first write (P2-2)
|
|
730
|
+
hboStore.createTask({
|
|
731
|
+
id: taskId,
|
|
732
|
+
companyId: cid,
|
|
733
|
+
title,
|
|
734
|
+
status: 'todo',
|
|
735
|
+
priority: 2,
|
|
736
|
+
assigneeAgentId: assignee,
|
|
737
|
+
body: (item.payload && item.payload.body) || '',
|
|
738
|
+
sourceItemId: item.target_id,
|
|
739
|
+
sourceChannel: item.channel,
|
|
740
|
+
progressPropagated: false,
|
|
741
|
+
createdAt: Date.now(),
|
|
742
|
+
});
|
|
743
|
+
// Non-blocking Memgraph projection (fire-and-forget)
|
|
744
|
+
setImmediate(() => mg.safeWrite(`
|
|
721
745
|
CREATE (t:Task {
|
|
722
746
|
id: $taskId,
|
|
723
747
|
companyId: $cid,
|
|
@@ -739,7 +763,7 @@ async function executeQueueAction(item) {
|
|
|
739
763
|
body: (item.payload && item.payload.body) || '',
|
|
740
764
|
sourceId: item.target_id,
|
|
741
765
|
channel: item.channel,
|
|
742
|
-
});
|
|
766
|
+
}).catch(e => console.warn('[daemon] Memgraph Task projection failed (non-fatal):', e.message)));
|
|
743
767
|
return;
|
|
744
768
|
}
|
|
745
769
|
|
|
@@ -1223,12 +1247,26 @@ class BudgetEnforcer {
|
|
|
1223
1247
|
|
|
1224
1248
|
let inProgress;
|
|
1225
1249
|
try {
|
|
1226
|
-
//
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1250
|
+
// Memgraph primary — SQLite fallback on unavailability
|
|
1251
|
+
try {
|
|
1252
|
+
// Retry up to 5 times (500ms apart) in case Memgraph snapshot isolation
|
|
1253
|
+
// hasn't propagated a recently-committed write yet.
|
|
1254
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
1255
|
+
inProgress = await this.mg(query, params);
|
|
1256
|
+
if (inProgress?.rows?.length > 0) break;
|
|
1257
|
+
if (attempt < 4) await new Promise(r => setTimeout(r, 500));
|
|
1258
|
+
}
|
|
1259
|
+
} catch (mgErr) {
|
|
1260
|
+
// Memgraph unavailable — fall back to SQLite
|
|
1261
|
+
if (hboStore.getTasksByCompanyStatus) {
|
|
1262
|
+
const storeRows = hboStore.getTasksByCompanyStatus(this.companyId, 'in_progress');
|
|
1263
|
+
const filtered = agentId ? storeRows.filter(t => t.assigneeAgentId === agentId) : storeRows;
|
|
1264
|
+
inProgress = {
|
|
1265
|
+
rows: filtered.map(t => [t.id, t.heliosRunId ?? null, t.dispatchedViaTUI ?? null, t.assigneeAgentId ?? null]),
|
|
1266
|
+
keys: ['t.id', 't.heliosRunId', 't.dispatchedViaTUI', 't.assigneeAgentId'],
|
|
1267
|
+
};
|
|
1268
|
+
log('info', `CancelInFlight: using SQLite fallback for in-progress task lookup (Memgraph unavailable): ${mgErr.message}`);
|
|
1269
|
+
}
|
|
1232
1270
|
}
|
|
1233
1271
|
} catch (e) {
|
|
1234
1272
|
log('error', `BudgetEnforcer: failed to query in-flight tasks: ${e.message}`);
|
|
@@ -1260,10 +1298,13 @@ class BudgetEnforcer {
|
|
|
1260
1298
|
log('error', `BudgetEnforcer: failed to cancel TUI run ${heliosRunId} for task ${taskId} after 3 retries (H3 watchdog will clean up)`);
|
|
1261
1299
|
}
|
|
1262
1300
|
}
|
|
1263
|
-
|
|
1301
|
+
// SQLite-first update (P2-4)
|
|
1302
|
+
try { hboStore.updateTask(taskRow[0], this.companyId, { status: 'todo', executionLockedAt: null, executionAgentId: null, dispatchedViaTUI: null, heliosRunId: null }); } catch (_) {}
|
|
1303
|
+
// Non-blocking Memgraph projection (fire-and-forget)
|
|
1304
|
+
setImmediate(() => this.mg(
|
|
1264
1305
|
`MATCH (t:Task {id: $taskId}) SET t.status = 'todo', t.executionLockedAt = null, t.executionAgentId = null, t.dispatchedViaTUI = null, t.heliosRunId = null`,
|
|
1265
1306
|
{ taskId: taskRow[0] }
|
|
1266
|
-
).catch(e => log('error', `BudgetEnforcer: failed to reset task ${taskRow[0]} after cancellation: ${e.message}`));
|
|
1307
|
+
).catch(e => log('error', `BudgetEnforcer: failed to reset task ${taskRow[0]} after cancellation: ${e.message}`)));
|
|
1267
1308
|
});
|
|
1268
1309
|
|
|
1269
1310
|
await Promise.race([
|
|
@@ -1273,14 +1314,30 @@ class BudgetEnforcer {
|
|
|
1273
1314
|
}
|
|
1274
1315
|
|
|
1275
1316
|
async enforce() {
|
|
1276
|
-
// Query 1: Get all policies
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1317
|
+
// Query 1: Get all policies — Memgraph primary, SQLite fallback on unavailability
|
|
1318
|
+
let policyRows = [];
|
|
1319
|
+
try {
|
|
1320
|
+
const policiesResult = await this.mg(
|
|
1321
|
+
`MATCH (bp:BudgetPolicy {companyId: $cid})
|
|
1322
|
+
RETURN bp.id, bp.scope, bp.agentId, bp.limitCents, bp.warnPercent, bp.hardStopEnabled`,
|
|
1323
|
+
{ cid: this.companyId }
|
|
1324
|
+
);
|
|
1325
|
+
policyRows = policiesResult?.rows ?? [];
|
|
1326
|
+
} catch (mgErr) {
|
|
1327
|
+
// Memgraph unavailable — fall back to SQLite budget policies
|
|
1328
|
+
if (hboStore.getBudgetPoliciesByCompany) {
|
|
1329
|
+
const storePolicies = hboStore.getBudgetPoliciesByCompany(this.companyId);
|
|
1330
|
+
policyRows = storePolicies.map(bp => [
|
|
1331
|
+
bp.id,
|
|
1332
|
+
bp.scope,
|
|
1333
|
+
bp.agent_id ?? bp.agentId,
|
|
1334
|
+
bp.limit_cents ?? bp.limitCents,
|
|
1335
|
+
bp.warn_percent ?? bp.warnPercent,
|
|
1336
|
+
bp.hard_stop_enabled ?? bp.hardStopEnabled,
|
|
1337
|
+
]);
|
|
1338
|
+
log('info', `BudgetEnforcer: using SQLite fallback for policy lookup (Memgraph unavailable): ${mgErr.message}`);
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1284
1341
|
|
|
1285
1342
|
let blocked = false;
|
|
1286
1343
|
let warningActive = false;
|
|
@@ -1309,12 +1366,21 @@ class BudgetEnforcer {
|
|
|
1309
1366
|
const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)).toISOString();
|
|
1310
1367
|
|
|
1311
1368
|
// Query 2: Spend per agent THIS MONTH (fast — time-bounded, indexed)
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1369
|
+
// Memgraph primary — SQLite has no CostEvent fallback so default to empty spend on unavailability
|
|
1370
|
+
let spendRows = { rows: [] };
|
|
1371
|
+
try {
|
|
1372
|
+
spendRows = await this.mg(
|
|
1373
|
+
`MATCH (ce:CostEvent {companyId: $cid})
|
|
1374
|
+
WHERE ce.createdAt >= datetime($start) AND ce.createdAt < datetime($end)
|
|
1375
|
+
RETURN ce.agentId, sum(ce.costCents) as total`,
|
|
1376
|
+
{ cid: this.companyId, start: start.replace(/\.\d{3}Z$/, '+00:00'), end: end.replace(/\.\d{3}Z$/, '+00:00') }
|
|
1377
|
+
);
|
|
1378
|
+
} catch (mgSpendErr) {
|
|
1379
|
+
log('info', `BudgetEnforcer: Memgraph unavailable for spend query — using zero spend (${mgSpendErr.message})`);
|
|
1380
|
+
// spendRows stays { rows: [] } — enforcement proceeds with zero observed spend,
|
|
1381
|
+
// which means soft/hard thresholds will not trigger. This is the safe fallback:
|
|
1382
|
+
// better to not block agents than to falsely block them on a stale DB state.
|
|
1383
|
+
}
|
|
1318
1384
|
|
|
1319
1385
|
// JS join (microseconds — no cartesian product)
|
|
1320
1386
|
const neo4j = require('neo4j-driver');
|
|
@@ -1334,29 +1400,39 @@ class BudgetEnforcer {
|
|
|
1334
1400
|
const pct = limit > 0 ? (spent * 100 / limit) : 0;
|
|
1335
1401
|
const warnThreshold = toNum(warnPercent) || 80;
|
|
1336
1402
|
|
|
1337
|
-
// Update policy in Memgraph
|
|
1338
|
-
|
|
1403
|
+
// Update policy spend in Memgraph — fire-and-forget so Memgraph downtime
|
|
1404
|
+
// does not abort the loop. SQLite hbo-core-store is the authoritative store.
|
|
1405
|
+
setImmediate(() => this.mg(
|
|
1339
1406
|
`MATCH (bp:BudgetPolicy {id: $id}) SET bp.spentCents = $spent, bp.percentUsed = $pct`,
|
|
1340
1407
|
{ id, spent, pct }
|
|
1341
|
-
);
|
|
1408
|
+
).catch(e => log('info', `BudgetEnforcer: spend update projection failed (non-fatal): ${e.message}`)));
|
|
1342
1409
|
|
|
1343
1410
|
policies.push({ id, scope, agentId, limitCents: limit, spentCents: spent, percentUsed: pct, status: 'active' });
|
|
1344
1411
|
|
|
1345
1412
|
// Soft incident at warnPercent (idempotent)
|
|
1346
1413
|
if (pct >= warnThreshold && pct < 100) {
|
|
1347
1414
|
warningActive = true;
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
)
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1415
|
+
// Use fire-and-forget for incident creation so Memgraph downtime does not abort enforce()
|
|
1416
|
+
const _mgThis = this.mg.bind(this);
|
|
1417
|
+
const _cid = this.companyId;
|
|
1418
|
+
setImmediate(async () => {
|
|
1419
|
+
try {
|
|
1420
|
+
const existing = await _mgThis(
|
|
1421
|
+
`MATCH (bi:BudgetIncident {policyId: $pid, companyId: $cid, thresholdType: 'soft', status: 'open'}) RETURN bi.id LIMIT 1`,
|
|
1422
|
+
{ pid: id, cid: _cid }
|
|
1423
|
+
);
|
|
1424
|
+
if (!existing?.rows?.length) {
|
|
1425
|
+
const incidentId = `bi:soft:${id}:${Date.now()}`;
|
|
1426
|
+
await _mgThis(
|
|
1427
|
+
`CREATE (bi:BudgetIncident {id: $id, companyId: $cid, policyId: $pid, scopeType: $scopeType, scopeId: $scopeId, thresholdType: 'soft', amountLimit: $limit, amountObserved: $spent, status: 'open', createdAt: datetime()})`,
|
|
1428
|
+
{ id: incidentId, cid: _cid, pid: id, scopeType: scope, scopeId: agentId ?? scope, limit, spent }
|
|
1429
|
+
);
|
|
1430
|
+
log('info', `BudgetEnforcer: Soft budget incident created for ${agentId ?? 'global'}`, { pct, warnThreshold });
|
|
1431
|
+
}
|
|
1432
|
+
} catch (e) {
|
|
1433
|
+
log('info', `BudgetEnforcer: soft incident projection failed (non-fatal): ${e.message}`);
|
|
1434
|
+
}
|
|
1435
|
+
});
|
|
1360
1436
|
if (scope === 'global') {
|
|
1361
1437
|
log('info', `BudgetEnforcer: Global budget warning active (>${warnThreshold}%)`, { percentUsed: pct });
|
|
1362
1438
|
}
|
|
@@ -1368,18 +1444,31 @@ class BudgetEnforcer {
|
|
|
1368
1444
|
const existingHard = await this.mg(
|
|
1369
1445
|
`MATCH (bi:BudgetIncident {policyId: $pid, companyId: $cid, thresholdType: 'hard', status: 'open'}) RETURN bi.id LIMIT 1`,
|
|
1370
1446
|
{ pid: id, cid: this.companyId }
|
|
1371
|
-
);
|
|
1447
|
+
).catch(() => ({ rows: [] })); // treat Memgraph unavailability as "no existing incident"
|
|
1372
1448
|
if (!existingHard?.rows?.length) {
|
|
1373
1449
|
const incidentId = `bi:hard:${id}:${Date.now()}`;
|
|
1374
1450
|
const approvalId = `approval:budget:${id}:${Date.now()}`;
|
|
1375
|
-
|
|
1451
|
+
// SQLite-first: write Approval to SQLite before Memgraph so if Memgraph
|
|
1452
|
+
// is down the approval still exists and budget enforcement still functions (F7).
|
|
1453
|
+
try {
|
|
1454
|
+
hboStore.createApproval({
|
|
1455
|
+
id: approvalId, companyId: this.companyId, type: 'budget_exceeded',
|
|
1456
|
+
title: `Budget exceeded for ${agentId ?? 'global'} — raise limit to resume`,
|
|
1457
|
+
requestedBy: agentId ?? 'agent:ceo', status: 'pending', followUpTaskCreated: false, createdAt: Date.now(),
|
|
1458
|
+
});
|
|
1459
|
+
} catch (storeErr) {
|
|
1460
|
+
log('warn', `BudgetEnforcer: SQLite approval write failed (non-fatal): ${storeErr.message}`);
|
|
1461
|
+
}
|
|
1462
|
+
// Memgraph projections — fire-and-forget so Memgraph downtime does not crash enforce()
|
|
1463
|
+
const _mgThis = this.mg.bind(this);
|
|
1464
|
+
setImmediate(() => _mgThis(
|
|
1376
1465
|
`CREATE (bi:BudgetIncident {id: $incId, companyId: $cid, policyId: $pid, scopeType: $scopeType, scopeId: $scopeId, thresholdType: 'hard', amountLimit: $limit, amountObserved: $spent, status: 'open', approvalId: $apId, createdAt: datetime()})`,
|
|
1377
1466
|
{ incId: incidentId, cid: this.companyId, pid: id, scopeType: scope, scopeId: agentId ?? scope, limit, spent, apId: approvalId }
|
|
1378
|
-
);
|
|
1379
|
-
|
|
1467
|
+
).catch(e => log('warn', `BudgetEnforcer: BudgetIncident Memgraph projection failed: ${e.message}`)));
|
|
1468
|
+
setImmediate(() => _mgThis(
|
|
1380
1469
|
`CREATE (a:Approval {id: $id, companyId: $cid, type: 'budget_exceeded', title: $title, requestedBy: $agentId, status: 'pending', followUpTaskCreated: false, createdAt: datetime()})`,
|
|
1381
1470
|
{ id: approvalId, cid: this.companyId, title: `Budget exceeded for ${agentId ?? 'global'} — raise limit to resume`, agentId: agentId ?? 'agent:ceo' }
|
|
1382
|
-
);
|
|
1471
|
+
).catch(e => log('warn', `BudgetEnforcer: Approval Memgraph projection failed: ${e.message}`)));
|
|
1383
1472
|
// Day 5: Emit BUDGET_EXCEEDED P0 AnomalySignal.
|
|
1384
1473
|
// OPTIONAL MATCH dedup guard — Tournament winner: Candidate C.
|
|
1385
1474
|
// P0 because a hard budget stop halts all agent work immediately.
|
|
@@ -1463,12 +1552,15 @@ class AgentDispatcher {
|
|
|
1463
1552
|
* @param {Function} spawnFn - optional spawn override (for testing)
|
|
1464
1553
|
* @param {object} _testConfig - optional config override (for testing only, replaces _daemonConfig)
|
|
1465
1554
|
* @param {object} registry - optional AdapterRegistry instance
|
|
1555
|
+
* @param {Function} broadcastFn - optional closure for SSE broadcast; use (...args) => daemon._broadcast?.(...args)
|
|
1556
|
+
* so the live broadcast reference is resolved at call time, not construction time.
|
|
1466
1557
|
*/
|
|
1467
|
-
constructor(mgQuery, companyId, spawnFn = null, _testConfig = null, registry = null) {
|
|
1558
|
+
constructor(mgQuery, companyId, spawnFn = null, _testConfig = null, registry = null, broadcastFn = null) {
|
|
1468
1559
|
if (!companyId) throw new Error('AgentDispatcher: companyId required');
|
|
1469
1560
|
this.mg = mgQuery;
|
|
1470
1561
|
this.companyId = companyId;
|
|
1471
1562
|
this._spawnFn = spawnFn;
|
|
1563
|
+
this._daemonBroadcast = typeof broadcastFn === 'function' ? broadcastFn : null;
|
|
1472
1564
|
this._config = _testConfig !== null ? _testConfig : _daemonConfig;
|
|
1473
1565
|
// M-11: Use per-company config agents, not the module-level first-company alias
|
|
1474
1566
|
const _perCompanyCfg = _allCompanyConfigs.find(c =>
|
|
@@ -1488,13 +1580,18 @@ class AgentDispatcher {
|
|
|
1488
1580
|
const agentHelios = heliosConfig.agents?.[agentId] ?? {};
|
|
1489
1581
|
if (!agentHelios.apiKey) return null;
|
|
1490
1582
|
|
|
1491
|
-
// Check if issue already exists on task node
|
|
1583
|
+
// Check if issue already exists on task node — SQLite-first (P2-4)
|
|
1492
1584
|
try {
|
|
1493
|
-
const
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1585
|
+
const _storeTask = hboStore.getTask ? hboStore.getTask(taskId, this.companyId) : null;
|
|
1586
|
+
if (_storeTask && _storeTask.heliosIssueId) return _storeTask.heliosIssueId;
|
|
1587
|
+
if (!_storeTask) {
|
|
1588
|
+
// Fallback to Memgraph
|
|
1589
|
+
const existing = await this.mg(
|
|
1590
|
+
`MATCH (t:Task {id: $taskId}) WHERE t.heliosIssueId IS NOT NULL RETURN t.heliosIssueId`,
|
|
1591
|
+
{ taskId }
|
|
1592
|
+
);
|
|
1593
|
+
if (existing?.rows?.length) return existing.rows[0][0];
|
|
1594
|
+
}
|
|
1498
1595
|
} catch (e) { /* ignore */ }
|
|
1499
1596
|
|
|
1500
1597
|
// Create new issue in Helios TUI
|
|
@@ -1511,11 +1608,13 @@ class AgentDispatcher {
|
|
|
1511
1608
|
if (!resp.ok) return null;
|
|
1512
1609
|
const issue = await resp.json();
|
|
1513
1610
|
const issueId = issue.id;
|
|
1514
|
-
//
|
|
1515
|
-
|
|
1611
|
+
// SQLite-first update (P2-4)
|
|
1612
|
+
try { hboStore.updateTask(taskId, this.companyId, { heliosIssueId: issueId }); } catch (_) {}
|
|
1613
|
+
// Non-blocking Memgraph projection (fire-and-forget)
|
|
1614
|
+
setImmediate(() => this.mg(
|
|
1516
1615
|
`MATCH (t:Task {id: $taskId}) SET t.heliosIssueId = $issueId`,
|
|
1517
1616
|
{ taskId, issueId }
|
|
1518
|
-
).catch(e => log('warn', `Failed to store heliosIssueId on task ${taskId}: ${e.message}`));
|
|
1617
|
+
).catch(e => log('warn', `Failed to store heliosIssueId on task ${taskId}: ${e.message}`)));
|
|
1519
1618
|
return issueId;
|
|
1520
1619
|
} catch (e) {
|
|
1521
1620
|
log('warn', `_ensureHeliosIssue failed for task ${taskId}: ${e.message}`);
|
|
@@ -1647,28 +1746,55 @@ class AgentDispatcher {
|
|
|
1647
1746
|
const signalId = sigRow[0] ?? sigRow['signalId'];
|
|
1648
1747
|
const agentId = sigRow[1] ?? sigRow['agentId'];
|
|
1649
1748
|
let taskResult;
|
|
1749
|
+
// Hoist taskRow before try so it remains in scope at the outer if (!taskRow) check (T20-B)
|
|
1750
|
+
let taskRow = null;
|
|
1650
1751
|
try {
|
|
1651
|
-
//
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1752
|
+
// Memgraph primary — SQLite is the fallback when Memgraph throws (e.g. unavailable)
|
|
1753
|
+
try {
|
|
1754
|
+
// CONFLICT 004: CEO inbox priority — P0 first, then P1, then OKRFeedback-origin, then by priority/createdAt
|
|
1755
|
+
taskResult = await this.mg(
|
|
1756
|
+
`MATCH (t:Task {status: 'todo', companyId: $companyId, assigneeAgentId: $agentId})
|
|
1757
|
+
WHERE t.title IS NOT NULL
|
|
1758
|
+
WITH t,
|
|
1759
|
+
CASE
|
|
1760
|
+
WHEN toInteger(coalesce(t.priority, 3)) = 0 THEN 0
|
|
1761
|
+
WHEN toInteger(coalesce(t.priority, 3)) = 1 THEN 1
|
|
1762
|
+
WHEN t.originKind = 'okr_feedback' THEN 2
|
|
1763
|
+
ELSE toInteger(coalesce(t.priority, 3)) + 2
|
|
1764
|
+
END AS sortPriority
|
|
1765
|
+
RETURN t.id AS taskId, t.title AS title, t.originKind AS originKind, t.body AS body, t.priority AS priority
|
|
1766
|
+
ORDER BY sortPriority ASC, t.createdAt ASC
|
|
1767
|
+
LIMIT 1`,
|
|
1768
|
+
{ companyId: this.companyId, agentId }
|
|
1769
|
+
);
|
|
1770
|
+
taskRow = taskResult?.rows?.[0];
|
|
1771
|
+
} catch (mgErr) {
|
|
1772
|
+
// Memgraph unavailable — fall back to SQLite
|
|
1773
|
+
if (hboStore.getTasksByCompanyStatus) {
|
|
1774
|
+
const storeTodos = hboStore.getTasksByCompanyStatus(this.companyId, 'todo')
|
|
1775
|
+
.filter(t => t.assigneeAgentId === agentId && t.title);
|
|
1776
|
+
storeTodos.sort((a, b) => {
|
|
1777
|
+
const _sortPri = t => {
|
|
1778
|
+
const p = parseInt(t.priority ?? 3, 10);
|
|
1779
|
+
if (p === 0) return 0;
|
|
1780
|
+
if (p === 1) return 1;
|
|
1781
|
+
if (t.originKind === 'okr_feedback') return 2;
|
|
1782
|
+
return (isNaN(p) ? 3 : p) + 2;
|
|
1783
|
+
};
|
|
1784
|
+
const diff = _sortPri(a) - _sortPri(b);
|
|
1785
|
+
return diff !== 0 ? diff : (a.createdAt ?? 0) - (b.createdAt ?? 0);
|
|
1786
|
+
});
|
|
1787
|
+
const best = storeTodos.find(t => !claimedTaskIds.has(t.id));
|
|
1788
|
+
if (best) {
|
|
1789
|
+
taskRow = [best.id, best.title, best.originKind ?? null, best.body ?? null, best.priority ?? 3];
|
|
1790
|
+
log('info', `AgentDispatcher: using SQLite fallback for task lookup (Memgraph unavailable): ${mgErr.message}`);
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1667
1794
|
} catch (err) {
|
|
1668
1795
|
log('warn', `AgentDispatcher: task lookup failed for agent ${agentId}: ${err.message}`);
|
|
1669
1796
|
continue;
|
|
1670
1797
|
}
|
|
1671
|
-
const taskRow = taskResult?.rows?.[0];
|
|
1672
1798
|
if (!taskRow) continue;
|
|
1673
1799
|
const taskId = taskRow[0] ?? taskRow['taskId'];
|
|
1674
1800
|
const title = taskRow[1] ?? taskRow['title'];
|
|
@@ -1816,9 +1942,10 @@ class AgentDispatcher {
|
|
|
1816
1942
|
|
|
1817
1943
|
try {
|
|
1818
1944
|
// Non-blocking dispatch: run task in background so tick doesn't stall.
|
|
1819
|
-
// TaskCompletionWatchdog
|
|
1820
|
-
//
|
|
1821
|
-
|
|
1945
|
+
// TaskCompletionWatchdog reverts non-TUI tasks after taskTimeoutMs (default 30min).
|
|
1946
|
+
// TUI tasks are reverted after 30min (hardcoded in watchdog).
|
|
1947
|
+
// This Promise.race adds 60s grace above the configured timeout.
|
|
1948
|
+
const TASK_DISPATCH_TIMEOUT_MS = (_daemonConfig.taskTimeoutMs ?? 1800000) + 60000; // taskTimeout + 60s grace
|
|
1822
1949
|
// TUI-09: pass pre-built contextBrief so TuiWakeupAdapter.execute() skips its own
|
|
1823
1950
|
// buildContextBrief() call — prevents the expensive double-query on this path.
|
|
1824
1951
|
const taskPromise = adapter.execute(adapterContext, _adapterContextBrief);
|
|
@@ -2245,6 +2372,10 @@ class AgentDispatcher {
|
|
|
2245
2372
|
).catch(err => log('warn', `Failed to clear currentTaskId for ${agentId}: ${err.message}`));
|
|
2246
2373
|
// Pull-dispatch: agent is free — emit AgentReadySignal so next task can be dispatched.
|
|
2247
2374
|
await this._emitAgentReadySignal(agentId);
|
|
2375
|
+
// DSP-01: push task.updated SSE event for the direct-spawn completion path.
|
|
2376
|
+
// Uses this._daemonBroadcast (closure passed at construction) — NOT daemon._broadcast
|
|
2377
|
+
// which is not in scope inside AgentDispatcher.
|
|
2378
|
+
this._daemonBroadcast?.({ type: 'task.updated', taskId, status: newStatus, companyId: this.companyId });
|
|
2248
2379
|
log('info', `Task ${taskId} ${newStatus} (exit code: ${code})`);
|
|
2249
2380
|
} catch (err) {
|
|
2250
2381
|
log('warn', `Failed to update task ${taskId} completion: ${err.message}`);
|
|
@@ -2604,10 +2735,15 @@ class CostEventSyncer {
|
|
|
2604
2735
|
}
|
|
2605
2736
|
|
|
2606
2737
|
class TaskCompletionWatchdog {
|
|
2607
|
-
constructor(mgQuery, companyId) {
|
|
2738
|
+
constructor(mgQuery, companyId, taskTimeoutMs = 1800000) {
|
|
2608
2739
|
if (!companyId) throw new Error('TaskCompletionWatchdog: companyId required');
|
|
2609
2740
|
this.mg = mgQuery;
|
|
2610
2741
|
this.companyId = companyId;
|
|
2742
|
+
// Build ISO 8601 duration string for Cypher — passed as a parameter to avoid
|
|
2743
|
+
// hardcoded duration literals. 1800000ms → "PT1800S" (30 minutes default).
|
|
2744
|
+
// This is the non-TUI task timeout; TUI tasks always use the hardcoded PT30M path.
|
|
2745
|
+
this._nonTuiTimeoutDuration = `PT${Math.floor(taskTimeoutMs / 1000)}S`;
|
|
2746
|
+
this._nonTuiTimeoutMins = Math.round(taskTimeoutMs / 60000);
|
|
2611
2747
|
}
|
|
2612
2748
|
|
|
2613
2749
|
/**
|
|
@@ -2615,7 +2751,11 @@ class TaskCompletionWatchdog {
|
|
|
2615
2751
|
* Tournament winner: Candidate C — consistent with andon-tier1.js pattern,
|
|
2616
2752
|
* Memgraph-safe, no MERGE key uniqueness dependency.
|
|
2617
2753
|
*/
|
|
2618
|
-
|
|
2754
|
+
async _emitTaskTimeoutSignal(taskId, agentId) {
|
|
2755
|
+
// Build message in JavaScript before the Cypher call — avoids nested backtick
|
|
2756
|
+
// template literals (the outer Cypher string is a backtick; inner backtick would
|
|
2757
|
+
// close it prematurely). Passing as $message param is cleaner and correct.
|
|
2758
|
+
const _timeoutMsg = `Task exceeded ${this._nonTuiTimeoutMins}-minute completion timeout — reverted to todo`;
|
|
2619
2759
|
try {
|
|
2620
2760
|
await this.mg(
|
|
2621
2761
|
`OPTIONAL MATCH (existing:AnomalySignal {taskId: $taskId, signalType: 'TASK_TIMEOUT', status: 'open'})
|
|
@@ -2630,12 +2770,12 @@ class TaskCompletionWatchdog {
|
|
|
2630
2770
|
source: 'watchdog',
|
|
2631
2771
|
signalType: 'TASK_TIMEOUT',
|
|
2632
2772
|
severity: 'P1',
|
|
2633
|
-
|
|
2773
|
+
message: $message,
|
|
2634
2774
|
detectedAt: datetime(),
|
|
2635
2775
|
status: 'open'
|
|
2636
2776
|
})`,
|
|
2637
|
-
{ taskId, agentId: agentId ?? 'unknown', cid: this.companyId }
|
|
2638
|
-
);
|
|
2777
|
+
{ taskId, agentId: agentId ?? 'unknown', cid: this.companyId, message: _timeoutMsg }
|
|
2778
|
+
);
|
|
2639
2779
|
} catch (err) {
|
|
2640
2780
|
// Log at warn — a silent failure here leaves the Andon board stale.
|
|
2641
2781
|
const msg = JSON.stringify({ ts: new Date().toISOString(), level: 'warn', module: 'TaskCompletionWatchdog', msg: `TASK_TIMEOUT AnomalySignal write failed for ${taskId}: ${err.message}` });
|
|
@@ -2648,10 +2788,10 @@ class TaskCompletionWatchdog {
|
|
|
2648
2788
|
// TUI tasks can take >5 min (model probe + execution). Reverting them causes infinite cycling.
|
|
2649
2789
|
const stale = await this.mg(
|
|
2650
2790
|
`MATCH (t:Task {status: 'in_progress', companyId: $cid})
|
|
2651
|
-
WHERE t.executionLockedAt < datetime() - duration(
|
|
2791
|
+
WHERE t.executionLockedAt < datetime() - duration($timeout)
|
|
2652
2792
|
AND (t.dispatchedViaTUI IS NULL OR t.dispatchedViaTUI = false)
|
|
2653
2793
|
RETURN t.id, t.title, t.executionAgentId`,
|
|
2654
|
-
{ cid: this.companyId }
|
|
2794
|
+
{ cid: this.companyId, timeout: this._nonTuiTimeoutDuration }
|
|
2655
2795
|
);
|
|
2656
2796
|
|
|
2657
2797
|
const rows = stale?.rows ?? [];
|
|
@@ -2669,10 +2809,14 @@ class TaskCompletionWatchdog {
|
|
|
2669
2809
|
{ taskId }
|
|
2670
2810
|
);
|
|
2671
2811
|
|
|
2812
|
+
// Sync SQLite fallback store — keeps hbo-core.db consistent when Memgraph is unavailable.
|
|
2813
|
+
// Memgraph is primary; SQLite is the fallback path only (AgentDispatcher line 1724).
|
|
2814
|
+
try { hboStore.updateTask(taskId, this.companyId, { status: 'todo', executionLockedAt: null, executionAgentId: null }); } catch (_) {}
|
|
2815
|
+
|
|
2672
2816
|
// Preserve PDSACycle with abandon decision — losing the PDSA context on
|
|
2673
2817
|
// revert would break the learning loop. Mark as abandoned so PDSACompletion
|
|
2674
2818
|
// doesn't re-process it and KnowledgeAsset extraction is skipped correctly.
|
|
2675
|
-
await this.
|
|
2819
|
+
await this.mg(
|
|
2676
2820
|
`MATCH (p:PDSACycle {taskId: $taskId})
|
|
2677
2821
|
WHERE p.actDecision = 'iterate' OR p.actDecision IS NULL
|
|
2678
2822
|
SET p.actDecision = 'abandon',
|
|
@@ -2691,7 +2835,28 @@ class TaskCompletionWatchdog {
|
|
|
2691
2835
|
// OPTIONAL MATCH dedup guard prevents duplicate signals for the same task.
|
|
2692
2836
|
await this._emitTaskTimeoutSignal(taskId, agentId);
|
|
2693
2837
|
|
|
2694
|
-
log('warn', `Task ${taskId} timed out after
|
|
2838
|
+
log('warn', `Task ${taskId} timed out after ${this._nonTuiTimeoutMins}min — reverting to todo`);
|
|
2839
|
+
|
|
2840
|
+
// Re-emit AgentReadySignal so the agent can be dispatched again on the next tick.
|
|
2841
|
+
// Without this, the agent stays idle forever after a timeout event.
|
|
2842
|
+
// Uses the same idempotent OPTIONAL MATCH pattern as AgentDispatcher._emitAgentReadySignal.
|
|
2843
|
+
if (agentId) {
|
|
2844
|
+
await this.mg(
|
|
2845
|
+
`MATCH (a:BusinessAgent {id: $agentId, companyId: $cid, status: 'active'})
|
|
2846
|
+
OPTIONAL MATCH (existing:AgentReadySignal {agentId: $agentId, companyId: $cid, status: 'pending'})
|
|
2847
|
+
WITH a, existing
|
|
2848
|
+
WHERE existing IS NULL
|
|
2849
|
+
CREATE (s:AgentReadySignal {
|
|
2850
|
+
id: randomUUID(),
|
|
2851
|
+
agentId: $agentId,
|
|
2852
|
+
companyId: $cid,
|
|
2853
|
+
status: 'pending',
|
|
2854
|
+
claimedBy: null,
|
|
2855
|
+
createdAt: datetime()
|
|
2856
|
+
})`,
|
|
2857
|
+
{ agentId, cid: this.companyId }
|
|
2858
|
+
).catch(() => {});
|
|
2859
|
+
}
|
|
2695
2860
|
}
|
|
2696
2861
|
|
|
2697
2862
|
// Timeout TUI-dispatched tasks stuck >30min
|
|
@@ -2710,8 +2875,11 @@ class TaskCompletionWatchdog {
|
|
|
2710
2875
|
`MATCH (t:Task {id: $taskId}) SET t.status = 'todo', t.executionLockedAt = null, t.executionAgentId = null, t.dispatchedViaTUI = null, t.heliosRunId = null, t.timedOutAt = datetime() RETURN t.id`,
|
|
2711
2876
|
{ taskId }
|
|
2712
2877
|
);
|
|
2878
|
+
|
|
2879
|
+
// Sync SQLite fallback store — also null dispatchedViaTUI and heliosRunId for TUI tasks.
|
|
2880
|
+
try { hboStore.updateTask(taskId, this.companyId, { status: 'todo', executionLockedAt: null, executionAgentId: null, dispatchedViaTUI: null, heliosRunId: null }); } catch (_) {}
|
|
2713
2881
|
// Preserve PDSACycle with abandon decision for TUI-timed-out tasks.
|
|
2714
|
-
await this.
|
|
2882
|
+
await this.mg(
|
|
2715
2883
|
`MATCH (p:PDSACycle {taskId: $taskId})
|
|
2716
2884
|
WHERE p.actDecision = 'iterate' OR p.actDecision IS NULL
|
|
2717
2885
|
SET p.actDecision = 'abandon',
|
|
@@ -2723,6 +2891,25 @@ class TaskCompletionWatchdog {
|
|
|
2723
2891
|
// Also emit for TUI-timed-out tasks (30-min threshold variant).
|
|
2724
2892
|
await this._emitTaskTimeoutSignal(taskId, agentId);
|
|
2725
2893
|
log('warn', `TaskCompletionWatchdog: TUI task ${taskId} timed out after 30min — reverting to todo`);
|
|
2894
|
+
|
|
2895
|
+
// Re-emit AgentReadySignal so the agent can be dispatched again on the next tick.
|
|
2896
|
+
if (agentId) {
|
|
2897
|
+
await this.mg(
|
|
2898
|
+
`MATCH (a:BusinessAgent {id: $agentId, companyId: $cid, status: 'active'})
|
|
2899
|
+
OPTIONAL MATCH (existing:AgentReadySignal {agentId: $agentId, companyId: $cid, status: 'pending'})
|
|
2900
|
+
WITH a, existing
|
|
2901
|
+
WHERE existing IS NULL
|
|
2902
|
+
CREATE (s:AgentReadySignal {
|
|
2903
|
+
id: randomUUID(),
|
|
2904
|
+
agentId: $agentId,
|
|
2905
|
+
companyId: $cid,
|
|
2906
|
+
status: 'pending',
|
|
2907
|
+
claimedBy: null,
|
|
2908
|
+
createdAt: datetime()
|
|
2909
|
+
})`,
|
|
2910
|
+
{ agentId, cid: this.companyId }
|
|
2911
|
+
).catch(() => {});
|
|
2912
|
+
}
|
|
2726
2913
|
}
|
|
2727
2914
|
}
|
|
2728
2915
|
}
|
|
@@ -2733,10 +2920,15 @@ class TaskCompletionWatchdog {
|
|
|
2733
2920
|
// Reverted twice (418b606f, 1bf902d0) — protected by __tests__/tui-integration.test.js
|
|
2734
2921
|
// See: .sisyphus/plans/talisman-daemon-wave8.md (EC-03)
|
|
2735
2922
|
class RunCompletionPoller {
|
|
2736
|
-
|
|
2923
|
+
// broadcastFn: optional closure — use (...args) => daemon._broadcast?.(...args) so the
|
|
2924
|
+
// live SSE function is resolved at call time (not at construction time, when it is still null).
|
|
2925
|
+
// completionProcessor: optional TaskCompletionProcessor instance for post-completion pipeline.
|
|
2926
|
+
constructor(mgQuery, companyId, broadcastFn, completionProcessor) {
|
|
2737
2927
|
if (!companyId) throw new Error('RunCompletionPoller: companyId required');
|
|
2738
2928
|
this.mg = mgQuery;
|
|
2739
2929
|
this.companyId = companyId;
|
|
2930
|
+
this._broadcastFn = typeof broadcastFn === 'function' ? broadcastFn : null;
|
|
2931
|
+
this._cp = completionProcessor ?? null;
|
|
2740
2932
|
}
|
|
2741
2933
|
|
|
2742
2934
|
async poll() {
|
|
@@ -2789,6 +2981,8 @@ class RunCompletionPoller {
|
|
|
2789
2981
|
const newStatus = (run.status === 'completed' || run.status === 'succeeded') ? 'done' : 'failed';
|
|
2790
2982
|
// FINDING-2 fix: criticalOp wrapper — revert to todo if markDone write fails,
|
|
2791
2983
|
// then re-emit AgentReadySignal so the agent is not permanently locked.
|
|
2984
|
+
// markDoneOk tracks success so downstream pipeline and broadcast are skipped on revert.
|
|
2985
|
+
let markDoneOk = true;
|
|
2792
2986
|
await criticalOp(
|
|
2793
2987
|
() => this.mg(
|
|
2794
2988
|
`MATCH (t:Task {id: $taskId})
|
|
@@ -2798,6 +2992,7 @@ class RunCompletionPoller {
|
|
|
2798
2992
|
),
|
|
2799
2993
|
{ module: 'RunCompletionPoller', operation: 'markDone', taskId, companyId: this.companyId }
|
|
2800
2994
|
).catch(async (markDoneErr) => {
|
|
2995
|
+
markDoneOk = false;
|
|
2801
2996
|
log('warn', `RunCompletionPoller: markDone failed for ${taskId} — reverting to todo: ${markDoneErr.message}`);
|
|
2802
2997
|
await this.mg(
|
|
2803
2998
|
`MATCH (t:Task {id: $taskId}) SET t.status = 'todo', t.executionLockedAt = null, t.executionAgentId = null, t.dispatchedViaTUI = null, t.heliosRunId = null`,
|
|
@@ -2805,11 +3000,26 @@ class RunCompletionPoller {
|
|
|
2805
3000
|
).catch(revertErr => log('warn', `RunCompletionPoller: failed reverting to todo for ${taskId}: ${revertErr.message}`));
|
|
2806
3001
|
await this._emitAgentReadySignal(agentId).catch(() => {});
|
|
2807
3002
|
});
|
|
3003
|
+
// If markDone was reverted, skip completion pipeline and broadcast — task is back in todo.
|
|
3004
|
+
if (!markDoneOk) continue;
|
|
2808
3005
|
await this.mg(
|
|
2809
3006
|
`MATCH (a:BusinessAgent {id: $agentId, companyId: $cid}) SET a.lastHeartbeatAt = toInteger(timestamp() / 1000)`,
|
|
2810
3007
|
{ agentId, cid: this.companyId }
|
|
2811
3008
|
).catch(err => log('warn', `RunCompletionPoller: failed to update heartbeat for ${agentId}: ${err.message}`));
|
|
2812
3009
|
log('info', `Task ${taskId} ${newStatus} (TUI run ${runId} → ${run.status})`);
|
|
3010
|
+
// RCP-01: run completion pipeline (TaskResult, OKR progress, CRM, PDSA) for TUI-completed tasks.
|
|
3011
|
+
if (newStatus === 'done' && this._cp) {
|
|
3012
|
+
this._cp.process(taskId, run.summary ?? '', {
|
|
3013
|
+
agentId,
|
|
3014
|
+
companyId: this.companyId,
|
|
3015
|
+
originKind: run.originKind ?? 'tui_wakeup',
|
|
3016
|
+
exitCode: 0,
|
|
3017
|
+
}).catch(e => log('warn', `RunCompletionPoller: completionProcessor failed for ${taskId}: ${e.message}`));
|
|
3018
|
+
}
|
|
3019
|
+
// RCP-02: push task.updated SSE event so the desktop UI updates reactively without polling.
|
|
3020
|
+
// _broadcastFn is a closure ((...args) => this._broadcast?.(...args)) set at start() time,
|
|
3021
|
+
// so it correctly resolves the live broadcast reference even if SSE server started after construction.
|
|
3022
|
+
this._broadcastFn?.({ type: 'task.updated', taskId, status: newStatus, companyId: this.companyId });
|
|
2813
3023
|
// TUI-01: emit AgentReadySignal so the agent picks up the next task immediately.
|
|
2814
3024
|
await this._emitAgentReadySignal(agentId).catch(() => {});
|
|
2815
3025
|
} catch (e) {
|
|
@@ -2868,12 +3078,26 @@ class ApprovalWatcher {
|
|
|
2868
3078
|
async check() {
|
|
2869
3079
|
let approved;
|
|
2870
3080
|
try {
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
3081
|
+
// Memgraph primary — SQLite fallback on unavailability
|
|
3082
|
+
try {
|
|
3083
|
+
approved = await this.mg(
|
|
3084
|
+
`MATCH (a:Approval {companyId: $cid, status: 'approved'})
|
|
3085
|
+
WHERE a.followUpTaskCreated IS NULL OR a.followUpTaskCreated = false
|
|
3086
|
+
RETURN a.id, a.title, a.requestedBy, a.type, a.strategyId`,
|
|
3087
|
+
{ cid: this.companyId }
|
|
3088
|
+
);
|
|
3089
|
+
} catch (mgErr) {
|
|
3090
|
+
// Memgraph unavailable — fall back to SQLite approvals
|
|
3091
|
+
if (hboStore.getApprovalsByCompanyStatus) {
|
|
3092
|
+
const storeApprovals = hboStore.getApprovalsByCompanyStatus(this.companyId, 'approved')
|
|
3093
|
+
.filter(a => !a.followUpTaskCreated);
|
|
3094
|
+
approved = {
|
|
3095
|
+
rows: storeApprovals.map(a => [a.id, a.title, a.requestedBy, a.type, a.strategyId ?? null]),
|
|
3096
|
+
keys: ['a.id', 'a.title', 'a.requestedBy', 'a.type', 'a.strategyId'],
|
|
3097
|
+
};
|
|
3098
|
+
log('info', `ApprovalWatcher: using SQLite fallback for approval lookup (Memgraph unavailable): ${mgErr.message}`);
|
|
3099
|
+
}
|
|
3100
|
+
}
|
|
2877
3101
|
} catch (err) {
|
|
2878
3102
|
log('warn', `ApprovalWatcher: query failed: ${err.message}`);
|
|
2879
3103
|
return;
|
|
@@ -2891,41 +3115,55 @@ class ApprovalWatcher {
|
|
|
2891
3115
|
const requestedBy = approval['a.requestedBy'] ?? 'agent:ceo';
|
|
2892
3116
|
const approvalType = approval['a.type'];
|
|
2893
3117
|
const taskId = `task:approval-followup:${approvalId}:${randomUUID()}`;
|
|
2894
|
-
|
|
2895
3118
|
try {
|
|
2896
3119
|
if (approvalType === 'budget_exceeded') {
|
|
2897
|
-
|
|
3120
|
+
// Non-blocking Memgraph side-effects — fire-and-forget so Memgraph downtime
|
|
3121
|
+
// does not prevent SQLite writes (task creation + approval update) below.
|
|
3122
|
+
const _mgThis = this.mg.bind(this);
|
|
3123
|
+
setImmediate(() => _mgThis(
|
|
2898
3124
|
`MATCH (bi:BudgetIncident {approvalId: $apId}) SET bi.status = 'resolved', bi.resolvedAt = datetime()`,
|
|
2899
3125
|
{ apId: approvalId }
|
|
2900
|
-
);
|
|
2901
|
-
|
|
3126
|
+
).catch(e => log('warn', `ApprovalWatcher: BudgetIncident resolve projection failed: ${e.message}`)));
|
|
3127
|
+
setImmediate(() => _mgThis(
|
|
2902
3128
|
`MATCH (a:BusinessAgent {id: $agentId, companyId: $cid}) WHERE a.pauseReason IN ['budget_exceeded', 'budget_exceeded_global'] SET a.status = 'active', a.pauseReason = null, a.resumedAt = datetime()`,
|
|
2903
3129
|
{ agentId: requestedBy, cid: this.companyId }
|
|
2904
|
-
);
|
|
3130
|
+
).catch(e => log('warn', `ApprovalWatcher: agent resume projection failed: ${e.message}`)));
|
|
2905
3131
|
log('info', `ApprovalWatcher: budget approval resolved — agent ${requestedBy} resumed`);
|
|
2906
3132
|
}
|
|
2907
3133
|
|
|
2908
3134
|
if (approvalType === 'strategy_proposal') {
|
|
2909
3135
|
const strategyId = approval['a.strategyId'];
|
|
2910
3136
|
if (strategyId) {
|
|
2911
|
-
|
|
3137
|
+
setImmediate(() => this.mg(
|
|
2912
3138
|
`MATCH (s:Strategy {id: $strategyId}) SET s.status = 'approved', s.approvedAt = datetime()`,
|
|
2913
3139
|
{ strategyId }
|
|
2914
|
-
);
|
|
3140
|
+
).catch(e => log('warn', `ApprovalWatcher: strategy approve projection failed: ${e.message}`)));
|
|
2915
3141
|
log('info', `ApprovalWatcher: strategy ${strategyId} approved`);
|
|
2916
3142
|
}
|
|
2917
|
-
}
|
|
3143
|
+
}
|
|
2918
3144
|
|
|
2919
|
-
|
|
3145
|
+
// SQLite-first task create (P2-5)
|
|
3146
|
+
try {
|
|
3147
|
+
hboStore.createTask({
|
|
3148
|
+
id: taskId, title: `Approval resolved: ${title}. Execute the approved plan.`,
|
|
3149
|
+
status: 'todo', assigneeAgentId: requestedBy, companyId: this.companyId,
|
|
3150
|
+
originKind: 'approval_resolved', approvalId, progressPropagated: false, createdAt: Date.now(),
|
|
3151
|
+
});
|
|
3152
|
+
} catch (_) {}
|
|
3153
|
+
// Non-blocking Memgraph projection (fire-and-forget)
|
|
3154
|
+
setImmediate(() => this.mg(
|
|
2920
3155
|
`MERGE (t:Task {id: $taskId}) SET t.title = $title, t.status = 'todo',
|
|
2921
3156
|
t.assigneeAgentId = $agentId, t.companyId = $cid, t.originKind = 'approval_resolved',
|
|
2922
3157
|
t.approvalId = $approvalId, t.progressPropagated = false, t.createdAt = datetime()`,
|
|
2923
3158
|
{ taskId, title: `Approval resolved: ${title}. Execute the approved plan.`, agentId: requestedBy, cid: this.companyId, approvalId }
|
|
2924
|
-
);
|
|
2925
|
-
|
|
3159
|
+
).catch(e => log('warn', `[daemon] Memgraph Task projection failed (non-fatal): ${e.message}`)));
|
|
3160
|
+
// SQLite-first approval update (P2-5)
|
|
3161
|
+
try { hboStore.updateApproval(approvalId, this.companyId, { followUpTaskCreated: true }); } catch (_) {}
|
|
3162
|
+
// Non-blocking Memgraph projection (fire-and-forget)
|
|
3163
|
+
setImmediate(() => this.mg(
|
|
2926
3164
|
`MATCH (a:Approval {id: $approvalId}) SET a.followUpTaskCreated = true`,
|
|
2927
3165
|
{ approvalId }
|
|
2928
|
-
);
|
|
3166
|
+
).catch(e => log('warn', `[daemon] Memgraph Approval projection failed (non-fatal): ${e.message}`)));
|
|
2929
3167
|
log('info', `ApprovalWatcher: created follow-up task ${taskId} for approval ${approvalId}`);
|
|
2930
3168
|
} catch (err) {
|
|
2931
3169
|
log('warn', `ApprovalWatcher: failed to create follow-up for ${approvalId}: ${err.message}`);
|
|
@@ -2952,7 +3190,7 @@ class ApprovalWatcher {
|
|
|
2952
3190
|
function buildForCompany(companyId, mgQueryAsync, opts) {
|
|
2953
3191
|
if (!companyId) throw new Error('buildForCompany: companyId required');
|
|
2954
3192
|
if (!mgQueryAsync) throw new Error('buildForCompany: mgQueryAsync required');
|
|
2955
|
-
const { rpcAdapter, companyConfig = null } = opts || {};
|
|
3193
|
+
const { rpcAdapter, companyConfig = null, broadcast = null } = opts || {};
|
|
2956
3194
|
const cid = companyId;
|
|
2957
3195
|
|
|
2958
3196
|
// Helper: safe require + construct, non-fatal
|
|
@@ -2983,10 +3221,10 @@ function buildForCompany(companyId, mgQueryAsync, opts) {
|
|
|
2983
3221
|
try { mods.costEventSyncer = new CostEventSyncer(mgQueryAsync, cid); }
|
|
2984
3222
|
catch (e) { log('warn', `[module-factory] CostEventSyncer init failed for ${cid}: ${e.message}`); mods.costEventSyncer = null; }
|
|
2985
3223
|
|
|
2986
|
-
try { mods.taskCompletionWatchdog = new TaskCompletionWatchdog(mgQueryAsync, cid); }
|
|
3224
|
+
try { mods.taskCompletionWatchdog = new TaskCompletionWatchdog(mgQueryAsync, cid, _daemonConfig.taskTimeoutMs ?? 1800000); }
|
|
2987
3225
|
catch (e) { log('warn', `[module-factory] TaskCompletionWatchdog init failed for ${cid}: ${e.message}`); mods.taskCompletionWatchdog = null; }
|
|
2988
3226
|
|
|
2989
|
-
try { mods.runCompletionPoller = new RunCompletionPoller(mgQueryAsync, cid); }
|
|
3227
|
+
try { mods.runCompletionPoller = new RunCompletionPoller(mgQueryAsync, cid, typeof broadcast === 'function' ? (...args) => broadcast(...args) : null, new TaskCompletionProcessor({ mgQuery: mgQueryAsync })); }
|
|
2990
3228
|
catch (e) { log('warn', `[module-factory] RunCompletionPoller init failed for ${cid}: ${e.message}`); mods.runCompletionPoller = null; }
|
|
2991
3229
|
|
|
2992
3230
|
try { mods.activityLogger = new ActivityLogger(mgQueryAsync, cid); }
|
|
@@ -3052,9 +3290,13 @@ function buildForCompany(companyId, mgQueryAsync, opts) {
|
|
|
3052
3290
|
mods.sacrificeDeclaration = safeNew('SacrificeDeclaration', './lib/harada/sacrifice-declaration', 'SacrificeDeclaration', mgQueryAsync, cid);
|
|
3053
3291
|
|
|
3054
3292
|
try {
|
|
3293
|
+
// MirrorPatternScan runs on a P7D (weekly) wall-clock schedule via WallClockScheduler
|
|
3294
|
+
// in addition to the per-10-tasks trigger below. Weekly cadence ensures agents who
|
|
3295
|
+
// complete few tasks still receive periodic mirror feedback.
|
|
3055
3296
|
const { KataSessionPrompt, MasteryCheck, MirrorPatternScan } = require('./lib/harada/sensei');
|
|
3056
3297
|
mods.kataSessionPrompt = new KataSessionPrompt(mgQueryAsync, cid);
|
|
3057
3298
|
mods.masteryCheck = new MasteryCheck(mgQueryAsync, cid);
|
|
3299
|
+
// P7D weekly wall-clock guard: MirrorPatternScan is also scheduled weekly via WallClockScheduler
|
|
3058
3300
|
mods.mirrorPatternScan = new MirrorPatternScan(mgQueryAsync, cid);
|
|
3059
3301
|
} catch (e) {
|
|
3060
3302
|
log('warn', `[module-factory] Harada Sensei init failed for ${cid}: ${e.message}`);
|
|
@@ -3210,7 +3452,7 @@ class HeliosCompanyDaemon {
|
|
|
3210
3452
|
const cid = cfg.company?.id || cfg.companyName;
|
|
3211
3453
|
if (!cid) continue;
|
|
3212
3454
|
try {
|
|
3213
|
-
const mods = buildForCompany(cid, this._mgQueryAsync.bind(this), { rpcAdapter: this._rpcAdapter, companyConfig: cfg });
|
|
3455
|
+
const mods = buildForCompany(cid, this._mgQueryAsync.bind(this), { rpcAdapter: this._rpcAdapter, companyConfig: cfg, broadcast: (...args) => this._broadcast?.(...args) });
|
|
3214
3456
|
this._modulesByCompany.set(cid, mods);
|
|
3215
3457
|
log('info', `_initModules: per-company modules built for '${cid}'`);
|
|
3216
3458
|
} catch (e) {
|
|
@@ -3255,9 +3497,62 @@ class HeliosCompanyDaemon {
|
|
|
3255
3497
|
const mods = buildForCompany(cid, this._mgQueryAsync.bind(this), {
|
|
3256
3498
|
rpcAdapter: this._rpcAdapter,
|
|
3257
3499
|
companyConfig: null, // not available for runtime-added companies
|
|
3500
|
+
broadcast: (...args) => this._broadcast?.(...args),
|
|
3258
3501
|
});
|
|
3259
3502
|
this._modulesByCompany.set(cid, mods);
|
|
3260
3503
|
log('info', `[registerCompany] per-company modules live for '${cid}'`);
|
|
3504
|
+
|
|
3505
|
+
// Wire the SSE broadcast function into HBOBridge now that the API server is running.
|
|
3506
|
+
// buildForCompany passes companyConfig (null) as the 4th arg to HBOBridge — not the
|
|
3507
|
+
// broadcast function — so _bc is null after construction. setBroadcast() sets it here,
|
|
3508
|
+
// enabling wizard:pillars_ready and other real-time events to reach the desktop.
|
|
3509
|
+
if (mods.hboBridge && typeof mods.hboBridge.setBroadcast === 'function' && typeof this._broadcast === 'function') {
|
|
3510
|
+
mods.hboBridge.setBroadcast(this._broadcast);
|
|
3511
|
+
log('info', `[registerCompany] HBOBridge broadcast wired for '${cid}'`);
|
|
3512
|
+
}
|
|
3513
|
+
|
|
3514
|
+
// Trigger GoalPillar creation immediately after modules are registered.
|
|
3515
|
+
// tickGoalDecompose creates the 8 GoalPillar + 64 ActionCell nodes via MandalaManager.
|
|
3516
|
+
// This must run AFTER _modulesByCompany.set() so mods.hboBridge is available.
|
|
3517
|
+
// This is the correct point to run it — after wizard completes and registerCompany is
|
|
3518
|
+
// called from the desktop, _modulesByCompany is guaranteed to have this company's entry.
|
|
3519
|
+
if (mods.hboBridge && typeof mods.hboBridge.tickGoalDecompose === 'function') {
|
|
3520
|
+
setImmediate(async () => {
|
|
3521
|
+
try {
|
|
3522
|
+
await mods.hboBridge.tickGoalDecompose({ fromWizard: true });
|
|
3523
|
+
log('info', `[registerCompany] tickGoalDecompose complete for '${cid}'`);
|
|
3524
|
+
// Emit wizard:pillars_ready via SSE bridge if broadcast is wired.
|
|
3525
|
+
if (typeof this._broadcast === 'function') {
|
|
3526
|
+
this._broadcast({
|
|
3527
|
+
type: 'wizard:pillars_ready',
|
|
3528
|
+
companyId: cid,
|
|
3529
|
+
pillarCount: 8,
|
|
3530
|
+
ts: Date.now(),
|
|
3531
|
+
});
|
|
3532
|
+
} else {
|
|
3533
|
+
// D1: _broadcast may not yet be wired if API server started after registerCompany
|
|
3534
|
+
// (startup race condition). Retry once after 1s before giving up.
|
|
3535
|
+
log('warn', `[registerCompany] wizard:pillars_ready not broadcast for '${cid}' — _broadcast not yet wired, retrying in 1s`);
|
|
3536
|
+
const self = this;
|
|
3537
|
+
setTimeout(() => {
|
|
3538
|
+
if (typeof self._broadcast === 'function') {
|
|
3539
|
+
self._broadcast({
|
|
3540
|
+
type: 'wizard:pillars_ready',
|
|
3541
|
+
companyId: cid,
|
|
3542
|
+
pillarCount: 8,
|
|
3543
|
+
ts: Date.now(),
|
|
3544
|
+
});
|
|
3545
|
+
log('info', `[registerCompany] wizard:pillars_ready broadcast on retry for '${cid}'`);
|
|
3546
|
+
} else {
|
|
3547
|
+
log('warn', `[registerCompany] wizard:pillars_ready not broadcast after retry for '${cid}' — SSE events will be missed`);
|
|
3548
|
+
}
|
|
3549
|
+
}, 1000);
|
|
3550
|
+
}
|
|
3551
|
+
} catch (e) {
|
|
3552
|
+
log('warn', `[registerCompany] tickGoalDecompose failed for '${cid}' (non-fatal): ${e.message}`);
|
|
3553
|
+
}
|
|
3554
|
+
});
|
|
3555
|
+
}
|
|
3261
3556
|
} catch (e) {
|
|
3262
3557
|
log('warn', `[registerCompany] buildForCompany failed for '${cid}': ${e.message}`);
|
|
3263
3558
|
throw e; // surface to the HTTP handler — caller can retry
|
|
@@ -4165,7 +4460,13 @@ class HeliosCompanyDaemon {
|
|
|
4165
4460
|
try {
|
|
4166
4461
|
const { verifyMemgraphConfig } = require('./lib/memgraph-verify');
|
|
4167
4462
|
const vr = await verifyMemgraphConfig(this._mgQueryAsync.bind(this));
|
|
4168
|
-
|
|
4463
|
+
if (vr.timestampUnit === 'microseconds') {
|
|
4464
|
+
log('info', `Memgraph config verified: ${vr.serverTime} (timestamp unit: ${vr.timestampUnit})`);
|
|
4465
|
+
} else {
|
|
4466
|
+
// Non-fatal: zombie-detection uses toInteger(timestamp()/1000) consistently
|
|
4467
|
+
// on both SET and GET, so the unit cancels out and comparisons remain correct.
|
|
4468
|
+
log('warn', `Memgraph config verified with non-standard timestamp unit: ${vr.timestampUnit} (server: ${vr.serverTime}) — zombie-detection unaffected`);
|
|
4469
|
+
}
|
|
4169
4470
|
} catch (e) {
|
|
4170
4471
|
const msg = String(e.message || e);
|
|
4171
4472
|
// Detect connection-level failures — Memgraph is not running yet
|
|
@@ -4198,24 +4499,31 @@ class HeliosCompanyDaemon {
|
|
|
4198
4499
|
}
|
|
4199
4500
|
}
|
|
4200
4501
|
|
|
4201
|
-
// ──
|
|
4202
|
-
// Starts the
|
|
4203
|
-
//
|
|
4204
|
-
// they reach the
|
|
4205
|
-
//
|
|
4502
|
+
// ── Helios Compression Server ─────────────────────────────────────────────
|
|
4503
|
+
// Starts the TypeScript compression sidecar (lib/compression/server.ts).
|
|
4504
|
+
// Compresses tool outputs, HEMA recall payloads, and HBO API responses
|
|
4505
|
+
// before they reach the LLM — reducing token cost by 40–85%.
|
|
4506
|
+
//
|
|
4507
|
+
// Non-fatal: if the server fails to start, the daemon continues without
|
|
4508
|
+
// compression. All compression call sites check getBaseUrl() === null and
|
|
4509
|
+
// skip compression gracefully. HBO still works, agents still function.
|
|
4510
|
+
// This matches the principle: compression improves the system but is not
|
|
4511
|
+
// a correctness dependency — every company's data remains accessible.
|
|
4206
4512
|
//
|
|
4207
|
-
// Auto-restarts on mid-session crash
|
|
4208
|
-
// getBaseUrl() is read at call time by all LLM path wrappers — they pick up
|
|
4209
|
-
// the new URL automatically after a proxy restart.
|
|
4513
|
+
// Auto-restarts on mid-session crash with exponential backoff.
|
|
4210
4514
|
try {
|
|
4211
4515
|
const { HeadroomProxyManager } = require('./lib/headroom-proxy-manager');
|
|
4212
4516
|
this._headroomProxy = HeadroomProxyManager.getInstance();
|
|
4213
4517
|
const hr = await this._headroomProxy.start();
|
|
4214
|
-
log('info', `
|
|
4518
|
+
log('info', `Helios Compression Server ready: ${hr.baseUrl}`);
|
|
4215
4519
|
} catch (e) {
|
|
4216
|
-
log('
|
|
4217
|
-
|
|
4218
|
-
|
|
4520
|
+
log('warn',
|
|
4521
|
+
`Helios Compression Server failed to start — daemon continues without compression.\n` +
|
|
4522
|
+
`Token costs will be higher until the server is fixed and the daemon restarts.\n` +
|
|
4523
|
+
`Error: ${e.message}`
|
|
4524
|
+
);
|
|
4525
|
+
// Do NOT exit — compression is an optimization, not a correctness dependency.
|
|
4526
|
+
// All call sites handle null baseUrl gracefully.
|
|
4219
4527
|
}
|
|
4220
4528
|
|
|
4221
4529
|
// ── Redis maxmemory verification (warn if unset) ──────────────────────────
|
|
@@ -4277,6 +4585,58 @@ class HeliosCompanyDaemon {
|
|
|
4277
4585
|
log('warn', `Daemon-online heartbeat reset failed (non-fatal): ${e.message}`);
|
|
4278
4586
|
}
|
|
4279
4587
|
|
|
4588
|
+
// ── Startup AgentReadySignal sweep — all companies ──────────────────────────
|
|
4589
|
+
// Emits a pending AgentReadySignal for every active agent that has neither
|
|
4590
|
+
// a pending signal nor a current in-progress task.
|
|
4591
|
+
//
|
|
4592
|
+
// Primary path: daemon restarts → signals emitted for idle agents → next tick
|
|
4593
|
+
// dispatches tasks to all active agents → no stranded agents after restart.
|
|
4594
|
+
//
|
|
4595
|
+
// Uses OPTIONAL MATCH ... WHERE existing IS NULL (idempotent, same pattern as
|
|
4596
|
+
// _emitAgentReadySignal). NOT EXISTS subqueries are not supported in Memgraph 3.
|
|
4597
|
+
try {
|
|
4598
|
+
let _sweepEmitted = 0;
|
|
4599
|
+
let _sweepAttempted = 0;
|
|
4600
|
+
for (const cfg of _allCompanyConfigs) {
|
|
4601
|
+
const cid = cfg.company?.id || cfg.companyName;
|
|
4602
|
+
if (!cid) continue;
|
|
4603
|
+
// Find active agents with no pending signal and no in-progress task.
|
|
4604
|
+
const _candidates = await this._mgQueryAsync(
|
|
4605
|
+
`MATCH (a:BusinessAgent {companyId: $cid, status: 'active'})
|
|
4606
|
+
OPTIONAL MATCH (sig:AgentReadySignal {agentId: a.id, companyId: $cid, status: 'pending'})
|
|
4607
|
+
OPTIONAL MATCH (wip:Task {assigneeAgentId: a.id, companyId: $cid})
|
|
4608
|
+
WHERE wip.status IN ['in_progress', 'andon_paused', 'help_pending']
|
|
4609
|
+
WITH a, sig, wip
|
|
4610
|
+
WHERE sig IS NULL AND wip IS NULL
|
|
4611
|
+
RETURN a.id AS agentId`,
|
|
4612
|
+
{ cid }
|
|
4613
|
+
).catch(() => null);
|
|
4614
|
+
for (const row of (_candidates?.rows ?? [])) {
|
|
4615
|
+
const agentId = Array.isArray(row) ? row[0] : row?.agentId;
|
|
4616
|
+
if (!agentId) continue;
|
|
4617
|
+
_sweepAttempted++;
|
|
4618
|
+
await this._mgQueryAsync(
|
|
4619
|
+
`MATCH (a:BusinessAgent {id: $agentId, companyId: $cid, status: 'active'})
|
|
4620
|
+
OPTIONAL MATCH (existing:AgentReadySignal {agentId: $agentId, companyId: $cid, status: 'pending'})
|
|
4621
|
+
WITH a, existing
|
|
4622
|
+
WHERE existing IS NULL
|
|
4623
|
+
CREATE (s:AgentReadySignal {
|
|
4624
|
+
id: randomUUID(),
|
|
4625
|
+
agentId: $agentId,
|
|
4626
|
+
companyId: $cid,
|
|
4627
|
+
status: 'pending',
|
|
4628
|
+
claimedBy: null,
|
|
4629
|
+
createdAt: datetime()
|
|
4630
|
+
})`,
|
|
4631
|
+
{ agentId, cid }
|
|
4632
|
+
).then(() => { _sweepEmitted++; }).catch(() => {});
|
|
4633
|
+
}
|
|
4634
|
+
}
|
|
4635
|
+
log('info', `Startup AgentReadySignal sweep: ${_sweepEmitted}/${_sweepAttempted} signals emitted for ${_allCompanyConfigs.length} company(ies)`);
|
|
4636
|
+
} catch (e) {
|
|
4637
|
+
log('warn', `Startup AgentReadySignal sweep failed (non-fatal): ${e.message}`);
|
|
4638
|
+
}
|
|
4639
|
+
|
|
4280
4640
|
// ── CV-T fix: reset stale in-progress RoutineRun nodes on restart ─────────
|
|
4281
4641
|
// RoutineRun nodes with status 'queued' or 'running' from a previous daemon
|
|
4282
4642
|
// session are permanently stuck — the process that started them is gone.
|
|
@@ -4467,10 +4827,24 @@ class HeliosCompanyDaemon {
|
|
|
4467
4827
|
registry.register('tui_wakeup', new TuiWakeupAdapter(heliosConfig, this._mgQueryAsync.bind(this), _companyConfig.agents ?? []));
|
|
4468
4828
|
registry.register('process', new ProcessAdapter());
|
|
4469
4829
|
|
|
4470
|
-
this._agentDispatcher = new AgentDispatcher(
|
|
4830
|
+
this._agentDispatcher = new AgentDispatcher(
|
|
4831
|
+
this._mgQueryAsync.bind(this),
|
|
4832
|
+
primaryCompanyId,
|
|
4833
|
+
null, // spawnFn
|
|
4834
|
+
null, // _testConfig
|
|
4835
|
+
registry,
|
|
4836
|
+
(...args) => this._broadcast?.(...args) // broadcastFn — closure so live ref resolved at call time
|
|
4837
|
+
);
|
|
4471
4838
|
this._activityLogger = new ActivityLogger(this._mgQueryAsync.bind(this), primaryCompanyId);
|
|
4472
|
-
this._taskCompletionWatchdog = new TaskCompletionWatchdog(this._mgQueryAsync.bind(this), primaryCompanyId);
|
|
4473
|
-
|
|
4839
|
+
this._taskCompletionWatchdog = new TaskCompletionWatchdog(this._mgQueryAsync.bind(this), primaryCompanyId, _daemonConfig.taskTimeoutMs ?? 1800000);
|
|
4840
|
+
// Pass a closure for broadcastFn so it resolves daemon._broadcast at call time (not at construction).
|
|
4841
|
+
// Pass _agentDispatcher._completionProcessor so TUI-completed tasks run the full completion pipeline.
|
|
4842
|
+
this._runCompletionPoller = new RunCompletionPoller(
|
|
4843
|
+
this._mgQueryAsync.bind(this),
|
|
4844
|
+
primaryCompanyId,
|
|
4845
|
+
(...args) => this._broadcast?.(...args),
|
|
4846
|
+
this._agentDispatcher._completionProcessor ?? null
|
|
4847
|
+
);
|
|
4474
4848
|
this._costEventSyncer = new CostEventSyncer(this._mgQueryAsync.bind(this), primaryCompanyId);
|
|
4475
4849
|
this._nodeCleaner = new NodeCleaner(this._mgQueryAsync.bind(this));
|
|
4476
4850
|
this._mageAnalytics = new MageAnalytics(this._mgQueryAsync.bind(this));
|
|
@@ -5190,6 +5564,24 @@ if (require.main === module) {
|
|
|
5190
5564
|
daemon._apiUpdateTick = updateTick;
|
|
5191
5565
|
}
|
|
5192
5566
|
daemon._broadcast = broadcast;
|
|
5567
|
+
|
|
5568
|
+
// Wire the broadcast function into every per-company HBOBridge instance.
|
|
5569
|
+
// buildForCompany() receives companyConfig (not the broadcast fn) as its 4th arg,
|
|
5570
|
+
// so _bc is null after _initModules() runs. We fix that here, immediately after
|
|
5571
|
+
// the API server starts and broadcast becomes available.
|
|
5572
|
+
// Without this, all startup-company HBOBridge instances have _bc = null for
|
|
5573
|
+
// the entire daemon lifetime — no real-time SSE events (approval.created,
|
|
5574
|
+
// wizard:pillars_ready, etc.) can be pushed to connected desktop clients.
|
|
5575
|
+
for (const [, mods] of daemon._modulesByCompany || []) {
|
|
5576
|
+
if (mods?.hboBridge && typeof mods.hboBridge.setBroadcast === 'function') {
|
|
5577
|
+
mods.hboBridge.setBroadcast(broadcast);
|
|
5578
|
+
}
|
|
5579
|
+
}
|
|
5580
|
+
// Also wire the legacy single-company bridge (used when _modulesByCompany is absent).
|
|
5581
|
+
if (daemon._hboBridge && typeof daemon._hboBridge.setBroadcast === 'function') {
|
|
5582
|
+
daemon._hboBridge.setBroadcast(broadcast);
|
|
5583
|
+
}
|
|
5584
|
+
log('info', `[startApi] HBOBridge broadcast wired for ${(daemon._modulesByCompany || new Map()).size} company(ies)`);
|
|
5193
5585
|
const apiPort = _daemonConfig.apiPort ?? 9093;
|
|
5194
5586
|
// Store the bound port on the daemon instance so _writeHealthFile() can include it.
|
|
5195
5587
|
// This makes the port visible in daemon-health.json for operators and monitoring tools.
|
|
@@ -5208,5 +5600,5 @@ if (require.main === module) {
|
|
|
5208
5600
|
|
|
5209
5601
|
// Export classes for testing
|
|
5210
5602
|
if (typeof module !== 'undefined') {
|
|
5211
|
-
module.exports = { HeliosCompanyDaemon, RoutineEvaluator, LivenessWatchdog, BudgetEnforcer, AgentDispatcher, ActivityLogger, TaskCompletionWatchdog, CostEventSyncer, NodeCleaner, requireRunId, MageAnalytics, _allCompanyConfigs, buildForCompany };
|
|
5603
|
+
module.exports = { HeliosCompanyDaemon, RoutineEvaluator, LivenessWatchdog, BudgetEnforcer, AgentDispatcher, ApprovalWatcher, ActivityLogger, TaskCompletionWatchdog, CostEventSyncer, NodeCleaner, requireRunId, MageAnalytics, _allCompanyConfigs, buildForCompany };
|
|
5212
5604
|
}
|