larkway 0.3.37 → 0.3.39
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 +42 -3
- package/README.zh.md +1 -1
- package/dist/cli/index.js +119 -16
- package/dist/main.js +358 -96
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
You @ the bot in a Feishu thread. It runs on your machine — reading your real codebase, executing commands, opening MRs — and posts the result back. You define what the agent knows and what it can do. Larkway just carries the messages.
|
|
10
10
|
|
|
11
|
-
**Current release: v0.3.
|
|
11
|
+
**Current release: v0.3.39**
|
|
12
12
|
|
|
13
13
|
---
|
|
14
14
|
|
|
@@ -105,6 +105,45 @@ Larkway never injects `ANTHROPIC_API_KEY` or any other API key. The subprocess i
|
|
|
105
105
|
|
|
106
106
|
---
|
|
107
107
|
|
|
108
|
+
## Security model — read this before inviting the bot anywhere
|
|
109
|
+
|
|
110
|
+
Larkway runs a real coding agent **on your machine, with your permissions**. Be
|
|
111
|
+
explicit about what that means:
|
|
112
|
+
|
|
113
|
+
**Who can trigger it.** By default a bot ships with `chats: []` — *open mode*:
|
|
114
|
+
it responds to an @-mention in **any** group it has been added to. Anyone who
|
|
115
|
+
can add the bot to a group (or is in a group with it) can make it act. To
|
|
116
|
+
narrow this, list allowed group ids in the bot's yaml (`chats: [oc_…]`); the
|
|
117
|
+
bot then ignores every other chat.
|
|
118
|
+
|
|
119
|
+
**What a triggered agent can do.** The agent subprocess is not sandboxed by
|
|
120
|
+
Larkway. With the default permission mode (`bypassPermissions` — required for
|
|
121
|
+
unattended headless operation, where an interactive permission prompt would
|
|
122
|
+
silently hang the turn), a Claude-backend agent can read/write files, run
|
|
123
|
+
commands, and use the network as your OS user. The Codex backend's equivalent
|
|
124
|
+
default is full access. You can tighten this globally via `permissions.mode`
|
|
125
|
+
in `~/.larkway/config.json` (`acceptEdits` / `ask`), at the cost of turns
|
|
126
|
+
stalling on operations the mode blocks. **Note the mode words are Claude
|
|
127
|
+
semantics; on Codex anything other than `ask` currently maps to full access.**
|
|
128
|
+
|
|
129
|
+
**Prompt injection is real.** Everything in the triggering message — and
|
|
130
|
+
thread history / attachments the agent chooses to read — becomes agent input.
|
|
131
|
+
A malicious group member can try to steer the agent ("ignore your instructions
|
|
132
|
+
and run …"). L2 memory and your repo's `CLAUDE.md`/`AGENTS.md` are guidance,
|
|
133
|
+
not enforcement.
|
|
134
|
+
|
|
135
|
+
**Practical posture.**
|
|
136
|
+
- Treat "which groups is this bot in" as the primary security boundary; use
|
|
137
|
+
`chats:` allowlists for any bot with write access.
|
|
138
|
+
- Run write-capable bots against repos where an unwanted commit/MR is
|
|
139
|
+
reviewable and revertable; keep production credentials out of the host's
|
|
140
|
+
environment.
|
|
141
|
+
- Secrets in `~/.larkway/.env` are shared across all bots on the bridge and
|
|
142
|
+
visible to any agent that can read your home directory — don't mix a
|
|
143
|
+
low-trust open bot and high-value credentials on the same host.
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
108
147
|
## Defining a bot (three layers)
|
|
109
148
|
|
|
110
149
|
| Layer | What it is | Where it lives |
|
|
@@ -122,7 +161,7 @@ Secrets live only in `~/.larkway/.env` (mode 0600). Config and memory contain no
|
|
|
122
161
|
- **Multiple bots on one bridge** — a read-only Q&A bot and a write-capable engineering bot can share the same process, each with its own L1/L2/L3 definition
|
|
123
162
|
- **Web UI** — `larkway ui` opens a local management dashboard (127.0.0.1 + token); create bots, edit memory, watch live logs
|
|
124
163
|
- **Session continuity** — every Feishu thread maps to a persistent `session_id`; the agent remembers what it did in prior turns
|
|
125
|
-
- **Agent Workspace** — per-thread
|
|
164
|
+
- **Agent Workspace** — each bot gets its own workspace where the agent clones the repo itself, with per-thread session dirs; concurrent threads don't trip over each other's git state (expect disk usage and a slower first turn on large repos — clones are per-session, GC'd after 24h idle)
|
|
126
165
|
- **Codex runtime pre-checks** — `larkway doctor` validates Codex state directory writability before start
|
|
127
166
|
- **Topic ↔ Feishu task handle** — turn a topic into a Feishu task and the agent claims it, then keeps its lifecycle (done/failed/reopened, stalled, handed off, overdue) in sync automatically — see below
|
|
128
167
|
|
|
@@ -157,7 +196,7 @@ platform-fact writeups: [docs/task-handle.md](docs/task-handle.md).
|
|
|
157
196
|
|
|
158
197
|
- **Node.js 20+ LTS**
|
|
159
198
|
- **A Claude Code or Codex subscription** with local CLI installed and logged in
|
|
160
|
-
- **`lark-cli`** — Feishu long-connection client and message utilities
|
|
199
|
+
- **`lark-cli`** — Feishu long-connection client and message utilities: `npm i -g @larksuite/cli`, then `lark-cli auth login` (the agent uses it to read thread history and attachments; `larkway doctor` checks it's present)
|
|
161
200
|
- **`glab` + `git`** — for bots that open MRs (optional for read-only bots)
|
|
162
201
|
- **An always-on host machine** — the bridge must stay running to receive Feishu events; a laptop that sleeps will miss messages; a small server or desktop works well
|
|
163
202
|
|
package/README.zh.md
CHANGED
package/dist/cli/index.js
CHANGED
|
@@ -112719,15 +112719,27 @@ var init_channelClient = __esm({
|
|
|
112719
112719
|
inFlightMessageIds = /* @__PURE__ */ new Set();
|
|
112720
112720
|
/**
|
|
112721
112721
|
* Per-message (re-)dispatch counter for the poison-message guard. Incremented
|
|
112722
|
-
* every time a message_id is pushed onto the inbound
|
|
112723
|
-
* gap-fill branch) and
|
|
112724
|
-
*
|
|
112725
|
-
*
|
|
112726
|
-
*
|
|
112722
|
+
* ONCE per dispatch — every time a message_id is pushed onto the inbound
|
|
112723
|
+
* queue (live WS or either gap-fill branch), and nowhere else, so the cap
|
|
112724
|
+
* really means "N dispatches". When a turn fails and the count has reached
|
|
112725
|
+
* {@link MAX_MESSAGE_ATTEMPTS}, markUnhandled GIVES UP: it promotes the
|
|
112726
|
+
* message to seen (so it stops being re-dispatched) and logs a warning.
|
|
112727
|
+
* markHandled clears the entry on terminal success. Not persisted:
|
|
112727
112728
|
* post-restart, an interrupted message starts fresh — same policy as
|
|
112728
112729
|
* inFlightMessageIds.
|
|
112729
112730
|
*/
|
|
112730
112731
|
messageAttempts = /* @__PURE__ */ new Map();
|
|
112732
|
+
/**
|
|
112733
|
+
* chatId + create_time for every in-flight message, captured at dispatch.
|
|
112734
|
+
* Consumed by {@link markUnhandled}: a FAILED turn records an unresolved gap
|
|
112735
|
+
* window for its chat so a later replay actually re-pulls (and re-dispatches)
|
|
112736
|
+
* it. Without this there is no steady-state trigger for the re-dispatch that
|
|
112737
|
+
* markUnhandled promises — chats-mode bots have no discovery timer at all,
|
|
112738
|
+
* and open-mode steady-state discovery pulls 0 for already-known chats — so
|
|
112739
|
+
* a turn failing before any visible surface existed was silently dropped.
|
|
112740
|
+
* Cleared in markHandled / markUnhandled; bounded by the in-flight set.
|
|
112741
|
+
*/
|
|
112742
|
+
inFlightMessageMeta = /* @__PURE__ */ new Map();
|
|
112731
112743
|
/**
|
|
112732
112744
|
* Chats observed from live WS events during this process lifetime.
|
|
112733
112745
|
*
|
|
@@ -112762,6 +112774,17 @@ var init_channelClient = __esm({
|
|
|
112762
112774
|
openChatDiscoveryTimer = null;
|
|
112763
112775
|
openChatDiscoveryRunning = false;
|
|
112764
112776
|
openChatDiscoveryBootstrapped = false;
|
|
112777
|
+
/**
|
|
112778
|
+
* Chats-mode (allowedChatIds non-empty) counterpart of the open-mode
|
|
112779
|
+
* discovery cycle's unresolved-window replay: those bots never start
|
|
112780
|
+
* discovery, so without this timer a window recorded by markUnhandled had
|
|
112781
|
+
* NO trigger to replay it (the only other gapFill source is a WS reconnect,
|
|
112782
|
+
* which a healthy network may not produce for days). Steady state with no
|
|
112783
|
+
* pending windows does nothing — zero API calls — so the 0.3.28 storm fix
|
|
112784
|
+
* is preserved.
|
|
112785
|
+
*/
|
|
112786
|
+
unresolvedReplayTimer = null;
|
|
112787
|
+
unresolvedReplayRunning = false;
|
|
112765
112788
|
/**
|
|
112766
112789
|
* Consecutive discovery-cycle failures. Used to SKIP cycles with exponential
|
|
112767
112790
|
* backoff (storm: a failing +chat-list/gap-fill shouldn't re-fire every
|
|
@@ -112967,6 +112990,7 @@ var init_channelClient = __esm({
|
|
|
112967
112990
|
return;
|
|
112968
112991
|
}
|
|
112969
112992
|
this.inFlightMessageIds.add(ev.message_id);
|
|
112993
|
+
this.noteInFlightMeta(ev);
|
|
112970
112994
|
this.noteDispatchAttempt(ev.message_id);
|
|
112971
112995
|
log(`dispatching (channel-sdk): message_id=${ev.message_id} thread=${ev.thread_id ?? "?"}`);
|
|
112972
112996
|
this.queue.push(ev);
|
|
@@ -112995,6 +113019,7 @@ var init_channelClient = __esm({
|
|
|
112995
113019
|
this.connected = true;
|
|
112996
113020
|
log(`connected as ${channel.botIdentity?.name ?? "?"} (${channel.botIdentity?.openId ?? "?"})`);
|
|
112997
113021
|
this.startOpenChatDiscovery(log);
|
|
113022
|
+
this.startUnresolvedReplayTimer(log);
|
|
112998
113023
|
}
|
|
112999
113024
|
/**
|
|
113000
113025
|
* Turn a card-button click into a synthesized LarkMessageEvent pushed onto the
|
|
@@ -113235,6 +113260,7 @@ var init_channelClient = __esm({
|
|
|
113235
113260
|
})();
|
|
113236
113261
|
if (!fallbackEv) continue;
|
|
113237
113262
|
this.inFlightMessageIds.add(fallbackEv.message_id);
|
|
113263
|
+
this.noteInFlightMeta(fallbackEv);
|
|
113238
113264
|
this.noteDispatchAttempt(fallbackEv.message_id);
|
|
113239
113265
|
log(`gap-fill dispatching (fallback): message_id=${fallbackEv.message_id} chat=${chatId}`);
|
|
113240
113266
|
this.queue.push(fallbackEv);
|
|
@@ -113242,6 +113268,7 @@ var init_channelClient = __esm({
|
|
|
113242
113268
|
continue;
|
|
113243
113269
|
}
|
|
113244
113270
|
this.inFlightMessageIds.add(ev.message_id);
|
|
113271
|
+
this.noteInFlightMeta(ev);
|
|
113245
113272
|
this.noteDispatchAttempt(ev.message_id);
|
|
113246
113273
|
log(`gap-fill dispatching: message_id=${ev.message_id} thread=${ev.thread_id ?? "?"} chat=${chatId}`);
|
|
113247
113274
|
this.queue.push(ev);
|
|
@@ -113332,6 +113359,37 @@ var init_channelClient = __esm({
|
|
|
113332
113359
|
if (tracked === void 0) return;
|
|
113333
113360
|
if (coveredFrom <= tracked) this.unresolvedGapWindowByChat.delete(chatId);
|
|
113334
113361
|
}
|
|
113362
|
+
/**
|
|
113363
|
+
* Chats-mode replay loop (see {@link unresolvedReplayTimer}): every discovery
|
|
113364
|
+
* interval, IF any unresolved gap window is pending, gapFill exactly those
|
|
113365
|
+
* chats. Open-mode bots don't need this — their discovery cycle already
|
|
113366
|
+
* includes unresolved-window chats in its target set.
|
|
113367
|
+
*/
|
|
113368
|
+
startUnresolvedReplayTimer(log) {
|
|
113369
|
+
if (this.opts.allowedChatIds.size === 0) return;
|
|
113370
|
+
if (this.unresolvedReplayTimer) return;
|
|
113371
|
+
const intervalMs = resolveOpenChatDiscoveryMs(this.opts.openChatDiscoveryMs);
|
|
113372
|
+
if (intervalMs <= 0) return;
|
|
113373
|
+
this.unresolvedReplayTimer = setInterval(() => {
|
|
113374
|
+
void this.replayUnresolvedWindows(log);
|
|
113375
|
+
}, intervalMs);
|
|
113376
|
+
this.unresolvedReplayTimer.unref?.();
|
|
113377
|
+
}
|
|
113378
|
+
async replayUnresolvedWindows(log) {
|
|
113379
|
+
if (this.closed || this.unresolvedReplayRunning) return;
|
|
113380
|
+
this.pruneUnresolvedGapWindows(Date.now());
|
|
113381
|
+
if (this.unresolvedGapWindowByChat.size === 0) return;
|
|
113382
|
+
this.unresolvedReplayRunning = true;
|
|
113383
|
+
try {
|
|
113384
|
+
const chats = new Set(this.unresolvedGapWindowByChat.keys());
|
|
113385
|
+
log(`unresolved-window replay: re-pulling ${chats.size} chat(s)`);
|
|
113386
|
+
await this.gapFill(Date.now(), log, chats);
|
|
113387
|
+
} catch (e) {
|
|
113388
|
+
log(`unresolved-window replay failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
113389
|
+
} finally {
|
|
113390
|
+
this.unresolvedReplayRunning = false;
|
|
113391
|
+
}
|
|
113392
|
+
}
|
|
113335
113393
|
startOpenChatDiscovery(log) {
|
|
113336
113394
|
if (this.opts.allowedChatIds.size > 0) return;
|
|
113337
113395
|
if (this.openChatDiscoveryTimer) return;
|
|
@@ -113601,6 +113659,7 @@ var init_channelClient = __esm({
|
|
|
113601
113659
|
*/
|
|
113602
113660
|
markHandled(messageId) {
|
|
113603
113661
|
this.inFlightMessageIds.delete(messageId);
|
|
113662
|
+
this.inFlightMessageMeta.delete(messageId);
|
|
113604
113663
|
this.messageAttempts.delete(messageId);
|
|
113605
113664
|
this.noteSeenMessage(messageId);
|
|
113606
113665
|
}
|
|
@@ -113610,38 +113669,63 @@ var init_channelClient = __esm({
|
|
|
113610
113669
|
* self-heal — one transient blip no longer swallows the @ forever). Does not
|
|
113611
113670
|
* touch persisted seen state.
|
|
113612
113671
|
*
|
|
113613
|
-
* Poison-message guard:
|
|
113614
|
-
*
|
|
113615
|
-
*
|
|
113616
|
-
*
|
|
113672
|
+
* Poison-message guard: if the message has already been dispatched
|
|
113673
|
+
* {@link MAX_MESSAGE_ATTEMPTS} times, GIVE UP — promote it to seen (so it
|
|
113674
|
+
* stops being re-dispatched on every gap-fill) and log a visible warning
|
|
113675
|
+
* instead of silently looping forever. Otherwise, record an unresolved gap
|
|
113676
|
+
* window for the message's chat so the next replay cycle re-dispatches it.
|
|
113617
113677
|
*/
|
|
113618
|
-
markUnhandled(messageId) {
|
|
113678
|
+
markUnhandled(messageId, opts) {
|
|
113619
113679
|
this.inFlightMessageIds.delete(messageId);
|
|
113620
|
-
const
|
|
113621
|
-
this.
|
|
113680
|
+
const meta = this.inFlightMessageMeta.get(messageId);
|
|
113681
|
+
this.inFlightMessageMeta.delete(messageId);
|
|
113682
|
+
const attempts = this.messageAttempts.get(messageId) ?? 0;
|
|
113622
113683
|
if (attempts >= MAX_MESSAGE_ATTEMPTS) {
|
|
113623
113684
|
console.warn(
|
|
113624
113685
|
`[channel.client] giving up on message_id=${messageId} after ${attempts} failed attempts \u2014 promoting to seen so it is no longer re-dispatched (poison-message guard)`
|
|
113625
113686
|
);
|
|
113626
113687
|
this.messageAttempts.delete(messageId);
|
|
113627
113688
|
this.noteSeenMessage(messageId);
|
|
113689
|
+
return;
|
|
113690
|
+
}
|
|
113691
|
+
if (meta && opts?.replay !== false) {
|
|
113692
|
+
this.recordUnresolvedGapWindow(
|
|
113693
|
+
meta.chatId,
|
|
113694
|
+
meta.createTimeMs - 1e3,
|
|
113695
|
+
(s) => console.log(`[channel.client] ${s}`)
|
|
113696
|
+
);
|
|
113628
113697
|
}
|
|
113629
113698
|
}
|
|
113630
113699
|
/**
|
|
113631
113700
|
* Increment the per-message dispatch counter (poison-message guard). Called
|
|
113632
|
-
* each time a message_id is pushed onto the inbound queue
|
|
113633
|
-
*
|
|
113634
|
-
*
|
|
113701
|
+
* each time a message_id is pushed onto the inbound queue — and ONLY then;
|
|
113702
|
+
* {@link markUnhandled} deliberately reads without bumping, so the cap means
|
|
113703
|
+
* "N real dispatches" (see the messageAttempts field doc).
|
|
113635
113704
|
*/
|
|
113636
113705
|
noteDispatchAttempt(messageId) {
|
|
113637
113706
|
this.messageAttempts.set(messageId, (this.messageAttempts.get(messageId) ?? 0) + 1);
|
|
113638
113707
|
}
|
|
113708
|
+
/**
|
|
113709
|
+
* Capture chatId + create_time for an in-flight message at dispatch time, so
|
|
113710
|
+
* {@link markUnhandled} can queue an unresolved gap window that reaches back
|
|
113711
|
+
* to the message itself. An unparseable create_time falls back to "1 min ago"
|
|
113712
|
+
* — wide enough to cover the message without flooding the replay pull.
|
|
113713
|
+
*/
|
|
113714
|
+
noteInFlightMeta(ev) {
|
|
113715
|
+
const t = Number(ev.create_time);
|
|
113716
|
+
const createTimeMs = Number.isFinite(t) && t > 0 ? t < 1e12 ? t * 1e3 : t : Date.now() - 6e4;
|
|
113717
|
+
this.inFlightMessageMeta.set(ev.message_id, { chatId: ev.chat_id, createTimeMs });
|
|
113718
|
+
}
|
|
113639
113719
|
async close() {
|
|
113640
113720
|
this.closed = true;
|
|
113641
113721
|
if (this.openChatDiscoveryTimer) {
|
|
113642
113722
|
clearInterval(this.openChatDiscoveryTimer);
|
|
113643
113723
|
this.openChatDiscoveryTimer = null;
|
|
113644
113724
|
}
|
|
113725
|
+
if (this.unresolvedReplayTimer) {
|
|
113726
|
+
clearInterval(this.unresolvedReplayTimer);
|
|
113727
|
+
this.unresolvedReplayTimer = null;
|
|
113728
|
+
}
|
|
113645
113729
|
this.queue.close();
|
|
113646
113730
|
if (this.channel && this.connected) {
|
|
113647
113731
|
try {
|
|
@@ -127722,6 +127806,19 @@ async function checkClaude(ctx) {
|
|
|
127722
127806
|
message: required ? claudeLoginHint() : "\u672A\u68C0\u6D4B\u5230 Claude \u767B\u5F55\u6001(\u5F53\u524D\u65E0 bot \u4F7F\u7528 claude backend,\u53EF\u5FFD\u7565)\u3002\u5982\u9700\u4F7F\u7528 Claude Code backend,\u8BF7\u5148\u8FD0\u884C `claude` \u767B\u5F55\u3002"
|
|
127723
127807
|
};
|
|
127724
127808
|
}
|
|
127809
|
+
async function checkLarkCli() {
|
|
127810
|
+
try {
|
|
127811
|
+
await execFileAsync4("lark-cli", ["--version"], { timeout: 1e4 });
|
|
127812
|
+
return { id: "lark-cli-binary", label: "lark-cli", status: "ok" };
|
|
127813
|
+
} catch {
|
|
127814
|
+
return {
|
|
127815
|
+
id: "lark-cli-binary",
|
|
127816
|
+
label: "lark-cli",
|
|
127817
|
+
status: "error",
|
|
127818
|
+
message: "\u672A\u627E\u5230 `lark-cli`(\u98DE\u4E66\u957F\u8FDE\u63A5/\u6D88\u606F\u5DE5\u5177,larkway \u786C\u4F9D\u8D56)\u3002\u5B89\u88C5:`npm i -g @larksuite/cli`,\u7136\u540E `lark-cli auth login` \u5B8C\u6210\u6388\u6743\u3002"
|
|
127819
|
+
};
|
|
127820
|
+
}
|
|
127821
|
+
}
|
|
127725
127822
|
async function checkCodex(ctx) {
|
|
127726
127823
|
const results = [];
|
|
127727
127824
|
const required = await backendRequired(ctx, "codex");
|
|
@@ -128149,6 +128246,7 @@ async function runAllChecks(ctx, opts) {
|
|
|
128149
128246
|
results.push(await checkClaude(ctx));
|
|
128150
128247
|
const credChecks = await checkFeishuCreds(ctx);
|
|
128151
128248
|
results.push(...credChecks);
|
|
128249
|
+
results.push(await checkLarkCli());
|
|
128152
128250
|
const yamlChecks = await checkBotYaml(ctx);
|
|
128153
128251
|
results.push(...yamlChecks);
|
|
128154
128252
|
const wtChecks = await checkWorktrees(ctx);
|
|
@@ -130998,6 +131096,7 @@ async function finalizeOnboard(sessionId, form) {
|
|
|
130998
131096
|
if (!s._creds) {
|
|
130999
131097
|
throw new Error("\u5185\u90E8\u9519\u8BEF:creds \u672A\u6301\u6709");
|
|
131000
131098
|
}
|
|
131099
|
+
s.status = "finalizing";
|
|
131001
131100
|
try {
|
|
131002
131101
|
const { botId } = await createBotFromCreds({
|
|
131003
131102
|
creds: s._creds,
|
|
@@ -131019,12 +131118,16 @@ async function finalizeOnboard(sessionId, form) {
|
|
|
131019
131118
|
async function cancelOnboard(sessionId) {
|
|
131020
131119
|
const s = sessions.get(sessionId);
|
|
131021
131120
|
if (!s) return { cancelled: false };
|
|
131022
|
-
if (s.status === "done" || s.status === "error" || s.status === "cancelled"
|
|
131121
|
+
if (s.status === "done" || s.status === "error" || s.status === "cancelled" || // A finalize is already writing this session's bot to disk — treat like a
|
|
131122
|
+
// terminal state (racing a SECOND createBotFromCreds here would land two
|
|
131123
|
+
// bots from one Feishu app). The finalize's own outcome stands.
|
|
131124
|
+
s.status === "finalizing") {
|
|
131023
131125
|
return { cancelled: false };
|
|
131024
131126
|
}
|
|
131025
131127
|
if (s.status === "awaiting-name" && s._creds) {
|
|
131026
131128
|
const defaultName = s.prefill?.suggestedName || "\u65B0\u52A9\u624B";
|
|
131027
131129
|
const form = { name: defaultName };
|
|
131130
|
+
s.status = "finalizing";
|
|
131028
131131
|
try {
|
|
131029
131132
|
const { botId } = await createBotFromCreds({
|
|
131030
131133
|
creds: s._creds,
|
package/dist/main.js
CHANGED
|
@@ -116846,15 +116846,27 @@ var ChannelClient = class {
|
|
|
116846
116846
|
inFlightMessageIds = /* @__PURE__ */ new Set();
|
|
116847
116847
|
/**
|
|
116848
116848
|
* Per-message (re-)dispatch counter for the poison-message guard. Incremented
|
|
116849
|
-
* every time a message_id is pushed onto the inbound
|
|
116850
|
-
* gap-fill branch) and
|
|
116851
|
-
*
|
|
116852
|
-
*
|
|
116853
|
-
*
|
|
116849
|
+
* ONCE per dispatch — every time a message_id is pushed onto the inbound
|
|
116850
|
+
* queue (live WS or either gap-fill branch), and nowhere else, so the cap
|
|
116851
|
+
* really means "N dispatches". When a turn fails and the count has reached
|
|
116852
|
+
* {@link MAX_MESSAGE_ATTEMPTS}, markUnhandled GIVES UP: it promotes the
|
|
116853
|
+
* message to seen (so it stops being re-dispatched) and logs a warning.
|
|
116854
|
+
* markHandled clears the entry on terminal success. Not persisted:
|
|
116854
116855
|
* post-restart, an interrupted message starts fresh — same policy as
|
|
116855
116856
|
* inFlightMessageIds.
|
|
116856
116857
|
*/
|
|
116857
116858
|
messageAttempts = /* @__PURE__ */ new Map();
|
|
116859
|
+
/**
|
|
116860
|
+
* chatId + create_time for every in-flight message, captured at dispatch.
|
|
116861
|
+
* Consumed by {@link markUnhandled}: a FAILED turn records an unresolved gap
|
|
116862
|
+
* window for its chat so a later replay actually re-pulls (and re-dispatches)
|
|
116863
|
+
* it. Without this there is no steady-state trigger for the re-dispatch that
|
|
116864
|
+
* markUnhandled promises — chats-mode bots have no discovery timer at all,
|
|
116865
|
+
* and open-mode steady-state discovery pulls 0 for already-known chats — so
|
|
116866
|
+
* a turn failing before any visible surface existed was silently dropped.
|
|
116867
|
+
* Cleared in markHandled / markUnhandled; bounded by the in-flight set.
|
|
116868
|
+
*/
|
|
116869
|
+
inFlightMessageMeta = /* @__PURE__ */ new Map();
|
|
116858
116870
|
/**
|
|
116859
116871
|
* Chats observed from live WS events during this process lifetime.
|
|
116860
116872
|
*
|
|
@@ -116889,6 +116901,17 @@ var ChannelClient = class {
|
|
|
116889
116901
|
openChatDiscoveryTimer = null;
|
|
116890
116902
|
openChatDiscoveryRunning = false;
|
|
116891
116903
|
openChatDiscoveryBootstrapped = false;
|
|
116904
|
+
/**
|
|
116905
|
+
* Chats-mode (allowedChatIds non-empty) counterpart of the open-mode
|
|
116906
|
+
* discovery cycle's unresolved-window replay: those bots never start
|
|
116907
|
+
* discovery, so without this timer a window recorded by markUnhandled had
|
|
116908
|
+
* NO trigger to replay it (the only other gapFill source is a WS reconnect,
|
|
116909
|
+
* which a healthy network may not produce for days). Steady state with no
|
|
116910
|
+
* pending windows does nothing — zero API calls — so the 0.3.28 storm fix
|
|
116911
|
+
* is preserved.
|
|
116912
|
+
*/
|
|
116913
|
+
unresolvedReplayTimer = null;
|
|
116914
|
+
unresolvedReplayRunning = false;
|
|
116892
116915
|
/**
|
|
116893
116916
|
* Consecutive discovery-cycle failures. Used to SKIP cycles with exponential
|
|
116894
116917
|
* backoff (storm: a failing +chat-list/gap-fill shouldn't re-fire every
|
|
@@ -117094,6 +117117,7 @@ var ChannelClient = class {
|
|
|
117094
117117
|
return;
|
|
117095
117118
|
}
|
|
117096
117119
|
this.inFlightMessageIds.add(ev.message_id);
|
|
117120
|
+
this.noteInFlightMeta(ev);
|
|
117097
117121
|
this.noteDispatchAttempt(ev.message_id);
|
|
117098
117122
|
log(`dispatching (channel-sdk): message_id=${ev.message_id} thread=${ev.thread_id ?? "?"}`);
|
|
117099
117123
|
this.queue.push(ev);
|
|
@@ -117122,6 +117146,7 @@ var ChannelClient = class {
|
|
|
117122
117146
|
this.connected = true;
|
|
117123
117147
|
log(`connected as ${channel.botIdentity?.name ?? "?"} (${channel.botIdentity?.openId ?? "?"})`);
|
|
117124
117148
|
this.startOpenChatDiscovery(log);
|
|
117149
|
+
this.startUnresolvedReplayTimer(log);
|
|
117125
117150
|
}
|
|
117126
117151
|
/**
|
|
117127
117152
|
* Turn a card-button click into a synthesized LarkMessageEvent pushed onto the
|
|
@@ -117362,6 +117387,7 @@ var ChannelClient = class {
|
|
|
117362
117387
|
})();
|
|
117363
117388
|
if (!fallbackEv) continue;
|
|
117364
117389
|
this.inFlightMessageIds.add(fallbackEv.message_id);
|
|
117390
|
+
this.noteInFlightMeta(fallbackEv);
|
|
117365
117391
|
this.noteDispatchAttempt(fallbackEv.message_id);
|
|
117366
117392
|
log(`gap-fill dispatching (fallback): message_id=${fallbackEv.message_id} chat=${chatId}`);
|
|
117367
117393
|
this.queue.push(fallbackEv);
|
|
@@ -117369,6 +117395,7 @@ var ChannelClient = class {
|
|
|
117369
117395
|
continue;
|
|
117370
117396
|
}
|
|
117371
117397
|
this.inFlightMessageIds.add(ev.message_id);
|
|
117398
|
+
this.noteInFlightMeta(ev);
|
|
117372
117399
|
this.noteDispatchAttempt(ev.message_id);
|
|
117373
117400
|
log(`gap-fill dispatching: message_id=${ev.message_id} thread=${ev.thread_id ?? "?"} chat=${chatId}`);
|
|
117374
117401
|
this.queue.push(ev);
|
|
@@ -117459,6 +117486,37 @@ var ChannelClient = class {
|
|
|
117459
117486
|
if (tracked === void 0) return;
|
|
117460
117487
|
if (coveredFrom <= tracked) this.unresolvedGapWindowByChat.delete(chatId);
|
|
117461
117488
|
}
|
|
117489
|
+
/**
|
|
117490
|
+
* Chats-mode replay loop (see {@link unresolvedReplayTimer}): every discovery
|
|
117491
|
+
* interval, IF any unresolved gap window is pending, gapFill exactly those
|
|
117492
|
+
* chats. Open-mode bots don't need this — their discovery cycle already
|
|
117493
|
+
* includes unresolved-window chats in its target set.
|
|
117494
|
+
*/
|
|
117495
|
+
startUnresolvedReplayTimer(log) {
|
|
117496
|
+
if (this.opts.allowedChatIds.size === 0) return;
|
|
117497
|
+
if (this.unresolvedReplayTimer) return;
|
|
117498
|
+
const intervalMs = resolveOpenChatDiscoveryMs(this.opts.openChatDiscoveryMs);
|
|
117499
|
+
if (intervalMs <= 0) return;
|
|
117500
|
+
this.unresolvedReplayTimer = setInterval(() => {
|
|
117501
|
+
void this.replayUnresolvedWindows(log);
|
|
117502
|
+
}, intervalMs);
|
|
117503
|
+
this.unresolvedReplayTimer.unref?.();
|
|
117504
|
+
}
|
|
117505
|
+
async replayUnresolvedWindows(log) {
|
|
117506
|
+
if (this.closed || this.unresolvedReplayRunning) return;
|
|
117507
|
+
this.pruneUnresolvedGapWindows(Date.now());
|
|
117508
|
+
if (this.unresolvedGapWindowByChat.size === 0) return;
|
|
117509
|
+
this.unresolvedReplayRunning = true;
|
|
117510
|
+
try {
|
|
117511
|
+
const chats = new Set(this.unresolvedGapWindowByChat.keys());
|
|
117512
|
+
log(`unresolved-window replay: re-pulling ${chats.size} chat(s)`);
|
|
117513
|
+
await this.gapFill(Date.now(), log, chats);
|
|
117514
|
+
} catch (e) {
|
|
117515
|
+
log(`unresolved-window replay failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
117516
|
+
} finally {
|
|
117517
|
+
this.unresolvedReplayRunning = false;
|
|
117518
|
+
}
|
|
117519
|
+
}
|
|
117462
117520
|
startOpenChatDiscovery(log) {
|
|
117463
117521
|
if (this.opts.allowedChatIds.size > 0) return;
|
|
117464
117522
|
if (this.openChatDiscoveryTimer) return;
|
|
@@ -117728,6 +117786,7 @@ var ChannelClient = class {
|
|
|
117728
117786
|
*/
|
|
117729
117787
|
markHandled(messageId) {
|
|
117730
117788
|
this.inFlightMessageIds.delete(messageId);
|
|
117789
|
+
this.inFlightMessageMeta.delete(messageId);
|
|
117731
117790
|
this.messageAttempts.delete(messageId);
|
|
117732
117791
|
this.noteSeenMessage(messageId);
|
|
117733
117792
|
}
|
|
@@ -117737,38 +117796,63 @@ var ChannelClient = class {
|
|
|
117737
117796
|
* self-heal — one transient blip no longer swallows the @ forever). Does not
|
|
117738
117797
|
* touch persisted seen state.
|
|
117739
117798
|
*
|
|
117740
|
-
* Poison-message guard:
|
|
117741
|
-
*
|
|
117742
|
-
*
|
|
117743
|
-
*
|
|
117799
|
+
* Poison-message guard: if the message has already been dispatched
|
|
117800
|
+
* {@link MAX_MESSAGE_ATTEMPTS} times, GIVE UP — promote it to seen (so it
|
|
117801
|
+
* stops being re-dispatched on every gap-fill) and log a visible warning
|
|
117802
|
+
* instead of silently looping forever. Otherwise, record an unresolved gap
|
|
117803
|
+
* window for the message's chat so the next replay cycle re-dispatches it.
|
|
117744
117804
|
*/
|
|
117745
|
-
markUnhandled(messageId) {
|
|
117805
|
+
markUnhandled(messageId, opts) {
|
|
117746
117806
|
this.inFlightMessageIds.delete(messageId);
|
|
117747
|
-
const
|
|
117748
|
-
this.
|
|
117807
|
+
const meta = this.inFlightMessageMeta.get(messageId);
|
|
117808
|
+
this.inFlightMessageMeta.delete(messageId);
|
|
117809
|
+
const attempts = this.messageAttempts.get(messageId) ?? 0;
|
|
117749
117810
|
if (attempts >= MAX_MESSAGE_ATTEMPTS) {
|
|
117750
117811
|
console.warn(
|
|
117751
117812
|
`[channel.client] giving up on message_id=${messageId} after ${attempts} failed attempts \u2014 promoting to seen so it is no longer re-dispatched (poison-message guard)`
|
|
117752
117813
|
);
|
|
117753
117814
|
this.messageAttempts.delete(messageId);
|
|
117754
117815
|
this.noteSeenMessage(messageId);
|
|
117816
|
+
return;
|
|
117817
|
+
}
|
|
117818
|
+
if (meta && opts?.replay !== false) {
|
|
117819
|
+
this.recordUnresolvedGapWindow(
|
|
117820
|
+
meta.chatId,
|
|
117821
|
+
meta.createTimeMs - 1e3,
|
|
117822
|
+
(s) => console.log(`[channel.client] ${s}`)
|
|
117823
|
+
);
|
|
117755
117824
|
}
|
|
117756
117825
|
}
|
|
117757
117826
|
/**
|
|
117758
117827
|
* Increment the per-message dispatch counter (poison-message guard). Called
|
|
117759
|
-
* each time a message_id is pushed onto the inbound queue
|
|
117760
|
-
*
|
|
117761
|
-
*
|
|
117828
|
+
* each time a message_id is pushed onto the inbound queue — and ONLY then;
|
|
117829
|
+
* {@link markUnhandled} deliberately reads without bumping, so the cap means
|
|
117830
|
+
* "N real dispatches" (see the messageAttempts field doc).
|
|
117762
117831
|
*/
|
|
117763
117832
|
noteDispatchAttempt(messageId) {
|
|
117764
117833
|
this.messageAttempts.set(messageId, (this.messageAttempts.get(messageId) ?? 0) + 1);
|
|
117765
117834
|
}
|
|
117835
|
+
/**
|
|
117836
|
+
* Capture chatId + create_time for an in-flight message at dispatch time, so
|
|
117837
|
+
* {@link markUnhandled} can queue an unresolved gap window that reaches back
|
|
117838
|
+
* to the message itself. An unparseable create_time falls back to "1 min ago"
|
|
117839
|
+
* — wide enough to cover the message without flooding the replay pull.
|
|
117840
|
+
*/
|
|
117841
|
+
noteInFlightMeta(ev) {
|
|
117842
|
+
const t = Number(ev.create_time);
|
|
117843
|
+
const createTimeMs = Number.isFinite(t) && t > 0 ? t < 1e12 ? t * 1e3 : t : Date.now() - 6e4;
|
|
117844
|
+
this.inFlightMessageMeta.set(ev.message_id, { chatId: ev.chat_id, createTimeMs });
|
|
117845
|
+
}
|
|
117766
117846
|
async close() {
|
|
117767
117847
|
this.closed = true;
|
|
117768
117848
|
if (this.openChatDiscoveryTimer) {
|
|
117769
117849
|
clearInterval(this.openChatDiscoveryTimer);
|
|
117770
117850
|
this.openChatDiscoveryTimer = null;
|
|
117771
117851
|
}
|
|
117852
|
+
if (this.unresolvedReplayTimer) {
|
|
117853
|
+
clearInterval(this.unresolvedReplayTimer);
|
|
117854
|
+
this.unresolvedReplayTimer = null;
|
|
117855
|
+
}
|
|
117772
117856
|
this.queue.close();
|
|
117773
117857
|
if (this.channel && this.connected) {
|
|
117774
117858
|
try {
|
|
@@ -118248,13 +118332,27 @@ function compactErrorText(err) {
|
|
|
118248
118332
|
}
|
|
118249
118333
|
|
|
118250
118334
|
// src/claude/sessionStore.ts
|
|
118251
|
-
import { rename as rename2, readFile as readFile3, writeFile as writeFile2, mkdir as mkdir2, copyFile } from "node:fs/promises";
|
|
118335
|
+
import { rename as rename2, readFile as readFile3, writeFile as writeFile2, mkdir as mkdir2, copyFile, unlink as unlink2 } from "node:fs/promises";
|
|
118252
118336
|
import { dirname } from "node:path";
|
|
118253
118337
|
var STORE_VERSION = 2;
|
|
118254
118338
|
var TOUCH_DEBOUNCE_MS = 1e3;
|
|
118255
118339
|
var SessionStore = class _SessionStore {
|
|
118256
118340
|
#filePath;
|
|
118257
118341
|
#map;
|
|
118342
|
+
/**
|
|
118343
|
+
* Serializes every #flush() through one chain — mirrors TaskHandleStore's
|
|
118344
|
+
* `#flushChain` (same rationale). Concurrent writers are real here: up to
|
|
118345
|
+
* MAX_CONCURRENT turns call `put()`/`delete()` while the touch-debounce
|
|
118346
|
+
* timer fires its own flush. Without serialization, two overlapping
|
|
118347
|
+
* `writeFile(SAME tmp path)+rename` pairs can interleave (both open with
|
|
118348
|
+
* O_TRUNC at their own offset 0) and land corrupt JSON as sessions.json.
|
|
118349
|
+
* Each link's #writeSnapshot() takes its #map snapshot when it actually
|
|
118350
|
+
* RUNS (not at enqueue time), so the LAST queued write is always the LAST
|
|
118351
|
+
* to land on disk.
|
|
118352
|
+
*/
|
|
118353
|
+
#flushChain = Promise.resolve();
|
|
118354
|
+
/** Monotonic suffix so no two in-flight tmp files ever share a path. */
|
|
118355
|
+
#tmpCounter = 0;
|
|
118258
118356
|
/** Whether a touch flush is pending */
|
|
118259
118357
|
#touchDirty = false;
|
|
118260
118358
|
#touchTimer;
|
|
@@ -118296,13 +118394,12 @@ var SessionStore = class _SessionStore {
|
|
|
118296
118394
|
try {
|
|
118297
118395
|
parsed = JSON.parse(raw);
|
|
118298
118396
|
} catch {
|
|
118299
|
-
|
|
118300
|
-
`[SessionStore] ${filePath} is not valid JSON \u2014 fix or delete the file and restart.`
|
|
118301
|
-
);
|
|
118397
|
+
return await _SessionStore.#recoverFromCorruption(filePath, "is not valid JSON");
|
|
118302
118398
|
}
|
|
118303
118399
|
if (typeof parsed !== "object" || parsed === null || !("records" in parsed)) {
|
|
118304
|
-
|
|
118305
|
-
|
|
118400
|
+
return await _SessionStore.#recoverFromCorruption(
|
|
118401
|
+
filePath,
|
|
118402
|
+
"is missing required fields (records)"
|
|
118306
118403
|
);
|
|
118307
118404
|
}
|
|
118308
118405
|
const file = parsed;
|
|
@@ -118316,8 +118413,9 @@ var SessionStore = class _SessionStore {
|
|
|
118316
118413
|
);
|
|
118317
118414
|
}
|
|
118318
118415
|
if (typeof file.records !== "object" || file.records === null) {
|
|
118319
|
-
|
|
118320
|
-
|
|
118416
|
+
return await _SessionStore.#recoverFromCorruption(
|
|
118417
|
+
filePath,
|
|
118418
|
+
"records field is not an object"
|
|
118321
118419
|
);
|
|
118322
118420
|
}
|
|
118323
118421
|
const map2 = /* @__PURE__ */ new Map();
|
|
@@ -118325,14 +118423,44 @@ var SessionStore = class _SessionStore {
|
|
|
118325
118423
|
file.records
|
|
118326
118424
|
)) {
|
|
118327
118425
|
if (!isStoredRecord(value)) {
|
|
118328
|
-
|
|
118329
|
-
`[SessionStore] ${filePath} record "${key}" has unexpected shape \u2014
|
|
118426
|
+
console.warn(
|
|
118427
|
+
`[SessionStore] ${filePath} record "${key}" has unexpected shape \u2014 skipping it.`
|
|
118330
118428
|
);
|
|
118429
|
+
continue;
|
|
118331
118430
|
}
|
|
118332
118431
|
map2.set(key, value);
|
|
118333
118432
|
}
|
|
118334
118433
|
return new _SessionStore(filePath, map2);
|
|
118335
118434
|
}
|
|
118435
|
+
/**
|
|
118436
|
+
* A corrupt sessions.json must not keep the whole bridge from booting:
|
|
118437
|
+
* load() runs in main.ts startup with no surrounding try/catch, so a throw
|
|
118438
|
+
* here used to take EVERY bot down and require manual file surgery.
|
|
118439
|
+
* Mirror TaskHandleStore's posture instead: move the bad file to a
|
|
118440
|
+
* timestamped `.corrupt-*` backup (never silently lost) and start from an
|
|
118441
|
+
* empty store. Cost: threads lose their resume mapping and start fresh
|
|
118442
|
+
* sessions — a safe degradation compared to a full outage.
|
|
118443
|
+
*
|
|
118444
|
+
* NOTE: an unknown FUTURE `version` still throws (see load) — that file is
|
|
118445
|
+
* valid data written by newer code, not corruption, and must not be nuked.
|
|
118446
|
+
*/
|
|
118447
|
+
static async #recoverFromCorruption(filePath, reason) {
|
|
118448
|
+
const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
118449
|
+
const backupPath = `${filePath}.corrupt-${ts}`;
|
|
118450
|
+
try {
|
|
118451
|
+
await rename2(filePath, backupPath);
|
|
118452
|
+
console.error(
|
|
118453
|
+
`[SessionStore] ${filePath} ${reason} \u2014 moved to ${backupPath}; starting with an empty store (existing threads will start fresh sessions).`
|
|
118454
|
+
);
|
|
118455
|
+
} catch (err) {
|
|
118456
|
+
console.error(
|
|
118457
|
+
`[SessionStore] ${filePath} ${reason} \u2014 backup rename failed (${String(err)}); starting with an empty store anyway.`
|
|
118458
|
+
);
|
|
118459
|
+
}
|
|
118460
|
+
const store = new _SessionStore(filePath, /* @__PURE__ */ new Map());
|
|
118461
|
+
await store.#flush();
|
|
118462
|
+
return store;
|
|
118463
|
+
}
|
|
118336
118464
|
/**
|
|
118337
118465
|
* Migrate a v1 sessions.json in-place to v2.
|
|
118338
118466
|
* Backup written as `<path>.v1-backup-<ISO timestamp>` before any write.
|
|
@@ -118397,7 +118525,10 @@ var SessionStore = class _SessionStore {
|
|
|
118397
118525
|
lastActiveTs: record.lastActiveTs,
|
|
118398
118526
|
...record.senderOpenId !== void 0 ? { senderOpenId: record.senderOpenId } : {},
|
|
118399
118527
|
...record.rootText !== void 0 ? { rootText: record.rootText } : {},
|
|
118400
|
-
...record.chatId !== void 0 ? { chatId: record.chatId } : {}
|
|
118528
|
+
...record.chatId !== void 0 ? { chatId: record.chatId } : {},
|
|
118529
|
+
// BL-38: only persist when > 0 — a 0/undefined counter is a clean thread,
|
|
118530
|
+
// so passing consecutiveStuckCount: 0 naturally clears the field on reset.
|
|
118531
|
+
...record.consecutiveStuckCount ? { consecutiveStuckCount: record.consecutiveStuckCount } : {}
|
|
118401
118532
|
};
|
|
118402
118533
|
this.#map.set(key, stored);
|
|
118403
118534
|
await this.#flush();
|
|
@@ -118466,18 +118597,33 @@ var SessionStore = class _SessionStore {
|
|
|
118466
118597
|
// Private helpers
|
|
118467
118598
|
// -------------------------------------------------------------------------
|
|
118468
118599
|
/**
|
|
118469
|
-
* Atomic write
|
|
118600
|
+
* Atomic write, serialized through #flushChain (see field doc).
|
|
118601
|
+
* The caller of THIS flush still observes its own link's rejection;
|
|
118602
|
+
* the chain itself swallows it so one failure can't wedge later flushes.
|
|
118470
118603
|
*/
|
|
118471
|
-
|
|
118604
|
+
#flush() {
|
|
118605
|
+
const next = this.#flushChain.then(() => this.#writeSnapshot());
|
|
118606
|
+
this.#flushChain = next.catch(() => {
|
|
118607
|
+
});
|
|
118608
|
+
return next;
|
|
118609
|
+
}
|
|
118610
|
+
/** serialize → write to a unique tmp path → fs.rename (POSIX atomic). */
|
|
118611
|
+
async #writeSnapshot() {
|
|
118472
118612
|
const file = {
|
|
118473
118613
|
version: STORE_VERSION,
|
|
118474
118614
|
records: Object.fromEntries(this.#map)
|
|
118475
118615
|
};
|
|
118476
118616
|
const json2 = JSON.stringify(file, null, 2);
|
|
118477
|
-
const tmpPath = `${this.#filePath}.tmp`;
|
|
118617
|
+
const tmpPath = `${this.#filePath}.tmp.${process.pid}.${this.#tmpCounter++}`;
|
|
118478
118618
|
await mkdir2(dirname(this.#filePath), { recursive: true });
|
|
118479
|
-
|
|
118480
|
-
|
|
118619
|
+
try {
|
|
118620
|
+
await writeFile2(tmpPath, json2, "utf8");
|
|
118621
|
+
await rename2(tmpPath, this.#filePath);
|
|
118622
|
+
} catch (err) {
|
|
118623
|
+
await unlink2(tmpPath).catch(() => {
|
|
118624
|
+
});
|
|
118625
|
+
throw err;
|
|
118626
|
+
}
|
|
118481
118627
|
}
|
|
118482
118628
|
};
|
|
118483
118629
|
function isStoredRecord(value) {
|
|
@@ -121803,6 +121949,13 @@ function derivePostIdempotencyKey(input) {
|
|
|
121803
121949
|
var DEFAULT_CARDKIT_RESPONSE_SURFACE_TIMEOUT_MS = 20 * 60 * 1e3;
|
|
121804
121950
|
var DEFAULT_CARDKIT_IDLE_TIMEOUT_MS = 3 * 60 * 1e3;
|
|
121805
121951
|
var COT_BUBBLE_CREATE_BUDGET_MS = 3e3;
|
|
121952
|
+
var DEFAULT_STUCK_SESSION_RESET_AFTER = 3;
|
|
121953
|
+
function resolveStuckSessionResetAfter() {
|
|
121954
|
+
const raw = process.env.LARKWAY_STUCK_SESSION_RESET_AFTER;
|
|
121955
|
+
if (raw === void 0) return DEFAULT_STUCK_SESSION_RESET_AFTER;
|
|
121956
|
+
const n = Number.parseInt(raw, 10);
|
|
121957
|
+
return Number.isFinite(n) && n > 0 ? n : DEFAULT_STUCK_SESSION_RESET_AFTER;
|
|
121958
|
+
}
|
|
121806
121959
|
function summarizeMentionPolicyRules(rules) {
|
|
121807
121960
|
const counts = /* @__PURE__ */ new Map();
|
|
121808
121961
|
for (const rule of rules) {
|
|
@@ -121810,6 +121963,7 @@ function summarizeMentionPolicyRules(rules) {
|
|
|
121810
121963
|
}
|
|
121811
121964
|
return Array.from(counts.entries()).map(([rule, count]) => `${rule}=${count}`).join(", ");
|
|
121812
121965
|
}
|
|
121966
|
+
var GIT_OP_TIMEOUT_MS = 10 * 60 * 1e3;
|
|
121813
121967
|
function execGit(cwd, args) {
|
|
121814
121968
|
return new Promise((resolve2, reject) => {
|
|
121815
121969
|
const child = child_process.spawn("git", args, {
|
|
@@ -121817,18 +121971,37 @@ function execGit(cwd, args) {
|
|
|
121817
121971
|
stdio: ["ignore", "pipe", "pipe"]
|
|
121818
121972
|
});
|
|
121819
121973
|
let stderr = "";
|
|
121974
|
+
let timedOut = false;
|
|
121975
|
+
const timer = setTimeout(() => {
|
|
121976
|
+
timedOut = true;
|
|
121977
|
+
child.kill("SIGTERM");
|
|
121978
|
+
const killTimer = setTimeout(() => child.kill("SIGKILL"), 5e3);
|
|
121979
|
+
killTimer.unref();
|
|
121980
|
+
}, GIT_OP_TIMEOUT_MS);
|
|
121981
|
+
timer.unref();
|
|
121820
121982
|
child.stderr.on("data", (c) => {
|
|
121821
121983
|
stderr += c.toString();
|
|
121822
121984
|
});
|
|
121823
121985
|
child.on("close", (code) => {
|
|
121824
|
-
|
|
121986
|
+
clearTimeout(timer);
|
|
121987
|
+
if (timedOut) {
|
|
121988
|
+
reject(
|
|
121989
|
+
new Error(
|
|
121990
|
+
`git ${args.join(" ")} timed out after ${GIT_OP_TIMEOUT_MS / 1e3}s (killed)
|
|
121991
|
+
stderr: ${stderr}`
|
|
121992
|
+
)
|
|
121993
|
+
);
|
|
121994
|
+
} else if (code === 0) resolve2();
|
|
121825
121995
|
else
|
|
121826
121996
|
reject(
|
|
121827
121997
|
new Error(`git ${args.join(" ")} exited ${code ?? "null"}
|
|
121828
121998
|
stderr: ${stderr}`)
|
|
121829
121999
|
);
|
|
121830
122000
|
});
|
|
121831
|
-
child.on("error",
|
|
122001
|
+
child.on("error", (err) => {
|
|
122002
|
+
clearTimeout(timer);
|
|
122003
|
+
reject(err);
|
|
122004
|
+
});
|
|
121832
122005
|
});
|
|
121833
122006
|
}
|
|
121834
122007
|
async function pathExists(p) {
|
|
@@ -121850,7 +122023,16 @@ async function isWorktreeGitHealthy(worktreePath) {
|
|
|
121850
122023
|
child.on("error", () => resolve2(false));
|
|
121851
122024
|
});
|
|
121852
122025
|
}
|
|
121853
|
-
|
|
122026
|
+
var repoCloneLocks = /* @__PURE__ */ new Map();
|
|
122027
|
+
function ensureRepoClone(basePath, url, token, label) {
|
|
122028
|
+
const prev = repoCloneLocks.get(basePath) ?? Promise.resolve();
|
|
122029
|
+
const next = prev.catch(() => {
|
|
122030
|
+
}).then(() => ensureRepoCloneImpl(basePath, url, token, label));
|
|
122031
|
+
repoCloneLocks.set(basePath, next.catch(() => {
|
|
122032
|
+
}));
|
|
122033
|
+
return next;
|
|
122034
|
+
}
|
|
122035
|
+
async function ensureRepoCloneImpl(basePath, url, token, label) {
|
|
121854
122036
|
const gitDir = path10.join(basePath, ".git");
|
|
121855
122037
|
if (await pathExists(gitDir)) {
|
|
121856
122038
|
return;
|
|
@@ -121877,23 +122059,48 @@ async function ensureRepoClone(basePath, url, token, label) {
|
|
|
121877
122059
|
GIT_TERMINAL_PROMPT: "0",
|
|
121878
122060
|
[tokenEnvVar]: token ?? ""
|
|
121879
122061
|
};
|
|
121880
|
-
|
|
121881
|
-
|
|
121882
|
-
|
|
121883
|
-
|
|
121884
|
-
|
|
121885
|
-
|
|
121886
|
-
|
|
121887
|
-
|
|
121888
|
-
stderr
|
|
121889
|
-
|
|
121890
|
-
|
|
121891
|
-
|
|
121892
|
-
|
|
122062
|
+
let timedOut = false;
|
|
122063
|
+
try {
|
|
122064
|
+
await new Promise((resolve2, reject) => {
|
|
122065
|
+
const child = child_process.spawn(
|
|
122066
|
+
"git",
|
|
122067
|
+
["clone", "--quiet", url, basePath],
|
|
122068
|
+
{ stdio: ["ignore", "pipe", "pipe"], env }
|
|
122069
|
+
);
|
|
122070
|
+
let stderr = "";
|
|
122071
|
+
const timer = setTimeout(() => {
|
|
122072
|
+
timedOut = true;
|
|
122073
|
+
child.kill("SIGTERM");
|
|
122074
|
+
const killTimer = setTimeout(() => child.kill("SIGKILL"), 5e3);
|
|
122075
|
+
killTimer.unref();
|
|
122076
|
+
}, GIT_OP_TIMEOUT_MS);
|
|
122077
|
+
timer.unref();
|
|
122078
|
+
child.stderr.on("data", (b) => {
|
|
122079
|
+
stderr += b.toString();
|
|
122080
|
+
});
|
|
122081
|
+
child.on("close", (code) => {
|
|
122082
|
+
clearTimeout(timer);
|
|
122083
|
+
if (timedOut) {
|
|
122084
|
+
reject(new Error(
|
|
122085
|
+
`git clone ${url} timed out after ${GIT_OP_TIMEOUT_MS / 1e3}s (killed)
|
|
122086
|
+
stderr: ${stderr}`
|
|
122087
|
+
));
|
|
122088
|
+
} else if (code === 0) resolve2();
|
|
122089
|
+
else reject(new Error(`git clone ${url} exited ${code ?? "null"}
|
|
121893
122090
|
stderr: ${stderr}`));
|
|
122091
|
+
});
|
|
122092
|
+
child.on("error", (err) => {
|
|
122093
|
+
clearTimeout(timer);
|
|
122094
|
+
reject(err);
|
|
122095
|
+
});
|
|
121894
122096
|
});
|
|
121895
|
-
|
|
121896
|
-
|
|
122097
|
+
} catch (err) {
|
|
122098
|
+
if (timedOut) {
|
|
122099
|
+
await fs8.rm(basePath, { recursive: true, force: true }).catch(() => {
|
|
122100
|
+
});
|
|
122101
|
+
}
|
|
122102
|
+
throw err;
|
|
122103
|
+
}
|
|
121897
122104
|
console.log(`[bridge.handler] clone of ${label} complete.`);
|
|
121898
122105
|
await execGit(basePath, ["remote", "set-url", "origin", url]);
|
|
121899
122106
|
} finally {
|
|
@@ -122150,7 +122357,8 @@ var BridgeHandler = class {
|
|
|
122150
122357
|
/**
|
|
122151
122358
|
* Enter the main loop: for-await over client.events(), per-thread concurrent dispatch.
|
|
122152
122359
|
*
|
|
122153
|
-
* Each unique
|
|
122360
|
+
* Each unique session key (root_id, or message_id for top-level msgs — the
|
|
122361
|
+
* same normalization parseLarkMessage uses for threadId) gets its own serial
|
|
122154
122362
|
* promise chain, so the same thread stays ordered while different threads run
|
|
122155
122363
|
* concurrently. This fixes the UX problem where multiple operators sending
|
|
122156
122364
|
* requests simultaneously would block each other for the duration of each
|
|
@@ -122185,7 +122393,7 @@ var BridgeHandler = class {
|
|
|
122185
122393
|
for await (const event of this.deps.client.events()) {
|
|
122186
122394
|
if (this.closed) break;
|
|
122187
122395
|
if (signal?.aborted) break;
|
|
122188
|
-
const key = event.
|
|
122396
|
+
const key = typeof event.root_id === "string" && event.root_id ? event.root_id : event.message_id;
|
|
122189
122397
|
this.threadReceivedAt.set(key, Date.now());
|
|
122190
122398
|
const prev = threadQueues.get(key) ?? Promise.resolve();
|
|
122191
122399
|
const next = prev.then(() => acquire()).then(() => this.handleOne(event)).catch((err) => {
|
|
@@ -122212,6 +122420,7 @@ var BridgeHandler = class {
|
|
|
122212
122420
|
async handleOne(event) {
|
|
122213
122421
|
const settleMessageId = event.message_id;
|
|
122214
122422
|
let settled = false;
|
|
122423
|
+
let agentRunCompleted = false;
|
|
122215
122424
|
let cotPublisher;
|
|
122216
122425
|
let bubbleCreate;
|
|
122217
122426
|
let cotTurnOutcome = "done";
|
|
@@ -122219,7 +122428,7 @@ var BridgeHandler = class {
|
|
|
122219
122428
|
if (settled) return;
|
|
122220
122429
|
settled = true;
|
|
122221
122430
|
if (ok) this.deps.client.markHandled?.(settleMessageId);
|
|
122222
|
-
else this.deps.client.markUnhandled?.(settleMessageId);
|
|
122431
|
+
else this.deps.client.markUnhandled?.(settleMessageId, { replay: !agentRunCompleted });
|
|
122223
122432
|
};
|
|
122224
122433
|
try {
|
|
122225
122434
|
const parsed = parseMessage(event);
|
|
@@ -122779,7 +122988,7 @@ var BridgeHandler = class {
|
|
|
122779
122988
|
let toolsInFlight = 0;
|
|
122780
122989
|
let toolUseTotalCount = 0;
|
|
122781
122990
|
let idleWatchdog;
|
|
122782
|
-
|
|
122991
|
+
{
|
|
122783
122992
|
const cadenceMs = Math.max(50, Math.min(Math.floor(idleTimeoutMs / 4), 15e3));
|
|
122784
122993
|
idleWatchdog = setInterval(() => {
|
|
122785
122994
|
if (toolsInFlight > 0) return;
|
|
@@ -122827,6 +123036,7 @@ var BridgeHandler = class {
|
|
|
122827
123036
|
}
|
|
122828
123037
|
}
|
|
122829
123038
|
const result = await handle.done;
|
|
123039
|
+
agentRunCompleted = true;
|
|
122830
123040
|
if (idleWatchdog) {
|
|
122831
123041
|
clearInterval(idleWatchdog);
|
|
122832
123042
|
idleWatchdog = void 0;
|
|
@@ -122862,7 +123072,6 @@ var BridgeHandler = class {
|
|
|
122862
123072
|
pooled: result.pooled,
|
|
122863
123073
|
resumeMode: result.resumeMode
|
|
122864
123074
|
});
|
|
122865
|
-
const cardKitTurnTimedOut = cardKitProgress != null && interruptedByIdle;
|
|
122866
123075
|
const reportedStateRead = await readStateFileDetailed(worktreePath);
|
|
122867
123076
|
const rawReportedState = reportedStateRead.state;
|
|
122868
123077
|
const reportedState = rawReportedState?.updated_at != null && rawReportedState.updated_at !== preRunUpdatedAt ? rawReportedState : null;
|
|
@@ -122893,37 +123102,9 @@ var BridgeHandler = class {
|
|
|
122893
123102
|
console.warn("[bridge.handler] taskHandleClaim hook failed (continuing):", err);
|
|
122894
123103
|
}
|
|
122895
123104
|
}
|
|
122896
|
-
const now = Date.now();
|
|
122897
|
-
if (sessionId !== void 0 && currentExisting === void 0) {
|
|
122898
|
-
await this.deps.sessionStore.put({
|
|
122899
|
-
threadId,
|
|
122900
|
-
sessionId,
|
|
122901
|
-
botId,
|
|
122902
|
-
createdTs: now,
|
|
122903
|
-
lastActiveTs: now,
|
|
122904
|
-
senderOpenId,
|
|
122905
|
-
...isTopLevel ? { rootText: parsed.text.slice(0, 200), chatId: parsed.chatId } : {}
|
|
122906
|
-
});
|
|
122907
|
-
} else if (sessionId !== void 0 && currentExisting !== void 0) {
|
|
122908
|
-
await this.deps.sessionStore.put({
|
|
122909
|
-
threadId,
|
|
122910
|
-
sessionId,
|
|
122911
|
-
botId,
|
|
122912
|
-
createdTs: currentExisting.createdTs,
|
|
122913
|
-
lastActiveTs: now,
|
|
122914
|
-
senderOpenId,
|
|
122915
|
-
rootText: currentExisting.rootText,
|
|
122916
|
-
chatId: currentExisting.chatId
|
|
122917
|
-
});
|
|
122918
|
-
} else if (currentExisting !== void 0 && sessionId === void 0) {
|
|
122919
|
-
await this.deps.sessionStore.put({
|
|
122920
|
-
...currentExisting,
|
|
122921
|
-
lastActiveTs: now
|
|
122922
|
-
});
|
|
122923
|
-
}
|
|
122924
123105
|
const reportedStatus = reportedState?.status;
|
|
122925
123106
|
const reportedError = reportedState?.error;
|
|
122926
|
-
const cardKitTimeoutFailure =
|
|
123107
|
+
const cardKitTimeoutFailure = interruptedByIdle && reportedStatus !== "ready" && reportedStatus !== "failed";
|
|
122927
123108
|
let success;
|
|
122928
123109
|
let failureReason;
|
|
122929
123110
|
if (reportedStatus === "failed") {
|
|
@@ -122950,10 +123131,53 @@ var BridgeHandler = class {
|
|
|
122950
123131
|
reason: failureReason
|
|
122951
123132
|
});
|
|
122952
123133
|
}
|
|
123134
|
+
const stuckResetAfter = resolveStuckSessionResetAfter();
|
|
123135
|
+
const prevStuckCount = currentExisting?.consecutiveStuckCount ?? 0;
|
|
123136
|
+
const nextStuckCount = cardKitTimeoutFailure ? prevStuckCount + 1 : success ? 0 : prevStuckCount;
|
|
123137
|
+
const stuckResetTriggered = cardKitTimeoutFailure && nextStuckCount >= stuckResetAfter;
|
|
123138
|
+
const now = Date.now();
|
|
123139
|
+
if (stuckResetTriggered) {
|
|
123140
|
+
await this.deps.sessionStore.delete(threadId, botId);
|
|
123141
|
+
console.info(
|
|
123142
|
+
`[bridge.handler] BL-38 stuck-session reset: thread=${threadId} bot=${botId} consecutiveStuckCount=${nextStuckCount} (>= ${stuckResetAfter}) \u2014 dropped session record; next @ starts fresh`
|
|
123143
|
+
);
|
|
123144
|
+
} else if (sessionId !== void 0 && currentExisting === void 0) {
|
|
123145
|
+
await this.deps.sessionStore.put({
|
|
123146
|
+
threadId,
|
|
123147
|
+
sessionId,
|
|
123148
|
+
botId,
|
|
123149
|
+
createdTs: now,
|
|
123150
|
+
lastActiveTs: now,
|
|
123151
|
+
senderOpenId,
|
|
123152
|
+
// BL-38: a brand-new thread that idle-killed on its very first turn
|
|
123153
|
+
// starts the counter at 1 (0 when clean; only persisted when > 0).
|
|
123154
|
+
consecutiveStuckCount: nextStuckCount,
|
|
123155
|
+
...isTopLevel ? { rootText: parsed.text.slice(0, 200), chatId: parsed.chatId } : {}
|
|
123156
|
+
});
|
|
123157
|
+
} else if (sessionId !== void 0 && currentExisting !== void 0) {
|
|
123158
|
+
await this.deps.sessionStore.put({
|
|
123159
|
+
threadId,
|
|
123160
|
+
sessionId,
|
|
123161
|
+
botId,
|
|
123162
|
+
createdTs: currentExisting.createdTs,
|
|
123163
|
+
lastActiveTs: now,
|
|
123164
|
+
senderOpenId,
|
|
123165
|
+
rootText: currentExisting.rootText,
|
|
123166
|
+
chatId: currentExisting.chatId,
|
|
123167
|
+
// BL-38: +1 on an idle-stuck turn, 0 (cleared) on any clean turn.
|
|
123168
|
+
consecutiveStuckCount: nextStuckCount
|
|
123169
|
+
});
|
|
123170
|
+
} else if (currentExisting !== void 0 && sessionId === void 0) {
|
|
123171
|
+
await this.deps.sessionStore.put({
|
|
123172
|
+
...currentExisting,
|
|
123173
|
+
lastActiveTs: now,
|
|
123174
|
+
consecutiveStuckCount: nextStuckCount
|
|
123175
|
+
});
|
|
123176
|
+
}
|
|
122953
123177
|
const fallbackAnswer = trustedAnswerText.trim() || cardKitProgress?.answerText.trim() || "";
|
|
122954
123178
|
const noOutputFallback = "\u26A0\uFE0F \u672C\u8F6E agent \u6CA1\u6709\u4EA7\u51FA\u6B63\u6587\uFF0C\u4E5F\u6CA1\u6709\u5199\u5165\u6709\u6548\u72B6\u6001(state.json)\u3002\n" + (result.exitCode !== 0 ? `agent \u8FDB\u7A0B\u5F02\u5E38\u9000\u51FA(exit code ${result.exitCode})\u3002
|
|
122955
123179
|
` : "") + "\u4E0B\u4E00\u6B65\uFF1A\u6362\u4E2A\u8BF4\u6CD5\u91CD\u8BD5\uFF0C\u6216\u65B0\u5F00\u4E00\u4E2A\u8BDD\u9898\u7EE7\u7EED\uFF1B\u82E5\u53CD\u590D\u5982\u6B64\uFF0C\u8BF7\u8BA9\u7EF4\u62A4\u8005\u67E5\u770B\u8BE5 session \u65E5\u5FD7\u3002";
|
|
122956
|
-
const cardBody = cardKitTimeoutFailure ? "\u26A0\uFE0F \u672C\u8F6E\u88AB\u4E2D\u65AD\uFF08\u957F\u65F6\u95F4\u65E0\u6D3B\u6027\uFF0C\u5224\u5B9A\u5361\u6B7B\uFF09\uFF0C\u672A\u5B8C\u6210\u3002\u8BF7\u91CD\u8BD5\u3002" : reportedState?.last_message ?? (fallbackAnswer ? fallbackAnswer : noOutputFallback);
|
|
123180
|
+
const cardBody = stuckResetTriggered ? "\u26A0\uFE0F \u672C\u8F6E\u88AB\u4E2D\u65AD\uFF08\u957F\u65F6\u95F4\u65E0\u6D3B\u6027\uFF0C\u5224\u5B9A\u5361\u6B7B\uFF09\u3002\u8FDE\u7EED\u591A\u6B21\u5361\u6B7B\uFF0C\u5DF2\u91CD\u7F6E\u672C\u8BDD\u9898\u4E0A\u4E0B\u6587 \u2014\u2014 \u4E0B\u6B21 @ \u6211\u5C06\u5168\u65B0\u5F00\u59CB\uFF0C\u8BF7\u628A\u9700\u6C42\u91CD\u65B0\u8BF4\u4E00\u904D\u3002" : cardKitTimeoutFailure ? "\u26A0\uFE0F \u672C\u8F6E\u88AB\u4E2D\u65AD\uFF08\u957F\u65F6\u95F4\u65E0\u6D3B\u6027\uFF0C\u5224\u5B9A\u5361\u6B7B\uFF09\uFF0C\u672A\u5B8C\u6210\u3002\u8BF7\u91CD\u8BD5\u3002" : reportedState?.last_message ?? (fallbackAnswer ? fallbackAnswer : noOutputFallback);
|
|
122957
123181
|
const noReportThisTurn = reportedState === null;
|
|
122958
123182
|
const neutralTitle = noReportThisTurn && success && !failureReason ? "\u{1F4AC} \u5DF2\u56DE\u590D" : void 0;
|
|
122959
123183
|
const baseCardPayload = {
|
|
@@ -123132,8 +123356,13 @@ var BridgeHandler = class {
|
|
|
123132
123356
|
);
|
|
123133
123357
|
break;
|
|
123134
123358
|
} catch (spawnErr) {
|
|
123359
|
+
if (idleWatchdog) {
|
|
123360
|
+
clearInterval(idleWatchdog);
|
|
123361
|
+
idleWatchdog = void 0;
|
|
123362
|
+
}
|
|
123135
123363
|
const errMsg = String(spawnErr.message ?? spawnErr);
|
|
123136
|
-
|
|
123364
|
+
const isStaleSessionErr = errMsg.includes("No conversation found") || errMsg.includes("thread/resume failed") && errMsg.includes("no rollout found");
|
|
123365
|
+
if (attempt === 1 && currentExisting != null && isStaleSessionErr) {
|
|
123137
123366
|
console.warn(
|
|
123138
123367
|
`[bridge.handler] stale session ${currentExisting.sessionId} for thread ${threadId} \u2014 removing and retrying without --resume`
|
|
123139
123368
|
);
|
|
@@ -127509,7 +127738,7 @@ async function writeStatusFile(botId, w) {
|
|
|
127509
127738
|
|
|
127510
127739
|
// src/claude/runner.ts
|
|
127511
127740
|
import { spawn as spawn2 } from "node:child_process";
|
|
127512
|
-
import { writeFile as writeFile3, unlink as
|
|
127741
|
+
import { writeFile as writeFile3, unlink as unlink3, mkdir as mkdir3 } from "node:fs/promises";
|
|
127513
127742
|
import { join as join2 } from "node:path";
|
|
127514
127743
|
import { createInterface } from "node:readline";
|
|
127515
127744
|
var SIGKILL_GRACE_MS = 5e3;
|
|
@@ -127703,9 +127932,13 @@ function runClaude(opts) {
|
|
|
127703
127932
|
function doKill() {
|
|
127704
127933
|
if (child.killed || killScheduled) return;
|
|
127705
127934
|
killScheduled = true;
|
|
127935
|
+
let exited = false;
|
|
127936
|
+
child.once("exit", () => {
|
|
127937
|
+
exited = true;
|
|
127938
|
+
});
|
|
127706
127939
|
child.kill("SIGTERM");
|
|
127707
127940
|
killTimer = setTimeout(() => {
|
|
127708
|
-
if (!
|
|
127941
|
+
if (!exited) child.kill("SIGKILL");
|
|
127709
127942
|
}, SIGKILL_GRACE_MS);
|
|
127710
127943
|
killTimer.unref();
|
|
127711
127944
|
}
|
|
@@ -127758,7 +127991,7 @@ function runClaude(opts) {
|
|
|
127758
127991
|
clearTimeout(totalTimeoutFallbackHandle);
|
|
127759
127992
|
rlAbortController.abort();
|
|
127760
127993
|
if (pidFilePath !== null) {
|
|
127761
|
-
void
|
|
127994
|
+
void unlink3(pidFilePath).catch(() => {
|
|
127762
127995
|
});
|
|
127763
127996
|
}
|
|
127764
127997
|
if (exitCode !== 0 && !killScheduled) {
|
|
@@ -127788,7 +128021,7 @@ stderr: ${stderr}` : "")
|
|
|
127788
128021
|
clearTimeout(totalTimeoutFallbackHandle);
|
|
127789
128022
|
rlAbortController.abort();
|
|
127790
128023
|
if (pidFilePath !== null) {
|
|
127791
|
-
void
|
|
128024
|
+
void unlink3(pidFilePath).catch(() => {
|
|
127792
128025
|
});
|
|
127793
128026
|
}
|
|
127794
128027
|
if (err.code === "ENOENT") {
|
|
@@ -127867,7 +128100,7 @@ var ClaudeRunner = class {
|
|
|
127867
128100
|
|
|
127868
128101
|
// src/claude/pool.ts
|
|
127869
128102
|
import { execFile as execFile4, spawn as spawn3 } from "node:child_process";
|
|
127870
|
-
import { mkdir as mkdir4, readFile as readFile6, rename as rename3, stat as stat3, unlink as
|
|
128103
|
+
import { mkdir as mkdir4, readFile as readFile6, rename as rename3, stat as stat3, unlink as unlink4, writeFile as writeFile4 } from "node:fs/promises";
|
|
127871
128104
|
import path16 from "node:path";
|
|
127872
128105
|
import { createInterface as createInterface2 } from "node:readline";
|
|
127873
128106
|
import { randomUUID } from "node:crypto";
|
|
@@ -128404,7 +128637,7 @@ ${stderr}` : "")
|
|
|
128404
128637
|
try {
|
|
128405
128638
|
const raw = await readFile6(pidFilePath, "utf8");
|
|
128406
128639
|
const parsed = JSON.parse(raw);
|
|
128407
|
-
if (parsed.pid === entry.child.pid) await
|
|
128640
|
+
if (parsed.pid === entry.child.pid) await unlink4(pidFilePath);
|
|
128408
128641
|
} catch {
|
|
128409
128642
|
}
|
|
128410
128643
|
}
|
|
@@ -128531,7 +128764,7 @@ async function reapOrphanedWarmClaudeProcesses(pidListFilePath) {
|
|
|
128531
128764
|
} catch {
|
|
128532
128765
|
}
|
|
128533
128766
|
}
|
|
128534
|
-
await
|
|
128767
|
+
await unlink4(pidListFilePath).catch(() => {
|
|
128535
128768
|
});
|
|
128536
128769
|
}
|
|
128537
128770
|
|
|
@@ -128699,6 +128932,8 @@ function runCodex(opts, codexBinPath = "codex") {
|
|
|
128699
128932
|
...opts.cwd != null ? { cwd: opts.cwd } : {}
|
|
128700
128933
|
});
|
|
128701
128934
|
markPerf("spawn");
|
|
128935
|
+
child.stdin?.on("error", () => {
|
|
128936
|
+
});
|
|
128702
128937
|
function sendRequest(method, params) {
|
|
128703
128938
|
const id = nextRequestId++;
|
|
128704
128939
|
requestById.set(id, method);
|
|
@@ -128711,9 +128946,13 @@ function runCodex(opts, codexBinPath = "codex") {
|
|
|
128711
128946
|
function doKill() {
|
|
128712
128947
|
if (child.killed || killScheduled) return;
|
|
128713
128948
|
killScheduled = true;
|
|
128949
|
+
let exited = false;
|
|
128950
|
+
child.once("exit", () => {
|
|
128951
|
+
exited = true;
|
|
128952
|
+
});
|
|
128714
128953
|
child.kill("SIGTERM");
|
|
128715
128954
|
killTimer = setTimeout(() => {
|
|
128716
|
-
if (!
|
|
128955
|
+
if (!exited) child.kill("SIGKILL");
|
|
128717
128956
|
}, SIGKILL_GRACE_MS3);
|
|
128718
128957
|
killTimer.unref();
|
|
128719
128958
|
}
|
|
@@ -128933,7 +129172,7 @@ var CodexRunner = class {
|
|
|
128933
129172
|
|
|
128934
129173
|
// src/codex/pool.ts
|
|
128935
129174
|
import { execFile as execFile5, spawn as spawn5 } from "node:child_process";
|
|
128936
|
-
import { mkdir as mkdir5, readFile as readFile7, unlink as
|
|
129175
|
+
import { mkdir as mkdir5, readFile as readFile7, unlink as unlink5, writeFile as writeFile5 } from "node:fs/promises";
|
|
128937
129176
|
import path17 from "node:path";
|
|
128938
129177
|
import { createInterface as createInterface4 } from "node:readline";
|
|
128939
129178
|
var DEFAULT_WARM_PROCESS_IDLE_MS2 = 10 * 60 * 1e3;
|
|
@@ -129195,6 +129434,8 @@ var CodexProcessPool = class {
|
|
|
129195
129434
|
this.#pending.clear();
|
|
129196
129435
|
this.#threadOwners.clear();
|
|
129197
129436
|
this.#currentSpawnReadyResolved = false;
|
|
129437
|
+
child.stdin?.on("error", () => {
|
|
129438
|
+
});
|
|
129198
129439
|
void this.#writePidFileBestEffort(child.pid);
|
|
129199
129440
|
this.#ready = new Promise((resolve2, reject) => {
|
|
129200
129441
|
this.#readyResolve = resolve2;
|
|
@@ -129312,7 +129553,7 @@ var CodexProcessPool = class {
|
|
|
129312
129553
|
try {
|
|
129313
129554
|
const raw = await readFile7(this.#pidFilePath, "utf8");
|
|
129314
129555
|
const parsed = JSON.parse(raw);
|
|
129315
|
-
if (parsed.pid === pid) await
|
|
129556
|
+
if (parsed.pid === pid) await unlink5(this.#pidFilePath);
|
|
129316
129557
|
} catch {
|
|
129317
129558
|
}
|
|
129318
129559
|
}
|
|
@@ -129468,7 +129709,7 @@ async function reapOrphanedWarmProcess(pidFilePath) {
|
|
|
129468
129709
|
);
|
|
129469
129710
|
}
|
|
129470
129711
|
}
|
|
129471
|
-
await
|
|
129712
|
+
await unlink5(pidFilePath).catch(() => {
|
|
129472
129713
|
});
|
|
129473
129714
|
}
|
|
129474
129715
|
|
|
@@ -129732,12 +129973,33 @@ function handleUnhandledRejection(reason, log = console) {
|
|
|
129732
129973
|
rendered
|
|
129733
129974
|
);
|
|
129734
129975
|
}
|
|
129976
|
+
var BREAKER_THRESHOLD = 50;
|
|
129977
|
+
var BREAKER_WINDOW_MS = 6e4;
|
|
129978
|
+
function createCrashStormBreaker(threshold = BREAKER_THRESHOLD, windowMs = BREAKER_WINDOW_MS) {
|
|
129979
|
+
const hits = [];
|
|
129980
|
+
return function note() {
|
|
129981
|
+
const now = Date.now();
|
|
129982
|
+
hits.push(now);
|
|
129983
|
+
while (hits.length > 0 && hits[0] < now - windowMs) hits.shift();
|
|
129984
|
+
return hits.length >= threshold;
|
|
129985
|
+
};
|
|
129986
|
+
}
|
|
129735
129987
|
function registerCrashGuard(log = console) {
|
|
129988
|
+
const noteCrash = createCrashStormBreaker();
|
|
129989
|
+
const maybeTrip = () => {
|
|
129990
|
+
if (!noteCrash()) return;
|
|
129991
|
+
log.error(
|
|
129992
|
+
`[larkway] crash-storm breaker TRIPPED: \u2265${BREAKER_THRESHOLD} crash-guard hits within ${BREAKER_WINDOW_MS / 1e3}s \u2014 the process is likely wedged, not weathering a transient. Exiting 1 so a supervisor can restart with a clean process.`
|
|
129993
|
+
);
|
|
129994
|
+
process.exit(1);
|
|
129995
|
+
};
|
|
129736
129996
|
process.on("uncaughtException", (err, origin) => {
|
|
129737
129997
|
handleUncaughtException(err, origin, log);
|
|
129998
|
+
maybeTrip();
|
|
129738
129999
|
});
|
|
129739
130000
|
process.on("unhandledRejection", (reason) => {
|
|
129740
130001
|
handleUnhandledRejection(reason, log);
|
|
130002
|
+
maybeTrip();
|
|
129741
130003
|
});
|
|
129742
130004
|
}
|
|
129743
130005
|
|