@cocorograph/hub-agent 0.7.66 → 0.7.67
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/package.json +1 -1
- package/src/codex-appserver-client.mjs +28 -5
- package/src/codex-stream-bridge.mjs +68 -0
- package/src/main.mjs +8 -0
package/package.json
CHANGED
|
@@ -74,6 +74,12 @@ export class CodexAppServerClient extends EventEmitter {
|
|
|
74
74
|
cwd: this.cwd,
|
|
75
75
|
env: this.env || process.env,
|
|
76
76
|
stdio: ["pipe", "pipe", "pipe"],
|
|
77
|
+
// detached にして子 (node ランチャー codex.js) を新しいプロセスグループの leader に
|
|
78
|
+
// する。こうすると stop() で `process.kill(-pid)` によりグループごと落とせ、node
|
|
79
|
+
// ランチャーが spawn する native codex バイナリ (孫) まで確実に終了できる。detached
|
|
80
|
+
// 単体では親終了時に孫が生き残るが、stop()/shutdown() で必ずグループ kill するため
|
|
81
|
+
// orphan 化しない (逆に child.kill() 単体だと node だけ落ちて native が orphan 化する)。
|
|
82
|
+
detached: true,
|
|
77
83
|
})
|
|
78
84
|
this.child.stdout.on("data", (chunk) => this._onStdout(chunk))
|
|
79
85
|
this.child.stderr.on("data", (chunk) => {
|
|
@@ -224,15 +230,32 @@ export class CodexAppServerClient extends EventEmitter {
|
|
|
224
230
|
if (!this.child || this.closed) return
|
|
225
231
|
this.closed = true
|
|
226
232
|
this._settleAllPending(new Error("codex app-server client stopped"))
|
|
233
|
+
const child = this.child
|
|
227
234
|
try {
|
|
228
|
-
|
|
235
|
+
child.stdin.end()
|
|
229
236
|
} catch {
|
|
230
237
|
// stdin が既に閉じている等は無視して良い (stop の冪等性を優先)。
|
|
231
238
|
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
239
|
+
// detached spawn によりプロセスグループ leader なので、グループごと (孫の native
|
|
240
|
+
// codex バイナリ含む) 落とす。グループが無い/権限エラー時は単体 kill にフォールバック。
|
|
241
|
+
const killGroup = (signal) => {
|
|
242
|
+
try {
|
|
243
|
+
if (typeof child.pid === "number") {
|
|
244
|
+
process.kill(-child.pid, signal)
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
} catch {
|
|
248
|
+
// グループが既に消えている / 未 detached 等 → 単体 kill にフォールバック。
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
child.kill(signal)
|
|
252
|
+
} catch {
|
|
253
|
+
// プロセスが既に無い場合等は無視して良い。
|
|
254
|
+
}
|
|
236
255
|
}
|
|
256
|
+
killGroup("SIGTERM")
|
|
257
|
+
// SIGTERM で落ちない (ハング等) 場合に備え、猶予後 SIGKILL でグループごと強制終了。
|
|
258
|
+
const t = setTimeout(() => killGroup("SIGKILL"), 3000)
|
|
259
|
+
t.unref?.()
|
|
237
260
|
}
|
|
238
261
|
}
|
|
@@ -64,6 +64,16 @@ function siblingEnvFor(cwd) {
|
|
|
64
64
|
// 関わらず完走まで絶対に落とさない (_maybeReapAfterIdle が busy 中は予約しない)。
|
|
65
65
|
const IDLE_DETACH_TTL_MS = 5 * 60 * 1000
|
|
66
66
|
|
|
67
|
+
// 放棄セッションの reaper 閾値。claude-stream-bridge.mjs の gcDeadTerminals と同じ思想・
|
|
68
|
+
// 同じ env (HUB_AGENT_DEAD_TERMINAL_TTL_MS、既定 1 時間)。detach を送らず消えた端末や、
|
|
69
|
+
// codex エラーで turn/completed が来ず activeTurnId が固着したスタックセッションは、
|
|
70
|
+
// detach→TTL(5分) の graceful reap 経路に乗らないため app-server が永久に残る (リーク)。
|
|
71
|
+
// これを補い、「一定時間まったく通信 (browser 入力 / app-server 通知) が無い」セッションを
|
|
72
|
+
// activeTurnId の有無に関わらず定期回収する。進行中の正常なターンは app-server 通知で
|
|
73
|
+
// 都度 touch されるため回収されない (通知が途絶えたスタックターンのみ回収される)。
|
|
74
|
+
const ABANDONED_SESSION_TTL_MS =
|
|
75
|
+
Number(process.env.HUB_AGENT_DEAD_TERMINAL_TTL_MS) || 60 * 60 * 1000
|
|
76
|
+
|
|
67
77
|
const CLIENT_INFO = { name: "cockpit-hub-agent", title: "Cockpit", version: "1.0.0" }
|
|
68
78
|
|
|
69
79
|
// Slice 6: thread/resume が返す Thread.turns[].items[] を単一の時系列配列へ平坦化する。
|
|
@@ -191,6 +201,9 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
191
201
|
pendingApprovals: new Map(), // request_id -> { turnId }
|
|
192
202
|
detachTimer: null,
|
|
193
203
|
dead: false,
|
|
204
|
+
// 放棄セッション reaper (gcAbandonedSessions) 用の最終活性時刻。browser 入力と
|
|
205
|
+
// app-server 通知 (ターン進捗) の両方で更新する。Date.now() は hub-agent 実行環境で利用可。
|
|
206
|
+
lastActivityMs: Date.now(),
|
|
194
207
|
}
|
|
195
208
|
this.sessions.set(stream_id, session)
|
|
196
209
|
this._wireClient(session)
|
|
@@ -284,8 +297,17 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
284
297
|
})
|
|
285
298
|
}
|
|
286
299
|
|
|
300
|
+
/** gcAbandonedSessions 用の最終活性更新。browser 入力 / app-server 通知の両方で呼ぶ。 */
|
|
301
|
+
_touch(session) {
|
|
302
|
+
if (session) session.lastActivityMs = Date.now()
|
|
303
|
+
}
|
|
304
|
+
|
|
287
305
|
_onNotification(session, msg) {
|
|
288
306
|
const { stream_id } = session
|
|
307
|
+
// app-server からの通知 = ターンが進行している証跡。これで touch することで、
|
|
308
|
+
// 正常に走っているターンは reaper に回収されず、通知が途絶えたスタックターンだけが
|
|
309
|
+
// 放棄セッションとして回収される。
|
|
310
|
+
this._touch(session)
|
|
289
311
|
switch (msg.method) {
|
|
290
312
|
case "turn/started":
|
|
291
313
|
session.activeTurnId = msg.params?.turn?.id || null
|
|
@@ -361,6 +383,7 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
361
383
|
*/
|
|
362
384
|
async input({ stream_id, message, model, effort }) {
|
|
363
385
|
const session = this._require(stream_id)
|
|
386
|
+
this._touch(session)
|
|
364
387
|
const input = userInputFromMessage(message)
|
|
365
388
|
if (session.activeTurnId) {
|
|
366
389
|
try {
|
|
@@ -408,6 +431,7 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
408
431
|
/** @returns {Promise<void>} */
|
|
409
432
|
async interrupt({ stream_id }) {
|
|
410
433
|
const session = this._require(stream_id)
|
|
434
|
+
this._touch(session)
|
|
411
435
|
if (!session.activeTurnId) return
|
|
412
436
|
await session.client.request("turn/interrupt", {
|
|
413
437
|
threadId: session.threadId,
|
|
@@ -418,6 +442,7 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
418
442
|
/** サーバー発の承認/入力要求への応答。result の shape は method ごとに呼び出し側が組み立てる。 */
|
|
419
443
|
permissionReply({ stream_id, request_id, result }) {
|
|
420
444
|
const session = this._require(stream_id)
|
|
445
|
+
this._touch(session)
|
|
421
446
|
if (!session.pendingApprovals.has(request_id)) {
|
|
422
447
|
this.logger?.warn?.({ stream_id, request_id }, "codex permissionReply: unknown request_id (ignored)")
|
|
423
448
|
return
|
|
@@ -508,6 +533,49 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
508
533
|
return session
|
|
509
534
|
}
|
|
510
535
|
|
|
536
|
+
/**
|
|
537
|
+
* 放棄セッションの reaper。claude-stream-bridge.mjs の gcDeadTerminals と同じ思想:
|
|
538
|
+
* 一定時間まったく通信 (browser 入力 / app-server 通知) が無いセッションの app-server を
|
|
539
|
+
* 停止して回収する。detach を送らず消えた端末や、codex エラーで turn/completed が来ず
|
|
540
|
+
* activeTurnId が固着したスタックターンは detach→TTL の graceful reap 経路に乗らないため、
|
|
541
|
+
* これが唯一の回収経路になる (プロセスリーク / 生成中固着の根治)。
|
|
542
|
+
*
|
|
543
|
+
* 進行中の正常なターンは app-server 通知で都度 touch されるので回収されない。
|
|
544
|
+
* main.mjs の state loop から claudeBridge.gcDeadTerminals と並べて定期実行する想定。
|
|
545
|
+
*
|
|
546
|
+
* @param {number} [ttlMs] 無通信とみなす閾値 (テスト用に上書き可)。
|
|
547
|
+
* @returns {number} 回収したセッション数。
|
|
548
|
+
*/
|
|
549
|
+
gcAbandonedSessions(ttlMs = ABANDONED_SESSION_TTL_MS) {
|
|
550
|
+
const now = Date.now()
|
|
551
|
+
let reaped = 0
|
|
552
|
+
for (const session of Array.from(this.sessions.values())) {
|
|
553
|
+
if (session.dead) continue
|
|
554
|
+
const last = session.lastActivityMs ?? 0
|
|
555
|
+
if (now - last < ttlMs) continue
|
|
556
|
+
this._clearDetachTimer(session)
|
|
557
|
+
session.dead = true
|
|
558
|
+
this.sessions.delete(session.stream_id)
|
|
559
|
+
try {
|
|
560
|
+
session.client.stop()
|
|
561
|
+
} catch (err) {
|
|
562
|
+
this.logger?.warn?.({ stream_id: session.stream_id, err: err?.message }, "gcAbandonedSessions: stop threw")
|
|
563
|
+
}
|
|
564
|
+
reaped += 1
|
|
565
|
+
this.logger?.info?.(
|
|
566
|
+
{ stream_id: session.stream_id, thread_id: session.threadId, idle_ms: now - last },
|
|
567
|
+
"codex abandoned session reaped (no activity past TTL)",
|
|
568
|
+
)
|
|
569
|
+
this.emit("exit", {
|
|
570
|
+
stream_id: session.stream_id,
|
|
571
|
+
thread_id: session.threadId,
|
|
572
|
+
code: 0,
|
|
573
|
+
reason: "idle-reaped",
|
|
574
|
+
})
|
|
575
|
+
}
|
|
576
|
+
return reaped
|
|
577
|
+
}
|
|
578
|
+
|
|
511
579
|
/** 全セッションを即座に終了する (agent shutdown 用。ターン完走は待たない)。 */
|
|
512
580
|
shutdown() {
|
|
513
581
|
for (const session of this.sessions.values()) {
|
package/src/main.mjs
CHANGED
|
@@ -1871,6 +1871,14 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
|
|
|
1871
1871
|
} catch (err) {
|
|
1872
1872
|
logger?.warn({ err: err?.message }, "gcDeadTerminals failed")
|
|
1873
1873
|
}
|
|
1874
|
+
// Codex 版 (claude の gcDeadTerminals と対): detach を送らず消えた / turn/completed が
|
|
1875
|
+
// 来ず activeTurnId が固着したスタックセッションの app-server を回収する (プロセス
|
|
1876
|
+
// リーク・生成中固着の根治)。進行中の正常ターンは app-server 通知で touch され残る。
|
|
1877
|
+
try {
|
|
1878
|
+
codexBridge?.gcAbandonedSessions?.()
|
|
1879
|
+
} catch (err) {
|
|
1880
|
+
logger?.warn({ err: err?.message }, "gcAbandonedSessions failed")
|
|
1881
|
+
}
|
|
1874
1882
|
// 実コンテキスト窓サイズ (1M ベータ等) を反映。contextPctFromUsage の分母を
|
|
1875
1883
|
// 5s ごとに最新化し、ドーナツが 200k 固定で振り切れるのを防ぐ。
|
|
1876
1884
|
await refreshContextWindow()
|