paseo-req-kit 1.11.0 → 1.12.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/bin/req.mjs +5 -0
  3. package/config.example.json +3 -1
  4. package/contracts/cli-json.md +5 -1
  5. package/contracts/notifier-event.md +17 -1
  6. package/contracts/state.md +3 -1
  7. package/contracts/usage-record.md +4 -1
  8. package/docs/adr/0031-fewer-interventions.md +41 -0
  9. package/docs/adr/README.md +1 -0
  10. package/docs/guide//344/275/277/347/224/250/350/200/205/346/214/207/345/215/227.md +2 -0
  11. package/docs/guide//345/256/211/350/243/205/344/270/216/345/215/207/347/272/247.md +3 -2
  12. 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 +58 -4
  13. package/engine/prompts/04b-design-revise.md +15 -0
  14. package/engine/src/api.ts +74 -3
  15. package/engine/src/card-cmd.ts +21 -1
  16. package/engine/src/card.ts +38 -2
  17. package/engine/src/config-view.ts +289 -0
  18. package/engine/src/config.ts +65 -4
  19. package/engine/src/configure.ts +161 -36
  20. package/engine/src/control.ts +1 -0
  21. package/engine/src/doctor.ts +14 -0
  22. package/engine/src/factory.ts +162 -7
  23. package/engine/src/main.ts +1 -0
  24. package/engine/src/messages.ts +22 -0
  25. package/engine/src/notifiers.ts +32 -1
  26. package/engine/src/notify-test.ts +8 -2
  27. package/engine/src/paseo-profiles.ts +2 -0
  28. package/engine/src/paseo-runtime.ts +9 -0
  29. package/engine/src/pipeline.ts +222 -30
  30. package/engine/src/records.ts +8 -0
  31. package/engine/src/reply.ts +59 -0
  32. package/engine/src/runtime.ts +2 -0
  33. package/engine/src/state.ts +41 -1
  34. package/engine/src/stats.ts +90 -0
  35. package/engine/src/steps.ts +4 -1
  36. package/install.mjs +5 -1
  37. package/notifiers/im-template.mjs +31 -5
  38. package/package.json +2 -2
  39. package/paseo-plugin/OVERVIEW.md +2 -0
  40. package/paseo-plugin/client/config.tsx +216 -0
  41. package/paseo-plugin/client/data.ts +21 -2
  42. package/paseo-plugin/client/format.ts +1 -0
  43. package/paseo-plugin/client/inbox.tsx +173 -0
  44. package/paseo-plugin/client/models.tsx +117 -0
  45. package/paseo-plugin/client/overview.tsx +7 -3
  46. package/paseo-plugin/client/settings.tsx +3 -1
  47. package/paseo-plugin/index.client.tsx +10 -0
  48. package/paseo-plugin/index.server.ts +4 -1
  49. package/paseo-plugin/package.json +1 -1
  50. package/paseo-plugin/server/handlers.ts +21 -3
  51. package/paseo-plugin/shared/contracts.ts +77 -1
  52. package/skills/req/playbooks/new.md +14 -14
  53. package/skills/req/playbooks/small.md +3 -3
  54. package/skills/req/playbooks/unattended.md +5 -2
