paseo-req-kit 0.0.0-stage → 1.7.0-beta.3
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 +217 -0
- package/LICENSE +21 -0
- package/README.md +141 -2
- package/SCOPE.md +11 -0
- package/bin/locate.mjs +46 -0
- package/bin/req-doctor.mjs +21 -0
- package/bin/req.mjs +175 -0
- package/bin/start-pipeline +21 -0
- package/bin/start-pipeline.mjs +22 -0
- package/config.example.json +101 -0
- package/contracts/README.md +18 -0
- package/contracts/feature-files.md +87 -0
- package/contracts/notifier-event.md +71 -0
- package/contracts/result-file.md +17 -0
- package/contracts/spec.md +34 -0
- package/contracts/state.md +44 -0
- package/contracts/verify.md +39 -0
- package/docs/adr/0001-step-agents-under-origin-chat.md +15 -0
- package/docs/adr/0002-no-directory-switch-in-chat.md +15 -0
- package/docs/adr/0003-script-engine-not-hub.md +15 -0
- package/docs/adr/0004-result-files.md +15 -0
- package/docs/adr/0005-no-default-branch-local-commits-only.md +15 -0
- package/docs/adr/0006-skill-install-targets.md +15 -0
- package/docs/adr/0007-own-interview-skill.md +15 -0
- package/docs/adr/0008-entries-do-not-call-entries.md +15 -0
- package/docs/adr/0009-versioned-install-layout.md +15 -0
- package/docs/adr/0010-generated-feature-overview.md +15 -0
- package/docs/adr/0011-resume-after-completion.md +15 -0
- package/docs/adr/0012-review-panel.md +18 -0
- package/docs/adr/0013-no-release-tooling.md +15 -0
- package/docs/adr/0014-notifier-slot.md +19 -0
- package/docs/adr/0015-incremental-continue.md +17 -0
- package/docs/adr/0016-upgrade-from-tags.md +19 -0
- package/docs/adr/0017-use-paseo-capabilities.md +18 -0
- package/docs/adr/0018-own-worktree-option.md +21 -0
- package/docs/adr/0019-parallel-orchestration.md +21 -0
- package/docs/adr/0020-scheduling-through-the-chat.md +21 -0
- package/docs/adr/0021-npm-version-manager.md +19 -0
- package/docs/adr/0022-develop-and-verify-phases.md +32 -0
- package/docs/adr/README.md +26 -0
- package/docs/guide//344/275/277/347/224/250/350/200/205/346/214/207/345/215/227.md +78 -0
- package/docs/guide//345/256/211/350/243/205/344/270/216/345/215/207/347/272/247.md +348 -0
- package/docs/guide//347/273/264/346/212/244/350/200/205/346/214/207/345/215/227.md +85 -0
- 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 +251 -0
- package/engine/prompts/00-shared-context.md +100 -0
- package/engine/prompts/02-spec-review.md +46 -0
- package/engine/prompts/03-design.md +50 -0
- package/engine/prompts/03a-design-proposal.md +30 -0
- package/engine/prompts/04-design-review.md +72 -0
- package/engine/prompts/04b-design-revise.md +34 -0
- package/engine/prompts/05-implement.md +66 -0
- package/engine/prompts/06-code-review.md +66 -0
- package/engine/prompts/07-review-fix.md +41 -0
- package/engine/prompts/07b-handoff.md +45 -0
- package/engine/prompts/08-service-start.md +59 -0
- package/engine/prompts/09-verify.md +90 -0
- package/engine/prompts/10-defect-fix.md +41 -0
- package/engine/prompts/10b-reverify.md +43 -0
- package/engine/prompts/11-report.md +93 -0
- package/engine/prompts/browser-verify.md +13 -0
- package/engine/prompts/incremental.md +21 -0
- package/engine/prompts/panel-member.md +38 -0
- package/engine/prompts/paseo-services.md +9 -0
- package/engine/prompts/verify-deployed.md +14 -0
- package/engine/src/activity.ts +87 -0
- package/engine/src/cli-args.ts +204 -0
- package/engine/src/config.ts +479 -0
- package/engine/src/configure.ts +237 -0
- package/engine/src/control.ts +230 -0
- package/engine/src/daemon-check.ts +104 -0
- package/engine/src/daemon-version.ts +66 -0
- package/engine/src/digest.ts +133 -0
- package/engine/src/doctor.ts +435 -0
- package/engine/src/eval.ts +324 -0
- package/engine/src/features.ts +268 -0
- package/engine/src/git.ts +30 -0
- package/engine/src/launch.ts +405 -0
- package/engine/src/locations.ts +55 -0
- package/engine/src/main.ts +313 -0
- package/engine/src/messages.ts +308 -0
- package/engine/src/notifiers.ts +76 -0
- package/engine/src/notify-test.ts +97 -0
- package/engine/src/paseo-json.ts +73 -0
- package/engine/src/paseo-profiles.ts +133 -0
- package/engine/src/paseo-runtime.ts +437 -0
- package/engine/src/paths.ts +10 -0
- package/engine/src/pipeline.ts +1126 -0
- package/engine/src/preflight.ts +298 -0
- package/engine/src/prompts.ts +72 -0
- package/engine/src/queue.ts +61 -0
- package/engine/src/runtime-loader.ts +14 -0
- package/engine/src/runtime.ts +155 -0
- package/engine/src/schedule-time.ts +51 -0
- package/engine/src/smoke-test.ts +202 -0
- package/engine/src/spec-review.ts +100 -0
- package/engine/src/state.ts +278 -0
- package/engine/src/steps.ts +242 -0
- package/engine/src/usage.ts +67 -0
- package/engine/src/worktree.ts +72 -0
- package/install.mjs +928 -0
- package/notifiers/im-template.mjs +58 -0
- package/package.json +67 -4
- package/principles/INDEX.md +11 -0
- package/skills/acceptance-criteria/SKILL.md +52 -0
- package/skills/principle-delete-before-add/SKILL.md +33 -0
- package/skills/principle-dont-block-on-humans/SKILL.md +35 -0
- package/skills/principle-fix-root-cause/SKILL.md +33 -0
- package/skills/principle-prove-it-works/SKILL.md +33 -0
- package/skills/principle-report-honestly/SKILL.md +33 -0
- package/skills/principle-speak-to-reader/SKILL.md +33 -0
- package/skills/principle-this-requirement-only/SKILL.md +33 -0
- package/skills/req/SKILL.md +99 -0
- package/skills/req/playbooks/accept.md +65 -0
- package/skills/req/playbooks/change.md +51 -0
- package/skills/req/playbooks/incident.md +55 -0
- package/skills/req/playbooks/new.md +83 -0
- package/skills/req/playbooks/small.md +48 -0
- package/skills/req/playbooks/status.md +39 -0
- package/skills/req/playbooks/takeover.md +51 -0
- package/skills/req/playbooks/verify.md +57 -0
- package/skills/req-doctor/SKILL.md +36 -0
- package/skills/req-retro/SKILL.md +79 -0
- package/skills/req-setup/SKILL.md +101 -0
- package/skills/req-setup/templates/paseo/copy-local-files.mjs +32 -0
- package/skills/req-setup/templates/paseo/with-port.mjs +36 -0
- package/skills/requirement-interview/SKILL.md +94 -0
- package/skills/requirement-kickoff/SKILL.md +17 -0
- package/skills/result-report/SKILL.md +34 -0
- package/skills/spec-writing/SKILL.md +41 -0
- package/skills/spec-writing/spec-template.md +106 -0
|
@@ -0,0 +1,1126 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { type AgentProfile, PHASE_MODE_LABELS, PROFILE_LABELS, type PipelineConfig, type ReviewPanelMember } from "./config.js";
|
|
5
|
+
import {
|
|
6
|
+
autoResumePrompt,
|
|
7
|
+
blockedMessage,
|
|
8
|
+
waitingReminderMessage,
|
|
9
|
+
completedMessage,
|
|
10
|
+
failedMessage,
|
|
11
|
+
phaseDoneMessage,
|
|
12
|
+
phaseWaitingReminderMessage,
|
|
13
|
+
verifyStartPrompt,
|
|
14
|
+
milestoneMessage,
|
|
15
|
+
permissionMessage,
|
|
16
|
+
resultFollowUp,
|
|
17
|
+
} from "./messages.js";
|
|
18
|
+
import { kitVersion } from "./locations.js";
|
|
19
|
+
import { type AgentUsage, describeTotals, totals } from "./usage.js";
|
|
20
|
+
import { dispatchNotifiers, type NotifierEventType } from "./notifiers.js";
|
|
21
|
+
import { principlesSection, projectAdditions, type PromptLocations, readToolPrompt } from "./prompts.js";
|
|
22
|
+
import type { AgentRuntime, ManagedService, ServiceTarget, StepAgent } from "./runtime.js";
|
|
23
|
+
import { type PhaseName, type PipelineState, type RunPhases, type StepRecord, writeState } from "./state.js";
|
|
24
|
+
import { type PanelStep, STEPS, type StepDef, type StepId, stepKey, validateResult, VERIFY_PHASE_STEPS } from "./steps.js";
|
|
25
|
+
|
|
26
|
+
export interface PipelineContext {
|
|
27
|
+
repo: string;
|
|
28
|
+
featureDir: string;
|
|
29
|
+
config: PipelineConfig;
|
|
30
|
+
runtime: AgentRuntime;
|
|
31
|
+
state: PipelineState;
|
|
32
|
+
statePath: string;
|
|
33
|
+
prompts: PromptLocations;
|
|
34
|
+
logPath: string;
|
|
35
|
+
resumeCommand: string;
|
|
36
|
+
/** Command to see progress; used in prompts that Paseo schedules send to the chat. */
|
|
37
|
+
statusCommand?: string;
|
|
38
|
+
/** Command that starts the verify phase (gated runs). */
|
|
39
|
+
verifyCommand?: string;
|
|
40
|
+
log: (message: string) => void;
|
|
41
|
+
sleep: (ms: number) => Promise<void>;
|
|
42
|
+
/** Paseo-managed services of this run (filled in by the pipeline). */
|
|
43
|
+
services?: { names: string[] | null | undefined; started: Set<string> };
|
|
44
|
+
/** Paseo tools the step agents get; looked up once (undefined: not yet). */
|
|
45
|
+
capabilities?: { paseoTools: boolean; browser: boolean } | null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type PipelineOutcome = "completed" | "waiting_for_business" | "failed" | "phase_done";
|
|
49
|
+
|
|
50
|
+
/** The develop phase is done and the run stops at the gate (phases.mode "gated"). */
|
|
51
|
+
class PhaseGate extends Error {
|
|
52
|
+
constructor() {
|
|
53
|
+
super("研发完成,待测试验证");
|
|
54
|
+
this.name = "PhaseGate";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const DEFAULT_PHASES: RunPhases = { mode: "continuous", environment: "local", defects: "fix" };
|
|
59
|
+
|
|
60
|
+
export function runPhases(state: Pick<PipelineState, "phases">): RunPhases {
|
|
61
|
+
return state.phases ?? DEFAULT_PHASES;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const ENVIRONMENT_TEXT = { local: "本地启动服务验证", deployed: "已部署的测试环境(见 test-env.md)" } as const;
|
|
65
|
+
const DEFECTS_TEXT = { fix: "当场修复并复验", handback: "只记录,退回研发阶段修复后再提测", report: "只记录,不修复" } as const;
|
|
66
|
+
|
|
67
|
+
function phaseOf(ctx: PipelineContext): PhaseName {
|
|
68
|
+
return ctx.state.phase ?? "develop";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function verifyCommandOf(ctx: PipelineContext): string {
|
|
72
|
+
return ctx.verifyCommand ?? `${ctx.resumeCommand} --phase verify`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Ends the run early; the message is what the user-facing notification explains. */
|
|
76
|
+
class StepFailure extends Error {
|
|
77
|
+
constructor(
|
|
78
|
+
public readonly stepTitle: string,
|
|
79
|
+
message: string,
|
|
80
|
+
) {
|
|
81
|
+
super(message);
|
|
82
|
+
this.name = "StepFailure";
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Errors that usually go away by themselves: provider rate limits and overload,
|
|
88
|
+
* dropped connections, a restarting daemon. A step that fails this way is started
|
|
89
|
+
* again after a pause instead of stopping the whole run.
|
|
90
|
+
*/
|
|
91
|
+
const TRANSIENT_ERROR =
|
|
92
|
+
/rate.?limit|too many requests|\b429\b|overload|\b5(?:02|03|04|29)\b|temporarily|unavailable|ECONNRESET|ECONNREFUSED|ETIMEDOUT|EPIPE|socket hang up|network|disconnect|连接|无法获取 agent 状态/i;
|
|
93
|
+
|
|
94
|
+
export function isTransientError(message: string): boolean {
|
|
95
|
+
return TRANSIENT_ERROR.test(message);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
class BusinessDecisionNeeded extends Error {
|
|
99
|
+
constructor(public readonly questions: string[]) {
|
|
100
|
+
super("需要需求方决定");
|
|
101
|
+
this.name = "BusinessDecisionNeeded";
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function featureName(featureDir: string): string {
|
|
106
|
+
return path.basename(featureDir);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function save(ctx: PipelineContext): void {
|
|
110
|
+
writeState(ctx.statePath, ctx.state);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function notifyOrigin(ctx: PipelineContext, text: string): Promise<void> {
|
|
114
|
+
const origin = ctx.state.originAgentId;
|
|
115
|
+
ctx.log(`通知:\n${text}`);
|
|
116
|
+
if (!origin) {
|
|
117
|
+
ctx.log("没有发起对话的 agent ID,通知只写入日志。");
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
await ctx.runtime.notifyAgent(origin, text);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
// The chat may have been archived. The state file and log still hold everything.
|
|
124
|
+
ctx.log(`通知发送失败(agent ${origin}):${(error as Error).message}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Extra material for one run of a step: panel member details, or the panel's findings to consolidate. */
|
|
129
|
+
interface StepExtras {
|
|
130
|
+
key?: string;
|
|
131
|
+
title?: string;
|
|
132
|
+
profile?: AgentProfile;
|
|
133
|
+
/** Appended as extra lines inside <pipeline-context>. */
|
|
134
|
+
contextLines?: string[];
|
|
135
|
+
/** Prompt sections appended after the step task (before project additions). */
|
|
136
|
+
sections?: string[];
|
|
137
|
+
/** Prompt file to quote as the review criteria (panel members). */
|
|
138
|
+
criteriaFile?: string;
|
|
139
|
+
/** Step whose project additions (steps/<id>.md) apply; defaults to the step itself. */
|
|
140
|
+
additionsFor?: StepId;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function buildPrompt(
|
|
144
|
+
ctx: PipelineContext,
|
|
145
|
+
def: StepDef,
|
|
146
|
+
round: number | null,
|
|
147
|
+
maxRounds: number | null,
|
|
148
|
+
resultFile: string,
|
|
149
|
+
extras: StepExtras = {},
|
|
150
|
+
): string {
|
|
151
|
+
const sources = { ...ctx.prompts, repo: ctx.repo };
|
|
152
|
+
const profile = ctx.state.profile;
|
|
153
|
+
const lines = [
|
|
154
|
+
"<pipeline-context>",
|
|
155
|
+
`仓库目录:${ctx.repo}`,
|
|
156
|
+
`功能目录:${ctx.featureDir}`,
|
|
157
|
+
def.id === "spec_review" ? "工作分支:(本步骤不提交)" : `工作分支:${ctx.state.branch ?? "(不是 git 仓库,不需要提交)"}`,
|
|
158
|
+
`档位:${PROFILE_LABELS[profile]}${profile === "quick" ? "(小改动:各产物只写必要内容,保持简短)" : ""}`,
|
|
159
|
+
`本步骤:${def.title}(流水线第 ${def.number} 步)`,
|
|
160
|
+
];
|
|
161
|
+
if (round !== null && maxRounds !== null) lines.push(`轮次:第 ${round} 轮,最多 ${maxRounds} 轮`);
|
|
162
|
+
const phases = runPhases(ctx.state);
|
|
163
|
+
if (ctx.state.phases && phases.mode !== "continuous") {
|
|
164
|
+
lines.push(`执行方式:${PHASE_MODE_LABELS[phases.mode]}(当前阶段:${phaseOf(ctx) === "verify" ? "测试验证" : "研发"})`);
|
|
165
|
+
}
|
|
166
|
+
if (VERIFY_PHASE_STEPS.includes(def.id) || def.id === "report") {
|
|
167
|
+
if (phases.environment !== "local" || phases.defects !== "fix") {
|
|
168
|
+
lines.push(`测试验证:${ENVIRONMENT_TEXT[phases.environment]};发现缺陷时${DEFECTS_TEXT[phases.defects]}`);
|
|
169
|
+
}
|
|
170
|
+
lines.push(`提测说明:${ctx.featureDir}/handoff.md(研发阶段写的,先读它)`);
|
|
171
|
+
}
|
|
172
|
+
const inc = ctx.state.incremental;
|
|
173
|
+
if (inc) {
|
|
174
|
+
lines.push(
|
|
175
|
+
`增量继续:本次是第 ${ctx.state.attempt} 次尝试,接着第 ${inc.fromAttempt} 次的工作(${inc.reason})`,
|
|
176
|
+
`上一次是否已经写过代码:${inc.hadImplementation ? "是" : "否"}`,
|
|
177
|
+
`本次开始时的提交:${inc.baseCommit ?? "(不是 git 仓库)"}`,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
if (extras.contextLines) lines.push(...extras.contextLines);
|
|
181
|
+
lines.push(`结果文件:${resultFile}`, `结果文件绝对路径:${path.join(ctx.repo, resultFile)}`, "</pipeline-context>");
|
|
182
|
+
const parts = [readToolPrompt(sources, "00-shared-context.md")];
|
|
183
|
+
const principles = principlesSection(sources);
|
|
184
|
+
if (principles) parts.push(principles);
|
|
185
|
+
parts.push(readToolPrompt(sources, def.promptFile));
|
|
186
|
+
if (ctx.state.incremental) parts.push(readToolPrompt(sources, "incremental.md"));
|
|
187
|
+
if (extras.criteriaFile) {
|
|
188
|
+
parts.push(
|
|
189
|
+
[
|
|
190
|
+
"# 评审标准(引用自该评审步骤的说明)",
|
|
191
|
+
"",
|
|
192
|
+
"下面是这一轮评审本来的完整说明,用来确定评审什么、怎么判断级别。其中关于写哪个产物文件、结果文件写什么、是否提交的要求不适用于你,以上一节\"评审小组成员\"的说明为准。",
|
|
193
|
+
"",
|
|
194
|
+
readToolPrompt(sources, extras.criteriaFile),
|
|
195
|
+
].join("\n"),
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
if (extras.sections) parts.push(...extras.sections);
|
|
199
|
+
const extra = projectAdditions(sources, extras.additionsFor ?? def.id);
|
|
200
|
+
if (extra) parts.push(extra);
|
|
201
|
+
parts.push(lines.join("\n"));
|
|
202
|
+
return `${parts.join("\n\n")}\n`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The feature directory as the chat should see it: relative to its own directory normally,
|
|
207
|
+
* absolute when the requirement is developed in a worktree created for it.
|
|
208
|
+
*/
|
|
209
|
+
function shownFeatureDir(ctx: PipelineContext): string {
|
|
210
|
+
return ctx.state.worktree ? path.join(ctx.repo, ctx.featureDir).split(path.sep).join("/") : ctx.featureDir;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function specTitle(ctx: PipelineContext): string {
|
|
214
|
+
try {
|
|
215
|
+
const spec = fs.readFileSync(path.join(ctx.repo, ctx.featureDir, "spec.md"), "utf8");
|
|
216
|
+
return /^#\s+(.+)$/m.exec(spec)?.[1]?.trim() ?? featureName(ctx.featureDir);
|
|
217
|
+
} catch {
|
|
218
|
+
return featureName(ctx.featureDir);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Calls the configured external notifiers (e.g. an internal IM). Never throws. */
|
|
223
|
+
async function notifyExternal(ctx: PipelineContext, type: NotifierEventType, text: string, details: Record<string, unknown>): Promise<void> {
|
|
224
|
+
if (ctx.config.notifiers.length === 0) return;
|
|
225
|
+
await dispatchNotifiers(
|
|
226
|
+
ctx.config.notifiers,
|
|
227
|
+
{
|
|
228
|
+
schema: 1,
|
|
229
|
+
type,
|
|
230
|
+
at: new Date().toISOString(),
|
|
231
|
+
kitVersion: kitVersion(),
|
|
232
|
+
feature: { dir: ctx.featureDir, name: featureName(ctx.featureDir), title: specTitle(ctx) },
|
|
233
|
+
repo: ctx.repo,
|
|
234
|
+
branch: ctx.state.branch,
|
|
235
|
+
profile: ctx.state.profile,
|
|
236
|
+
originAgentId: ctx.state.originAgentId,
|
|
237
|
+
text,
|
|
238
|
+
details,
|
|
239
|
+
},
|
|
240
|
+
ctx.log,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Sends a progress note once per run attempt, when enabled. */
|
|
245
|
+
async function milestone(ctx: PipelineContext, id: string, text: string): Promise<void> {
|
|
246
|
+
if (!ctx.config.notifyMilestones || ctx.state.milestones.includes(id)) return;
|
|
247
|
+
ctx.state.milestones.push(id);
|
|
248
|
+
save(ctx);
|
|
249
|
+
await notifyOrigin(ctx, milestoneMessage(text));
|
|
250
|
+
await notifyExternal(ctx, "milestone", text, { milestone: id });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function waitForTurn(ctx: PipelineContext, agent: StepAgent, def: StepDef, agentTitle: string, deadline: number): Promise<AgentUsage | null> {
|
|
254
|
+
const notifiedPermissions = new Set<string>();
|
|
255
|
+
let consecutiveErrors = 0;
|
|
256
|
+
for (;;) {
|
|
257
|
+
const remaining = deadline - Date.now();
|
|
258
|
+
if (remaining <= 0) {
|
|
259
|
+
throw new StepFailure(
|
|
260
|
+
def.title,
|
|
261
|
+
`超过 ${ctx.config.stepTimeoutMinutes[def.id]} 分钟仍未完成。该 agent 可能仍在运行,可以在 Paseo 中查看「${agentTitle}」。`,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
let outcome;
|
|
265
|
+
try {
|
|
266
|
+
outcome = await agent.wait(Math.min(ctx.config.waitChunkSeconds * 1000, remaining));
|
|
267
|
+
consecutiveErrors = 0;
|
|
268
|
+
} catch (error) {
|
|
269
|
+
consecutiveErrors += 1;
|
|
270
|
+
ctx.log(`等待 ${agentTitle} 时出错(第 ${consecutiveErrors} 次):${(error as Error).message}`);
|
|
271
|
+
if (consecutiveErrors >= 5) {
|
|
272
|
+
throw new StepFailure(def.title, `连续 ${consecutiveErrors} 次无法获取 agent 状态:${(error as Error).message}`);
|
|
273
|
+
}
|
|
274
|
+
await ctx.sleep(Math.min(60_000, 5_000 * consecutiveErrors));
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
switch (outcome.status) {
|
|
278
|
+
case "idle":
|
|
279
|
+
return outcome.usage ?? null;
|
|
280
|
+
case "timeout":
|
|
281
|
+
continue;
|
|
282
|
+
case "error":
|
|
283
|
+
throw new StepFailure(def.title, `agent 报告错误:${outcome.error ?? "未知错误"}`);
|
|
284
|
+
case "permission": {
|
|
285
|
+
const fresh = outcome.pendingPermissions.filter((p) => !notifiedPermissions.has(p.id));
|
|
286
|
+
if (fresh.length > 0) {
|
|
287
|
+
for (const p of fresh) notifiedPermissions.add(p.id);
|
|
288
|
+
await notifyOrigin(
|
|
289
|
+
ctx,
|
|
290
|
+
permissionMessage({ stepTitle: def.title, agentTitle, requests: fresh.map((p) => p.title) }),
|
|
291
|
+
);
|
|
292
|
+
await notifyExternal(ctx, "permission", `「${def.title}」步骤在等待权限批准,批准前会一直停在这里。`, {
|
|
293
|
+
stepTitle: def.title,
|
|
294
|
+
agentTitle,
|
|
295
|
+
requests: fresh.map((p) => p.title),
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
await ctx.sleep(ctx.config.permissionPollSeconds * 1000);
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function readResultFile(file: string, def: StepDef) {
|
|
306
|
+
if (!fs.existsSync(file)) return { ok: false as const, error: "结果文件不存在" };
|
|
307
|
+
let raw: unknown;
|
|
308
|
+
try {
|
|
309
|
+
raw = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
310
|
+
} catch (error) {
|
|
311
|
+
return { ok: false as const, error: `结果文件不是合法的 JSON(${(error as Error).message})` };
|
|
312
|
+
}
|
|
313
|
+
return validateResult(def.result, raw);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Runs a step; a transient failure (see isTransientError) starts it again with a
|
|
318
|
+
* fresh agent after a pause, up to config.retry.attempts more times.
|
|
319
|
+
*/
|
|
320
|
+
async function runStep(
|
|
321
|
+
ctx: PipelineContext,
|
|
322
|
+
id: StepId,
|
|
323
|
+
round: number | null = null,
|
|
324
|
+
maxRounds: number | null = null,
|
|
325
|
+
extras: StepExtras = {},
|
|
326
|
+
): Promise<Record<string, unknown>> {
|
|
327
|
+
const { attempts, backoffSeconds } = ctx.config.retry;
|
|
328
|
+
for (let retry = 0; ; retry += 1) {
|
|
329
|
+
try {
|
|
330
|
+
return await runStepOnce(ctx, id, round, maxRounds, extras);
|
|
331
|
+
} catch (error) {
|
|
332
|
+
const message = (error as Error).message;
|
|
333
|
+
if (!(error instanceof StepFailure) || !isTransientError(message) || retry >= attempts) {
|
|
334
|
+
if (error instanceof StepFailure && retry > 0) {
|
|
335
|
+
throw new StepFailure(error.stepTitle, `${message}(已自动重试 ${retry} 次)`);
|
|
336
|
+
}
|
|
337
|
+
throw error;
|
|
338
|
+
}
|
|
339
|
+
const wait = backoffSeconds.at(Math.min(retry, backoffSeconds.length - 1)) ?? 60;
|
|
340
|
+
ctx.log(`${error.stepTitle} 遇到临时错误:${message}。${wait} 秒后自动重试(第 ${retry + 1} 次,最多 ${attempts} 次)`);
|
|
341
|
+
await ctx.sleep(wait * 1000);
|
|
342
|
+
extras = {
|
|
343
|
+
...extras,
|
|
344
|
+
contextLines: [
|
|
345
|
+
...(extras.contextLines ?? []).filter((l) => !l.startsWith("自动重试:")),
|
|
346
|
+
`自动重试:本步骤上一次因临时错误中断(${message.slice(0, 200)})。工作区里可能留有上一次没做完的改动,先用 git status 和 git diff 查看,在此基础上继续,不要重复提交`,
|
|
347
|
+
],
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function runStepOnce(
|
|
354
|
+
ctx: PipelineContext,
|
|
355
|
+
id: StepId,
|
|
356
|
+
round: number | null,
|
|
357
|
+
maxRounds: number | null,
|
|
358
|
+
extras: StepExtras,
|
|
359
|
+
): Promise<Record<string, unknown>> {
|
|
360
|
+
const def = STEPS[id];
|
|
361
|
+
const key = extras.key ?? stepKey(id, round);
|
|
362
|
+
const cached = ctx.state.results[key];
|
|
363
|
+
if (cached) {
|
|
364
|
+
ctx.log(`跳过 ${key}:沿用上次运行的结果 ${JSON.stringify(cached)}`);
|
|
365
|
+
return cached;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const resultRel = `${ctx.featureDir}/.pipeline/results/${key}.json`;
|
|
369
|
+
const resultAbs = path.join(ctx.repo, resultRel);
|
|
370
|
+
fs.rmSync(resultAbs, { force: true });
|
|
371
|
+
|
|
372
|
+
const managed = mergeExtras(mergeExtras(await serviceExtras(ctx, id), await browserExtras(ctx, id)), deployedExtras(ctx, id));
|
|
373
|
+
if (managed) {
|
|
374
|
+
extras = {
|
|
375
|
+
...extras,
|
|
376
|
+
contextLines: [...(extras.contextLines ?? []), ...managed.contextLines],
|
|
377
|
+
sections: [...(extras.sections ?? []), ...managed.sections],
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const agentTitle =
|
|
382
|
+
`[${featureName(ctx.featureDir)}] ` +
|
|
383
|
+
(extras.title ?? `${def.number}-${def.title}${round !== null && round > 1 ? ` 第${round}轮` : ""}`);
|
|
384
|
+
const record: StepRecord = {
|
|
385
|
+
key,
|
|
386
|
+
stepId: id,
|
|
387
|
+
round,
|
|
388
|
+
attempt: ctx.state.attempt,
|
|
389
|
+
agentId: "",
|
|
390
|
+
startedAt: new Date().toISOString(),
|
|
391
|
+
endedAt: null,
|
|
392
|
+
outcome: "running",
|
|
393
|
+
followUps: 0,
|
|
394
|
+
result: null,
|
|
395
|
+
error: null,
|
|
396
|
+
phase: phaseOf(ctx),
|
|
397
|
+
};
|
|
398
|
+
ctx.log(`开始 ${key}(${def.title})`);
|
|
399
|
+
|
|
400
|
+
try {
|
|
401
|
+
const agent = await ctx.runtime.startAgent({
|
|
402
|
+
role: def.role,
|
|
403
|
+
title: agentTitle,
|
|
404
|
+
prompt: buildPrompt(ctx, def, round, maxRounds, resultRel, extras),
|
|
405
|
+
cwd: ctx.repo,
|
|
406
|
+
labels: {
|
|
407
|
+
"requirement-pipeline": featureName(ctx.featureDir),
|
|
408
|
+
"pipeline-step": key,
|
|
409
|
+
},
|
|
410
|
+
parentAgentId: ctx.config.attachToOriginChat ? ctx.state.originAgentId : null,
|
|
411
|
+
// Always passed: the run's config has Paseo profile names resolved; the runtime's may not.
|
|
412
|
+
profile: extras.profile ?? phaseAgent(ctx, id) ?? ctx.config.agents[def.role],
|
|
413
|
+
workspaceId: ctx.state.worktree?.workspaceId ?? null,
|
|
414
|
+
});
|
|
415
|
+
record.agentId = agent.id;
|
|
416
|
+
ctx.state.history.push(record);
|
|
417
|
+
save(ctx);
|
|
418
|
+
|
|
419
|
+
const deadline = Date.now() + ctx.config.stepTimeoutMinutes[id] * 60_000;
|
|
420
|
+
record.usage = (await waitForTurn(ctx, agent, def, agentTitle, deadline)) ?? record.usage ?? null;
|
|
421
|
+
let validation = readResultFile(resultAbs, def);
|
|
422
|
+
while (!validation.ok && record.followUps < ctx.config.resultFollowUps) {
|
|
423
|
+
record.followUps += 1;
|
|
424
|
+
save(ctx);
|
|
425
|
+
ctx.log(`${key} 的结果无效(${validation.error}),第 ${record.followUps} 次提醒 agent`);
|
|
426
|
+
await agent.followUp(resultFollowUp({ resultFile: resultAbs, problem: validation.error }));
|
|
427
|
+
record.usage = (await waitForTurn(ctx, agent, def, agentTitle, deadline)) ?? record.usage ?? null;
|
|
428
|
+
validation = readResultFile(resultAbs, def);
|
|
429
|
+
}
|
|
430
|
+
if (!validation.ok) {
|
|
431
|
+
throw new StepFailure(def.title, `提醒 ${record.followUps} 次后仍没有得到有效的结果文件:${validation.error}`);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
record.outcome = "completed";
|
|
435
|
+
record.endedAt = new Date().toISOString();
|
|
436
|
+
record.result = validation.value;
|
|
437
|
+
ctx.state.results[key] = validation.value;
|
|
438
|
+
save(ctx);
|
|
439
|
+
ctx.log(`完成 ${key}:${JSON.stringify(validation.value)}`);
|
|
440
|
+
if (ctx.config.archiveStepAgents) {
|
|
441
|
+
await agent.archive().catch((error: Error) => ctx.log(`归档 ${agentTitle} 失败:${error.message}`));
|
|
442
|
+
}
|
|
443
|
+
return validation.value;
|
|
444
|
+
} catch (error) {
|
|
445
|
+
record.outcome = "failed";
|
|
446
|
+
record.endedAt = new Date().toISOString();
|
|
447
|
+
record.error = (error as Error).message;
|
|
448
|
+
if (!ctx.state.history.includes(record)) ctx.state.history.push(record);
|
|
449
|
+
save(ctx);
|
|
450
|
+
if (error instanceof StepFailure) throw error;
|
|
451
|
+
throw new StepFailure(def.title, (error as Error).message);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** phases.verify.agents applies to the verify-phase steps. */
|
|
456
|
+
function phaseAgent(ctx: PipelineContext, id: StepId): AgentProfile | undefined {
|
|
457
|
+
if (!VERIFY_PHASE_STEPS.includes(id)) return undefined;
|
|
458
|
+
return ctx.config.phases.verify.agents[STEPS[id].role];
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function deployed(ctx: PipelineContext): boolean {
|
|
462
|
+
return runPhases(ctx.state).environment === "deployed";
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Verification against an environment deployed for testing, described in test-env.md. */
|
|
466
|
+
function deployedExtras(ctx: PipelineContext, id: StepId): { contextLines: string[]; sections: string[] } | null {
|
|
467
|
+
if (!deployed(ctx) || !["verify", "defect_fix", "reverify"].includes(id)) return null;
|
|
468
|
+
return {
|
|
469
|
+
contextLines: [`验证环境:已部署的测试环境,地址、账号从哪里取、可以用的测试数据见 ${ctx.featureDir}/test-env.md`],
|
|
470
|
+
sections: [readToolPrompt({ ...ctx.prompts, repo: ctx.repo }, "verify-deployed.md")],
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function mergeExtras(
|
|
475
|
+
a: { contextLines: string[]; sections: string[] } | null,
|
|
476
|
+
b: { contextLines: string[]; sections: string[] } | null,
|
|
477
|
+
): { contextLines: string[]; sections: string[] } | null {
|
|
478
|
+
if (!a && !b) return null;
|
|
479
|
+
return { contextLines: [...(a?.contextLines ?? []), ...(b?.contextLines ?? [])], sections: [...(a?.sections ?? []), ...(b?.sections ?? [])] };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** Steps that check the result the way a user would. */
|
|
483
|
+
const BROWSER_STEPS: readonly StepId[] = ["verify", "defect_fix", "reverify"];
|
|
484
|
+
|
|
485
|
+
/** Points verification at Paseo's built-in browser when the step agents have it. */
|
|
486
|
+
async function browserExtras(ctx: PipelineContext, id: StepId): Promise<{ contextLines: string[]; sections: string[] } | null> {
|
|
487
|
+
if (!BROWSER_STEPS.includes(id) || ctx.config.browserVerify === "off") return null;
|
|
488
|
+
if (ctx.capabilities === undefined) ctx.capabilities = await ctx.runtime.capabilities().catch(() => null);
|
|
489
|
+
if (!ctx.capabilities?.browser) return null;
|
|
490
|
+
return {
|
|
491
|
+
contextLines: ["浏览器:可以用 Paseo 内置浏览器(browser_* 工具)验证网页界面"],
|
|
492
|
+
sections: [readToolPrompt({ ...ctx.prompts, repo: ctx.repo }, "browser-verify.md")],
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** Steps that need the project's services running. */
|
|
497
|
+
const SERVICE_STEPS: readonly StepId[] = ["service_start", "verify", "defect_fix", "reverify"];
|
|
498
|
+
|
|
499
|
+
function serviceState(ctx: PipelineContext): NonNullable<PipelineContext["services"]> {
|
|
500
|
+
ctx.services ??= { names: undefined, started: new Set() };
|
|
501
|
+
return ctx.services;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** The workspace whose paseo.json services the steps use: the requirement's own worktree, or the chat's. */
|
|
505
|
+
function serviceTarget(ctx: PipelineContext): ServiceTarget | null {
|
|
506
|
+
if (ctx.state.worktree) return { workspaceId: ctx.state.worktree.workspaceId };
|
|
507
|
+
const origin = ctx.state.originAgentId;
|
|
508
|
+
return origin && ctx.config.attachToOriginChat ? { agentId: origin } : null;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** Service scripts from paseo.json that Paseo can run for this pipeline; null when not used. */
|
|
512
|
+
async function serviceNames(ctx: PipelineContext): Promise<string[] | null> {
|
|
513
|
+
const state = serviceState(ctx);
|
|
514
|
+
if (state.names !== undefined) return state.names;
|
|
515
|
+
const target = serviceTarget(ctx);
|
|
516
|
+
if (ctx.config.paseoServices === "off" || !target) {
|
|
517
|
+
state.names = null;
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
state.names = await ctx.runtime.listServices(target).catch(() => null);
|
|
521
|
+
if (state.names) ctx.log(`项目在 paseo.json 中定义了服务(${state.names.join("、")}),服务启动和验证相关的步骤由流水线通过 Paseo 启动它们`);
|
|
522
|
+
return state.names;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function describeService(s: ManagedService, logFile: string | null): string {
|
|
526
|
+
const where = `${s.url ?? "(Paseo 没有给出地址)"}${s.port ? `(端口 ${s.port})` : ""}`;
|
|
527
|
+
const output = logFile ? `,最后的输出见 ${logFile}` : "";
|
|
528
|
+
if (!s.running) return `Paseo 管理的服务:${s.name} 没有启动成功${s.exitCode !== null ? `(退出码 ${s.exitCode})` : ""}${output}`;
|
|
529
|
+
if (s.health === "unhealthy") return `Paseo 管理的服务:${s.name} → ${where},已启动但健康检查没有通过${output}`;
|
|
530
|
+
return `Paseo 管理的服务:${s.name} → ${where},已启动${s.health === "healthy" ? ",健康检查通过" : ""}`;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** Starts the services (fresh, with the current code) before a step that needs them. */
|
|
534
|
+
async function serviceExtras(ctx: PipelineContext, id: StepId): Promise<{ contextLines: string[]; sections: string[] } | null> {
|
|
535
|
+
if (!SERVICE_STEPS.includes(id) || deployed(ctx)) return null;
|
|
536
|
+
const names = await serviceNames(ctx);
|
|
537
|
+
const target = serviceTarget(ctx);
|
|
538
|
+
if (!names || !target) return null;
|
|
539
|
+
const sections = [readToolPrompt({ ...ctx.prompts, repo: ctx.repo }, "paseo-services.md")];
|
|
540
|
+
let services: ManagedService[];
|
|
541
|
+
try {
|
|
542
|
+
services = await ctx.runtime.startServices(target, names, ctx.config.serviceReadySeconds * 1000);
|
|
543
|
+
} catch (error) {
|
|
544
|
+
ctx.log(`通过 Paseo 启动服务失败:${(error as Error).message}`);
|
|
545
|
+
return {
|
|
546
|
+
contextLines: [`Paseo 管理的服务:这次没能通过 Paseo 启动(${(error as Error).message}),请按验证方法或 service-start.md 自己启动,用完关掉`],
|
|
547
|
+
sections: [],
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
for (const name of names) serviceState(ctx).started.add(name);
|
|
551
|
+
const dir = path.join(ctx.repo, ctx.featureDir, ".pipeline", "services");
|
|
552
|
+
const lines = services.map((s) => {
|
|
553
|
+
let logFile: string | null = null;
|
|
554
|
+
if (s.output && (!s.running || s.health === "unhealthy")) {
|
|
555
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
556
|
+
logFile = `${ctx.featureDir}/.pipeline/services/${s.name}.log`;
|
|
557
|
+
fs.writeFileSync(path.join(ctx.repo, logFile), `${s.output}\n`);
|
|
558
|
+
}
|
|
559
|
+
return describeService(s, logFile);
|
|
560
|
+
});
|
|
561
|
+
ctx.log(lines.join(";"));
|
|
562
|
+
return { contextLines: lines, sections };
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
async function stopManagedServices(ctx: PipelineContext): Promise<void> {
|
|
566
|
+
const started = [...(ctx.services?.started ?? [])];
|
|
567
|
+
const target = serviceTarget(ctx);
|
|
568
|
+
if (started.length === 0 || !target) return;
|
|
569
|
+
await ctx.runtime.stopServices(target, started).catch((error: Error) => ctx.log(`停止服务失败:${error.message}`));
|
|
570
|
+
ctx.services?.started.clear();
|
|
571
|
+
ctx.log(`已停止 Paseo 管理的服务:${started.join("、")}`);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** A Paseo schedule that reminds the chat about unanswered questions; returns the hours, or null. */
|
|
575
|
+
async function scheduleReminder(ctx: PipelineContext, questions: string[] | null): Promise<number | null> {
|
|
576
|
+
const hours = ctx.config.waitingReminderHours;
|
|
577
|
+
const origin = ctx.state.originAgentId;
|
|
578
|
+
if (hours <= 0 || !origin) return null;
|
|
579
|
+
const at = new Date(Date.now() + hours * 3600_000);
|
|
580
|
+
try {
|
|
581
|
+
const id = await ctx.runtime.scheduleMessage({
|
|
582
|
+
agentId: origin,
|
|
583
|
+
at,
|
|
584
|
+
name: `req-kit 提醒:${featureName(ctx.featureDir)} ${questions ? "等待回答" : "待测试验证"}`,
|
|
585
|
+
prompt: questions
|
|
586
|
+
? waitingReminderMessage({ featureDir: shownFeatureDir(ctx), hours, questions, resumeCommand: ctx.resumeCommand })
|
|
587
|
+
: phaseWaitingReminderMessage({
|
|
588
|
+
featureDir: shownFeatureDir(ctx),
|
|
589
|
+
hours,
|
|
590
|
+
handoffPath: `${shownFeatureDir(ctx)}/handoff.md`,
|
|
591
|
+
verifyCommand: verifyCommandOf(ctx),
|
|
592
|
+
}),
|
|
593
|
+
});
|
|
594
|
+
if (!id) return null;
|
|
595
|
+
ctx.state.schedules = [...(ctx.state.schedules ?? []), { id, kind: "reminder", at: at.toISOString() }];
|
|
596
|
+
save(ctx);
|
|
597
|
+
ctx.log(`已预约 ${hours} 小时后的提醒(Paseo 定时任务 ${id})`);
|
|
598
|
+
return hours;
|
|
599
|
+
} catch (error) {
|
|
600
|
+
ctx.log(`预约提醒失败:${(error as Error).message}`);
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function shortTime(at: Date): string {
|
|
606
|
+
return at.toLocaleString("zh-CN", { hour12: false, month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" });
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** --verify-at: a Paseo schedule that has the chat start the verify phase; returns the time as text, or null. */
|
|
610
|
+
async function scheduleVerifyStart(ctx: PipelineContext): Promise<string | null> {
|
|
611
|
+
const origin = ctx.state.originAgentId;
|
|
612
|
+
if (!ctx.state.verifyAt || !origin) return null;
|
|
613
|
+
const at = new Date(ctx.state.verifyAt);
|
|
614
|
+
try {
|
|
615
|
+
const id = await ctx.runtime.scheduleMessage({
|
|
616
|
+
agentId: origin,
|
|
617
|
+
at,
|
|
618
|
+
name: `req-kit 测试验证:${featureName(ctx.featureDir)}`,
|
|
619
|
+
prompt: verifyStartPrompt({
|
|
620
|
+
featureDir: shownFeatureDir(ctx),
|
|
621
|
+
statusCommand: ctx.statusCommand ?? ctx.resumeCommand.replace(/ --resume$/, " --status"),
|
|
622
|
+
verifyCommand: verifyCommandOf(ctx),
|
|
623
|
+
environment: runPhases(ctx.state).environment,
|
|
624
|
+
}),
|
|
625
|
+
});
|
|
626
|
+
if (!id) return null;
|
|
627
|
+
ctx.state.schedules = [...(ctx.state.schedules ?? []), { id, kind: "verify-start", at: at.toISOString() }];
|
|
628
|
+
save(ctx);
|
|
629
|
+
ctx.log(`已预约在 ${shortTime(at)} 开始测试验证(Paseo 定时任务 ${id})`);
|
|
630
|
+
return shortTime(at);
|
|
631
|
+
} catch (error) {
|
|
632
|
+
ctx.log(`预约测试验证失败:${(error as Error).message}`);
|
|
633
|
+
return null;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** A Paseo schedule that has the chat continue the run later; returns the time as text, or null. */
|
|
638
|
+
async function scheduleAutoResume(ctx: PipelineContext, reason: string): Promise<string | null> {
|
|
639
|
+
const minutes = ctx.config.autoResumeMinutes;
|
|
640
|
+
const origin = ctx.state.originAgentId;
|
|
641
|
+
if (minutes <= 0 || !origin) return null;
|
|
642
|
+
const used = ctx.state.autoResumes?.attempt === ctx.state.attempt ? ctx.state.autoResumes.count : 0;
|
|
643
|
+
if (used >= ctx.config.autoResumeMax) {
|
|
644
|
+
ctx.log(`这次尝试已经自动继续过 ${used} 次,不再预约`);
|
|
645
|
+
return null;
|
|
646
|
+
}
|
|
647
|
+
const at = new Date(Date.now() + minutes * 60_000);
|
|
648
|
+
try {
|
|
649
|
+
const id = await ctx.runtime.scheduleMessage({
|
|
650
|
+
agentId: origin,
|
|
651
|
+
at,
|
|
652
|
+
name: `req-kit 自动继续:${featureName(ctx.featureDir)}`,
|
|
653
|
+
prompt: autoResumePrompt({
|
|
654
|
+
featureDir: shownFeatureDir(ctx),
|
|
655
|
+
reason: reason.slice(0, 300),
|
|
656
|
+
statusCommand: ctx.statusCommand ?? ctx.resumeCommand.replace(/ --resume$/, " --status"),
|
|
657
|
+
resumeCommand: ctx.resumeCommand,
|
|
658
|
+
}),
|
|
659
|
+
});
|
|
660
|
+
if (!id) return null;
|
|
661
|
+
ctx.state.schedules = [...(ctx.state.schedules ?? []), { id, kind: "auto-resume", at: at.toISOString() }];
|
|
662
|
+
ctx.state.autoResumes = { attempt: ctx.state.attempt, count: used + 1 };
|
|
663
|
+
save(ctx);
|
|
664
|
+
ctx.log(`已预约 ${minutes} 分钟后自动继续(Paseo 定时任务 ${id})`);
|
|
665
|
+
return at.toLocaleString("zh-CN", { hour12: false, month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" });
|
|
666
|
+
} catch (error) {
|
|
667
|
+
ctx.log(`预约自动继续失败:${(error as Error).message}`);
|
|
668
|
+
return null;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/** Warns once at the start when a provider's subscription is nearly used up. Never blocks. */
|
|
673
|
+
async function checkQuota(ctx: PipelineContext): Promise<void> {
|
|
674
|
+
const threshold = ctx.config.quotaWarnPercent;
|
|
675
|
+
if (threshold <= 0) return;
|
|
676
|
+
const profiles = [ctx.config.agents.builder, ctx.config.agents.reviewer];
|
|
677
|
+
if (ctx.config.reviewPanel.enabled && ctx.config.reviewPanel.profiles.includes(ctx.state.profile)) profiles.push(...ctx.config.reviewPanel.members);
|
|
678
|
+
const providers = [...new Set(profiles.map((p) => (p.provider ?? "").split("/")[0]).filter(Boolean))];
|
|
679
|
+
const quotas = await ctx.runtime.providerQuota(providers).catch(() => null);
|
|
680
|
+
if (!quotas) return;
|
|
681
|
+
const tight = quotas.filter((q) => (q.usedPct !== null && q.usedPct >= threshold) || q.tone === "danger");
|
|
682
|
+
if (tight.length === 0) {
|
|
683
|
+
ctx.log(`额度:${quotas.map((q) => `${q.displayName} ${q.window} ${q.usedPct === null ? "?" : `${Math.round(q.usedPct)}%`}`).join(",")}`);
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
const items = tight.map((q) => {
|
|
687
|
+
const reset = q.resetsAt ? `,${new Date(q.resetsAt).toLocaleString("zh-CN", { hour12: false })} 恢复` : "";
|
|
688
|
+
return `${q.displayName} 的「${q.window}」额度已用 ${q.usedPct === null ? "大部分" : `${Math.round(q.usedPct)}%`}${reset}`;
|
|
689
|
+
});
|
|
690
|
+
await notifyOrigin(
|
|
691
|
+
ctx,
|
|
692
|
+
[
|
|
693
|
+
"【需求开发流水线通知】这条消息来自后台流水线,不是用户发的。",
|
|
694
|
+
`额度提醒:${items.join(";")}。`,
|
|
695
|
+
"流水线照常进行;额度用完时会遇到限流,流水线会自动等待重试,重试用完会中断,之后可以继续。如果希望等额度恢复后再开发,请转告用户,用户同意的话可以先暂停。",
|
|
696
|
+
].join("\n"),
|
|
697
|
+
);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function panelApplies(ctx: PipelineContext, step: PanelStep, round: number): boolean {
|
|
701
|
+
const panel = ctx.config.reviewPanel;
|
|
702
|
+
return panel.enabled && round === 1 && panel.steps.includes(step) && panel.profiles.includes(ctx.state.profile);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const PANEL_OUTPUT: Record<PanelStep, string> = { design_review: "design-review.md", code_review: "code-review.md" };
|
|
706
|
+
|
|
707
|
+
function memberName(member: ReviewPanelMember, index: number): string {
|
|
708
|
+
return member.name?.trim() || `成员 ${index + 1}`;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Runs the panel members of one review in parallel. Returns the findings files
|
|
713
|
+
* (relative to the repository) of the members that finished; a member that fails
|
|
714
|
+
* is logged and left out. Fails only when no member finishes.
|
|
715
|
+
*/
|
|
716
|
+
async function runPanel(ctx: PipelineContext, step: PanelStep, round: number): Promise<string[]> {
|
|
717
|
+
const members = ctx.config.reviewPanel.members;
|
|
718
|
+
const stepTitle = STEPS[step].title;
|
|
719
|
+
const runs = members.map((member, i) => {
|
|
720
|
+
const key = `${step}-r${round}-p${i + 1}`;
|
|
721
|
+
const findings = `${ctx.featureDir}/review-panel/${key}.md`;
|
|
722
|
+
const name = memberName(member, i);
|
|
723
|
+
const contextLines = [
|
|
724
|
+
`评审小组:${stepTitle},第 ${round} 轮,${name}(共 ${members.length} 位)`,
|
|
725
|
+
`你的意见文件:${findings}`,
|
|
726
|
+
`你的意见文件绝对路径:${path.join(ctx.repo, findings)}`,
|
|
727
|
+
];
|
|
728
|
+
if (member.focus) contextLines.push(`你的关注重点:${member.focus}`);
|
|
729
|
+
return runStep(ctx, "panel_review", round, null, {
|
|
730
|
+
key,
|
|
731
|
+
title: `${STEPS[step].number}-${stepTitle} 评审小组·${name}`,
|
|
732
|
+
profile: member,
|
|
733
|
+
contextLines,
|
|
734
|
+
criteriaFile: STEPS[step].promptFile,
|
|
735
|
+
additionsFor: step,
|
|
736
|
+
}).then(() => findings);
|
|
737
|
+
});
|
|
738
|
+
const settled = await Promise.allSettled(runs);
|
|
739
|
+
const done: string[] = [];
|
|
740
|
+
settled.forEach((outcome, i) => {
|
|
741
|
+
if (outcome.status === "fulfilled") done.push(outcome.value);
|
|
742
|
+
else ctx.log(`评审小组${memberName(members[i], i)}没有完成,汇总时不包含它的意见:${(outcome.reason as Error).message}`);
|
|
743
|
+
});
|
|
744
|
+
if (done.length === 0) {
|
|
745
|
+
const first = settled.find((s): s is PromiseRejectedResult => s.status === "rejected");
|
|
746
|
+
throw first?.reason instanceof StepFailure ? first.reason : new StepFailure(stepTitle, "评审小组的成员都没有完成");
|
|
747
|
+
}
|
|
748
|
+
return done;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function consolidationSection(step: PanelStep, findings: string[], total: number): string {
|
|
752
|
+
return [
|
|
753
|
+
"# 评审小组的意见",
|
|
754
|
+
"",
|
|
755
|
+
`这一轮先由 ${total} 位评审者各自独立评审${findings.length < total ? `(其中 ${total - findings.length} 位没有完成,只有下面 ${findings.length} 份)` : ""},意见文件如下。你负责核实并汇总,再按上文的要求完成本步骤:`,
|
|
756
|
+
"",
|
|
757
|
+
...findings.map((f) => `- \`${f}\``),
|
|
758
|
+
"",
|
|
759
|
+
"做法:",
|
|
760
|
+
"1. 逐条读完所有意见。不同成员说的是同一个问题的,合并为一条。",
|
|
761
|
+
"2. 每条都亲自核实(读代码、对照 spec、能运行的就运行),然后归入四类之一:",
|
|
762
|
+
" - **必须修改**:成立,且属于\"阻塞\"或\"重要\"。按上文的格式写进评审意见,参与结论判断。",
|
|
763
|
+
" - **建议修改**:成立,但属于\"建议\"。",
|
|
764
|
+
" - **留给人决定**:涉及需求取舍、只有需求方或开发同事能判断的。在 `decisions.tsv` 中记一行,`needs_human` 为 `yes`。",
|
|
765
|
+
" - **已驳回**:核实后不成立。写明依据,并在 `decisions.tsv` 中记一行,`kind` 为 `驳回意见`。",
|
|
766
|
+
`3. 在 \`${PANEL_OUTPUT[step]}\` 中增加\"评审小组意见汇总\"一节,表格列出每条原始意见:来源成员、意见摘要、归类、理由。被驳回的也要列出,最终报告会展示它们。`,
|
|
767
|
+
"4. 结论和结果文件只根据你核实后的\"必须修改\"判断,不按成员的数量投票。",
|
|
768
|
+
].join("\n");
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function proposalsApply(ctx: PipelineContext): boolean {
|
|
772
|
+
const p = ctx.config.designProposals;
|
|
773
|
+
// A continuation after a change only touches what changed; one designer is enough.
|
|
774
|
+
return p.enabled && p.profiles.includes(ctx.state.profile) && !ctx.state.incremental;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function proposalMembers(ctx: PipelineContext): ReviewPanelMember[] {
|
|
778
|
+
const members = ctx.config.designProposals.members;
|
|
779
|
+
if (members.length > 0) return members;
|
|
780
|
+
return [
|
|
781
|
+
{ ...ctx.config.agents.builder, name: "builder" },
|
|
782
|
+
{ ...ctx.config.agents.reviewer, name: "reviewer" },
|
|
783
|
+
];
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Several agents propose a design in parallel (no code changes, no commits); returns the
|
|
788
|
+
* proposal files of those that finished. The design step then compares and decides.
|
|
789
|
+
*/
|
|
790
|
+
async function runProposals(ctx: PipelineContext): Promise<string[]> {
|
|
791
|
+
const members = proposalMembers(ctx);
|
|
792
|
+
const runs = members.map((member, i) => {
|
|
793
|
+
const key = `design_proposal-p${i + 1}`;
|
|
794
|
+
const file = `${ctx.featureDir}/design-proposals/${key}.md`;
|
|
795
|
+
const name = memberName(member, i);
|
|
796
|
+
const contextLines = [
|
|
797
|
+
`方案提案:${name}(共 ${members.length} 份,各自独立完成)`,
|
|
798
|
+
`你的提案文件:${file}`,
|
|
799
|
+
`你的提案文件绝对路径:${path.join(ctx.repo, file)}`,
|
|
800
|
+
];
|
|
801
|
+
if (member.focus) contextLines.push(`你的关注重点:${member.focus}`);
|
|
802
|
+
return runStep(ctx, "design_proposal", null, null, {
|
|
803
|
+
key,
|
|
804
|
+
title: `3-方案提案·${name}`,
|
|
805
|
+
profile: member,
|
|
806
|
+
contextLines,
|
|
807
|
+
additionsFor: "design",
|
|
808
|
+
}).then(() => file);
|
|
809
|
+
});
|
|
810
|
+
const settled = await Promise.allSettled(runs);
|
|
811
|
+
const done: string[] = [];
|
|
812
|
+
settled.forEach((outcome, i) => {
|
|
813
|
+
if (outcome.status === "fulfilled") done.push(outcome.value);
|
|
814
|
+
else ctx.log(`方案提案·${memberName(members[i], i)}没有完成,方案设计时不包含它:${(outcome.reason as Error).message}`);
|
|
815
|
+
});
|
|
816
|
+
return done;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function proposalsSection(files: string[], total: number): string {
|
|
820
|
+
return [
|
|
821
|
+
"# 方案提案",
|
|
822
|
+
"",
|
|
823
|
+
`这一次先由 ${total} 个 agent 各自独立提出了方案${files.length < total ? `(其中 ${total - files.length} 份没有完成,只有下面 ${files.length} 份)` : ""}:`,
|
|
824
|
+
"",
|
|
825
|
+
...files.map((f) => `- \`${f}\``),
|
|
826
|
+
"",
|
|
827
|
+
"你负责比较并做出决定,再按上文的要求写 `design.md`:",
|
|
828
|
+
"1. 逐份读完,对照 spec 和代码核实其中的判断,不要只看结论。",
|
|
829
|
+
"2. 选择一份为基础,或者把各份中更好的部分合并起来。不要为了合并而合并,取舍要有理由。",
|
|
830
|
+
"3. 在 `design.md` 中增加\"方案比较\"一节:一张表列出各提案的思路、优点、风险、改动范围,写明最后的选择和理由,以及放弃了什么。",
|
|
831
|
+
"4. 会影响需求方能看到的行为、成本或时间的取舍,在 `decisions.tsv` 中记一行,`needs_human` 为 `yes`。",
|
|
832
|
+
].join("\n");
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/** One review round; with a panel in the first round, members review first and this step consolidates. */
|
|
836
|
+
async function runReview(ctx: PipelineContext, step: PanelStep, round: number, maxRounds: number): Promise<Record<string, unknown>> {
|
|
837
|
+
if (!panelApplies(ctx, step, round)) return runStep(ctx, step, round, maxRounds);
|
|
838
|
+
const findings = await runPanel(ctx, step, round);
|
|
839
|
+
return runStep(ctx, step, round, maxRounds, {
|
|
840
|
+
sections: [consolidationSection(step, findings, ctx.config.reviewPanel.members.length)],
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/** Runs one step outside the pipeline's flow (the spec review started from the chat). */
|
|
845
|
+
export async function runSingleStep(ctx: PipelineContext, id: StepId, extras: StepExtras = {}): Promise<Record<string, unknown>> {
|
|
846
|
+
try {
|
|
847
|
+
return await runStep(ctx, id, null, null, extras);
|
|
848
|
+
} catch (error) {
|
|
849
|
+
throw new Error((error as Error).message);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/** Develop phase: design → design review ⇄ revise → implement → code review ⇄ fix → handoff. Returns the design result. */
|
|
854
|
+
async function developPhase(ctx: PipelineContext): Promise<Record<string, unknown>> {
|
|
855
|
+
const rounds = ctx.config.profiles[ctx.state.profile].rounds;
|
|
856
|
+
|
|
857
|
+
let designExtras: StepExtras = {};
|
|
858
|
+
if (proposalsApply(ctx) && !ctx.state.results.design) {
|
|
859
|
+
const files = await runProposals(ctx);
|
|
860
|
+
if (files.length > 0) designExtras = { sections: [proposalsSection(files, proposalMembers(ctx).length)] };
|
|
861
|
+
else ctx.log("方案提案都没有完成,按原来的方式由一个 agent 做方案设计");
|
|
862
|
+
}
|
|
863
|
+
const design = await runStep(ctx, "design", null, null, designExtras);
|
|
864
|
+
|
|
865
|
+
let designRounds = 0;
|
|
866
|
+
for (let r = 1; r <= rounds.designReview; r++) {
|
|
867
|
+
const review = await runReview(ctx, "design_review", r, rounds.designReview);
|
|
868
|
+
designRounds = r;
|
|
869
|
+
if (review.verdict === "approved") break;
|
|
870
|
+
if (review.verdict === "blocked") throw new BusinessDecisionNeeded(review.questions as string[]);
|
|
871
|
+
// changes_required. The last round's revision goes straight to implementation;
|
|
872
|
+
// the implementer reads both the review and the revision record.
|
|
873
|
+
await runStep(ctx, "design_revise", r, rounds.designReview);
|
|
874
|
+
}
|
|
875
|
+
await milestone(
|
|
876
|
+
ctx,
|
|
877
|
+
"design-settled",
|
|
878
|
+
`技术方案已经确定(审核 ${designRounds} 轮),开始写代码。写代码和代码检查通常是整个过程中最耗时的部分。`,
|
|
879
|
+
);
|
|
880
|
+
|
|
881
|
+
await runStep(ctx, "implement");
|
|
882
|
+
|
|
883
|
+
for (let r = 1; r <= rounds.codeReview; r++) {
|
|
884
|
+
const review = await runReview(ctx, "code_review", r, rounds.codeReview);
|
|
885
|
+
if (review.verdict === "pass") break;
|
|
886
|
+
await runStep(ctx, "review_fix", r, rounds.codeReview);
|
|
887
|
+
}
|
|
888
|
+
await runStep(ctx, "handoff");
|
|
889
|
+
return design;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* Verify phase: service start → verification → defects as configured.
|
|
894
|
+
* Returns true when the defects go back to development (defects "handback").
|
|
895
|
+
*/
|
|
896
|
+
async function verifyPhase(ctx: PipelineContext, design: Record<string, unknown>): Promise<boolean> {
|
|
897
|
+
const settings = ctx.config.profiles[ctx.state.profile];
|
|
898
|
+
const rounds = settings.rounds;
|
|
899
|
+
const phases = runPhases(ctx.state);
|
|
900
|
+
const serviceStart = phases.environment === "deployed" ? false : (ctx.config.phases.verify.serviceStart ?? settings.serviceStart);
|
|
901
|
+
|
|
902
|
+
if (design.has_runnable_service === true && serviceStart) {
|
|
903
|
+
await runStep(ctx, "service_start");
|
|
904
|
+
} else if (design.has_runnable_service === true && phases.environment === "deployed") {
|
|
905
|
+
ctx.log("在已部署的测试环境中验证,不在本地做服务启动验证。");
|
|
906
|
+
} else if (design.has_runnable_service === true) {
|
|
907
|
+
ctx.log(`${PROFILE_LABELS[ctx.state.profile]}档不执行服务启动验证,由功能验证一并处理。`);
|
|
908
|
+
} else {
|
|
909
|
+
ctx.log("方案设计判断项目没有常驻服务,跳过服务启动验证。");
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
const verification = await runStep(ctx, "verify");
|
|
913
|
+
if (verification.result !== "defects_found") return false;
|
|
914
|
+
if (phases.defects === "report") {
|
|
915
|
+
ctx.log("验证发现了问题,按设置只记录、不修复,直接写报告。");
|
|
916
|
+
return false;
|
|
917
|
+
}
|
|
918
|
+
if (phases.defects === "handback") {
|
|
919
|
+
const used = ctx.state.handbacks ?? 0;
|
|
920
|
+
if (used < rounds.defectFix) return true;
|
|
921
|
+
ctx.log(`已经退回研发 ${used} 次(上限 ${rounds.defectFix}),不再退回,问题写进报告。`);
|
|
922
|
+
return false;
|
|
923
|
+
}
|
|
924
|
+
await milestone(ctx, "fixing-defects", `验证中发现了一些问题,正在修复并重新验证(最多 ${rounds.defectFix} 轮)。`);
|
|
925
|
+
for (let r = 1; r <= rounds.defectFix; r++) {
|
|
926
|
+
await runStep(ctx, "defect_fix", r, rounds.defectFix);
|
|
927
|
+
const recheck = await runStep(ctx, "reverify", r, rounds.defectFix);
|
|
928
|
+
if (recheck.result === "pass") break;
|
|
929
|
+
}
|
|
930
|
+
return false;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
function headCommit(repo: string): string | null {
|
|
934
|
+
try {
|
|
935
|
+
return execFileSync("git", ["rev-parse", "HEAD"], { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
|
|
936
|
+
} catch {
|
|
937
|
+
return null;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* Defects found in the verify phase go back to development: recorded in changes.md (source
|
|
943
|
+
* "测试验证"), then a new attempt starts from design and only handles them (incremental).
|
|
944
|
+
*/
|
|
945
|
+
async function handBack(ctx: PipelineContext): Promise<void> {
|
|
946
|
+
const n = (ctx.state.handbacks ?? 0) + 1;
|
|
947
|
+
const changes = path.join(ctx.repo, ctx.featureDir, "changes.md");
|
|
948
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
949
|
+
const section = [
|
|
950
|
+
"",
|
|
951
|
+
`## ${today} 测试验证退回(第 ${n} 次)`,
|
|
952
|
+
"",
|
|
953
|
+
"- 来源:测试验证",
|
|
954
|
+
`- 内容:第 ${ctx.state.attempt} 次尝试的功能验证发现了缺陷,缺陷清单见 verification.md 最后一轮的“缺陷”一节`,
|
|
955
|
+
"- 处理:回到研发阶段,从方案设计开始只处理这些缺陷,修复经过代码评审后重新写提测说明、再提测",
|
|
956
|
+
"",
|
|
957
|
+
].join("\n");
|
|
958
|
+
fs.appendFileSync(changes, fs.existsSync(changes) ? section : `# 变更记录\n${section}`);
|
|
959
|
+
ctx.state.handbacks = n;
|
|
960
|
+
ctx.state.incremental = {
|
|
961
|
+
fromAttempt: ctx.state.attempt,
|
|
962
|
+
reason: `测试验证发现缺陷,第 ${n} 次退回研发阶段修复(见 changes.md 和 verification.md)`,
|
|
963
|
+
baseCommit: headCommit(ctx.repo),
|
|
964
|
+
hadImplementation: true,
|
|
965
|
+
};
|
|
966
|
+
ctx.state.attempt += 1;
|
|
967
|
+
ctx.state.results = {};
|
|
968
|
+
ctx.state.milestones = [];
|
|
969
|
+
ctx.state.phase = "develop";
|
|
970
|
+
save(ctx);
|
|
971
|
+
ctx.log(`测试验证发现缺陷,第 ${n} 次退回研发阶段(第 ${ctx.state.attempt} 次尝试)`);
|
|
972
|
+
const text = `测试验证发现了缺陷,按设置退回研发阶段修复(第 ${n} 次):从方案设计开始只处理这些缺陷,修完经过代码评审后${runPhases(ctx.state).mode === "gated" ? "重新提测" : "再次验证"}。`;
|
|
973
|
+
await notifyOrigin(ctx, milestoneMessage(text));
|
|
974
|
+
await notifyExternal(ctx, "milestone", text, { milestone: `handback-${n}` });
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
function usageLines(ctx: PipelineContext): string[] {
|
|
978
|
+
const thisAttempt = ctx.state.history.filter((h) => h.attempt === ctx.state.attempt);
|
|
979
|
+
const lines = [`用时与用量(本次尝试,截至报告前):${describeTotals(totals(thisAttempt))}`];
|
|
980
|
+
if (ctx.state.attempt > 1) lines.push(`用时与用量(这个需求的全部尝试):${describeTotals(totals(ctx.state.history))}`);
|
|
981
|
+
if (ctx.state.phases && runPhases(ctx.state).mode !== "continuous") {
|
|
982
|
+
for (const [phase, label] of [["develop", "研发阶段"], ["verify", "测试验证阶段"]] as const) {
|
|
983
|
+
const records = ctx.state.history.filter((h) => (h.phase ?? "develop") === phase);
|
|
984
|
+
if (records.length > 0) lines.push(`用时与用量(${label},全部尝试):${describeTotals(totals(records))}`);
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
return lines;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/** Steps 3–11. Loop rounds and optional steps come from the run's profile; the gate from the execution mode. */
|
|
991
|
+
async function flow(ctx: PipelineContext): Promise<Record<string, unknown>> {
|
|
992
|
+
for (;;) {
|
|
993
|
+
const design = await developPhase(ctx);
|
|
994
|
+
const phases = runPhases(ctx.state);
|
|
995
|
+
if (phaseOf(ctx) !== "verify") {
|
|
996
|
+
if (phases.mode === "develop-only") {
|
|
997
|
+
await stopManagedServices(ctx);
|
|
998
|
+
return runStep(ctx, "report", null, null, {
|
|
999
|
+
contextLines: [...usageLines(ctx), "只做研发:本次不做测试验证,完成报告按本步骤说明中“只做研发时”一节写成研发报告"],
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
if (phases.mode === "gated") {
|
|
1003
|
+
const at = ctx.state.verifyAt ? new Date(ctx.state.verifyAt) : null;
|
|
1004
|
+
if (!at || at.getTime() > Date.now()) throw new PhaseGate();
|
|
1005
|
+
ctx.log("研发完成时已经过了预约的测试验证时间,直接开始测试验证");
|
|
1006
|
+
ctx.state.verifyAt = null;
|
|
1007
|
+
} else {
|
|
1008
|
+
await milestone(ctx, "code-reviewed", "代码已经写完并通过检查,开始按验收标准逐条验证功能。");
|
|
1009
|
+
}
|
|
1010
|
+
ctx.state.phase = "verify";
|
|
1011
|
+
save(ctx);
|
|
1012
|
+
}
|
|
1013
|
+
if (!(await verifyPhase(ctx, design))) break;
|
|
1014
|
+
await handBack(ctx);
|
|
1015
|
+
}
|
|
1016
|
+
await stopManagedServices(ctx);
|
|
1017
|
+
return runStep(ctx, "report", null, null, { contextLines: usageLines(ctx) });
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
export async function runPipeline(ctx: PipelineContext): Promise<PipelineOutcome> {
|
|
1021
|
+
ctx.state.status = "running";
|
|
1022
|
+
ctx.state.stopReason = null;
|
|
1023
|
+
save(ctx);
|
|
1024
|
+
try {
|
|
1025
|
+
await checkQuota(ctx);
|
|
1026
|
+
const report = await flow(ctx);
|
|
1027
|
+
ctx.state.status = "completed";
|
|
1028
|
+
ctx.state.outcome = report.outcome as string;
|
|
1029
|
+
save(ctx);
|
|
1030
|
+
const developOnly = runPhases(ctx.state).mode === "develop-only" && phaseOf(ctx) !== "verify";
|
|
1031
|
+
await notifyOrigin(
|
|
1032
|
+
ctx,
|
|
1033
|
+
completedMessage({
|
|
1034
|
+
featureDir: shownFeatureDir(ctx),
|
|
1035
|
+
outcome: report.outcome as string,
|
|
1036
|
+
summary: report.summary as string,
|
|
1037
|
+
reportPath: `${shownFeatureDir(ctx)}/report.md`,
|
|
1038
|
+
handoffPath: developOnly ? `${shownFeatureDir(ctx)}/handoff.md` : null,
|
|
1039
|
+
}),
|
|
1040
|
+
);
|
|
1041
|
+
const outcomeText: Record<string, string> = {
|
|
1042
|
+
completed: "已完成,可以验收",
|
|
1043
|
+
completed_with_issues: "已完成,但有遗留问题",
|
|
1044
|
+
not_completed: "未完成",
|
|
1045
|
+
};
|
|
1046
|
+
const text = developOnly
|
|
1047
|
+
? `研发已完成(只做研发):${outcomeText[report.outcome as string] ?? report.outcome},请按提测说明人工测试。`
|
|
1048
|
+
: `开发流水线已结束:${outcomeText[report.outcome as string] ?? report.outcome}。`;
|
|
1049
|
+
await notifyExternal(ctx, "completed", text, {
|
|
1050
|
+
outcome: report.outcome,
|
|
1051
|
+
summary: report.summary,
|
|
1052
|
+
reportPath: `${ctx.featureDir}/report.md`,
|
|
1053
|
+
...(developOnly ? { mode: "develop-only", handoffPath: `${ctx.featureDir}/handoff.md` } : {}),
|
|
1054
|
+
});
|
|
1055
|
+
return "completed";
|
|
1056
|
+
} catch (error) {
|
|
1057
|
+
if (error instanceof PhaseGate) {
|
|
1058
|
+
ctx.state.status = "phase_done";
|
|
1059
|
+
ctx.state.stopReason = null;
|
|
1060
|
+
save(ctx);
|
|
1061
|
+
await stopManagedServices(ctx);
|
|
1062
|
+
const verifyAt = await scheduleVerifyStart(ctx);
|
|
1063
|
+
const reminderHours = verifyAt ? null : await scheduleReminder(ctx, null);
|
|
1064
|
+
await notifyOrigin(
|
|
1065
|
+
ctx,
|
|
1066
|
+
phaseDoneMessage({
|
|
1067
|
+
featureDir: shownFeatureDir(ctx),
|
|
1068
|
+
handoffPath: `${shownFeatureDir(ctx)}/handoff.md`,
|
|
1069
|
+
verifyCommand: verifyCommandOf(ctx),
|
|
1070
|
+
environment: runPhases(ctx.state).environment,
|
|
1071
|
+
verifyAt,
|
|
1072
|
+
reminderHours,
|
|
1073
|
+
handbacks: ctx.state.handbacks ?? 0,
|
|
1074
|
+
}),
|
|
1075
|
+
);
|
|
1076
|
+
await notifyExternal(ctx, "phase_done", `研发已完成,可以提测:提测说明在 ${ctx.featureDir}/handoff.md。${verifyAt ? `已预约在 ${verifyAt} 开始测试验证。` : ""}`, {
|
|
1077
|
+
handoffPath: `${ctx.featureDir}/handoff.md`,
|
|
1078
|
+
mode: runPhases(ctx.state).mode,
|
|
1079
|
+
verifyCommand: verifyCommandOf(ctx),
|
|
1080
|
+
verifyAt: ctx.state.verifyAt ?? null,
|
|
1081
|
+
handbacks: ctx.state.handbacks ?? 0,
|
|
1082
|
+
});
|
|
1083
|
+
return "phase_done";
|
|
1084
|
+
}
|
|
1085
|
+
if (error instanceof BusinessDecisionNeeded) {
|
|
1086
|
+
ctx.state.status = "waiting_for_business";
|
|
1087
|
+
ctx.state.stopReason = `方案审核需要需求方决定:${error.questions.join(";")}`;
|
|
1088
|
+
save(ctx);
|
|
1089
|
+
const reminderHours = await scheduleReminder(ctx, error.questions);
|
|
1090
|
+
await notifyOrigin(
|
|
1091
|
+
ctx,
|
|
1092
|
+
blockedMessage({ featureDir: shownFeatureDir(ctx), questions: error.questions, resumeCommand: ctx.resumeCommand, reminderHours }),
|
|
1093
|
+
);
|
|
1094
|
+
await notifyExternal(ctx, "blocked", `方案审核发现 ${error.questions.length} 个需要需求方决定的问题,流水线已暂停,请到发起的对话中回答。`, {
|
|
1095
|
+
questions: error.questions,
|
|
1096
|
+
resumeCommand: ctx.resumeCommand,
|
|
1097
|
+
});
|
|
1098
|
+
return "waiting_for_business";
|
|
1099
|
+
}
|
|
1100
|
+
const stepTitle = error instanceof StepFailure ? error.stepTitle : "流水线";
|
|
1101
|
+
ctx.state.status = "failed";
|
|
1102
|
+
ctx.state.stopReason = `${stepTitle}:${(error as Error).message}`;
|
|
1103
|
+
save(ctx);
|
|
1104
|
+
const autoResumeAt =
|
|
1105
|
+
error instanceof StepFailure && isTransientError((error as Error).message) ? await scheduleAutoResume(ctx, (error as Error).message) : null;
|
|
1106
|
+
await notifyOrigin(
|
|
1107
|
+
ctx,
|
|
1108
|
+
failedMessage({
|
|
1109
|
+
stepTitle,
|
|
1110
|
+
reason: (error as Error).message,
|
|
1111
|
+
logPath: ctx.logPath,
|
|
1112
|
+
resumeCommand: ctx.resumeCommand,
|
|
1113
|
+
autoResumeAt,
|
|
1114
|
+
}),
|
|
1115
|
+
);
|
|
1116
|
+
await notifyExternal(ctx, "failed", `开发流水线在「${stepTitle}」步骤中断:${(error as Error).message}`, {
|
|
1117
|
+
stepTitle,
|
|
1118
|
+
reason: (error as Error).message,
|
|
1119
|
+
logPath: ctx.logPath,
|
|
1120
|
+
resumeCommand: ctx.resumeCommand,
|
|
1121
|
+
});
|
|
1122
|
+
return "failed";
|
|
1123
|
+
} finally {
|
|
1124
|
+
await stopManagedServices(ctx);
|
|
1125
|
+
}
|
|
1126
|
+
}
|