larkway 0.3.37 → 0.3.38
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 +303 -66
- 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.38**
|
|
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.
|
|
@@ -118466,18 +118594,33 @@ var SessionStore = class _SessionStore {
|
|
|
118466
118594
|
// Private helpers
|
|
118467
118595
|
// -------------------------------------------------------------------------
|
|
118468
118596
|
/**
|
|
118469
|
-
* Atomic write
|
|
118597
|
+
* Atomic write, serialized through #flushChain (see field doc).
|
|
118598
|
+
* The caller of THIS flush still observes its own link's rejection;
|
|
118599
|
+
* the chain itself swallows it so one failure can't wedge later flushes.
|
|
118470
118600
|
*/
|
|
118471
|
-
|
|
118601
|
+
#flush() {
|
|
118602
|
+
const next = this.#flushChain.then(() => this.#writeSnapshot());
|
|
118603
|
+
this.#flushChain = next.catch(() => {
|
|
118604
|
+
});
|
|
118605
|
+
return next;
|
|
118606
|
+
}
|
|
118607
|
+
/** serialize → write to a unique tmp path → fs.rename (POSIX atomic). */
|
|
118608
|
+
async #writeSnapshot() {
|
|
118472
118609
|
const file = {
|
|
118473
118610
|
version: STORE_VERSION,
|
|
118474
118611
|
records: Object.fromEntries(this.#map)
|
|
118475
118612
|
};
|
|
118476
118613
|
const json2 = JSON.stringify(file, null, 2);
|
|
118477
|
-
const tmpPath = `${this.#filePath}.tmp`;
|
|
118614
|
+
const tmpPath = `${this.#filePath}.tmp.${process.pid}.${this.#tmpCounter++}`;
|
|
118478
118615
|
await mkdir2(dirname(this.#filePath), { recursive: true });
|
|
118479
|
-
|
|
118480
|
-
|
|
118616
|
+
try {
|
|
118617
|
+
await writeFile2(tmpPath, json2, "utf8");
|
|
118618
|
+
await rename2(tmpPath, this.#filePath);
|
|
118619
|
+
} catch (err) {
|
|
118620
|
+
await unlink2(tmpPath).catch(() => {
|
|
118621
|
+
});
|
|
118622
|
+
throw err;
|
|
118623
|
+
}
|
|
118481
118624
|
}
|
|
118482
118625
|
};
|
|
118483
118626
|
function isStoredRecord(value) {
|
|
@@ -121810,6 +121953,7 @@ function summarizeMentionPolicyRules(rules) {
|
|
|
121810
121953
|
}
|
|
121811
121954
|
return Array.from(counts.entries()).map(([rule, count]) => `${rule}=${count}`).join(", ");
|
|
121812
121955
|
}
|
|
121956
|
+
var GIT_OP_TIMEOUT_MS = 10 * 60 * 1e3;
|
|
121813
121957
|
function execGit(cwd, args) {
|
|
121814
121958
|
return new Promise((resolve2, reject) => {
|
|
121815
121959
|
const child = child_process.spawn("git", args, {
|
|
@@ -121817,18 +121961,37 @@ function execGit(cwd, args) {
|
|
|
121817
121961
|
stdio: ["ignore", "pipe", "pipe"]
|
|
121818
121962
|
});
|
|
121819
121963
|
let stderr = "";
|
|
121964
|
+
let timedOut = false;
|
|
121965
|
+
const timer = setTimeout(() => {
|
|
121966
|
+
timedOut = true;
|
|
121967
|
+
child.kill("SIGTERM");
|
|
121968
|
+
const killTimer = setTimeout(() => child.kill("SIGKILL"), 5e3);
|
|
121969
|
+
killTimer.unref();
|
|
121970
|
+
}, GIT_OP_TIMEOUT_MS);
|
|
121971
|
+
timer.unref();
|
|
121820
121972
|
child.stderr.on("data", (c) => {
|
|
121821
121973
|
stderr += c.toString();
|
|
121822
121974
|
});
|
|
121823
121975
|
child.on("close", (code) => {
|
|
121824
|
-
|
|
121976
|
+
clearTimeout(timer);
|
|
121977
|
+
if (timedOut) {
|
|
121978
|
+
reject(
|
|
121979
|
+
new Error(
|
|
121980
|
+
`git ${args.join(" ")} timed out after ${GIT_OP_TIMEOUT_MS / 1e3}s (killed)
|
|
121981
|
+
stderr: ${stderr}`
|
|
121982
|
+
)
|
|
121983
|
+
);
|
|
121984
|
+
} else if (code === 0) resolve2();
|
|
121825
121985
|
else
|
|
121826
121986
|
reject(
|
|
121827
121987
|
new Error(`git ${args.join(" ")} exited ${code ?? "null"}
|
|
121828
121988
|
stderr: ${stderr}`)
|
|
121829
121989
|
);
|
|
121830
121990
|
});
|
|
121831
|
-
child.on("error",
|
|
121991
|
+
child.on("error", (err) => {
|
|
121992
|
+
clearTimeout(timer);
|
|
121993
|
+
reject(err);
|
|
121994
|
+
});
|
|
121832
121995
|
});
|
|
121833
121996
|
}
|
|
121834
121997
|
async function pathExists(p) {
|
|
@@ -121850,7 +122013,16 @@ async function isWorktreeGitHealthy(worktreePath) {
|
|
|
121850
122013
|
child.on("error", () => resolve2(false));
|
|
121851
122014
|
});
|
|
121852
122015
|
}
|
|
121853
|
-
|
|
122016
|
+
var repoCloneLocks = /* @__PURE__ */ new Map();
|
|
122017
|
+
function ensureRepoClone(basePath, url, token, label) {
|
|
122018
|
+
const prev = repoCloneLocks.get(basePath) ?? Promise.resolve();
|
|
122019
|
+
const next = prev.catch(() => {
|
|
122020
|
+
}).then(() => ensureRepoCloneImpl(basePath, url, token, label));
|
|
122021
|
+
repoCloneLocks.set(basePath, next.catch(() => {
|
|
122022
|
+
}));
|
|
122023
|
+
return next;
|
|
122024
|
+
}
|
|
122025
|
+
async function ensureRepoCloneImpl(basePath, url, token, label) {
|
|
121854
122026
|
const gitDir = path10.join(basePath, ".git");
|
|
121855
122027
|
if (await pathExists(gitDir)) {
|
|
121856
122028
|
return;
|
|
@@ -121877,23 +122049,48 @@ async function ensureRepoClone(basePath, url, token, label) {
|
|
|
121877
122049
|
GIT_TERMINAL_PROMPT: "0",
|
|
121878
122050
|
[tokenEnvVar]: token ?? ""
|
|
121879
122051
|
};
|
|
121880
|
-
|
|
121881
|
-
|
|
121882
|
-
|
|
121883
|
-
|
|
121884
|
-
|
|
121885
|
-
|
|
121886
|
-
|
|
121887
|
-
|
|
121888
|
-
stderr
|
|
121889
|
-
|
|
121890
|
-
|
|
121891
|
-
|
|
121892
|
-
|
|
122052
|
+
let timedOut = false;
|
|
122053
|
+
try {
|
|
122054
|
+
await new Promise((resolve2, reject) => {
|
|
122055
|
+
const child = child_process.spawn(
|
|
122056
|
+
"git",
|
|
122057
|
+
["clone", "--quiet", url, basePath],
|
|
122058
|
+
{ stdio: ["ignore", "pipe", "pipe"], env }
|
|
122059
|
+
);
|
|
122060
|
+
let stderr = "";
|
|
122061
|
+
const timer = setTimeout(() => {
|
|
122062
|
+
timedOut = true;
|
|
122063
|
+
child.kill("SIGTERM");
|
|
122064
|
+
const killTimer = setTimeout(() => child.kill("SIGKILL"), 5e3);
|
|
122065
|
+
killTimer.unref();
|
|
122066
|
+
}, GIT_OP_TIMEOUT_MS);
|
|
122067
|
+
timer.unref();
|
|
122068
|
+
child.stderr.on("data", (b) => {
|
|
122069
|
+
stderr += b.toString();
|
|
122070
|
+
});
|
|
122071
|
+
child.on("close", (code) => {
|
|
122072
|
+
clearTimeout(timer);
|
|
122073
|
+
if (timedOut) {
|
|
122074
|
+
reject(new Error(
|
|
122075
|
+
`git clone ${url} timed out after ${GIT_OP_TIMEOUT_MS / 1e3}s (killed)
|
|
122076
|
+
stderr: ${stderr}`
|
|
122077
|
+
));
|
|
122078
|
+
} else if (code === 0) resolve2();
|
|
122079
|
+
else reject(new Error(`git clone ${url} exited ${code ?? "null"}
|
|
121893
122080
|
stderr: ${stderr}`));
|
|
122081
|
+
});
|
|
122082
|
+
child.on("error", (err) => {
|
|
122083
|
+
clearTimeout(timer);
|
|
122084
|
+
reject(err);
|
|
122085
|
+
});
|
|
121894
122086
|
});
|
|
121895
|
-
|
|
121896
|
-
|
|
122087
|
+
} catch (err) {
|
|
122088
|
+
if (timedOut) {
|
|
122089
|
+
await fs8.rm(basePath, { recursive: true, force: true }).catch(() => {
|
|
122090
|
+
});
|
|
122091
|
+
}
|
|
122092
|
+
throw err;
|
|
122093
|
+
}
|
|
121897
122094
|
console.log(`[bridge.handler] clone of ${label} complete.`);
|
|
121898
122095
|
await execGit(basePath, ["remote", "set-url", "origin", url]);
|
|
121899
122096
|
} finally {
|
|
@@ -122150,7 +122347,8 @@ var BridgeHandler = class {
|
|
|
122150
122347
|
/**
|
|
122151
122348
|
* Enter the main loop: for-await over client.events(), per-thread concurrent dispatch.
|
|
122152
122349
|
*
|
|
122153
|
-
* Each unique
|
|
122350
|
+
* Each unique session key (root_id, or message_id for top-level msgs — the
|
|
122351
|
+
* same normalization parseLarkMessage uses for threadId) gets its own serial
|
|
122154
122352
|
* promise chain, so the same thread stays ordered while different threads run
|
|
122155
122353
|
* concurrently. This fixes the UX problem where multiple operators sending
|
|
122156
122354
|
* requests simultaneously would block each other for the duration of each
|
|
@@ -122185,7 +122383,7 @@ var BridgeHandler = class {
|
|
|
122185
122383
|
for await (const event of this.deps.client.events()) {
|
|
122186
122384
|
if (this.closed) break;
|
|
122187
122385
|
if (signal?.aborted) break;
|
|
122188
|
-
const key = event.
|
|
122386
|
+
const key = typeof event.root_id === "string" && event.root_id ? event.root_id : event.message_id;
|
|
122189
122387
|
this.threadReceivedAt.set(key, Date.now());
|
|
122190
122388
|
const prev = threadQueues.get(key) ?? Promise.resolve();
|
|
122191
122389
|
const next = prev.then(() => acquire()).then(() => this.handleOne(event)).catch((err) => {
|
|
@@ -122212,6 +122410,7 @@ var BridgeHandler = class {
|
|
|
122212
122410
|
async handleOne(event) {
|
|
122213
122411
|
const settleMessageId = event.message_id;
|
|
122214
122412
|
let settled = false;
|
|
122413
|
+
let agentRunCompleted = false;
|
|
122215
122414
|
let cotPublisher;
|
|
122216
122415
|
let bubbleCreate;
|
|
122217
122416
|
let cotTurnOutcome = "done";
|
|
@@ -122219,7 +122418,7 @@ var BridgeHandler = class {
|
|
|
122219
122418
|
if (settled) return;
|
|
122220
122419
|
settled = true;
|
|
122221
122420
|
if (ok) this.deps.client.markHandled?.(settleMessageId);
|
|
122222
|
-
else this.deps.client.markUnhandled?.(settleMessageId);
|
|
122421
|
+
else this.deps.client.markUnhandled?.(settleMessageId, { replay: !agentRunCompleted });
|
|
122223
122422
|
};
|
|
122224
122423
|
try {
|
|
122225
122424
|
const parsed = parseMessage(event);
|
|
@@ -122779,7 +122978,7 @@ var BridgeHandler = class {
|
|
|
122779
122978
|
let toolsInFlight = 0;
|
|
122780
122979
|
let toolUseTotalCount = 0;
|
|
122781
122980
|
let idleWatchdog;
|
|
122782
|
-
|
|
122981
|
+
{
|
|
122783
122982
|
const cadenceMs = Math.max(50, Math.min(Math.floor(idleTimeoutMs / 4), 15e3));
|
|
122784
122983
|
idleWatchdog = setInterval(() => {
|
|
122785
122984
|
if (toolsInFlight > 0) return;
|
|
@@ -122827,6 +123026,7 @@ var BridgeHandler = class {
|
|
|
122827
123026
|
}
|
|
122828
123027
|
}
|
|
122829
123028
|
const result = await handle.done;
|
|
123029
|
+
agentRunCompleted = true;
|
|
122830
123030
|
if (idleWatchdog) {
|
|
122831
123031
|
clearInterval(idleWatchdog);
|
|
122832
123032
|
idleWatchdog = void 0;
|
|
@@ -122862,7 +123062,6 @@ var BridgeHandler = class {
|
|
|
122862
123062
|
pooled: result.pooled,
|
|
122863
123063
|
resumeMode: result.resumeMode
|
|
122864
123064
|
});
|
|
122865
|
-
const cardKitTurnTimedOut = cardKitProgress != null && interruptedByIdle;
|
|
122866
123065
|
const reportedStateRead = await readStateFileDetailed(worktreePath);
|
|
122867
123066
|
const rawReportedState = reportedStateRead.state;
|
|
122868
123067
|
const reportedState = rawReportedState?.updated_at != null && rawReportedState.updated_at !== preRunUpdatedAt ? rawReportedState : null;
|
|
@@ -122923,7 +123122,7 @@ var BridgeHandler = class {
|
|
|
122923
123122
|
}
|
|
122924
123123
|
const reportedStatus = reportedState?.status;
|
|
122925
123124
|
const reportedError = reportedState?.error;
|
|
122926
|
-
const cardKitTimeoutFailure =
|
|
123125
|
+
const cardKitTimeoutFailure = interruptedByIdle && reportedStatus !== "ready" && reportedStatus !== "failed";
|
|
122927
123126
|
let success;
|
|
122928
123127
|
let failureReason;
|
|
122929
123128
|
if (reportedStatus === "failed") {
|
|
@@ -123132,8 +123331,13 @@ var BridgeHandler = class {
|
|
|
123132
123331
|
);
|
|
123133
123332
|
break;
|
|
123134
123333
|
} catch (spawnErr) {
|
|
123334
|
+
if (idleWatchdog) {
|
|
123335
|
+
clearInterval(idleWatchdog);
|
|
123336
|
+
idleWatchdog = void 0;
|
|
123337
|
+
}
|
|
123135
123338
|
const errMsg = String(spawnErr.message ?? spawnErr);
|
|
123136
|
-
|
|
123339
|
+
const isStaleSessionErr = errMsg.includes("No conversation found") || errMsg.includes("thread/resume failed") && errMsg.includes("no rollout found");
|
|
123340
|
+
if (attempt === 1 && currentExisting != null && isStaleSessionErr) {
|
|
123137
123341
|
console.warn(
|
|
123138
123342
|
`[bridge.handler] stale session ${currentExisting.sessionId} for thread ${threadId} \u2014 removing and retrying without --resume`
|
|
123139
123343
|
);
|
|
@@ -127509,7 +127713,7 @@ async function writeStatusFile(botId, w) {
|
|
|
127509
127713
|
|
|
127510
127714
|
// src/claude/runner.ts
|
|
127511
127715
|
import { spawn as spawn2 } from "node:child_process";
|
|
127512
|
-
import { writeFile as writeFile3, unlink as
|
|
127716
|
+
import { writeFile as writeFile3, unlink as unlink3, mkdir as mkdir3 } from "node:fs/promises";
|
|
127513
127717
|
import { join as join2 } from "node:path";
|
|
127514
127718
|
import { createInterface } from "node:readline";
|
|
127515
127719
|
var SIGKILL_GRACE_MS = 5e3;
|
|
@@ -127703,9 +127907,13 @@ function runClaude(opts) {
|
|
|
127703
127907
|
function doKill() {
|
|
127704
127908
|
if (child.killed || killScheduled) return;
|
|
127705
127909
|
killScheduled = true;
|
|
127910
|
+
let exited = false;
|
|
127911
|
+
child.once("exit", () => {
|
|
127912
|
+
exited = true;
|
|
127913
|
+
});
|
|
127706
127914
|
child.kill("SIGTERM");
|
|
127707
127915
|
killTimer = setTimeout(() => {
|
|
127708
|
-
if (!
|
|
127916
|
+
if (!exited) child.kill("SIGKILL");
|
|
127709
127917
|
}, SIGKILL_GRACE_MS);
|
|
127710
127918
|
killTimer.unref();
|
|
127711
127919
|
}
|
|
@@ -127758,7 +127966,7 @@ function runClaude(opts) {
|
|
|
127758
127966
|
clearTimeout(totalTimeoutFallbackHandle);
|
|
127759
127967
|
rlAbortController.abort();
|
|
127760
127968
|
if (pidFilePath !== null) {
|
|
127761
|
-
void
|
|
127969
|
+
void unlink3(pidFilePath).catch(() => {
|
|
127762
127970
|
});
|
|
127763
127971
|
}
|
|
127764
127972
|
if (exitCode !== 0 && !killScheduled) {
|
|
@@ -127788,7 +127996,7 @@ stderr: ${stderr}` : "")
|
|
|
127788
127996
|
clearTimeout(totalTimeoutFallbackHandle);
|
|
127789
127997
|
rlAbortController.abort();
|
|
127790
127998
|
if (pidFilePath !== null) {
|
|
127791
|
-
void
|
|
127999
|
+
void unlink3(pidFilePath).catch(() => {
|
|
127792
128000
|
});
|
|
127793
128001
|
}
|
|
127794
128002
|
if (err.code === "ENOENT") {
|
|
@@ -127867,7 +128075,7 @@ var ClaudeRunner = class {
|
|
|
127867
128075
|
|
|
127868
128076
|
// src/claude/pool.ts
|
|
127869
128077
|
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
|
|
128078
|
+
import { mkdir as mkdir4, readFile as readFile6, rename as rename3, stat as stat3, unlink as unlink4, writeFile as writeFile4 } from "node:fs/promises";
|
|
127871
128079
|
import path16 from "node:path";
|
|
127872
128080
|
import { createInterface as createInterface2 } from "node:readline";
|
|
127873
128081
|
import { randomUUID } from "node:crypto";
|
|
@@ -128404,7 +128612,7 @@ ${stderr}` : "")
|
|
|
128404
128612
|
try {
|
|
128405
128613
|
const raw = await readFile6(pidFilePath, "utf8");
|
|
128406
128614
|
const parsed = JSON.parse(raw);
|
|
128407
|
-
if (parsed.pid === entry.child.pid) await
|
|
128615
|
+
if (parsed.pid === entry.child.pid) await unlink4(pidFilePath);
|
|
128408
128616
|
} catch {
|
|
128409
128617
|
}
|
|
128410
128618
|
}
|
|
@@ -128531,7 +128739,7 @@ async function reapOrphanedWarmClaudeProcesses(pidListFilePath) {
|
|
|
128531
128739
|
} catch {
|
|
128532
128740
|
}
|
|
128533
128741
|
}
|
|
128534
|
-
await
|
|
128742
|
+
await unlink4(pidListFilePath).catch(() => {
|
|
128535
128743
|
});
|
|
128536
128744
|
}
|
|
128537
128745
|
|
|
@@ -128699,6 +128907,8 @@ function runCodex(opts, codexBinPath = "codex") {
|
|
|
128699
128907
|
...opts.cwd != null ? { cwd: opts.cwd } : {}
|
|
128700
128908
|
});
|
|
128701
128909
|
markPerf("spawn");
|
|
128910
|
+
child.stdin?.on("error", () => {
|
|
128911
|
+
});
|
|
128702
128912
|
function sendRequest(method, params) {
|
|
128703
128913
|
const id = nextRequestId++;
|
|
128704
128914
|
requestById.set(id, method);
|
|
@@ -128711,9 +128921,13 @@ function runCodex(opts, codexBinPath = "codex") {
|
|
|
128711
128921
|
function doKill() {
|
|
128712
128922
|
if (child.killed || killScheduled) return;
|
|
128713
128923
|
killScheduled = true;
|
|
128924
|
+
let exited = false;
|
|
128925
|
+
child.once("exit", () => {
|
|
128926
|
+
exited = true;
|
|
128927
|
+
});
|
|
128714
128928
|
child.kill("SIGTERM");
|
|
128715
128929
|
killTimer = setTimeout(() => {
|
|
128716
|
-
if (!
|
|
128930
|
+
if (!exited) child.kill("SIGKILL");
|
|
128717
128931
|
}, SIGKILL_GRACE_MS3);
|
|
128718
128932
|
killTimer.unref();
|
|
128719
128933
|
}
|
|
@@ -128933,7 +129147,7 @@ var CodexRunner = class {
|
|
|
128933
129147
|
|
|
128934
129148
|
// src/codex/pool.ts
|
|
128935
129149
|
import { execFile as execFile5, spawn as spawn5 } from "node:child_process";
|
|
128936
|
-
import { mkdir as mkdir5, readFile as readFile7, unlink as
|
|
129150
|
+
import { mkdir as mkdir5, readFile as readFile7, unlink as unlink5, writeFile as writeFile5 } from "node:fs/promises";
|
|
128937
129151
|
import path17 from "node:path";
|
|
128938
129152
|
import { createInterface as createInterface4 } from "node:readline";
|
|
128939
129153
|
var DEFAULT_WARM_PROCESS_IDLE_MS2 = 10 * 60 * 1e3;
|
|
@@ -129195,6 +129409,8 @@ var CodexProcessPool = class {
|
|
|
129195
129409
|
this.#pending.clear();
|
|
129196
129410
|
this.#threadOwners.clear();
|
|
129197
129411
|
this.#currentSpawnReadyResolved = false;
|
|
129412
|
+
child.stdin?.on("error", () => {
|
|
129413
|
+
});
|
|
129198
129414
|
void this.#writePidFileBestEffort(child.pid);
|
|
129199
129415
|
this.#ready = new Promise((resolve2, reject) => {
|
|
129200
129416
|
this.#readyResolve = resolve2;
|
|
@@ -129312,7 +129528,7 @@ var CodexProcessPool = class {
|
|
|
129312
129528
|
try {
|
|
129313
129529
|
const raw = await readFile7(this.#pidFilePath, "utf8");
|
|
129314
129530
|
const parsed = JSON.parse(raw);
|
|
129315
|
-
if (parsed.pid === pid) await
|
|
129531
|
+
if (parsed.pid === pid) await unlink5(this.#pidFilePath);
|
|
129316
129532
|
} catch {
|
|
129317
129533
|
}
|
|
129318
129534
|
}
|
|
@@ -129468,7 +129684,7 @@ async function reapOrphanedWarmProcess(pidFilePath) {
|
|
|
129468
129684
|
);
|
|
129469
129685
|
}
|
|
129470
129686
|
}
|
|
129471
|
-
await
|
|
129687
|
+
await unlink5(pidFilePath).catch(() => {
|
|
129472
129688
|
});
|
|
129473
129689
|
}
|
|
129474
129690
|
|
|
@@ -129732,12 +129948,33 @@ function handleUnhandledRejection(reason, log = console) {
|
|
|
129732
129948
|
rendered
|
|
129733
129949
|
);
|
|
129734
129950
|
}
|
|
129951
|
+
var BREAKER_THRESHOLD = 50;
|
|
129952
|
+
var BREAKER_WINDOW_MS = 6e4;
|
|
129953
|
+
function createCrashStormBreaker(threshold = BREAKER_THRESHOLD, windowMs = BREAKER_WINDOW_MS) {
|
|
129954
|
+
const hits = [];
|
|
129955
|
+
return function note() {
|
|
129956
|
+
const now = Date.now();
|
|
129957
|
+
hits.push(now);
|
|
129958
|
+
while (hits.length > 0 && hits[0] < now - windowMs) hits.shift();
|
|
129959
|
+
return hits.length >= threshold;
|
|
129960
|
+
};
|
|
129961
|
+
}
|
|
129735
129962
|
function registerCrashGuard(log = console) {
|
|
129963
|
+
const noteCrash = createCrashStormBreaker();
|
|
129964
|
+
const maybeTrip = () => {
|
|
129965
|
+
if (!noteCrash()) return;
|
|
129966
|
+
log.error(
|
|
129967
|
+
`[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.`
|
|
129968
|
+
);
|
|
129969
|
+
process.exit(1);
|
|
129970
|
+
};
|
|
129736
129971
|
process.on("uncaughtException", (err, origin) => {
|
|
129737
129972
|
handleUncaughtException(err, origin, log);
|
|
129973
|
+
maybeTrip();
|
|
129738
129974
|
});
|
|
129739
129975
|
process.on("unhandledRejection", (reason) => {
|
|
129740
129976
|
handleUnhandledRejection(reason, log);
|
|
129977
|
+
maybeTrip();
|
|
129741
129978
|
});
|
|
129742
129979
|
}
|
|
129743
129980
|
|