triflux 10.44.0 → 10.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/README.md +1 -1
  2. package/adapters/codex/skills/tfx-harness/SKILL.md +45 -9
  3. package/bin/tfx-live.mjs +752 -77
  4. package/bin/triflux.mjs +9 -6
  5. package/hooks/codex-session-hook.mjs +67 -1
  6. package/hooks/cto-north-star-brief.mjs +3 -16
  7. package/hooks/error-context.mjs +3 -6
  8. package/hooks/hook-manager.mjs +1 -1
  9. package/hooks/hook-registry.json +0 -11
  10. package/hooks/keyword-rules.json +8 -4
  11. package/hooks/permission-safe-allow.mjs +36 -2
  12. package/hooks/session-start-fast.mjs +50 -1
  13. package/hooks/session-start-lake.mjs +3 -16
  14. package/hub/bridge.mjs +111 -5
  15. package/hub/gemini-adapter.mjs +29 -11
  16. package/hub/lib/memory-store.mjs +66 -30
  17. package/hub/lib/tfx-route-args.mjs +41 -4
  18. package/hub/pipe.mjs +10 -15
  19. package/hub/reflexion.mjs +0 -57
  20. package/hub/router.mjs +202 -24
  21. package/hub/schema.sql +1 -0
  22. package/hub/server.mjs +172 -93
  23. package/hub/store.mjs +106 -33
  24. package/hub/team/backend.mjs +4 -2
  25. package/hub/team/claude-daemon-control.mjs +2 -0
  26. package/hub/team/cli/commands/start/start-headless.mjs +2 -0
  27. package/hub/team/cli/commands/start/start-mux.mjs +24 -3
  28. package/hub/team/cli/services/runtime-mode.mjs +23 -9
  29. package/hub/team/codex-review.mjs +2 -3
  30. package/hub/team/conductor.mjs +80 -24
  31. package/hub/team/cto-auto-collect.mjs +2 -6
  32. package/hub/team/cto-pull.mjs +4 -0
  33. package/hub/team/dashboard-open.mjs +1 -0
  34. package/hub/team/execution-mode.mjs +38 -2
  35. package/hub/team/headless.mjs +493 -82
  36. package/hub/team/interactive-tui-transport.mjs +15 -1
  37. package/hub/team/launcher-template.mjs +19 -2
  38. package/hub/team/orchestrator.mjs +36 -9
  39. package/hub/team/pane.mjs +176 -14
  40. package/hub/team/psmux.mjs +79 -70
  41. package/hub/team/retry-state-machine.mjs +39 -16
  42. package/hub/team/session.mjs +57 -0
  43. package/hub/team/terminal-opener.mjs +68 -13
  44. package/hub/team/tui-viewer.mjs +65 -14
  45. package/hub/tools.mjs +6 -0
  46. package/hub/workers/codex-mcp.mjs +34 -5
  47. package/hud/cli-policy.mjs +69 -0
  48. package/hud/constants.mjs +20 -17
  49. package/hud/context-monitor.mjs +31 -21
  50. package/hud/hud-qos-status.mjs +81 -91
  51. package/hud/providers/claude.mjs +150 -84
  52. package/hud/providers/codex.mjs +10 -1
  53. package/hud/renderers.mjs +21 -59
  54. package/hud/utils.mjs +1 -15
  55. package/package.json +1 -1
  56. package/scripts/__tests__/keyword-detector.test.mjs +89 -1
  57. package/scripts/__tests__/release-governance.test.mjs +94 -1
  58. package/scripts/__tests__/skill-surface.test.mjs +6 -4
  59. package/scripts/__tests__/tfx-route-bash-node-parity.test.mjs +6 -4
  60. package/scripts/__tests__/tfx-route-node-entry.test.mjs +23 -17
  61. package/scripts/claude-login-detect.mjs +107 -25
  62. package/scripts/ensure-agy-hooks.mjs +31 -1
  63. package/scripts/keyword-detector.mjs +11 -1
  64. package/scripts/lib/agent-route-policy.mjs +29 -22
  65. package/scripts/lib/cli-agy.mjs +24 -4
  66. package/scripts/lib/codex-profile-config.mjs +26 -8
  67. package/scripts/lib/codex-recovery.sh +19 -0
  68. package/scripts/lib/gemini-profiles.mjs +113 -29
  69. package/scripts/lib/keyword-rules.mjs +12 -0
  70. package/scripts/lib/logger.mjs +60 -43
  71. package/scripts/lib/machine-profile.mjs +293 -0
  72. package/scripts/lib/mcp-health.mjs +44 -18
  73. package/scripts/notion-read.mjs +2 -1
  74. package/scripts/release/check-packages-mirror.mjs +53 -1
  75. package/scripts/release/lib.mjs +1 -2
  76. package/scripts/release/prepare.mjs +24 -9
  77. package/scripts/remote-spawn.mjs +830 -32
  78. package/scripts/session-stale-cleanup.mjs +0 -1
  79. package/scripts/setup.mjs +474 -143
  80. package/scripts/sync-hub-mcp-settings.mjs +11 -3
  81. package/scripts/test-lock.mjs +15 -1
  82. package/scripts/tfx-gate-activate.mjs +4 -4
  83. package/scripts/tfx-route-post.mjs +90 -13
  84. package/scripts/tfx-route.sh +502 -126
  85. package/skills/merge-worktree/SKILL.md +3 -3
  86. package/skills/star-prompt/skill.json +1 -1
  87. package/skills/tfx-analysis/SKILL.md +8 -9
  88. package/skills/tfx-analysis/skill.json +1 -10
  89. package/skills/tfx-auto/SKILL.md +203 -46
  90. package/skills/tfx-auto/skill.json +2 -23
  91. package/skills/tfx-doctor/skill.json +0 -3
  92. package/skills/tfx-find/skill.json +0 -7
  93. package/skills/tfx-forge/SKILL.md +2 -2
  94. package/skills/tfx-forge/skill.json +0 -7
  95. package/skills/tfx-goal-clarify/skill.json +5 -0
  96. package/skills/tfx-harness/SKILL.md +26 -3
  97. package/skills/tfx-harness/skill.json +5 -0
  98. package/skills/tfx-hooks/SKILL.md +0 -2
  99. package/skills/tfx-hooks/skill.json +0 -3
  100. package/skills/tfx-hub/SKILL.md +1 -6
  101. package/skills/tfx-hub/skill.json +0 -3
  102. package/skills/tfx-index/skill.json +0 -6
  103. package/skills/tfx-interview/SKILL.md +0 -2
  104. package/skills/tfx-live/SKILL.md +22 -1
  105. package/skills/tfx-live/skill.json +0 -5
  106. package/skills/tfx-plan/SKILL.md +7 -8
  107. package/skills/tfx-plan/skill.json +1 -12
  108. package/skills/tfx-profile/SKILL.md +6 -6
  109. package/skills/tfx-profile/skill.json +0 -3
  110. package/skills/tfx-prune/SKILL.md +4 -6
  111. package/skills/tfx-qa/SKILL.md +7 -7
  112. package/skills/tfx-qa/skill.json +1 -11
  113. package/skills/tfx-ralph/SKILL.md +8 -11
  114. package/skills/tfx-remote/SKILL.md +2 -2
  115. package/skills/tfx-remote/skill.json +0 -3
  116. package/skills/tfx-research/SKILL.md +2 -2
  117. package/skills/tfx-research/skill.json +1 -16
  118. package/skills/tfx-review/SKILL.md +11 -11
  119. package/skills/tfx-review/skill.json +1 -11
  120. package/skills/tfx-setup/SKILL.md +5 -6
  121. package/skills/tfx-setup/skill.json +0 -3
  122. package/skills/tfx-ship/skill.json +1 -9
  123. package/skills/tfx-wt/skill.json +0 -7
  124. package/tui/codex-profile.mjs +2 -1
  125. package/tui/gemini-profile.mjs +11 -24
  126. package/tui/monitor.mjs +1 -0
  127. package/tui/setup.mjs +3 -3
  128. package/scripts/headless-guard-fast.sh +0 -29
  129. package/scripts/headless-guard.mjs +0 -523
  130. package/skills/tfx-autopilot/SKILL.md +0 -51
  131. package/skills/tfx-autopilot/skill.json +0 -12
  132. package/skills/tfx-codex-swarm-workspace/iteration-1/benchmark.json +0 -75
  133. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/eval_metadata.json +0 -42
  134. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/with_skill/grading.json +0 -39
  135. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/with_skill/outputs/analysis.md +0 -87
  136. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/with_skill/outputs/classification.md +0 -35
  137. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/with_skill/outputs/commands.sh +0 -275
  138. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/with_skill/outputs/routing.md +0 -56
  139. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/with_skill/timing.json +0 -5
  140. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/without_skill/grading.json +0 -39
  141. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/without_skill/outputs/analysis.md +0 -92
  142. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/without_skill/outputs/classification.md +0 -71
  143. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/without_skill/outputs/commands.sh +0 -264
  144. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/without_skill/outputs/routing.md +0 -113
  145. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/without_skill/timing.json +0 -5
  146. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/eval_metadata.json +0 -32
  147. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/with_skill/grading.json +0 -29
  148. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/with_skill/outputs/analysis.md +0 -96
  149. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/with_skill/outputs/classification.md +0 -38
  150. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/with_skill/outputs/commands.sh +0 -151
  151. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/with_skill/outputs/routing.md +0 -51
  152. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/with_skill/timing.json +0 -5
  153. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/without_skill/grading.json +0 -29
  154. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/without_skill/outputs/analysis.md +0 -127
  155. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/without_skill/outputs/classification.md +0 -57
  156. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/without_skill/outputs/commands.sh +0 -129
  157. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/without_skill/outputs/routing.md +0 -84
  158. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/without_skill/timing.json +0 -5
  159. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/eval_metadata.json +0 -27
  160. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/with_skill/grading.json +0 -24
  161. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/with_skill/outputs/analysis.md +0 -98
  162. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/with_skill/outputs/classification.md +0 -65
  163. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/with_skill/outputs/commands.sh +0 -123
  164. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/with_skill/outputs/routing.md +0 -66
  165. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/with_skill/timing.json +0 -5
  166. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/without_skill/grading.json +0 -20
  167. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/without_skill/outputs/analysis.md +0 -88
  168. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/without_skill/outputs/classification.md +0 -40
  169. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/without_skill/outputs/commands.sh +0 -130
  170. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/without_skill/outputs/routing.md +0 -61
  171. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/without_skill/timing.json +0 -5
  172. package/skills/tfx-consensus/SKILL.md +0 -56
  173. package/skills/tfx-consensus/skill.json +0 -8
  174. package/skills/tfx-debate/SKILL.md +0 -60
  175. package/skills/tfx-debate/skill.json +0 -14
  176. package/skills/tfx-fullcycle/SKILL.md +0 -47
  177. package/skills/tfx-fullcycle/skill.json +0 -13
  178. package/skills/tfx-multi/SKILL.md +0 -45
  179. package/skills/tfx-multi/references/agent-wrapper-rules.md +0 -94
  180. package/skills/tfx-multi/references/thorough-pipeline.md +0 -105
  181. package/skills/tfx-multi/skill.json +0 -10
  182. package/skills/tfx-panel/SKILL.md +0 -60
  183. package/skills/tfx-panel/skill.json +0 -14
  184. package/skills/tfx-persist/SKILL.md +0 -63
  185. package/skills/tfx-persist/skill.json +0 -14
  186. package/skills/tfx-psmux-rules/SKILL.md +0 -41
  187. package/skills/tfx-psmux-rules/skill.json +0 -10
  188. package/skills/tfx-remote-setup/SKILL.md +0 -42
  189. package/skills/tfx-remote-setup/skill.json +0 -10
  190. package/skills/tfx-remote-spawn/SKILL.md +0 -52
  191. package/skills/tfx-remote-spawn/skill.json +0 -10
  192. package/skills/tfx-swarm/SKILL.md +0 -49
  193. package/skills/tfx-swarm/skill.json +0 -13
