paseo-req-kit 1.11.0-beta.1 → 1.12.0-beta.2

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