github-router 0.3.74 → 0.3.87
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/README.md +1 -1
- package/dist/browser-ext/background.js +34 -3
- package/dist/browser-ext/manifest.json +1 -1
- package/dist/browser-ext/snapshot-cdp.js +69 -36
- package/dist/browser-ext/visible-text.js +102 -0
- package/dist/{lifecycle-CQlm3YlF.js → lifecycle-C5fB3ODy.js} +2 -2
- package/dist/{lifecycle-CMPthagV.js → lifecycle-CHjAPu8u.js} +2 -2
- package/dist/{lifecycle-CMPthagV.js.map → lifecycle-CHjAPu8u.js.map} +1 -1
- package/dist/{lifecycle-yaqqtsV1.js → lifecycle-CTLlFU45.js} +54 -10
- package/dist/lifecycle-CTLlFU45.js.map +1 -0
- package/dist/lifecycle-uNpNYzQ_.js +4 -0
- package/dist/main.js +3174 -584
- package/dist/main.js.map +1 -1
- package/dist/{paths-BGx0RpNs.js → paths-Czi0-nEE.js} +1 -1
- package/dist/{paths-yJ97KlKp.js → paths-DWVKYv16.js} +3 -3
- package/dist/paths-DWVKYv16.js.map +1 -0
- package/package.json +1 -1
- package/dist/lifecycle-BL4rWSrT.js +0 -4
- package/dist/lifecycle-yaqqtsV1.js.map +0 -1
- package/dist/paths-yJ97KlKp.js.map +0 -1
package/README.md
CHANGED
|
@@ -100,7 +100,7 @@ For codex-side write capability (a `codex-implementer` persona that can mutate f
|
|
|
100
100
|
|
|
101
101
|
### Code search (`mcp__search__code`)
|
|
102
102
|
|
|
103
|
-
Alongside the peer reviewers, the same MCP surface exposes a `code` tool (under the `search` server — `mcp__search__code`) — fast structured code search over the workspace, ranked by **BM25F** (Robertson, Zaragoza, Taylor 2004) over four code-aware fields: matched line, surrounding context, file path tokens, and a symbol-definition heuristic. On top of that, the top hits get a tree-sitter pass that promotes true identifier-definition sites over incidental string matches; depth is controlled by the optional `structural` argument (`"full"` parses the top 50 hits, `"topN"` parses the top 10 for tighter latency on big repos). A single `notice` field surfaces in the response on the rare occasions an actionable degradation fires — the structural pass overran its 200ms wall-clock budget, or the response hit the 256KB size cap and was truncated; the message text tells the model what to retry. Defaults to a "
|
|
103
|
+
Alongside the peer reviewers, the same MCP surface exposes a `code` tool (under the `search` server — `mcp__search__code`) — fast structured code search over the workspace, ranked by **BM25F** (Robertson, Zaragoza, Taylor 2004) over four code-aware fields: matched line, surrounding context, file path tokens, and a symbol-definition heuristic. On top of that, the top hits get a tree-sitter pass that promotes true identifier-definition sites over incidental string matches; depth is controlled by the optional `structural` argument (`"full"` parses the top 50 hits, `"topN"` parses the top 10 for tighter latency on big repos). A single `notice` field surfaces in the response on the rare occasions an actionable degradation fires — the structural pass overran its 200ms wall-clock budget, or the response hit the 256KB size cap and was truncated; the message text tells the model what to retry. Defaults to a semantic mode (`mode: "semantic"`): it ranks by MEANING via ColBERT over a per-workspace index, and transparently falls back to lexical BM25F when that index isn't ready (building / stale / not yet provisioned), labelling the response `source` (`semantic`, `lexical`, or `lexical-fallback`) so a degrade is never silent. The `lexical` mode is the BM25F + tree-sitter path with shoulder pruning (best for exact symbols); `exact` and `regex` force fixed-string / PCRE2 search, and `ast` matches ast-grep structural patterns. Single-identifier queries in the lexical modes auto-expand across camelCase / snake_case / kebab-case skeletons so `getUserName` also matches `get_user_name`. (This one tool absorbs what was previously a separate `semantic_search` tool; ColBERT semantic search is just its default mode.)
|
|
104
104
|
|
|
105
105
|
`workspace` is any absolute path the proxy process can read — typically the project root or a sub-tree you're working in. The model picks it. There's no allow-set or secret-shape file denylist: the threat model is symmetric since Claude Code already has Read / Bash / Edit tools that reach the same paths, so gating one tool would have been inconsistency rather than defense. Paths in results are returned relative to the workspace, never absolute.
|
|
106
106
|
|
|
@@ -487,12 +487,43 @@ async function extractSnapshotLegacy(tabId, opts) {
|
|
|
487
487
|
// summary: walk text nodes whose parent is in the viewport; cap
|
|
488
488
|
// at 20 KB. The model sees what a user could read without
|
|
489
489
|
// scrolling. Off-screen content remains reachable via mode:"full".
|
|
490
|
-
// full:
|
|
490
|
+
// full: walk all rendered text nodes; cap at 256 KiB.
|
|
491
491
|
let text = ""
|
|
492
492
|
if (mode === "full") {
|
|
493
|
+
// Mirror of collectVisibleText(root, cap, "rendered") in
|
|
494
|
+
// src/browser-ext/visible-text.js — executeScript serializes only
|
|
495
|
+
// this func and drops its module closure, so it cannot import that
|
|
496
|
+
// helper; keep the two in sync by hand. We walk text nodes and join
|
|
497
|
+
// with "\n" instead of using document.body.innerText, which glues
|
|
498
|
+
// adjacent inline siblings with no separator
|
|
499
|
+
// (<span>A</span><span>B</span> -> "AB" instead of "A\nB").
|
|
493
500
|
const MAX_FULL = 256 * 1024
|
|
494
|
-
|
|
495
|
-
|
|
501
|
+
const parts = []
|
|
502
|
+
let total = 0
|
|
503
|
+
const root = document.body || document.documentElement
|
|
504
|
+
if (root) {
|
|
505
|
+
const tw = document.createTreeWalker(root, 4) // NodeFilter.SHOW_TEXT === 4
|
|
506
|
+
let n
|
|
507
|
+
while ((n = tw.nextNode())) {
|
|
508
|
+
const parent = n.parentElement
|
|
509
|
+
if (!parent) continue
|
|
510
|
+
const ptag = parent.tagName ? parent.tagName.toLowerCase() : ""
|
|
511
|
+
if (ptag === "script" || ptag === "style" || ptag === "noscript") continue
|
|
512
|
+
// display:none / detached parents report zero client rects;
|
|
513
|
+
// off-screen (scrolled-out) parents still report rects, so full
|
|
514
|
+
// mode keeps them — matching innerText's "rendered text" intent.
|
|
515
|
+
if (parent.getClientRects().length === 0) continue
|
|
516
|
+
const t = (n.textContent || "").replace(/\s+/g, " ").trim()
|
|
517
|
+
if (!t) continue
|
|
518
|
+
if (total + t.length + 1 > MAX_FULL) {
|
|
519
|
+
parts.push(t.slice(0, Math.max(0, MAX_FULL - total)))
|
|
520
|
+
break
|
|
521
|
+
}
|
|
522
|
+
parts.push(t)
|
|
523
|
+
total += t.length + 1
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
text = parts.join("\n")
|
|
496
527
|
} else {
|
|
497
528
|
const TEXT_CAP = 20 * 1024
|
|
498
529
|
const parts = []
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "github-router browser bridge",
|
|
4
4
|
"short_name": "gh-router-browser",
|
|
5
|
-
"version": "0.3.
|
|
5
|
+
"version": "0.3.87",
|
|
6
6
|
"description": "Bridge between Claude (via github-router /mcp) and the browser. Implements tab control, navigation, clicks, form fill, downloads, screenshots, devtools eval. Blocks navigation to chrome://settings.",
|
|
7
7
|
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqJElxuBlonBS3TVW9FJN0mGTtShB3L1hoaYf6k39SOr1ogGYmF90EjRxy1i21k9wQQjPf26bcBu/9X67KrQjQV0uB38CaNukgiSeoLjfptN811u+PJHx6BP+jx3Qa6/3VenNPxHC8WEU0GXql8QSjIHEyCwKb6fMASXOK94JyB5Ywov2x8mt/+9ncqBBBMVzf6r5Sagy4PL1XnryLsuADD/vOEkPet8wXgH/Oj7v5tTsQQZ7U1JT51PoDs2BFnXc5v3TkVgZwd32k3ONh+nkDw1Hof+4zwUGOyJE6eMrlYzRlKM4Qxdf9JpavQvqfieAbTRWcyKeclnHeoIfE7cDBQIDAQAB",
|
|
8
8
|
"background": {
|
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
// fails (enterprise DeveloperToolsAvailability=2, DevTools already
|
|
19
19
|
// open on the tab, etc.).
|
|
20
20
|
|
|
21
|
+
import { buildVisibleTextExpr } from "./visible-text.js"
|
|
22
|
+
|
|
21
23
|
const ELEMENT_CAP = 500 // total elements across all frames
|
|
22
24
|
const PER_FRAME_CAP = 200 // per-frame element cap
|
|
23
25
|
const TEXT_CAP = 32 * 1024 // viewport-visible text cap
|
|
@@ -89,7 +91,7 @@ export async function extractSnapshotCDP(tabId, opts, deps) {
|
|
|
89
91
|
const elements = []
|
|
90
92
|
const refCounter = { next: 1 }
|
|
91
93
|
const usedRefs = new Set()
|
|
92
|
-
const diag = { frames: frames.length, axNodes: 0, interesting: 0, resolved: 0, withRef: 0 }
|
|
94
|
+
const diag = { frames: frames.length, axNodes: 0, interesting: 0, resolved: 0, withRef: 0, textFramesSkipped: 0 }
|
|
93
95
|
for (const frame of frames) {
|
|
94
96
|
if (timedOut) break
|
|
95
97
|
if (elements.length >= ELEMENT_CAP) {
|
|
@@ -119,7 +121,7 @@ export async function extractSnapshotCDP(tabId, opts, deps) {
|
|
|
119
121
|
// attach already succeeded so an enable failure is rare.
|
|
120
122
|
}
|
|
121
123
|
}
|
|
122
|
-
const text = await extractVisibleText(tabId, sendCommand).catch(() => "")
|
|
124
|
+
const text = await extractVisibleText(tabId, frames, sendCommand, diag, () => timedOut).catch(() => "")
|
|
123
125
|
const truncatedText = text.length >= TEXT_CAP
|
|
124
126
|
const visualSurfaces = await extractVisualSurfaces(tabId, sendCommand).catch(() => [])
|
|
125
127
|
const out = {
|
|
@@ -368,40 +370,71 @@ function attrFromList(attrList, name) {
|
|
|
368
370
|
return undefined
|
|
369
371
|
}
|
|
370
372
|
|
|
371
|
-
async function extractVisibleText(tabId, sendCommand) {
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
373
|
+
async function extractVisibleText(tabId, frames, sendCommand, diag, isTimedOut) {
|
|
374
|
+
// Per-frame visible text. The old implementation ran a single
|
|
375
|
+
// Runtime.evaluate in the top frame's default context, so text inside
|
|
376
|
+
// child frames (same-origin app frames, embedded widgets) was invisible
|
|
377
|
+
// even though the element extractor already pierces frames. We now run the
|
|
378
|
+
// shared collectVisibleText expression in EACH frame: the top frame in its
|
|
379
|
+
// default context, child frames in a per-frame isolated world (Runtime
|
|
380
|
+
// .evaluate has no frameId — Page.createIsolatedWorld({frameId}) is the
|
|
381
|
+
// CDP-blessed way to get an executionContextId for a specific frame).
|
|
382
|
+
//
|
|
383
|
+
// Per-frame failures are non-fatal (cross-process OOPIFs may refuse
|
|
384
|
+
// createIsolatedWorld; we count them in diag and keep the rest) — mirroring
|
|
385
|
+
// the element loop's best-effort cross-origin handling. The merged result
|
|
386
|
+
// is bounded by the same global TEXT_CAP.
|
|
387
|
+
//
|
|
388
|
+
// Caveat: a child frame's "viewport" gate is the FRAME's own viewport
|
|
389
|
+
// (window/getBoundingClientRect are frame-local), not the top-page viewport,
|
|
390
|
+
// so a frame scrolled out of the top viewport can still contribute text.
|
|
391
|
+
// Gating on top-viewport visibility needs the owner-iframe rect in top
|
|
392
|
+
// coordinates (the deferred per-frame bbox transform). Bounded here by
|
|
393
|
+
// processing the top frame first and the global TEXT_CAP.
|
|
394
|
+
const parts = []
|
|
395
|
+
let total = 0
|
|
396
|
+
for (let i = 0; i < frames.length; i++) {
|
|
397
|
+
if (total >= TEXT_CAP) break
|
|
398
|
+
if (typeof isTimedOut === "function" && isTimedOut()) break
|
|
399
|
+
const frame = frames[i]
|
|
400
|
+
const isTopFrame = i === 0
|
|
401
|
+
// Ask each frame only for the budget still remaining so a later frame
|
|
402
|
+
// can't serialize text we'd immediately discard.
|
|
403
|
+
const expr = buildVisibleTextExpr("viewport", TEXT_CAP - total)
|
|
404
|
+
let frameText = ""
|
|
405
|
+
try {
|
|
406
|
+
frameText = await evaluateTextInFrame(tabId, frame, isTopFrame, expr, sendCommand)
|
|
407
|
+
} catch {
|
|
408
|
+
if (diag) diag.textFramesSkipped = (diag.textFramesSkipped || 0) + 1
|
|
409
|
+
continue
|
|
410
|
+
}
|
|
411
|
+
if (!frameText) continue
|
|
412
|
+
parts.push(frameText)
|
|
413
|
+
total += frameText.length + 1
|
|
414
|
+
}
|
|
415
|
+
const joined = parts.join("\n")
|
|
416
|
+
return joined.length > TEXT_CAP ? joined.slice(0, TEXT_CAP) : joined
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Run the visible-text expression in one frame. The top frame uses the
|
|
421
|
+
* attachment's default execution context; a child frame needs an isolated
|
|
422
|
+
* world minted for its frameId. Returns "" when no context could be obtained
|
|
423
|
+
* (e.g. a cross-process frame that refuses createIsolatedWorld) — the caller
|
|
424
|
+
* treats a throw as a skipped frame, but a missing context degrades quietly.
|
|
425
|
+
*/
|
|
426
|
+
async function evaluateTextInFrame(tabId, frame, isTopFrame, expr, sendCommand) {
|
|
427
|
+
const params = { expression: expr, returnByValue: true }
|
|
428
|
+
if (!isTopFrame) {
|
|
429
|
+
const world = await sendCommand(tabId, "Page.createIsolatedWorld", {
|
|
430
|
+
frameId: frame.frameId,
|
|
431
|
+
worldName: "gh_router_text",
|
|
432
|
+
})
|
|
433
|
+
const contextId = world && world.executionContextId
|
|
434
|
+
if (!contextId) return ""
|
|
435
|
+
params.contextId = contextId
|
|
436
|
+
}
|
|
437
|
+
const res = await sendCommand(tabId, "Runtime.evaluate", params)
|
|
405
438
|
return res?.result?.value ?? ""
|
|
406
439
|
}
|
|
407
440
|
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// visible-text.js — the canonical visible-text walk shared by both snapshot
|
|
2
|
+
// extractors.
|
|
3
|
+
//
|
|
4
|
+
// It runs in TWO execution contexts:
|
|
5
|
+
// 1. Serialized via Function.prototype.toString() into a CDP
|
|
6
|
+
// `Runtime.evaluate` expression (snapshot-cdp.js, the primary path) and
|
|
7
|
+
// run PER FRAME, including same-process child frames the old top-frame-
|
|
8
|
+
// only evaluate missed.
|
|
9
|
+
// 2. Mirrored inline inside the legacy `executeScript({func})` extractor
|
|
10
|
+
// (background.js). `chrome.scripting.executeScript` serializes ONLY the
|
|
11
|
+
// given function and drops its module closure, so that copy cannot
|
|
12
|
+
// `import` this one — it is kept in sync by hand (see the comment there).
|
|
13
|
+
//
|
|
14
|
+
// Why a TreeWalker join instead of `element.innerText`: `innerText` glues
|
|
15
|
+
// adjacent inline siblings with no separator — `<span>Item-757</span>` +
|
|
16
|
+
// `<span>ITM_a209f4</span>` collapses to the unreadable "Item-757ITM_a209f4".
|
|
17
|
+
// Walking text nodes and joining with "\n" keeps distinct fields separable for
|
|
18
|
+
// the model.
|
|
19
|
+
//
|
|
20
|
+
// Authored in plain ES5 (no arrow / spread / optional-chaining / template
|
|
21
|
+
// literals) so its `.toString()` source is self-contained and survives
|
|
22
|
+
// bundling intact for in-page injection — a transpiler helper reference in the
|
|
23
|
+
// emitted source would break the serialized expression. For the same reason
|
|
24
|
+
// the function closes over NOTHING from module scope (constants are inlined):
|
|
25
|
+
// `.toString()` captures only the function body, not module-level bindings.
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Collect viewport- or render-visible text from `root`, joining text nodes
|
|
29
|
+
* with "\n" and capping the result at `cap` UTF-16 code units.
|
|
30
|
+
*
|
|
31
|
+
* `mode` selects the per-node visibility gate:
|
|
32
|
+
* - "viewport" : keep nodes whose parent rect intersects the frame's
|
|
33
|
+
* viewport (what a user sees without scrolling). Needs a
|
|
34
|
+
* live `window` + layout.
|
|
35
|
+
* - "rendered" : keep nodes whose parent has >=1 client rect (i.e. not
|
|
36
|
+
* display:none / detached); off-screen content IS kept.
|
|
37
|
+
* Used by the "full" snapshot mode.
|
|
38
|
+
* - anything else (e.g. "none"): no visibility gate — keep every non-
|
|
39
|
+
* script/style text node. Used by unit tests so the walk is
|
|
40
|
+
* exercisable without a layout engine.
|
|
41
|
+
*
|
|
42
|
+
* Pure and dependency-free. `script` / `style` / `noscript` text is always
|
|
43
|
+
* dropped. Returns "" for a missing root / document.
|
|
44
|
+
*/
|
|
45
|
+
export function collectVisibleText(root, cap, mode) {
|
|
46
|
+
if (!root) return ""
|
|
47
|
+
var doc = root.ownerDocument || (typeof document !== "undefined" ? document : null)
|
|
48
|
+
if (!doc || typeof doc.createTreeWalker !== "function") return ""
|
|
49
|
+
var tw = doc.createTreeWalker(root, 4) // NodeFilter.SHOW_TEXT — inlined (see header)
|
|
50
|
+
var out = []
|
|
51
|
+
var total = 0
|
|
52
|
+
var n
|
|
53
|
+
while ((n = tw.nextNode())) {
|
|
54
|
+
var p = n.parentElement
|
|
55
|
+
if (!p) continue
|
|
56
|
+
var tag = p.tagName ? String(p.tagName).toLowerCase() : ""
|
|
57
|
+
if (tag === "script" || tag === "style" || tag === "noscript") continue
|
|
58
|
+
if (mode === "viewport") {
|
|
59
|
+
var r = p.getBoundingClientRect()
|
|
60
|
+
if (!(r.bottom > 0 && r.right > 0 && r.top < window.innerHeight && r.left < window.innerWidth)) {
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
} else if (mode === "rendered") {
|
|
64
|
+
// display:none / detached parents report zero client rects; off-screen
|
|
65
|
+
// (scrolled-out) parents still report rects, so full mode keeps them.
|
|
66
|
+
// NB: `visibility:hidden` text IS kept (it retains layout boxes) — this
|
|
67
|
+
// matches the "viewport" path's getBoundingClientRect behavior; excluding
|
|
68
|
+
// it would need a per-node getComputedStyle (style-recalc cost) and would
|
|
69
|
+
// diverge the two extractors.
|
|
70
|
+
if (p.getClientRects().length === 0) continue
|
|
71
|
+
}
|
|
72
|
+
var s = (n.textContent || "").replace(/\s+/g, " ").trim()
|
|
73
|
+
if (!s) continue
|
|
74
|
+
if (total + s.length + 1 > cap) {
|
|
75
|
+
out.push(s.slice(0, Math.max(0, cap - total)))
|
|
76
|
+
break
|
|
77
|
+
}
|
|
78
|
+
out.push(s)
|
|
79
|
+
total += s.length + 1
|
|
80
|
+
}
|
|
81
|
+
return out.join("\n")
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Build the in-page `Runtime.evaluate` expression that runs
|
|
86
|
+
* `collectVisibleText` against the frame's document. Self-contained: the
|
|
87
|
+
* function source is inlined via `.toString()` so it needs nothing from the
|
|
88
|
+
* page or this module at eval time. `cap` is coerced to a number and `mode`
|
|
89
|
+
* is JSON-encoded so the generated source is always a well-formed literal
|
|
90
|
+
* (callers pass constants today; this keeps it injection-safe regardless).
|
|
91
|
+
*/
|
|
92
|
+
export function buildVisibleTextExpr(mode, cap) {
|
|
93
|
+
return (
|
|
94
|
+
"(" +
|
|
95
|
+
collectVisibleText.toString() +
|
|
96
|
+
")(document.body||document.documentElement," +
|
|
97
|
+
Number(cap) +
|
|
98
|
+
"," +
|
|
99
|
+
JSON.stringify(String(mode)) +
|
|
100
|
+
")"
|
|
101
|
+
)
|
|
102
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import "./paths-
|
|
2
|
-
import { a as sweepRegistry, i as registerExitHandlers, n as getInstanceUuid, o as sweepStaleWorktreesAtBoot, r as recordWorkerRepo, t as WorktreeRegistry } from "./lifecycle-
|
|
1
|
+
import "./paths-DWVKYv16.js";
|
|
2
|
+
import { a as sweepRegistry, i as registerExitHandlers, n as getInstanceUuid, o as sweepStaleWorktreesAtBoot, r as recordWorkerRepo, t as WorktreeRegistry } from "./lifecycle-CHjAPu8u.js";
|
|
3
3
|
|
|
4
4
|
export { WorktreeRegistry, getInstanceUuid, recordWorkerRepo, registerExitHandlers, sweepRegistry, sweepStaleWorktreesAtBoot };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { l as writeRuntimeFileSecure, t as PATHS } from "./paths-
|
|
1
|
+
import { l as writeRuntimeFileSecure, t as PATHS } from "./paths-DWVKYv16.js";
|
|
2
2
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
@@ -307,4 +307,4 @@ async function sweepStaleWorktreesAtBoot() {
|
|
|
307
307
|
|
|
308
308
|
//#endregion
|
|
309
309
|
export { sweepRegistry as a, registerExitHandlers as i, getInstanceUuid as n, sweepStaleWorktreesAtBoot as o, recordWorkerRepo as r, WorktreeRegistry as t };
|
|
310
|
-
//# sourceMappingURL=lifecycle-
|
|
310
|
+
//# sourceMappingURL=lifecycle-CHjAPu8u.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"lifecycle-CMPthagV.js","names":["_instanceUuid: string | null","_activeRegistry: WorktreeRegistry | null","_exitHandler: (() => void) | null","_sigintHandler: (() => void) | null","_sigtermHandler: (() => void) | null","raw: string","cleaned: Array<LedgerEntry>","_ledgerChain: Promise<void>","ledger: LedgerFile","names: Array<string>"],"sources":["../src/lib/worker-agent/lifecycle.ts"],"sourcesContent":["/**\n * Lifecycle plumbing for worker worktrees: in-memory registry, signal\n * handlers, ledger of repos touched, and the boot-time PID+instance\n * safety net.\n *\n * Plan: see `plans/we-have-added-a-dreamy-tide.md` (\"Worktree mode\" →\n * \"Cleanup paths\"). Three layers cooperate, none of them sufficient\n * alone:\n *\n * 1. Per-call cleanup (`engine.ts` finally block invoking\n * `WorktreeHandle.remove()`) — covers the happy path.\n *\n * 2. Session-end signal sweep (this file, registered via\n * `registerExitHandlers`) — covers Ctrl+C, service-manager stop,\n * and (in `github-router claude` mode) the spawned child's exit.\n * Synchronous `execFileSync` is intentional: exit handlers can't\n * reliably await async work.\n *\n * 3. Boot-time PID+instance sweep (`sweepStaleWorktreesAtBoot`) —\n * covers SIGKILL, OOM, container restart. Walks the ledger of\n * repos this proxy has touched and removes worktree dirs whose\n * `<pid>` is dead OR whose `<instance>` UUID doesn't match the\n * current proxy's UUID.\n *\n * Ledger writes are ATOMIC (temp + rename) per peer review — a\n * concurrent-RMW corruption would silently strand worktrees because\n * the boot sweep can't find their repo roots.\n */\n\nimport { execFileSync } from \"node:child_process\"\nimport { randomBytes, randomUUID } from \"node:crypto\"\nimport fs from \"node:fs/promises\"\nimport path from \"node:path\"\nimport process from \"node:process\"\n\nimport { PATHS, writeRuntimeFileSecure } from \"../paths\"\n\n/**\n * Same regex worktree.ts uses for its per-call age sweep — kept in\n * sync intentionally. `<pid>-<uuid>-<8hex>` strictly.\n */\nconst WORKTREE_DIR_NAME_RE =\n /^(\\d+)-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})-([0-9a-f]{8})$/\n\n/**\n * Cap on the ledger: how many repos we remember across boots, and how\n * old an entry may be before it's pruned. Both are belt-and-suspenders\n * — the per-call age sweep is the primary guard against accumulation\n * inside any single repo.\n */\nconst LEDGER_MAX_ENTRIES = 100\nconst LEDGER_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000\n\nexport interface WorktreeRegistryEntry {\n repoRoot: string\n dir: string\n branch: string\n}\n\n/**\n * Set-like in-memory registry of worktrees this proxy created. Engine\n * passes it to `createWorktree` so per-call cleanup deletes the entry\n * on success; the signal handlers walk what's left at shutdown.\n *\n * Not a bare `Set` because we want to expose only the operations we\n * actually use, and we want a stable testable surface.\n */\nexport class WorktreeRegistry {\n private readonly entries = new Set<WorktreeRegistryEntry>()\n\n add(entry: WorktreeRegistryEntry): void {\n this.entries.add(entry)\n }\n delete(entry: WorktreeRegistryEntry): void {\n this.entries.delete(entry)\n }\n has(entry: WorktreeRegistryEntry): boolean {\n return this.entries.has(entry)\n }\n values(): IterableIterator<WorktreeRegistryEntry> {\n return this.entries.values()\n }\n get size(): number {\n return this.entries.size\n }\n clear(): void {\n this.entries.clear()\n }\n}\n\n// ---------------------------------------------------------------------\n// Per-launch instance UUID\n// ---------------------------------------------------------------------\n\nlet _instanceUuid: string | null = null\n\n/**\n * Stable UUID4 generated once per proxy process. Used in worktree\n * dir/branch names so the boot sweep can reliably distinguish \"this\n * proxy's still-live worktrees\" from \"stranded dirs from a prior\n * proxy that happens to have a recycled PID\" — Docker PID-1 across\n * container restarts is the classic case (peer-review HIGH finding).\n */\nexport function getInstanceUuid(): string {\n if (_instanceUuid === null) {\n _instanceUuid = randomUUID()\n }\n return _instanceUuid\n}\n\n/** Test-only: reset the cached UUID. */\nexport function __resetInstanceUuidForTests(): void {\n _instanceUuid = null\n}\n\n// ---------------------------------------------------------------------\n// Signal handlers + sweepRegistry\n// ---------------------------------------------------------------------\n\nlet _registered = false\nlet _activeRegistry: WorktreeRegistry | null = null\nlet _exitHandler: (() => void) | null = null\nlet _sigintHandler: (() => void) | null = null\nlet _sigtermHandler: (() => void) | null = null\n\n/**\n * Synchronous cleanup of every registry entry. Best-effort:\n * `execFileSync` failures are swallowed (the dir may have been\n * removed already, or git may not be on PATH any more in some\n * environments). After a successful removal we drop the entry from\n * the registry so a second call is a true no-op.\n *\n * Synchronous on purpose — exit handlers can't reliably await async\n * work; the process would die before the promise settled.\n */\nexport function sweepRegistry(): void {\n if (!_activeRegistry) return\n // Snapshot the values first so we can mutate the underlying set\n // during iteration without skipping entries.\n const snapshot = [..._activeRegistry.values()]\n for (const entry of snapshot) {\n try {\n // `-C entry.repoRoot` is load-bearing: without it git resolves\n // the worktree path relative to the proxy's cwd (which is the\n // user's launch dir, typically NOT inside the target repo), and\n // fails with `fatal: '<path>' is not a working tree`. The E2E\n // boot-sweep test (worker-agent-boot-sweep.test.ts) is what\n // caught the missing flag.\n execFileSync(\n \"git\",\n [\"-C\", entry.repoRoot, \"worktree\", \"remove\", \"--force\", entry.dir],\n { stdio: \"ignore\", timeout: 10_000, windowsHide: true },\n )\n } catch {\n // Already gone, EBUSY, or git not on PATH — best effort.\n }\n try {\n execFileSync(\"git\", [\"-C\", entry.repoRoot, \"branch\", \"-D\", entry.branch], {\n stdio: \"ignore\",\n timeout: 5_000,\n windowsHide: true,\n })\n } catch {\n // Same as above.\n }\n _activeRegistry.delete(entry)\n }\n}\n\n/**\n * Windows ConPTY / node-pty signal behavior:\n *\n * When a ConPTY host (VS Code terminal, Windows Terminal, node-pty) closes\n * the pseudo-console, the ConPTY layer sends CTRL_CLOSE_EVENT to the\n * process group. Node.js translates this into SIGINT (NOT SIGTERM). The\n * process has a ~5-second window before forced termination.\n *\n * Implication: the SIGTERM handler below may NEVER fire in node-pty\n * environments. This is by design — the three-layer cleanup architecture\n * ensures coverage:\n * 1. Per-call cleanup (engine.ts finally block) — happy path\n * 2. SIGINT handler (this file) — ConPTY close, Ctrl+C\n * 3. `exit` handler (this file) — unconditional, fires on any exit\n * 4. Boot-time PID+instance sweep (sweepStaleWorktreesAtBoot) — crash recovery\n *\n * Layers 1+2+3 cover ConPTY; layer 4 covers SIGKILL/OOM/container restart.\n */\n\n/**\n * Wire up SIGINT/SIGTERM/exit handlers that walk the registry and\n * remove every entry. Idempotent: subsequent calls swap the registry\n * pointer but do NOT register additional process listeners (otherwise\n * we'd leak listeners on every `runWorkerAgent`).\n *\n * Signal handlers re-raise the signal after sweeping. Naively running\n * the sweep on SIGINT/SIGTERM and returning would *suppress* the\n * signal: Node defaults to terminating the process on these, but only\n * if no user listener is attached. Once we attach a listener, the\n * default action is cancelled and the process keeps running — which\n * means Ctrl-C would clean worktrees but not actually exit, leaving\n * orphan processes in dev. The `process.kill(pid, sig)` re-raise\n * after removing our own listener restores the default behaviour\n * (the second delivery now hits an empty listener list, so Node\n * terminates with the conventional `128 + signum` exit code).\n */\nexport function registerExitHandlers(registry: WorktreeRegistry): void {\n _activeRegistry = registry\n if (_registered) return\n _registered = true\n _exitHandler = () => sweepRegistry()\n _sigintHandler = () => {\n sweepRegistry()\n if (_sigintHandler) process.off(\"SIGINT\", _sigintHandler)\n process.kill(process.pid, \"SIGINT\")\n }\n _sigtermHandler = () => {\n sweepRegistry()\n if (_sigtermHandler) process.off(\"SIGTERM\", _sigtermHandler)\n process.kill(process.pid, \"SIGTERM\")\n }\n process.on(\"SIGINT\", _sigintHandler)\n process.on(\"SIGTERM\", _sigtermHandler)\n // `exit` handlers can only run synchronous code — exactly what\n // sweepRegistry does. Async work here would never complete.\n process.on(\"exit\", _exitHandler)\n}\n\n/**\n * Test-only: unregister the handlers and reset module state. Tests\n * that want to verify `registerExitHandlers` semantics must clean up\n * after themselves or future tests in the same process inherit the\n * (now stale) registry pointer.\n */\nexport function __unregisterExitHandlersForTests(): void {\n if (_sigintHandler) {\n process.off(\"SIGINT\", _sigintHandler)\n _sigintHandler = null\n }\n if (_sigtermHandler) {\n process.off(\"SIGTERM\", _sigtermHandler)\n _sigtermHandler = null\n }\n if (_exitHandler) {\n process.off(\"exit\", _exitHandler)\n _exitHandler = null\n }\n _registered = false\n _activeRegistry = null\n}\n\n// ---------------------------------------------------------------------\n// Ledger: which repos has this proxy touched?\n// ---------------------------------------------------------------------\n\ninterface LedgerEntry {\n repoRoot: string\n lastSeenMs: number\n}\n\ninterface LedgerFile {\n entries: Array<LedgerEntry>\n}\n\nfunction ledgerPath(): string {\n return path.join(PATHS.APP_DIR, \"worker-repos.json\")\n}\n\nasync function readLedger(): Promise<LedgerFile> {\n let raw: string\n try {\n raw = await fs.readFile(ledgerPath(), \"utf8\")\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === \"ENOENT\") {\n return { entries: [] }\n }\n return { entries: [] }\n }\n try {\n const parsed = JSON.parse(raw) as Partial<LedgerFile>\n if (!parsed || !Array.isArray(parsed.entries)) return { entries: [] }\n const cleaned: Array<LedgerEntry> = []\n for (const e of parsed.entries) {\n if (\n e &&\n typeof e === \"object\" &&\n typeof (e as LedgerEntry).repoRoot === \"string\" &&\n typeof (e as LedgerEntry).lastSeenMs === \"number\"\n ) {\n cleaned.push({\n repoRoot: (e as LedgerEntry).repoRoot,\n lastSeenMs: (e as LedgerEntry).lastSeenMs,\n })\n }\n }\n return { entries: cleaned }\n } catch {\n // Corrupted JSON — start fresh rather than crashing the proxy.\n return { entries: [] }\n }\n}\n\n/**\n * Per-process serializer for ledger writes. Multiple concurrent\n * `recordWorkerRepo` calls (legitimate: several workers may start at\n * once) would otherwise race read-modify-write on the JSON file. Each\n * call chains onto the previous so the on-disk sequence is\n * deterministic from this process's perspective.\n *\n * Cross-process safety is provided by the atomic temp+rename below,\n * which makes the final state of the file always be a well-formed\n * full snapshot from ONE writer — never a partial write or\n * interleaved JSON.\n */\nlet _ledgerChain: Promise<void> = Promise.resolve()\n\n/**\n * Append `repoRoot` to the ledger (or update its `lastSeenMs`).\n * Atomic temp+rename per peer review.\n */\nexport function recordWorkerRepo(repoRoot: string): Promise<void> {\n const next = _ledgerChain.then(async () => {\n await fs.mkdir(PATHS.APP_DIR, { recursive: true })\n const current = await readLedger()\n // Dedup: drop any existing entry for this root before appending\n // the fresh one so the array doesn't grow unbounded with repeats.\n const filtered = current.entries.filter((e) => e.repoRoot !== repoRoot)\n filtered.push({ repoRoot, lastSeenMs: Date.now() })\n // Prune by age and cap entry count (newest wins).\n const now = Date.now()\n const pruned = filtered\n .filter((e) => now - e.lastSeenMs < LEDGER_MAX_AGE_MS)\n .slice(-LEDGER_MAX_ENTRIES)\n const ledger: LedgerFile = { entries: pruned }\n\n // Atomic temp+rename. The temp filename is unique per call\n // (PID + 8 random hex chars) so concurrent processes don't\n // collide on the temp name; the final `rename` is atomic on\n // POSIX and on Windows (both with same filesystem).\n const tmp = `${ledgerPath()}.tmp.${process.pid}.${randomBytes(4).toString(\n \"hex\",\n )}`\n try {\n await writeRuntimeFileSecure(tmp, JSON.stringify(ledger, null, 2))\n await fs.rename(tmp, ledgerPath())\n } catch (err) {\n // Clean up the temp file if rename failed midway.\n await fs.unlink(tmp).catch(() => {})\n throw err\n }\n })\n // Swallow chain-internal errors so one failed write doesn't poison\n // the chain for every subsequent caller. Each call still sees its\n // own rejection (we return `next`, not the catch-handler chain).\n _ledgerChain = next.catch(() => undefined)\n return next\n}\n\nfunction isPidAlive(pid: number): boolean {\n if (!Number.isInteger(pid) || pid <= 0) return false\n try {\n process.kill(pid, 0)\n return true\n } catch (err) {\n const code = (err as NodeJS.ErrnoException).code\n // EPERM = process exists but we can't signal it — still alive\n // for our purposes (we just need to know whether to clean up).\n if (code === \"EPERM\") return true\n return false\n }\n}\n\n/**\n * Boot-time sweep. For every repo we recorded in the ledger,\n * enumerate `<repoRoot>/.git/worker-worktrees/` (the conventional\n * location — for repos already inside a worktree, the actual\n * `git-common-dir` may differ, in which case we'll miss this batch\n * and the per-call age sweep will catch them within 7 days) and\n * remove dirs that aren't owned by THIS proxy.\n *\n * Ownership rule: dir is \"ours\" iff its embedded PID is alive AND\n * its embedded UUID equals `getInstanceUuid()`. Either condition\n * failing → remove.\n */\nexport async function sweepStaleWorktreesAtBoot(): Promise<void> {\n const ledger = await readLedger()\n if (ledger.entries.length === 0) return\n const currentUuid = getInstanceUuid()\n for (const entry of ledger.entries) {\n const parent = path.join(entry.repoRoot, \".git\", \"worker-worktrees\")\n let names: Array<string>\n try {\n names = await fs.readdir(parent)\n } catch {\n continue\n }\n for (const name of names) {\n const m = WORKTREE_DIR_NAME_RE.exec(name)\n if (!m) continue\n const pid = Number.parseInt(m[1], 10)\n const uuid = m[2]\n const isOurs = isPidAlive(pid) && uuid === currentUuid\n if (isOurs) continue\n\n const fullDir = path.join(parent, name)\n const branch = `worker/${pid}-${uuid}-${m[3]}`\n try {\n // `-C entry.repoRoot` is load-bearing here too — see the\n // matching comment in `sweepRegistry`. The boot sweep runs\n // BEFORE any worker tool has set cwd, so the proxy's cwd is\n // the user's launch dir, which is almost never inside the\n // target repo.\n execFileSync(\n \"git\",\n [\"-C\", entry.repoRoot, \"worktree\", \"remove\", \"--force\", fullDir],\n { stdio: \"ignore\", timeout: 10_000, windowsHide: true },\n )\n } catch {\n // ignore\n }\n try {\n execFileSync(\n \"git\",\n [\"-C\", entry.repoRoot, \"branch\", \"-D\", branch],\n { stdio: \"ignore\", timeout: 5_000, windowsHide: true },\n )\n } catch {\n // ignore\n }\n try {\n await fs.rm(fullDir, { recursive: true, force: true })\n } catch {\n // ignore — git may have removed it already\n }\n }\n }\n}\n\n/** Test-only: clear the ledger file (does NOT remove on-disk worktrees). */\nexport async function __clearLedgerForTests(): Promise<void> {\n await fs.unlink(ledgerPath()).catch(() => {})\n}\n\n/** Test-only: read the ledger as a plain array (no side effects). */\nexport async function __readLedgerForTests(): Promise<Array<LedgerEntry>> {\n return (await readLedger()).entries\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"lifecycle-CHjAPu8u.js","names":["_instanceUuid: string | null","_activeRegistry: WorktreeRegistry | null","_exitHandler: (() => void) | null","_sigintHandler: (() => void) | null","_sigtermHandler: (() => void) | null","raw: string","cleaned: Array<LedgerEntry>","_ledgerChain: Promise<void>","ledger: LedgerFile","names: Array<string>"],"sources":["../src/lib/worker-agent/lifecycle.ts"],"sourcesContent":["/**\n * Lifecycle plumbing for worker worktrees: in-memory registry, signal\n * handlers, ledger of repos touched, and the boot-time PID+instance\n * safety net.\n *\n * Plan: see `plans/we-have-added-a-dreamy-tide.md` (\"Worktree mode\" →\n * \"Cleanup paths\"). Three layers cooperate, none of them sufficient\n * alone:\n *\n * 1. Per-call cleanup (`engine.ts` finally block invoking\n * `WorktreeHandle.remove()`) — covers the happy path.\n *\n * 2. Session-end signal sweep (this file, registered via\n * `registerExitHandlers`) — covers Ctrl+C, service-manager stop,\n * and (in `github-router claude` mode) the spawned child's exit.\n * Synchronous `execFileSync` is intentional: exit handlers can't\n * reliably await async work.\n *\n * 3. Boot-time PID+instance sweep (`sweepStaleWorktreesAtBoot`) —\n * covers SIGKILL, OOM, container restart. Walks the ledger of\n * repos this proxy has touched and removes worktree dirs whose\n * `<pid>` is dead OR whose `<instance>` UUID doesn't match the\n * current proxy's UUID.\n *\n * Ledger writes are ATOMIC (temp + rename) per peer review — a\n * concurrent-RMW corruption would silently strand worktrees because\n * the boot sweep can't find their repo roots.\n */\n\nimport { execFileSync } from \"node:child_process\"\nimport { randomBytes, randomUUID } from \"node:crypto\"\nimport fs from \"node:fs/promises\"\nimport path from \"node:path\"\nimport process from \"node:process\"\n\nimport { PATHS, writeRuntimeFileSecure } from \"../paths\"\n\n/**\n * Same regex worktree.ts uses for its per-call age sweep — kept in\n * sync intentionally. `<pid>-<uuid>-<8hex>` strictly.\n */\nconst WORKTREE_DIR_NAME_RE =\n /^(\\d+)-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})-([0-9a-f]{8})$/\n\n/**\n * Cap on the ledger: how many repos we remember across boots, and how\n * old an entry may be before it's pruned. Both are belt-and-suspenders\n * — the per-call age sweep is the primary guard against accumulation\n * inside any single repo.\n */\nconst LEDGER_MAX_ENTRIES = 100\nconst LEDGER_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000\n\nexport interface WorktreeRegistryEntry {\n repoRoot: string\n dir: string\n branch: string\n}\n\n/**\n * Set-like in-memory registry of worktrees this proxy created. Engine\n * passes it to `createWorktree` so per-call cleanup deletes the entry\n * on success; the signal handlers walk what's left at shutdown.\n *\n * Not a bare `Set` because we want to expose only the operations we\n * actually use, and we want a stable testable surface.\n */\nexport class WorktreeRegistry {\n private readonly entries = new Set<WorktreeRegistryEntry>()\n\n add(entry: WorktreeRegistryEntry): void {\n this.entries.add(entry)\n }\n delete(entry: WorktreeRegistryEntry): void {\n this.entries.delete(entry)\n }\n has(entry: WorktreeRegistryEntry): boolean {\n return this.entries.has(entry)\n }\n values(): IterableIterator<WorktreeRegistryEntry> {\n return this.entries.values()\n }\n get size(): number {\n return this.entries.size\n }\n clear(): void {\n this.entries.clear()\n }\n}\n\n// ---------------------------------------------------------------------\n// Per-launch instance UUID\n// ---------------------------------------------------------------------\n\nlet _instanceUuid: string | null = null\n\n/**\n * Stable UUID4 generated once per proxy process. Used in worktree\n * dir/branch names so the boot sweep can reliably distinguish \"this\n * proxy's still-live worktrees\" from \"stranded dirs from a prior\n * proxy that happens to have a recycled PID\" — Docker PID-1 across\n * container restarts is the classic case (peer-review HIGH finding).\n */\nexport function getInstanceUuid(): string {\n if (_instanceUuid === null) {\n _instanceUuid = randomUUID()\n }\n return _instanceUuid\n}\n\n/** Test-only: reset the cached UUID. */\nexport function __resetInstanceUuidForTests(): void {\n _instanceUuid = null\n}\n\n// ---------------------------------------------------------------------\n// Signal handlers + sweepRegistry\n// ---------------------------------------------------------------------\n\nlet _registered = false\nlet _activeRegistry: WorktreeRegistry | null = null\nlet _exitHandler: (() => void) | null = null\nlet _sigintHandler: (() => void) | null = null\nlet _sigtermHandler: (() => void) | null = null\n\n/**\n * Synchronous cleanup of every registry entry. Best-effort:\n * `execFileSync` failures are swallowed (the dir may have been\n * removed already, or git may not be on PATH any more in some\n * environments). After a successful removal we drop the entry from\n * the registry so a second call is a true no-op.\n *\n * Synchronous on purpose — exit handlers can't reliably await async\n * work; the process would die before the promise settled.\n */\nexport function sweepRegistry(): void {\n if (!_activeRegistry) return\n // Snapshot the values first so we can mutate the underlying set\n // during iteration without skipping entries.\n const snapshot = [..._activeRegistry.values()]\n for (const entry of snapshot) {\n try {\n // `-C entry.repoRoot` is load-bearing: without it git resolves\n // the worktree path relative to the proxy's cwd (which is the\n // user's launch dir, typically NOT inside the target repo), and\n // fails with `fatal: '<path>' is not a working tree`. The E2E\n // boot-sweep test (worker-agent-boot-sweep.test.ts) is what\n // caught the missing flag.\n execFileSync(\n \"git\",\n [\"-C\", entry.repoRoot, \"worktree\", \"remove\", \"--force\", entry.dir],\n { stdio: \"ignore\", timeout: 10_000, windowsHide: true },\n )\n } catch {\n // Already gone, EBUSY, or git not on PATH — best effort.\n }\n try {\n execFileSync(\"git\", [\"-C\", entry.repoRoot, \"branch\", \"-D\", entry.branch], {\n stdio: \"ignore\",\n timeout: 5_000,\n windowsHide: true,\n })\n } catch {\n // Same as above.\n }\n _activeRegistry.delete(entry)\n }\n}\n\n/**\n * Windows ConPTY / node-pty signal behavior:\n *\n * When a ConPTY host (VS Code terminal, Windows Terminal, node-pty) closes\n * the pseudo-console, the ConPTY layer sends CTRL_CLOSE_EVENT to the\n * process group. Node.js translates this into SIGINT (NOT SIGTERM). The\n * process has a ~5-second window before forced termination.\n *\n * Implication: the SIGTERM handler below may NEVER fire in node-pty\n * environments. This is by design — the three-layer cleanup architecture\n * ensures coverage:\n * 1. Per-call cleanup (engine.ts finally block) — happy path\n * 2. SIGINT handler (this file) — ConPTY close, Ctrl+C\n * 3. `exit` handler (this file) — unconditional, fires on any exit\n * 4. Boot-time PID+instance sweep (sweepStaleWorktreesAtBoot) — crash recovery\n *\n * Layers 1+2+3 cover ConPTY; layer 4 covers SIGKILL/OOM/container restart.\n */\n\n/**\n * Wire up SIGINT/SIGTERM/exit handlers that walk the registry and\n * remove every entry. Idempotent: subsequent calls swap the registry\n * pointer but do NOT register additional process listeners (otherwise\n * we'd leak listeners on every `runWorkerAgent`).\n *\n * Signal handlers re-raise the signal after sweeping. Naively running\n * the sweep on SIGINT/SIGTERM and returning would *suppress* the\n * signal: Node defaults to terminating the process on these, but only\n * if no user listener is attached. Once we attach a listener, the\n * default action is cancelled and the process keeps running — which\n * means Ctrl-C would clean worktrees but not actually exit, leaving\n * orphan processes in dev. The `process.kill(pid, sig)` re-raise\n * after removing our own listener restores the default behaviour\n * (the second delivery now hits an empty listener list, so Node\n * terminates with the conventional `128 + signum` exit code).\n */\nexport function registerExitHandlers(registry: WorktreeRegistry): void {\n _activeRegistry = registry\n if (_registered) return\n _registered = true\n _exitHandler = () => sweepRegistry()\n _sigintHandler = () => {\n sweepRegistry()\n if (_sigintHandler) process.off(\"SIGINT\", _sigintHandler)\n process.kill(process.pid, \"SIGINT\")\n }\n _sigtermHandler = () => {\n sweepRegistry()\n if (_sigtermHandler) process.off(\"SIGTERM\", _sigtermHandler)\n process.kill(process.pid, \"SIGTERM\")\n }\n process.on(\"SIGINT\", _sigintHandler)\n process.on(\"SIGTERM\", _sigtermHandler)\n // `exit` handlers can only run synchronous code — exactly what\n // sweepRegistry does. Async work here would never complete.\n process.on(\"exit\", _exitHandler)\n}\n\n/**\n * Test-only: unregister the handlers and reset module state. Tests\n * that want to verify `registerExitHandlers` semantics must clean up\n * after themselves or future tests in the same process inherit the\n * (now stale) registry pointer.\n */\nexport function __unregisterExitHandlersForTests(): void {\n if (_sigintHandler) {\n process.off(\"SIGINT\", _sigintHandler)\n _sigintHandler = null\n }\n if (_sigtermHandler) {\n process.off(\"SIGTERM\", _sigtermHandler)\n _sigtermHandler = null\n }\n if (_exitHandler) {\n process.off(\"exit\", _exitHandler)\n _exitHandler = null\n }\n _registered = false\n _activeRegistry = null\n}\n\n// ---------------------------------------------------------------------\n// Ledger: which repos has this proxy touched?\n// ---------------------------------------------------------------------\n\ninterface LedgerEntry {\n repoRoot: string\n lastSeenMs: number\n}\n\ninterface LedgerFile {\n entries: Array<LedgerEntry>\n}\n\nfunction ledgerPath(): string {\n return path.join(PATHS.APP_DIR, \"worker-repos.json\")\n}\n\nasync function readLedger(): Promise<LedgerFile> {\n let raw: string\n try {\n raw = await fs.readFile(ledgerPath(), \"utf8\")\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === \"ENOENT\") {\n return { entries: [] }\n }\n return { entries: [] }\n }\n try {\n const parsed = JSON.parse(raw) as Partial<LedgerFile>\n if (!parsed || !Array.isArray(parsed.entries)) return { entries: [] }\n const cleaned: Array<LedgerEntry> = []\n for (const e of parsed.entries) {\n if (\n e &&\n typeof e === \"object\" &&\n typeof (e as LedgerEntry).repoRoot === \"string\" &&\n typeof (e as LedgerEntry).lastSeenMs === \"number\"\n ) {\n cleaned.push({\n repoRoot: (e as LedgerEntry).repoRoot,\n lastSeenMs: (e as LedgerEntry).lastSeenMs,\n })\n }\n }\n return { entries: cleaned }\n } catch {\n // Corrupted JSON — start fresh rather than crashing the proxy.\n return { entries: [] }\n }\n}\n\n/**\n * Per-process serializer for ledger writes. Multiple concurrent\n * `recordWorkerRepo` calls (legitimate: several workers may start at\n * once) would otherwise race read-modify-write on the JSON file. Each\n * call chains onto the previous so the on-disk sequence is\n * deterministic from this process's perspective.\n *\n * Cross-process safety is provided by the atomic temp+rename below,\n * which makes the final state of the file always be a well-formed\n * full snapshot from ONE writer — never a partial write or\n * interleaved JSON.\n */\nlet _ledgerChain: Promise<void> = Promise.resolve()\n\n/**\n * Append `repoRoot` to the ledger (or update its `lastSeenMs`).\n * Atomic temp+rename per peer review.\n */\nexport function recordWorkerRepo(repoRoot: string): Promise<void> {\n const next = _ledgerChain.then(async () => {\n await fs.mkdir(PATHS.APP_DIR, { recursive: true })\n const current = await readLedger()\n // Dedup: drop any existing entry for this root before appending\n // the fresh one so the array doesn't grow unbounded with repeats.\n const filtered = current.entries.filter((e) => e.repoRoot !== repoRoot)\n filtered.push({ repoRoot, lastSeenMs: Date.now() })\n // Prune by age and cap entry count (newest wins).\n const now = Date.now()\n const pruned = filtered\n .filter((e) => now - e.lastSeenMs < LEDGER_MAX_AGE_MS)\n .slice(-LEDGER_MAX_ENTRIES)\n const ledger: LedgerFile = { entries: pruned }\n\n // Atomic temp+rename. The temp filename is unique per call\n // (PID + 8 random hex chars) so concurrent processes don't\n // collide on the temp name; the final `rename` is atomic on\n // POSIX and on Windows (both with same filesystem).\n const tmp = `${ledgerPath()}.tmp.${process.pid}.${randomBytes(4).toString(\n \"hex\",\n )}`\n try {\n await writeRuntimeFileSecure(tmp, JSON.stringify(ledger, null, 2))\n await fs.rename(tmp, ledgerPath())\n } catch (err) {\n // Clean up the temp file if rename failed midway.\n await fs.unlink(tmp).catch(() => {})\n throw err\n }\n })\n // Swallow chain-internal errors so one failed write doesn't poison\n // the chain for every subsequent caller. Each call still sees its\n // own rejection (we return `next`, not the catch-handler chain).\n _ledgerChain = next.catch(() => undefined)\n return next\n}\n\nfunction isPidAlive(pid: number): boolean {\n if (!Number.isInteger(pid) || pid <= 0) return false\n try {\n process.kill(pid, 0)\n return true\n } catch (err) {\n const code = (err as NodeJS.ErrnoException).code\n // EPERM = process exists but we can't signal it — still alive\n // for our purposes (we just need to know whether to clean up).\n if (code === \"EPERM\") return true\n return false\n }\n}\n\n/**\n * Boot-time sweep. For every repo we recorded in the ledger,\n * enumerate `<repoRoot>/.git/worker-worktrees/` (the conventional\n * location — for repos already inside a worktree, the actual\n * `git-common-dir` may differ, in which case we'll miss this batch\n * and the per-call age sweep will catch them within 7 days) and\n * remove dirs that aren't owned by THIS proxy.\n *\n * Ownership rule: dir is \"ours\" iff its embedded PID is alive AND\n * its embedded UUID equals `getInstanceUuid()`. Either condition\n * failing → remove.\n */\nexport async function sweepStaleWorktreesAtBoot(): Promise<void> {\n const ledger = await readLedger()\n if (ledger.entries.length === 0) return\n const currentUuid = getInstanceUuid()\n for (const entry of ledger.entries) {\n const parent = path.join(entry.repoRoot, \".git\", \"worker-worktrees\")\n let names: Array<string>\n try {\n names = await fs.readdir(parent)\n } catch {\n continue\n }\n for (const name of names) {\n const m = WORKTREE_DIR_NAME_RE.exec(name)\n if (!m) continue\n const pid = Number.parseInt(m[1], 10)\n const uuid = m[2]\n const isOurs = isPidAlive(pid) && uuid === currentUuid\n if (isOurs) continue\n\n const fullDir = path.join(parent, name)\n const branch = `worker/${pid}-${uuid}-${m[3]}`\n try {\n // `-C entry.repoRoot` is load-bearing here too — see the\n // matching comment in `sweepRegistry`. The boot sweep runs\n // BEFORE any worker tool has set cwd, so the proxy's cwd is\n // the user's launch dir, which is almost never inside the\n // target repo.\n execFileSync(\n \"git\",\n [\"-C\", entry.repoRoot, \"worktree\", \"remove\", \"--force\", fullDir],\n { stdio: \"ignore\", timeout: 10_000, windowsHide: true },\n )\n } catch {\n // ignore\n }\n try {\n execFileSync(\n \"git\",\n [\"-C\", entry.repoRoot, \"branch\", \"-D\", branch],\n { stdio: \"ignore\", timeout: 5_000, windowsHide: true },\n )\n } catch {\n // ignore\n }\n try {\n await fs.rm(fullDir, { recursive: true, force: true })\n } catch {\n // ignore — git may have removed it already\n }\n }\n }\n}\n\n/** Test-only: clear the ledger file (does NOT remove on-disk worktrees). */\nexport async function __clearLedgerForTests(): Promise<void> {\n await fs.unlink(ledgerPath()).catch(() => {})\n}\n\n/** Test-only: read the ledger as a plain array (no side effects). */\nexport async function __readLedgerForTests(): Promise<Array<LedgerEntry>> {\n return (await readLedger()).entries\n}\n"],"mappings":"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"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { t as PATHS } from "./paths-
|
|
1
|
+
import { t as PATHS } from "./paths-DWVKYv16.js";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
@@ -251,24 +251,58 @@ function runManagedExeCapture(command, args, opts = {}) {
|
|
|
251
251
|
const STDERR_CAP = 64 * 1024;
|
|
252
252
|
let timedOut = false;
|
|
253
253
|
let stdoutTruncated = false;
|
|
254
|
+
let stalled = false;
|
|
254
255
|
let settled = false;
|
|
255
|
-
|
|
256
|
-
const
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
256
|
+
let terminated = false;
|
|
257
|
+
const terminate = (reason) => {
|
|
258
|
+
if (terminated) return;
|
|
259
|
+
terminated = true;
|
|
260
|
+
if (reason === "timeout") timedOut = true;
|
|
261
|
+
else if (reason === "stall") stalled = true;
|
|
262
|
+
else stdoutTruncated = true;
|
|
263
|
+
if (timer) clearTimeout(timer);
|
|
264
|
+
if (inactivityTimer) clearTimeout(inactivityTimer);
|
|
265
|
+
killManagedTree(child, isWin);
|
|
266
|
+
};
|
|
267
|
+
const timer = opts.timeoutMs ? setTimeout(() => terminate("timeout"), opts.timeoutMs) : void 0;
|
|
260
268
|
timer?.unref?.();
|
|
269
|
+
let inactivityTimer;
|
|
270
|
+
const armInactivity = () => {
|
|
271
|
+
if (opts.inactivityTimeoutMs === void 0 || settled || terminated) return;
|
|
272
|
+
inactivityTimer = setTimeout(() => {
|
|
273
|
+
if (settled) return;
|
|
274
|
+
let progressing = false;
|
|
275
|
+
if (opts.onInactivityCheck) try {
|
|
276
|
+
progressing = opts.onInactivityCheck() === true;
|
|
277
|
+
} catch {
|
|
278
|
+
progressing = true;
|
|
279
|
+
}
|
|
280
|
+
if (progressing) {
|
|
281
|
+
armInactivity();
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
terminate("stall");
|
|
285
|
+
}, opts.inactivityTimeoutMs);
|
|
286
|
+
inactivityTimer?.unref?.();
|
|
287
|
+
};
|
|
288
|
+
const resetInactivity = () => {
|
|
289
|
+
if (inactivityTimer) clearTimeout(inactivityTimer);
|
|
290
|
+
armInactivity();
|
|
291
|
+
};
|
|
292
|
+
armInactivity();
|
|
261
293
|
child.stdout?.on("data", (c) => {
|
|
294
|
+
resetInactivity();
|
|
262
295
|
if (stdoutTruncated) return;
|
|
263
296
|
stdoutBytes += c.length;
|
|
264
297
|
if (opts.maxStdoutBytes !== void 0 && stdoutBytes > opts.maxStdoutBytes) {
|
|
265
298
|
stdoutTruncated = true;
|
|
266
|
-
|
|
299
|
+
if (!opts.truncateInsteadOfKill) terminate("truncate");
|
|
267
300
|
return;
|
|
268
301
|
}
|
|
269
302
|
chunks.push(c);
|
|
270
303
|
});
|
|
271
304
|
child.stderr?.on("data", (c) => {
|
|
305
|
+
resetInactivity();
|
|
272
306
|
if (stderrBytes >= STDERR_CAP) return;
|
|
273
307
|
const remaining = STDERR_CAP - stderrBytes;
|
|
274
308
|
const slice = c.length > remaining ? c.subarray(0, remaining) : c;
|
|
@@ -281,18 +315,21 @@ function runManagedExeCapture(command, args, opts = {}) {
|
|
|
281
315
|
if (settled) return;
|
|
282
316
|
settled = true;
|
|
283
317
|
if (timer) clearTimeout(timer);
|
|
318
|
+
if (inactivityTimer) clearTimeout(inactivityTimer);
|
|
284
319
|
resolve({
|
|
285
320
|
stdout: Buffer.concat(chunks).toString("utf8"),
|
|
286
321
|
stderr: Buffer.concat(stderrChunks).toString("utf8"),
|
|
287
322
|
code,
|
|
288
323
|
timedOut,
|
|
289
|
-
stdoutTruncated
|
|
324
|
+
stdoutTruncated,
|
|
325
|
+
stalled
|
|
290
326
|
});
|
|
291
327
|
};
|
|
292
328
|
child.on("error", (err) => {
|
|
293
329
|
if (settled) return;
|
|
294
330
|
settled = true;
|
|
295
331
|
if (timer) clearTimeout(timer);
|
|
332
|
+
if (inactivityTimer) clearTimeout(inactivityTimer);
|
|
296
333
|
reject(err);
|
|
297
334
|
});
|
|
298
335
|
child.on("close", (code) => finish(code));
|
|
@@ -393,6 +430,12 @@ function registerColbertExitHandlers() {
|
|
|
393
430
|
process.on("SIGTERM", _sigtermHandler);
|
|
394
431
|
process.on("exit", _exitHandler);
|
|
395
432
|
}
|
|
433
|
+
/**
|
|
434
|
+
* True iff `pid` names a live process. `process.kill(pid, 0)` probes
|
|
435
|
+
* existence without signalling; `EPERM` means the process exists but is
|
|
436
|
+
* owned by another user (still alive). Exported so the per-query freshness
|
|
437
|
+
* verdict can mirror the boot sweep's liveness check.
|
|
438
|
+
*/
|
|
396
439
|
function isPidAlive(pid) {
|
|
397
440
|
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
398
441
|
try {
|
|
@@ -437,6 +480,7 @@ async function sweepStaleColbertMetaAtBoot() {
|
|
|
437
480
|
const buildPid = typeof meta.buildPid === "number" ? meta.buildPid : 0;
|
|
438
481
|
if (buildPid > 0 && isPidAlive(buildPid)) continue;
|
|
439
482
|
meta.status = "failed";
|
|
483
|
+
meta.failureClass = "crashed";
|
|
440
484
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
441
485
|
try {
|
|
442
486
|
await fs.writeFile(tmp, JSON.stringify(meta, null, 2));
|
|
@@ -448,5 +492,5 @@ async function sweepStaleColbertMetaAtBoot() {
|
|
|
448
492
|
}
|
|
449
493
|
|
|
450
494
|
//#endregion
|
|
451
|
-
export {
|
|
452
|
-
//# sourceMappingURL=lifecycle-
|
|
495
|
+
export { sweepStaleColbertMetaAtBoot as a, resolveExecutable as c, runManagedExeCapture as d, sweepLiveChildren as i, runCommandCapture as l, isPidAlive as n, trackChild as o, registerColbertExitHandlers as r, parseBoolEnv as s, getColbertInstanceUuid as t, runCommandVoid as u };
|
|
496
|
+
//# sourceMappingURL=lifecycle-CTLlFU45.js.map
|