@triflux/remote 10.30.0 → 10.31.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.
- package/hub/server.mjs +11 -0
- package/hub/team/synapse-registry.mjs +83 -1
- package/hub/workers/codex-mcp.mjs +87 -3
- package/package.json +1 -1
package/hub/server.mjs
CHANGED
|
@@ -2054,6 +2054,16 @@ export async function startHub({
|
|
|
2054
2054
|
}, 60000);
|
|
2055
2055
|
sessionTimer.unref();
|
|
2056
2056
|
|
|
2057
|
+
const synapsePruneTimer = setInterval(() => {
|
|
2058
|
+
try {
|
|
2059
|
+
const { count } = synapseRegistry.pruneExpired();
|
|
2060
|
+
if (count > 0) hubLog.info({ count }, "synapse.prune");
|
|
2061
|
+
} catch (err) {
|
|
2062
|
+
hubLog.warn({ err }, "synapse.prune_failed");
|
|
2063
|
+
}
|
|
2064
|
+
}, 60000);
|
|
2065
|
+
synapsePruneTimer.unref();
|
|
2066
|
+
|
|
2057
2067
|
// 고아 node.exe 프로세스 + stale spawn 세션 주기적 정리 (5분마다)
|
|
2058
2068
|
// TFX_DISABLE_ORPHAN_CLEANUP=1 로 비활성 (active SSH/swarm 세션 보호 회피용 hotfix gate)
|
|
2059
2069
|
const orphanCleanupTimer = setInterval(
|
|
@@ -2288,6 +2298,7 @@ export async function startHub({
|
|
|
2288
2298
|
router.stopSweeper();
|
|
2289
2299
|
clearInterval(hitlTimer);
|
|
2290
2300
|
clearInterval(sessionTimer);
|
|
2301
|
+
clearInterval(synapsePruneTimer);
|
|
2291
2302
|
clearInterval(rateLimitTimer);
|
|
2292
2303
|
clearInterval(orphanCleanupTimer);
|
|
2293
2304
|
if (idleTimer) {
|
|
@@ -5,6 +5,7 @@ const DEFAULT_LOCAL_HEARTBEAT_INTERVAL_MS = 5_000;
|
|
|
5
5
|
const DEFAULT_LOCAL_TIMEOUT_MS = 30_000;
|
|
6
6
|
const DEFAULT_REMOTE_HEARTBEAT_INTERVAL_MS = 15_000;
|
|
7
7
|
const DEFAULT_REMOTE_TIMEOUT_MS = 90_000;
|
|
8
|
+
const DEFAULT_EXPIRE_TIMEOUT_MS = 24 * 60 * 60 * 1000;
|
|
8
9
|
// Interactive (Claude/Codex) sessions idle for long stretches; a 30s local
|
|
9
10
|
// timeout produces stale false-positives. 5-minute TTL + an `idle` state
|
|
10
11
|
// distinguishes "alive but inactive" from "presumed dead".
|
|
@@ -133,6 +134,7 @@ export function createSynapseRegistry(opts = {}) {
|
|
|
133
134
|
remoteTimeoutMs = DEFAULT_REMOTE_TIMEOUT_MS,
|
|
134
135
|
interactiveHeartbeatIntervalMs = DEFAULT_INTERACTIVE_HEARTBEAT_INTERVAL_MS,
|
|
135
136
|
interactiveTimeoutMs = DEFAULT_INTERACTIVE_TIMEOUT_MS,
|
|
137
|
+
expireTimeoutMs = DEFAULT_EXPIRE_TIMEOUT_MS,
|
|
136
138
|
} = opts;
|
|
137
139
|
|
|
138
140
|
const sessions = new Map();
|
|
@@ -355,10 +357,89 @@ export function createSynapseRegistry(opts = {}) {
|
|
|
355
357
|
return true;
|
|
356
358
|
}
|
|
357
359
|
|
|
360
|
+
// stale/expired 세션이 expireTimeoutMs 넘게 누적되면 Map에서 제거.
|
|
361
|
+
// live(active/idle)는 lastHeartbeat가 오래돼도 보존 — git-preflight dirty-file 가드가 의존.
|
|
362
|
+
function pruneExpired(opts2 = {}) {
|
|
363
|
+
if (destroyed) return { removed: [], count: 0 };
|
|
364
|
+
|
|
365
|
+
const cutoff =
|
|
366
|
+
typeof opts2.olderThanMs === "number"
|
|
367
|
+
? opts2.olderThanMs
|
|
368
|
+
: expireTimeoutMs;
|
|
369
|
+
const removed = [];
|
|
370
|
+
const currentTime = now();
|
|
371
|
+
|
|
372
|
+
for (const [sessionId, session] of sessions) {
|
|
373
|
+
if (
|
|
374
|
+
isLiveStatus(session.status) ||
|
|
375
|
+
currentTime - session.lastHeartbeat <= cutoff
|
|
376
|
+
) {
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
stopMonitor(sessionId);
|
|
381
|
+
sessions.delete(sessionId);
|
|
382
|
+
removed.push(sessionId);
|
|
383
|
+
notifyRemoved(session);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (removed.length > 0) persist();
|
|
387
|
+
return { removed, count: removed.length };
|
|
388
|
+
}
|
|
389
|
+
|
|
358
390
|
function heartbeat(sessionId, partialMeta = null) {
|
|
359
391
|
const normalized = normalizeSessionId(sessionId);
|
|
360
392
|
const session = sessions.get(normalized);
|
|
361
|
-
if (!session)
|
|
393
|
+
if (!session) {
|
|
394
|
+
// self-heal은 위치 정보(worktreePath 또는 cwd)가 있는 heartbeat에서만
|
|
395
|
+
// 발동한다. UserPromptSubmit heartbeat는 항상 worktreePath를 보내므로
|
|
396
|
+
// SessionStart register POST drop 복구 경로는 커버되고, taskSummary만 담은
|
|
397
|
+
// 빈약한 heartbeat로 위치 없는 가비지 세션이 register 되는 것은 막는다
|
|
398
|
+
// (그 경우는 기존대로 false → 400/heartbeat failed).
|
|
399
|
+
const hasLocator =
|
|
400
|
+
partialMeta &&
|
|
401
|
+
typeof partialMeta === "object" &&
|
|
402
|
+
!Array.isArray(partialMeta) &&
|
|
403
|
+
((typeof partialMeta.worktreePath === "string" &&
|
|
404
|
+
partialMeta.worktreePath.length > 0) ||
|
|
405
|
+
(typeof partialMeta.cwd === "string" && partialMeta.cwd.length > 0));
|
|
406
|
+
const canSelfHeal = Boolean(normalized) && hasLocator;
|
|
407
|
+
if (!canSelfHeal) return false;
|
|
408
|
+
|
|
409
|
+
// 미등록 세션 heartbeat = SessionStart register POST가 drop된 경우.
|
|
410
|
+
// self-heal로 register 복구(영구 미등록 방지).
|
|
411
|
+
//
|
|
412
|
+
// sessionKind는 명시값이 없으면 "interactive"로 본다. 실제 복구 경로인
|
|
413
|
+
// heartbeatInteractiveSession은 worktreePath/host/taskSummary만 보내고
|
|
414
|
+
// sessionKind는 생략하는데, sanitizeSession 기본값("headless")으로 떨어지면
|
|
415
|
+
// 복구된 Claude 세션이 30초 local timeout을 받아 idle/5분 TTL을 잃고 곧장
|
|
416
|
+
// stale로 전이돼 getActive()/peer discovery에서 사라진다(이 PRD가 고치려던
|
|
417
|
+
// 원래 증상의 재현). heartbeat 기반 liveness로 살아나는 세션은 interactive다.
|
|
418
|
+
const sanitizedKind =
|
|
419
|
+
typeof partialMeta.sessionKind === "string"
|
|
420
|
+
? partialMeta.sessionKind
|
|
421
|
+
: "interactive";
|
|
422
|
+
const healed = sanitizeSession(
|
|
423
|
+
{
|
|
424
|
+
...partialMeta,
|
|
425
|
+
sessionId: normalized,
|
|
426
|
+
sessionKind: sanitizedKind,
|
|
427
|
+
status: "active",
|
|
428
|
+
lastHeartbeat: now(),
|
|
429
|
+
},
|
|
430
|
+
normalized,
|
|
431
|
+
);
|
|
432
|
+
if (!healed) return false;
|
|
433
|
+
|
|
434
|
+
sessions.set(normalized, healed);
|
|
435
|
+
startMonitor(normalized);
|
|
436
|
+
persist();
|
|
437
|
+
emitter?.emit("synapse.session.started", {
|
|
438
|
+
sessionId: normalized,
|
|
439
|
+
session: cloneSession(healed),
|
|
440
|
+
});
|
|
441
|
+
return true;
|
|
442
|
+
}
|
|
362
443
|
|
|
363
444
|
const wasRemote = session.isRemote;
|
|
364
445
|
const updated = { ...session, lastHeartbeat: now(), status: "active" };
|
|
@@ -492,6 +573,7 @@ export function createSynapseRegistry(opts = {}) {
|
|
|
492
573
|
return Object.freeze({
|
|
493
574
|
register,
|
|
494
575
|
unregister,
|
|
576
|
+
pruneExpired,
|
|
495
577
|
heartbeat,
|
|
496
578
|
getActive,
|
|
497
579
|
getAll,
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
// hub/workers/codex-mcp.mjs — Codex MCP 서버 래퍼
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
2
5
|
import process from "node:process";
|
|
3
6
|
import { fileURLToPath } from "node:url";
|
|
4
7
|
|
|
@@ -94,13 +97,78 @@ function normalizeStructuredContent(structuredContent, fallbackText = "") {
|
|
|
94
97
|
return { threadId, content };
|
|
95
98
|
}
|
|
96
99
|
|
|
97
|
-
|
|
100
|
+
/**
|
|
101
|
+
* Read a top-level TOML scalar (`key = "value"` / `key = 'value'` /
|
|
102
|
+
* `key = value`), tolerating an inline `# comment` and surrounding whitespace.
|
|
103
|
+
* Returns the trimmed value, or null when the key is absent/empty. Intentionally
|
|
104
|
+
* tiny — the per-profile files only carry `model` + `model_reasoning_effort`.
|
|
105
|
+
*
|
|
106
|
+
* @param {string} raw
|
|
107
|
+
* @param {string} key
|
|
108
|
+
* @returns {string|null}
|
|
109
|
+
*/
|
|
110
|
+
function readTomlScalar(raw, key) {
|
|
111
|
+
const re = new RegExp(
|
|
112
|
+
`^\\s*${key}\\s*=\\s*(?:"([^"\\n]*)"|'([^'\\n]*)'|([^#\\n]*?))\\s*(?:#.*)?$`,
|
|
113
|
+
"m",
|
|
114
|
+
);
|
|
115
|
+
const match = raw.match(re);
|
|
116
|
+
if (!match) return null;
|
|
117
|
+
const value = (match[1] ?? match[2] ?? match[3] ?? "").trim();
|
|
118
|
+
return value || null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Resolve a Codex effort profile name (e.g. "gpt55_high") to its concrete
|
|
123
|
+
* `model` + reasoning effort. SSOT = the per-profile file written by
|
|
124
|
+
* `tfx setup` at `~/.codex/<profile>.config.toml`. Falls back to deriving the
|
|
125
|
+
* effort from the `<...>_<effort>` naming convention when the file is absent.
|
|
126
|
+
*
|
|
127
|
+
* codex 0.137 removed `profile` from the Codex MCP tool schema, so we translate
|
|
128
|
+
* the profile into the still-accepted `model` + `config` fields instead of
|
|
129
|
+
* forwarding `profile` (which now hard-fails with "unknown field `profile`").
|
|
130
|
+
*
|
|
131
|
+
* @param {string} profileName
|
|
132
|
+
* @returns {{ model: string|null, reasoningEffort: string|null }}
|
|
133
|
+
*/
|
|
134
|
+
export function resolveCodexProfileConfig(profileName) {
|
|
135
|
+
const result = { model: null, reasoningEffort: null };
|
|
136
|
+
if (typeof profileName !== "string" || !profileName) return result;
|
|
137
|
+
|
|
138
|
+
const codexHome = process.env.CODEX_HOME || join(homedir(), ".codex");
|
|
139
|
+
try {
|
|
140
|
+
const raw = readFileSync(
|
|
141
|
+
join(codexHome, `${profileName}.config.toml`),
|
|
142
|
+
"utf8",
|
|
143
|
+
);
|
|
144
|
+
result.model = readTomlScalar(raw, "model");
|
|
145
|
+
result.reasoningEffort = readTomlScalar(raw, "model_reasoning_effort");
|
|
146
|
+
} catch {
|
|
147
|
+
// Profile file absent (non-file alias). Derive effort from the naming
|
|
148
|
+
// convention; leave model unset so codex uses its config.toml default.
|
|
149
|
+
const suffix = /_(xhigh|high|med|medium|low)$/.exec(profileName);
|
|
150
|
+
if (suffix) {
|
|
151
|
+
const map = {
|
|
152
|
+
xhigh: "xhigh",
|
|
153
|
+
high: "high",
|
|
154
|
+
med: "medium",
|
|
155
|
+
medium: "medium",
|
|
156
|
+
low: "low",
|
|
157
|
+
};
|
|
158
|
+
result.reasoningEffort = map[suffix[1]] || null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function buildCodexArguments(prompt, opts = {}) {
|
|
98
165
|
const args = { prompt };
|
|
99
166
|
|
|
100
167
|
if (typeof opts.cwd === "string" && opts.cwd) args.cwd = opts.cwd;
|
|
101
168
|
if (typeof opts.model === "string" && opts.model) args.model = opts.model;
|
|
102
|
-
|
|
103
|
-
|
|
169
|
+
// NOTE: `profile` is intentionally NOT forwarded as a Codex tool argument.
|
|
170
|
+
// codex 0.137 dropped it from the tool schema; it is resolved into
|
|
171
|
+
// `model` + `config.model_reasoning_effort` just before return (see below).
|
|
104
172
|
if (typeof opts.approvalPolicy === "string" && opts.approvalPolicy) {
|
|
105
173
|
args["approval-policy"] = opts.approvalPolicy;
|
|
106
174
|
}
|
|
@@ -120,6 +188,22 @@ function buildCodexArguments(prompt, opts = {}) {
|
|
|
120
188
|
args["compact-prompt"] = opts.compactPrompt;
|
|
121
189
|
}
|
|
122
190
|
|
|
191
|
+
// Map the effort profile to model + reasoning effort instead of forwarding the
|
|
192
|
+
// (0.137-rejected) `profile` field. Explicit opts.model wins over the profile;
|
|
193
|
+
// profile effort merges on top of any opts.config.
|
|
194
|
+
if (typeof opts.profile === "string" && opts.profile) {
|
|
195
|
+
const resolved = resolveCodexProfileConfig(opts.profile);
|
|
196
|
+
if (resolved.model && typeof args.model !== "string") {
|
|
197
|
+
args.model = resolved.model;
|
|
198
|
+
}
|
|
199
|
+
if (resolved.reasoningEffort) {
|
|
200
|
+
args.config = {
|
|
201
|
+
...(args.config || {}),
|
|
202
|
+
model_reasoning_effort: resolved.reasoningEffort,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
123
207
|
return args;
|
|
124
208
|
}
|
|
125
209
|
|