triflux 10.9.12 → 10.9.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/triflux.mjs +55 -0
- package/hub/server.mjs +19 -3
- package/hub/team/git-preflight.mjs +286 -0
- package/hub/team/swarm-intent.mjs +216 -0
- package/hub/team/swarm-locks.mjs +65 -9
- package/hub/team/swarm-reconciler.mjs +80 -0
- package/hub/team/synapse-cli.mjs +207 -0
- package/hub/team/synapse-registry.mjs +275 -0
- package/hub/team/worktree-lifecycle.mjs +48 -1
- package/hub/workers/gemini-worker.mjs +5 -1
- package/package.json +1 -1
- package/scripts/__tests__/tfx-doctor-diagnose.test.mjs +9 -0
- package/scripts/doctor-diagnose.mjs +65 -1
- package/scripts/headless-guard.mjs +2 -11
- package/scripts/lib/process-utils.mjs +26 -0
- package/scripts/session-stale-cleanup.mjs +1 -9
- package/scripts/tfx-route-worker.mjs +21 -1
- package/scripts/tfx-route.sh +37 -8
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_LOCAL_HEARTBEAT_INTERVAL_MS = 5_000;
|
|
5
|
+
const DEFAULT_LOCAL_TIMEOUT_MS = 30_000;
|
|
6
|
+
const DEFAULT_REMOTE_HEARTBEAT_INTERVAL_MS = 15_000;
|
|
7
|
+
const DEFAULT_REMOTE_TIMEOUT_MS = 90_000;
|
|
8
|
+
|
|
9
|
+
function normalizeSessionId(sessionId) {
|
|
10
|
+
if (sessionId == null) return "";
|
|
11
|
+
return String(sessionId).trim();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function cloneSession(session) {
|
|
15
|
+
return {
|
|
16
|
+
...session,
|
|
17
|
+
dirtyFiles: Array.isArray(session.dirtyFiles) ? [...session.dirtyFiles] : [],
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function sanitizeSession(raw, fallbackSessionId = "") {
|
|
22
|
+
const sessionId = normalizeSessionId(raw?.sessionId ?? fallbackSessionId);
|
|
23
|
+
if (!sessionId) return null;
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
sessionId,
|
|
27
|
+
host: typeof raw?.host === "string" ? raw.host : "local",
|
|
28
|
+
worktreePath: typeof raw?.worktreePath === "string" ? raw.worktreePath : "",
|
|
29
|
+
branch: typeof raw?.branch === "string" ? raw.branch : "",
|
|
30
|
+
dirtyFiles: Array.isArray(raw?.dirtyFiles) ? [...raw.dirtyFiles] : [],
|
|
31
|
+
taskSummary: typeof raw?.taskSummary === "string" ? raw.taskSummary : "",
|
|
32
|
+
lastHeartbeat:
|
|
33
|
+
typeof raw?.lastHeartbeat === "number" ? raw.lastHeartbeat : Date.now(),
|
|
34
|
+
status:
|
|
35
|
+
raw?.status === "stale" || raw?.status === "expired"
|
|
36
|
+
? raw.status
|
|
37
|
+
: "active",
|
|
38
|
+
isRemote: Boolean(raw?.isRemote),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function createSynapseRegistry(opts = {}) {
|
|
43
|
+
const {
|
|
44
|
+
persistPath,
|
|
45
|
+
localHeartbeatIntervalMs = DEFAULT_LOCAL_HEARTBEAT_INTERVAL_MS,
|
|
46
|
+
localTimeoutMs = DEFAULT_LOCAL_TIMEOUT_MS,
|
|
47
|
+
remoteHeartbeatIntervalMs = DEFAULT_REMOTE_HEARTBEAT_INTERVAL_MS,
|
|
48
|
+
remoteTimeoutMs = DEFAULT_REMOTE_TIMEOUT_MS,
|
|
49
|
+
} = opts;
|
|
50
|
+
|
|
51
|
+
const sessions = new Map();
|
|
52
|
+
const monitors = new Map();
|
|
53
|
+
const staleCallbacks = new Set();
|
|
54
|
+
const removedCallbacks = new Set();
|
|
55
|
+
|
|
56
|
+
function now() {
|
|
57
|
+
return Date.now();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function intervalFor(session) {
|
|
61
|
+
return session.isRemote
|
|
62
|
+
? remoteHeartbeatIntervalMs
|
|
63
|
+
: localHeartbeatIntervalMs;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function timeoutFor(session) {
|
|
67
|
+
return session.isRemote ? remoteTimeoutMs : localTimeoutMs;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function persist() {
|
|
71
|
+
if (!persistPath) return;
|
|
72
|
+
try {
|
|
73
|
+
mkdirSync(dirname(persistPath), { recursive: true });
|
|
74
|
+
const data = Object.fromEntries(
|
|
75
|
+
[...sessions].map(([k, v]) => [k, cloneSession(v)]),
|
|
76
|
+
);
|
|
77
|
+
writeFileSync(persistPath, JSON.stringify(data, null, 2), "utf8");
|
|
78
|
+
} catch {
|
|
79
|
+
/* best-effort */
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function restore() {
|
|
84
|
+
if (!persistPath || !existsSync(persistPath)) return;
|
|
85
|
+
try {
|
|
86
|
+
const data = JSON.parse(readFileSync(persistPath, "utf8"));
|
|
87
|
+
for (const [sessionId, session] of Object.entries(data)) {
|
|
88
|
+
const sanitized = sanitizeSession(session, sessionId);
|
|
89
|
+
if (sanitized) sessions.set(sanitized.sessionId, sanitized);
|
|
90
|
+
}
|
|
91
|
+
} catch {
|
|
92
|
+
/* corrupted file — start fresh */
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function stopMonitor(sessionId) {
|
|
97
|
+
const timer = monitors.get(sessionId);
|
|
98
|
+
if (!timer) return;
|
|
99
|
+
clearInterval(timer);
|
|
100
|
+
monitors.delete(sessionId);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function notifyStale(session) {
|
|
104
|
+
// TODO: emit synapse.session.stale via Hub deliveryEmitter
|
|
105
|
+
for (const callback of staleCallbacks) {
|
|
106
|
+
try {
|
|
107
|
+
callback(cloneSession(session));
|
|
108
|
+
} catch {
|
|
109
|
+
/* no-op */
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function notifyRemoved(session) {
|
|
115
|
+
// TODO: emit synapse.session.removed via Hub deliveryEmitter
|
|
116
|
+
for (const callback of removedCallbacks) {
|
|
117
|
+
try {
|
|
118
|
+
callback(cloneSession(session));
|
|
119
|
+
} catch {
|
|
120
|
+
/* no-op */
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function startMonitor(sessionId) {
|
|
126
|
+
stopMonitor(sessionId);
|
|
127
|
+
|
|
128
|
+
const session = sessions.get(sessionId);
|
|
129
|
+
if (!session) return;
|
|
130
|
+
|
|
131
|
+
const timer = setInterval(() => {
|
|
132
|
+
const current = sessions.get(sessionId);
|
|
133
|
+
if (!current) return;
|
|
134
|
+
|
|
135
|
+
const elapsedMs = now() - current.lastHeartbeat;
|
|
136
|
+
if (elapsedMs > timeoutFor(current) && current.status !== "stale") {
|
|
137
|
+
current.status = "stale";
|
|
138
|
+
notifyStale(current);
|
|
139
|
+
}
|
|
140
|
+
}, intervalFor(session));
|
|
141
|
+
|
|
142
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
143
|
+
monitors.set(sessionId, timer);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
restore();
|
|
147
|
+
for (const sessionId of sessions.keys()) {
|
|
148
|
+
startMonitor(sessionId);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function register(meta) {
|
|
152
|
+
const sessionId = normalizeSessionId(meta?.sessionId);
|
|
153
|
+
if (!sessionId || sessions.has(sessionId)) {
|
|
154
|
+
return { ok: false, sessionId };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const session = sanitizeSession(
|
|
158
|
+
{
|
|
159
|
+
...meta,
|
|
160
|
+
sessionId,
|
|
161
|
+
status: "active",
|
|
162
|
+
lastHeartbeat: now(),
|
|
163
|
+
},
|
|
164
|
+
sessionId,
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
sessions.set(sessionId, session);
|
|
168
|
+
startMonitor(sessionId);
|
|
169
|
+
persist();
|
|
170
|
+
|
|
171
|
+
// TODO: emit synapse.session.started via Hub deliveryEmitter
|
|
172
|
+
return { ok: true, sessionId };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function unregister(sessionId) {
|
|
176
|
+
const normalized = normalizeSessionId(sessionId);
|
|
177
|
+
const session = sessions.get(normalized);
|
|
178
|
+
if (!session) return false;
|
|
179
|
+
|
|
180
|
+
stopMonitor(normalized);
|
|
181
|
+
sessions.delete(normalized);
|
|
182
|
+
persist();
|
|
183
|
+
notifyRemoved(session);
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function heartbeat(sessionId, partialMeta = null) {
|
|
188
|
+
const normalized = normalizeSessionId(sessionId);
|
|
189
|
+
const session = sessions.get(normalized);
|
|
190
|
+
if (!session) return false;
|
|
191
|
+
|
|
192
|
+
const wasRemote = session.isRemote;
|
|
193
|
+
|
|
194
|
+
session.lastHeartbeat = now();
|
|
195
|
+
session.status = "active";
|
|
196
|
+
|
|
197
|
+
if (partialMeta && typeof partialMeta === "object") {
|
|
198
|
+
if (typeof partialMeta.host === "string") session.host = partialMeta.host;
|
|
199
|
+
if (typeof partialMeta.worktreePath === "string") {
|
|
200
|
+
session.worktreePath = partialMeta.worktreePath;
|
|
201
|
+
}
|
|
202
|
+
if (typeof partialMeta.branch === "string") session.branch = partialMeta.branch;
|
|
203
|
+
if (Array.isArray(partialMeta.dirtyFiles)) {
|
|
204
|
+
session.dirtyFiles = [...partialMeta.dirtyFiles];
|
|
205
|
+
}
|
|
206
|
+
if (typeof partialMeta.taskSummary === "string") {
|
|
207
|
+
session.taskSummary = partialMeta.taskSummary;
|
|
208
|
+
}
|
|
209
|
+
if (typeof partialMeta.isRemote === "boolean") {
|
|
210
|
+
session.isRemote = partialMeta.isRemote;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (session.isRemote !== wasRemote) {
|
|
215
|
+
startMonitor(normalized);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
persist();
|
|
219
|
+
// TODO: emit synapse.session.heartbeat via Hub deliveryEmitter
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function getActive() {
|
|
224
|
+
return [...sessions.values()]
|
|
225
|
+
.filter((session) => session.status === "active")
|
|
226
|
+
.map((session) => cloneSession(session));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function getAll() {
|
|
230
|
+
return [...sessions.values()].map((session) => cloneSession(session));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function getSession(sessionId) {
|
|
234
|
+
const normalized = normalizeSessionId(sessionId);
|
|
235
|
+
if (!normalized) return null;
|
|
236
|
+
const session = sessions.get(normalized);
|
|
237
|
+
return session ? cloneSession(session) : null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function onStale(callback) {
|
|
241
|
+
if (typeof callback !== "function") return;
|
|
242
|
+
staleCallbacks.add(callback);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function onRemoved(callback) {
|
|
246
|
+
if (typeof callback !== "function") return;
|
|
247
|
+
removedCallbacks.add(callback);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function snapshot() {
|
|
251
|
+
return {
|
|
252
|
+
sessions: getAll(),
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function destroy() {
|
|
257
|
+
for (const sessionId of monitors.keys()) {
|
|
258
|
+
stopMonitor(sessionId);
|
|
259
|
+
}
|
|
260
|
+
persist();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return Object.freeze({
|
|
264
|
+
register,
|
|
265
|
+
unregister,
|
|
266
|
+
heartbeat,
|
|
267
|
+
getActive,
|
|
268
|
+
getAll,
|
|
269
|
+
getSession,
|
|
270
|
+
onStale,
|
|
271
|
+
onRemoved,
|
|
272
|
+
snapshot,
|
|
273
|
+
destroy,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
@@ -170,17 +170,42 @@ export async function prepareIntegrationBranch({
|
|
|
170
170
|
* Rebase a shard branch onto the integration branch.
|
|
171
171
|
* Uses rebase + fast-forward only (no merge commits).
|
|
172
172
|
*
|
|
173
|
+
* Synapse v1: when a `preflight` checker + `sessionContext` are provided,
|
|
174
|
+
* the rebase is pre-flighted against active sessions' dirty files/leases.
|
|
175
|
+
* Blocked rebases return `{ ok: false, preflight: decision }` without touching
|
|
176
|
+
* any branch. If preflight is omitted, behavior is unchanged.
|
|
177
|
+
*
|
|
173
178
|
* @param {object} opts
|
|
174
179
|
* @param {string} opts.shardBranch — the shard's branch name
|
|
175
180
|
* @param {string} opts.integrationBranch — target integration branch
|
|
176
181
|
* @param {string} [opts.rootDir=process.cwd()]
|
|
177
|
-
* @
|
|
182
|
+
* @param {{ checkRebase: Function } | null} [opts.preflight=null] — git-preflight instance
|
|
183
|
+
* @param {{ sessionId: string, workerId?: string } | null} [opts.sessionContext=null]
|
|
184
|
+
* @returns {Promise<{ ok: boolean, headCommit?: string, error?: string, preflight?: object }>}
|
|
178
185
|
*/
|
|
179
186
|
export async function rebaseShardOntoIntegration({
|
|
180
187
|
shardBranch,
|
|
181
188
|
integrationBranch,
|
|
182
189
|
rootDir = process.cwd(),
|
|
190
|
+
preflight = null,
|
|
191
|
+
sessionContext = null,
|
|
183
192
|
}) {
|
|
193
|
+
// Synapse v1 pre-flight: bail out before mutating branches when another
|
|
194
|
+
// session claims overlapping files.
|
|
195
|
+
if (preflight && sessionContext) {
|
|
196
|
+
const decision = preflight.checkRebase(
|
|
197
|
+
{ branch: integrationBranch },
|
|
198
|
+
sessionContext,
|
|
199
|
+
);
|
|
200
|
+
if (!decision.allowed) {
|
|
201
|
+
return {
|
|
202
|
+
ok: false,
|
|
203
|
+
error: `git-preflight blocked rebase: ${decision.reason || "overlap"}`,
|
|
204
|
+
preflight: decision,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
184
209
|
// Backup integration HEAD for rollback
|
|
185
210
|
const backupCommit = await git(["rev-parse", integrationBranch], rootDir);
|
|
186
211
|
|
|
@@ -220,18 +245,40 @@ export async function rebaseShardOntoIntegration({
|
|
|
220
245
|
* Remove a worktree and its branch.
|
|
221
246
|
* Follows WT race-guard: sleep between operations.
|
|
222
247
|
*
|
|
248
|
+
* Synapse v1: when `preflight` + `sessionContext` are provided, the removal
|
|
249
|
+
* is pre-flighted against active sessions — blocked if another session is
|
|
250
|
+
* still registered to this worktree path.
|
|
251
|
+
*
|
|
223
252
|
* @param {object} opts
|
|
224
253
|
* @param {string} opts.worktreePath
|
|
225
254
|
* @param {string} [opts.branchName] — optional branch to delete
|
|
226
255
|
* @param {string} [opts.rootDir=process.cwd()]
|
|
227
256
|
* @param {boolean} [opts.force=false]
|
|
257
|
+
* @param {{ checkWorktreeRemove: Function } | null} [opts.preflight=null]
|
|
258
|
+
* @param {{ sessionId: string, workerId?: string } | null} [opts.sessionContext=null]
|
|
228
259
|
*/
|
|
229
260
|
export async function pruneWorktree({
|
|
230
261
|
worktreePath,
|
|
231
262
|
branchName,
|
|
232
263
|
rootDir = process.cwd(),
|
|
233
264
|
force = false,
|
|
265
|
+
preflight = null,
|
|
266
|
+
sessionContext = null,
|
|
234
267
|
}) {
|
|
268
|
+
if (preflight && sessionContext) {
|
|
269
|
+
const decision = preflight.checkWorktreeRemove(
|
|
270
|
+
{ worktreePath },
|
|
271
|
+
sessionContext,
|
|
272
|
+
);
|
|
273
|
+
if (!decision.allowed) {
|
|
274
|
+
return {
|
|
275
|
+
ok: false,
|
|
276
|
+
error: `git-preflight blocked worktree-remove: ${decision.reason || "active_worktree"}`,
|
|
277
|
+
preflight: decision,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
235
282
|
const forceFlag = force ? "--force" : "";
|
|
236
283
|
|
|
237
284
|
// Remove worktree (with retry for Windows file handle issues — E5)
|
|
@@ -349,7 +349,11 @@ export class GeminiWorker {
|
|
|
349
349
|
}
|
|
350
350
|
|
|
351
351
|
if (exitCode !== 0) {
|
|
352
|
-
|
|
352
|
+
// Build a descriptive message when stderr is empty to aid debugging
|
|
353
|
+
const errMsg = result.stderr
|
|
354
|
+
? `Gemini worker exited with code ${exitCode}`
|
|
355
|
+
: `Gemini worker exited with code ${exitCode} (stderr empty, signal=${exitSignal ?? "none"}, events=${events.length}, stdout=${result.stdout.length}B)`;
|
|
356
|
+
throw createWorkerError(errMsg, {
|
|
353
357
|
code: "WORKER_EXIT",
|
|
354
358
|
result,
|
|
355
359
|
stderr: result.stderr,
|
package/package.json
CHANGED
|
@@ -39,6 +39,15 @@ describe("doctor-diagnose: 진단 번들 생성", () => {
|
|
|
39
39
|
assert.equal(typeof result.hookTimingCount, "number");
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
+
it("codexMcpApproval 진단 결과 포함", () => {
|
|
43
|
+
assert.ok(result.codexMcpApproval, "codexMcpApproval 필드 누락");
|
|
44
|
+
assert.equal(typeof result.codexMcpApproval.found, "boolean");
|
|
45
|
+
if (result.codexMcpApproval.found) {
|
|
46
|
+
assert.ok(Array.isArray(result.codexMcpApproval.tools));
|
|
47
|
+
assert.ok(result.codexMcpApproval.tools.length > 0);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
42
51
|
after(() => {
|
|
43
52
|
// 테스트 생성 zip 정리
|
|
44
53
|
if (result?.zipPath && existsSync(result.zipPath)) {
|
|
@@ -18,6 +18,7 @@ import { join } from "node:path";
|
|
|
18
18
|
const TRIFLUX_DIR = join(homedir(), ".triflux");
|
|
19
19
|
const LOGS_DIR = join(TRIFLUX_DIR, "logs");
|
|
20
20
|
const DIAG_DIR = join(TRIFLUX_DIR, "diagnostics");
|
|
21
|
+
const CODEX_CONFIG_PATH = join(homedir(), ".codex", "config.toml");
|
|
21
22
|
const ONE_HOUR_MS = 60 * 60 * 1000;
|
|
22
23
|
|
|
23
24
|
function collectSpawnTraces(cutoffMs = ONE_HOUR_MS) {
|
|
@@ -85,6 +86,47 @@ function computeSpawnStats(traces) {
|
|
|
85
86
|
};
|
|
86
87
|
}
|
|
87
88
|
|
|
89
|
+
/**
|
|
90
|
+
* ~/.codex/config.toml에서 [mcp_servers.*.tools.*] 섹션 내부의
|
|
91
|
+
* approval_mode = "approve"를 감지한다. 이 값이 있으면 codex exec가
|
|
92
|
+
* non-TTY subprocess에서 승인 대기로 stall한다.
|
|
93
|
+
* refs: tellang/triflux#66, Yeachan-Heo/oh-my-codex#1478
|
|
94
|
+
*/
|
|
95
|
+
function checkCodexMcpApproval() {
|
|
96
|
+
if (!existsSync(CODEX_CONFIG_PATH)) {
|
|
97
|
+
return { found: false, reason: "config.toml not found" };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let content;
|
|
101
|
+
try {
|
|
102
|
+
content = readFileSync(CODEX_CONFIG_PATH, "utf8");
|
|
103
|
+
} catch {
|
|
104
|
+
return { found: false, reason: "config.toml unreadable" };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const badTools = [];
|
|
108
|
+
let inMcpTool = false;
|
|
109
|
+
let currentSection = "";
|
|
110
|
+
|
|
111
|
+
for (const line of content.split("\n")) {
|
|
112
|
+
const sectionMatch = line.match(/^\[(.+)\]/);
|
|
113
|
+
if (sectionMatch) {
|
|
114
|
+
currentSection = sectionMatch[1];
|
|
115
|
+
inMcpTool = /^mcp_servers\..+\.tools\./.test(currentSection);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (inMcpTool && /^\s*approval_mode\s*=\s*"approve"/.test(line)) {
|
|
119
|
+
badTools.push(currentSection);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
found: badTools.length > 0,
|
|
125
|
+
tools: badTools,
|
|
126
|
+
fix: 'sed -i \'s/approval_mode = "approve"/approval_mode = "auto"/g\' ~/.codex/config.toml',
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
88
130
|
function collectSystemInfo() {
|
|
89
131
|
const info = {
|
|
90
132
|
platform: platform(),
|
|
@@ -165,9 +207,28 @@ function generateSummary(stats, sysInfo, hookTimings, traceCount) {
|
|
|
165
207
|
`blocked: ${stats.blocked}`,
|
|
166
208
|
`trace entries: ${traceCount}`,
|
|
167
209
|
"",
|
|
168
|
-
"---
|
|
210
|
+
"--- Codex MCP Approval Check ---",
|
|
169
211
|
];
|
|
170
212
|
|
|
213
|
+
const mcpCheck = checkCodexMcpApproval();
|
|
214
|
+
if (!mcpCheck.found && mcpCheck.reason) {
|
|
215
|
+
lines.push(` ${mcpCheck.reason}`);
|
|
216
|
+
} else if (mcpCheck.found) {
|
|
217
|
+
lines.push(` ⚠ ${mcpCheck.tools.length}개 MCP tool에 approval_mode="approve" 감지`);
|
|
218
|
+
lines.push(` codex exec non-TTY stall 위험 (refs: triflux#66)`);
|
|
219
|
+
for (const tool of mcpCheck.tools) {
|
|
220
|
+
lines.push(` - [${tool}]`);
|
|
221
|
+
}
|
|
222
|
+
lines.push(` fix: ${mcpCheck.fix}`);
|
|
223
|
+
} else {
|
|
224
|
+
lines.push(" ✔ MCP tool approval_mode 정상");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
lines.push(
|
|
228
|
+
"",
|
|
229
|
+
"--- Hook Timings (last 1h) ---",
|
|
230
|
+
);
|
|
231
|
+
|
|
171
232
|
if (hookTimings.length === 0) {
|
|
172
233
|
lines.push("no hook timing data found");
|
|
173
234
|
} else {
|
|
@@ -248,6 +309,8 @@ export async function diagnose({ json = false } = {}) {
|
|
|
248
309
|
rmSync(bundleDir, { recursive: true, force: true });
|
|
249
310
|
} catch { /* leave it */ }
|
|
250
311
|
|
|
312
|
+
const mcpApprovalCheck = checkCodexMcpApproval();
|
|
313
|
+
|
|
251
314
|
const result = {
|
|
252
315
|
ok: true,
|
|
253
316
|
zipPath,
|
|
@@ -255,6 +318,7 @@ export async function diagnose({ json = false } = {}) {
|
|
|
255
318
|
sysInfo,
|
|
256
319
|
traceCount: traces.length,
|
|
257
320
|
hookTimingCount: hookTimings.length,
|
|
321
|
+
codexMcpApproval: mcpApprovalCheck,
|
|
258
322
|
};
|
|
259
323
|
|
|
260
324
|
if (json) {
|
|
@@ -30,6 +30,7 @@ import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
|
30
30
|
import { tmpdir } from "node:os";
|
|
31
31
|
import { join } from "node:path";
|
|
32
32
|
import { deny, nudge } from "./lib/hook-utils.mjs";
|
|
33
|
+
import { isProcessAlive } from "./lib/process-utils.mjs";
|
|
33
34
|
import { probePsmuxSupport } from "./lib/psmux-info.mjs";
|
|
34
35
|
|
|
35
36
|
const CACHE_FILE = join(tmpdir(), "tfx-psmux-check.json");
|
|
@@ -41,16 +42,6 @@ const MULTI_EXPIRE_MS = 30 * 60 * 1000; // 30분 자동 만료
|
|
|
41
42
|
const GATE_THRESHOLD = 2; // A: dispatch 전 허용할 Agent 호출 수
|
|
42
43
|
const NUDGE_THRESHOLD = 4; // B: dispatch 후 nudge 트리거 횟수
|
|
43
44
|
|
|
44
|
-
function isOwnerAlive(pid) {
|
|
45
|
-
if (!pid || typeof pid !== "number") return false;
|
|
46
|
-
try {
|
|
47
|
-
process.kill(pid, 0);
|
|
48
|
-
return true;
|
|
49
|
-
} catch {
|
|
50
|
-
return false;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
45
|
function readMultiState() {
|
|
55
46
|
try {
|
|
56
47
|
if (!existsSync(MULTI_STATE_FILE)) return null;
|
|
@@ -66,7 +57,7 @@ function readMultiState() {
|
|
|
66
57
|
return null;
|
|
67
58
|
}
|
|
68
59
|
// ownerPid 생존 체크 — 소유 세션이 죽었으면 stale (#62)
|
|
69
|
-
if (state.ownerPid && !
|
|
60
|
+
if (state.ownerPid && !isProcessAlive(state.ownerPid)) {
|
|
70
61
|
try {
|
|
71
62
|
unlinkSync(MULTI_STATE_FILE);
|
|
72
63
|
} catch {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// scripts/lib/process-utils.mjs
|
|
2
|
+
// 프로세스 관련 공유 유틸리티 (훅 및 lightweight 스크립트용)
|
|
3
|
+
//
|
|
4
|
+
// Note: hub/lib/process-utils.mjs에는 더 포괄적인 orphan cleanup 로직이 있습니다.
|
|
5
|
+
// 이 파일은 단순 alive 체크만 필요한 훅/스크립트용 (상위 의존성 없이 동작).
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 주어진 PID의 프로세스가 살아있는지 확인합니다.
|
|
9
|
+
* - EPERM: 프로세스는 존재하지만 signal 권한 없음 → alive
|
|
10
|
+
* - ESRCH: 프로세스가 존재하지 않음 → dead
|
|
11
|
+
* - 기타 에러 (invalid pid 포함): dead로 간주
|
|
12
|
+
*
|
|
13
|
+
* @param {number|string} pid
|
|
14
|
+
* @returns {boolean}
|
|
15
|
+
*/
|
|
16
|
+
export function isProcessAlive(pid) {
|
|
17
|
+
const n = Number(pid);
|
|
18
|
+
if (!Number.isInteger(n) || n <= 0) return false;
|
|
19
|
+
try {
|
|
20
|
+
process.kill(n, 0);
|
|
21
|
+
return true;
|
|
22
|
+
} catch (e) {
|
|
23
|
+
if (e?.code === "EPERM") return true;
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -20,20 +20,12 @@ import {
|
|
|
20
20
|
} from "node:fs";
|
|
21
21
|
import { platform, tmpdir } from "node:os";
|
|
22
22
|
import { join } from "node:path";
|
|
23
|
+
import { isProcessAlive } from "./lib/process-utils.mjs";
|
|
23
24
|
|
|
24
25
|
const MULTI_STATE_FILE = join(tmpdir(), "tfx-multi-state.json");
|
|
25
26
|
const EXPIRE_MS = 30 * 60 * 1000; // 30분
|
|
26
27
|
const PID_FILE_RE = /^tfx-route-(\d+)-pids$/;
|
|
27
28
|
|
|
28
|
-
function isProcessAlive(pid) {
|
|
29
|
-
try {
|
|
30
|
-
process.kill(pid, 0);
|
|
31
|
-
return true;
|
|
32
|
-
} catch {
|
|
33
|
-
return false;
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
29
|
function treeKill(pid) {
|
|
38
30
|
try {
|
|
39
31
|
if (platform() === "win32") {
|
|
@@ -222,11 +222,31 @@ try {
|
|
|
222
222
|
if (!result.response.endsWith("\n")) process.stdout.write("\n");
|
|
223
223
|
}
|
|
224
224
|
} catch (error) {
|
|
225
|
+
// Always emit error.message first for quick identification
|
|
226
|
+
process.stderr.write(`[tfx-route-worker] ${error.message}\n`);
|
|
227
|
+
|
|
228
|
+
// Emit captured stderr from the child process (may be empty)
|
|
225
229
|
if (error.stderr) {
|
|
226
230
|
process.stderr.write(String(error.stderr));
|
|
227
231
|
if (!String(error.stderr).endsWith("\n")) process.stderr.write("\n");
|
|
228
232
|
}
|
|
229
|
-
|
|
233
|
+
|
|
234
|
+
// When stderr is empty, surface diagnostic details from error.result
|
|
235
|
+
// so the caller can debug silent failures
|
|
236
|
+
if (!error.stderr && error.result) {
|
|
237
|
+
const r = error.result;
|
|
238
|
+
const diag = [
|
|
239
|
+
`[tfx-route-worker] diagnostics: exitCode=${r.exitCode ?? "null"} signal=${r.exitSignal ?? "none"} timedOut=${r.timedOut ?? false}`,
|
|
240
|
+
r.events?.length
|
|
241
|
+
? `[tfx-route-worker] events(${r.events.length}): ${JSON.stringify(r.events.slice(-3))}`
|
|
242
|
+
: "[tfx-route-worker] events: none",
|
|
243
|
+
r.stdout
|
|
244
|
+
? `[tfx-route-worker] child stdout(${r.stdout.length}B): ${r.stdout.slice(0, 512)}`
|
|
245
|
+
: "[tfx-route-worker] child stdout: empty",
|
|
246
|
+
];
|
|
247
|
+
process.stderr.write(diag.join("\n") + "\n");
|
|
248
|
+
}
|
|
249
|
+
|
|
230
250
|
process.exitCode = error.code === "ETIMEDOUT" ? 124 : 1;
|
|
231
251
|
} finally {
|
|
232
252
|
try {
|