@love-moon/conductor-cli 0.17.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 +17 -0
- package/bin/conductor-fire.js +286 -1
- package/bin/conductor-task.js +3 -2
- package/package.json +5 -5
- package/src/fire/quota-resume.js +313 -0
- package/src/task-commands.js +6 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
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
|
+
|
|
3
20
|
## 0.17.0
|
|
4
21
|
|
|
5
22
|
### Minor Changes
|
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,
|
|
@@ -2100,8 +2111,19 @@ export class BridgeRunner {
|
|
|
2100
2111
|
shouldProcessPrePrompt,
|
|
2101
2112
|
createFreshBackendSession,
|
|
2102
2113
|
withFreshSessionBootstrap,
|
|
2114
|
+
quotaResumeScheduler,
|
|
2115
|
+
now,
|
|
2116
|
+
random,
|
|
2103
2117
|
}) {
|
|
2104
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;
|
|
2105
2127
|
this.createFreshBackendSession = createFreshBackendSession;
|
|
2106
2128
|
this.withFreshSessionBootstrap = withFreshSessionBootstrap;
|
|
2107
2129
|
this.conductor = conductor;
|
|
@@ -2428,6 +2450,7 @@ export class BridgeRunner {
|
|
|
2428
2450
|
if (this.needsReconnectRecovery && !this.runningTurn) {
|
|
2429
2451
|
await this.recoverAfterReconnect();
|
|
2430
2452
|
}
|
|
2453
|
+
await this.runLocalQuotaResumeIfDue();
|
|
2431
2454
|
let processed = false;
|
|
2432
2455
|
try {
|
|
2433
2456
|
processed = await this.processIncomingBatch();
|
|
@@ -3255,6 +3278,19 @@ export class BridgeRunner {
|
|
|
3255
3278
|
}
|
|
3256
3279
|
|
|
3257
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
|
+
}
|
|
3258
3294
|
await this.reportRuntimeStatus(payload, payload?.replyTo);
|
|
3259
3295
|
}
|
|
3260
3296
|
|
|
@@ -3319,6 +3355,228 @@ export class BridgeRunner {
|
|
|
3319
3355
|
this.errorLoop = null;
|
|
3320
3356
|
}
|
|
3321
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
|
+
|
|
3322
3580
|
evaluateErrorLoop(errorMessage) {
|
|
3323
3581
|
const normalizedKey = normalizeExecutionErrorKey(errorMessage);
|
|
3324
3582
|
const now = Date.now();
|
|
@@ -3383,7 +3641,7 @@ export class BridgeRunner {
|
|
|
3383
3641
|
)
|
|
3384
3642
|
: [];
|
|
3385
3643
|
const rawContent = String(message.content || "").trim();
|
|
3386
|
-
|
|
3644
|
+
let content = rawContent || (localAttachments.length ? "Analyze the attached files." : "");
|
|
3387
3645
|
if (!content) {
|
|
3388
3646
|
this.copilotLog(`skip empty message replyTo=${message?.message_id || "latest"}`);
|
|
3389
3647
|
return;
|
|
@@ -3397,6 +3655,16 @@ export class BridgeRunner {
|
|
|
3397
3655
|
this.copilotLog(`skip in-flight duplicated message replyTo=${replyTo}`);
|
|
3398
3656
|
return;
|
|
3399
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
|
+
}
|
|
3400
3668
|
if (replyTo) {
|
|
3401
3669
|
this.inFlightMessageIds.add(replyTo);
|
|
3402
3670
|
}
|
|
@@ -3538,6 +3806,8 @@ export class BridgeRunner {
|
|
|
3538
3806
|
this.pendingInitialPrompt = "";
|
|
3539
3807
|
}
|
|
3540
3808
|
this.resetErrorLoop();
|
|
3809
|
+
this.quotaResumeAttempts = 0;
|
|
3810
|
+
this.lastQuotaResumeQueue = null;
|
|
3541
3811
|
if (this.useSessionFileReplyStream) {
|
|
3542
3812
|
this.copilotLog(`session_file turn settled replyTo=${replyTo || "latest"}`);
|
|
3543
3813
|
} else {
|
|
@@ -3565,6 +3835,12 @@ export class BridgeRunner {
|
|
|
3565
3835
|
if (await this.settleCodexCheckpointUnavailableAfterStream(replyTo, errorMessage)) {
|
|
3566
3836
|
return;
|
|
3567
3837
|
}
|
|
3838
|
+
if (await this.enterQuotaWaitIfQuotaError(errorMessage, { replyTo, content })) {
|
|
3839
|
+
if (replyTo) {
|
|
3840
|
+
this.processedMessageIds.add(replyTo);
|
|
3841
|
+
}
|
|
3842
|
+
return;
|
|
3843
|
+
}
|
|
3568
3844
|
this.copilotLog(
|
|
3569
3845
|
`turn failed replyTo=${replyTo || "latest"} elapsedMs=${Date.now() - turnStartedAt} error="${sanitizeForLog(errorMessage, 200)}"`,
|
|
3570
3846
|
);
|
|
@@ -3942,6 +4218,12 @@ export class BridgeRunner {
|
|
|
3942
4218
|
surfaceUserMessage,
|
|
3943
4219
|
replyMetadata,
|
|
3944
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
|
+
}
|
|
3945
4227
|
this.lastRuntimeStatusSignature = null;
|
|
3946
4228
|
this.runningTurn = true;
|
|
3947
4229
|
// Lets a target-less interrupt (`/stop`) reach this turn.
|
|
@@ -4032,6 +4314,9 @@ export class BridgeRunner {
|
|
|
4032
4314
|
) {
|
|
4033
4315
|
return;
|
|
4034
4316
|
}
|
|
4317
|
+
if (await this.enterQuotaWaitIfQuotaError(errorMessage, { replyTo: replyTarget, content })) {
|
|
4318
|
+
return;
|
|
4319
|
+
}
|
|
4035
4320
|
this.copilotLog(
|
|
4036
4321
|
`${logTag} turn failed elapsedMs=${Date.now() - startedAt} error="${sanitizeForLog(errorMessage, 200)}"`,
|
|
4037
4322
|
);
|
package/bin/conductor-task.js
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* schedule update <id> <schedule-id> [<message>] [--delay|--at|--every ...]
|
|
20
20
|
* stop <id> | interrupt <id> [--target-reply-to <msg-id>]
|
|
21
21
|
* restart <id> [--strategy inplace|new_task] [--backend] [--refresh-session]
|
|
22
|
-
* [--daemon-host <h>] [--code-host <h>] [--first-message <text>]
|
|
22
|
+
* [--daemon-host <h>] [--code-host <h>] [--first-message <text> [--new-worktree]]
|
|
23
23
|
* delete <id> --yes [--permanent] | archive <id> | unarchive <id> [--daemon-host <h>]
|
|
24
24
|
* rename <id> <title> | pin <id> | unpin <id> | labels <id> [<label-id>...] [--clear]
|
|
25
25
|
* move <id> [<project>] [--back] | share <id> | unshare <id> | shared <token|link>
|
|
@@ -875,7 +875,8 @@ export async function main(argvInput = hideBin(process.argv), deps = {}) {
|
|
|
875
875
|
.option("refresh-session", { type: "boolean", default: false, describe: "Start a fresh AI session in place" })
|
|
876
876
|
.option("daemon-host", { type: "string", describe: "Run the restarted AI on this daemon; with --strategy new_task it keeps working on the source task's code remotely when it can (add --code-host <same> to move the work there instead)" })
|
|
877
877
|
.option("code-host", { type: "string", describe: "With --strategy new_task: daemon holding the code (default: the source task's); another daemon moves the work there and needs --daemon-host set to it" })
|
|
878
|
-
.option("first-message", { type: "string", describe: "With --strategy new_task: first message instead of the transcript handoff" })
|
|
878
|
+
.option("first-message", { type: "string", describe: "With --strategy new_task: first message instead of the transcript handoff" })
|
|
879
|
+
.option("new-worktree", { type: "boolean", default: false, describe: "With --strategy new_task and --first-message: run the new task in its own fresh git worktree (a parallel branch) instead of the source task's workspace, without loading the source conversation; the source keeps its place in its agent group" }),
|
|
879
880
|
run(extra.handleRestart),
|
|
880
881
|
)
|
|
881
882
|
.command(
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@love-moon/conductor-cli",
|
|
3
|
-
"version": "0.17.
|
|
4
|
-
"gitCommitId": "
|
|
3
|
+
"version": "0.17.1",
|
|
4
|
+
"gitCommitId": "e3dbbad",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/lovemoon-ai/conductor.git"
|
|
@@ -24,8 +24,8 @@
|
|
|
24
24
|
"test": "node --test test/*.test.js"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@love-moon/ai-sdk": "0.17.
|
|
28
|
-
"@love-moon/conductor-sdk": "0.17.
|
|
27
|
+
"@love-moon/ai-sdk": "0.17.1",
|
|
28
|
+
"@love-moon/conductor-sdk": "0.17.1",
|
|
29
29
|
"@github/copilot-sdk": "^0.3.0",
|
|
30
30
|
"chrome-launcher": "^1.2.1",
|
|
31
31
|
"chrome-remote-interface": "^0.33.0",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
},
|
|
39
39
|
"optionalDependencies": {
|
|
40
40
|
"@roamhq/wrtc": "^0.10.0",
|
|
41
|
-
"@love-moon/chat-web": "0.17.
|
|
41
|
+
"@love-moon/chat-web": "0.17.1"
|
|
42
42
|
},
|
|
43
43
|
"pnpm": {
|
|
44
44
|
"onlyBuiltDependencies": [
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model quota ("session limit") handling for fire.
|
|
3
|
+
*
|
|
4
|
+
* When a backend turn fails because the subscription quota is used up, fire
|
|
5
|
+
* parks the task until the quota resets instead of failing every following
|
|
6
|
+
* message. The resume itself is a one-off Conductor scheduled message
|
|
7
|
+
* (`source: "quota_resume"`), so it survives fire/daemon restarts and shows up
|
|
8
|
+
* in the task's schedule list where a person can cancel it.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const QUOTA_ERROR_PATTERNS = [
|
|
12
|
+
/hit your (?:[\w-]+ )?limit/i,
|
|
13
|
+
/usage limit (?:reached|exceeded)/i,
|
|
14
|
+
/\b(?:session|5-hour|five-hour|weekly|daily|opus|sonnet) limit\b/i,
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
|
|
18
|
+
|
|
19
|
+
const MINUTE_MS = 60_000;
|
|
20
|
+
const HOUR_MS = 60 * MINUTE_MS;
|
|
21
|
+
const DAY_MS = 24 * HOUR_MS;
|
|
22
|
+
|
|
23
|
+
/** Used when the error names no reset time at all. */
|
|
24
|
+
export const QUOTA_FALLBACK_WAIT_MS = HOUR_MS;
|
|
25
|
+
/** Resume this long after the reset at the earliest, so the quota has really refilled. */
|
|
26
|
+
export const QUOTA_RESUME_MIN_DELAY_MS = MINUTE_MS;
|
|
27
|
+
/** Extra random spread on top, so tasks sharing one subscription do not all start at once. */
|
|
28
|
+
export const QUOTA_RESUME_JITTER_MS = 4 * MINUTE_MS;
|
|
29
|
+
/** Consecutive quota hits on resume before fire stops scheduling more. */
|
|
30
|
+
export const QUOTA_RESUME_MAX_ATTEMPTS = 3;
|
|
31
|
+
|
|
32
|
+
export const QUOTA_RESUME_PROMPT =
|
|
33
|
+
"[额度已恢复,自动继续] 模型额度用完时中断了你的工作。请继续中断前未完成的工作。";
|
|
34
|
+
|
|
35
|
+
export function isQuotaLimitError(message) {
|
|
36
|
+
const text = String(message || "");
|
|
37
|
+
return QUOTA_ERROR_PATTERNS.some((pattern) => pattern.test(text));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Recognises a quota error and works out when the quota resets.
|
|
42
|
+
* Returns null for any other error; `resetAt` is null when the text names no time.
|
|
43
|
+
*/
|
|
44
|
+
export function parseQuotaLimitError(message, { now = new Date(), defaultTimeZone } = {}) {
|
|
45
|
+
const text = String(message || "");
|
|
46
|
+
if (!isQuotaLimitError(text)) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
return { resetAt: parseQuotaResetAt(text, { now, defaultTimeZone }) };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function parseQuotaResetAt(text, { now = new Date(), defaultTimeZone } = {}) {
|
|
53
|
+
const nowMs = now.getTime();
|
|
54
|
+
|
|
55
|
+
// Older Claude Code: "Claude AI usage limit reached|1759561200"
|
|
56
|
+
const epoch = /limit reached\|(\d{10,13})/i.exec(text);
|
|
57
|
+
if (epoch) {
|
|
58
|
+
const value = Number(epoch[1]);
|
|
59
|
+
return new Date(epoch[1].length === 10 ? value * 1000 : value);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// "try again in 2 hours 13 minutes"
|
|
63
|
+
const relative = /try again in\s+(?:(\d+)\s*(?:hours?|hrs?|h)\b)?\s*(?:(\d+)\s*(?:minutes?|mins?|m)\b)?/i.exec(text);
|
|
64
|
+
if (relative && (relative[1] || relative[2])) {
|
|
65
|
+
return new Date(nowMs + Number(relative[1] || 0) * HOUR_MS + Number(relative[2] || 0) * MINUTE_MS);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// "resets 1:20am (Asia/Shanghai)", "resets Oct 7, 2am (Asia/Shanghai)", "try again at 3:05 PM"
|
|
69
|
+
const clock =
|
|
70
|
+
/(?:resets?|try again at)\s+(?:at\s+)?(?:([A-Za-z]{3,9})\.?\s+(\d{1,2}),?\s+(?:at\s+)?)?(\d{1,2})(?::(\d{2}))?\s*([ap]\.?m\.?)?(?:\s*\(([^)]+)\))?/i.exec(
|
|
71
|
+
text,
|
|
72
|
+
);
|
|
73
|
+
if (!clock) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
const [, monthName, dayText, hourText, minuteText, meridiem, zoneText] = clock;
|
|
77
|
+
let hour = Number(hourText);
|
|
78
|
+
const minute = Number(minuteText || 0);
|
|
79
|
+
if (meridiem) {
|
|
80
|
+
const pm = meridiem.toLowerCase().startsWith("p");
|
|
81
|
+
if (hour < 1 || hour > 12) return null;
|
|
82
|
+
hour = (hour % 12) + (pm ? 12 : 0);
|
|
83
|
+
}
|
|
84
|
+
if (hour > 23 || minute > 59) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
const timeZone = resolveTimeZone(zoneText) || resolveTimeZone(defaultTimeZone) || undefined;
|
|
88
|
+
const today = zonedParts(nowMs, timeZone);
|
|
89
|
+
|
|
90
|
+
if (monthName) {
|
|
91
|
+
const month = MONTHS.indexOf(monthName.slice(0, 3).toLowerCase());
|
|
92
|
+
if (month < 0) return null;
|
|
93
|
+
const day = Number(dayText);
|
|
94
|
+
let resetMs = zonedWallTimeToUtc({ year: today.year, month: month + 1, day, hour, minute }, timeZone);
|
|
95
|
+
if (resetMs < nowMs - DAY_MS) {
|
|
96
|
+
resetMs = zonedWallTimeToUtc({ year: today.year + 1, month: month + 1, day, hour, minute }, timeZone);
|
|
97
|
+
}
|
|
98
|
+
return new Date(resetMs);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let resetMs = zonedWallTimeToUtc({ ...today, hour, minute }, timeZone);
|
|
102
|
+
if (resetMs <= nowMs - 10 * MINUTE_MS) {
|
|
103
|
+
// A time-only reset already behind us means tomorrow.
|
|
104
|
+
resetMs = zonedWallTimeToUtc({ ...today, day: today.day + 1, hour, minute }, timeZone);
|
|
105
|
+
} else if (resetMs < nowMs) {
|
|
106
|
+
resetMs = nowMs;
|
|
107
|
+
}
|
|
108
|
+
return new Date(resetMs);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** When to send the resume message: after the reset, plus jitter. */
|
|
112
|
+
export function computeQuotaResumeAt(resetAt, { now = new Date(), random = Math.random } = {}) {
|
|
113
|
+
const nowMs = now.getTime();
|
|
114
|
+
const base = resetAt instanceof Date && Number.isFinite(resetAt.getTime())
|
|
115
|
+
? resetAt.getTime()
|
|
116
|
+
: nowMs + QUOTA_FALLBACK_WAIT_MS;
|
|
117
|
+
const jitter = Math.floor(Math.max(0, Math.min(1, Number(random()) || 0)) * QUOTA_RESUME_JITTER_MS);
|
|
118
|
+
return new Date(Math.max(base, nowMs) + QUOTA_RESUME_MIN_DELAY_MS + jitter);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The prompt for the resume turn: the resume note plus every message that has
|
|
123
|
+
* not been handled yet (the one whose turn hit the quota, then those that
|
|
124
|
+
* arrived while waiting).
|
|
125
|
+
*/
|
|
126
|
+
export function buildQuotaResumePrompt(queued = []) {
|
|
127
|
+
const parts = [QUOTA_RESUME_PROMPT];
|
|
128
|
+
const pending = queued.filter((item) => item && String(item.content || "").trim());
|
|
129
|
+
if (pending.length) {
|
|
130
|
+
parts.push(`以下 ${pending.length} 条消息还没有处理(按时间顺序):`);
|
|
131
|
+
pending.forEach((item, index) => {
|
|
132
|
+
parts.push(`--- 消息 ${index + 1} ---\n${String(item.content).trim()}`);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
return parts.join("\n\n");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function isQuotaResumePrompt(content) {
|
|
139
|
+
return String(content || "").trim().startsWith(QUOTA_RESUME_PROMPT);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const RESUME_ITEM_SEPARATOR = /\n\n--- 消息 \d+ ---\n/;
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The pending messages a resume prompt lists (the inverse of
|
|
146
|
+
* buildQuotaResumePrompt), so a fire that restarted mid-wait can carry them on.
|
|
147
|
+
*/
|
|
148
|
+
export function parseQuotaResumePrompt(content) {
|
|
149
|
+
const text = String(content || "").trim();
|
|
150
|
+
if (!isQuotaResumePrompt(text)) {
|
|
151
|
+
return [];
|
|
152
|
+
}
|
|
153
|
+
return text
|
|
154
|
+
.split(RESUME_ITEM_SEPARATOR)
|
|
155
|
+
.slice(1)
|
|
156
|
+
.map((part) => part.trim())
|
|
157
|
+
.filter(Boolean)
|
|
158
|
+
.map((part) => ({ messageId: null, content: part }));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Concatenates pending-message lists, dropping repeats of the same text. */
|
|
162
|
+
export function mergeQueuedMessages(...lists) {
|
|
163
|
+
const seen = new Set();
|
|
164
|
+
const merged = [];
|
|
165
|
+
for (const item of lists.flat()) {
|
|
166
|
+
const key = String(item?.content || "").trim();
|
|
167
|
+
if (!key || seen.has(key)) continue;
|
|
168
|
+
seen.add(key);
|
|
169
|
+
merged.push(item);
|
|
170
|
+
}
|
|
171
|
+
return merged;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function formatResumeTime(date, timeZone) {
|
|
175
|
+
try {
|
|
176
|
+
return new Intl.DateTimeFormat("zh-CN", {
|
|
177
|
+
timeZone: resolveTimeZone(timeZone) || undefined,
|
|
178
|
+
month: "2-digit",
|
|
179
|
+
day: "2-digit",
|
|
180
|
+
hour: "2-digit",
|
|
181
|
+
minute: "2-digit",
|
|
182
|
+
hourCycle: "h23",
|
|
183
|
+
}).format(date);
|
|
184
|
+
} catch {
|
|
185
|
+
return date.toISOString();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Creates and cancels the resume schedule through the Conductor HTTP API.
|
|
191
|
+
* Returns null when fire has no backend URL/token (e.g. a guest daemon).
|
|
192
|
+
*/
|
|
193
|
+
export function createQuotaResumeScheduler({ env = process.env, fetchImpl = globalThis.fetch } = {}) {
|
|
194
|
+
const backendUrl = typeof env.CONDUCTOR_BACKEND_URL === "string" ? env.CONDUCTOR_BACKEND_URL.trim() : "";
|
|
195
|
+
const token = typeof env.CONDUCTOR_AGENT_TOKEN === "string" ? env.CONDUCTOR_AGENT_TOKEN.trim() : "";
|
|
196
|
+
if (!backendUrl || !token || typeof fetchImpl !== "function") {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
const base = backendUrl.replace(/\/+$/, "");
|
|
200
|
+
// No X-Conductor-Actor header: this is fire itself, not the agent, so the
|
|
201
|
+
// task's agent schedule-access setting must not block it.
|
|
202
|
+
const headers = {
|
|
203
|
+
Authorization: `Bearer ${token}`,
|
|
204
|
+
Accept: "application/json",
|
|
205
|
+
"Content-Type": "application/json",
|
|
206
|
+
};
|
|
207
|
+
const schedulesUrl = (taskId) => `${base}/api/tasks/${encodeURIComponent(taskId)}/scheduled-messages`;
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
async schedule({ taskId, sendAt, content }) {
|
|
211
|
+
const response = await fetchImpl(schedulesUrl(taskId), {
|
|
212
|
+
method: "POST",
|
|
213
|
+
headers,
|
|
214
|
+
body: JSON.stringify({
|
|
215
|
+
content,
|
|
216
|
+
source: "quota_resume",
|
|
217
|
+
schedule: { mode: "at", sendAt: sendAt.toISOString() },
|
|
218
|
+
}),
|
|
219
|
+
});
|
|
220
|
+
if (!response.ok) {
|
|
221
|
+
const detail = await response.text().catch(() => "");
|
|
222
|
+
throw new Error(`schedule quota resume failed: HTTP ${response.status} ${detail}`.trim());
|
|
223
|
+
}
|
|
224
|
+
const body = await response.json().catch(() => ({}));
|
|
225
|
+
return { id: typeof body?.id === "string" ? body.id : null };
|
|
226
|
+
},
|
|
227
|
+
/** Active quota_resume schedules of the task (left behind by an earlier fire). */
|
|
228
|
+
async listActive({ taskId }) {
|
|
229
|
+
const response = await fetchImpl(`${schedulesUrl(taskId)}?status=active`, { method: "GET", headers });
|
|
230
|
+
if (!response.ok) {
|
|
231
|
+
throw new Error(`list quota resumes failed: HTTP ${response.status}`);
|
|
232
|
+
}
|
|
233
|
+
const body = await response.json().catch(() => ({}));
|
|
234
|
+
const schedules = Array.isArray(body?.schedules) ? body.schedules : [];
|
|
235
|
+
// Servers before `source` existed: recognise the resume by its text.
|
|
236
|
+
return schedules.filter((row) =>
|
|
237
|
+
row?.source ? row.source === "quota_resume" : isQuotaResumePrompt(row?.content),
|
|
238
|
+
);
|
|
239
|
+
},
|
|
240
|
+
async update({ taskId, scheduleId, content }) {
|
|
241
|
+
const response = await fetchImpl(`${schedulesUrl(taskId)}/${encodeURIComponent(scheduleId)}`, {
|
|
242
|
+
method: "PATCH",
|
|
243
|
+
headers,
|
|
244
|
+
body: JSON.stringify({ content }),
|
|
245
|
+
});
|
|
246
|
+
if (!response.ok) {
|
|
247
|
+
throw new Error(`update quota resume failed: HTTP ${response.status}`);
|
|
248
|
+
}
|
|
249
|
+
},
|
|
250
|
+
async cancel({ taskId, scheduleId }) {
|
|
251
|
+
// cancel_only: never delete a row the dispatcher already sent (that
|
|
252
|
+
// would erase its history); a server without the flag still cancels
|
|
253
|
+
// active rows, and fire only cancels before the send time.
|
|
254
|
+
const response = await fetchImpl(
|
|
255
|
+
`${schedulesUrl(taskId)}/${encodeURIComponent(scheduleId)}?cancel_only=1`,
|
|
256
|
+
{ method: "DELETE", headers },
|
|
257
|
+
);
|
|
258
|
+
if (!response.ok && response.status !== 404 && response.status !== 409) {
|
|
259
|
+
throw new Error(`cancel quota resume failed: HTTP ${response.status}`);
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function resolveTimeZone(value) {
|
|
266
|
+
const zone = typeof value === "string" ? value.trim() : "";
|
|
267
|
+
if (!zone) return null;
|
|
268
|
+
try {
|
|
269
|
+
new Intl.DateTimeFormat("en-US", { timeZone: zone });
|
|
270
|
+
return zone;
|
|
271
|
+
} catch {
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function zonedParts(ms, timeZone) {
|
|
277
|
+
const parts = {};
|
|
278
|
+
for (const part of new Intl.DateTimeFormat("en-US", {
|
|
279
|
+
timeZone,
|
|
280
|
+
hourCycle: "h23",
|
|
281
|
+
year: "numeric",
|
|
282
|
+
month: "2-digit",
|
|
283
|
+
day: "2-digit",
|
|
284
|
+
hour: "2-digit",
|
|
285
|
+
minute: "2-digit",
|
|
286
|
+
second: "2-digit",
|
|
287
|
+
}).formatToParts(new Date(ms))) {
|
|
288
|
+
if (part.type !== "literal") parts[part.type] = Number(part.value);
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
year: parts.year,
|
|
292
|
+
month: parts.month,
|
|
293
|
+
day: parts.day,
|
|
294
|
+
hour: parts.hour === 24 ? 0 : parts.hour,
|
|
295
|
+
minute: parts.minute,
|
|
296
|
+
second: parts.second,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Offset of `timeZone` from UTC at instant `ms`, in ms (local minus UTC). */
|
|
301
|
+
function zoneOffsetMs(ms, timeZone) {
|
|
302
|
+
const p = zonedParts(ms, timeZone);
|
|
303
|
+
const asUtc = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second);
|
|
304
|
+
return asUtc - Math.floor(ms / 1000) * 1000;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** UTC instant of a wall-clock time in `timeZone` (day overflow is normalised). */
|
|
308
|
+
function zonedWallTimeToUtc({ year, month, day, hour, minute }, timeZone) {
|
|
309
|
+
const guess = Date.UTC(year, month - 1, day, hour, minute, 0);
|
|
310
|
+
const first = guess - zoneOffsetMs(guess, timeZone);
|
|
311
|
+
const second = guess - zoneOffsetMs(first, timeZone);
|
|
312
|
+
return second;
|
|
313
|
+
}
|
package/src/task-commands.js
CHANGED
|
@@ -110,6 +110,12 @@ export async function handleRestart(argv, deps) {
|
|
|
110
110
|
if (argv.strategy !== "new_task") throw argsError("--first-message requires --strategy new_task");
|
|
111
111
|
body.first_message = trimmed(argv.firstMessage);
|
|
112
112
|
}
|
|
113
|
+
if (argv.newWorktree) {
|
|
114
|
+
if (argv.strategy !== "new_task") throw argsError("--new-worktree requires --strategy new_task");
|
|
115
|
+
// A fresh worktree does not load the source conversation; the first message replaces it.
|
|
116
|
+
if (!trimmed(argv.firstMessage)) throw argsError("--new-worktree requires --first-message");
|
|
117
|
+
body.new_worktree = true;
|
|
118
|
+
}
|
|
113
119
|
if (argv.refreshSession && argv.strategy) {
|
|
114
120
|
throw argsError("--refresh-session cannot be combined with --strategy");
|
|
115
121
|
}
|