@cgh567/agent 2.4.0 → 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.
Files changed (146) hide show
  1. package/bin/helios +0 -0
  2. package/bin/helios-rpc-node-wrapper.cjs +0 -0
  3. package/bin/helios-rpc-wrapper.sh +0 -0
  4. package/daemon/adapters/helios-rpc-adapter.js +47 -25
  5. package/daemon/config/com.familiar.helios-daemon.plist +5 -0
  6. package/daemon/config/helios-daemon.service +4 -0
  7. package/daemon/context-enrichment.js +59 -21
  8. package/daemon/helios-api.js +149 -37
  9. package/daemon/helios-company-daemon.js +516 -124
  10. package/daemon/lib/harada/cascade-judge.js +12 -50
  11. package/daemon/lib/harada/mandala.js +20 -0
  12. package/daemon/lib/harada/pillar-dispatcher.js +1 -1
  13. package/daemon/lib/harada/project-factory.js +7 -2
  14. package/daemon/lib/hbo-bridge.js +31 -12
  15. package/daemon/lib/helios-hitl-host.js +15 -2
  16. package/daemon/lib/hitl-interaction-service.js +0 -0
  17. package/daemon/lib/memgraph-verify.js +38 -33
  18. package/daemon/lib/project-drift-detector.js +7 -17
  19. package/daemon/lib/project-semantic-updater.js +1 -14
  20. package/daemon/routes/channels.js +10 -5
  21. package/daemon/routes/harada-map.js +11 -48
  22. package/daemon/routes/hbo.js +89 -28
  23. package/daemon/routes/hitl.js +0 -0
  24. package/daemon/routes/project.js +4 -3
  25. package/daemon/routes/wizard.js +11 -4
  26. package/daemon/schema-migrations-hitl.js +0 -0
  27. package/extensions/001-tool-output-cap.ts +0 -0
  28. package/extensions/context-compaction.ts +45 -26
  29. package/extensions/cortex/activation-bridge.ts +5 -0
  30. package/extensions/cortex/learn.ts +26 -0
  31. package/extensions/email/backfill.ts +0 -0
  32. package/extensions/helios-governance/analysis/ambiguity.ts +0 -0
  33. package/extensions/helios-governance/analysis/compliance.ts +0 -0
  34. package/extensions/helios-governance/analysis/long-task-detector.ts +0 -0
  35. package/extensions/helios-governance/analysis/output-contract.ts +0 -0
  36. package/extensions/helios-governance/analysis/patterns.ts +0 -0
  37. package/extensions/helios-governance/analysis/preflight.ts +0 -0
  38. package/extensions/helios-governance/analysis/recurring-violations.ts +0 -0
  39. package/extensions/helios-governance/analysis/task-classification.ts +0 -0
  40. package/extensions/helios-governance/analysis/task-intent.ts +0 -0
  41. package/extensions/helios-governance/gates/high-impact.ts +1 -1
  42. package/extensions/helios-governance/handlers/_jiti-require.ts +15 -8
  43. package/extensions/helios-governance/handlers/proxy-test-detector.ts +0 -0
  44. package/extensions/hema-dispatch-v3/graph-memory.ts +10 -0
  45. package/extensions/hema-dispatch-v3/index.ts +59 -40
  46. package/extensions/lib/elo-engine.js +0 -0
  47. package/extensions/lib/elo-engine.test.js +0 -0
  48. package/extensions/memgraph-autostart.ts +13 -0
  49. package/extensions/neuroplastic-eval.ts +0 -0
  50. package/extensions/shadow-loop/index.ts +0 -0
  51. package/lib/brain-v2-budget.js +0 -0
  52. package/lib/brain-v2-circuit-breaker.js +0 -0
  53. package/lib/brain-v2.js +0 -0
  54. package/lib/broker/adaptive-throttle.js +0 -0
  55. package/lib/broker/batch-coalescer.js +0 -0
  56. package/lib/broker/bulkhead.js +0 -0
  57. package/lib/broker/channel-registry.js +0 -0
  58. package/lib/broker/circuit-breaker.js +0 -0
  59. package/lib/broker/evidence-cache.js +0 -0
  60. package/lib/broker/health-monitor.js +0 -0
  61. package/lib/broker/mage-queue.js +0 -0
  62. package/lib/broker/priority-queue.js +0 -0
  63. package/lib/broker/server.js.bak-error2-fix +0 -0
  64. package/lib/broker/session-registry.js +0 -0
  65. package/lib/broker/singleton-timers.js +0 -0
  66. package/lib/broker/types.d.ts +0 -0
  67. package/lib/broker/vegas-limit.js +0 -0
  68. package/lib/compression/dist/ccr-store.js +74 -0
  69. package/lib/compression/dist/content-router.js +115 -0
  70. package/lib/compression/dist/pipeline.js +113 -0
  71. package/lib/compression/dist/server.js +265 -0
  72. package/lib/compression/dist/smart-crusher.js +251 -0
  73. package/lib/context-budget.ts +0 -0
  74. package/lib/context-firewall.js +0 -0
  75. package/lib/crm/integration/triage-bridge.js +0 -0
  76. package/lib/email-utils.ts +0 -0
  77. package/lib/eval/__tests__/preflight-checker.test.ts +0 -0
  78. package/lib/eval/__tests__/task-instruction-parser.test.ts +0 -0
  79. package/lib/eval/__tests__/verifier-runner.test.ts +0 -0
  80. package/lib/eval/index.ts +0 -0
  81. package/lib/eval/preflight-checker.ts +0 -0
  82. package/lib/eval/task-domain-classifier.ts +0 -0
  83. package/lib/eval/task-instruction-parser.ts +0 -0
  84. package/lib/eval/verifier-runner.ts +0 -0
  85. package/lib/event-bus.d.ts +0 -0
  86. package/lib/governance-context-selector.ts +0 -0
  87. package/lib/graph/generate-extension-embeddings.js +0 -0
  88. package/lib/graph/generate-static-embeddings.js +0 -0
  89. package/lib/graph/lib/utils.js +1 -1
  90. package/lib/graph-audit.d.ts +0 -0
  91. package/lib/mesh-circuit-breaker.js +0 -0
  92. package/lib/mission-loop/lesson-extractor.ts +0 -0
  93. package/lib/mission-loop/mental-model-scorer.ts +0 -0
  94. package/lib/mission-loop/occ-detector.ts +0 -0
  95. package/lib/mission-loop/query-variants.ts +0 -0
  96. package/lib/mission-loop/verifier-check.ts +0 -0
  97. package/lib/skill-reference-builder.ts +0 -0
  98. package/lib/telemetry/token-breakdown.ts +0 -0
  99. package/lib/tool-compressor.ts +0 -0
  100. package/lib/triage-core/legal-routing.ts +0 -0
  101. package/lib/triage-core/mental-model/dunbar-classifier.ts +0 -0
  102. package/lib/triage-core/mental-model/enrich-all.ts +0 -0
  103. package/lib/triage-core/mental-model/identity-resolver.ts +0 -0
  104. package/lib/triage-core/mental-model/key-facts.ts +0 -0
  105. package/lib/triage-core/mental-model/model-assembler.ts +0 -0
  106. package/lib/triage-core/orchestrator.ts +0 -0
  107. package/lib/triage-core/orchestrator.ts.bak-r005-r006-r008 +0 -0
  108. package/package.json +10 -4
  109. package/skills/helios-business-operator/services/signals/upwork-signals.js +0 -0
  110. package/skills/talisman-ceo/SKILL.md +23 -25
  111. package/skills/talisman-comms/SKILL.md +5 -5
  112. package/skills/talisman-engineering/SKILL.md +5 -5
  113. package/skills/talisman-finance/SKILL.md +10 -8
  114. package/skills/talisman-marketing/SKILL.md +10 -10
  115. package/skills/talisman-sales/SKILL.md +12 -15
  116. package/skills/talisman-support/SKILL.md +5 -5
  117. package/agents/business/talisman-ceo.md +0 -183
  118. package/agents/business/talisman-comms.md +0 -257
  119. package/agents/business/talisman-cto.md +0 -153
  120. package/agents/business/talisman-finance.md +0 -246
  121. package/agents/business/talisman-marketing.md +0 -240
  122. package/agents/business/talisman-sales.md +0 -242
  123. package/agents/business/talisman-support.md +0 -236
  124. package/daemon/lib/approval-expiry.js +0 -162
  125. package/daemon/lib/blast-radius-analyzer.js +0 -75
  126. package/daemon/lib/domain-bootstrap-orchestrator.js +0 -267
  127. package/daemon/lib/forensic-log.js +0 -113
  128. package/daemon/lib/goal-research-pipeline.js +0 -644
  129. package/daemon/lib/harada/cascade-research-dispatcher.js +0 -261
  130. package/daemon/lib/headroom-middleware.js +0 -167
  131. package/daemon/lib/headroom-proxy-manager.js +0 -623
  132. package/daemon/lib/hed-engine.js +0 -307
  133. package/daemon/lib/mental-model-cache.js +0 -96
  134. package/daemon/lib/project-factory.js +0 -47
  135. package/daemon/lib/session-log-reader.js +0 -93
  136. package/daemon/routes/hed.js +0 -133
  137. package/lib/graph/learning/headroom-learn-bridge.js +0 -215
  138. package/skills/helios-bookkeeping/SKILL.md +0 -321
  139. package/skills/helios-briefer/SKILL.md +0 -44
  140. package/skills/helios-client-relations/SKILL.md +0 -322
  141. package/skills/helios-personal-triager/SKILL.md +0 -45
  142. package/skills/helios-recruitment/SKILL.md +0 -317
  143. package/skills/helios-relationship-nudger/SKILL.md +0 -77
  144. package/skills/helios-researcher/SKILL.md +0 -44
  145. package/skills/helios-scheduler/SKILL.md +0 -58
  146. 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