@@ -93,6 +93,7 @@ export function usesPaseoProfiles(config: PipelineConfig): boolean {
93
93
  return [
94
94
  config.agents.builder,
95
95
  config.agents.reviewer,
96
+ ...(config.agents.fallback ? [config.agents.fallback] : []),
96
97
  ...config.reviewPanel.members,
97
98
  ...config.designProposals.members,
98
99
  ...Object.values(config.phases.verify.agents),
@@ -108,6 +109,7 @@ export function resolveConfigProfiles(config: PipelineConfig, available: PaseoPr
108
109
  agents: {
109
110
  builder: resolveAgentProfile("agents.builder", config.agents.builder, available),
110
111
  reviewer: resolveAgentProfile("agents.reviewer", config.agents.reviewer, available),
112
+ ...(config.agents.fallback ? { fallback: resolveAgentProfile("agents.fallback", config.agents.fallback, available) } : {}),
111
113
  },
112
114
  reviewPanel: {
113
115
  ...config.reviewPanel,
@@ -352,6 +352,15 @@ export function createRuntimeWithClient(
352
352
  const api = await getScripts();
353
353
  if (api) await api.deleteSchedule(id);
354
354
  },
355
+ async providerStatus() {
356
+ if (!client.providers) return null;
357
+ try {
358
+ const snapshot = await client.providers.waitForReady({ timeoutMs: 15_000 });
359
+ return snapshot.entries.map((e) => ({ provider: e.provider, status: e.status }));
360
+ } catch {
361
+ return null;
362
+ }
363
+ },
355
364
  async capabilities() {
356
365
  if (!client.config) return null;
357
366
  try {
@@ -32,12 +32,12 @@ import {
32
32
  } from "./messages.js";
33
33
  import { kitVersion } from "./locations.js";
34
34
  import { type AgentUsage, describeTotals, totals } from "./usage.js";
35
- import { dispatchNotifiers, type NotifierEventType } from "./notifiers.js";
36
- import { describeExternal, type IntegrationEvent, type Runner, syncIntegration } from "./integrations.js";
35
+ import { dispatchNotifiers, type NotifierEventType, peopleOf } from "./notifiers.js";
36
+ import { describeExternal, type IntegrationEvent, renderArgs, resolveCommand, 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
39
  import type { AgentRuntime, ManagedService, ServiceTarget, StepAgent, WaitOutcome } from "./runtime.js";
40
- import { type CheckOutcome, type GateRun, type JointRepo, type PhaseName, type PipelineState, type RunPhases, type StepRecord, writeState } from "./state.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 {
@@ -125,7 +125,38 @@ class StepFailure extends Error {
125
125
  }
126
126
 
127
127
  /** The step gave up on its agent while the agent may still be working (1.11). */
128
- class AgentLeftRunning extends StepFailure {}
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
+ }
129
160
 
130
161
  /**
131
162
  * Errors that usually go away by themselves: provider rate limits and overload,
@@ -386,6 +417,10 @@ async function notifyExternal(ctx: PipelineContext, type: NotifierEventType, tex
386
417
  originAgentId: ctx.state.originAgentId,
387
418
  text,
388
419
  details,
420
+ people: (() => {
421
+ const source = readSource(ctx.repo, ctx.featureDir);
422
+ return peopleOf(type, source?.author ?? null, source ? { system: source.system, id: source.id, url: source.url } : null, ctx.config.people);
423
+ })(),
389
424
  },
390
425
  ctx.log,
391
426
  );
@@ -438,16 +473,67 @@ function pushBranch(ctx: PipelineContext): void {
438
473
  const stderr = (e as { stderr?: Buffer | string }).stderr?.toString().trim();
439
474
  error = (stderr || (e as Error).message).split("\n").filter(Boolean).slice(-2).join(" ");
440
475
  }
441
- ctx.state.pushed = [...(ctx.state.pushed ?? []).filter((p) => p.repo !== repo), { repo, remote: push.remote, branch, at, error }];
476
+ const before = ctx.state.pushed?.find((p) => p.repo === repo);
442
477
  ctx.log(error ? `推送 ${repo} 的分支到 ${push.remote}/${branch} 失败:${error}` : `已推送 ${repo} 的分支到 ${push.remote}/${branch}`);
478
+ // A merge request is made once; later pushes (after testing sent defects back) update it by themselves.
479
+ const mergeRequest = !error && push.mergeRequest && !before?.mergeRequest?.url ? makeMergeRequest(ctx, repo, branch, source) : (before?.mergeRequest ?? null);
480
+ ctx.state.pushed = [...(ctx.state.pushed ?? []).filter((p) => p.repo !== repo), { repo, remote: push.remote, branch, at, error, mergeRequest }];
443
481
  }
444
482
  save(ctx);
445
483
  }
446
484
 
485
+ /** The remote's default branch (origin/HEAD), for the merge request's target; main when unknown. */
486
+ function defaultBranch(repo: string, remote: string): string {
487
+ try {
488
+ const ref = execFileSync("git", ["symbolic-ref", "--short", `refs/remotes/${remote}/HEAD`], { cwd: repo, stdio: ["ignore", "pipe", "ignore"], windowsHide: true }).toString().trim();
489
+ return ref.startsWith(`${remote}/`) ? ref.slice(remote.length + 1) : ref || "main";
490
+ } catch {
491
+ return "main";
492
+ }
493
+ }
494
+
495
+ /**
496
+ * 1.12: a draft merge request for the pushed branch, with the machine's own command (push.mergeRequest).
497
+ * Its description is the handoff note and the card. Never merged here. Never throws.
498
+ */
499
+ function makeMergeRequest(ctx: PipelineContext, repo: string, branch: string, source: NonNullable<ReturnType<typeof readSource>>): { url: string | null; at: string; error: string | null } {
500
+ const mr = ctx.config.push.mergeRequest!;
501
+ const at = new Date().toISOString();
502
+ const handoff = path.join(repo, ctx.featureDir, "handoff.md");
503
+ const note = fs.existsSync(handoff) ? fs.readFileSync(handoff, "utf8").trim() : "";
504
+ const cardLine = `需求卡片:${source.system} ${source.id}${source.url ? ` ${source.url}` : ""}`;
505
+ const values: Record<string, string> = {
506
+ branch,
507
+ target: mr.target || defaultBranch(repo, ctx.config.push.remote),
508
+ title: `[${source.id}] ${specTitle(ctx)}`,
509
+ description: [`> 由 req-kit 在研发完成后创建的草稿,合并前请人工评审。`, "", cardLine, "", note.slice(0, 20_000)].join("\n"),
510
+ card: source.id,
511
+ cardUrl: source.url ?? "",
512
+ };
513
+ try {
514
+ const { file, prefix } = resolveCommand(mr.command);
515
+ const output = execFileSync(file, [...prefix, ...renderArgs(mr.args, values)], {
516
+ cwd: repo,
517
+ stdio: ["ignore", "pipe", "pipe"],
518
+ timeout: (mr.timeoutSeconds ?? 60) * 1000,
519
+ windowsHide: true,
520
+ }).toString();
521
+ const url = /https?:\/\/\S+/.exec(output)?.[0]?.replace(/[)。,,.]+$/, "") ?? null;
522
+ ctx.log(`已为 ${branch} 建草稿合并请求${url ? `:${url}` : "(输出中没有找到地址)"}`);
523
+ return { url, at, error: null };
524
+ } catch (e) {
525
+ const stderr = (e as { stderr?: Buffer | string }).stderr?.toString().trim();
526
+ const error = (stderr || (e as Error).message).split("\n").filter(Boolean).slice(-2).join(" ").slice(0, 300);
527
+ ctx.log(`为 ${branch} 建合并请求失败:${error}。分支已推送,可以手工建`);
528
+ return { url: null, at, error };
529
+ }
530
+ }
531
+
447
532
  /** Where the requirement's code is, for the card: the pushed branch, or the local one. */
448
533
  function branchText(ctx: PipelineContext): string | null {
449
534
  const mine = ctx.state.pushed?.find((p) => p.repo === ctx.repo);
450
- if (mine) return mine.error ? `分支 ${ctx.state.branch}(推送到 ${mine.remote}/${mine.branch} 失败:${mine.error})` : `代码已推送到 ${mine.remote} 的分支 ${mine.branch}`;
535
+ if (mine?.error) return `分支 ${ctx.state.branch}(推送到 ${mine.remote}/${mine.branch} 失败:${mine.error})`;
536
+ if (mine) return `代码已推送到 ${mine.remote} 的分支 ${mine.branch}${mine.mergeRequest?.url ? `,草稿合并请求 ${mine.mergeRequest.url}` : ""}`;
451
537
  return ctx.state.branch ? `分支 ${ctx.state.branch}` : null;
452
538
  }
453
539
 
@@ -487,15 +573,31 @@ async function milestone(ctx: PipelineContext, id: string, text: string): Promis
487
573
  await notifyExternal(ctx, "milestone", text, { milestone: id });
488
574
  }
489
575
 
490
- async function waitForTurn(ctx: PipelineContext, agent: StepAgent, def: StepDef, agentTitle: string, deadline: number): Promise<AgentUsage | null> {
576
+ /** A step's time limit; `extended` once it was given a second period (selfHeal). */
577
+ interface Deadline {
578
+ at: number;
579
+ extended: boolean;
580
+ key: string;
581
+ }
582
+
583
+ async function waitForTurn(ctx: PipelineContext, agent: StepAgent, def: StepDef, agentTitle: string, deadline: Deadline): Promise<AgentUsage | null> {
491
584
  const notifiedPermissions = new Set<string>();
492
585
  let consecutiveErrors = 0;
493
586
  for (;;) {
494
- const remaining = deadline - Date.now();
587
+ let remaining = deadline.at - Date.now();
588
+ if (remaining <= 0 && ctx.config.selfHeal && !deadline.extended) {
589
+ // Usually a big step, not a stuck one: the agent keeps its progress and gets as long again.
590
+ deadline.extended = true;
591
+ deadline.at = Date.now() + ctx.config.stepTimeoutMinutes[def.id] * 60_000;
592
+ remaining = deadline.at - Date.now();
593
+ recover(ctx, deadline.key, "extend_timeout", `超过 ${ctx.config.stepTimeoutMinutes[def.id]} 分钟仍未完成,agent 还在做,再给它同样长的时间`);
594
+ }
495
595
  if (remaining <= 0) {
596
+ const total = ctx.config.stepTimeoutMinutes[def.id] * (deadline.extended ? 2 : 1);
496
597
  throw new AgentLeftRunning(
497
598
  def.title,
498
- `超过 ${ctx.config.stepTimeoutMinutes[def.id]} 分钟仍未完成。agent「${agentTitle}」还在运行,没有停止它;继续时会先看它的情况:做完了就用它的结果,还在做就接着等它,不会另起一个 agent 重复做。`,
599
+ `超过 ${total} 分钟仍未完成。agent「${agentTitle}」还在运行,没有停止它;继续时会先看它的情况:做完了就用它的结果,还在做就接着等它,不会另起一个 agent 重复做。`,
600
+ true,
499
601
  );
500
602
  }
501
603
  let outcome;
@@ -506,7 +608,7 @@ async function waitForTurn(ctx: PipelineContext, agent: StepAgent, def: StepDef,
506
608
  consecutiveErrors += 1;
507
609
  ctx.log(`等待 ${agentTitle} 时出错(第 ${consecutiveErrors} 次):${(error as Error).message}`);
508
610
  if (consecutiveErrors >= 5) {
509
- throw new AgentLeftRunning(def.title, `连续 ${consecutiveErrors} 次无法获取 agent 状态:${(error as Error).message}`);
611
+ throw new AgentLeftRunning(def.title, `连续 ${consecutiveErrors} 次无法获取 agent 状态:${(error as Error).message}`, false);
510
612
  }
511
613
  await ctx.sleep(Math.min(60_000, 5_000 * consecutiveErrors));
512
614
  continue;
@@ -517,7 +619,7 @@ async function waitForTurn(ctx: PipelineContext, agent: StepAgent, def: StepDef,
517
619
  case "timeout":
518
620
  continue;
519
621
  case "error":
520
- throw new StepFailure(def.title, `agent 报告错误:${outcome.error ?? "未知错误"}`);
622
+ throw new AgentErrored(def.title, `agent 报告错误:${outcome.error ?? "未知错误"}`);
521
623
  case "permission": {
522
624
  const fresh = outcome.pendingPermissions.filter((p) => !notifiedPermissions.has(p.id));
523
625
  if (fresh.length > 0) {
@@ -562,31 +664,63 @@ async function runStep(
562
664
  extras: StepExtras = {},
563
665
  ): Promise<Record<string, unknown>> {
564
666
  const { attempts, backoffSeconds } = ctx.config.retry;
667
+ const key = extras.key ?? keyFor(stepKey(id, round), extras.target);
668
+ const fallback = ctx.config.agents.fallback ?? null;
669
+ let redone = false;
670
+ let usedFallback = false;
565
671
  for (let retry = 0; ; retry += 1) {
566
672
  try {
567
673
  return await runStepOnce(ctx, id, round, maxRounds, extras);
568
674
  } catch (error) {
569
675
  const message = (error as Error).message;
570
- if (!(error instanceof StepFailure) || !isTransientError(message) || retry >= attempts) {
676
+ const transient = error instanceof StepFailure && isTransientError(message);
677
+ const healable = error instanceof AgentErrored || error instanceof ResultInvalid;
678
+ // 1.12: a step whose agent errored or wrote no valid result is redone once with a new
679
+ // agent; one that keeps hitting rate limits gets the fallback model, when there is one.
680
+ if (error instanceof StepFailure && ctx.config.selfHeal && !transient && healable && !redone) {
681
+ redone = true;
682
+ const switchModel = fallback !== null && !usedFallback && error instanceof AgentErrored;
683
+ if (switchModel) usedFallback = true;
684
+ recover(
685
+ ctx,
686
+ key,
687
+ switchModel ? "fallback" : "redo",
688
+ `${message.slice(0, 160)};${switchModel ? "换备用模型" : "新开一个 agent"}重做这一步`,
689
+ );
690
+ extras = withRetryNote({ ...extras, ...(switchModel ? { profile: fallback } : {}) }, message);
691
+ continue;
692
+ }
693
+ if (error instanceof StepFailure && transient && retry >= attempts && fallback !== null && !usedFallback && !(error instanceof AgentLeftRunning)) {
694
+ usedFallback = true;
695
+ recover(ctx, key, "fallback", `重试 ${retry} 次后仍然${message.slice(0, 120)};换备用模型继续这一步`);
696
+ extras = withRetryNote({ ...extras, profile: fallback }, message);
697
+ continue;
698
+ }
699
+ if (!(error instanceof StepFailure) || !transient || retry >= attempts) {
571
700
  if (error instanceof StepFailure && retry > 0) {
572
- throw new StepFailure(error.stepTitle, `${message}(已自动重试 ${retry} 次)`);
701
+ throw new (error.constructor as typeof StepFailure)(error.stepTitle, `${message}(已自动重试 ${retry} 次)`);
573
702
  }
574
703
  throw error;
575
704
  }
576
705
  const wait = backoffSeconds.at(Math.min(retry, backoffSeconds.length - 1)) ?? 60;
577
- ctx.log(`${error.stepTitle} 遇到临时错误:${message}。${wait} 秒后自动重试(第 ${retry + 1} 次,最多 ${attempts} 次)`);
706
+ recover(ctx, key, "retry", `遇到临时错误:${message.slice(0, 160)}。${wait} 秒后自动重试(第 ${retry + 1} 次,最多 ${attempts} 次)`);
578
707
  await ctx.sleep(wait * 1000);
579
- extras = {
580
- ...extras,
581
- contextLines: [
582
- ...(extras.contextLines ?? []).filter((l) => !l.startsWith("自动重试:")),
583
- `自动重试:本步骤上一次因临时错误中断(${message.slice(0, 200)})。工作区里可能留有上一次没做完的改动,先用 git status 和 git diff 查看,在此基础上继续,不要重复提交`,
584
- ],
585
- };
708
+ extras = withRetryNote(extras, message);
586
709
  }
587
710
  }
588
711
  }
589
712
 
713
+ /** Tells the next agent of a step that an earlier one stopped part way. */
714
+ function withRetryNote(extras: StepExtras, message: string): StepExtras {
715
+ return {
716
+ ...extras,
717
+ contextLines: [
718
+ ...(extras.contextLines ?? []).filter((l) => !l.startsWith("自动重试:")),
719
+ `自动重试:本步骤上一次中断了(${message.slice(0, 200)})。工作区里可能留有上一次没做完的改动,先用 git status 和 git diff 查看,在此基础上继续,不要重复提交`,
720
+ ],
721
+ };
722
+ }
723
+
590
724
  async function runStepOnce(
591
725
  ctx: PipelineContext,
592
726
  id: StepId,
@@ -671,7 +805,7 @@ async function runStepOnce(
671
805
  ctx.state.history.push(record);
672
806
  save(ctx);
673
807
 
674
- const deadline = Date.now() + ctx.config.stepTimeoutMinutes[id] * 60_000;
808
+ const deadline: Deadline = { at: Date.now() + ctx.config.stepTimeoutMinutes[id] * 60_000, extended: false, key };
675
809
  record.usage = (await waitForTurn(ctx, agent, def, agentTitle, deadline)) ?? record.usage ?? null;
676
810
  let validation = readResultFile(resultAbs, def);
677
811
  while (!validation.ok && record.followUps < ctx.config.resultFollowUps) {
@@ -683,7 +817,7 @@ async function runStepOnce(
683
817
  validation = readResultFile(resultAbs, def);
684
818
  }
685
819
  if (!validation.ok) {
686
- throw new StepFailure(def.title, `提醒 ${record.followUps} 次后仍没有得到有效的结果文件:${validation.error}`);
820
+ throw new ResultInvalid(def.title, `提醒 ${record.followUps} 次后仍没有得到有效的结果文件:${validation.error}`);
687
821
  }
688
822
 
689
823
  if (extras.gate) {
@@ -761,7 +895,7 @@ async function checkLeftover(
761
895
  last.result = validation.value;
762
896
  ctx.state.results[key] = validation.value;
763
897
  save(ctx);
764
- ctx.log(`${key} 上一次的 agent ${last.agentId} 已经做完,沿用它的结果:${JSON.stringify(validation.value)}`);
898
+ recover(ctx, key, "reuse_result", `上一次的 agent ${last.agentId} 已经做完,沿用它的结果`);
765
899
  if (ctx.config.archiveStepAgents) await agent.archive().catch(() => undefined);
766
900
  return { done: validation.value };
767
901
  }
@@ -769,7 +903,10 @@ async function checkLeftover(
769
903
  return {};
770
904
  }
771
905
  if (status === "timeout" || status === "permission") {
772
- if ((last.reattached ?? 0) < 1) return { agent, reattached: (last.reattached ?? 0) + 1 };
906
+ if ((last.reattached ?? 0) < 1) {
907
+ recover(ctx, key, "reattach", `上一次的 agent ${last.agentId} 还在做,接着等它,不另起一个`);
908
+ return { agent, reattached: (last.reattached ?? 0) + 1 };
909
+ }
773
910
  ctx.log(`${key} 上一次的 agent ${last.agentId} 已经等过两轮仍未完成,停止它并新建一个 agent`);
774
911
  await ctx.runtime
775
912
  .interruptAgent(last.agentId, "这一步已超时,流水线会另起一个 agent 重做。请立即停止当前工作,不要再修改任何文件,也不要写结果文件,直接结束。")
@@ -1023,6 +1160,26 @@ async function scheduleApprovalContinue(ctx: PipelineContext): Promise<string |
1023
1160
  }
1024
1161
 
1025
1162
  /** A Paseo schedule that has the chat continue the run later; returns the time as text, or null. */
1163
+ /** Rate limits and used-up subscriptions, as opposed to network trouble. */
1164
+ const QUOTA_ERROR = /rate.?limit|too many requests|\b429\b|quota|usage limit|额度|限流/i;
1165
+
1166
+ /**
1167
+ * 1.12: when a step stopped on rate limits and Paseo says when a used-up subscription window
1168
+ * resets, the time to continue (5 minutes after the reset, within 24 hours); null otherwise.
1169
+ */
1170
+ async function quotaResetTime(ctx: PipelineContext, reason: string): Promise<Date | null> {
1171
+ if (!QUOTA_ERROR.test(reason)) return null;
1172
+ const profiles = [ctx.config.agents.builder, ctx.config.agents.reviewer, ...(ctx.config.agents.fallback ? [ctx.config.agents.fallback] : [])];
1173
+ const providers = [...new Set(profiles.map((p) => (p.provider ?? "").split("/")[0]).filter(Boolean))];
1174
+ const quotas = await ctx.runtime.providerQuota(providers).catch(() => null);
1175
+ const now = Date.now();
1176
+ const resets = (quotas ?? [])
1177
+ .filter((q) => q.resetsAt && ((q.usedPct !== null && q.usedPct >= 95) || q.tone === "danger"))
1178
+ .map((q) => new Date(q.resetsAt!).getTime())
1179
+ .filter((t) => t > now && t < now + 24 * 3600_000);
1180
+ return resets.length === 0 ? null : new Date(Math.max(...resets) + 5 * 60_000);
1181
+ }
1182
+
1026
1183
  async function scheduleAutoResume(ctx: PipelineContext, reason: string): Promise<string | null> {
1027
1184
  const minutes = ctx.config.autoResumeMinutes;
1028
1185
  const origin = ctx.state.originAgentId;
@@ -1032,7 +1189,8 @@ async function scheduleAutoResume(ctx: PipelineContext, reason: string): Promise
1032
1189
  ctx.log(`这次尝试已经自动继续过 ${used} 次,不再预约`);
1033
1190
  return null;
1034
1191
  }
1035
- const at = new Date(Date.now() + minutes * 60_000);
1192
+ const reset = await quotaResetTime(ctx, reason);
1193
+ const at = reset ?? new Date(Date.now() + minutes * 60_000);
1036
1194
  try {
1037
1195
  const id = await ctx.runtime.scheduleMessage({
1038
1196
  agentId: origin,
@@ -1049,7 +1207,9 @@ async function scheduleAutoResume(ctx: PipelineContext, reason: string): Promise
1049
1207
  ctx.state.schedules = [...(ctx.state.schedules ?? []), { id, kind: "auto-resume", at: at.toISOString() }];
1050
1208
  ctx.state.autoResumes = { attempt: ctx.state.attempt, count: used + 1 };
1051
1209
  save(ctx);
1052
- ctx.log(`已预约 ${minutes} 分钟后自动继续(Paseo 定时任务 ${id})`);
1210
+ const when = at.toLocaleString("zh-CN", { hour12: false });
1211
+ if (reset) recover(ctx, "pipeline", "quota_wait", `额度用完,Paseo 显示会恢复;已预约在 ${when} 自动继续(Paseo 定时任务 ${id})`);
1212
+ else recover(ctx, "pipeline", "auto_resume", `已预约 ${minutes} 分钟后自动继续(Paseo 定时任务 ${id})`);
1053
1213
  return at.toLocaleString("zh-CN", { hour12: false, month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" });
1054
1214
  } catch (error) {
1055
1215
  ctx.log(`预约自动继续失败:${(error as Error).message}`);
@@ -1080,7 +1240,7 @@ async function checkQuota(ctx: PipelineContext): Promise<void> {
1080
1240
  [
1081
1241
  "【需求开发流水线通知】这条消息来自后台流水线,不是用户发的。",
1082
1242
  `额度提醒:${items.join(";")}。`,
1083
- "流水线照常进行;额度用完时会遇到限流,流水线会自动等待重试,重试用完会中断,之后可以继续。如果希望等额度恢复后再开发,请转告用户,用户同意的话可以先暂停。",
1243
+ `流水线照常进行;额度用完时会遇到限流,流水线会先重试${ctx.config.agents.fallback ? "、再换备用模型" : ""},仍不行就预约在额度恢复后自动继续,不需要人处理。如果希望等额度恢复后再开发,请转告用户,用户同意的话可以先暂停,再预约在恢复时间开始。`,
1084
1244
  ].join("\n"),
1085
1245
  );
1086
1246
  }
@@ -1567,11 +1727,33 @@ async function assessmentPhase(ctx: PipelineContext): Promise<void> {
1567
1727
  for (let r = 1; r <= rounds.designReview; r++) {
1568
1728
  const review = await runReview(ctx, "design_review", r, rounds.designReview);
1569
1729
  if (review.verdict === "approved") break;
1570
- if (review.verdict === "blocked") throw new BusinessDecisionNeeded(review.questions as string[]);
1730
+ if (review.verdict === "blocked") {
1731
+ await answerFirst(ctx, review, r, rounds.designReview);
1732
+ continue;
1733
+ }
1571
1734
  await runStep(ctx, "design_revise", r, rounds.designReview);
1572
1735
  }
1573
1736
  }
1574
1737
 
1738
+ /**
1739
+ * The review says only the requester can decide (1.12): the builder first answers what the
1740
+ * code, the documents and the spec settle, in a revision of the design; what is left goes to
1741
+ * the requester, each with a recommended answer.
1742
+ */
1743
+ async function answerFirst(ctx: PipelineContext, review: Record<string, unknown>, round: number, maxRounds: number): Promise<void> {
1744
+ const questions = (review.questions as string[]) ?? [];
1745
+ if (!ctx.config.answerQuestionsFirst) throw new BusinessDecisionNeeded(questions);
1746
+ const revised = await runStep(ctx, "design_revise", round, maxRounds, {
1747
+ contextLines: [`审核结论:需要需求方决定(${questions.length} 个问题,见 design-review.md)。按本步骤说明中"审核要求需求方决定时"一节,先自己找答案`],
1748
+ });
1749
+ const left = Array.isArray(revised.business_questions) ? (revised.business_questions as string[]).map((q) => q.trim()).filter(Boolean) : [];
1750
+ const answered = Math.max(0, questions.length - left.length);
1751
+ if (answered > 0) {
1752
+ recover(ctx, `design_revise-r${round}`, "self_answer", `方案审核提出的 ${questions.length} 个问题中,${answered} 个由 builder 从代码和文档中确定了答案${left.length > 0 ? `,${left.length} 个交给需求方` : ",不用等需求方"}`);
1753
+ }
1754
+ if (left.length > 0) throw new BusinessDecisionNeeded(left);
1755
+ }
1756
+
1575
1757
  /** Develop phase: design → design review ⇄ revise → implement → code review ⇄ fix → handoff. Returns the design result. */
1576
1758
  async function developPhase(ctx: PipelineContext): Promise<Record<string, unknown>> {
1577
1759
  const rounds = ctx.config.profiles[ctx.state.profile].rounds;
@@ -1602,7 +1784,10 @@ async function developPhase(ctx: PipelineContext): Promise<Record<string, unknow
1602
1784
  const review = await runReview(ctx, "design_review", r, rounds.designReview);
1603
1785
  designRounds = r;
1604
1786
  if (review.verdict === "approved") break;
1605
- if (review.verdict === "blocked") throw new BusinessDecisionNeeded(review.questions as string[]);
1787
+ if (review.verdict === "blocked") {
1788
+ await answerFirst(ctx, review, r, rounds.designReview);
1789
+ continue;
1790
+ }
1606
1791
  // changes_required. The last round's revision goes straight to implementation;
1607
1792
  // the implementer reads both the review and the revision record.
1608
1793
  await runStep(ctx, "design_revise", r, rounds.designReview);
@@ -1876,6 +2061,8 @@ async function flow(ctx: PipelineContext): Promise<Record<string, unknown>> {
1876
2061
  export async function runPipeline(ctx: PipelineContext): Promise<PipelineOutcome> {
1877
2062
  ctx.state.status = "running";
1878
2063
  ctx.state.stopReason = null;
2064
+ ctx.state.stopKind = null;
2065
+ ctx.state.pendingQuestions = null;
1879
2066
  save(ctx);
1880
2067
  await integrate(ctx, phaseOf(ctx) === "verify" ? "verify" : "started");
1881
2068
  try {
@@ -1917,6 +2104,7 @@ export async function runPipeline(ctx: PipelineContext): Promise<PipelineOutcome
1917
2104
  if (error instanceof PhaseGate) {
1918
2105
  ctx.state.status = "phase_done";
1919
2106
  ctx.state.stopReason = null;
2107
+ ctx.state.stopKind = "phase_gate";
1920
2108
  save(ctx);
1921
2109
  await stopManagedServices(ctx);
1922
2110
  const verifyAt = await scheduleVerifyStart(ctx);
@@ -1947,6 +2135,7 @@ export async function runPipeline(ctx: PipelineContext): Promise<PipelineOutcome
1947
2135
  if (error instanceof ApprovalNeeded) {
1948
2136
  ctx.state.status = "waiting_for_approval";
1949
2137
  ctx.state.stopReason = null;
2138
+ ctx.state.stopKind = "approval";
1950
2139
  save(ctx);
1951
2140
  const autoContinueAt = await scheduleApprovalContinue(ctx);
1952
2141
  const reminderHours = await scheduleReminder(ctx, "approval");
@@ -1974,6 +2163,8 @@ export async function runPipeline(ctx: PipelineContext): Promise<PipelineOutcome
1974
2163
  if (error instanceof BusinessDecisionNeeded) {
1975
2164
  const reproduce = error.source === "reproduce";
1976
2165
  ctx.state.status = "waiting_for_business";
2166
+ ctx.state.stopKind = reproduce ? "reproduce" : "business";
2167
+ ctx.state.pendingQuestions = error.questions;
1977
2168
  ctx.state.stopReason = `${reproduce ? "没能复现缺陷,需要需求方补充信息" : "方案审核需要需求方决定"}:${error.questions.join(";")}`;
1978
2169
  save(ctx);
1979
2170
  const reminderHours = await scheduleReminder(ctx, error.questions);
@@ -1994,6 +2185,7 @@ export async function runPipeline(ctx: PipelineContext): Promise<PipelineOutcome
1994
2185
  const stepTitle = error instanceof StepFailure ? error.stepTitle : "流水线";
1995
2186
  ctx.state.status = "failed";
1996
2187
  ctx.state.stopReason = `${stepTitle}:${(error as Error).message}`;
2188
+ ctx.state.stopKind = failureKind(error);
1997
2189
  save(ctx);
1998
2190
  const autoResumeAt =
1999
2191
  error instanceof StepFailure && isTransientError((error as Error).message) ? await scheduleAutoResume(ctx, (error as Error).message) : null;
@@ -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,
@@ -0,0 +1,59 @@
1
+ // `req reply <需求> <回复>` (1.12): passes an answer written in the plugin's inbox to the
2
+ // requirement's chat, which handles it as if the user had typed it there. Nothing else changes:
3
+ // the chat updates the requirement and continues the pipeline itself.
4
+ import { locate } from "./api.js";
5
+ import { DEFAULT_CONFIG, loadConfig, type PipelineConfig } from "./config.js";
6
+ import { defaultConfigPath } from "./locations.js";
7
+ import { pluginReplyMessage } from "./messages.js";
8
+ import { loadRuntimeFactory } from "./runtime-loader.js";
9
+ import { readState, statePath } from "./state.js";
10
+
11
+ async function main(argv: string[]): Promise<number> {
12
+ const [feature, ...words] = argv;
13
+ const text = words.join(" ").trim();
14
+ if (!feature || !text) {
15
+ console.error("用法:req reply <需求> <回复内容>");
16
+ return 2;
17
+ }
18
+ const { repo, featureDir } = locate(process.cwd(), feature);
19
+ const state = readState(statePath(repo, featureDir));
20
+ if (!state || (state.status !== "waiting_for_business" && state.status !== "waiting_for_approval")) {
21
+ console.error(`${featureDir} 现在没有在等待回答或确认,不用回复。`);
22
+ return 1;
23
+ }
24
+ if (!state.originAgentId) {
25
+ console.error("这个需求不是从 Paseo 对话中开始的,没有可以转达的对话。请在终端或卡片中处理。");
26
+ return 1;
27
+ }
28
+ let config: PipelineConfig;
29
+ try {
30
+ config = loadConfig(defaultConfigPath(), repo).config;
31
+ } catch {
32
+ // Only the daemon address is needed here.
33
+ config = DEFAULT_CONFIG;
34
+ }
35
+ const runtime = await (await loadRuntimeFactory())({ config, log: () => undefined });
36
+ try {
37
+ await runtime.notifyAgent(
38
+ state.originAgentId,
39
+ pluginReplyMessage({
40
+ featureDir,
41
+ waitingFor: state.status === "waiting_for_approval" ? "approval" : "business",
42
+ text: text.slice(0, 8000),
43
+ questions: state.pendingQuestions ?? [],
44
+ }),
45
+ );
46
+ } finally {
47
+ await runtime.close().catch(() => undefined);
48
+ }
49
+ console.log("已转给这个需求的对话,它会按回复更新需求说明并继续开发。");
50
+ return 0;
51
+ }
52
+
53
+ main(process.argv.slice(2)).then(
54
+ (code) => process.exit(code),
55
+ (error: Error) => {
56
+ console.error(`没有转达:${error.message}`);
57
+ process.exit(1);
58
+ },
59
+ );
@@ -143,6 +143,8 @@ export interface AgentRuntime {
143
143
  recentActivity(agentId: string, limit: number): Promise<ActivityItem[] | null>;
144
144
  /** Which Paseo tools step agents get (Settings → Agents); null when unknown. */
145
145
  capabilities(): Promise<{ paseoTools: boolean; browser: boolean } | null>;
146
+ /** 1.12: whether each agent provider is installed and logged in ("ready"); null when the daemon does not say. */
147
+ providerStatus(): Promise<Array<{ provider: string; status: string }> | null>;
146
148
  /**
147
149
  * Paseo schedule that sends `prompt` into an existing agent's conversation (the chat):
148
150
  * once at `at`, or on `cron` (5 fields, local time zone). Returns the schedule ID, or
@@ -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;
@@ -69,6 +95,12 @@ export interface PipelineState {
69
95
  results: Record<string, Record<string, unknown>>;
70
96
  history: StepRecord[];
71
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[];
102
+ /** 1.12: the questions a run waiting for the requester (waiting_for_business) asks; cleared when it runs again. */
103
+ pendingQuestions?: string[] | null;
72
104
  outcome: string | null;
73
105
  /** "quick" or "standard"; decides loop rounds and optional steps. */
74
106
  profile: ProfileName;
@@ -120,7 +152,15 @@ export interface PipelineState {
120
152
  * Branches pushed for a requirement that started from a card (1.10, `push` config): one entry
121
153
  * per repository (the main one, then joint ones), the latest attempt. `error` is null when it worked.
122
154
  */
123
- pushed?: Array<{ repo: string; remote: string; branch: string; at: string; error: string | null }> | null;
155
+ pushed?: Array<{
156
+ repo: string;
157
+ remote: string;
158
+ branch: string;
159
+ at: string;
160
+ error: string | null;
161
+ /** 1.12: the draft merge request made after the push (push.mergeRequest); made once per repository. */
162
+ mergeRequest?: { url: string | null; at: string; error: string | null } | null;
163
+ }> | null;
124
164
  }
125
165
 
126
166
  export interface JointRepo {