@love-moon/conductor-cli 0.16.0 → 0.17.1
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/CHANGELOG.md +84 -0
- package/bin/conductor-config.js +6 -9
- package/bin/conductor-fire.js +377 -3
- package/bin/conductor-task.js +36 -7
- package/bin/conductor-update.js +17 -0
- package/package.json +5 -5
- package/src/backend-http.js +9 -3
- package/src/daemon-update.js +3 -0
- package/src/daemon.js +51 -11
- package/src/fire/quota-resume.js +313 -0
- package/src/remote/mcp-launch.js +3 -3
- package/src/remote-file-handlers.js +65 -6
- package/src/task-commands.js +57 -15
- package/src/version-check.js +10 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,89 @@
|
|
|
1
1
|
# @love-moon/conductor-cli
|
|
2
2
|
|
|
3
|
+
## 0.17.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 7e92c0f: When a task's model quota runs out ("You've hit your session limit · resets
|
|
8
|
+
1:20am"), fire no longer stops there. It reads the reset time, schedules a
|
|
9
|
+
one-off Conductor scheduled message to continue a minute or few after the
|
|
10
|
+
reset, and posts a single notice. Messages that arrive while waiting are queued
|
|
11
|
+
and handed to the resume turn together instead of each failing with another
|
|
12
|
+
"处理失败". The resume shows up in the task's scheduled-message list, where it
|
|
13
|
+
can be canceled. This also covers a quota hit while Claude works on its own
|
|
14
|
+
between user turns: ai-sdk now reports such a background turn that ends in an
|
|
15
|
+
error result (or a process exit after it) as a failed turn.
|
|
16
|
+
- Updated dependencies [7e92c0f]
|
|
17
|
+
- @love-moon/ai-sdk@0.17.1
|
|
18
|
+
- @love-moon/conductor-sdk@0.17.1
|
|
19
|
+
|
|
20
|
+
## 0.17.0
|
|
21
|
+
|
|
22
|
+
### Minor Changes
|
|
23
|
+
|
|
24
|
+
- 88e1ae0: Support `/stop` in task chats. A bare `/stop` message, sent from the web, the
|
|
25
|
+
CLI or a channel, makes the server interrupt the AI's running turn right away
|
|
26
|
+
instead of waiting for fire to read the message once that turn ends. When fire
|
|
27
|
+
does read the queued `/stop`, it posts a confirmation and does not send it to
|
|
28
|
+
the model. `/stop` also drops every message queued before it (except the
|
|
29
|
+
server's end-of-round summary request); resend what you still want. The server
|
|
30
|
+
targets the reply it knows is running, so older fires stop it too;
|
|
31
|
+
`interrupt_turn` no longer needs a reply target, and without one fire
|
|
32
|
+
interrupts the turn it is running, including the initial prompt and pre_prompt
|
|
33
|
+
turns. In the web chat, the ⋯ menu gains `/stop`, `/clear` and `/compact`,
|
|
34
|
+
which send those commands as messages, and Interrupt (Esc) sends `/stop`,
|
|
35
|
+
ignored while an earlier `/stop` is unanswered. The Interrupt action is gone
|
|
36
|
+
from the message toolbar.
|
|
37
|
+
|
|
38
|
+
### Patch Changes
|
|
39
|
+
|
|
40
|
+
- 79ca552: Claude no longer switches to `acceptEdits` mode when the daemon runs as root.
|
|
41
|
+
That fallback looked like it worked, but in a headless task it silently refused
|
|
42
|
+
every shell command (ssh, rsync, ...) and every read outside the project. Claude
|
|
43
|
+
now keeps `bypassPermissions` everywhere. As root without `IS_SANDBOX=1`, the
|
|
44
|
+
chat shows a warning right after "session started" telling you to add
|
|
45
|
+
`envs: { IS_SANDBOX: "1" }` to the conductor config (or run the daemon as a
|
|
46
|
+
regular user). Terminal tasks print the same hint. An `IS_SANDBOX` set in config
|
|
47
|
+
`envs` now also reaches terminal (PTY) claude tasks. `conductor config` always
|
|
48
|
+
writes `--dangerously-skip-permissions` for claude.
|
|
49
|
+
- c0c1489: `conductor task speak <text> [-o FILE|-]` saves Doubao text-to-speech as an
|
|
50
|
+
.mp3 — the same voice the web chat's new voice-conversation mode reads replies with.
|
|
51
|
+
- 39ee057: `conductor update` and the daemon updater compile node-pty against the headers
|
|
52
|
+
the running Node ships, instead of downloading them from nodejs.org, so an
|
|
53
|
+
update no longer needs that host to be reachable. `conductor update` also stops
|
|
54
|
+
an npm install that has not finished after 15 minutes instead of waiting on a
|
|
55
|
+
stalled registry connection forever.
|
|
56
|
+
- b09b28a: Remove `conductor task create --remote-worktree <host>`; passing it now fails
|
|
57
|
+
with a pointer to the replacement. Running the AI on one daemon while the code
|
|
58
|
+
lives on another is done only through a global AI backend:
|
|
59
|
+
`--global-backend <backend>@<host>` (add `--worktree` for a worktree on the
|
|
60
|
+
code's daemon). Note the host changes meaning: `--remote-worktree` named the
|
|
61
|
+
daemon holding the code, `--global-backend` names the daemon running the AI, so
|
|
62
|
+
create the task in the project on the code's daemon. The server refuses a caller-supplied
|
|
63
|
+
`launch_config.remoteWorktree`.
|
|
64
|
+
|
|
65
|
+
`conductor task create --global-backend` now takes `<backend>@<host>`, the same
|
|
66
|
+
form as `conductor issue start` and the web UI. The older `<host>:<backend>` is
|
|
67
|
+
still accepted.
|
|
68
|
+
|
|
69
|
+
- 68fe7b9: Add `conductor task preview <id> <path>`: open a temporary link to a file on the
|
|
70
|
+
task's daemon (HTML with its CSS/JS/images, Markdown, images, video), the same
|
|
71
|
+
preview the chat opens when you click a file link in an AI reply or shows in
|
|
72
|
+
place for `` / ``. The link expires after
|
|
73
|
+
5 idle minutes (30 minutes at most) and only reaches the file's own directory.
|
|
74
|
+
|
|
75
|
+
The daemon advertises a new `remote_file_preview` capability; previews need a
|
|
76
|
+
daemon of this version or later. `conductor config` now writes
|
|
77
|
+
`remote_file: true` explicitly into a new config file (the default was already
|
|
78
|
+
on; set it to `false` to decline file transfers and previews).
|
|
79
|
+
|
|
80
|
+
- Updated dependencies [d088b31]
|
|
81
|
+
- Updated dependencies [79ca552]
|
|
82
|
+
- Updated dependencies [ccbfeeb]
|
|
83
|
+
- Updated dependencies [88e1ae0]
|
|
84
|
+
- @love-moon/ai-sdk@0.17.0
|
|
85
|
+
- @love-moon/conductor-sdk@0.17.0
|
|
86
|
+
|
|
3
87
|
## 0.16.0
|
|
4
88
|
|
|
5
89
|
### Minor Changes
|
package/bin/conductor-config.js
CHANGED
|
@@ -10,7 +10,6 @@ import { createRequire } from "node:module";
|
|
|
10
10
|
import yargs from "yargs/yargs";
|
|
11
11
|
import { hideBin } from "yargs/helpers";
|
|
12
12
|
import { RUNTIME_SUPPORTED_BACKENDS } from "../src/runtime-backends.js";
|
|
13
|
-
import { isClaudeRootPermissionRestricted } from "@love-moon/ai-sdk";
|
|
14
13
|
import { resolveConductorConfigPath } from "../src/conductor-paths.js";
|
|
15
14
|
|
|
16
15
|
const CONFIG_FILE = resolveConductorConfigPath();
|
|
@@ -22,12 +21,10 @@ const packageJson = JSON.parse(
|
|
|
22
21
|
const DEFAULT_CLIs = {
|
|
23
22
|
claude: {
|
|
24
23
|
command: "claude",
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
// daemon
|
|
28
|
-
execArgs:
|
|
29
|
-
? "--permission-mode acceptEdits"
|
|
30
|
-
: "--dangerously-skip-permissions",
|
|
24
|
+
// Kept even on root: a headless acceptEdits session silently denies every
|
|
25
|
+
// shell command. Root users opt in with `envs.IS_SANDBOX: "1"`; fire and
|
|
26
|
+
// the daemon tell them so when claude starts.
|
|
27
|
+
execArgs: "--dangerously-skip-permissions",
|
|
31
28
|
description: "Anthropic Claude CLI"
|
|
32
29
|
},
|
|
33
30
|
codex: {
|
|
@@ -387,9 +384,9 @@ async function main() {
|
|
|
387
384
|
"# Env: CONDUCTOR_REMOTE_EXEC",
|
|
388
385
|
"# remote_exec: true",
|
|
389
386
|
"",
|
|
390
|
-
"# Accept `conductor remote cp` file transfers for this host.",
|
|
387
|
+
"# Accept `conductor remote cp` file transfers and file previews for this host.",
|
|
391
388
|
"# Env: CONDUCTOR_REMOTE_FILE",
|
|
392
|
-
"
|
|
389
|
+
"remote_file: true",
|
|
393
390
|
"",
|
|
394
391
|
"# Launch each Fire process inside a detached tmux session so it survives a",
|
|
395
392
|
"# daemon restart. Requires tmux on PATH. Env: CONDUCTOR_FIRE_TMUX_MODE",
|
package/bin/conductor-fire.js
CHANGED
|
@@ -26,6 +26,17 @@ import {
|
|
|
26
26
|
resolveSessionRunDirectory as resolveCliSessionRunDirectory,
|
|
27
27
|
resumeProviderForBackend as resumeProviderForCliBackend,
|
|
28
28
|
} from "../src/fire/resume.js";
|
|
29
|
+
import {
|
|
30
|
+
QUOTA_RESUME_MAX_ATTEMPTS,
|
|
31
|
+
buildQuotaResumePrompt,
|
|
32
|
+
computeQuotaResumeAt,
|
|
33
|
+
createQuotaResumeScheduler,
|
|
34
|
+
formatResumeTime,
|
|
35
|
+
isQuotaResumePrompt,
|
|
36
|
+
mergeQueuedMessages,
|
|
37
|
+
parseQuotaLimitError,
|
|
38
|
+
parseQuotaResumePrompt,
|
|
39
|
+
} from "../src/fire/quota-resume.js";
|
|
29
40
|
import {
|
|
30
41
|
REMOTE_WORKTREE_ENV,
|
|
31
42
|
buildRemoteWorktreeSessionOptions,
|
|
@@ -1648,6 +1659,10 @@ export function formatCompactReply(backendName, compact, instructions = "") {
|
|
|
1648
1659
|
* Per-message `/clear` detector. Only a bare `/clear` (case-insensitive)
|
|
1649
1660
|
* counts: anything after it is more likely an instruction for the model.
|
|
1650
1661
|
*/
|
|
1662
|
+
export function isStopCommand(content) {
|
|
1663
|
+
return typeof content === "string" && /^\/stop$/i.test(content.trim());
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1651
1666
|
export function isClearCommand(content) {
|
|
1652
1667
|
return typeof content === "string" && /^\/clear$/i.test(content.trim());
|
|
1653
1668
|
}
|
|
@@ -2096,8 +2111,19 @@ export class BridgeRunner {
|
|
|
2096
2111
|
shouldProcessPrePrompt,
|
|
2097
2112
|
createFreshBackendSession,
|
|
2098
2113
|
withFreshSessionBootstrap,
|
|
2114
|
+
quotaResumeScheduler,
|
|
2115
|
+
now,
|
|
2116
|
+
random,
|
|
2099
2117
|
}) {
|
|
2100
2118
|
this.backendSession = backendSession;
|
|
2119
|
+
// Model quota handling (see src/fire/quota-resume.js). `undefined` means
|
|
2120
|
+
// "build the HTTP scheduler from env on first use"; tests inject their own.
|
|
2121
|
+
this.quotaResumeSchedulerOverride = quotaResumeScheduler;
|
|
2122
|
+
this.now = typeof now === "function" ? now : () => new Date();
|
|
2123
|
+
this.random = typeof random === "function" ? random : Math.random;
|
|
2124
|
+
this.quotaWait = null;
|
|
2125
|
+
this.quotaResumeAttempts = 0;
|
|
2126
|
+
this.lastQuotaResumeQueue = null;
|
|
2101
2127
|
this.createFreshBackendSession = createFreshBackendSession;
|
|
2102
2128
|
this.withFreshSessionBootstrap = withFreshSessionBootstrap;
|
|
2103
2129
|
this.conductor = conductor;
|
|
@@ -2168,6 +2194,8 @@ export class BridgeRunner {
|
|
|
2168
2194
|
this.remoteInterruptsByReplyTo = new Map();
|
|
2169
2195
|
this.pendingInterruptRetryTimers = new Map();
|
|
2170
2196
|
this.activeTurnReplyTo = "";
|
|
2197
|
+
// A `/stop` arrived mid-batch: drop the rest of this batch (all sent before it).
|
|
2198
|
+
this.dropQueuedBeforeStop = false;
|
|
2171
2199
|
this.sessionAnnouncementSent = false;
|
|
2172
2200
|
this.sessionAnnouncementSubscribed = false;
|
|
2173
2201
|
this.boundSessionId = "";
|
|
@@ -2309,6 +2337,7 @@ export class BridgeRunner {
|
|
|
2309
2337
|
});
|
|
2310
2338
|
this.sessionAnnouncementSent = true;
|
|
2311
2339
|
this.copilotLog(hasRealSessionId ? `session announced id=${sessionId}` : "session announced without id");
|
|
2340
|
+
await this.reportBackendNotices();
|
|
2312
2341
|
await this.reportRuntimeStatus(
|
|
2313
2342
|
{
|
|
2314
2343
|
state: this.useSessionFileReplyStream ? undefined : "WAIT_READY",
|
|
@@ -2421,6 +2450,7 @@ export class BridgeRunner {
|
|
|
2421
2450
|
if (this.needsReconnectRecovery && !this.runningTurn) {
|
|
2422
2451
|
await this.recoverAfterReconnect();
|
|
2423
2452
|
}
|
|
2453
|
+
await this.runLocalQuotaResumeIfDue();
|
|
2424
2454
|
let processed = false;
|
|
2425
2455
|
try {
|
|
2426
2456
|
processed = await this.processIncomingBatch();
|
|
@@ -2577,7 +2607,12 @@ export class BridgeRunner {
|
|
|
2577
2607
|
}
|
|
2578
2608
|
const requestId = typeof event.requestId === "string" ? event.requestId.trim() : "";
|
|
2579
2609
|
const reason = typeof event.reason === "string" ? event.reason.trim() : "";
|
|
2580
|
-
|
|
2610
|
+
if (reason === "user_stop" && this.runningTurn) {
|
|
2611
|
+
this.dropQueuedBeforeStop = true;
|
|
2612
|
+
}
|
|
2613
|
+
// No target (`/stop`): interrupt whatever turn is running right now.
|
|
2614
|
+
const targetReplyTo =
|
|
2615
|
+
this.normalizeReplyTarget(event.targetReplyTo) || (this.runningTurn ? this.activeTurnReplyTo : "");
|
|
2581
2616
|
if (!targetReplyTo) {
|
|
2582
2617
|
return false;
|
|
2583
2618
|
}
|
|
@@ -2767,7 +2802,12 @@ export class BridgeRunner {
|
|
|
2767
2802
|
.join(",")}`,
|
|
2768
2803
|
);
|
|
2769
2804
|
|
|
2770
|
-
|
|
2805
|
+
// `/stop` drops every message queued before it; the user resends what they still want.
|
|
2806
|
+
let lastStopIndex = -1;
|
|
2807
|
+
messages.forEach((message, index) => {
|
|
2808
|
+
if (this.isUserStopMessage(message)) lastStopIndex = index;
|
|
2809
|
+
});
|
|
2810
|
+
for (const [index, message] of messages.entries()) {
|
|
2771
2811
|
if (this.stopped) {
|
|
2772
2812
|
break;
|
|
2773
2813
|
}
|
|
@@ -2775,8 +2815,16 @@ export class BridgeRunner {
|
|
|
2775
2815
|
this.copilotLog(`skip message role=${String(message?.role || "unknown").toLowerCase()}`);
|
|
2776
2816
|
continue;
|
|
2777
2817
|
}
|
|
2818
|
+
if (this.isUserStopMessage(message)) {
|
|
2819
|
+
this.dropQueuedBeforeStop = false;
|
|
2820
|
+
} else if ((index < lastStopIndex || this.dropQueuedBeforeStop) && this.isDroppableOnStop(message)) {
|
|
2821
|
+
if (message.message_id) this.processedMessageIds.add(message.message_id);
|
|
2822
|
+
this.copilotLog(`drop message queued before /stop replyTo=${message.message_id || "unknown"}`);
|
|
2823
|
+
continue;
|
|
2824
|
+
}
|
|
2778
2825
|
await this.respondToMessage(message);
|
|
2779
2826
|
}
|
|
2827
|
+
this.dropQueuedBeforeStop = false;
|
|
2780
2828
|
|
|
2781
2829
|
if (ackToken) {
|
|
2782
2830
|
await this.conductor.ackMessages(this.taskId, ackToken);
|
|
@@ -2786,6 +2834,15 @@ export class BridgeRunner {
|
|
|
2786
2834
|
return hasMore;
|
|
2787
2835
|
}
|
|
2788
2836
|
|
|
2837
|
+
isUserStopMessage(message) {
|
|
2838
|
+
return String(message?.role || "").toLowerCase() === "user" && isStopCommand(message.content);
|
|
2839
|
+
}
|
|
2840
|
+
|
|
2841
|
+
// The end-of-round summary request is the server's, not the user's: dropping it would lock the round.
|
|
2842
|
+
isDroppableOnStop(message) {
|
|
2843
|
+
return String(message?.role || "").toLowerCase() === "user" && message.metadata?.kind !== "persistent_round_end";
|
|
2844
|
+
}
|
|
2845
|
+
|
|
2789
2846
|
shouldRespond(message) {
|
|
2790
2847
|
if (!message || typeof message !== "object") {
|
|
2791
2848
|
return false;
|
|
@@ -3221,6 +3278,19 @@ export class BridgeRunner {
|
|
|
3221
3278
|
}
|
|
3222
3279
|
|
|
3223
3280
|
async handleContinuousWorkingStatus(payload) {
|
|
3281
|
+
// A turn claude ran on its own (after a background task or subagent) hit
|
|
3282
|
+
// the quota: no user turn fails, so park the task here.
|
|
3283
|
+
if (
|
|
3284
|
+
payload?.background === true &&
|
|
3285
|
+
payload?.phase === "turn_failed" &&
|
|
3286
|
+
!this.runningTurn &&
|
|
3287
|
+
(await this.enterQuotaWaitIfQuotaError(String(payload.status_done_line || ""), {
|
|
3288
|
+
replyTo: undefined,
|
|
3289
|
+
content: "",
|
|
3290
|
+
}))
|
|
3291
|
+
) {
|
|
3292
|
+
return;
|
|
3293
|
+
}
|
|
3224
3294
|
await this.reportRuntimeStatus(payload, payload?.replyTo);
|
|
3225
3295
|
}
|
|
3226
3296
|
|
|
@@ -3285,6 +3355,228 @@ export class BridgeRunner {
|
|
|
3285
3355
|
this.errorLoop = null;
|
|
3286
3356
|
}
|
|
3287
3357
|
|
|
3358
|
+
getQuotaResumeScheduler() {
|
|
3359
|
+
if (this.quotaResumeSchedulerOverride !== undefined) {
|
|
3360
|
+
return this.quotaResumeSchedulerOverride;
|
|
3361
|
+
}
|
|
3362
|
+
this.quotaResumeSchedulerOverride = createQuotaResumeScheduler();
|
|
3363
|
+
return this.quotaResumeSchedulerOverride;
|
|
3364
|
+
}
|
|
3365
|
+
|
|
3366
|
+
/**
|
|
3367
|
+
* A turn failed because the model quota is used up: park the task until the
|
|
3368
|
+
* quota resets. The resume is a one-off Conductor scheduled message, so it
|
|
3369
|
+
* survives restarts and can be canceled from the schedule list; messages that
|
|
3370
|
+
* arrive meanwhile are queued instead of each failing again. Returns false
|
|
3371
|
+
* (report the error as usual) for other errors, or after
|
|
3372
|
+
* QUOTA_RESUME_MAX_ATTEMPTS resumes in a row hit the quota again.
|
|
3373
|
+
*/
|
|
3374
|
+
async enterQuotaWaitIfQuotaError(errorMessage, { replyTo, content }) {
|
|
3375
|
+
const now = this.now();
|
|
3376
|
+
const quota = parseQuotaLimitError(errorMessage, { now });
|
|
3377
|
+
if (!quota) {
|
|
3378
|
+
return false;
|
|
3379
|
+
}
|
|
3380
|
+
const failedItems = this.pendingItemsOfFailedTurn(replyTo, content);
|
|
3381
|
+
if (this.quotaWait) {
|
|
3382
|
+
// Already parked (a background failure, or a turn that raced the wait):
|
|
3383
|
+
// fold this one in rather than scheduling a second resume.
|
|
3384
|
+
await this.addToQuotaWait(failedItems);
|
|
3385
|
+
return true;
|
|
3386
|
+
}
|
|
3387
|
+
this.quotaResumeAttempts += 1;
|
|
3388
|
+
if (this.quotaResumeAttempts > QUOTA_RESUME_MAX_ATTEMPTS) {
|
|
3389
|
+
log(`[quota] ${this.quotaResumeAttempts - 1} resumes in a row hit the quota again; not scheduling another`);
|
|
3390
|
+
this.quotaResumeAttempts = 0;
|
|
3391
|
+
this.lastQuotaResumeQueue = null;
|
|
3392
|
+
return false;
|
|
3393
|
+
}
|
|
3394
|
+
this.lastQuotaResumeQueue = null;
|
|
3395
|
+
const sendAt = computeQuotaResumeAt(quota.resetAt, { now, random: this.random });
|
|
3396
|
+
// Set synchronously, before any await, so messages and background failures
|
|
3397
|
+
// that arrive while the schedule is being created are queued into it.
|
|
3398
|
+
const wait = {
|
|
3399
|
+
// Messages that arrive before this are queued; later ones end the wait.
|
|
3400
|
+
resumeAfter: quota.resetAt && quota.resetAt.getTime() > now.getTime() ? quota.resetAt : sendAt,
|
|
3401
|
+
sendAt,
|
|
3402
|
+
scheduleId: null,
|
|
3403
|
+
localResumeAt: null,
|
|
3404
|
+
queued: failedItems,
|
|
3405
|
+
};
|
|
3406
|
+
this.quotaWait = wait;
|
|
3407
|
+
const scheduler = this.getQuotaResumeScheduler();
|
|
3408
|
+
let scheduleNote = "可在定时消息列表里取消。";
|
|
3409
|
+
let scheduledContent = "";
|
|
3410
|
+
if (scheduler) {
|
|
3411
|
+
// A fire that restarted mid-wait left its resume schedule behind; the new
|
|
3412
|
+
// one replaces it, so carry over what it listed.
|
|
3413
|
+
if (typeof scheduler.listActive === "function") {
|
|
3414
|
+
try {
|
|
3415
|
+
const leftover = await scheduler.listActive({ taskId: this.taskId });
|
|
3416
|
+
wait.queued = mergeQueuedMessages(
|
|
3417
|
+
...leftover.map((row) => parseQuotaResumePrompt(row?.content)),
|
|
3418
|
+
wait.queued,
|
|
3419
|
+
);
|
|
3420
|
+
} catch (error) {
|
|
3421
|
+
log(`[quota] failed to read earlier resume schedules: ${error?.message || error}`);
|
|
3422
|
+
}
|
|
3423
|
+
}
|
|
3424
|
+
scheduledContent = buildQuotaResumePrompt(wait.queued);
|
|
3425
|
+
try {
|
|
3426
|
+
const created = await scheduler.schedule({
|
|
3427
|
+
taskId: this.taskId,
|
|
3428
|
+
sendAt,
|
|
3429
|
+
content: scheduledContent,
|
|
3430
|
+
});
|
|
3431
|
+
wait.scheduleId = created?.id || null;
|
|
3432
|
+
} catch (error) {
|
|
3433
|
+
log(`[quota] failed to schedule resume: ${error?.message || error}`);
|
|
3434
|
+
}
|
|
3435
|
+
}
|
|
3436
|
+
if (!wait.scheduleId) {
|
|
3437
|
+
// No server schedule: fire keeps the time itself (lost if fire restarts).
|
|
3438
|
+
wait.localResumeAt = sendAt;
|
|
3439
|
+
scheduleNote = "定时消息没有建成,改由本进程计时;如果 fire 重启,需要手动发消息继续。";
|
|
3440
|
+
} else if (buildQuotaResumePrompt(wait.queued) !== scheduledContent) {
|
|
3441
|
+
// Messages queued while the schedule was being created.
|
|
3442
|
+
await this.syncQuotaResumeSchedule();
|
|
3443
|
+
}
|
|
3444
|
+
const resumeLabel = formatResumeTime(sendAt);
|
|
3445
|
+
log(
|
|
3446
|
+
`[quota] waiting until ${sendAt.toISOString()} (reset ${quota.resetAt ? quota.resetAt.toISOString() : "unknown"}) schedule=${wait.scheduleId || "local"} attempt=${this.quotaResumeAttempts}`,
|
|
3447
|
+
);
|
|
3448
|
+
await this.reportRuntimeStatus(
|
|
3449
|
+
{
|
|
3450
|
+
state: "WAITING",
|
|
3451
|
+
phase: "quota_wait",
|
|
3452
|
+
reply_in_progress: false,
|
|
3453
|
+
status_done_line: `额度已用完,${resumeLabel} 自动继续`,
|
|
3454
|
+
quota_resume_at: sendAt.toISOString(),
|
|
3455
|
+
},
|
|
3456
|
+
replyTo,
|
|
3457
|
+
);
|
|
3458
|
+
try {
|
|
3459
|
+
await this.conductor.sendMessage(
|
|
3460
|
+
this.taskId,
|
|
3461
|
+
`${this.backendName} 额度已用完:${errorMessage}\n将在 ${resumeLabel} 自动继续,在此之前收到的消息会排队,届时一起处理。${scheduleNote}`,
|
|
3462
|
+
{
|
|
3463
|
+
severity: "warning",
|
|
3464
|
+
backend: this.backendName,
|
|
3465
|
+
reply_to: replyTo || undefined,
|
|
3466
|
+
quota_wait: true,
|
|
3467
|
+
quota_resume_at: sendAt.toISOString(),
|
|
3468
|
+
cli_args: this.cliArgs,
|
|
3469
|
+
},
|
|
3470
|
+
);
|
|
3471
|
+
} catch (error) {
|
|
3472
|
+
log(`[quota] failed to post wait notice: ${error?.message || error}`);
|
|
3473
|
+
}
|
|
3474
|
+
return true;
|
|
3475
|
+
}
|
|
3476
|
+
|
|
3477
|
+
/** What still has to run after a turn that hit the quota. */
|
|
3478
|
+
pendingItemsOfFailedTurn(replyTo, content) {
|
|
3479
|
+
const text = String(content || "").trim();
|
|
3480
|
+
if (!text) {
|
|
3481
|
+
// A background turn: nothing to redo beyond "continue".
|
|
3482
|
+
return [];
|
|
3483
|
+
}
|
|
3484
|
+
if (!isQuotaResumePrompt(text)) {
|
|
3485
|
+
return [{ messageId: replyTo || null, content: text }];
|
|
3486
|
+
}
|
|
3487
|
+
// A failed resume: what it carried. A fire that restarted mid-wait has no
|
|
3488
|
+
// memory of it, so read the list back out of the prompt itself.
|
|
3489
|
+
return this.lastQuotaResumeQueue
|
|
3490
|
+
? [...this.lastQuotaResumeQueue]
|
|
3491
|
+
: parseQuotaResumePrompt(text);
|
|
3492
|
+
}
|
|
3493
|
+
|
|
3494
|
+
async addToQuotaWait(items) {
|
|
3495
|
+
const wait = this.quotaWait;
|
|
3496
|
+
const before = wait.queued.length;
|
|
3497
|
+
wait.queued = mergeQueuedMessages(wait.queued, items);
|
|
3498
|
+
if (wait.queued.length !== before) {
|
|
3499
|
+
await this.syncQuotaResumeSchedule();
|
|
3500
|
+
}
|
|
3501
|
+
}
|
|
3502
|
+
|
|
3503
|
+
/** Keep the scheduled resume's text complete, so a fire restart loses nothing. */
|
|
3504
|
+
async syncQuotaResumeSchedule() {
|
|
3505
|
+
const wait = this.quotaWait;
|
|
3506
|
+
const scheduler = this.getQuotaResumeScheduler();
|
|
3507
|
+
if (!wait?.scheduleId || typeof scheduler?.update !== "function") {
|
|
3508
|
+
return;
|
|
3509
|
+
}
|
|
3510
|
+
try {
|
|
3511
|
+
await scheduler.update({
|
|
3512
|
+
taskId: this.taskId,
|
|
3513
|
+
scheduleId: wait.scheduleId,
|
|
3514
|
+
content: buildQuotaResumePrompt(wait.queued),
|
|
3515
|
+
});
|
|
3516
|
+
} catch (error) {
|
|
3517
|
+
log(`[quota] failed to update resume schedule: ${error?.message || error}`);
|
|
3518
|
+
}
|
|
3519
|
+
}
|
|
3520
|
+
|
|
3521
|
+
isQuotaResumeMessage(message, content) {
|
|
3522
|
+
const scheduledMessageId = message?.metadata?.scheduledMessageId;
|
|
3523
|
+
if (scheduledMessageId && this.quotaWait?.scheduleId && scheduledMessageId === this.quotaWait.scheduleId) {
|
|
3524
|
+
return true;
|
|
3525
|
+
}
|
|
3526
|
+
return isQuotaResumePrompt(content);
|
|
3527
|
+
}
|
|
3528
|
+
|
|
3529
|
+
/** While waiting for the quota, queue the message. Returns false once the wait should end. */
|
|
3530
|
+
async queueMessageDuringQuotaWait(message, content) {
|
|
3531
|
+
const wait = this.quotaWait;
|
|
3532
|
+
if (this.isQuotaResumeMessage(message, content)) {
|
|
3533
|
+
return false;
|
|
3534
|
+
}
|
|
3535
|
+
if (this.now().getTime() >= wait.resumeAfter.getTime()) {
|
|
3536
|
+
return false;
|
|
3537
|
+
}
|
|
3538
|
+
const replyTo = message?.message_id || null;
|
|
3539
|
+
if (replyTo) {
|
|
3540
|
+
this.processedMessageIds.add(replyTo);
|
|
3541
|
+
}
|
|
3542
|
+
await this.addToQuotaWait([{ messageId: replyTo, content }]);
|
|
3543
|
+
log(`[quota] queued message ${replyTo || "(no id)"} until ${wait.sendAt.toISOString()} (${wait.queued.length} queued)`);
|
|
3544
|
+
return true;
|
|
3545
|
+
}
|
|
3546
|
+
|
|
3547
|
+
/** Ends the quota wait; returns the prompt for the turn that resumes the task. */
|
|
3548
|
+
async leaveQuotaWait(message, content) {
|
|
3549
|
+
const wait = this.quotaWait;
|
|
3550
|
+
this.quotaWait = null;
|
|
3551
|
+
const isResume = this.isQuotaResumeMessage(message, content);
|
|
3552
|
+
const queued = [...wait.queued];
|
|
3553
|
+
if (!isResume) {
|
|
3554
|
+
// Someone wrote after the quota reset, before the scheduled resume fired.
|
|
3555
|
+
queued.push({ messageId: message?.message_id || null, content });
|
|
3556
|
+
const scheduler = this.getQuotaResumeScheduler();
|
|
3557
|
+
// At or after the send time the schedule is (being) sent: leave it be.
|
|
3558
|
+
if (wait.scheduleId && scheduler?.cancel && this.now().getTime() < wait.sendAt.getTime()) {
|
|
3559
|
+
try {
|
|
3560
|
+
await scheduler.cancel({ taskId: this.taskId, scheduleId: wait.scheduleId });
|
|
3561
|
+
} catch (error) {
|
|
3562
|
+
log(`[quota] failed to cancel resume schedule: ${error?.message || error}`);
|
|
3563
|
+
}
|
|
3564
|
+
}
|
|
3565
|
+
}
|
|
3566
|
+
this.lastQuotaResumeQueue = queued;
|
|
3567
|
+
log(`[quota] resuming with ${queued.length} pending message(s)${isResume ? "" : " (ended by a new message)"}`);
|
|
3568
|
+
return buildQuotaResumePrompt(queued);
|
|
3569
|
+
}
|
|
3570
|
+
|
|
3571
|
+
/** The fallback when no server schedule could be made: resume from fire's own clock. */
|
|
3572
|
+
async runLocalQuotaResumeIfDue() {
|
|
3573
|
+
const wait = this.quotaWait;
|
|
3574
|
+
if (!wait?.localResumeAt || this.runningTurn || this.now().getTime() < wait.localResumeAt.getTime()) {
|
|
3575
|
+
return;
|
|
3576
|
+
}
|
|
3577
|
+
await this.respondToMessage({ role: "user", content: buildQuotaResumePrompt(wait.queued) });
|
|
3578
|
+
}
|
|
3579
|
+
|
|
3288
3580
|
evaluateErrorLoop(errorMessage) {
|
|
3289
3581
|
const normalizedKey = normalizeExecutionErrorKey(errorMessage);
|
|
3290
3582
|
const now = Date.now();
|
|
@@ -3349,7 +3641,7 @@ export class BridgeRunner {
|
|
|
3349
3641
|
)
|
|
3350
3642
|
: [];
|
|
3351
3643
|
const rawContent = String(message.content || "").trim();
|
|
3352
|
-
|
|
3644
|
+
let content = rawContent || (localAttachments.length ? "Analyze the attached files." : "");
|
|
3353
3645
|
if (!content) {
|
|
3354
3646
|
this.copilotLog(`skip empty message replyTo=${message?.message_id || "latest"}`);
|
|
3355
3647
|
return;
|
|
@@ -3363,6 +3655,16 @@ export class BridgeRunner {
|
|
|
3363
3655
|
this.copilotLog(`skip in-flight duplicated message replyTo=${replyTo}`);
|
|
3364
3656
|
return;
|
|
3365
3657
|
}
|
|
3658
|
+
if (this.quotaWait) {
|
|
3659
|
+
if (await this.queueMessageDuringQuotaWait(message, content)) {
|
|
3660
|
+
return;
|
|
3661
|
+
}
|
|
3662
|
+
content = await this.leaveQuotaWait(message, content);
|
|
3663
|
+
} else if (isQuotaResumePrompt(content)) {
|
|
3664
|
+
// A resume this fire did not schedule (it restarted while waiting):
|
|
3665
|
+
// its text already lists what was queued.
|
|
3666
|
+
this.lastQuotaResumeQueue = null;
|
|
3667
|
+
}
|
|
3366
3668
|
if (replyTo) {
|
|
3367
3669
|
this.inFlightMessageIds.add(replyTo);
|
|
3368
3670
|
}
|
|
@@ -3504,6 +3806,8 @@ export class BridgeRunner {
|
|
|
3504
3806
|
this.pendingInitialPrompt = "";
|
|
3505
3807
|
}
|
|
3506
3808
|
this.resetErrorLoop();
|
|
3809
|
+
this.quotaResumeAttempts = 0;
|
|
3810
|
+
this.lastQuotaResumeQueue = null;
|
|
3507
3811
|
if (this.useSessionFileReplyStream) {
|
|
3508
3812
|
this.copilotLog(`session_file turn settled replyTo=${replyTo || "latest"}`);
|
|
3509
3813
|
} else {
|
|
@@ -3531,6 +3835,12 @@ export class BridgeRunner {
|
|
|
3531
3835
|
if (await this.settleCodexCheckpointUnavailableAfterStream(replyTo, errorMessage)) {
|
|
3532
3836
|
return;
|
|
3533
3837
|
}
|
|
3838
|
+
if (await this.enterQuotaWaitIfQuotaError(errorMessage, { replyTo, content })) {
|
|
3839
|
+
if (replyTo) {
|
|
3840
|
+
this.processedMessageIds.add(replyTo);
|
|
3841
|
+
}
|
|
3842
|
+
return;
|
|
3843
|
+
}
|
|
3534
3844
|
this.copilotLog(
|
|
3535
3845
|
`turn failed replyTo=${replyTo || "latest"} elapsedMs=${Date.now() - turnStartedAt} error="${sanitizeForLog(errorMessage, 200)}"`,
|
|
3536
3846
|
);
|
|
@@ -3664,6 +3974,9 @@ export class BridgeRunner {
|
|
|
3664
3974
|
if (!hasAttachmentInputs && this.createFreshBackendSession && isClearCommand(content)) {
|
|
3665
3975
|
return this.runClearCommand(options);
|
|
3666
3976
|
}
|
|
3977
|
+
if (!hasAttachmentInputs && isStopCommand(content)) {
|
|
3978
|
+
return this.runStopCommand(options);
|
|
3979
|
+
}
|
|
3667
3980
|
|
|
3668
3981
|
if (willRunGoal) {
|
|
3669
3982
|
const goalResult = await this.runWithTurnUsage(() =>
|
|
@@ -3852,6 +4165,24 @@ export class BridgeRunner {
|
|
|
3852
4165
|
return { text, items: [], usage: null, provider: this.backendName, events: [], metadata: {} };
|
|
3853
4166
|
}
|
|
3854
4167
|
|
|
4168
|
+
/**
|
|
4169
|
+
* `/stop`: the server already interrupted the running turn when the message
|
|
4170
|
+
* arrived (fire only reads it once that turn ends), so just confirm it
|
|
4171
|
+
* instead of sending it to the model.
|
|
4172
|
+
*/
|
|
4173
|
+
async runStopCommand({ onProgress, replyTo = "" } = {}) {
|
|
4174
|
+
const text = `${this.backendName} 已停止。`;
|
|
4175
|
+
onProgress?.({ phase: "turn_completed", reply_in_progress: false, status_done_line: text });
|
|
4176
|
+
if (this.useSessionFileReplyStream && !this.stopped) {
|
|
4177
|
+
try {
|
|
4178
|
+
await this.sendSessionStreamMessage({ text, replyTo });
|
|
4179
|
+
} catch (error) {
|
|
4180
|
+
log(`[stop] failed to post confirmation: ${error?.message || error}`);
|
|
4181
|
+
}
|
|
4182
|
+
}
|
|
4183
|
+
return { text, items: [], usage: null, provider: this.backendName, events: [], metadata: {} };
|
|
4184
|
+
}
|
|
4185
|
+
|
|
3855
4186
|
async handlePrePromptMessage(content) {
|
|
3856
4187
|
const text = typeof content === "string" ? content.trim() : "";
|
|
3857
4188
|
if (!text) {
|
|
@@ -3887,8 +4218,16 @@ export class BridgeRunner {
|
|
|
3887
4218
|
surfaceUserMessage,
|
|
3888
4219
|
replyMetadata,
|
|
3889
4220
|
}) {
|
|
4221
|
+
if (this.quotaWait) {
|
|
4222
|
+
// Parked on the quota (e.g. the pre-prompt just hit it): run this with the resume.
|
|
4223
|
+
log(`[quota] ${logTag} queued until ${this.quotaWait.sendAt.toISOString()}`);
|
|
4224
|
+
await this.addToQuotaWait([{ messageId: replyTarget || null, content: String(content || "") }]);
|
|
4225
|
+
return;
|
|
4226
|
+
}
|
|
3890
4227
|
this.lastRuntimeStatusSignature = null;
|
|
3891
4228
|
this.runningTurn = true;
|
|
4229
|
+
// Lets a target-less interrupt (`/stop`) reach this turn.
|
|
4230
|
+
this.activeTurnReplyTo = replyTarget;
|
|
3892
4231
|
// Only this turn's own streamed replies may carry its usage.
|
|
3893
4232
|
this.lastStreamedReplyIds.clear();
|
|
3894
4233
|
const startedAt = Date.now();
|
|
@@ -3963,6 +4302,11 @@ export class BridgeRunner {
|
|
|
3963
4302
|
this.copilotLog(`${logTag} turn interrupted by stop_task elapsedMs=${Date.now() - startedAt}`);
|
|
3964
4303
|
return;
|
|
3965
4304
|
}
|
|
4305
|
+
const interruptInfo = this.remoteInterruptsByReplyTo.get(replyTarget);
|
|
4306
|
+
if (interruptInfo && this.isTurnInterruptedError(error)) {
|
|
4307
|
+
await this.handleInterruptedTurn(replyTarget, interruptInfo);
|
|
4308
|
+
return;
|
|
4309
|
+
}
|
|
3966
4310
|
if (
|
|
3967
4311
|
await this.settleCodexCheckpointUnavailableAfterStream(replyTarget, errorMessage, {
|
|
3968
4312
|
markProcessed: false,
|
|
@@ -3970,17 +4314,47 @@ export class BridgeRunner {
|
|
|
3970
4314
|
) {
|
|
3971
4315
|
return;
|
|
3972
4316
|
}
|
|
4317
|
+
if (await this.enterQuotaWaitIfQuotaError(errorMessage, { replyTo: replyTarget, content })) {
|
|
4318
|
+
return;
|
|
4319
|
+
}
|
|
3973
4320
|
this.copilotLog(
|
|
3974
4321
|
`${logTag} turn failed elapsedMs=${Date.now() - startedAt} error="${sanitizeForLog(errorMessage, 200)}"`,
|
|
3975
4322
|
);
|
|
3976
4323
|
await this.reportError(`${errorLabel}执行失败: ${errorMessage}`);
|
|
3977
4324
|
} finally {
|
|
3978
4325
|
this.copilotLog(`${logTag} turn end elapsedMs=${Date.now() - startedAt}`);
|
|
4326
|
+
this.activeTurnReplyTo = "";
|
|
4327
|
+
this.clearInterruptRetryForReplyTarget(replyTarget);
|
|
4328
|
+
this.remoteInterruptsByReplyTo.delete(replyTarget);
|
|
3979
4329
|
this.runningTurn = false;
|
|
3980
4330
|
this.scheduleRuntimeHeartbeat();
|
|
3981
4331
|
}
|
|
3982
4332
|
}
|
|
3983
4333
|
|
|
4334
|
+
// Provider-level warnings the user has to act on (e.g. claude refusing
|
|
4335
|
+
// bypassPermissions as root without IS_SANDBOX=1). Sent right after the
|
|
4336
|
+
// "session started" line so the reason is visible before the turn fails.
|
|
4337
|
+
async reportBackendNotices() {
|
|
4338
|
+
const snapshot =
|
|
4339
|
+
typeof this.backendSession?.getSnapshot === "function" ? this.backendSession.getSnapshot() : null;
|
|
4340
|
+
const notices = Array.isArray(snapshot?.notices)
|
|
4341
|
+
? snapshot.notices.filter((notice) => typeof notice === "string" && notice.trim())
|
|
4342
|
+
: [];
|
|
4343
|
+
for (const notice of notices) {
|
|
4344
|
+
try {
|
|
4345
|
+
await this.conductor.sendMessage(this.taskId, notice, {
|
|
4346
|
+
severity: "warning",
|
|
4347
|
+
backend: this.backendName,
|
|
4348
|
+
cli_args: this.cliArgs,
|
|
4349
|
+
synthetic: true,
|
|
4350
|
+
});
|
|
4351
|
+
this.copilotLog(`backend notice sent: ${sanitizeForLog(notice, 120)}`);
|
|
4352
|
+
} catch (error) {
|
|
4353
|
+
log(`Failed to send backend notice: ${error?.message || error}`);
|
|
4354
|
+
}
|
|
4355
|
+
}
|
|
4356
|
+
}
|
|
4357
|
+
|
|
3984
4358
|
async reportError(message, replyTo) {
|
|
3985
4359
|
try {
|
|
3986
4360
|
await this.conductor.sendMessage(this.taskId, message, {
|