paseo-req-kit 1.11.0-beta.1 → 1.12.0-beta.2
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 +41 -0
- package/bin/req.mjs +1 -0
- package/config.example.json +3 -1
- package/contracts/cli-json.md +2 -0
- package/contracts/state.md +3 -1
- package/contracts/usage-record.md +4 -1
- package/docs/adr/0031-fewer-interventions.md +33 -0
- package/docs/adr/README.md +1 -0
- package/docs/guide//345/256/211/350/243/205/344/270/216/345/215/207/347/272/247.md +3 -2
- package/docs/guide//351/241/271/347/233/256/345/256/232/345/210/266/344/270/216/350/277/220/350/241/214/350/247/204/345/210/231.md +21 -3
- package/engine/prompts/04b-design-revise.md +15 -0
- package/engine/src/api.ts +33 -1
- package/engine/src/card-cmd.ts +8 -1
- package/engine/src/card.ts +9 -2
- package/engine/src/config.ts +23 -2
- package/engine/src/configure.ts +161 -36
- package/engine/src/control.ts +1 -0
- package/engine/src/factory.ts +48 -4
- package/engine/src/main.ts +1 -0
- package/engine/src/paseo-profiles.ts +2 -0
- package/engine/src/paseo-runtime.ts +3 -0
- package/engine/src/pipeline.ts +238 -28
- package/engine/src/records.ts +8 -0
- package/engine/src/runtime.ts +2 -0
- package/engine/src/state.ts +37 -0
- package/engine/src/stats.ts +90 -0
- package/engine/src/steps.ts +4 -1
- package/install.mjs +5 -1
- package/package.json +2 -2
- package/paseo-plugin/OVERVIEW.md +2 -0
- package/paseo-plugin/client/data.ts +15 -1
- package/paseo-plugin/client/models.tsx +117 -0
- package/paseo-plugin/client/settings.tsx +3 -1
- package/paseo-plugin/index.server.ts +3 -1
- package/paseo-plugin/package.json +1 -1
- package/paseo-plugin/server/handlers.ts +14 -1
- package/paseo-plugin/shared/contracts.ts +37 -0
- package/skills/req/playbooks/new.md +14 -14
- package/skills/req/playbooks/small.md +3 -3
- package/skills/req/playbooks/unattended.md +4 -2
package/engine/src/pipeline.ts
CHANGED
|
@@ -36,8 +36,8 @@ import { dispatchNotifiers, type NotifierEventType } from "./notifiers.js";
|
|
|
36
36
|
import { describeExternal, type IntegrationEvent, type Runner, syncIntegration } from "./integrations.js";
|
|
37
37
|
import { cardSlug, planAppendix, publishCard, readSource } from "./card.js";
|
|
38
38
|
import { principlesSection, projectAdditions, type PromptLocations, readToolPrompt } from "./prompts.js";
|
|
39
|
-
import type { AgentRuntime, ManagedService, ServiceTarget, StepAgent } from "./runtime.js";
|
|
40
|
-
import { type CheckOutcome, type GateRun, type JointRepo, type PhaseName, type PipelineState, type RunPhases, type StepRecord, writeState } from "./state.js";
|
|
39
|
+
import type { AgentRuntime, ManagedService, ServiceTarget, StepAgent, WaitOutcome } from "./runtime.js";
|
|
40
|
+
import { type CheckOutcome, type GateRun, type JointRepo, type PhaseName, type PipelineState, type Recovery, type RunPhases, type StepRecord, type StopKind, writeState } from "./state.js";
|
|
41
41
|
import { type PanelStep, STEPS, type StepDef, type StepId, stepKey, validateResult, VERIFY_PHASE_STEPS } from "./steps.js";
|
|
42
42
|
|
|
43
43
|
export interface PipelineContext {
|
|
@@ -124,6 +124,40 @@ class StepFailure extends Error {
|
|
|
124
124
|
}
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/** The step gave up on its agent while the agent may still be working (1.11). */
|
|
128
|
+
class AgentLeftRunning extends StepFailure {
|
|
129
|
+
constructor(
|
|
130
|
+
stepTitle: string,
|
|
131
|
+
message: string,
|
|
132
|
+
readonly timedOut: boolean,
|
|
133
|
+
) {
|
|
134
|
+
super(stepTitle, message);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The agent's turn ended with an error status (1.12). */
|
|
139
|
+
class AgentErrored extends StepFailure {}
|
|
140
|
+
|
|
141
|
+
/** The agent did not write a valid result file, even after the reminders (1.12). */
|
|
142
|
+
class ResultInvalid extends StepFailure {}
|
|
143
|
+
|
|
144
|
+
/** Notes something the pipeline handled by itself (state.recoveries, the log). */
|
|
145
|
+
function recover(ctx: PipelineContext, key: string, kind: Recovery["kind"], detail: string): void {
|
|
146
|
+
ctx.state.recoveries = [...(ctx.state.recoveries ?? []), { at: new Date().toISOString(), key, kind, detail: detail.slice(0, 300) }].slice(-200);
|
|
147
|
+
save(ctx);
|
|
148
|
+
ctx.log(`自动处理(${key}):${detail}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Why a run stopped, as a code for the usage record (1.12). */
|
|
152
|
+
function failureKind(error: unknown): StopKind {
|
|
153
|
+
if (error instanceof AgentLeftRunning) return error.timedOut ? "timeout" : "transient";
|
|
154
|
+
const message = (error as Error).message ?? "";
|
|
155
|
+
if (error instanceof StepFailure && isTransientError(message)) return "transient";
|
|
156
|
+
if (error instanceof AgentErrored) return "agent_error";
|
|
157
|
+
if (error instanceof ResultInvalid) return "invalid_result";
|
|
158
|
+
return "other";
|
|
159
|
+
}
|
|
160
|
+
|
|
127
161
|
/**
|
|
128
162
|
* Errors that usually go away by themselves: provider rate limits and overload,
|
|
129
163
|
* dropped connections, a restarting daemon. A step that fails this way is started
|
|
@@ -484,15 +518,31 @@ async function milestone(ctx: PipelineContext, id: string, text: string): Promis
|
|
|
484
518
|
await notifyExternal(ctx, "milestone", text, { milestone: id });
|
|
485
519
|
}
|
|
486
520
|
|
|
487
|
-
|
|
521
|
+
/** A step's time limit; `extended` once it was given a second period (selfHeal). */
|
|
522
|
+
interface Deadline {
|
|
523
|
+
at: number;
|
|
524
|
+
extended: boolean;
|
|
525
|
+
key: string;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
async function waitForTurn(ctx: PipelineContext, agent: StepAgent, def: StepDef, agentTitle: string, deadline: Deadline): Promise<AgentUsage | null> {
|
|
488
529
|
const notifiedPermissions = new Set<string>();
|
|
489
530
|
let consecutiveErrors = 0;
|
|
490
531
|
for (;;) {
|
|
491
|
-
|
|
532
|
+
let remaining = deadline.at - Date.now();
|
|
533
|
+
if (remaining <= 0 && ctx.config.selfHeal && !deadline.extended) {
|
|
534
|
+
// Usually a big step, not a stuck one: the agent keeps its progress and gets as long again.
|
|
535
|
+
deadline.extended = true;
|
|
536
|
+
deadline.at = Date.now() + ctx.config.stepTimeoutMinutes[def.id] * 60_000;
|
|
537
|
+
remaining = deadline.at - Date.now();
|
|
538
|
+
recover(ctx, deadline.key, "extend_timeout", `超过 ${ctx.config.stepTimeoutMinutes[def.id]} 分钟仍未完成,agent 还在做,再给它同样长的时间`);
|
|
539
|
+
}
|
|
492
540
|
if (remaining <= 0) {
|
|
493
|
-
|
|
541
|
+
const total = ctx.config.stepTimeoutMinutes[def.id] * (deadline.extended ? 2 : 1);
|
|
542
|
+
throw new AgentLeftRunning(
|
|
494
543
|
def.title,
|
|
495
|
-
`超过 ${
|
|
544
|
+
`超过 ${total} 分钟仍未完成。agent「${agentTitle}」还在运行,没有停止它;继续时会先看它的情况:做完了就用它的结果,还在做就接着等它,不会另起一个 agent 重复做。`,
|
|
545
|
+
true,
|
|
496
546
|
);
|
|
497
547
|
}
|
|
498
548
|
let outcome;
|
|
@@ -503,7 +553,7 @@ async function waitForTurn(ctx: PipelineContext, agent: StepAgent, def: StepDef,
|
|
|
503
553
|
consecutiveErrors += 1;
|
|
504
554
|
ctx.log(`等待 ${agentTitle} 时出错(第 ${consecutiveErrors} 次):${(error as Error).message}`);
|
|
505
555
|
if (consecutiveErrors >= 5) {
|
|
506
|
-
throw new
|
|
556
|
+
throw new AgentLeftRunning(def.title, `连续 ${consecutiveErrors} 次无法获取 agent 状态:${(error as Error).message}`, false);
|
|
507
557
|
}
|
|
508
558
|
await ctx.sleep(Math.min(60_000, 5_000 * consecutiveErrors));
|
|
509
559
|
continue;
|
|
@@ -514,7 +564,7 @@ async function waitForTurn(ctx: PipelineContext, agent: StepAgent, def: StepDef,
|
|
|
514
564
|
case "timeout":
|
|
515
565
|
continue;
|
|
516
566
|
case "error":
|
|
517
|
-
throw new
|
|
567
|
+
throw new AgentErrored(def.title, `agent 报告错误:${outcome.error ?? "未知错误"}`);
|
|
518
568
|
case "permission": {
|
|
519
569
|
const fresh = outcome.pendingPermissions.filter((p) => !notifiedPermissions.has(p.id));
|
|
520
570
|
if (fresh.length > 0) {
|
|
@@ -559,31 +609,63 @@ async function runStep(
|
|
|
559
609
|
extras: StepExtras = {},
|
|
560
610
|
): Promise<Record<string, unknown>> {
|
|
561
611
|
const { attempts, backoffSeconds } = ctx.config.retry;
|
|
612
|
+
const key = extras.key ?? keyFor(stepKey(id, round), extras.target);
|
|
613
|
+
const fallback = ctx.config.agents.fallback ?? null;
|
|
614
|
+
let redone = false;
|
|
615
|
+
let usedFallback = false;
|
|
562
616
|
for (let retry = 0; ; retry += 1) {
|
|
563
617
|
try {
|
|
564
618
|
return await runStepOnce(ctx, id, round, maxRounds, extras);
|
|
565
619
|
} catch (error) {
|
|
566
620
|
const message = (error as Error).message;
|
|
567
|
-
|
|
621
|
+
const transient = error instanceof StepFailure && isTransientError(message);
|
|
622
|
+
const healable = error instanceof AgentErrored || error instanceof ResultInvalid;
|
|
623
|
+
// 1.12: a step whose agent errored or wrote no valid result is redone once with a new
|
|
624
|
+
// agent; one that keeps hitting rate limits gets the fallback model, when there is one.
|
|
625
|
+
if (error instanceof StepFailure && ctx.config.selfHeal && !transient && healable && !redone) {
|
|
626
|
+
redone = true;
|
|
627
|
+
const switchModel = fallback !== null && !usedFallback && error instanceof AgentErrored;
|
|
628
|
+
if (switchModel) usedFallback = true;
|
|
629
|
+
recover(
|
|
630
|
+
ctx,
|
|
631
|
+
key,
|
|
632
|
+
switchModel ? "fallback" : "redo",
|
|
633
|
+
`${message.slice(0, 160)};${switchModel ? "换备用模型" : "新开一个 agent"}重做这一步`,
|
|
634
|
+
);
|
|
635
|
+
extras = withRetryNote({ ...extras, ...(switchModel ? { profile: fallback } : {}) }, message);
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
if (error instanceof StepFailure && transient && retry >= attempts && fallback !== null && !usedFallback && !(error instanceof AgentLeftRunning)) {
|
|
639
|
+
usedFallback = true;
|
|
640
|
+
recover(ctx, key, "fallback", `重试 ${retry} 次后仍然${message.slice(0, 120)};换备用模型继续这一步`);
|
|
641
|
+
extras = withRetryNote({ ...extras, profile: fallback }, message);
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
if (!(error instanceof StepFailure) || !transient || retry >= attempts) {
|
|
568
645
|
if (error instanceof StepFailure && retry > 0) {
|
|
569
|
-
throw new StepFailure(error.stepTitle, `${message}(已自动重试 ${retry} 次)`);
|
|
646
|
+
throw new (error.constructor as typeof StepFailure)(error.stepTitle, `${message}(已自动重试 ${retry} 次)`);
|
|
570
647
|
}
|
|
571
648
|
throw error;
|
|
572
649
|
}
|
|
573
650
|
const wait = backoffSeconds.at(Math.min(retry, backoffSeconds.length - 1)) ?? 60;
|
|
574
|
-
ctx.
|
|
651
|
+
recover(ctx, key, "retry", `遇到临时错误:${message.slice(0, 160)}。${wait} 秒后自动重试(第 ${retry + 1} 次,最多 ${attempts} 次)`);
|
|
575
652
|
await ctx.sleep(wait * 1000);
|
|
576
|
-
extras =
|
|
577
|
-
...extras,
|
|
578
|
-
contextLines: [
|
|
579
|
-
...(extras.contextLines ?? []).filter((l) => !l.startsWith("自动重试:")),
|
|
580
|
-
`自动重试:本步骤上一次因临时错误中断(${message.slice(0, 200)})。工作区里可能留有上一次没做完的改动,先用 git status 和 git diff 查看,在此基础上继续,不要重复提交`,
|
|
581
|
-
],
|
|
582
|
-
};
|
|
653
|
+
extras = withRetryNote(extras, message);
|
|
583
654
|
}
|
|
584
655
|
}
|
|
585
656
|
}
|
|
586
657
|
|
|
658
|
+
/** Tells the next agent of a step that an earlier one stopped part way. */
|
|
659
|
+
function withRetryNote(extras: StepExtras, message: string): StepExtras {
|
|
660
|
+
return {
|
|
661
|
+
...extras,
|
|
662
|
+
contextLines: [
|
|
663
|
+
...(extras.contextLines ?? []).filter((l) => !l.startsWith("自动重试:")),
|
|
664
|
+
`自动重试:本步骤上一次中断了(${message.slice(0, 200)})。工作区里可能留有上一次没做完的改动,先用 git status 和 git diff 查看,在此基础上继续,不要重复提交`,
|
|
665
|
+
],
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
|
|
587
669
|
async function runStepOnce(
|
|
588
670
|
ctx: PipelineContext,
|
|
589
671
|
id: StepId,
|
|
@@ -604,7 +686,11 @@ async function runStepOnce(
|
|
|
604
686
|
const resultRel = `${ctx.featureDir}/.pipeline/results/${key}.json`;
|
|
605
687
|
const resultAbs = path.join(stepRepo, resultRel);
|
|
606
688
|
fs.mkdirSync(path.dirname(resultAbs), { recursive: true });
|
|
607
|
-
|
|
689
|
+
|
|
690
|
+
// An earlier run of this step may have left its agent working: use it, never run a second one.
|
|
691
|
+
const leftover = await checkLeftover(ctx, key, def, resultAbs);
|
|
692
|
+
if (leftover.done) return leftover.done;
|
|
693
|
+
if (!leftover.agent) fs.rmSync(resultAbs, { force: true });
|
|
608
694
|
|
|
609
695
|
// Services, the browser and the deployed environment belong to the steps in this repository.
|
|
610
696
|
const managed = target
|
|
@@ -638,10 +724,14 @@ async function runStepOnce(
|
|
|
638
724
|
error: null,
|
|
639
725
|
phase: phaseOf(ctx),
|
|
640
726
|
};
|
|
641
|
-
|
|
727
|
+
if (leftover.agent) {
|
|
728
|
+
record.agentId = leftover.agent.id;
|
|
729
|
+
record.reattached = leftover.reattached;
|
|
730
|
+
}
|
|
731
|
+
ctx.log(leftover.agent ? `继续等待 ${key}(${def.title})上一次的 agent ${leftover.agent.id}` : `开始 ${key}(${def.title})`);
|
|
642
732
|
|
|
643
733
|
try {
|
|
644
|
-
const agent = await ctx.runtime.startAgent({
|
|
734
|
+
const agent = leftover.agent ?? await ctx.runtime.startAgent({
|
|
645
735
|
role: def.role,
|
|
646
736
|
title: agentTitle,
|
|
647
737
|
prompt: buildPrompt(ctx, def, round, maxRounds, resultRel, extras),
|
|
@@ -660,7 +750,7 @@ async function runStepOnce(
|
|
|
660
750
|
ctx.state.history.push(record);
|
|
661
751
|
save(ctx);
|
|
662
752
|
|
|
663
|
-
const deadline = Date.now() + ctx.config.stepTimeoutMinutes[id] * 60_000;
|
|
753
|
+
const deadline: Deadline = { at: Date.now() + ctx.config.stepTimeoutMinutes[id] * 60_000, extended: false, key };
|
|
664
754
|
record.usage = (await waitForTurn(ctx, agent, def, agentTitle, deadline)) ?? record.usage ?? null;
|
|
665
755
|
let validation = readResultFile(resultAbs, def);
|
|
666
756
|
while (!validation.ok && record.followUps < ctx.config.resultFollowUps) {
|
|
@@ -672,7 +762,7 @@ async function runStepOnce(
|
|
|
672
762
|
validation = readResultFile(resultAbs, def);
|
|
673
763
|
}
|
|
674
764
|
if (!validation.ok) {
|
|
675
|
-
throw new
|
|
765
|
+
throw new ResultInvalid(def.title, `提醒 ${record.followUps} 次后仍没有得到有效的结果文件:${validation.error}`);
|
|
676
766
|
}
|
|
677
767
|
|
|
678
768
|
if (extras.gate) {
|
|
@@ -699,6 +789,7 @@ async function runStepOnce(
|
|
|
699
789
|
record.outcome = "failed";
|
|
700
790
|
record.endedAt = new Date().toISOString();
|
|
701
791
|
record.error = (error as Error).message;
|
|
792
|
+
if (error instanceof AgentLeftRunning) record.leftRunning = true;
|
|
702
793
|
if (!ctx.state.history.includes(record)) ctx.state.history.push(record);
|
|
703
794
|
save(ctx);
|
|
704
795
|
if (error instanceof StepFailure) throw error;
|
|
@@ -706,6 +797,72 @@ async function runStepOnce(
|
|
|
706
797
|
}
|
|
707
798
|
}
|
|
708
799
|
|
|
800
|
+
/**
|
|
801
|
+
* Before a step starts an agent, looks at the agent an earlier run of the same step left
|
|
802
|
+
* working (a timeout, unreadable status, or a pipeline process that died mid-step):
|
|
803
|
+
* - it has finished and written a valid result: that result is the step's result;
|
|
804
|
+
* - it is still working: the step waits for it again (once); the second time it is stopped
|
|
805
|
+
* and a new agent starts, so a stuck agent does not hold the step forever;
|
|
806
|
+
* - it cannot be reached, or finished without a valid result: a new agent starts.
|
|
807
|
+
* Two agents on one step would edit the same files, commit in the same worktree and write
|
|
808
|
+
* the same result file, so a new one never starts while the old one may be working.
|
|
809
|
+
*/
|
|
810
|
+
async function checkLeftover(
|
|
811
|
+
ctx: PipelineContext,
|
|
812
|
+
key: string,
|
|
813
|
+
def: StepDef,
|
|
814
|
+
resultAbs: string,
|
|
815
|
+
): Promise<{ done?: Record<string, unknown>; agent?: StepAgent; reattached?: number }> {
|
|
816
|
+
const last = [...ctx.state.history].reverse().find((h) => h.key === key);
|
|
817
|
+
if (!last?.agentId || last.outcome === "completed") return {};
|
|
818
|
+
if (last.outcome !== "running" && !last.leftRunning) return {};
|
|
819
|
+
last.leftRunning = false;
|
|
820
|
+
if (last.outcome === "running") {
|
|
821
|
+
last.outcome = "failed";
|
|
822
|
+
last.endedAt = new Date().toISOString();
|
|
823
|
+
last.error ??= "流水线进程在这一步运行时退出";
|
|
824
|
+
}
|
|
825
|
+
save(ctx);
|
|
826
|
+
const agent = ctx.runtime.attachAgent(last.agentId);
|
|
827
|
+
let status: WaitOutcome["status"] | "unreachable";
|
|
828
|
+
try {
|
|
829
|
+
status = (await agent.wait(Math.min(5_000, ctx.config.waitChunkSeconds * 1000))).status;
|
|
830
|
+
} catch (error) {
|
|
831
|
+
ctx.log(`无法读取 ${key} 上一次的 agent ${last.agentId} 的状态(${(error as Error).message}),新建一个 agent`);
|
|
832
|
+
status = "unreachable";
|
|
833
|
+
}
|
|
834
|
+
if (status === "idle") {
|
|
835
|
+
const validation = readResultFile(resultAbs, def);
|
|
836
|
+
if (validation.ok) {
|
|
837
|
+
last.outcome = "completed";
|
|
838
|
+
last.endedAt = new Date().toISOString();
|
|
839
|
+
last.error = null;
|
|
840
|
+
last.result = validation.value;
|
|
841
|
+
ctx.state.results[key] = validation.value;
|
|
842
|
+
save(ctx);
|
|
843
|
+
recover(ctx, key, "reuse_result", `上一次的 agent ${last.agentId} 已经做完,沿用它的结果`);
|
|
844
|
+
if (ctx.config.archiveStepAgents) await agent.archive().catch(() => undefined);
|
|
845
|
+
return { done: validation.value };
|
|
846
|
+
}
|
|
847
|
+
ctx.log(`${key} 上一次的 agent ${last.agentId} 已经停下,但没有有效的结果文件(${validation.error}),新建一个 agent`);
|
|
848
|
+
return {};
|
|
849
|
+
}
|
|
850
|
+
if (status === "timeout" || status === "permission") {
|
|
851
|
+
if ((last.reattached ?? 0) < 1) {
|
|
852
|
+
recover(ctx, key, "reattach", `上一次的 agent ${last.agentId} 还在做,接着等它,不另起一个`);
|
|
853
|
+
return { agent, reattached: (last.reattached ?? 0) + 1 };
|
|
854
|
+
}
|
|
855
|
+
ctx.log(`${key} 上一次的 agent ${last.agentId} 已经等过两轮仍未完成,停止它并新建一个 agent`);
|
|
856
|
+
await ctx.runtime
|
|
857
|
+
.interruptAgent(last.agentId, "这一步已超时,流水线会另起一个 agent 重做。请立即停止当前工作,不要再修改任何文件,也不要写结果文件,直接结束。")
|
|
858
|
+
.catch((error: Error) => ctx.log(`无法停止 agent ${last.agentId}:${error.message}`));
|
|
859
|
+
// Give it a moment to wind down before the new agent touches the same files.
|
|
860
|
+
await agent.wait(Math.min(60_000, ctx.config.waitChunkSeconds * 1000 * 2)).catch(() => undefined);
|
|
861
|
+
return {};
|
|
862
|
+
}
|
|
863
|
+
return {};
|
|
864
|
+
}
|
|
865
|
+
|
|
709
866
|
/** phases.verify.agents applies to the verify-phase steps. */
|
|
710
867
|
function phaseAgent(ctx: PipelineContext, id: StepId): AgentProfile | undefined {
|
|
711
868
|
if (!VERIFY_PHASE_STEPS.includes(id)) return undefined;
|
|
@@ -948,6 +1105,26 @@ async function scheduleApprovalContinue(ctx: PipelineContext): Promise<string |
|
|
|
948
1105
|
}
|
|
949
1106
|
|
|
950
1107
|
/** A Paseo schedule that has the chat continue the run later; returns the time as text, or null. */
|
|
1108
|
+
/** Rate limits and used-up subscriptions, as opposed to network trouble. */
|
|
1109
|
+
const QUOTA_ERROR = /rate.?limit|too many requests|\b429\b|quota|usage limit|额度|限流/i;
|
|
1110
|
+
|
|
1111
|
+
/**
|
|
1112
|
+
* 1.12: when a step stopped on rate limits and Paseo says when a used-up subscription window
|
|
1113
|
+
* resets, the time to continue (5 minutes after the reset, within 24 hours); null otherwise.
|
|
1114
|
+
*/
|
|
1115
|
+
async function quotaResetTime(ctx: PipelineContext, reason: string): Promise<Date | null> {
|
|
1116
|
+
if (!QUOTA_ERROR.test(reason)) return null;
|
|
1117
|
+
const profiles = [ctx.config.agents.builder, ctx.config.agents.reviewer, ...(ctx.config.agents.fallback ? [ctx.config.agents.fallback] : [])];
|
|
1118
|
+
const providers = [...new Set(profiles.map((p) => (p.provider ?? "").split("/")[0]).filter(Boolean))];
|
|
1119
|
+
const quotas = await ctx.runtime.providerQuota(providers).catch(() => null);
|
|
1120
|
+
const now = Date.now();
|
|
1121
|
+
const resets = (quotas ?? [])
|
|
1122
|
+
.filter((q) => q.resetsAt && ((q.usedPct !== null && q.usedPct >= 95) || q.tone === "danger"))
|
|
1123
|
+
.map((q) => new Date(q.resetsAt!).getTime())
|
|
1124
|
+
.filter((t) => t > now && t < now + 24 * 3600_000);
|
|
1125
|
+
return resets.length === 0 ? null : new Date(Math.max(...resets) + 5 * 60_000);
|
|
1126
|
+
}
|
|
1127
|
+
|
|
951
1128
|
async function scheduleAutoResume(ctx: PipelineContext, reason: string): Promise<string | null> {
|
|
952
1129
|
const minutes = ctx.config.autoResumeMinutes;
|
|
953
1130
|
const origin = ctx.state.originAgentId;
|
|
@@ -957,7 +1134,8 @@ async function scheduleAutoResume(ctx: PipelineContext, reason: string): Promise
|
|
|
957
1134
|
ctx.log(`这次尝试已经自动继续过 ${used} 次,不再预约`);
|
|
958
1135
|
return null;
|
|
959
1136
|
}
|
|
960
|
-
const
|
|
1137
|
+
const reset = await quotaResetTime(ctx, reason);
|
|
1138
|
+
const at = reset ?? new Date(Date.now() + minutes * 60_000);
|
|
961
1139
|
try {
|
|
962
1140
|
const id = await ctx.runtime.scheduleMessage({
|
|
963
1141
|
agentId: origin,
|
|
@@ -974,7 +1152,9 @@ async function scheduleAutoResume(ctx: PipelineContext, reason: string): Promise
|
|
|
974
1152
|
ctx.state.schedules = [...(ctx.state.schedules ?? []), { id, kind: "auto-resume", at: at.toISOString() }];
|
|
975
1153
|
ctx.state.autoResumes = { attempt: ctx.state.attempt, count: used + 1 };
|
|
976
1154
|
save(ctx);
|
|
977
|
-
|
|
1155
|
+
const when = at.toLocaleString("zh-CN", { hour12: false });
|
|
1156
|
+
if (reset) recover(ctx, "pipeline", "quota_wait", `额度用完,Paseo 显示会恢复;已预约在 ${when} 自动继续(Paseo 定时任务 ${id})`);
|
|
1157
|
+
else recover(ctx, "pipeline", "auto_resume", `已预约 ${minutes} 分钟后自动继续(Paseo 定时任务 ${id})`);
|
|
978
1158
|
return at.toLocaleString("zh-CN", { hour12: false, month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" });
|
|
979
1159
|
} catch (error) {
|
|
980
1160
|
ctx.log(`预约自动继续失败:${(error as Error).message}`);
|
|
@@ -1005,7 +1185,7 @@ async function checkQuota(ctx: PipelineContext): Promise<void> {
|
|
|
1005
1185
|
[
|
|
1006
1186
|
"【需求开发流水线通知】这条消息来自后台流水线,不是用户发的。",
|
|
1007
1187
|
`额度提醒:${items.join(";")}。`,
|
|
1008
|
-
"
|
|
1188
|
+
`流水线照常进行;额度用完时会遇到限流,流水线会先重试${ctx.config.agents.fallback ? "、再换备用模型" : ""},仍不行就预约在额度恢复后自动继续,不需要人处理。如果希望等额度恢复后再开发,请转告用户,用户同意的话可以先暂停,再预约在恢复时间开始。`,
|
|
1009
1189
|
].join("\n"),
|
|
1010
1190
|
);
|
|
1011
1191
|
}
|
|
@@ -1492,11 +1672,33 @@ async function assessmentPhase(ctx: PipelineContext): Promise<void> {
|
|
|
1492
1672
|
for (let r = 1; r <= rounds.designReview; r++) {
|
|
1493
1673
|
const review = await runReview(ctx, "design_review", r, rounds.designReview);
|
|
1494
1674
|
if (review.verdict === "approved") break;
|
|
1495
|
-
if (review.verdict === "blocked")
|
|
1675
|
+
if (review.verdict === "blocked") {
|
|
1676
|
+
await answerFirst(ctx, review, r, rounds.designReview);
|
|
1677
|
+
continue;
|
|
1678
|
+
}
|
|
1496
1679
|
await runStep(ctx, "design_revise", r, rounds.designReview);
|
|
1497
1680
|
}
|
|
1498
1681
|
}
|
|
1499
1682
|
|
|
1683
|
+
/**
|
|
1684
|
+
* The review says only the requester can decide (1.12): the builder first answers what the
|
|
1685
|
+
* code, the documents and the spec settle, in a revision of the design; what is left goes to
|
|
1686
|
+
* the requester, each with a recommended answer.
|
|
1687
|
+
*/
|
|
1688
|
+
async function answerFirst(ctx: PipelineContext, review: Record<string, unknown>, round: number, maxRounds: number): Promise<void> {
|
|
1689
|
+
const questions = (review.questions as string[]) ?? [];
|
|
1690
|
+
if (!ctx.config.answerQuestionsFirst) throw new BusinessDecisionNeeded(questions);
|
|
1691
|
+
const revised = await runStep(ctx, "design_revise", round, maxRounds, {
|
|
1692
|
+
contextLines: [`审核结论:需要需求方决定(${questions.length} 个问题,见 design-review.md)。按本步骤说明中"审核要求需求方决定时"一节,先自己找答案`],
|
|
1693
|
+
});
|
|
1694
|
+
const left = Array.isArray(revised.business_questions) ? (revised.business_questions as string[]).map((q) => q.trim()).filter(Boolean) : [];
|
|
1695
|
+
const answered = Math.max(0, questions.length - left.length);
|
|
1696
|
+
if (answered > 0) {
|
|
1697
|
+
recover(ctx, `design_revise-r${round}`, "self_answer", `方案审核提出的 ${questions.length} 个问题中,${answered} 个由 builder 从代码和文档中确定了答案${left.length > 0 ? `,${left.length} 个交给需求方` : ",不用等需求方"}`);
|
|
1698
|
+
}
|
|
1699
|
+
if (left.length > 0) throw new BusinessDecisionNeeded(left);
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1500
1702
|
/** Develop phase: design → design review ⇄ revise → implement → code review ⇄ fix → handoff. Returns the design result. */
|
|
1501
1703
|
async function developPhase(ctx: PipelineContext): Promise<Record<string, unknown>> {
|
|
1502
1704
|
const rounds = ctx.config.profiles[ctx.state.profile].rounds;
|
|
@@ -1527,7 +1729,10 @@ async function developPhase(ctx: PipelineContext): Promise<Record<string, unknow
|
|
|
1527
1729
|
const review = await runReview(ctx, "design_review", r, rounds.designReview);
|
|
1528
1730
|
designRounds = r;
|
|
1529
1731
|
if (review.verdict === "approved") break;
|
|
1530
|
-
if (review.verdict === "blocked")
|
|
1732
|
+
if (review.verdict === "blocked") {
|
|
1733
|
+
await answerFirst(ctx, review, r, rounds.designReview);
|
|
1734
|
+
continue;
|
|
1735
|
+
}
|
|
1531
1736
|
// changes_required. The last round's revision goes straight to implementation;
|
|
1532
1737
|
// the implementer reads both the review and the revision record.
|
|
1533
1738
|
await runStep(ctx, "design_revise", r, rounds.designReview);
|
|
@@ -1801,6 +2006,7 @@ async function flow(ctx: PipelineContext): Promise<Record<string, unknown>> {
|
|
|
1801
2006
|
export async function runPipeline(ctx: PipelineContext): Promise<PipelineOutcome> {
|
|
1802
2007
|
ctx.state.status = "running";
|
|
1803
2008
|
ctx.state.stopReason = null;
|
|
2009
|
+
ctx.state.stopKind = null;
|
|
1804
2010
|
save(ctx);
|
|
1805
2011
|
await integrate(ctx, phaseOf(ctx) === "verify" ? "verify" : "started");
|
|
1806
2012
|
try {
|
|
@@ -1842,6 +2048,7 @@ export async function runPipeline(ctx: PipelineContext): Promise<PipelineOutcome
|
|
|
1842
2048
|
if (error instanceof PhaseGate) {
|
|
1843
2049
|
ctx.state.status = "phase_done";
|
|
1844
2050
|
ctx.state.stopReason = null;
|
|
2051
|
+
ctx.state.stopKind = "phase_gate";
|
|
1845
2052
|
save(ctx);
|
|
1846
2053
|
await stopManagedServices(ctx);
|
|
1847
2054
|
const verifyAt = await scheduleVerifyStart(ctx);
|
|
@@ -1872,6 +2079,7 @@ export async function runPipeline(ctx: PipelineContext): Promise<PipelineOutcome
|
|
|
1872
2079
|
if (error instanceof ApprovalNeeded) {
|
|
1873
2080
|
ctx.state.status = "waiting_for_approval";
|
|
1874
2081
|
ctx.state.stopReason = null;
|
|
2082
|
+
ctx.state.stopKind = "approval";
|
|
1875
2083
|
save(ctx);
|
|
1876
2084
|
const autoContinueAt = await scheduleApprovalContinue(ctx);
|
|
1877
2085
|
const reminderHours = await scheduleReminder(ctx, "approval");
|
|
@@ -1899,6 +2107,7 @@ export async function runPipeline(ctx: PipelineContext): Promise<PipelineOutcome
|
|
|
1899
2107
|
if (error instanceof BusinessDecisionNeeded) {
|
|
1900
2108
|
const reproduce = error.source === "reproduce";
|
|
1901
2109
|
ctx.state.status = "waiting_for_business";
|
|
2110
|
+
ctx.state.stopKind = reproduce ? "reproduce" : "business";
|
|
1902
2111
|
ctx.state.stopReason = `${reproduce ? "没能复现缺陷,需要需求方补充信息" : "方案审核需要需求方决定"}:${error.questions.join(";")}`;
|
|
1903
2112
|
save(ctx);
|
|
1904
2113
|
const reminderHours = await scheduleReminder(ctx, error.questions);
|
|
@@ -1919,6 +2128,7 @@ export async function runPipeline(ctx: PipelineContext): Promise<PipelineOutcome
|
|
|
1919
2128
|
const stepTitle = error instanceof StepFailure ? error.stepTitle : "流水线";
|
|
1920
2129
|
ctx.state.status = "failed";
|
|
1921
2130
|
ctx.state.stopReason = `${stepTitle}:${(error as Error).message}`;
|
|
2131
|
+
ctx.state.stopKind = failureKind(error);
|
|
1922
2132
|
save(ctx);
|
|
1923
2133
|
const autoResumeAt =
|
|
1924
2134
|
error instanceof StepFailure && isTransientError((error as Error).message) ? await scheduleAutoResume(ctx, (error as Error).message) : null;
|
package/engine/src/records.ts
CHANGED
|
@@ -165,6 +165,10 @@ export interface RunRecord {
|
|
|
165
165
|
/** The report's verdict when the run completed (completed, completed_with_issues, not_completed). */
|
|
166
166
|
outcome: string | null;
|
|
167
167
|
stopReason: string | null;
|
|
168
|
+
/** 1.12: why the run stopped for a person, as a code (absent in older records). */
|
|
169
|
+
stopKind?: string | null;
|
|
170
|
+
/** 1.12: what the pipeline handled by itself during the run, by kind (absent in older records). */
|
|
171
|
+
recoveries?: Record<string, number>;
|
|
168
172
|
};
|
|
169
173
|
agents: { builder: string | null; reviewer: string | null };
|
|
170
174
|
steps: StepUsageRecord[];
|
|
@@ -241,6 +245,10 @@ export function runRecord(input: {
|
|
|
241
245
|
status: state.status,
|
|
242
246
|
outcome: state.status === "completed" ? state.outcome : null,
|
|
243
247
|
stopReason: state.stopReason,
|
|
248
|
+
stopKind: state.status === "completed" || state.status === "running" ? null : (state.stopKind ?? null),
|
|
249
|
+
recoveries: (state.recoveries ?? [])
|
|
250
|
+
.filter((r) => new Date(r.at).getTime() >= since)
|
|
251
|
+
.reduce<Record<string, number>>((m, r) => ({ ...m, [r.kind]: (m[r.kind] ?? 0) + 1 }), {}),
|
|
244
252
|
},
|
|
245
253
|
agents: input.agents,
|
|
246
254
|
steps,
|
package/engine/src/runtime.ts
CHANGED
|
@@ -111,6 +111,8 @@ export interface AgentRuntime {
|
|
|
111
111
|
*/
|
|
112
112
|
verifyOrigin(agentId: string, cwd: string): Promise<void>;
|
|
113
113
|
startAgent(input: StartAgentInput): Promise<StepAgent>;
|
|
114
|
+
/** An agent started earlier (possibly by another pipeline process), to check on, wait for or follow up. */
|
|
115
|
+
attachAgent(agentId: string): StepAgent;
|
|
114
116
|
/**
|
|
115
117
|
* Starts a requirement's own chat (1.10): a top-level agent working in the repository, given
|
|
116
118
|
* `prompt` and not waited for. It is the chat the requirement's pipeline is later launched
|
package/engine/src/state.ts
CHANGED
|
@@ -27,6 +27,32 @@ export interface RunPhases {
|
|
|
27
27
|
defects: DefectHandling;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Why a run stopped for a person (1.12). Necessary decisions: approval (the plan), business
|
|
32
|
+
* (a question only the requester can answer), reproduce (a defect that could not be reproduced),
|
|
33
|
+
* phase_gate (development done, waiting for testing). The rest are interruptions.
|
|
34
|
+
*/
|
|
35
|
+
export type StopKind =
|
|
36
|
+
| "approval"
|
|
37
|
+
| "business"
|
|
38
|
+
| "reproduce"
|
|
39
|
+
| "phase_gate"
|
|
40
|
+
| "timeout"
|
|
41
|
+
| "agent_error"
|
|
42
|
+
| "invalid_result"
|
|
43
|
+
| "transient"
|
|
44
|
+
| "stopped"
|
|
45
|
+
| "process_exit"
|
|
46
|
+
| "other";
|
|
47
|
+
|
|
48
|
+
/** Something the pipeline handled by itself (1.12). */
|
|
49
|
+
export interface Recovery {
|
|
50
|
+
at: string;
|
|
51
|
+
key: string;
|
|
52
|
+
kind: "retry" | "reattach" | "reuse_result" | "extend_timeout" | "redo" | "fallback" | "auto_resume" | "quota_wait" | "self_answer";
|
|
53
|
+
detail: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
30
56
|
export interface StepRecord {
|
|
31
57
|
key: string;
|
|
32
58
|
stepId: StepId;
|
|
@@ -43,6 +69,13 @@ export interface StepRecord {
|
|
|
43
69
|
usage?: AgentUsage | null;
|
|
44
70
|
/** Phase the step ran in; absent before 1.7 (develop or verify by step). */
|
|
45
71
|
phase?: PhaseName;
|
|
72
|
+
/**
|
|
73
|
+
* 1.11: the step failed while its agent was still at work (timed out, or its status could
|
|
74
|
+
* not be read). The next run of the step checks that agent before starting another one.
|
|
75
|
+
*/
|
|
76
|
+
leftRunning?: boolean;
|
|
77
|
+
/** 1.11: how many times a later run went on waiting for this record's agent instead of starting a new one. */
|
|
78
|
+
reattached?: number;
|
|
46
79
|
}
|
|
47
80
|
|
|
48
81
|
export interface PipelineState {
|
|
@@ -62,6 +95,10 @@ export interface PipelineState {
|
|
|
62
95
|
results: Record<string, Record<string, unknown>>;
|
|
63
96
|
history: StepRecord[];
|
|
64
97
|
stopReason: string | null;
|
|
98
|
+
/** 1.12: why the run stopped for a person, as a code (contracts/usage-record.md); null while running or done. */
|
|
99
|
+
stopKind?: StopKind | null;
|
|
100
|
+
/** 1.12: what the pipeline handled by itself instead of stopping (retries, waiting longer, a fallback model…). */
|
|
101
|
+
recoveries?: Recovery[];
|
|
65
102
|
outcome: string | null;
|
|
66
103
|
/** "quick" or "standard"; decides loop rounds and optional steps. */
|
|
67
104
|
profile: ProfileName;
|
package/engine/src/stats.ts
CHANGED
|
@@ -109,6 +109,35 @@ function waitKind(status: string): WaitKind {
|
|
|
109
109
|
return "other";
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
/** Why runs stopped for a person (1.12, state.stopKind). */
|
|
113
|
+
export const STOP_LABELS: Record<string, string> = {
|
|
114
|
+
approval: "确认方案",
|
|
115
|
+
phase_gate: "研发完成,等待测试",
|
|
116
|
+
business: "方案审核的问题等需求方决定",
|
|
117
|
+
reproduce: "缺陷复现不了,等需求方补充",
|
|
118
|
+
timeout: "步骤超时",
|
|
119
|
+
agent_error: "agent 报错",
|
|
120
|
+
invalid_result: "结果文件无效",
|
|
121
|
+
transient: "限流、网络等临时错误(自动重试用完)",
|
|
122
|
+
stopped: "被手动停止",
|
|
123
|
+
process_exit: "后台进程意外退出",
|
|
124
|
+
other: "其他中断",
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/** Stops that are the decisions people are meant to make; the rest are what 1.12 works to remove. */
|
|
128
|
+
export const NECESSARY_STOPS = ["approval", "phase_gate"];
|
|
129
|
+
|
|
130
|
+
/** The stop code of a run; older records (before 1.12) are inferred from the status. */
|
|
131
|
+
export function stopKindOf(r: RunRecord): string | null {
|
|
132
|
+
if (r.run.stopKind !== undefined) return r.run.stopKind;
|
|
133
|
+
if (r.run.status === "waiting_for_approval") return "approval";
|
|
134
|
+
if (r.run.status === "waiting_for_business") return "business";
|
|
135
|
+
if (r.run.status === "phase_done") return "phase_gate";
|
|
136
|
+
if (r.run.status === "failed") return /手动停止|被 SIG/.test(r.run.stopReason ?? "") ? "stopped" : "other";
|
|
137
|
+
if (r.run.status === "running") return "process_exit";
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
112
141
|
/** Records of the same run seen twice (merged exports) count once. */
|
|
113
142
|
export function dedupe(runs: RunRecord[]): RunRecord[] {
|
|
114
143
|
const seen = new Map<string, RunRecord>();
|
|
@@ -208,6 +237,19 @@ export interface StatsSummary {
|
|
|
208
237
|
waits: Array<{ kind: WaitKind; label: string; requirements: number; averageMinutes: number }>;
|
|
209
238
|
usage: { agentMinutes: number; inputTokens: number | null; outputTokens: number | null; costUsd: number | null };
|
|
210
239
|
byBuilder: Array<{ builder: string; requirements: number; averageReviewRounds: number | null; averageCostUsd: number | null }>;
|
|
240
|
+
/** 1.12: why the period's runs stopped for a person, most frequent first. */
|
|
241
|
+
stops: Array<{ kind: string; label: string; necessary: boolean; runs: number }>;
|
|
242
|
+
/** 1.12: how much went without a person. */
|
|
243
|
+
automation: {
|
|
244
|
+
/** Of the requirements that reached testing (or completion) in the period, the share with no stop other than the necessary ones. */
|
|
245
|
+
unattendedShare: number | null;
|
|
246
|
+
/** Problems the pipeline handled by itself in the period's runs, by kind. */
|
|
247
|
+
recovered: Record<string, number>;
|
|
248
|
+
/** Runs that ended in an interruption (not a necessary decision). */
|
|
249
|
+
interrupted: number;
|
|
250
|
+
/** recovered / (recovered + interrupted). */
|
|
251
|
+
autoRecoveryShare: number | null;
|
|
252
|
+
};
|
|
211
253
|
/** Requirements that started from cards, by who asked for them (1.10); empty when there are none. */
|
|
212
254
|
byRequester: Array<{ requester: string; requirements: number; completed: number; medianCycleHours: number | null }>;
|
|
213
255
|
requirements: RequirementStats[];
|
|
@@ -257,6 +299,24 @@ export function summarizeRuns(all: RunRecord[], period: Period): StatsSummary {
|
|
|
257
299
|
const requesters = new Map<string, RequirementStats[]>();
|
|
258
300
|
for (const q of active) if (q.card) requesters.set(q.requester ?? "(未记录)", [...(requesters.get(q.requester ?? "(未记录)") ?? []), q]);
|
|
259
301
|
|
|
302
|
+
const stopCounts = count(periodRuns.flatMap((r) => (stopKindOf(r) ? [stopKindOf(r)!] : [])));
|
|
303
|
+
const recovered: Record<string, number> = {};
|
|
304
|
+
for (const r of periodRuns) for (const [k, v] of Object.entries(r.run.recoveries ?? {})) recovered[k] = (recovered[k] ?? 0) + v;
|
|
305
|
+
const recoveredTotal = Object.values(recovered).reduce((a, b) => a + b, 0);
|
|
306
|
+
const interrupted = periodRuns.filter((r) => {
|
|
307
|
+
const k = stopKindOf(r);
|
|
308
|
+
return k !== null && !NECESSARY_STOPS.includes(k) && k !== "business" && k !== "reproduce";
|
|
309
|
+
}).length;
|
|
310
|
+
const reachedTesting = active.filter((q) => q.status === "completed" || q.status === "phase_done");
|
|
311
|
+
const unattended = reachedTesting.filter((q) =>
|
|
312
|
+
runs
|
|
313
|
+
.filter((r) => r.requirement.key === q.key && r.requirement.project === q.project)
|
|
314
|
+
.every((r) => {
|
|
315
|
+
const k = stopKindOf(r);
|
|
316
|
+
return k === null || NECESSARY_STOPS.includes(k);
|
|
317
|
+
}),
|
|
318
|
+
);
|
|
319
|
+
|
|
260
320
|
return {
|
|
261
321
|
schema: 1,
|
|
262
322
|
period: { from: period.from?.toISOString() ?? null, to: period.to?.toISOString() ?? null, label: period.label },
|
|
@@ -287,6 +347,15 @@ export function summarizeRuns(all: RunRecord[], period: Period): StatsSummary {
|
|
|
287
347
|
averageReviewRounds: avg(qs.map((q) => q.reviewRounds)),
|
|
288
348
|
averageCostUsd: avg(qs.flatMap((q) => (q.costUsd === null ? [] : [q.costUsd]))),
|
|
289
349
|
})),
|
|
350
|
+
stops: Object.entries(stopCounts)
|
|
351
|
+
.map(([kind, n]) => ({ kind, label: STOP_LABELS[kind] ?? kind, necessary: NECESSARY_STOPS.includes(kind), runs: n }))
|
|
352
|
+
.sort((a, b) => b.runs - a.runs || a.kind.localeCompare(b.kind)),
|
|
353
|
+
automation: {
|
|
354
|
+
unattendedShare: reachedTesting.length === 0 ? null : Math.round((unattended.length / reachedTesting.length) * 100) / 100,
|
|
355
|
+
recovered,
|
|
356
|
+
interrupted,
|
|
357
|
+
autoRecoveryShare: recoveredTotal + interrupted === 0 ? null : Math.round((recoveredTotal / (recoveredTotal + interrupted)) * 100) / 100,
|
|
358
|
+
},
|
|
290
359
|
byRequester: [...requesters.entries()]
|
|
291
360
|
.map(([requester, qs]) => {
|
|
292
361
|
const finished = qs.filter((q) => done.includes(q));
|
|
@@ -349,6 +418,27 @@ export function describeStats(s: StatsSummary): string {
|
|
|
349
418
|
`- 需要缺陷修复的需求占比:${s.rework.withDefectRounds === null ? "无数据" : `${Math.round(s.rework.withDefectRounds * 100)}%`}`,
|
|
350
419
|
`- 质量门:运行 ${s.rework.gateRuns} 次,其中发现新失败 ${s.rework.gateFailed} 次`,
|
|
351
420
|
);
|
|
421
|
+
const RECOVERY_LABELS: Record<string, string> = {
|
|
422
|
+
retry: "临时错误重试",
|
|
423
|
+
reattach: "接着等还在做的 agent",
|
|
424
|
+
reuse_result: "沿用旧 agent 的结果",
|
|
425
|
+
extend_timeout: "超时后多等一轮",
|
|
426
|
+
redo: "新开 agent 重做",
|
|
427
|
+
fallback: "换备用模型",
|
|
428
|
+
auto_resume: "自动继续",
|
|
429
|
+
quota_wait: "等额度恢复后继续",
|
|
430
|
+
};
|
|
431
|
+
lines.push(
|
|
432
|
+
"",
|
|
433
|
+
"## 人工介入",
|
|
434
|
+
"",
|
|
435
|
+
`- 到达提测(或完成)的需求中,除了确认方案外没有停下等人的:${s.automation.unattendedShare === null ? "无数据" : `${Math.round(s.automation.unattendedShare * 100)}%`}`,
|
|
436
|
+
`- 自动处理的问题 ${Object.values(s.automation.recovered).reduce((a, b) => a + b, 0)} 次(${list(s.automation.recovered, RECOVERY_LABELS)}),中断等人 ${s.automation.interrupted} 次${s.automation.autoRecoveryShare === null ? "" : `,自动处理的比例 ${Math.round(s.automation.autoRecoveryShare * 100)}%`}`,
|
|
437
|
+
);
|
|
438
|
+
if (s.stops.length > 0) {
|
|
439
|
+
lines.push("", "| 停下等人的原因 | 次数 | 必要的决定 |", "| --- | --- | --- |");
|
|
440
|
+
for (const st of s.stops) lines.push(`| ${st.label} | ${st.runs} | ${st.necessary ? "是" : "否"} |`);
|
|
441
|
+
}
|
|
352
442
|
if (s.waits.length > 0) {
|
|
353
443
|
lines.push("", "## 等待", "", "| 等待 | 需求数 | 平均 |", "| --- | --- | --- |");
|
|
354
444
|
for (const w of s.waits) lines.push(`| ${w.label} | ${w.requirements} | ${hours(w.averageMinutes)} |`);
|