larkway 0.3.42 → 0.3.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/dist/cli/index.js +29 -16
- package/dist/main.js +312 -31
- 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.43**
|
|
12
12
|
|
|
13
13
|
---
|
|
14
14
|
|
package/README.zh.md
CHANGED
package/dist/cli/index.js
CHANGED
|
@@ -126104,24 +126104,37 @@ var BotConfigSchema = external_exports.object({
|
|
|
126104
126104
|
*/
|
|
126105
126105
|
effort: external_exports.string().min(1).optional(),
|
|
126106
126106
|
/**
|
|
126107
|
-
* docs/larkway-perf-plan.md §4 —
|
|
126108
|
-
*
|
|
126109
|
-
*
|
|
126110
|
-
*
|
|
126111
|
-
*
|
|
126112
|
-
*
|
|
126113
|
-
*
|
|
126114
|
-
*
|
|
126115
|
-
*
|
|
126116
|
-
*
|
|
126117
|
-
*
|
|
126118
|
-
*
|
|
126119
|
-
*
|
|
126120
|
-
*
|
|
126121
|
-
*
|
|
126122
|
-
*
|
|
126107
|
+
* docs/larkway-perf-plan.md §4 — persistent warm process instead of a
|
|
126108
|
+
* one-shot cold start per turn. Takes effect for `backend: "codex"` (a
|
|
126109
|
+
* single bot-level `codex app-server` process — src/codex/pool.ts
|
|
126110
|
+
* CodexProcessPool) and `backend: "claude"` (one warm process per active
|
|
126111
|
+
* thread — src/claude/pool.ts ClaudeProcessPool). Any other backend value
|
|
126112
|
+
* is a no-op (main.ts only constructs a pool for these two; botLoader's
|
|
126113
|
+
* advisory warn below flags the mismatch upfront).
|
|
126114
|
+
*
|
|
126115
|
+
* 批D (warm-by-default):
|
|
126116
|
+
* DEFAULT IS NOW ON for the two supported backends — `undefined` means
|
|
126117
|
+
* `true` when backend is codex/claude (see {@link effectiveWarmProcess},
|
|
126118
|
+
* the single source of that rule). Set `warmProcess: false` explicitly to
|
|
126119
|
+
* opt a bot back out. `.optional()` (not `.default(true)`) is still
|
|
126120
|
+
* deliberate: a `.default()` would make every future `larkway bot` yaml
|
|
126121
|
+
* write-out bake the value in, and — because this schema is `.strict()` —
|
|
126122
|
+
* would break loading that yaml back with an OLDER larkway build that
|
|
126123
|
+
* predates this field.
|
|
126123
126124
|
*/
|
|
126124
126125
|
warmProcess: external_exports.boolean().optional(),
|
|
126126
|
+
/**
|
|
126127
|
+
* 批D: pre-warm a BLANK agent process at bridge boot (and keep one on
|
|
126128
|
+
* standby thereafter), so even the FIRST message of a NEW thread skips the
|
|
126129
|
+
* cold start. Only meaningful when the warm pool is on (see
|
|
126130
|
+
* `warmProcess`); default true. backend=claude: ClaudeProcessPool keeps
|
|
126131
|
+
* one blank (no --resume) child matching the bot's static spawn signature,
|
|
126132
|
+
* adopted by the first new-session turn and replenished after adoption.
|
|
126133
|
+
* backend=codex: CodexProcessPool spawns its app-server eagerly at boot
|
|
126134
|
+
* instead of on the first turn. Set false to keep warm pooling but skip
|
|
126135
|
+
* the always-resident standby (saves ~300MB RSS per bot at idle).
|
|
126136
|
+
*/
|
|
126137
|
+
prewarmProcess: external_exports.boolean().optional(),
|
|
126125
126138
|
/**
|
|
126126
126139
|
* Idle threshold (ms) before a warm process (see `warmProcess` above) with
|
|
126127
126140
|
* no in-flight turn gets SIGTERM'd. Only meaningful when `warmProcess` is
|
package/dist/main.js
CHANGED
|
@@ -119390,9 +119390,20 @@ async function renderPrompt(input) {
|
|
|
119390
119390
|
taskHandleClaimed = false,
|
|
119391
119391
|
taskHandleCandidates = [],
|
|
119392
119392
|
taskRoot,
|
|
119393
|
-
mtimeFacts = []
|
|
119393
|
+
mtimeFacts = [],
|
|
119394
|
+
queuedFollowups = []
|
|
119394
119395
|
} = input;
|
|
119395
119396
|
const backend = input.backend ?? "claude";
|
|
119397
|
+
const userMessageLines = [
|
|
119398
|
+
"<user-message>",
|
|
119399
|
+
`${parsed.senderOpenId}: ${parsed.text}`,
|
|
119400
|
+
...queuedFollowups.length > 0 ? [
|
|
119401
|
+
"",
|
|
119402
|
+
`(\u4EE5\u4E0B ${queuedFollowups.length} \u6761\u8FFD\u52A0\u6D88\u606F\u5728\u4E0A\u4E00\u8F6E\u5904\u7406\u671F\u95F4\u5230\u8FBE,\u5DF2\u5408\u5E76\u8FDB\u672C\u8F6E \u2014\u2014 \u6309\u987A\u5E8F\u4E00\u5E76\u5904\u7406,\u53EA\u9700\u4E00\u6B21\u7B54\u590D:)`,
|
|
119403
|
+
...queuedFollowups.map((f) => `${f.senderOpenId}: ${f.text}`)
|
|
119404
|
+
] : [],
|
|
119405
|
+
"</user-message>"
|
|
119406
|
+
];
|
|
119396
119407
|
const profileFlag = larkCliProfile ? ` --profile ${larkCliProfile}` : "";
|
|
119397
119408
|
const attachmentKeys = parsed.attachments.map((a) => a.fileKey);
|
|
119398
119409
|
const imageKeys = parsed.attachments.filter((a) => a.fileType === "image").map((a) => a.fileKey);
|
|
@@ -119524,9 +119535,7 @@ async function renderPrompt(input) {
|
|
|
119524
119535
|
...taskHandleBlock.length > 0 ? ["", ...taskHandleBlock] : [],
|
|
119525
119536
|
...taskRootBlock.length > 0 ? ["", ...taskRootBlock] : [],
|
|
119526
119537
|
"",
|
|
119527
|
-
|
|
119528
|
-
`${parsed.senderOpenId}: ${parsed.text}`,
|
|
119529
|
-
"</user-message>"
|
|
119538
|
+
...userMessageLines
|
|
119530
119539
|
].join("\n");
|
|
119531
119540
|
}
|
|
119532
119541
|
return [
|
|
@@ -119581,9 +119590,7 @@ async function renderPrompt(input) {
|
|
|
119581
119590
|
...taskHandleBlock.length > 0 ? ["", ...taskHandleBlock] : [],
|
|
119582
119591
|
...taskRootBlock.length > 0 ? ["", ...taskRootBlock] : [],
|
|
119583
119592
|
"",
|
|
119584
|
-
|
|
119585
|
-
`${parsed.senderOpenId}: ${parsed.text}`,
|
|
119586
|
-
"</user-message>"
|
|
119593
|
+
...userMessageLines
|
|
119587
119594
|
].join("\n");
|
|
119588
119595
|
}
|
|
119589
119596
|
|
|
@@ -119770,6 +119777,12 @@ function renderTranscriptEntry(input) {
|
|
|
119770
119777
|
"",
|
|
119771
119778
|
indentBlock(parsed.text),
|
|
119772
119779
|
"",
|
|
119780
|
+
...input.queuedFollowups && input.queuedFollowups.length > 0 ? [
|
|
119781
|
+
"### Coalesced Follow-ups",
|
|
119782
|
+
"",
|
|
119783
|
+
...input.queuedFollowups.flatMap((f) => [`- ${f.senderOpenId}:`, indentBlock(f.text)]),
|
|
119784
|
+
""
|
|
119785
|
+
] : [],
|
|
119773
119786
|
"### Feishu Doc Links",
|
|
119774
119787
|
"",
|
|
119775
119788
|
...renderList(parsed.feishuDocLinks),
|
|
@@ -122149,6 +122162,20 @@ async function isWorktreeGitHealthy(worktreePath) {
|
|
|
122149
122162
|
child.on("error", () => resolve2(false));
|
|
122150
122163
|
});
|
|
122151
122164
|
}
|
|
122165
|
+
function canCoalesceFollowup(primary, candidate) {
|
|
122166
|
+
if (primary.larkway_trigger_type != null || candidate.larkway_trigger_type != null) return false;
|
|
122167
|
+
if (primary.reply_anchor_message_id != null || candidate.reply_anchor_message_id != null) return false;
|
|
122168
|
+
const sessionKeyOf = (e) => typeof e.root_id === "string" && e.root_id ? e.root_id : e.message_id;
|
|
122169
|
+
if (sessionKeyOf(primary) !== sessionKeyOf(candidate)) return false;
|
|
122170
|
+
try {
|
|
122171
|
+
const parsed = parseMessage(candidate);
|
|
122172
|
+
if (parsed.attachments.length > 0) return false;
|
|
122173
|
+
if (parsed.text.trim() === "") return false;
|
|
122174
|
+
} catch {
|
|
122175
|
+
return false;
|
|
122176
|
+
}
|
|
122177
|
+
return true;
|
|
122178
|
+
}
|
|
122152
122179
|
var repoCloneLocks = /* @__PURE__ */ new Map();
|
|
122153
122180
|
function ensureRepoClone(basePath, url, token, label) {
|
|
122154
122181
|
const prev = repoCloneLocks.get(basePath) ?? Promise.resolve();
|
|
@@ -122498,6 +122525,7 @@ var BridgeHandler = class {
|
|
|
122498
122525
|
async run(opts) {
|
|
122499
122526
|
const signal = opts?.abortSignal;
|
|
122500
122527
|
const threadQueues = /* @__PURE__ */ new Map();
|
|
122528
|
+
const pendingByKey = /* @__PURE__ */ new Map();
|
|
122501
122529
|
const MAX_CONCURRENT = 5;
|
|
122502
122530
|
let running = 0;
|
|
122503
122531
|
const waiters = [];
|
|
@@ -122523,12 +122551,33 @@ var BridgeHandler = class {
|
|
|
122523
122551
|
this.threadReceivedAt.set(key, Date.now());
|
|
122524
122552
|
const legacySessionKey = typeof event.root_id === "string" && event.root_id ? event.root_id : event.message_id;
|
|
122525
122553
|
if (legacySessionKey !== key) this.threadReceivedAt.set(legacySessionKey, Date.now());
|
|
122554
|
+
let pendingList = pendingByKey.get(key);
|
|
122555
|
+
if (pendingList == null) {
|
|
122556
|
+
pendingList = [];
|
|
122557
|
+
pendingByKey.set(key, pendingList);
|
|
122558
|
+
}
|
|
122559
|
+
pendingList.push(event);
|
|
122526
122560
|
const prev = threadQueues.get(key) ?? Promise.resolve();
|
|
122527
|
-
const next = prev.then(() => acquire()).then(() =>
|
|
122561
|
+
const next = prev.then(() => acquire()).then(() => {
|
|
122562
|
+
const pending = pendingByKey.get(key);
|
|
122563
|
+
const primary = pending?.shift();
|
|
122564
|
+
if (primary == null) return;
|
|
122565
|
+
const followups = [];
|
|
122566
|
+
while (pending != null && pending.length > 0 && canCoalesceFollowup(primary, pending[0])) {
|
|
122567
|
+
followups.push(pending.shift());
|
|
122568
|
+
}
|
|
122569
|
+
if (followups.length > 0) {
|
|
122570
|
+
console.log(
|
|
122571
|
+
`[bridge.handler] coalescing ${followups.length} queued follow-up message(s) into turn ${primary.message_id} on thread ${key}`
|
|
122572
|
+
);
|
|
122573
|
+
}
|
|
122574
|
+
return this.handleOne(primary, followups);
|
|
122575
|
+
}).catch((err) => {
|
|
122528
122576
|
console.error(`[bridge.handler] unhandled error on thread ${key}:`, err);
|
|
122529
122577
|
}).finally(() => {
|
|
122530
122578
|
release();
|
|
122531
122579
|
if (threadQueues.get(key) === next) threadQueues.delete(key);
|
|
122580
|
+
if ((pendingByKey.get(key)?.length ?? 0) === 0) pendingByKey.delete(key);
|
|
122532
122581
|
this.inFlightTurns.delete(next);
|
|
122533
122582
|
});
|
|
122534
122583
|
this.inFlightTurns.add(next);
|
|
@@ -122545,7 +122594,14 @@ var BridgeHandler = class {
|
|
|
122545
122594
|
// ---------------------------------------------------------------------------
|
|
122546
122595
|
// Private: single-event lifecycle
|
|
122547
122596
|
// ---------------------------------------------------------------------------
|
|
122548
|
-
|
|
122597
|
+
/**
|
|
122598
|
+
* @param followups 批D gated coalescing — same-session messages that queued
|
|
122599
|
+
* up behind the previous turn and are merged into THIS one (see run()'s
|
|
122600
|
+
* drain loop + canCoalesceFollowup). They contribute their text to the
|
|
122601
|
+
* prompt and settle together with the primary; everything else about the
|
|
122602
|
+
* turn (card, session, task probes) is driven by `event` alone.
|
|
122603
|
+
*/
|
|
122604
|
+
async handleOne(event, followups = []) {
|
|
122549
122605
|
const settleMessageId = event.message_id;
|
|
122550
122606
|
let settled = false;
|
|
122551
122607
|
let agentRunCompleted = false;
|
|
@@ -122555,8 +122611,10 @@ var BridgeHandler = class {
|
|
|
122555
122611
|
const settle = (ok) => {
|
|
122556
122612
|
if (settled) return;
|
|
122557
122613
|
settled = true;
|
|
122558
|
-
|
|
122559
|
-
|
|
122614
|
+
for (const id of [settleMessageId, ...followups.map((f) => f.message_id)]) {
|
|
122615
|
+
if (ok) this.deps.client.markHandled?.(id);
|
|
122616
|
+
else this.deps.client.markUnhandled?.(id, { replay: !agentRunCompleted });
|
|
122617
|
+
}
|
|
122560
122618
|
};
|
|
122561
122619
|
try {
|
|
122562
122620
|
const parsed = parseMessage(event);
|
|
@@ -122626,6 +122684,27 @@ var BridgeHandler = class {
|
|
|
122626
122684
|
reason: "\u5DF2\u8FDB\u5165 bridge\uFF0C\u51C6\u5907\u521B\u5EFA\u5904\u7406\u5361\u7247\u3002"
|
|
122627
122685
|
});
|
|
122628
122686
|
await this.deps.client.addProcessingReaction?.(messageId);
|
|
122687
|
+
for (const f of followups) {
|
|
122688
|
+
if (!this.deps.recordRuntimeEvent) break;
|
|
122689
|
+
try {
|
|
122690
|
+
await this.deps.recordRuntimeEvent({
|
|
122691
|
+
id: f.message_id,
|
|
122692
|
+
botId,
|
|
122693
|
+
botName: this.deps.botConfig?.name,
|
|
122694
|
+
messageId: f.message_id,
|
|
122695
|
+
threadId,
|
|
122696
|
+
chatId: parsed.chatId,
|
|
122697
|
+
senderId: typeof f.sender_id === "string" ? f.sender_id : void 0,
|
|
122698
|
+
triggerType,
|
|
122699
|
+
status: "received",
|
|
122700
|
+
receivedAt: new Date(eventStartedAt).toISOString(),
|
|
122701
|
+
statusPath: ["\u5DF2\u6536\u5230", "\u5408\u5E76\u8FDB\u540C\u8F6E"],
|
|
122702
|
+
reason: `\u6392\u961F\u671F\u95F4\u5230\u8FBE,\u5DF2\u5408\u5E76\u8FDB ${messageId} \u7684\u540C\u4E00\u8F6E\u5904\u7406\u3002`
|
|
122703
|
+
});
|
|
122704
|
+
} catch (err) {
|
|
122705
|
+
console.warn("[bridge.handler] recordRuntimeEvent (coalesced followup) failed (continuing):", err);
|
|
122706
|
+
}
|
|
122707
|
+
}
|
|
122629
122708
|
const invokeTaskHandleLifecycle = async (fields) => {
|
|
122630
122709
|
if (!this.deps.taskHandleLifecycle || !botId) return;
|
|
122631
122710
|
try {
|
|
@@ -122992,12 +123071,17 @@ var BridgeHandler = class {
|
|
|
122992
123071
|
}
|
|
122993
123072
|
}
|
|
122994
123073
|
}
|
|
123074
|
+
const queuedFollowups = followups.map((f) => {
|
|
123075
|
+
const p = parseMessage(f);
|
|
123076
|
+
return { senderOpenId: p.senderOpenId, text: p.text };
|
|
123077
|
+
});
|
|
122995
123078
|
if (isAgentWorkspace) {
|
|
122996
123079
|
await ensureSessionArtifacts({
|
|
122997
123080
|
sessionPath: worktreePath,
|
|
122998
123081
|
parsed,
|
|
122999
123082
|
isNewThread: existing === void 0,
|
|
123000
|
-
larkCliProfile: this.deps.larkCliProfile
|
|
123083
|
+
larkCliProfile: this.deps.larkCliProfile,
|
|
123084
|
+
queuedFollowups
|
|
123001
123085
|
});
|
|
123002
123086
|
}
|
|
123003
123087
|
try {
|
|
@@ -123114,6 +123198,7 @@ var BridgeHandler = class {
|
|
|
123114
123198
|
const prompt = await renderPrompt({
|
|
123115
123199
|
parsed,
|
|
123116
123200
|
isNewThread: currentIsNewThread,
|
|
123201
|
+
queuedFollowups,
|
|
123117
123202
|
conventions: {
|
|
123118
123203
|
worktreePath,
|
|
123119
123204
|
runtime: conventions.runtime,
|
|
@@ -127039,24 +127124,37 @@ var BotConfigSchema = external_exports.object({
|
|
|
127039
127124
|
*/
|
|
127040
127125
|
effort: external_exports.string().min(1).optional(),
|
|
127041
127126
|
/**
|
|
127042
|
-
* docs/larkway-perf-plan.md §4 —
|
|
127043
|
-
*
|
|
127044
|
-
*
|
|
127045
|
-
*
|
|
127046
|
-
*
|
|
127047
|
-
*
|
|
127048
|
-
*
|
|
127049
|
-
*
|
|
127050
|
-
*
|
|
127051
|
-
*
|
|
127052
|
-
*
|
|
127053
|
-
*
|
|
127054
|
-
*
|
|
127055
|
-
*
|
|
127056
|
-
*
|
|
127057
|
-
*
|
|
127127
|
+
* docs/larkway-perf-plan.md §4 — persistent warm process instead of a
|
|
127128
|
+
* one-shot cold start per turn. Takes effect for `backend: "codex"` (a
|
|
127129
|
+
* single bot-level `codex app-server` process — src/codex/pool.ts
|
|
127130
|
+
* CodexProcessPool) and `backend: "claude"` (one warm process per active
|
|
127131
|
+
* thread — src/claude/pool.ts ClaudeProcessPool). Any other backend value
|
|
127132
|
+
* is a no-op (main.ts only constructs a pool for these two; botLoader's
|
|
127133
|
+
* advisory warn below flags the mismatch upfront).
|
|
127134
|
+
*
|
|
127135
|
+
* 批D (warm-by-default):
|
|
127136
|
+
* DEFAULT IS NOW ON for the two supported backends — `undefined` means
|
|
127137
|
+
* `true` when backend is codex/claude (see {@link effectiveWarmProcess},
|
|
127138
|
+
* the single source of that rule). Set `warmProcess: false` explicitly to
|
|
127139
|
+
* opt a bot back out. `.optional()` (not `.default(true)`) is still
|
|
127140
|
+
* deliberate: a `.default()` would make every future `larkway bot` yaml
|
|
127141
|
+
* write-out bake the value in, and — because this schema is `.strict()` —
|
|
127142
|
+
* would break loading that yaml back with an OLDER larkway build that
|
|
127143
|
+
* predates this field.
|
|
127058
127144
|
*/
|
|
127059
127145
|
warmProcess: external_exports.boolean().optional(),
|
|
127146
|
+
/**
|
|
127147
|
+
* 批D: pre-warm a BLANK agent process at bridge boot (and keep one on
|
|
127148
|
+
* standby thereafter), so even the FIRST message of a NEW thread skips the
|
|
127149
|
+
* cold start. Only meaningful when the warm pool is on (see
|
|
127150
|
+
* `warmProcess`); default true. backend=claude: ClaudeProcessPool keeps
|
|
127151
|
+
* one blank (no --resume) child matching the bot's static spawn signature,
|
|
127152
|
+
* adopted by the first new-session turn and replenished after adoption.
|
|
127153
|
+
* backend=codex: CodexProcessPool spawns its app-server eagerly at boot
|
|
127154
|
+
* instead of on the first turn. Set false to keep warm pooling but skip
|
|
127155
|
+
* the always-resident standby (saves ~300MB RSS per bot at idle).
|
|
127156
|
+
*/
|
|
127157
|
+
prewarmProcess: external_exports.boolean().optional(),
|
|
127060
127158
|
/**
|
|
127061
127159
|
* Idle threshold (ms) before a warm process (see `warmProcess` above) with
|
|
127062
127160
|
* no in-flight turn gets SIGTERM'd. Only meaningful when `warmProcess` is
|
|
@@ -127074,6 +127172,13 @@ var BotConfigSchema = external_exports.object({
|
|
|
127074
127172
|
*/
|
|
127075
127173
|
warmProcessMaxProcesses: external_exports.number().int().positive().optional()
|
|
127076
127174
|
}).strict();
|
|
127175
|
+
function effectiveWarmProcess(bot) {
|
|
127176
|
+
if (bot.backend !== "claude" && bot.backend !== "codex") return bot.warmProcess === true;
|
|
127177
|
+
return bot.warmProcess !== false;
|
|
127178
|
+
}
|
|
127179
|
+
function effectivePrewarmProcess(bot) {
|
|
127180
|
+
return effectiveWarmProcess(bot) && bot.prewarmProcess !== false;
|
|
127181
|
+
}
|
|
127077
127182
|
async function loadBots(botsDir) {
|
|
127078
127183
|
let entries;
|
|
127079
127184
|
try {
|
|
@@ -128347,6 +128452,8 @@ var INTERRUPT_GRACE_MS = 3e3;
|
|
|
128347
128452
|
var DEFAULT_TURN_TIMEOUT_MS = 15 * 60 * 1e3;
|
|
128348
128453
|
var SHUTDOWN_EXIT_WAIT_MS = SIGKILL_GRACE_MS2 + 2e3;
|
|
128349
128454
|
var STDERR_BUFFER_CAP_BYTES = 64 * 1024;
|
|
128455
|
+
var MAX_PREWARM_FAILURES = 3;
|
|
128456
|
+
var PREWARM_RESPAWN_BACKOFF_MS = 1e4;
|
|
128350
128457
|
var ClaudeProcessPool = class {
|
|
128351
128458
|
#botId;
|
|
128352
128459
|
#botGitIdentity;
|
|
@@ -128380,6 +128487,15 @@ var ClaudeProcessPool = class {
|
|
|
128380
128487
|
* -killed while that child still lives. Removed in `#onEntryExit`.
|
|
128381
128488
|
*/
|
|
128382
128489
|
#dying = /* @__PURE__ */ new Set();
|
|
128490
|
+
// -- 批D blank-standby prewarm state ----------------------------------------
|
|
128491
|
+
/** Set by prewarm(); undefined = prewarm never requested (no blank maintenance). */
|
|
128492
|
+
#prewarmProto;
|
|
128493
|
+
/** Consecutive unprompted blank deaths (any age) — resets on a successful adoption. */
|
|
128494
|
+
#prewarmFailures = 0;
|
|
128495
|
+
/** True once the circuit breaker tripped (MAX_PREWARM_FAILURES) — prewarm off until bridge restart. */
|
|
128496
|
+
#prewarmDisabled = false;
|
|
128497
|
+
#prewarmRespawnTimer;
|
|
128498
|
+
#nextBlankId = 1;
|
|
128383
128499
|
constructor(opts) {
|
|
128384
128500
|
this.#botId = opts.botId;
|
|
128385
128501
|
this.#botGitIdentity = opts.botGitIdentity;
|
|
@@ -128396,6 +128512,23 @@ var ClaudeProcessPool = class {
|
|
|
128396
128512
|
get pidsForTesting() {
|
|
128397
128513
|
return [...this.#entries.values()].map((e) => e.child.pid).filter((pid) => pid != null);
|
|
128398
128514
|
}
|
|
128515
|
+
get blankProcessCountForTesting() {
|
|
128516
|
+
return [...this.#entries.values()].filter((e) => e.blank).length;
|
|
128517
|
+
}
|
|
128518
|
+
/**
|
|
128519
|
+
* 批D: start maintaining ONE blank standby child matching `proto`'s spawn
|
|
128520
|
+
* signature — spawned now, adopted by the first new-session turn whose own
|
|
128521
|
+
* options produce the same signature (see PoolEntry.spawnSignature), and
|
|
128522
|
+
* replenished after each adoption. Call once at bridge boot (main.ts),
|
|
128523
|
+
* only for bots whose cwd is static across threads (agent_workspace
|
|
128524
|
+
* runtime); calling it again replaces the proto for future respawns but
|
|
128525
|
+
* never kills an existing blank. No-op after shutdown() or once the
|
|
128526
|
+
* circuit breaker has tripped.
|
|
128527
|
+
*/
|
|
128528
|
+
prewarm(proto) {
|
|
128529
|
+
this.#prewarmProto = proto;
|
|
128530
|
+
this.#maybeSpawnBlank();
|
|
128531
|
+
}
|
|
128399
128532
|
run(opts) {
|
|
128400
128533
|
const queue = new TurnEventQueue();
|
|
128401
128534
|
const markPerf = createPerfMarker(opts.onPerfMarker);
|
|
@@ -128443,6 +128576,9 @@ var ClaudeProcessPool = class {
|
|
|
128443
128576
|
}
|
|
128444
128577
|
}
|
|
128445
128578
|
let entry = this.#entries.get(key);
|
|
128579
|
+
if (entry == null && opts.resumeSessionId == null) {
|
|
128580
|
+
entry = this.#adoptBlankEntry(key, threadId, opts);
|
|
128581
|
+
}
|
|
128446
128582
|
if (entry == null) {
|
|
128447
128583
|
if (this.#entries.size >= this.#maxProcesses) {
|
|
128448
128584
|
const victim = this.#pickLruIdleVictim();
|
|
@@ -128472,6 +128608,10 @@ var ClaudeProcessPool = class {
|
|
|
128472
128608
|
clearInterval(this.#idleSweepTimer);
|
|
128473
128609
|
this.#idleSweepTimer = void 0;
|
|
128474
128610
|
}
|
|
128611
|
+
if (this.#prewarmRespawnTimer) {
|
|
128612
|
+
clearTimeout(this.#prewarmRespawnTimer);
|
|
128613
|
+
this.#prewarmRespawnTimer = void 0;
|
|
128614
|
+
}
|
|
128475
128615
|
const inFlight = [...this.#entries.values()].map((e) => e.current?.done.catch(() => void 0)).filter((p) => p != null);
|
|
128476
128616
|
await Promise.race([
|
|
128477
128617
|
Promise.all(inFlight),
|
|
@@ -128518,6 +128658,9 @@ var ClaudeProcessPool = class {
|
|
|
128518
128658
|
return `${this.#botId}::${threadId}::${cwd}::${model}::${effort}`;
|
|
128519
128659
|
}
|
|
128520
128660
|
#pickLruIdleVictim() {
|
|
128661
|
+
for (const entry of this.#entries.values()) {
|
|
128662
|
+
if (entry.current == null && entry.blank) return entry;
|
|
128663
|
+
}
|
|
128521
128664
|
let victim;
|
|
128522
128665
|
for (const entry of this.#entries.values()) {
|
|
128523
128666
|
if (entry.current != null) continue;
|
|
@@ -128670,7 +128813,89 @@ var ClaudeProcessPool = class {
|
|
|
128670
128813
|
if (state.interruptEscalateTimer) clearTimeout(state.interruptEscalateTimer);
|
|
128671
128814
|
}
|
|
128672
128815
|
// -- process lifecycle -------------------------------------------------------
|
|
128673
|
-
|
|
128816
|
+
/**
|
|
128817
|
+
* The spawn identity a set of run/prewarm options resolves to. Built from
|
|
128818
|
+
* the SAME buildWarmCommand path #spawnEntry actually spawns with, so an
|
|
128819
|
+
* adoption match is by construction a spawn-arg-identical process.
|
|
128820
|
+
* `resumeSessionId` is deliberately dropped: adoption is only ever
|
|
128821
|
+
* attempted for turns with no resume (run() guards), and the blank itself
|
|
128822
|
+
* is spawned without one.
|
|
128823
|
+
*/
|
|
128824
|
+
#spawnSignatureOf(opts) {
|
|
128825
|
+
const [bin, args] = buildWarmCommand({
|
|
128826
|
+
prompt: "",
|
|
128827
|
+
cwd: opts.cwd,
|
|
128828
|
+
model: opts.model,
|
|
128829
|
+
effort: opts.effort,
|
|
128830
|
+
permissionMode: opts.permissionMode,
|
|
128831
|
+
agentBinPath: opts.agentBinPath
|
|
128832
|
+
});
|
|
128833
|
+
return JSON.stringify([bin, args, opts.cwd ?? null]);
|
|
128834
|
+
}
|
|
128835
|
+
/**
|
|
128836
|
+
* 批D: find an idle blank whose spawn signature matches this turn's options
|
|
128837
|
+
* and rekey it in place onto (key, threadId). Returns undefined when no
|
|
128838
|
+
* blank matches — the caller falls through to a normal spawn. Schedules a
|
|
128839
|
+
* replacement blank on success.
|
|
128840
|
+
*/
|
|
128841
|
+
#adoptBlankEntry(key, threadId, opts) {
|
|
128842
|
+
let blank;
|
|
128843
|
+
for (const e of this.#entries.values()) {
|
|
128844
|
+
if (e.blank && !e.destroyed && e.current == null) {
|
|
128845
|
+
blank = e;
|
|
128846
|
+
break;
|
|
128847
|
+
}
|
|
128848
|
+
}
|
|
128849
|
+
if (blank == null) return void 0;
|
|
128850
|
+
const wanted = this.#spawnSignatureOf(opts);
|
|
128851
|
+
if (blank.spawnSignature !== wanted) {
|
|
128852
|
+
console.warn(
|
|
128853
|
+
`[claude-pool] blank standby exists but its spawn signature doesn't match this turn's options \u2014 not adopting (blank=${blank.spawnSignature} turn=${wanted}). Check the prewarm wiring in main.ts.`
|
|
128854
|
+
);
|
|
128855
|
+
return void 0;
|
|
128856
|
+
}
|
|
128857
|
+
this.#entries.delete(blank.key);
|
|
128858
|
+
blank.key = key;
|
|
128859
|
+
blank.threadId = threadId;
|
|
128860
|
+
blank.blank = false;
|
|
128861
|
+
blank.lastUsedAt = Date.now();
|
|
128862
|
+
this.#entries.set(key, blank);
|
|
128863
|
+
this.#rewritePidListBestEffort();
|
|
128864
|
+
this.#prewarmFailures = 0;
|
|
128865
|
+
console.warn(
|
|
128866
|
+
`[claude-pool] blank standby pid=${blank.child.pid ?? "?"} adopted by thread=${threadId} \u2014 this turn skips the cold start entirely.`
|
|
128867
|
+
);
|
|
128868
|
+
queueMicrotask(() => this.#maybeSpawnBlank());
|
|
128869
|
+
return blank;
|
|
128870
|
+
}
|
|
128871
|
+
/**
|
|
128872
|
+
* Spawn the standby blank when prewarm is configured and conditions allow:
|
|
128873
|
+
* at most one blank at a time, never past maxProcesses, never while
|
|
128874
|
+
* shutting down, and never after the circuit breaker tripped.
|
|
128875
|
+
*/
|
|
128876
|
+
#maybeSpawnBlank() {
|
|
128877
|
+
if (this.#prewarmProto == null || this.#prewarmDisabled || this.#shuttingDown) return;
|
|
128878
|
+
if (this.#prewarmRespawnTimer != null) return;
|
|
128879
|
+
if (this.#entries.size >= this.#maxProcesses) return;
|
|
128880
|
+
for (const e of this.#entries.values()) {
|
|
128881
|
+
if (e.blank) return;
|
|
128882
|
+
}
|
|
128883
|
+
const proto = this.#prewarmProto;
|
|
128884
|
+
const key = `__blank__::${this.#botId}::${this.#nextBlankId++}`;
|
|
128885
|
+
const opts = {
|
|
128886
|
+
prompt: "",
|
|
128887
|
+
cwd: proto.cwd,
|
|
128888
|
+
model: proto.model,
|
|
128889
|
+
effort: proto.effort,
|
|
128890
|
+
permissionMode: proto.permissionMode,
|
|
128891
|
+
agentBinPath: proto.agentBinPath
|
|
128892
|
+
};
|
|
128893
|
+
const entry = this.#spawnEntry(key, key, opts, { blank: true });
|
|
128894
|
+
console.warn(
|
|
128895
|
+
`[claude-pool] pre-warmed blank standby pid=${entry.child.pid ?? "?"} for bot=${this.#botId} (signature=${entry.spawnSignature.slice(0, 120)}\u2026)`
|
|
128896
|
+
);
|
|
128897
|
+
}
|
|
128898
|
+
#spawnEntry(key, threadId, opts, flags) {
|
|
128674
128899
|
const [bin, args] = buildWarmCommand(opts);
|
|
128675
128900
|
const env = buildEnv(this.#botGitIdentity, this.#gitlabToken);
|
|
128676
128901
|
const child = spawn3(bin, args, {
|
|
@@ -128682,6 +128907,8 @@ var ClaudeProcessPool = class {
|
|
|
128682
128907
|
key,
|
|
128683
128908
|
threadId,
|
|
128684
128909
|
cwd: opts.cwd,
|
|
128910
|
+
blank: flags?.blank === true,
|
|
128911
|
+
spawnSignature: this.#spawnSignatureOf(opts),
|
|
128685
128912
|
child,
|
|
128686
128913
|
spawnedAt: Date.now(),
|
|
128687
128914
|
lastUsedAt: Date.now(),
|
|
@@ -128747,6 +128974,32 @@ var ClaudeProcessPool = class {
|
|
|
128747
128974
|
this.#dying.delete(entry);
|
|
128748
128975
|
this.#rewritePidListBestEffort();
|
|
128749
128976
|
void this.#deleteRunnerPidFileIfMine(entry);
|
|
128977
|
+
if (this.#prewarmProto != null && !this.#shuttingDown && !this.#prewarmDisabled) {
|
|
128978
|
+
const unpromptedBlankDeath = entry.blank && !wasAlreadyMarkedDestroyed;
|
|
128979
|
+
if (unpromptedBlankDeath) {
|
|
128980
|
+
this.#prewarmFailures += 1;
|
|
128981
|
+
if (this.#prewarmFailures >= MAX_PREWARM_FAILURES) {
|
|
128982
|
+
this.#prewarmDisabled = true;
|
|
128983
|
+
const stderr = Buffer.concat(entry.stderrChunks).toString("utf8").trim().slice(-2e3);
|
|
128984
|
+
console.warn(
|
|
128985
|
+
`[claude-pool] blank standby died unprompted ${this.#prewarmFailures} times in a row \u2014 disabling prewarm until the bridge restarts (warm pooling itself stays on; turns just spawn on demand).` + (stderr ? ` last stderr tail:
|
|
128986
|
+
${stderr}` : "")
|
|
128987
|
+
);
|
|
128988
|
+
} else {
|
|
128989
|
+
const delay = PREWARM_RESPAWN_BACKOFF_MS * 2 ** (this.#prewarmFailures - 1);
|
|
128990
|
+
console.warn(
|
|
128991
|
+
`[claude-pool] blank standby pid=${entry.child.pid ?? "?"} died unprompted after ${Math.round((Date.now() - entry.spawnedAt) / 1e3)}s (${this.#prewarmFailures}/${MAX_PREWARM_FAILURES}) \u2014 respawning in ${delay}ms.`
|
|
128992
|
+
);
|
|
128993
|
+
this.#prewarmRespawnTimer = setTimeout(() => {
|
|
128994
|
+
this.#prewarmRespawnTimer = void 0;
|
|
128995
|
+
this.#maybeSpawnBlank();
|
|
128996
|
+
}, delay);
|
|
128997
|
+
this.#prewarmRespawnTimer.unref?.();
|
|
128998
|
+
}
|
|
128999
|
+
} else {
|
|
129000
|
+
queueMicrotask(() => this.#maybeSpawnBlank());
|
|
129001
|
+
}
|
|
129002
|
+
}
|
|
128750
129003
|
const state = entry.current;
|
|
128751
129004
|
entry.current = void 0;
|
|
128752
129005
|
if (state == null || state.settled) return;
|
|
@@ -128799,6 +129052,7 @@ ${stderr}` : "")
|
|
|
128799
129052
|
const now = Date.now();
|
|
128800
129053
|
for (const entry of [...this.#entries.values()]) {
|
|
128801
129054
|
if (entry.current != null) continue;
|
|
129055
|
+
if (entry.blank) continue;
|
|
128802
129056
|
if (now - entry.lastUsedAt >= this.#idleMs) {
|
|
128803
129057
|
this.#destroyEntry(entry, "idle timeout");
|
|
128804
129058
|
}
|
|
@@ -129418,6 +129672,19 @@ var CodexProcessPool = class {
|
|
|
129418
129672
|
get childPid() {
|
|
129419
129673
|
return this.#child?.pid ?? void 0;
|
|
129420
129674
|
}
|
|
129675
|
+
/**
|
|
129676
|
+
* 批D: spawn the app-server eagerly at bridge boot instead of lazily on the
|
|
129677
|
+
* first turn, so even the bot's first message hits a running process.
|
|
129678
|
+
* Idempotent; no-op while shutting down or after the spawn circuit breaker
|
|
129679
|
+
* (#poolDisabled) tripped. Unlike ClaudeProcessPool's blank standby there
|
|
129680
|
+
* is no post-idle-reap replenish here: after the 10-min idle reap the next
|
|
129681
|
+
* turn re-spawns on demand exactly as before (the reap exists to bound the
|
|
129682
|
+
* app-server's per-thread memory growth — auto-respawning would defeat it).
|
|
129683
|
+
*/
|
|
129684
|
+
prewarm() {
|
|
129685
|
+
if (this.#shuttingDown || this.#poolDisabled) return;
|
|
129686
|
+
if (this.#child == null) this.#spawnChild();
|
|
129687
|
+
}
|
|
129421
129688
|
run(opts) {
|
|
129422
129689
|
const turnKey = this.#nextTurnKey++;
|
|
129423
129690
|
const queue = new TurnEventQueue();
|
|
@@ -132357,7 +132624,8 @@ async function runV2Mode({
|
|
|
132357
132624
|
let codexPool;
|
|
132358
132625
|
let claudePool;
|
|
132359
132626
|
let runnerKey;
|
|
132360
|
-
|
|
132627
|
+
const warmProcessOn = effectiveWarmProcess(bot);
|
|
132628
|
+
if (warmProcessOn && bot.backend === "codex") {
|
|
132361
132629
|
const pidFilePath = path19.join(botDir, "warm-codex.pid");
|
|
132362
132630
|
try {
|
|
132363
132631
|
await reapOrphanedWarmProcess(pidFilePath);
|
|
@@ -132372,7 +132640,8 @@ async function runV2Mode({
|
|
|
132372
132640
|
});
|
|
132373
132641
|
runnerKey = `codex-pool:${bot.id}`;
|
|
132374
132642
|
registerRunner(runnerKey, () => codexPool);
|
|
132375
|
-
|
|
132643
|
+
if (effectivePrewarmProcess(bot)) codexPool.prewarm();
|
|
132644
|
+
} else if (warmProcessOn && bot.backend === "claude") {
|
|
132376
132645
|
const pidListFilePath = path19.join(botDir, "warm-claude.pids.json");
|
|
132377
132646
|
try {
|
|
132378
132647
|
await reapOrphanedWarmClaudeProcesses(pidListFilePath);
|
|
@@ -132389,6 +132658,18 @@ async function runV2Mode({
|
|
|
132389
132658
|
});
|
|
132390
132659
|
runnerKey = `claude-pool:${bot.id}`;
|
|
132391
132660
|
registerRunner(runnerKey, () => claudePool);
|
|
132661
|
+
if (effectivePrewarmProcess(bot) && bot.runtime === "agent_workspace" && agentWorkspacePath) {
|
|
132662
|
+
claudePool.prewarm({
|
|
132663
|
+
cwd: agentWorkspacePath,
|
|
132664
|
+
model: bot.model,
|
|
132665
|
+
effort: bot.effort,
|
|
132666
|
+
// Mirrors handler.ts's own default ("bypassPermissions" when
|
|
132667
|
+
// config.json leaves permissions.mode unset). If these two ever
|
|
132668
|
+
// drift the blank's signature just stops matching — fail-safe, and
|
|
132669
|
+
// the pool logs the mismatch on every missed adoption.
|
|
132670
|
+
permissionMode: configJson.permissions.mode ?? "bypassPermissions"
|
|
132671
|
+
});
|
|
132672
|
+
}
|
|
132392
132673
|
}
|
|
132393
132674
|
const handler = new BridgeHandler({
|
|
132394
132675
|
client,
|