- await mg.safeWrite(`
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
- // Retry up to 5 times (500ms apart) in case Memgraph snapshot isolation
1227
- // hasn't propagated a recently-committed write yet.
1228
- for (let attempt = 0; attempt < 5; attempt++) {
1229
- inProgress = await this.mg(query, params);
1230
- if (inProgress?.rows?.length > 0) break;
1231
- if (attempt < 4) await new Promise(r => setTimeout(r, 500));
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
- await this.mg(
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 (instant small node count)
1277
- const policiesResult = await this.mg(
1278
- `MATCH (bp:BudgetPolicy {companyId: $cid})
1279
- RETURN bp.id, bp.scope, bp.agentId, bp.limitCents, bp.warnPercent, bp.hardStopEnabled`,
1280
- { cid: this.companyId }
1281
- );
1282
-
1283
- const policyRows = policiesResult?.rows ?? [];
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
- const spendRows = await this.mg(
1313
- `MATCH (ce:CostEvent {companyId: $cid})
1314
- WHERE ce.createdAt >= datetime($start) AND ce.createdAt < datetime($end)
1315
- RETURN ce.agentId, sum(ce.costCents) as total`,
1316
- { cid: this.companyId, start: start.replace(/\.\d{3}Z$/, '+00:00'), end: end.replace(/\.\d{3}Z$/, '+00:00') }
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
- await this.mg(
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
- const existing = await this.mg(
1349
- `MATCH (bi:BudgetIncident {policyId: $pid, companyId: $cid, thresholdType: 'soft', status: 'open'}) RETURN bi.id LIMIT 1`,
1350
- { pid: id, cid: this.companyId }
1351
- );
1352
- if (!existing?.rows?.length) {
1353
- const incidentId = `bi:soft:${id}:${Date.now()}`;
1354
- await this.mg(
1355
- `CREATE (bi:BudgetIncident {id: $id, companyId: $cid, policyId: $pid, scopeType: $scopeType, scopeId: $scopeId, thresholdType: 'soft', amountLimit: $limit, amountObserved: $spent, status: 'open', createdAt: datetime()})`,
1356
- { id: incidentId, cid: this.companyId, pid: id, scopeType: scope, scopeId: agentId ?? scope, limit, spent }
1357
- );
1358
- log('info', `BudgetEnforcer: Soft budget incident created for ${agentId ?? 'global'}`, { pct, warnThreshold });
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
- await this.mg(
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
- await this.mg(
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 existing = await this.mg(
1494
- `MATCH (t:Task {id: $taskId}) WHERE t.heliosIssueId IS NOT NULL RETURN t.heliosIssueId`,
1495
- { taskId }
1496
- );
1497
- if (existing?.rows?.length) return existing.rows[0][0];
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
- // Store on task node
1515
- await this.mg(
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
- // CONFLICT 004: CEO inbox priority P0 first, then P1, then OKRFeedback-origin, then by priority/createdAt
1652
- taskResult = await this.mg(
1653
- `MATCH (t:Task {status: 'todo', companyId: $companyId, assigneeAgentId: $agentId})
1654
- WHERE t.title IS NOT NULL
1655
- WITH t,
1656
- CASE
1657
- WHEN toInteger(coalesce(t.priority, 3)) = 0 THEN 0
1658
- WHEN toInteger(coalesce(t.priority, 3)) = 1 THEN 1
1659
- WHEN t.originKind = 'okr_feedback' THEN 2
1660
- ELSE toInteger(coalesce(t.priority, 3)) + 2
1661
- END AS sortPriority
1662
- RETURN t.id AS taskId, t.title AS title, t.originKind AS originKind, t.body AS body, t.priority AS priority
1663
- ORDER BY sortPriority ASC, t.createdAt ASC
1664
- LIMIT 1`,
1665
- { companyId: this.companyId, agentId }
1666
- );
1752
+ // Memgraph primarySQLite 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 handles timeouts (reverts after taskTimeoutMs).
1820
- // This prevents a single long-running pi session from blocking all ticks.
1821
- const TASK_DISPATCH_TIMEOUT_MS = (_daemonConfig.taskTimeoutMs ?? 300000) + 60000; // taskTimeout + 60s grace
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
- async _emitTaskTimeoutSignal(taskId, agentId) {
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
- message: 'Task exceeded 5-minute completion timeout — reverted to todo',
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("PT5M")
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._mgQueryAsync(
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 5min — reverting to todo`);
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._mgQueryAsync(
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
- constructor(mgQuery, companyId) {
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
- approved = await this.mg(
2872
- `MATCH (a:Approval {companyId: $cid, status: 'approved'})
2873
- WHERE a.followUpTaskCreated IS NULL OR a.followUpTaskCreated = false
2874
- RETURN a.id, a.title, a.requestedBy, a.type, a.strategyId`,
2875
- { cid: this.companyId }
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
- await this.mg(
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
- await this.mg(
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
- await this.mg(
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
- await this.mg(
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
- await this.mg(
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
- log('info', `Memgraph config verified: ${vr.serverTime} (timestamp unit: ${vr.timestampUnit})`);
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
- // ── Headroom compression proxy (required service) ─────────────────────────
4202
- // Starts the local Headroom proxy sidecar that intercepts all LLM traffic and
4203
- // compresses tool outputs, HEMA recall payloads, and HBO API responses before
4204
- // they reach the model. This is a required service if it fails to start,
4205
- // the daemon exits with a clear error message (same pattern as verifyMemgraphConfig).
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 LLMreducing 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 (Option 1) with exponential backoff.
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', `Headroom compression proxy ready: ${hr.baseUrl}`);
4518
+ log('info', `Helios Compression Server ready: ${hr.baseUrl}`);
4215
4519
  } catch (e) {
4216
- log('error', `Headroom compression proxy FAILED to start:\n${e.message}`);
4217
- // Same comment as verifyMemgraphConfig: do NOT remove daemon.lock
4218
- process.exit(1);
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(this._mgQueryAsync.bind(this), primaryCompanyId, null, null, registry);
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
- this._runCompletionPoller = new RunCompletionPoller(this._mgQueryAsync.bind(this), primaryCompanyId);
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
  }