larkway 0.3.42 → 0.3.44
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 +34 -16
- package/dist/main.js +408 -43
- 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.44**
|
|
12
12
|
|
|
13
13
|
---
|
|
14
14
|
|
package/README.zh.md
CHANGED
package/dist/cli/index.js
CHANGED
|
@@ -112530,6 +112530,11 @@ var init_messageLookupClient = __esm({
|
|
|
112530
112530
|
const cached = this.#cache.get(messageId);
|
|
112531
112531
|
if (cached) return cached;
|
|
112532
112532
|
}
|
|
112533
|
+
const first = await this.#getOnce(messageId);
|
|
112534
|
+
if (first) return first;
|
|
112535
|
+
return this.#getOnce(messageId);
|
|
112536
|
+
}
|
|
112537
|
+
async #getOnce(messageId) {
|
|
112533
112538
|
try {
|
|
112534
112539
|
const channel = this.#resolveChannel();
|
|
112535
112540
|
if (!channel) return void 0;
|
|
@@ -126104,24 +126109,37 @@ var BotConfigSchema = external_exports.object({
|
|
|
126104
126109
|
*/
|
|
126105
126110
|
effort: external_exports.string().min(1).optional(),
|
|
126106
126111
|
/**
|
|
126107
|
-
* docs/larkway-perf-plan.md §4 —
|
|
126108
|
-
*
|
|
126109
|
-
*
|
|
126110
|
-
*
|
|
126111
|
-
*
|
|
126112
|
-
*
|
|
126113
|
-
*
|
|
126114
|
-
*
|
|
126115
|
-
*
|
|
126116
|
-
*
|
|
126117
|
-
*
|
|
126118
|
-
*
|
|
126119
|
-
*
|
|
126120
|
-
*
|
|
126121
|
-
*
|
|
126122
|
-
*
|
|
126112
|
+
* docs/larkway-perf-plan.md §4 — persistent warm process instead of a
|
|
126113
|
+
* one-shot cold start per turn. Takes effect for `backend: "codex"` (a
|
|
126114
|
+
* single bot-level `codex app-server` process — src/codex/pool.ts
|
|
126115
|
+
* CodexProcessPool) and `backend: "claude"` (one warm process per active
|
|
126116
|
+
* thread — src/claude/pool.ts ClaudeProcessPool). Any other backend value
|
|
126117
|
+
* is a no-op (main.ts only constructs a pool for these two; botLoader's
|
|
126118
|
+
* advisory warn below flags the mismatch upfront).
|
|
126119
|
+
*
|
|
126120
|
+
* 批D (warm-by-default):
|
|
126121
|
+
* DEFAULT IS NOW ON for the two supported backends — `undefined` means
|
|
126122
|
+
* `true` when backend is codex/claude (see {@link effectiveWarmProcess},
|
|
126123
|
+
* the single source of that rule). Set `warmProcess: false` explicitly to
|
|
126124
|
+
* opt a bot back out. `.optional()` (not `.default(true)`) is still
|
|
126125
|
+
* deliberate: a `.default()` would make every future `larkway bot` yaml
|
|
126126
|
+
* write-out bake the value in, and — because this schema is `.strict()` —
|
|
126127
|
+
* would break loading that yaml back with an OLDER larkway build that
|
|
126128
|
+
* predates this field.
|
|
126123
126129
|
*/
|
|
126124
126130
|
warmProcess: external_exports.boolean().optional(),
|
|
126131
|
+
/**
|
|
126132
|
+
* 批D: pre-warm a BLANK agent process at bridge boot (and keep one on
|
|
126133
|
+
* standby thereafter), so even the FIRST message of a NEW thread skips the
|
|
126134
|
+
* cold start. Only meaningful when the warm pool is on (see
|
|
126135
|
+
* `warmProcess`); default true. backend=claude: ClaudeProcessPool keeps
|
|
126136
|
+
* one blank (no --resume) child matching the bot's static spawn signature,
|
|
126137
|
+
* adopted by the first new-session turn and replenished after adoption.
|
|
126138
|
+
* backend=codex: CodexProcessPool spawns its app-server eagerly at boot
|
|
126139
|
+
* instead of on the first turn. Set false to keep warm pooling but skip
|
|
126140
|
+
* the always-resident standby (saves ~300MB RSS per bot at idle).
|
|
126141
|
+
*/
|
|
126142
|
+
prewarmProcess: external_exports.boolean().optional(),
|
|
126125
126143
|
/**
|
|
126126
126144
|
* Idle threshold (ms) before a warm process (see `warmProcess` above) with
|
|
126127
126145
|
* no in-flight turn gets SIGTERM'd. Only meaningful when `warmProcess` is
|
package/dist/main.js
CHANGED
|
@@ -116684,6 +116684,11 @@ var ChannelMessageLookupClient = class {
|
|
|
116684
116684
|
const cached = this.#cache.get(messageId);
|
|
116685
116685
|
if (cached) return cached;
|
|
116686
116686
|
}
|
|
116687
|
+
const first = await this.#getOnce(messageId);
|
|
116688
|
+
if (first) return first;
|
|
116689
|
+
return this.#getOnce(messageId);
|
|
116690
|
+
}
|
|
116691
|
+
async #getOnce(messageId) {
|
|
116687
116692
|
try {
|
|
116688
116693
|
const channel = this.#resolveChannel();
|
|
116689
116694
|
if (!channel) return void 0;
|
|
@@ -119225,12 +119230,18 @@ function renderTaskRootBlock(taskRoot) {
|
|
|
119225
119230
|
`task_root_claimed: ${taskRoot.claimed ? "yes" : "no"}`
|
|
119226
119231
|
];
|
|
119227
119232
|
if (taskRoot.claimed) {
|
|
119228
|
-
|
|
119229
|
-
|
|
119230
|
-
|
|
119233
|
+
if (taskRoot.justClaimed) {
|
|
119234
|
+
lines.push(
|
|
119235
|
+
`bridge \u5DF2\u81EA\u52A8\u8BA4\u9886\u8FD9\u4E2A\u4EFB\u52A1(\u673A\u68B0\u7ED1\u5B9A,\u4E0D\u9700\u8981\u4F60\u518D\u5199 state.json \u8BA4\u9886),\u4F46**\u8BA4\u9886\u8BC4\u8BBA\u8FD8\u6B20\u7740**\u3002\u672C\u8F6E\u8BF7:\u2460 \u5148\u8BFB\u4E00\u904D\u8BE5\u4EFB\u52A1\u7684\u73B0\u6709\u8BC4\u8BBA(lark-cli task comments list)\u2014\u2014\u5DF2\u6709**\u5176\u4ED6 agent** \u7684\u8BA4\u9886\u58F0\u660E\u8BF4\u660E\u5BF9\u65B9\u5DF2\u63A5\u624B,\u4E0D\u8981\u518D\u53D1\u8BA4\u9886\u8BC4\u8BBA(\u91CD\u590D\u8BC4\u8BBA=\u9A9A\u6270),\u914D\u5408\u5E72\u6D3B\u5373\u53EF;\u2461 \u8BC4\u8BBA\u533A\u6CA1\u6709\u522B\u4EBA\u8BA4\u9886 \u2192 \u53D1\u4E00\u6761**\u8BA4\u9886\u8BC4\u8BBA**(lark-cli task comments create):\u4E00\u53E5\u8BA4\u9886\u58F0\u660E${taskRoot.topicLink ? " + \u4E0A\u9762\u7684 topic_link(\u7528\u6237\u70B9\u5B83\u76F4\u8FBE\u672C\u8BDD\u9898\u2014\u2014\u4EFB\u52A1\u2192\u8BDD\u9898\u552F\u4E00\u56DE\u8DF3,\u522B\u7701\u7565)" : ""};\u2462 \u6B63\u5E38\u5E72\u6D3B(\u4EFB\u52A1\u6807\u9898/\u63CF\u8FF0\u5C31\u662F\u9700\u6C42)\u3002\u4E4B\u540E\u7EF4\u62A4\u9762\u53EA\u6709\u4EFB\u52A1\u8BC4\u8BBA(\u4EA4\u4ED8/\u5931\u8D25/\u7B49\u62CD\u677F\u624D\u53D1,\u8FC7\u7A0B\u788E\u788E\u5FF5\u4E0D\u53D1);\u771F\u6B63\u4EA4\u4ED8\u7684\u90A3\u4E00\u8F6E\u5728 state.json \u7684 task_handle \u91CC\u58F0\u660E done: true \u5E76\u53D1\u300C\u5DF2\u4EA4\u4ED8,\u770B\u8FC7\u8BF7\u70B9\u5B8C\u6210\u300D\u8BC4\u8BBA\u3002\u4E0D\u8981\u4FEE\u6539\u4EFB\u52A1\u63CF\u8FF0\u3001\u4E0D\u8981\u52FE\u5B8C\u6210/reopen\u2014\u2014\u5B8C\u6210\u6C38\u8FDC\u7531\u4EBA\u5728\u4EFB\u52A1\u4E2D\u5FC3\u70B9\u3002`
|
|
119236
|
+
);
|
|
119237
|
+
} else {
|
|
119238
|
+
lines.push(
|
|
119239
|
+
"\u672C\u8BDD\u9898\u5DF2\u8BA4\u9886\u8FD9\u4E2A\u4EFB\u52A1\u3002\u7EF4\u62A4\u9762\u53EA\u6709\u4EFB\u52A1\u8BC4\u8BBA:\u4EC5\u5728\u4EA4\u4ED8/\u5931\u8D25/\u7B49\u62CD\u677F\u4E09\u7C7B\u91CC\u7A0B\u7891\u8282\u70B9\u53D1\u4EFB\u52A1\u8BC4\u8BBA(lark-cli task comments create);\u771F\u6B63\u4EA4\u4ED8\u7684\u90A3\u4E00\u8F6E\u5728 state.json \u7684 task_handle \u91CC\u58F0\u660E done: true,\u5E76\u53D1\u4E00\u6761\u300C\u5DF2\u4EA4\u4ED8,\u770B\u8FC7\u8BF7\u70B9\u5B8C\u6210\u300D\u8BC4\u8BBA\u3002\u4E0D\u8981\u4FEE\u6539\u4EFB\u52A1\u63CF\u8FF0\u3001\u4E0D\u8981\u52FE\u5B8C\u6210/reopen\u2014\u2014\u5B8C\u6210\u6C38\u8FDC\u7531\u4EBA\u5728\u4EFB\u52A1\u4E2D\u5FC3\u70B9\u3002"
|
|
119240
|
+
);
|
|
119241
|
+
}
|
|
119231
119242
|
} else {
|
|
119232
119243
|
lines.push(
|
|
119233
|
-
|
|
119244
|
+
`bridge \u672A\u80FD\u81EA\u52A8\u8BA4\u9886\u8FD9\u4E2A\u4EFB\u52A1\u2014\u2014\u6700\u5E38\u89C1\u7684\u539F\u56E0\u662F\u5B83\u5DF2\u88AB**\u53E6\u4E00\u4E2A\u8BDD\u9898\u6216\u53E6\u4E00\u4E2A agent** \u8BA4\u9886(\u673A\u68B0\u5B88\u536B\u62D2\u7EDD\u91CD\u590D\u8BA4\u9886),\u4E5F\u53EF\u80FD\u662F\u8BA4\u9886\u901A\u9053\u672A\u63A5\u5165\u3002\u672C\u8F6E\u8BF7:\u2460 \u5148\u8BFB\u4E00\u904D\u8BE5\u4EFB\u52A1\u7684\u73B0\u6709\u8BC4\u8BBA(lark-cli task comments list)\u2014\u2014\u5DF2\u6709\u5176\u4ED6 agent \u7684\u8BA4\u9886\u58F0\u660E \u2192 \u5BF9\u65B9\u5DF2\u63A5\u624B,\u4F60\u53EA\u662F\u88AB\u53EB\u6765\u534F\u4F5C:\u5E72\u597D\u88AB @ \u6765\u505A\u7684\u4E8B,\u4E0D\u8981\u8BA4\u9886\u3001\u4E0D\u8981\u91CD\u590D\u53D1\u8BA4\u9886\u8BC4\u8BBA;\u2461 \u8BC4\u8BBA\u533A\u6CA1\u6709\u4EFB\u4F55\u8BA4\u9886\u3001\u4E14\u4F60\u5224\u65AD\u8BE5\u7531\u4F60\u63A5\u624B \u2192 \u628A\u4E0A\u9762\u7684 task_guid \u5199\u5165 state.json \u7684 task_handle.guid,\u5E76\u53D1\u4E00\u6761\u8BA4\u9886\u8BC4\u8BBA(lark-cli task comments create),\u5185\u5BB9 = \u4E00\u53E5\u8BA4\u9886\u58F0\u660E${taskRoot.topicLink ? " + \u4E0A\u9762\u7684 topic_link(\u7528\u6237\u70B9\u5B83\u76F4\u8FBE\u672C\u8BDD\u9898)" : ""}\u3002\u4E4B\u540E\u7EF4\u62A4\u9762\u53EA\u6709\u4EFB\u52A1\u8BC4\u8BBA(\u4EA4\u4ED8/\u5931\u8D25/\u7B49\u62CD\u677F\u624D\u53D1,\u8FC7\u7A0B\u788E\u788E\u5FF5\u4E0D\u53D1);\u4E0D\u8981\u4FEE\u6539\u4EFB\u52A1\u63CF\u8FF0\u3001\u4E0D\u8981\u52FE\u5B8C\u6210\u2014\u2014\u5B8C\u6210\u6C38\u8FDC\u7531\u4EBA\u70B9\u3002`
|
|
119234
119245
|
);
|
|
119235
119246
|
}
|
|
119236
119247
|
lines.push("</task-root>");
|
|
@@ -119390,9 +119401,20 @@ async function renderPrompt(input) {
|
|
|
119390
119401
|
taskHandleClaimed = false,
|
|
119391
119402
|
taskHandleCandidates = [],
|
|
119392
119403
|
taskRoot,
|
|
119393
|
-
mtimeFacts = []
|
|
119404
|
+
mtimeFacts = [],
|
|
119405
|
+
queuedFollowups = []
|
|
119394
119406
|
} = input;
|
|
119395
119407
|
const backend = input.backend ?? "claude";
|
|
119408
|
+
const userMessageLines = [
|
|
119409
|
+
"<user-message>",
|
|
119410
|
+
`${parsed.senderOpenId}: ${parsed.text}`,
|
|
119411
|
+
...queuedFollowups.length > 0 ? [
|
|
119412
|
+
"",
|
|
119413
|
+
`(\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:)`,
|
|
119414
|
+
...queuedFollowups.map((f) => `${f.senderOpenId}: ${f.text}`)
|
|
119415
|
+
] : [],
|
|
119416
|
+
"</user-message>"
|
|
119417
|
+
];
|
|
119396
119418
|
const profileFlag = larkCliProfile ? ` --profile ${larkCliProfile}` : "";
|
|
119397
119419
|
const attachmentKeys = parsed.attachments.map((a) => a.fileKey);
|
|
119398
119420
|
const imageKeys = parsed.attachments.filter((a) => a.fileType === "image").map((a) => a.fileKey);
|
|
@@ -119524,9 +119546,7 @@ async function renderPrompt(input) {
|
|
|
119524
119546
|
...taskHandleBlock.length > 0 ? ["", ...taskHandleBlock] : [],
|
|
119525
119547
|
...taskRootBlock.length > 0 ? ["", ...taskRootBlock] : [],
|
|
119526
119548
|
"",
|
|
119527
|
-
|
|
119528
|
-
`${parsed.senderOpenId}: ${parsed.text}`,
|
|
119529
|
-
"</user-message>"
|
|
119549
|
+
...userMessageLines
|
|
119530
119550
|
].join("\n");
|
|
119531
119551
|
}
|
|
119532
119552
|
return [
|
|
@@ -119581,9 +119601,7 @@ async function renderPrompt(input) {
|
|
|
119581
119601
|
...taskHandleBlock.length > 0 ? ["", ...taskHandleBlock] : [],
|
|
119582
119602
|
...taskRootBlock.length > 0 ? ["", ...taskRootBlock] : [],
|
|
119583
119603
|
"",
|
|
119584
|
-
|
|
119585
|
-
`${parsed.senderOpenId}: ${parsed.text}`,
|
|
119586
|
-
"</user-message>"
|
|
119604
|
+
...userMessageLines
|
|
119587
119605
|
].join("\n");
|
|
119588
119606
|
}
|
|
119589
119607
|
|
|
@@ -119770,6 +119788,12 @@ function renderTranscriptEntry(input) {
|
|
|
119770
119788
|
"",
|
|
119771
119789
|
indentBlock(parsed.text),
|
|
119772
119790
|
"",
|
|
119791
|
+
...input.queuedFollowups && input.queuedFollowups.length > 0 ? [
|
|
119792
|
+
"### Coalesced Follow-ups",
|
|
119793
|
+
"",
|
|
119794
|
+
...input.queuedFollowups.flatMap((f) => [`- ${f.senderOpenId}:`, indentBlock(f.text)]),
|
|
119795
|
+
""
|
|
119796
|
+
] : [],
|
|
119773
119797
|
"### Feishu Doc Links",
|
|
119774
119798
|
"",
|
|
119775
119799
|
...renderList(parsed.feishuDocLinks),
|
|
@@ -122149,6 +122173,20 @@ async function isWorktreeGitHealthy(worktreePath) {
|
|
|
122149
122173
|
child.on("error", () => resolve2(false));
|
|
122150
122174
|
});
|
|
122151
122175
|
}
|
|
122176
|
+
function canCoalesceFollowup(primary, candidate) {
|
|
122177
|
+
if (primary.larkway_trigger_type != null || candidate.larkway_trigger_type != null) return false;
|
|
122178
|
+
if (primary.reply_anchor_message_id != null || candidate.reply_anchor_message_id != null) return false;
|
|
122179
|
+
const sessionKeyOf = (e) => typeof e.root_id === "string" && e.root_id ? e.root_id : e.message_id;
|
|
122180
|
+
if (sessionKeyOf(primary) !== sessionKeyOf(candidate)) return false;
|
|
122181
|
+
try {
|
|
122182
|
+
const parsed = parseMessage(candidate);
|
|
122183
|
+
if (parsed.attachments.length > 0) return false;
|
|
122184
|
+
if (parsed.text.trim() === "") return false;
|
|
122185
|
+
} catch {
|
|
122186
|
+
return false;
|
|
122187
|
+
}
|
|
122188
|
+
return true;
|
|
122189
|
+
}
|
|
122152
122190
|
var repoCloneLocks = /* @__PURE__ */ new Map();
|
|
122153
122191
|
function ensureRepoClone(basePath, url, token, label) {
|
|
122154
122192
|
const prev = repoCloneLocks.get(basePath) ?? Promise.resolve();
|
|
@@ -122420,6 +122458,15 @@ var BridgeHandler = class {
|
|
|
122420
122458
|
* broken handoff and fire a spurious nudge. Read via getThreadReceivedAt().
|
|
122421
122459
|
*/
|
|
122422
122460
|
threadReceivedAt = /* @__PURE__ */ new Map();
|
|
122461
|
+
/**
|
|
122462
|
+
* v4.2 (docs/task-handle.md §13 revision 5): per-thread OUTCOME of the most
|
|
122463
|
+
* recent finished turn ("completed" | "failed"), recorded at finalize.
|
|
122464
|
+
* StallDetector reads a PEER handler's map via getThreadLastOutcome to tell
|
|
122465
|
+
* "the peer finished successfully" apart from "the peer crashed" — a failed
|
|
122466
|
+
* finish must NOT resolve a handoff. In-memory like threadReceivedAt (empty
|
|
122467
|
+
* after restart → detector falls back to its documented restart posture).
|
|
122468
|
+
*/
|
|
122469
|
+
threadLastOutcome = /* @__PURE__ */ new Map();
|
|
122423
122470
|
constructor(deps) {
|
|
122424
122471
|
this.deps = deps;
|
|
122425
122472
|
}
|
|
@@ -122432,6 +122479,10 @@ var BridgeHandler = class {
|
|
|
122432
122479
|
* — callers must apply their own startup quiet-period before treating that
|
|
122433
122480
|
* as evidence of a truly broken handoff.
|
|
122434
122481
|
*/
|
|
122482
|
+
/** v4.2 — see threadLastOutcome's field doc. */
|
|
122483
|
+
getThreadLastOutcome(threadId) {
|
|
122484
|
+
return this.threadLastOutcome.get(threadId);
|
|
122485
|
+
}
|
|
122435
122486
|
getThreadReceivedAt(threadId) {
|
|
122436
122487
|
return this.threadReceivedAt.get(threadId);
|
|
122437
122488
|
}
|
|
@@ -122498,6 +122549,7 @@ var BridgeHandler = class {
|
|
|
122498
122549
|
async run(opts) {
|
|
122499
122550
|
const signal = opts?.abortSignal;
|
|
122500
122551
|
const threadQueues = /* @__PURE__ */ new Map();
|
|
122552
|
+
const pendingByKey = /* @__PURE__ */ new Map();
|
|
122501
122553
|
const MAX_CONCURRENT = 5;
|
|
122502
122554
|
let running = 0;
|
|
122503
122555
|
const waiters = [];
|
|
@@ -122523,12 +122575,33 @@ var BridgeHandler = class {
|
|
|
122523
122575
|
this.threadReceivedAt.set(key, Date.now());
|
|
122524
122576
|
const legacySessionKey = typeof event.root_id === "string" && event.root_id ? event.root_id : event.message_id;
|
|
122525
122577
|
if (legacySessionKey !== key) this.threadReceivedAt.set(legacySessionKey, Date.now());
|
|
122578
|
+
let pendingList = pendingByKey.get(key);
|
|
122579
|
+
if (pendingList == null) {
|
|
122580
|
+
pendingList = [];
|
|
122581
|
+
pendingByKey.set(key, pendingList);
|
|
122582
|
+
}
|
|
122583
|
+
pendingList.push(event);
|
|
122526
122584
|
const prev = threadQueues.get(key) ?? Promise.resolve();
|
|
122527
|
-
const next = prev.then(() => acquire()).then(() =>
|
|
122585
|
+
const next = prev.then(() => acquire()).then(() => {
|
|
122586
|
+
const pending = pendingByKey.get(key);
|
|
122587
|
+
const primary = pending?.shift();
|
|
122588
|
+
if (primary == null) return;
|
|
122589
|
+
const followups = [];
|
|
122590
|
+
while (pending != null && pending.length > 0 && canCoalesceFollowup(primary, pending[0])) {
|
|
122591
|
+
followups.push(pending.shift());
|
|
122592
|
+
}
|
|
122593
|
+
if (followups.length > 0) {
|
|
122594
|
+
console.log(
|
|
122595
|
+
`[bridge.handler] coalescing ${followups.length} queued follow-up message(s) into turn ${primary.message_id} on thread ${key}`
|
|
122596
|
+
);
|
|
122597
|
+
}
|
|
122598
|
+
return this.handleOne(primary, followups);
|
|
122599
|
+
}).catch((err) => {
|
|
122528
122600
|
console.error(`[bridge.handler] unhandled error on thread ${key}:`, err);
|
|
122529
122601
|
}).finally(() => {
|
|
122530
122602
|
release();
|
|
122531
122603
|
if (threadQueues.get(key) === next) threadQueues.delete(key);
|
|
122604
|
+
if ((pendingByKey.get(key)?.length ?? 0) === 0) pendingByKey.delete(key);
|
|
122532
122605
|
this.inFlightTurns.delete(next);
|
|
122533
122606
|
});
|
|
122534
122607
|
this.inFlightTurns.add(next);
|
|
@@ -122545,7 +122618,14 @@ var BridgeHandler = class {
|
|
|
122545
122618
|
// ---------------------------------------------------------------------------
|
|
122546
122619
|
// Private: single-event lifecycle
|
|
122547
122620
|
// ---------------------------------------------------------------------------
|
|
122548
|
-
|
|
122621
|
+
/**
|
|
122622
|
+
* @param followups 批D gated coalescing — same-session messages that queued
|
|
122623
|
+
* up behind the previous turn and are merged into THIS one (see run()'s
|
|
122624
|
+
* drain loop + canCoalesceFollowup). They contribute their text to the
|
|
122625
|
+
* prompt and settle together with the primary; everything else about the
|
|
122626
|
+
* turn (card, session, task probes) is driven by `event` alone.
|
|
122627
|
+
*/
|
|
122628
|
+
async handleOne(event, followups = []) {
|
|
122549
122629
|
const settleMessageId = event.message_id;
|
|
122550
122630
|
let settled = false;
|
|
122551
122631
|
let agentRunCompleted = false;
|
|
@@ -122555,8 +122635,10 @@ var BridgeHandler = class {
|
|
|
122555
122635
|
const settle = (ok) => {
|
|
122556
122636
|
if (settled) return;
|
|
122557
122637
|
settled = true;
|
|
122558
|
-
|
|
122559
|
-
|
|
122638
|
+
for (const id of [settleMessageId, ...followups.map((f) => f.message_id)]) {
|
|
122639
|
+
if (ok) this.deps.client.markHandled?.(id);
|
|
122640
|
+
else this.deps.client.markUnhandled?.(id, { replay: !agentRunCompleted });
|
|
122641
|
+
}
|
|
122560
122642
|
};
|
|
122561
122643
|
try {
|
|
122562
122644
|
const parsed = parseMessage(event);
|
|
@@ -122567,7 +122649,33 @@ var BridgeHandler = class {
|
|
|
122567
122649
|
const eventStartedAt = Date.now();
|
|
122568
122650
|
let taskRootInfo;
|
|
122569
122651
|
let taskCardAnchorId;
|
|
122652
|
+
let taskRootJustClaimed = false;
|
|
122570
122653
|
let deferredTaskRootProbe;
|
|
122654
|
+
const autoClaimTaskRoot = async (guid) => {
|
|
122655
|
+
if (!this.deps.taskHandleClaim || !botId) return;
|
|
122656
|
+
const alreadyThis = this.deps.taskHandleClaimGuidLookup?.(threadId) === guid;
|
|
122657
|
+
if (alreadyThis) return;
|
|
122658
|
+
if (this.deps.taskGuidClaimedByOtherBot?.(guid)) return;
|
|
122659
|
+
try {
|
|
122660
|
+
await this.deps.taskHandleClaim({
|
|
122661
|
+
botId,
|
|
122662
|
+
threadId,
|
|
122663
|
+
chatId: parsed.chatId,
|
|
122664
|
+
taskGuid: guid,
|
|
122665
|
+
mode: "comment",
|
|
122666
|
+
// Round-2 fix: the mechanical auto-claim must never REPLACE an
|
|
122667
|
+
// existing different-guid claim on this thread (same charter as the
|
|
122668
|
+
// v3 auto-bind guard) — only the agent's own re-declaration may.
|
|
122669
|
+
onlyIfThreadUnclaimed: true,
|
|
122670
|
+
// Round-2 fix: persist "claim comment still owed" so a first-turn
|
|
122671
|
+
// crash after the claim doesn't lose the task→topic backlink.
|
|
122672
|
+
claimCommentPending: true
|
|
122673
|
+
});
|
|
122674
|
+
taskRootJustClaimed = this.deps.taskHandleClaimGuidLookup ? this.deps.taskHandleClaimGuidLookup(threadId) === guid : true;
|
|
122675
|
+
} catch (err) {
|
|
122676
|
+
console.warn("[bridge.handler] task-root auto-claim failed (continuing):", err);
|
|
122677
|
+
}
|
|
122678
|
+
};
|
|
122571
122679
|
{
|
|
122572
122680
|
const realTopic = realTopicThreadId(parsed.raw.thread_id);
|
|
122573
122681
|
const rootCandidate = (typeof parsed.raw.root_id === "string" && parsed.raw.root_id ? parsed.raw.root_id : void 0) ?? (typeof parsed.raw.parent_id === "string" && parsed.raw.parent_id ? parsed.raw.parent_id : void 0);
|
|
@@ -122588,6 +122696,7 @@ var BridgeHandler = class {
|
|
|
122588
122696
|
};
|
|
122589
122697
|
taskCardAnchorId = rootCandidate;
|
|
122590
122698
|
threadId = rootCandidate;
|
|
122699
|
+
await autoClaimTaskRoot(todo.taskGuid);
|
|
122591
122700
|
}
|
|
122592
122701
|
}
|
|
122593
122702
|
}
|
|
@@ -122626,6 +122735,27 @@ var BridgeHandler = class {
|
|
|
122626
122735
|
reason: "\u5DF2\u8FDB\u5165 bridge\uFF0C\u51C6\u5907\u521B\u5EFA\u5904\u7406\u5361\u7247\u3002"
|
|
122627
122736
|
});
|
|
122628
122737
|
await this.deps.client.addProcessingReaction?.(messageId);
|
|
122738
|
+
for (const f of followups) {
|
|
122739
|
+
if (!this.deps.recordRuntimeEvent) break;
|
|
122740
|
+
try {
|
|
122741
|
+
await this.deps.recordRuntimeEvent({
|
|
122742
|
+
id: f.message_id,
|
|
122743
|
+
botId,
|
|
122744
|
+
botName: this.deps.botConfig?.name,
|
|
122745
|
+
messageId: f.message_id,
|
|
122746
|
+
threadId,
|
|
122747
|
+
chatId: parsed.chatId,
|
|
122748
|
+
senderId: typeof f.sender_id === "string" ? f.sender_id : void 0,
|
|
122749
|
+
triggerType,
|
|
122750
|
+
status: "received",
|
|
122751
|
+
receivedAt: new Date(eventStartedAt).toISOString(),
|
|
122752
|
+
statusPath: ["\u5DF2\u6536\u5230", "\u5408\u5E76\u8FDB\u540C\u8F6E"],
|
|
122753
|
+
reason: `\u6392\u961F\u671F\u95F4\u5230\u8FBE,\u5DF2\u5408\u5E76\u8FDB ${messageId} \u7684\u540C\u4E00\u8F6E\u5904\u7406\u3002`
|
|
122754
|
+
});
|
|
122755
|
+
} catch (err) {
|
|
122756
|
+
console.warn("[bridge.handler] recordRuntimeEvent (coalesced followup) failed (continuing):", err);
|
|
122757
|
+
}
|
|
122758
|
+
}
|
|
122629
122759
|
const invokeTaskHandleLifecycle = async (fields) => {
|
|
122630
122760
|
if (!this.deps.taskHandleLifecycle || !botId) return;
|
|
122631
122761
|
try {
|
|
@@ -122992,12 +123122,17 @@ var BridgeHandler = class {
|
|
|
122992
123122
|
}
|
|
122993
123123
|
}
|
|
122994
123124
|
}
|
|
123125
|
+
const queuedFollowups = followups.map((f) => {
|
|
123126
|
+
const p = parseMessage(f);
|
|
123127
|
+
return { senderOpenId: p.senderOpenId, text: p.text };
|
|
123128
|
+
});
|
|
122995
123129
|
if (isAgentWorkspace) {
|
|
122996
123130
|
await ensureSessionArtifacts({
|
|
122997
123131
|
sessionPath: worktreePath,
|
|
122998
123132
|
parsed,
|
|
122999
123133
|
isNewThread: existing === void 0,
|
|
123000
|
-
larkCliProfile: this.deps.larkCliProfile
|
|
123134
|
+
larkCliProfile: this.deps.larkCliProfile,
|
|
123135
|
+
queuedFollowups
|
|
123001
123136
|
});
|
|
123002
123137
|
}
|
|
123003
123138
|
try {
|
|
@@ -123101,6 +123236,7 @@ var BridgeHandler = class {
|
|
|
123101
123236
|
summary: todo.summaryText,
|
|
123102
123237
|
topicLink: rawThreadId ? buildTopicDeepLink(parsed.chatId, rawThreadId) : probeThreadId ? buildTopicDeepLink(parsed.chatId, probeThreadId) : void 0
|
|
123103
123238
|
};
|
|
123239
|
+
await autoClaimTaskRoot(todo.taskGuid);
|
|
123104
123240
|
}
|
|
123105
123241
|
}
|
|
123106
123242
|
}
|
|
@@ -123114,6 +123250,7 @@ var BridgeHandler = class {
|
|
|
123114
123250
|
const prompt = await renderPrompt({
|
|
123115
123251
|
parsed,
|
|
123116
123252
|
isNewThread: currentIsNewThread,
|
|
123253
|
+
queuedFollowups,
|
|
123117
123254
|
conventions: {
|
|
123118
123255
|
worktreePath,
|
|
123119
123256
|
runtime: conventions.runtime,
|
|
@@ -123147,7 +123284,8 @@ var BridgeHandler = class {
|
|
|
123147
123284
|
// Exact-guid comparison (see taskHandleClaimGuidLookup's doc);
|
|
123148
123285
|
// falls back to the boolean lookup only when the precise one
|
|
123149
123286
|
// isn't wired (older embedding code).
|
|
123150
|
-
claimed: this.deps.taskHandleClaimGuidLookup ? this.deps.taskHandleClaimGuidLookup(threadId) === taskRootInfo.guid : this.deps.taskHandleClaimedLookup?.(threadId) ?? false
|
|
123287
|
+
claimed: this.deps.taskHandleClaimGuidLookup ? this.deps.taskHandleClaimGuidLookup(threadId) === taskRootInfo.guid : this.deps.taskHandleClaimedLookup?.(threadId) ?? false,
|
|
123288
|
+
justClaimed: taskRootJustClaimed || this.deps.taskHandleClaimCommentPending?.(threadId) || void 0
|
|
123151
123289
|
} : void 0,
|
|
123152
123290
|
mtimeFacts
|
|
123153
123291
|
});
|
|
@@ -123332,6 +123470,7 @@ var BridgeHandler = class {
|
|
|
123332
123470
|
reason: failureReason
|
|
123333
123471
|
});
|
|
123334
123472
|
}
|
|
123473
|
+
this.threadLastOutcome.set(threadId, success ? "completed" : "failed");
|
|
123335
123474
|
const stuckResetAfter = resolveStuckSessionResetAfter();
|
|
123336
123475
|
const prevStuckCount = currentExisting?.consecutiveStuckCount ?? 0;
|
|
123337
123476
|
const nextStuckCount = cardKitTimeoutFailure ? prevStuckCount + 1 : success ? 0 : prevStuckCount;
|
|
@@ -123576,6 +123715,7 @@ var BridgeHandler = class {
|
|
|
123576
123715
|
}
|
|
123577
123716
|
} catch (err) {
|
|
123578
123717
|
console.error("[bridge.handler] handleOne failed for thread", threadId, err);
|
|
123718
|
+
this.threadLastOutcome.set(threadId, "failed");
|
|
123579
123719
|
cotTurnOutcome = "error";
|
|
123580
123720
|
if (cotPublisher) {
|
|
123581
123721
|
void cotPublisher.finalize("error", { message: String(err) }).catch(() => {
|
|
@@ -127039,24 +127179,37 @@ var BotConfigSchema = external_exports.object({
|
|
|
127039
127179
|
*/
|
|
127040
127180
|
effort: external_exports.string().min(1).optional(),
|
|
127041
127181
|
/**
|
|
127042
|
-
* docs/larkway-perf-plan.md §4 —
|
|
127043
|
-
*
|
|
127044
|
-
*
|
|
127045
|
-
*
|
|
127046
|
-
*
|
|
127047
|
-
*
|
|
127048
|
-
*
|
|
127049
|
-
*
|
|
127050
|
-
*
|
|
127051
|
-
*
|
|
127052
|
-
*
|
|
127053
|
-
*
|
|
127054
|
-
*
|
|
127055
|
-
*
|
|
127056
|
-
*
|
|
127057
|
-
*
|
|
127182
|
+
* docs/larkway-perf-plan.md §4 — persistent warm process instead of a
|
|
127183
|
+
* one-shot cold start per turn. Takes effect for `backend: "codex"` (a
|
|
127184
|
+
* single bot-level `codex app-server` process — src/codex/pool.ts
|
|
127185
|
+
* CodexProcessPool) and `backend: "claude"` (one warm process per active
|
|
127186
|
+
* thread — src/claude/pool.ts ClaudeProcessPool). Any other backend value
|
|
127187
|
+
* is a no-op (main.ts only constructs a pool for these two; botLoader's
|
|
127188
|
+
* advisory warn below flags the mismatch upfront).
|
|
127189
|
+
*
|
|
127190
|
+
* 批D (warm-by-default):
|
|
127191
|
+
* DEFAULT IS NOW ON for the two supported backends — `undefined` means
|
|
127192
|
+
* `true` when backend is codex/claude (see {@link effectiveWarmProcess},
|
|
127193
|
+
* the single source of that rule). Set `warmProcess: false` explicitly to
|
|
127194
|
+
* opt a bot back out. `.optional()` (not `.default(true)`) is still
|
|
127195
|
+
* deliberate: a `.default()` would make every future `larkway bot` yaml
|
|
127196
|
+
* write-out bake the value in, and — because this schema is `.strict()` —
|
|
127197
|
+
* would break loading that yaml back with an OLDER larkway build that
|
|
127198
|
+
* predates this field.
|
|
127058
127199
|
*/
|
|
127059
127200
|
warmProcess: external_exports.boolean().optional(),
|
|
127201
|
+
/**
|
|
127202
|
+
* 批D: pre-warm a BLANK agent process at bridge boot (and keep one on
|
|
127203
|
+
* standby thereafter), so even the FIRST message of a NEW thread skips the
|
|
127204
|
+
* cold start. Only meaningful when the warm pool is on (see
|
|
127205
|
+
* `warmProcess`); default true. backend=claude: ClaudeProcessPool keeps
|
|
127206
|
+
* one blank (no --resume) child matching the bot's static spawn signature,
|
|
127207
|
+
* adopted by the first new-session turn and replenished after adoption.
|
|
127208
|
+
* backend=codex: CodexProcessPool spawns its app-server eagerly at boot
|
|
127209
|
+
* instead of on the first turn. Set false to keep warm pooling but skip
|
|
127210
|
+
* the always-resident standby (saves ~300MB RSS per bot at idle).
|
|
127211
|
+
*/
|
|
127212
|
+
prewarmProcess: external_exports.boolean().optional(),
|
|
127060
127213
|
/**
|
|
127061
127214
|
* Idle threshold (ms) before a warm process (see `warmProcess` above) with
|
|
127062
127215
|
* no in-flight turn gets SIGTERM'd. Only meaningful when `warmProcess` is
|
|
@@ -127074,6 +127227,13 @@ var BotConfigSchema = external_exports.object({
|
|
|
127074
127227
|
*/
|
|
127075
127228
|
warmProcessMaxProcesses: external_exports.number().int().positive().optional()
|
|
127076
127229
|
}).strict();
|
|
127230
|
+
function effectiveWarmProcess(bot) {
|
|
127231
|
+
if (bot.backend !== "claude" && bot.backend !== "codex") return bot.warmProcess === true;
|
|
127232
|
+
return bot.warmProcess !== false;
|
|
127233
|
+
}
|
|
127234
|
+
function effectivePrewarmProcess(bot) {
|
|
127235
|
+
return effectiveWarmProcess(bot) && bot.prewarmProcess !== false;
|
|
127236
|
+
}
|
|
127077
127237
|
async function loadBots(botsDir) {
|
|
127078
127238
|
let entries;
|
|
127079
127239
|
try {
|
|
@@ -128347,6 +128507,8 @@ var INTERRUPT_GRACE_MS = 3e3;
|
|
|
128347
128507
|
var DEFAULT_TURN_TIMEOUT_MS = 15 * 60 * 1e3;
|
|
128348
128508
|
var SHUTDOWN_EXIT_WAIT_MS = SIGKILL_GRACE_MS2 + 2e3;
|
|
128349
128509
|
var STDERR_BUFFER_CAP_BYTES = 64 * 1024;
|
|
128510
|
+
var MAX_PREWARM_FAILURES = 3;
|
|
128511
|
+
var PREWARM_RESPAWN_BACKOFF_MS = 1e4;
|
|
128350
128512
|
var ClaudeProcessPool = class {
|
|
128351
128513
|
#botId;
|
|
128352
128514
|
#botGitIdentity;
|
|
@@ -128380,6 +128542,15 @@ var ClaudeProcessPool = class {
|
|
|
128380
128542
|
* -killed while that child still lives. Removed in `#onEntryExit`.
|
|
128381
128543
|
*/
|
|
128382
128544
|
#dying = /* @__PURE__ */ new Set();
|
|
128545
|
+
// -- 批D blank-standby prewarm state ----------------------------------------
|
|
128546
|
+
/** Set by prewarm(); undefined = prewarm never requested (no blank maintenance). */
|
|
128547
|
+
#prewarmProto;
|
|
128548
|
+
/** Consecutive unprompted blank deaths (any age) — resets on a successful adoption. */
|
|
128549
|
+
#prewarmFailures = 0;
|
|
128550
|
+
/** True once the circuit breaker tripped (MAX_PREWARM_FAILURES) — prewarm off until bridge restart. */
|
|
128551
|
+
#prewarmDisabled = false;
|
|
128552
|
+
#prewarmRespawnTimer;
|
|
128553
|
+
#nextBlankId = 1;
|
|
128383
128554
|
constructor(opts) {
|
|
128384
128555
|
this.#botId = opts.botId;
|
|
128385
128556
|
this.#botGitIdentity = opts.botGitIdentity;
|
|
@@ -128396,6 +128567,23 @@ var ClaudeProcessPool = class {
|
|
|
128396
128567
|
get pidsForTesting() {
|
|
128397
128568
|
return [...this.#entries.values()].map((e) => e.child.pid).filter((pid) => pid != null);
|
|
128398
128569
|
}
|
|
128570
|
+
get blankProcessCountForTesting() {
|
|
128571
|
+
return [...this.#entries.values()].filter((e) => e.blank).length;
|
|
128572
|
+
}
|
|
128573
|
+
/**
|
|
128574
|
+
* 批D: start maintaining ONE blank standby child matching `proto`'s spawn
|
|
128575
|
+
* signature — spawned now, adopted by the first new-session turn whose own
|
|
128576
|
+
* options produce the same signature (see PoolEntry.spawnSignature), and
|
|
128577
|
+
* replenished after each adoption. Call once at bridge boot (main.ts),
|
|
128578
|
+
* only for bots whose cwd is static across threads (agent_workspace
|
|
128579
|
+
* runtime); calling it again replaces the proto for future respawns but
|
|
128580
|
+
* never kills an existing blank. No-op after shutdown() or once the
|
|
128581
|
+
* circuit breaker has tripped.
|
|
128582
|
+
*/
|
|
128583
|
+
prewarm(proto) {
|
|
128584
|
+
this.#prewarmProto = proto;
|
|
128585
|
+
this.#maybeSpawnBlank();
|
|
128586
|
+
}
|
|
128399
128587
|
run(opts) {
|
|
128400
128588
|
const queue = new TurnEventQueue();
|
|
128401
128589
|
const markPerf = createPerfMarker(opts.onPerfMarker);
|
|
@@ -128443,6 +128631,9 @@ var ClaudeProcessPool = class {
|
|
|
128443
128631
|
}
|
|
128444
128632
|
}
|
|
128445
128633
|
let entry = this.#entries.get(key);
|
|
128634
|
+
if (entry == null && opts.resumeSessionId == null) {
|
|
128635
|
+
entry = this.#adoptBlankEntry(key, threadId, opts);
|
|
128636
|
+
}
|
|
128446
128637
|
if (entry == null) {
|
|
128447
128638
|
if (this.#entries.size >= this.#maxProcesses) {
|
|
128448
128639
|
const victim = this.#pickLruIdleVictim();
|
|
@@ -128472,6 +128663,10 @@ var ClaudeProcessPool = class {
|
|
|
128472
128663
|
clearInterval(this.#idleSweepTimer);
|
|
128473
128664
|
this.#idleSweepTimer = void 0;
|
|
128474
128665
|
}
|
|
128666
|
+
if (this.#prewarmRespawnTimer) {
|
|
128667
|
+
clearTimeout(this.#prewarmRespawnTimer);
|
|
128668
|
+
this.#prewarmRespawnTimer = void 0;
|
|
128669
|
+
}
|
|
128475
128670
|
const inFlight = [...this.#entries.values()].map((e) => e.current?.done.catch(() => void 0)).filter((p) => p != null);
|
|
128476
128671
|
await Promise.race([
|
|
128477
128672
|
Promise.all(inFlight),
|
|
@@ -128518,6 +128713,9 @@ var ClaudeProcessPool = class {
|
|
|
128518
128713
|
return `${this.#botId}::${threadId}::${cwd}::${model}::${effort}`;
|
|
128519
128714
|
}
|
|
128520
128715
|
#pickLruIdleVictim() {
|
|
128716
|
+
for (const entry of this.#entries.values()) {
|
|
128717
|
+
if (entry.current == null && entry.blank) return entry;
|
|
128718
|
+
}
|
|
128521
128719
|
let victim;
|
|
128522
128720
|
for (const entry of this.#entries.values()) {
|
|
128523
128721
|
if (entry.current != null) continue;
|
|
@@ -128670,7 +128868,89 @@ var ClaudeProcessPool = class {
|
|
|
128670
128868
|
if (state.interruptEscalateTimer) clearTimeout(state.interruptEscalateTimer);
|
|
128671
128869
|
}
|
|
128672
128870
|
// -- process lifecycle -------------------------------------------------------
|
|
128673
|
-
|
|
128871
|
+
/**
|
|
128872
|
+
* The spawn identity a set of run/prewarm options resolves to. Built from
|
|
128873
|
+
* the SAME buildWarmCommand path #spawnEntry actually spawns with, so an
|
|
128874
|
+
* adoption match is by construction a spawn-arg-identical process.
|
|
128875
|
+
* `resumeSessionId` is deliberately dropped: adoption is only ever
|
|
128876
|
+
* attempted for turns with no resume (run() guards), and the blank itself
|
|
128877
|
+
* is spawned without one.
|
|
128878
|
+
*/
|
|
128879
|
+
#spawnSignatureOf(opts) {
|
|
128880
|
+
const [bin, args] = buildWarmCommand({
|
|
128881
|
+
prompt: "",
|
|
128882
|
+
cwd: opts.cwd,
|
|
128883
|
+
model: opts.model,
|
|
128884
|
+
effort: opts.effort,
|
|
128885
|
+
permissionMode: opts.permissionMode,
|
|
128886
|
+
agentBinPath: opts.agentBinPath
|
|
128887
|
+
});
|
|
128888
|
+
return JSON.stringify([bin, args, opts.cwd ?? null]);
|
|
128889
|
+
}
|
|
128890
|
+
/**
|
|
128891
|
+
* 批D: find an idle blank whose spawn signature matches this turn's options
|
|
128892
|
+
* and rekey it in place onto (key, threadId). Returns undefined when no
|
|
128893
|
+
* blank matches — the caller falls through to a normal spawn. Schedules a
|
|
128894
|
+
* replacement blank on success.
|
|
128895
|
+
*/
|
|
128896
|
+
#adoptBlankEntry(key, threadId, opts) {
|
|
128897
|
+
let blank;
|
|
128898
|
+
for (const e of this.#entries.values()) {
|
|
128899
|
+
if (e.blank && !e.destroyed && e.current == null) {
|
|
128900
|
+
blank = e;
|
|
128901
|
+
break;
|
|
128902
|
+
}
|
|
128903
|
+
}
|
|
128904
|
+
if (blank == null) return void 0;
|
|
128905
|
+
const wanted = this.#spawnSignatureOf(opts);
|
|
128906
|
+
if (blank.spawnSignature !== wanted) {
|
|
128907
|
+
console.warn(
|
|
128908
|
+
`[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.`
|
|
128909
|
+
);
|
|
128910
|
+
return void 0;
|
|
128911
|
+
}
|
|
128912
|
+
this.#entries.delete(blank.key);
|
|
128913
|
+
blank.key = key;
|
|
128914
|
+
blank.threadId = threadId;
|
|
128915
|
+
blank.blank = false;
|
|
128916
|
+
blank.lastUsedAt = Date.now();
|
|
128917
|
+
this.#entries.set(key, blank);
|
|
128918
|
+
this.#rewritePidListBestEffort();
|
|
128919
|
+
this.#prewarmFailures = 0;
|
|
128920
|
+
console.warn(
|
|
128921
|
+
`[claude-pool] blank standby pid=${blank.child.pid ?? "?"} adopted by thread=${threadId} \u2014 this turn skips the cold start entirely.`
|
|
128922
|
+
);
|
|
128923
|
+
queueMicrotask(() => this.#maybeSpawnBlank());
|
|
128924
|
+
return blank;
|
|
128925
|
+
}
|
|
128926
|
+
/**
|
|
128927
|
+
* Spawn the standby blank when prewarm is configured and conditions allow:
|
|
128928
|
+
* at most one blank at a time, never past maxProcesses, never while
|
|
128929
|
+
* shutting down, and never after the circuit breaker tripped.
|
|
128930
|
+
*/
|
|
128931
|
+
#maybeSpawnBlank() {
|
|
128932
|
+
if (this.#prewarmProto == null || this.#prewarmDisabled || this.#shuttingDown) return;
|
|
128933
|
+
if (this.#prewarmRespawnTimer != null) return;
|
|
128934
|
+
if (this.#entries.size >= this.#maxProcesses) return;
|
|
128935
|
+
for (const e of this.#entries.values()) {
|
|
128936
|
+
if (e.blank) return;
|
|
128937
|
+
}
|
|
128938
|
+
const proto = this.#prewarmProto;
|
|
128939
|
+
const key = `__blank__::${this.#botId}::${this.#nextBlankId++}`;
|
|
128940
|
+
const opts = {
|
|
128941
|
+
prompt: "",
|
|
128942
|
+
cwd: proto.cwd,
|
|
128943
|
+
model: proto.model,
|
|
128944
|
+
effort: proto.effort,
|
|
128945
|
+
permissionMode: proto.permissionMode,
|
|
128946
|
+
agentBinPath: proto.agentBinPath
|
|
128947
|
+
};
|
|
128948
|
+
const entry = this.#spawnEntry(key, key, opts, { blank: true });
|
|
128949
|
+
console.warn(
|
|
128950
|
+
`[claude-pool] pre-warmed blank standby pid=${entry.child.pid ?? "?"} for bot=${this.#botId} (signature=${entry.spawnSignature.slice(0, 120)}\u2026)`
|
|
128951
|
+
);
|
|
128952
|
+
}
|
|
128953
|
+
#spawnEntry(key, threadId, opts, flags) {
|
|
128674
128954
|
const [bin, args] = buildWarmCommand(opts);
|
|
128675
128955
|
const env = buildEnv(this.#botGitIdentity, this.#gitlabToken);
|
|
128676
128956
|
const child = spawn3(bin, args, {
|
|
@@ -128682,6 +128962,8 @@ var ClaudeProcessPool = class {
|
|
|
128682
128962
|
key,
|
|
128683
128963
|
threadId,
|
|
128684
128964
|
cwd: opts.cwd,
|
|
128965
|
+
blank: flags?.blank === true,
|
|
128966
|
+
spawnSignature: this.#spawnSignatureOf(opts),
|
|
128685
128967
|
child,
|
|
128686
128968
|
spawnedAt: Date.now(),
|
|
128687
128969
|
lastUsedAt: Date.now(),
|
|
@@ -128747,6 +129029,32 @@ var ClaudeProcessPool = class {
|
|
|
128747
129029
|
this.#dying.delete(entry);
|
|
128748
129030
|
this.#rewritePidListBestEffort();
|
|
128749
129031
|
void this.#deleteRunnerPidFileIfMine(entry);
|
|
129032
|
+
if (this.#prewarmProto != null && !this.#shuttingDown && !this.#prewarmDisabled) {
|
|
129033
|
+
const unpromptedBlankDeath = entry.blank && !wasAlreadyMarkedDestroyed;
|
|
129034
|
+
if (unpromptedBlankDeath) {
|
|
129035
|
+
this.#prewarmFailures += 1;
|
|
129036
|
+
if (this.#prewarmFailures >= MAX_PREWARM_FAILURES) {
|
|
129037
|
+
this.#prewarmDisabled = true;
|
|
129038
|
+
const stderr = Buffer.concat(entry.stderrChunks).toString("utf8").trim().slice(-2e3);
|
|
129039
|
+
console.warn(
|
|
129040
|
+
`[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:
|
|
129041
|
+
${stderr}` : "")
|
|
129042
|
+
);
|
|
129043
|
+
} else {
|
|
129044
|
+
const delay = PREWARM_RESPAWN_BACKOFF_MS * 2 ** (this.#prewarmFailures - 1);
|
|
129045
|
+
console.warn(
|
|
129046
|
+
`[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.`
|
|
129047
|
+
);
|
|
129048
|
+
this.#prewarmRespawnTimer = setTimeout(() => {
|
|
129049
|
+
this.#prewarmRespawnTimer = void 0;
|
|
129050
|
+
this.#maybeSpawnBlank();
|
|
129051
|
+
}, delay);
|
|
129052
|
+
this.#prewarmRespawnTimer.unref?.();
|
|
129053
|
+
}
|
|
129054
|
+
} else {
|
|
129055
|
+
queueMicrotask(() => this.#maybeSpawnBlank());
|
|
129056
|
+
}
|
|
129057
|
+
}
|
|
128750
129058
|
const state = entry.current;
|
|
128751
129059
|
entry.current = void 0;
|
|
128752
129060
|
if (state == null || state.settled) return;
|
|
@@ -128799,6 +129107,7 @@ ${stderr}` : "")
|
|
|
128799
129107
|
const now = Date.now();
|
|
128800
129108
|
for (const entry of [...this.#entries.values()]) {
|
|
128801
129109
|
if (entry.current != null) continue;
|
|
129110
|
+
if (entry.blank) continue;
|
|
128802
129111
|
if (now - entry.lastUsedAt >= this.#idleMs) {
|
|
128803
129112
|
this.#destroyEntry(entry, "idle timeout");
|
|
128804
129113
|
}
|
|
@@ -129418,6 +129727,19 @@ var CodexProcessPool = class {
|
|
|
129418
129727
|
get childPid() {
|
|
129419
129728
|
return this.#child?.pid ?? void 0;
|
|
129420
129729
|
}
|
|
129730
|
+
/**
|
|
129731
|
+
* 批D: spawn the app-server eagerly at bridge boot instead of lazily on the
|
|
129732
|
+
* first turn, so even the bot's first message hits a running process.
|
|
129733
|
+
* Idempotent; no-op while shutting down or after the spawn circuit breaker
|
|
129734
|
+
* (#poolDisabled) tripped. Unlike ClaudeProcessPool's blank standby there
|
|
129735
|
+
* is no post-idle-reap replenish here: after the 10-min idle reap the next
|
|
129736
|
+
* turn re-spawns on demand exactly as before (the reap exists to bound the
|
|
129737
|
+
* app-server's per-thread memory growth — auto-respawning would defeat it).
|
|
129738
|
+
*/
|
|
129739
|
+
prewarm() {
|
|
129740
|
+
if (this.#shuttingDown || this.#poolDisabled) return;
|
|
129741
|
+
if (this.#child == null) this.#spawnChild();
|
|
129742
|
+
}
|
|
129421
129743
|
run(opts) {
|
|
129422
129744
|
const turnKey = this.#nextTurnKey++;
|
|
129423
129745
|
const queue = new TurnEventQueue();
|
|
@@ -130215,7 +130537,7 @@ function isStallNudgeState(value) {
|
|
|
130215
130537
|
function isTaskHandleRecord(value) {
|
|
130216
130538
|
if (typeof value !== "object" || value === null) return false;
|
|
130217
130539
|
const v = value;
|
|
130218
|
-
return typeof v["threadId"] === "string" && typeof v["taskGuid"] === "string" && typeof v["chatId"] === "string" && typeof v["claimedTs"] === "number" && (v["lastSeenCommentId"] === void 0 || typeof v["lastSeenCommentId"] === "string") && (v["lastTurnOutcome"] === void 0 || v["lastTurnOutcome"] === "completed" || v["lastTurnOutcome"] === "failed") && (v["stallNudge"] === void 0 || isStallNudgeState(v["stallNudge"])) && (v["stallSuppressUntilActivityAfter"] === void 0 || typeof v["stallSuppressUntilActivityAfter"] === "number") && (v["lastTurnMentions"] === void 0 || Array.isArray(v["lastTurnMentions"]) && v["lastTurnMentions"].every((m) => typeof m === "string")) && (v["lastTurnMentionsAt"] === void 0 || typeof v["lastTurnMentionsAt"] === "number") && (v["mode"] === void 0 || v["mode"] === "comment") && (v["doneDeclared"] === void 0 || typeof v["doneDeclared"] === "boolean");
|
|
130540
|
+
return typeof v["threadId"] === "string" && typeof v["taskGuid"] === "string" && typeof v["chatId"] === "string" && typeof v["claimedTs"] === "number" && (v["lastSeenCommentId"] === void 0 || typeof v["lastSeenCommentId"] === "string") && (v["lastTurnOutcome"] === void 0 || v["lastTurnOutcome"] === "completed" || v["lastTurnOutcome"] === "failed") && (v["stallNudge"] === void 0 || isStallNudgeState(v["stallNudge"])) && (v["stallSuppressUntilActivityAfter"] === void 0 || typeof v["stallSuppressUntilActivityAfter"] === "number") && (v["lastTurnMentions"] === void 0 || Array.isArray(v["lastTurnMentions"]) && v["lastTurnMentions"].every((m) => typeof m === "string")) && (v["lastTurnMentionsAt"] === void 0 || typeof v["lastTurnMentionsAt"] === "number") && (v["mode"] === void 0 || v["mode"] === "comment") && (v["doneDeclared"] === void 0 || typeof v["doneDeclared"] === "boolean") && (v["claimCommentPending"] === void 0 || typeof v["claimCommentPending"] === "boolean");
|
|
130219
130541
|
}
|
|
130220
130542
|
var TaskHandleStore = class _TaskHandleStore {
|
|
130221
130543
|
#filePath;
|
|
@@ -130431,7 +130753,8 @@ var TaskHandleStore = class _TaskHandleStore {
|
|
|
130431
130753
|
taskGuid: input.taskGuid,
|
|
130432
130754
|
chatId: input.chatId,
|
|
130433
130755
|
claimedTs: Date.now(),
|
|
130434
|
-
...input.mode ? { mode: input.mode } : {}
|
|
130756
|
+
...input.mode ? { mode: input.mode } : {},
|
|
130757
|
+
...input.claimCommentPending ? { claimCommentPending: true } : {}
|
|
130435
130758
|
};
|
|
130436
130759
|
});
|
|
130437
130760
|
return { claimed, reason };
|
|
@@ -130911,7 +131234,11 @@ async function applyTaskHandleWriteback(patch, deps) {
|
|
|
130911
131234
|
// Agent declared delivery — its own "已交付" comment is the
|
|
130912
131235
|
// user-facing artifact; bridge just stops patrolling the
|
|
130913
131236
|
// "delivered, human hasn't ticked complete yet" window.
|
|
130914
|
-
...patch.agentDeclaredDone === true ? { doneDeclared: true } : {}
|
|
131237
|
+
...patch.agentDeclaredDone === true ? { doneDeclared: true } : {},
|
|
131238
|
+
// v4.2 round-2: a successfully completed turn ran under a
|
|
131239
|
+
// <task-root> that instructed the claim comment — consider
|
|
131240
|
+
// the backlink posted; only crashed turns keep it owed.
|
|
131241
|
+
claimCommentPending: void 0
|
|
130915
131242
|
} : current
|
|
130916
131243
|
);
|
|
130917
131244
|
break;
|
|
@@ -131560,10 +131887,11 @@ var CandidateAlertStore = class _CandidateAlertStore {
|
|
|
131560
131887
|
// src/tasklist/stallDetector.ts
|
|
131561
131888
|
var DEFAULT_INTERVAL_MS3 = 6e4;
|
|
131562
131889
|
var DEFAULT_JITTER_MS3 = 1e4;
|
|
131563
|
-
var DEFAULT_STALL_THRESHOLD_MS =
|
|
131890
|
+
var DEFAULT_STALL_THRESHOLD_MS = 60 * 6e4;
|
|
131564
131891
|
var DEFAULT_STALL_FAST_THRESHOLD_MS = 30 * 6e4;
|
|
131565
131892
|
var DEFAULT_STALL_HANDOFF_THRESHOLD_MS = 5 * 6e4;
|
|
131566
|
-
var DEFAULT_NUDGE_COOLDOWN_MS =
|
|
131893
|
+
var DEFAULT_NUDGE_COOLDOWN_MS = 60 * 6e4;
|
|
131894
|
+
var DUE_NUDGE_COOLDOWN_FLOOR_MS = 24 * 60 * 6e4;
|
|
131567
131895
|
var DEFAULT_ESCALATE_AFTER_NUDGES = 2;
|
|
131568
131896
|
var DEFAULT_PENDING_CONFIRM_TIMEOUT_MS = 30 * 6e4;
|
|
131569
131897
|
var DEFAULT_HANDOFF_STARTUP_QUIET_MS = 6 * 6e4;
|
|
@@ -131742,7 +132070,7 @@ var StallDetector = class {
|
|
|
131742
132070
|
const { ms: threshold, reason } = this.#effectiveThreshold(record, threadId);
|
|
131743
132071
|
if (!nudge || nudge.count === 0) {
|
|
131744
132072
|
if (now - lastActiveTs < threshold) return;
|
|
131745
|
-
if (reason === "due" && !justClearedPendingTimeout && nudge?.lastNudgeSentAt !== void 0 && now - nudge.lastNudgeSentAt < this.#
|
|
132073
|
+
if (reason === "due" && !justClearedPendingTimeout && nudge?.lastNudgeSentAt !== void 0 && now - nudge.lastNudgeSentAt < this.#cooldownFor(reason)) {
|
|
131746
132074
|
return;
|
|
131747
132075
|
}
|
|
131748
132076
|
await this.#sendNudge(threadId);
|
|
@@ -131767,7 +132095,7 @@ var StallDetector = class {
|
|
|
131767
132095
|
});
|
|
131768
132096
|
return;
|
|
131769
132097
|
}
|
|
131770
|
-
if (now - nudge.lastNudgeSentAt < this.#
|
|
132098
|
+
if (now - nudge.lastNudgeSentAt < this.#cooldownFor(reason)) return;
|
|
131771
132099
|
if (nudge.count >= this.#escalateAfterNudges) {
|
|
131772
132100
|
await this.#escalate(threadId);
|
|
131773
132101
|
} else {
|
|
@@ -131806,6 +132134,10 @@ var StallDetector = class {
|
|
|
131806
132134
|
* until gap-fill has had a chance to redeliver anything in flight across
|
|
131807
132135
|
* a restart.
|
|
131808
132136
|
*/
|
|
132137
|
+
/** Cooldown for a given tier — the due tier keeps a 24h floor (see DUE_NUDGE_COOLDOWN_FLOOR_MS). An explicit operator nudgeCooldownMs LONGER than the floor still wins. */
|
|
132138
|
+
#cooldownFor(reason) {
|
|
132139
|
+
return reason === "due" ? Math.max(this.#nudgeCooldownMs, DUE_NUDGE_COOLDOWN_FLOOR_MS) : this.#nudgeCooldownMs;
|
|
132140
|
+
}
|
|
131809
132141
|
#effectiveThreshold(record, threadId) {
|
|
131810
132142
|
const base = record.lastTurnOutcome === "failed" ? { ms: this.#stallFastThresholdMs, reason: "failed" } : { ms: this.#stallThresholdMs, reason: "normal" };
|
|
131811
132143
|
const dueMs = this.#deps.getTaskDueMs?.(record.taskGuid);
|
|
@@ -131819,6 +132151,14 @@ var StallDetector = class {
|
|
|
131819
132151
|
const now = Date.now();
|
|
131820
132152
|
let brokenFloorMs;
|
|
131821
132153
|
for (const peerBotId of record.lastTurnMentions) {
|
|
132154
|
+
{
|
|
132155
|
+
const peerOutcome = this.#deps.getPeerLastTurnOutcome?.(peerBotId, threadId);
|
|
132156
|
+
const peerEndedAt = this.#deps.getPeerLastActiveTs?.(peerBotId, threadId);
|
|
132157
|
+
if (peerOutcome === "failed" && peerEndedAt !== void 0 && peerEndedAt > mentionAt) {
|
|
132158
|
+
brokenFloorMs = this.#stallHandoffThresholdMs;
|
|
132159
|
+
break;
|
|
132160
|
+
}
|
|
132161
|
+
}
|
|
131822
132162
|
const receivedAt = this.#deps.getPeerReceivedAt(peerBotId, threadId);
|
|
131823
132163
|
if (receivedAt === void 0 || receivedAt <= mentionAt) {
|
|
131824
132164
|
const completedAt2 = this.#deps.getPeerLastActiveTs?.(peerBotId, threadId);
|
|
@@ -132102,6 +132442,7 @@ async function runV2Mode({
|
|
|
132102
132442
|
const { basename } = path19;
|
|
132103
132443
|
const instances = [];
|
|
132104
132444
|
const handlersByBotId = /* @__PURE__ */ new Map();
|
|
132445
|
+
const allTaskHandleStores = [];
|
|
132105
132446
|
const tasklistPollersByGuid = /* @__PURE__ */ new Map();
|
|
132106
132447
|
const tasklistGuidGroups = /* @__PURE__ */ new Map();
|
|
132107
132448
|
for (const bot of healthyBots) {
|
|
@@ -132195,6 +132536,8 @@ async function runV2Mode({
|
|
|
132195
132536
|
let taskHandleClaim;
|
|
132196
132537
|
let taskHandleClaimedLookup;
|
|
132197
132538
|
let taskHandleClaimGuidLookup;
|
|
132539
|
+
let taskGuidClaimedByOtherBot;
|
|
132540
|
+
let taskHandleClaimCommentPending;
|
|
132198
132541
|
let taskHandleCandidatesLookup;
|
|
132199
132542
|
let taskCommentPoller;
|
|
132200
132543
|
let stallDetector;
|
|
@@ -132224,6 +132567,7 @@ async function runV2Mode({
|
|
|
132224
132567
|
effectiveTaskHandleTasklistGuid = guid;
|
|
132225
132568
|
}
|
|
132226
132569
|
const taskHandleStore = await TaskHandleStore.load(resolveTaskHandlesPath(bot.id));
|
|
132570
|
+
allTaskHandleStores.push({ botId: bot.id, store: taskHandleStore });
|
|
132227
132571
|
taskHandleLifecycle = (patch) => applyTaskHandleWriteback(patch, { store: taskHandleStore, client: taskListClient });
|
|
132228
132572
|
taskHandleClaim = async (patch) => {
|
|
132229
132573
|
const result = await taskHandleStore.claim(patch);
|
|
@@ -132235,6 +132579,8 @@ async function runV2Mode({
|
|
|
132235
132579
|
};
|
|
132236
132580
|
taskHandleClaimedLookup = (threadId) => taskHandleStore.get(threadId) !== void 0;
|
|
132237
132581
|
taskHandleClaimGuidLookup = (threadId) => taskHandleStore.get(threadId)?.taskGuid;
|
|
132582
|
+
taskGuidClaimedByOtherBot = (guid2) => allTaskHandleStores.some((e) => e.botId !== bot.id && e.store.list().some((r) => r.taskGuid === guid2));
|
|
132583
|
+
taskHandleClaimCommentPending = (threadId) => taskHandleStore.get(threadId)?.claimCommentPending === true;
|
|
132238
132584
|
if (guid) {
|
|
132239
132585
|
const group = tasklistGuidGroups.get(guid) ?? { client: taskListClient, clientOwnerBotId: bot.id, bots: [] };
|
|
132240
132586
|
group.bots.push({ botId: bot.id, sessionStore, taskHandleStore });
|
|
@@ -132297,6 +132643,9 @@ async function runV2Mode({
|
|
|
132297
132643
|
// window — "did their turn genuinely finish", not "did it start
|
|
132298
132644
|
// or get queued". See stallDetector.ts's module doc revision 3.
|
|
132299
132645
|
getPeerLastActiveTs: (peerBotId, threadId) => handlersByBotId.get(peerBotId)?.getThreadLastActiveTs(threadId),
|
|
132646
|
+
// v4.2 revision 5: the peer's most recent finished-turn OUTCOME —
|
|
132647
|
+
// a failed finish must not resolve a handoff (see stallDetector.ts).
|
|
132648
|
+
getPeerLastTurnOutcome: (peerBotId, threadId) => handlersByBotId.get(peerBotId)?.getThreadLastOutcome(threadId),
|
|
132300
132649
|
// v3.3 due-date stall detection (docs/task-handle.md §14): free
|
|
132301
132650
|
// read of TasklistPoller's own per-guid `due` observation — same
|
|
132302
132651
|
// "populated after, read lazily" closure trick as
|
|
@@ -132357,7 +132706,8 @@ async function runV2Mode({
|
|
|
132357
132706
|
let codexPool;
|
|
132358
132707
|
let claudePool;
|
|
132359
132708
|
let runnerKey;
|
|
132360
|
-
|
|
132709
|
+
const warmProcessOn = effectiveWarmProcess(bot);
|
|
132710
|
+
if (warmProcessOn && bot.backend === "codex") {
|
|
132361
132711
|
const pidFilePath = path19.join(botDir, "warm-codex.pid");
|
|
132362
132712
|
try {
|
|
132363
132713
|
await reapOrphanedWarmProcess(pidFilePath);
|
|
@@ -132372,7 +132722,8 @@ async function runV2Mode({
|
|
|
132372
132722
|
});
|
|
132373
132723
|
runnerKey = `codex-pool:${bot.id}`;
|
|
132374
132724
|
registerRunner(runnerKey, () => codexPool);
|
|
132375
|
-
|
|
132725
|
+
if (effectivePrewarmProcess(bot)) codexPool.prewarm();
|
|
132726
|
+
} else if (warmProcessOn && bot.backend === "claude") {
|
|
132376
132727
|
const pidListFilePath = path19.join(botDir, "warm-claude.pids.json");
|
|
132377
132728
|
try {
|
|
132378
132729
|
await reapOrphanedWarmClaudeProcesses(pidListFilePath);
|
|
@@ -132389,6 +132740,18 @@ async function runV2Mode({
|
|
|
132389
132740
|
});
|
|
132390
132741
|
runnerKey = `claude-pool:${bot.id}`;
|
|
132391
132742
|
registerRunner(runnerKey, () => claudePool);
|
|
132743
|
+
if (effectivePrewarmProcess(bot) && bot.runtime === "agent_workspace" && agentWorkspacePath) {
|
|
132744
|
+
claudePool.prewarm({
|
|
132745
|
+
cwd: agentWorkspacePath,
|
|
132746
|
+
model: bot.model,
|
|
132747
|
+
effort: bot.effort,
|
|
132748
|
+
// Mirrors handler.ts's own default ("bypassPermissions" when
|
|
132749
|
+
// config.json leaves permissions.mode unset). If these two ever
|
|
132750
|
+
// drift the blank's signature just stops matching — fail-safe, and
|
|
132751
|
+
// the pool logs the mismatch on every missed adoption.
|
|
132752
|
+
permissionMode: configJson.permissions.mode ?? "bypassPermissions"
|
|
132753
|
+
});
|
|
132754
|
+
}
|
|
132392
132755
|
}
|
|
132393
132756
|
const handler = new BridgeHandler({
|
|
132394
132757
|
client,
|
|
@@ -132445,6 +132808,8 @@ async function runV2Mode({
|
|
|
132445
132808
|
taskHandleClaim,
|
|
132446
132809
|
taskHandleClaimedLookup,
|
|
132447
132810
|
taskHandleClaimGuidLookup,
|
|
132811
|
+
taskGuidClaimedByOtherBot,
|
|
132812
|
+
taskHandleClaimCommentPending,
|
|
132448
132813
|
taskHandleCandidatesLookup
|
|
132449
132814
|
});
|
|
132450
132815
|
handlersByBotId.set(bot.id, handler);
|