package/hub/server.mjs CHANGED
@@ -961,6 +961,7 @@ function servePublicFile(res, path) {
961
961
  * @param {string} [opts.host]
962
962
  * @param {string|number} [opts.sessionId]
963
963
  * @param {(options: { cwd: string }) => object} [opts.createDelegatorWorker]
964
+ * @param {(dbPath: string) => object|Promise<object>} [opts.createStoreAdapterFn] test seam
964
965
  */
965
966
  export async function startHub({
966
967
  port: portOpt,
@@ -968,6 +969,7 @@ export async function startHub({
968
969
  host = "127.0.0.1",
969
970
  sessionId = process.pid,
970
971
  createDelegatorWorker = createDelegatorMcpWorker,
972
+ createStoreAdapterFn = createStoreAdapter,
971
973
  } = {}) {
972
974
  const resolvedPort = parseHubPort(portOpt);
973
975
  const portSpecified = Number.isFinite(resolvedPort);
@@ -1062,7 +1064,7 @@ export async function startHub({
1062
1064
  cleanupOwnedTokenFile({ token: HUB_TOKEN, port });
1063
1065
  }
1064
1066
 
1065
- const store = await createStoreAdapter(dbPath);
1067
+ const store = await createStoreAdapterFn(dbPath);
1066
1068
  const router = createRouter(store);
1067
1069
  const fingerprintService = createAdaptiveFingerprintService({ store });
1068
1070
 
@@ -1720,6 +1722,8 @@ export async function startHub({
1720
1722
  agent_id,
1721
1723
  reason = "manual",
1722
1724
  requested_by = "http",
1725
+ hub_instance_id,
1726
+ store_fingerprint,
1723
1727
  } = body;
1724
1728
  if (!role || !agent_id) {
1725
1729
  return writeJson(res, 400, {
@@ -1732,6 +1736,8 @@ export async function startHub({
1732
1736
  agent_id,
1733
1737
  reason,
1734
1738
  requested_by,
1739
+ hub_instance_id,
1740
+ store_fingerprint,
1735
1741
  });
1736
1742
  return writeJson(res, result.ok ? 200 : 409, result);
1737
1743
  }
@@ -1748,6 +1754,8 @@ export async function startHub({
1748
1754
  session_id,
1749
1755
  host_id,
1750
1756
  transport_locators_json,
1757
+ hub_instance_id,
1758
+ store_fingerprint,
1751
1759
  } = body;
1752
1760
  if (!agent_id || !cli) {
1753
1761
  return writeJson(res, 400, {
@@ -1782,6 +1790,8 @@ export async function startHub({
1782
1790
  ...(transport_locators_json === undefined
1783
1791
  ? {}
1784
1792
  : { transport_locators_json }),
1793
+ ...(hub_instance_id === undefined ? {} : { hub_instance_id }),
1794
+ ...(store_fingerprint === undefined ? {} : { store_fingerprint }),
1785
1795
  });
1786
1796
  if (result.ok) {
1787
1797
  workerSpans.set(agent_id, {
@@ -1816,7 +1826,13 @@ export async function startHub({
1816
1826
  }
1817
1827
 
1818
1828
  if (path === "/bridge/heartbeat" && req.method === "POST") {
1819
- const { agent_id, ttl_ms } = body;
1829
+ const {
1830
+ agent_id,
1831
+ ttl_ms,
1832
+ presence_generation,
1833
+ hub_instance_id,
1834
+ store_fingerprint,
1835
+ } = body;
1820
1836
  if (!agent_id)
1821
1837
  return writeJson(res, 400, { ok: false, error: "agent_id 필수" });
1822
1838
  const result = await pipe.executeCommand("heartbeat", {
@@ -1825,6 +1841,9 @@ export async function startHub({
1825
1841
  ttl_ms,
1826
1842
  resolveWorkerLeaseTtlMs(),
1827
1843
  ),
1844
+ presence_generation,
1845
+ hub_instance_id,
1846
+ store_fingerprint,
1828
1847
  });
1829
1848
  return writeJson(res, result.ok ? 200 : 404, result);
1830
1849
  }
@@ -1905,6 +1924,9 @@ export async function startHub({
1905
1924
  status,
1906
1925
  include_metrics = true,
1907
1926
  trace_id,
1927
+ presence_generation,
1928
+ hub_instance_id,
1929
+ store_fingerprint,
1908
1930
  } = body;
1909
1931
 
1910
1932
  if (agent_id && status) {
@@ -1925,7 +1947,15 @@ export async function startHub({
1925
1947
  ]).has(normalizedStatus)
1926
1948
  ? normalizedStatus
1927
1949
  : "online";
1928
- router.updateAgentStatus(normalizedAgentId, statusForStore);
1950
+ const transition = router.transitionAgentStatus(
1951
+ normalizedAgentId,
1952
+ statusForStore,
1953
+ presence_generation,
1954
+ { hub_instance_id, store_fingerprint },
1955
+ );
1956
+ if (!transition.ok) {
1957
+ return writeJson(res, 409, transition);
1958
+ }
1929
1959
  const snapshot = await pipe.executeQuery("status", {
1930
1960
  scope: "agent",
1931
1961
  agent_id: normalizedAgentId,
@@ -1939,6 +1969,8 @@ export async function startHub({
1939
1969
  reported_status: normalizedStatus,
1940
1970
  reported_at_ms: Date.now(),
1941
1971
  snapshot: snapshot?.data?.agent || null,
1972
+ hub_instance_id: transition.data.hub_instance_id,
1973
+ store_fingerprint: transition.data.store_fingerprint,
1942
1974
  },
1943
1975
  });
1944
1976
  }
@@ -1948,6 +1980,8 @@ export async function startHub({
1948
1980
  agent_id,
1949
1981
  include_metrics,
1950
1982
  trace_id,
1983
+ hub_instance_id,
1984
+ store_fingerprint,
1951
1985
  });
1952
1986
  return writeJson(res, 200, result);
1953
1987
  }
@@ -2167,12 +2201,20 @@ export async function startHub({
2167
2201
  }
2168
2202
 
2169
2203
  if (path === "/bridge/deregister" && req.method === "POST") {
2170
- const { agent_id } = body;
2204
+ const {
2205
+ agent_id,
2206
+ presence_generation,
2207
+ hub_instance_id,
2208
+ store_fingerprint,
2209
+ } = body;
2171
2210
  if (!agent_id) {
2172
2211
  return writeJson(res, 400, { ok: false, error: "agent_id 필수" });
2173
2212
  }
2174
2213
  const result = await pipe.executeCommand("deregister", {
2175
2214
  agent_id,
2215
+ presence_generation,
2216
+ hub_instance_id,
2217
+ store_fingerprint,
2176
2218
  });
2177
2219
  if (result.ok) {
2178
2220
  finishWorkerTrace(agent_id, "deregister");
@@ -2316,102 +2358,107 @@ export async function startHub({
2316
2358
  httpServer.requestTimeout = 30000;
2317
2359
  httpServer.headersTimeout = 10000;
2318
2360
 
2319
- router.startSweeper();
2361
+ let hitlTimer = null;
2362
+ let sessionTimer = null;
2363
+ let synapsePruneTimer = null;
2364
+ let orphanCleanupTimer = null;
2365
+ let rateLimitTimer = null;
2320
2366
 
2321
- const hitlTimer = setInterval(() => {
2322
- try {
2323
- hitl.checkTimeouts();
2324
- } catch (err) {
2325
- hubLog.warn({ err }, "hitl.timeout_check_failed");
2326
- }
2327
- }, 10000);
2328
- hitlTimer.unref();
2329
-
2330
- // MCP session TTL: sessions idle for SESSION_TTL_MS are closed automatically.
2331
- // Configurable via SESSION_TTL_MS (default 30 minutes). The sweep runs every 60 s.
2332
- const SESSION_TTL_MS =
2333
- parseInt(process.env.TFX_SESSION_TTL_MS || "", 10) || 30 * 60 * 1000;
2334
- const sessionTimer = setInterval(() => {
2335
- const now = Date.now();
2336
- for (const [sid, session] of transports) {
2337
- if (now - (session.transport._lastActivity || 0) <= SESSION_TTL_MS)
2338
- continue;
2339
- void closeMcpTransportSession(sid, session, "session_ttl");
2340
- }
2341
- }, 60000);
2342
- sessionTimer.unref();
2367
+ const startBackgroundTimers = () => {
2368
+ hitlTimer = setInterval(() => {
2369
+ try {
2370
+ hitl.checkTimeouts();
2371
+ } catch (err) {
2372
+ hubLog.warn({ err }, "hitl.timeout_check_failed");
2373
+ }
2374
+ }, 10000);
2375
+ hitlTimer.unref();
2376
+
2377
+ // MCP session TTL: sessions idle for SESSION_TTL_MS are closed automatically.
2378
+ // Configurable via SESSION_TTL_MS (default 30 minutes). The sweep runs every 60 s.
2379
+ const SESSION_TTL_MS =
2380
+ parseInt(process.env.TFX_SESSION_TTL_MS || "", 10) || 30 * 60 * 1000;
2381
+ sessionTimer = setInterval(() => {
2382
+ const now = Date.now();
2383
+ for (const [sid, session] of transports) {
2384
+ if (now - (session.transport._lastActivity || 0) <= SESSION_TTL_MS)
2385
+ continue;
2386
+ void closeMcpTransportSession(sid, session, "session_ttl");
2387
+ }
2388
+ }, 60000);
2389
+ sessionTimer.unref();
2343
2390
 
2344
- const synapsePruneTimer = setInterval(() => {
2345
- try {
2346
- const { count } = synapseRegistry.pruneExpired();
2347
- if (count > 0) hubLog.info({ count }, "synapse.prune");
2348
- } catch (err) {
2349
- hubLog.warn({ err }, "synapse.prune_failed");
2350
- }
2351
- }, 60000);
2352
- synapsePruneTimer.unref();
2353
-
2354
- // 고아 node.exe 프로세스 + stale spawn 세션 주기적 정리 (5분마다)
2355
- // TFX_DISABLE_ORPHAN_CLEANUP=1 로 비활성 (active SSH/swarm 세션 보호 회피용 hotfix gate)
2356
- const orphanCleanupTimer = setInterval(
2357
- () => {
2358
- if (process.env.TFX_DISABLE_ORPHAN_CLEANUP === "1") return;
2391
+ synapsePruneTimer = setInterval(() => {
2359
2392
  try {
2360
- const { killed, killedProcesses = [] } = cleanupOrphanNodeProcesses();
2361
- const {
2362
- killed: runtimeKilled,
2363
- killedProcesses: runtimeKilledProcesses = [],
2364
- } = cleanupOrphanRuntimeProcesses();
2365
- const totalKilled = killed + runtimeKilled;
2366
- if (totalKilled > 0) {
2367
- hubLog.info(
2368
- {
2369
- killed: totalKilled,
2370
- processes: [...killedProcesses, ...runtimeKilledProcesses],
2371
- caller: "timer",
2372
- },
2373
- "hub.orphan_cleanup",
2374
- );
2375
- }
2393
+ const { count } = synapseRegistry.pruneExpired();
2394
+ if (count > 0) hubLog.info({ count }, "synapse.prune");
2395
+ } catch (err) {
2396
+ hubLog.warn({ err }, "synapse.prune_failed");
2397
+ }
2398
+ }, 60000);
2399
+ synapsePruneTimer.unref();
2400
+
2401
+ // 고아 node.exe 프로세스 + stale spawn 세션 주기적 정리 (5분마다)
2402
+ // TFX_DISABLE_ORPHAN_CLEANUP=1 로 비활성 (active SSH/swarm 세션 보호 회피용 hotfix gate)
2403
+ orphanCleanupTimer = setInterval(
2404
+ () => {
2405
+ if (process.env.TFX_DISABLE_ORPHAN_CLEANUP === "1") return;
2406
+ try {
2407
+ const { killed, killedProcesses = [] } = cleanupOrphanNodeProcesses();
2408
+ const {
2409
+ killed: runtimeKilled,
2410
+ killedProcesses: runtimeKilledProcesses = [],
2411
+ } = cleanupOrphanRuntimeProcesses();
2412
+ const totalKilled = killed + runtimeKilled;
2413
+ if (totalKilled > 0) {
2414
+ hubLog.info(
2415
+ {
2416
+ killed: totalKilled,
2417
+ processes: [...killedProcesses, ...runtimeKilledProcesses],
2418
+ caller: "timer",
2419
+ },
2420
+ "hub.orphan_cleanup",
2421
+ );
2422
+ }
2376
2423
 
2377
- const { killed: fsmonitorKilled, stale } = cleanupStaleFsmonitorDaemons(
2378
- {
2379
- minAgeMs: 24 * 60 * 60 * 1000,
2380
- },
2381
- );
2382
- if (fsmonitorKilled > 0) {
2383
- hubLog.info(
2384
- { killed: fsmonitorKilled, stale: stale.length },
2385
- "hub.fsmonitor_cleanup",
2386
- );
2387
- }
2388
- } catch {}
2424
+ const { killed: fsmonitorKilled, stale } =
2425
+ cleanupStaleFsmonitorDaemons({
2426
+ minAgeMs: 24 * 60 * 60 * 1000,
2427
+ });
2428
+ if (fsmonitorKilled > 0) {
2429
+ hubLog.info(
2430
+ { killed: fsmonitorKilled, stale: stale.length },
2431
+ "hub.fsmonitor_cleanup",
2432
+ );
2433
+ }
2434
+ } catch {}
2389
2435
 
2390
- // stale tfx-spawn-* psmux 세션 정리 (30분 이상 idle)
2391
- try {
2392
- const staleKilled = cleanupStaleSpawnSessions(hubLog);
2393
- if (staleKilled > 0) {
2394
- hubLog.info({ killed: staleKilled }, "hub.stale_spawn_cleanup");
2436
+ // stale tfx-spawn-* psmux 세션 정리 (30분 이상 idle)
2437
+ try {
2438
+ const staleKilled = cleanupStaleSpawnSessions(hubLog);
2439
+ if (staleKilled > 0) {
2440
+ hubLog.info({ killed: staleKilled }, "hub.stale_spawn_cleanup");
2441
+ }
2442
+ } catch {}
2443
+ },
2444
+ 5 * 60 * 1000,
2445
+ );
2446
+ orphanCleanupTimer.unref();
2447
+
2448
+ // Evict stale rate-limit buckets once per minute to bound memory usage.
2449
+ rateLimitTimer = setInterval(() => {
2450
+ const cutoff = Date.now() - RATE_LIMIT_WINDOW_MS;
2451
+ for (const [ip, timestamps] of rateLimitMap) {
2452
+ const fresh = timestamps.filter((t) => t >= cutoff);
2453
+ if (fresh.length === 0) {
2454
+ rateLimitMap.delete(ip);
2455
+ } else {
2456
+ rateLimitMap.set(ip, fresh);
2395
2457
  }
2396
- } catch {}
2397
- },
2398
- 5 * 60 * 1000,
2399
- );
2400
- orphanCleanupTimer.unref();
2401
-
2402
- // Evict stale rate-limit buckets once per minute to bound memory usage.
2403
- const rateLimitTimer = setInterval(() => {
2404
- const cutoff = Date.now() - RATE_LIMIT_WINDOW_MS;
2405
- for (const [ip, timestamps] of rateLimitMap) {
2406
- const fresh = timestamps.filter((t) => t >= cutoff);
2407
- if (fresh.length === 0) {
2408
- rateLimitMap.delete(ip);
2409
- } else {
2410
- rateLimitMap.set(ip, fresh);
2411
2458
  }
2412
- }
2413
- }, RATE_LIMIT_WINDOW_MS);
2414
- rateLimitTimer.unref();
2459
+ }, RATE_LIMIT_WINDOW_MS);
2460
+ rateLimitTimer.unref();
2461
+ };
2415
2462
 
2416
2463
  mkdirSync(PID_DIR, { recursive: true });
2417
2464
 
@@ -2419,6 +2466,19 @@ export async function startHub({
2419
2466
  try {
2420
2467
  router.stopSweeper();
2421
2468
  } catch {}
2469
+ for (const timer of [
2470
+ hitlTimer,
2471
+ sessionTimer,
2472
+ synapsePruneTimer,
2473
+ orphanCleanupTimer,
2474
+ rateLimitTimer,
2475
+ ]) {
2476
+ if (timer) clearInterval(timer);
2477
+ }
2478
+ for (const [sid, session] of Array.from(transports)) {
2479
+ await closeMcpTransportSession(sid, session, "hub.startup_failure");
2480
+ }
2481
+ transports.clear();
2422
2482
  try {
2423
2483
  await pipe.stop();
2424
2484
  } catch {}
@@ -2428,6 +2488,9 @@ export async function startHub({
2428
2488
  try {
2429
2489
  await delegatorWorker.stop();
2430
2490
  } catch {}
2491
+ try {
2492
+ synapseRegistry.destroy();
2493
+ } catch {}
2431
2494
  try {
2432
2495
  store.close();
2433
2496
  } catch {}
@@ -2438,6 +2501,22 @@ export async function startHub({
2438
2501
  };
2439
2502
 
2440
2503
  try {
2504
+ router.sweepExpired({ now_ms: Date.now(), source: "startup" });
2505
+ router.startSweeper({
2506
+ run_immediately: false,
2507
+ interval_ms: 10000,
2508
+ onSweepError: ({ source, error, consecutive_failures }) => {
2509
+ hubLog.warn(
2510
+ {
2511
+ source,
2512
+ code: error?.code || "UNKNOWN",
2513
+ consecutive_failures,
2514
+ },
2515
+ "hub.sweep_failed",
2516
+ );
2517
+ },
2518
+ });
2519
+ startBackgroundTimers();
2441
2520
  await pipe.start();
2442
2521
  await assignCallbacks.start();
2443
2522
  } catch (error) {
package/hub/store.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  // hub/store.mjs — SQLite 감사 로그/메타데이터/역할 레지스트리 저장소
2
2
  // 실시간 배달은 router/pipe가 담당하고, 역할 권한과 복구 상태는 SQLite가 보존한다.
3
3
 
4
+ import { createHash } from "node:crypto";
4
5
  import { mkdirSync, readFileSync } from "node:fs";
5
6
  import { createRequire } from "node:module";
6
7
  import { dirname, join } from "node:path";
@@ -119,6 +120,21 @@ function ensureV6Columns(db) {
119
120
  }
120
121
  }
121
122
 
123
+ function ensureV7Columns(db) {
124
+ if (hasTable(db, "agents")) {
125
+ ensureColumn(
126
+ db,
127
+ "agents",
128
+ "presence_generation",
129
+ "INTEGER NOT NULL DEFAULT 0",
130
+ );
131
+ }
132
+ }
133
+
134
+ function storeFingerprint(dbPath) {
135
+ return `sqlite:${createHash("sha256").update(String(dbPath)).digest("hex")}`;
136
+ }
137
+
122
138
  /**
123
139
  * 저장소 생성
124
140
  * @param {string} dbPath
@@ -149,7 +165,7 @@ export function createStore(dbPath, options = {}) {
149
165
  db.exec(
150
166
  "CREATE TABLE IF NOT EXISTS _meta (key TEXT PRIMARY KEY, value TEXT)",
151
167
  );
152
- const SCHEMA_VERSION = "6";
168
+ const SCHEMA_VERSION = "7";
153
169
  const curVer = (() => {
154
170
  try {
155
171
  return db
@@ -166,6 +182,7 @@ export function createStore(dbPath, options = {}) {
166
182
  // v5 테이블에는 schema.sql의 v6 인덱스가 참조하는 컬럼이 없으므로
167
183
  // CREATE IF NOT EXISTS 재실행 전에 멱등 컬럼 seam을 먼저 적용한다.
168
184
  ensureV6Columns(db);
185
+ ensureV7Columns(db);
169
186
  if (curVer !== SCHEMA_VERSION) {
170
187
  if (curVer != null) {
171
188
  // 이미 버전이 기록된 DB에서 버전 불일치가 발생한 경우 경고한다.
@@ -180,6 +197,7 @@ export function createStore(dbPath, options = {}) {
180
197
  }
181
198
  // 매 부팅 실행해 부분 적용된 마이그레이션도 안전하게 복구한다.
182
199
  ensureV6Columns(db);
200
+ ensureV7Columns(db);
183
201
  ensureColumn(
184
202
  db,
185
203
  "reflexion_entries",
@@ -196,8 +214,8 @@ export function createStore(dbPath, options = {}) {
196
214
 
197
215
  const S = {
198
216
  upsertAgent: db.prepare(`
199
- INSERT INTO agents (agent_id, cli, pid, capabilities_json, topics_json, last_seen_ms, lease_expires_ms, status, metadata_json)
200
- VALUES (@agent_id, @cli, @pid, @capabilities_json, @topics_json, @last_seen_ms, @lease_expires_ms, @status, @metadata_json)
217
+ INSERT INTO agents (agent_id, cli, pid, capabilities_json, topics_json, last_seen_ms, lease_expires_ms, presence_generation, status, metadata_json)
218
+ VALUES (@agent_id, @cli, @pid, @capabilities_json, @topics_json, @last_seen_ms, @lease_expires_ms, 1, @status, @metadata_json)
201
219
  ON CONFLICT(agent_id) DO UPDATE SET
202
220
  cli=excluded.cli,
203
221
  pid=excluded.pid,
@@ -205,6 +223,7 @@ export function createStore(dbPath, options = {}) {
205
223
  topics_json=excluded.topics_json,
206
224
  last_seen_ms=excluded.last_seen_ms,
207
225
  lease_expires_ms=excluded.lease_expires_ms,
226
+ presence_generation=agents.presence_generation + 1,
208
227
  status=excluded.status,
209
228
  metadata_json=excluded.metadata_json`),
210
229
  getAgent: db.prepare("SELECT * FROM agents WHERE agent_id = ?"),
@@ -212,19 +231,27 @@ export function createStore(dbPath, options = {}) {
212
231
  "UPDATE agents SET topics_json=?, last_seen_ms=? WHERE agent_id=?",
213
232
  ),
214
233
  heartbeat: db.prepare(
234
+ "UPDATE agents SET last_seen_ms=?, lease_expires_ms=?, status='online' WHERE agent_id=? AND presence_generation=?",
235
+ ),
236
+ heartbeatWithoutGeneration: db.prepare(
215
237
  "UPDATE agents SET last_seen_ms=?, lease_expires_ms=?, status='online' WHERE agent_id=?",
216
238
  ),
217
- setAgentStatus: db.prepare("UPDATE agents SET status=? WHERE agent_id=?"),
239
+ setAgentStatus: db.prepare(
240
+ "UPDATE agents SET status=? WHERE agent_id=? AND presence_generation=?",
241
+ ),
242
+ setAgentStatusWithoutGeneration: db.prepare(
243
+ "UPDATE agents SET status=? WHERE agent_id=?",
244
+ ),
218
245
  onlineAgents: db.prepare("SELECT * FROM agents WHERE status != 'offline'"),
219
246
  allAgents: db.prepare("SELECT * FROM agents"),
220
247
  agentsByTopic: db.prepare(
221
248
  "SELECT a.* FROM agents a, json_each(a.topics_json) t WHERE t.value=? AND a.status != 'offline'",
222
249
  ),
223
250
  markStale: db.prepare(
224
- "UPDATE agents SET status='stale' WHERE status='online' AND lease_expires_ms < ?",
251
+ "UPDATE agents SET status='stale' WHERE agent_id=? AND presence_generation=? AND status='online' AND lease_expires_ms < ?",
225
252
  ),
226
253
  markOffline: db.prepare(
227
- "UPDATE agents SET status='offline' WHERE status='stale' AND lease_expires_ms < ? - 300000",
254
+ "UPDATE agents SET status='offline' WHERE agent_id=? AND presence_generation=? AND status='stale' AND lease_expires_ms < ? - 300000",
228
255
  ),
229
256
 
230
257
  getRole: db.prepare("SELECT * FROM role_registry WHERE role_key_wire = ?"),
@@ -412,12 +439,9 @@ export function createStore(dbPath, options = {}) {
412
439
  "UPDATE human_requests SET state=?, response_json=? WHERE request_id=?",
413
440
  ),
414
441
  pendingHR: db.prepare("SELECT * FROM human_requests WHERE state='pending'"),
415
- expireHR: db.prepare(
416
- "UPDATE human_requests SET state='timed_out' WHERE state='pending' AND deadline_ms < ?",
417
- ),
418
442
 
419
443
  insertDL: db.prepare(
420
- "INSERT OR REPLACE INTO dead_letters (message_id, reason, failed_at_ms, last_error) VALUES (?,?,?,?)",
444
+ "INSERT INTO dead_letters (message_id, reason, failed_at_ms, last_error) VALUES (?,?,?,?) ON CONFLICT(message_id) DO NOTHING",
421
445
  ),
422
446
  getDL: db.prepare(
423
447
  "SELECT * FROM dead_letters ORDER BY failed_at_ms DESC LIMIT ?",
@@ -464,7 +488,13 @@ export function createStore(dbPath, options = {}) {
464
488
  WHERE job_id=@job_id`),
465
489
 
466
490
  findExpired: db.prepare(
467
- "SELECT id FROM messages WHERE status='queued' AND expires_at_ms < ?",
491
+ "SELECT * FROM messages WHERE status IN ('queued', 'delivered') AND expires_at_ms <= ?",
492
+ ),
493
+ claimExpired: db.prepare(
494
+ "UPDATE messages SET status='dead_letter' WHERE id=? AND status IN ('queued', 'delivered') AND expires_at_ms <= ?",
495
+ ),
496
+ claimDeadLetter: db.prepare(
497
+ "UPDATE messages SET status='dead_letter' WHERE id=? AND status != 'dead_letter'",
468
498
  ),
469
499
  urgentDepth: db.prepare(
470
500
  "SELECT COUNT(*) as cnt FROM messages WHERE status='queued' AND priority >= 7",
@@ -705,6 +735,8 @@ export function createStore(dbPath, options = {}) {
705
735
 
706
736
  const store = {
707
737
  db,
738
+ type: "sqlite",
739
+ fingerprint: storeFingerprint(dbPath),
708
740
  uuidv7,
709
741
 
710
742
  close() {
@@ -738,6 +770,7 @@ export function createStore(dbPath, options = {}) {
738
770
  return {
739
771
  agent_id,
740
772
  lease_id: leaseId,
773
+ presence_generation: persisted?.presence_generation ?? null,
741
774
  lease_expires_ms: leaseExpires,
742
775
  server_time_ms: now,
743
776
  effective: {
@@ -750,6 +783,7 @@ export function createStore(dbPath, options = {}) {
750
783
  lease_expires_ms: persisted?.lease_expires_ms ?? null,
751
784
  store_type: "sqlite",
752
785
  db_path: dbPath ?? null,
786
+ store_fingerprint: storeFingerprint(dbPath),
753
787
  },
754
788
  };
755
789
  },
@@ -758,9 +792,12 @@ export function createStore(dbPath, options = {}) {
758
792
  return parseAgentRow(S.getAgent.get(id));
759
793
  },
760
794
 
761
- refreshLease(agentId, ttlMs = 30000) {
795
+ refreshLease(agentId, ttlMs = 30000, presenceGeneration) {
762
796
  const now = Date.now();
763
- const write = S.heartbeat.run(now, now + ttlMs, agentId);
797
+ const write =
798
+ presenceGeneration == null
799
+ ? S.heartbeatWithoutGeneration.run(now, now + ttlMs, agentId)
800
+ : S.heartbeat.run(now, now + ttlMs, agentId, presenceGeneration);
764
801
  return {
765
802
  agent_id: agentId,
766
803
  lease_expires_ms: now + ttlMs,
@@ -769,6 +806,7 @@ export function createStore(dbPath, options = {}) {
769
806
  updated: write.changes > 0,
770
807
  store_type: "sqlite",
771
808
  db_path: dbPath ?? null,
809
+ store_fingerprint: storeFingerprint(dbPath),
772
810
  },
773
811
  };
774
812
  },
@@ -794,14 +832,40 @@ export function createStore(dbPath, options = {}) {
794
832
 
795
833
  sweepStaleAgents() {
796
834
  const now = Date.now();
835
+ const candidates = S.allAgents.all();
836
+ let stale = 0;
837
+ let offline = 0;
838
+ for (const candidate of candidates) {
839
+ if (candidate.status === "online" && candidate.lease_expires_ms < now) {
840
+ stale += S.markStale.run(
841
+ candidate.agent_id,
842
+ candidate.presence_generation,
843
+ now,
844
+ ).changes;
845
+ } else if (
846
+ candidate.status === "stale" &&
847
+ candidate.lease_expires_ms < now - 300000
848
+ ) {
849
+ offline += S.markOffline.run(
850
+ candidate.agent_id,
851
+ candidate.presence_generation,
852
+ now,
853
+ ).changes;
854
+ }
855
+ }
797
856
  return {
798
- stale: S.markStale.run(now).changes,
799
- offline: S.markOffline.run(now).changes,
857
+ stale,
858
+ offline,
800
859
  };
801
860
  },
802
861
 
803
- updateAgentStatus(agentId, status) {
804
- return S.setAgentStatus.run(status, agentId).changes > 0;
862
+ updateAgentStatus(agentId, status, presenceGeneration) {
863
+ return (
864
+ (presenceGeneration == null
865
+ ? S.setAgentStatusWithoutGeneration.run(status, agentId)
866
+ : S.setAgentStatus.run(status, agentId, presenceGeneration)
867
+ ).changes > 0
868
+ );
805
869
  },
806
870
 
807
871
  getRole(roleKeyWire) {
@@ -1179,16 +1243,14 @@ export function createStore(dbPath, options = {}) {
1179
1243
  return S.pendingHR.all().map(parseHumanRequestRow);
1180
1244
  },
1181
1245
 
1182
- expireHumanRequests() {
1183
- return S.expireHR.run(Date.now()).changes;
1184
- },
1185
-
1186
1246
  moveToDeadLetter(messageId, reason, lastError = null) {
1187
- db.transaction(() => {
1188
- S.setMsgStatus.run("dead_letter", messageId);
1189
- S.insertDL.run(messageId, reason, Date.now(), lastError);
1247
+ const failedAt = Date.now();
1248
+ return db.transaction(() => {
1249
+ const changed = S.claimDeadLetter.run(messageId).changes;
1250
+ if (!changed) return false;
1251
+ S.insertDL.run(messageId, reason, failedAt, lastError);
1252
+ return true;
1190
1253
  })();
1191
- return true;
1192
1254
  },
1193
1255
 
1194
1256
  getDeadLetters(limit = 50) {
@@ -1458,16 +1520,27 @@ export function createStore(dbPath, options = {}) {
1458
1520
  });
1459
1521
  },
1460
1522
 
1461
- sweepExpired() {
1462
- const now = Date.now();
1523
+ sweepExpired({ now_ms = Date.now() } = {}) {
1463
1524
  return db.transaction(() => {
1464
- const expired = S.findExpired.all(now);
1465
- for (const { id } of expired) {
1466
- S.setMsgStatus.run("dead_letter", id);
1467
- S.insertDL.run(id, "ttl_expired", now, null);
1525
+ const transitions = [];
1526
+ for (const row of S.findExpired.all(now_ms)) {
1527
+ if (!S.claimExpired.run(row.id, now_ms).changes) continue;
1528
+ options.beforeDeadLetterInsert?.({
1529
+ message_id: row.id,
1530
+ reason: "ttl_expired",
1531
+ failed_at_ms: now_ms,
1532
+ });
1533
+ const dlqInserted =
1534
+ S.insertDL.run(row.id, "ttl_expired", now_ms, null).changes === 1;
1535
+ transitions.push({
1536
+ message: parseMessageRow(row),
1537
+ previous_status: row.status,
1538
+ reason: "ttl_expired",
1539
+ failed_at_ms: now_ms,
1540
+ dlq_inserted: dlqInserted,
1541
+ });
1468
1542
  }
1469
- const humanRequests = S.expireHR.run(now).changes;
1470
- return { messages: expired.length, human_requests: humanRequests };
1543
+ return { now_ms, messages: transitions.length, transitions };
1471
1544
  })();
1472
1545
  },
1473
1546