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.
- package/CHANGELOG.md +45 -0
- package/bin/req.mjs +5 -0
- package/config.example.json +3 -1
- package/contracts/cli-json.md +5 -1
- package/contracts/notifier-event.md +17 -1
- package/contracts/state.md +3 -1
- package/contracts/usage-record.md +4 -1
- package/docs/adr/0031-fewer-interventions.md +41 -0
- package/docs/adr/README.md +1 -0
- package/docs/guide//344/275/277/347/224/250/350/200/205/346/214/207/345/215/227.md +2 -0
- package/docs/guide//345/256/211/350/243/205/344/270/216/345/215/207/347/272/247.md +3 -2
- package/docs/guide//351/241/271/347/233/256/345/256/232/345/210/266/344/270/216/350/277/220/350/241/214/350/247/204/345/210/231.md +58 -4
- package/engine/prompts/04b-design-revise.md +15 -0
- package/engine/src/api.ts +74 -3
- package/engine/src/card-cmd.ts +21 -1
- package/engine/src/card.ts +38 -2
- package/engine/src/config-view.ts +289 -0
- package/engine/src/config.ts +65 -4
- package/engine/src/configure.ts +161 -36
- package/engine/src/control.ts +1 -0
- package/engine/src/doctor.ts +14 -0
- package/engine/src/factory.ts +162 -7
- package/engine/src/main.ts +1 -0
- package/engine/src/messages.ts +22 -0
- package/engine/src/notifiers.ts +32 -1
- package/engine/src/notify-test.ts +8 -2
- package/engine/src/paseo-profiles.ts +2 -0
- package/engine/src/paseo-runtime.ts +9 -0
- package/engine/src/pipeline.ts +222 -30
- package/engine/src/records.ts +8 -0
- package/engine/src/reply.ts +59 -0
- package/engine/src/runtime.ts +2 -0
- package/engine/src/state.ts +41 -1
- package/engine/src/stats.ts +90 -0
- package/engine/src/steps.ts +4 -1
- package/install.mjs +5 -1
- package/notifiers/im-template.mjs +31 -5
- package/package.json +2 -2
- package/paseo-plugin/OVERVIEW.md +2 -0
- package/paseo-plugin/client/config.tsx +216 -0
- package/paseo-plugin/client/data.ts +21 -2
- package/paseo-plugin/client/format.ts +1 -0
- package/paseo-plugin/client/inbox.tsx +173 -0
- package/paseo-plugin/client/models.tsx +117 -0
- package/paseo-plugin/client/overview.tsx +7 -3
- package/paseo-plugin/client/settings.tsx +3 -1
- package/paseo-plugin/index.client.tsx +10 -0
- package/paseo-plugin/index.server.ts +4 -1
- package/paseo-plugin/package.json +1 -1
- package/paseo-plugin/server/handlers.ts +21 -3
- package/paseo-plugin/shared/contracts.ts +77 -1
- package/skills/req/playbooks/new.md +14 -14
- package/skills/req/playbooks/small.md +3 -3
- 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 {
|
package/engine/src/pipeline.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
`超过 ${
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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
|
-
|
|
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")
|
|
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")
|
|
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;
|
package/engine/src/records.ts
CHANGED
|
@@ -165,6 +165,10 @@ export interface RunRecord {
|
|
|
165
165
|
/** The report's verdict when the run completed (completed, completed_with_issues, not_completed). */
|
|
166
166
|
outcome: string | null;
|
|
167
167
|
stopReason: string | null;
|
|
168
|
+
/** 1.12: why the run stopped for a person, as a code (absent in older records). */
|
|
169
|
+
stopKind?: string | null;
|
|
170
|
+
/** 1.12: what the pipeline handled by itself during the run, by kind (absent in older records). */
|
|
171
|
+
recoveries?: Record<string, number>;
|
|
168
172
|
};
|
|
169
173
|
agents: { builder: string | null; reviewer: string | null };
|
|
170
174
|
steps: StepUsageRecord[];
|
|
@@ -241,6 +245,10 @@ export function runRecord(input: {
|
|
|
241
245
|
status: state.status,
|
|
242
246
|
outcome: state.status === "completed" ? state.outcome : null,
|
|
243
247
|
stopReason: state.stopReason,
|
|
248
|
+
stopKind: state.status === "completed" || state.status === "running" ? null : (state.stopKind ?? null),
|
|
249
|
+
recoveries: (state.recoveries ?? [])
|
|
250
|
+
.filter((r) => new Date(r.at).getTime() >= since)
|
|
251
|
+
.reduce<Record<string, number>>((m, r) => ({ ...m, [r.kind]: (m[r.kind] ?? 0) + 1 }), {}),
|
|
244
252
|
},
|
|
245
253
|
agents: input.agents,
|
|
246
254
|
steps,
|
|
@@ -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
|
+
);
|
package/engine/src/runtime.ts
CHANGED
|
@@ -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
|
package/engine/src/state.ts
CHANGED
|
@@ -27,6 +27,32 @@ export interface RunPhases {
|
|
|
27
27
|
defects: DefectHandling;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Why a run stopped for a person (1.12). Necessary decisions: approval (the plan), business
|
|
32
|
+
* (a question only the requester can answer), reproduce (a defect that could not be reproduced),
|
|
33
|
+
* phase_gate (development done, waiting for testing). The rest are interruptions.
|
|
34
|
+
*/
|
|
35
|
+
export type StopKind =
|
|
36
|
+
| "approval"
|
|
37
|
+
| "business"
|
|
38
|
+
| "reproduce"
|
|
39
|
+
| "phase_gate"
|
|
40
|
+
| "timeout"
|
|
41
|
+
| "agent_error"
|
|
42
|
+
| "invalid_result"
|
|
43
|
+
| "transient"
|
|
44
|
+
| "stopped"
|
|
45
|
+
| "process_exit"
|
|
46
|
+
| "other";
|
|
47
|
+
|
|
48
|
+
/** Something the pipeline handled by itself (1.12). */
|
|
49
|
+
export interface Recovery {
|
|
50
|
+
at: string;
|
|
51
|
+
key: string;
|
|
52
|
+
kind: "retry" | "reattach" | "reuse_result" | "extend_timeout" | "redo" | "fallback" | "auto_resume" | "quota_wait" | "self_answer";
|
|
53
|
+
detail: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
30
56
|
export interface StepRecord {
|
|
31
57
|
key: string;
|
|
32
58
|
stepId: StepId;
|
|
@@ -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<{
|
|
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 {
|