paseo-req-kit 1.10.0-beta.3 → 1.11.0
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 +31 -0
- package/bin/req.mjs +4 -0
- package/docs/adr/0030-card-intake-process.md +22 -0
- package/docs/adr/README.md +1 -0
- package/docs/guide//351/241/271/347/233/256/345/256/232/345/210/266/344/270/216/350/277/220/350/241/214/350/247/204/345/210/231.md +38 -1
- package/engine/src/card-cmd.ts +107 -61
- package/engine/src/config.ts +13 -1
- package/engine/src/factory.ts +406 -0
- package/engine/src/integrations.ts +29 -1
- package/engine/src/paseo-runtime.ts +3 -0
- package/engine/src/pipeline.ts +82 -7
- package/engine/src/runtime.ts +2 -0
- package/engine/src/state.ts +7 -0
- package/package.json +1 -1
- package/paseo-plugin/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,37 @@
|
|
|
2
2
|
|
|
3
3
|
每个版本分为"使用者会看到的变化"和"升级须知"。升级步骤见 [docs/guide/安装与升级.md](docs/guide/安装与升级.md)。
|
|
4
4
|
|
|
5
|
+
## 1.11.0
|
|
6
|
+
|
|
7
|
+
稳定版:包含 1.10 的"从卡片开始的需求"和 1.11 的接单器(详见下面的 beta 记录),并修正步骤超时后会重复开 agent 的问题。
|
|
8
|
+
|
|
9
|
+
### 使用者会看到的变化
|
|
10
|
+
|
|
11
|
+
- **从卡片开始的需求**(1.10):`req start --from <系统>:<卡号>` 读卡片、建需求会话;卡片描述末尾的 AI 区和需求方回复区代替对话;按子系统自动选仓库;工厂模式下研发完成后可以推送 `req/` 分支;统计按需求方汇总。
|
|
12
|
+
- **接单器**(1.11):`req factory` 定时接标题带 `[AI]`/`【AI】` 的新卡片,并把需求方的回复转给需求会话。
|
|
13
|
+
- **修正:步骤超时后不再重复开 agent**。以前超时只判定失败、不看 agent 实际状态,继续时会新建一个 agent,而旧的仍在运行,两个 agent 同时改同一批文件、在同一个工作区提交、写同一个结果文件,需要手动停止旧的。现在继续(`--resume`、自动重试)时先看上一次的 agent:做完了就用它的结果;还在做就接着等它一轮;第二次仍超时才停止它,再新建。连续读不到 agent 状态时同样处理。
|
|
14
|
+
|
|
15
|
+
### 升级须知
|
|
16
|
+
|
|
17
|
+
- 从 1.9 升级:先看 1.10.0-beta.1 至 1.11.0-beta.1 的升级须知(对接配置的 `query`、`describe`、`list`,项目配置的 `cardSource`,本机或团队配置的 `push`、`factory`)。不用卡片的项目不需要改任何配置。
|
|
18
|
+
- 状态文件的步骤记录新增 `leftRunning`、`reattached` 两项,旧状态文件照常可用。
|
|
19
|
+
|
|
20
|
+
## 1.11.0-beta.1
|
|
21
|
+
|
|
22
|
+
接单器:一台共享研发机器自动接卡片、转达回复,需求方只在卡片上操作。
|
|
23
|
+
|
|
24
|
+
### 使用者会看到的变化
|
|
25
|
+
|
|
26
|
+
- **`req factory`**:每隔几分钟(`factory.pollMinutes`,默认 5)检查一次需求管理系统:本机登记的仓库所在空间中,标题以 `[AI]` 或 `【AI】` 开头、还没开始过的卡片,自动按子系统进入对应仓库、开始需求;需求方在卡片中的新回复自动转给需求会话。
|
|
27
|
+
- `req factory once` 检查一次;`start` / `stop` 在后台运行;`status` 看最近一次检查和没能接的卡片;`service` 给出作为系统服务(开机自动运行)的写法。
|
|
28
|
+
- 每次最多开始 3 个需求(`factory.maxNewPerPoll`),其余下次再接;接不了的卡片记下原因,卡片改了马上再试,否则一天后再试。
|
|
29
|
+
- **修正**:`req start --from` 在建需求会话时失败(例如 Paseo 没在运行)后,再运行一次会补建会话,而不是提示"已经开始过"。
|
|
30
|
+
|
|
31
|
+
### 升级须知
|
|
32
|
+
|
|
33
|
+
- 需要在对接配置中加上 `list`(列出卡片的命令),写法见《项目定制与运行规则》的"接单器"一节;接单的仓库要在 `cardSource` 中写上空间(`projectId`)。
|
|
34
|
+
- 新增配置 `factory`(只能写在本机或团队配置中)、本机目录 `~/.req-kit/factory/`。现在只支持一台机器接单。设计决定见 ADR 0030。
|
|
35
|
+
|
|
5
36
|
## 1.10.0-beta.3
|
|
6
37
|
|
|
7
38
|
从卡片开始的需求:按子系统自动选仓库、研发完成后推送分支、统计按需求方汇总。
|
package/bin/req.mjs
CHANGED
|
@@ -36,6 +36,7 @@ const HELP = `req-kit ${version}(${kitRoot})
|
|
|
36
36
|
req card publish <卡号> 把需求会话写的 card-ai.md 写进卡片的 AI 区
|
|
37
37
|
req card list 本机从卡片开始的需求
|
|
38
38
|
req card route 本机接收卡片的仓库和子系统(--add <仓库> 登记)
|
|
39
|
+
req factory start|stop|status 接单器:定时为标题带 [AI] 的新卡片开始需求、转达回复(once 检查一次)
|
|
39
40
|
|
|
40
41
|
使用情况(可以在任何目录运行):
|
|
41
42
|
req stats [--since 30d] [--month 2026-10]
|
|
@@ -160,6 +161,9 @@ switch (command) {
|
|
|
160
161
|
case "card":
|
|
161
162
|
code = needTsx() ? node([tsxCli, path.join(kitRoot, "engine", "src", "card-cmd.ts"), ...rest]) : 1;
|
|
162
163
|
break;
|
|
164
|
+
case "factory":
|
|
165
|
+
code = needTsx() ? node([tsxCli, path.join(kitRoot, "engine", "src", "factory.ts"), ...rest]) : 1;
|
|
166
|
+
break;
|
|
163
167
|
case "doctor":
|
|
164
168
|
code = node([path.join(kitRoot, "bin", "req-doctor.mjs"), ...rest]);
|
|
165
169
|
break;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# 0030 接单器:共享机器上定时接卡片、转达回复
|
|
2
|
+
|
|
3
|
+
- 状态:已采纳(1.11.0-beta.1;建在 [0029](0029-requirements-from-cards.md) 之上)
|
|
4
|
+
|
|
5
|
+
## 背景
|
|
6
|
+
|
|
7
|
+
1.10 已经可以从一张卡片开始需求(`req start --from`)、把需求方在卡片中的回复转给需求会话(`req card check`),但这两步都要有人运行。"软件工厂"要求需求方只在卡片上操作:写好卡片、标题加上约定的标识,就有一台共享研发机器接手。
|
|
8
|
+
|
|
9
|
+
## 决定
|
|
10
|
+
|
|
11
|
+
- **一个常驻进程,两件事**:`req factory run` 每隔 `factory.pollMinutes`(默认 5 分钟)检查一次:先用对接配置的 `list` 命令列出各空间(本机登记仓库的 `cardSource.projectId`)中的卡片,标题以 `factory.titleTags`(默认 `[AI]`、`【AI】`)开头、本机还没开始过的,按 1.10 的 `req start --from` 开始(按子系统选仓库、建需求会话);再按 `req card check --all` 转达回复,跳过流水线已完成的需求。同一台机器上一次只做一次检查(`~/.req-kit/factory/poll.lock`)。
|
|
12
|
+
- **轮询外部系统,不用回调**:需求管理系统没有我们能用的事件推送;轮询的间隔是几分钟,对需求开发足够,失败了下次再来。卡片状态、条件(例如只查"新建"的用户故事)写在 `list` 的参数里,由团队配置决定。
|
|
13
|
+
- **克制地接单**:每次最多开始 `factory.maxNewPerPoll`(默认 3)个需求,其余下次再接;流水线本身仍受 `maxConcurrentPipelines` 限制。接不了的卡片(例如子系统没有对应的仓库)记下原因,卡片的标题和子系统不变就一天内不再试,`req factory status` 中可以看到。
|
|
14
|
+
- **只读卡片、不改卡片状态来"占位"**:本机的登记(`~/.req-kit/cards/`)就是去重的依据。卡片状态仍只由流水线事件改(开发中、待测试)。将来多台机器同时接单时再引入占位(例如改状态或经内网 Hub 派工),见 [0029](0029-requirements-from-cards.md)。
|
|
15
|
+
- **运行方式交给系统**:`req factory start` / `stop` 在后台运行,适合先试;长期运行用系统服务(`req factory service` 给出 systemd 用户服务或 Windows 计划任务的写法),机器重启后自动恢复。接单器通过 Paseo daemon 创建需求会话,daemon 需要同时运行。
|
|
16
|
+
- **配置只在本机或团队配置中**:`factory` 和 `integrations` 一样会让机器做事,写在项目配置中会被忽略。
|
|
17
|
+
|
|
18
|
+
## 影响
|
|
19
|
+
|
|
20
|
+
- 对接配置新增 `list`(参数可用 `{projectId}`、`{page}`、`{pageSize}`、`{titleTag}`;`fields` 写每张卡片中 id、title 等的位置;`items` 写输出中列表的位置,不写时依次找 data.list、data.records、data.items、data.rows、data)。
|
|
21
|
+
- 新增配置 `factory`(`pollMinutes`、`titleTags`、`maxNewPerPoll`)、命令 `req factory once | run | start | stop | status | service`、本机目录 `~/.req-kit/factory/`(日志、状态、没能接的卡片)。
|
|
22
|
+
- `req start --from` 中途失败(例如 Paseo 没在运行,会话没建成)时,再次运行会补建会话,而不是当作已经开始过。
|
package/docs/adr/README.md
CHANGED
|
@@ -31,3 +31,4 @@ req-kit 自身的设计决定。修改其中任何一条,请新增一条 ADR
|
|
|
31
31
|
- [0027 Paseo 插件:随 req-kit 一起发布,只通过 req api 工作](0027-paseo-plugin.md)
|
|
32
32
|
- [0028 外部系统对接:命令写法在配置里,内容由 AI 整理,调用是确定的](0028-external-integrations.md)
|
|
33
33
|
- [0029 从卡片开始的需求:需求会话代替人,卡片描述代替对话](0029-requirements-from-cards.md)
|
|
34
|
+
- [0030 接单器:共享机器上定时接卡片、转达回复](0030-card-intake-process.md)
|
|
@@ -374,6 +374,43 @@ Paseo 自带的 `/paseo-advisor`、`/paseo-committee` 等编排 skill 也可以
|
|
|
374
374
|
|
|
375
375
|
**使用统计**:从卡片开始的需求,运行记录带上需求方(卡片的提出人)和卡号。`req stats --report` 多一节"从卡片开始的需求(按需求方)",CSV 多 `requester`、`card` 两列。
|
|
376
376
|
|
|
377
|
+
### 接单器(1.11 起,beta)
|
|
378
|
+
|
|
379
|
+
接单器在一台共享研发机器上常驻运行,需求方只在卡片上操作:标题以 `[AI]`(或 `【AI】`)开头的新卡片会被自动接手,需求方在卡片中的回复会自动转给需求会话。
|
|
380
|
+
|
|
381
|
+
在对接配置中再写 `list`(列出卡片的命令),例如:
|
|
382
|
+
|
|
383
|
+
```json
|
|
384
|
+
"list": {
|
|
385
|
+
"args": ["alm-card", "list", "--project-id", "{projectId}", "-t", "用户故事", "-S", "新建", "-s", "AI", "-L", "{pageSize}", "-P", "{page}", "--fields", "globalId,name,state,subsysName,createdBy"],
|
|
386
|
+
"fields": { "id": "globalId", "title": "name", "state": "state", "subsystem": "subsysName", "author": "createdBy" }
|
|
387
|
+
}
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
- `{projectId}` 是本机登记的仓库在 `cardSource` 中写的空间,每个空间查一次;`{page}`、`{pageSize}` 用于翻页(`pageSize` 默认 100,最多 `maxPages` 页,默认 5)。
|
|
391
|
+
- 输出中卡片列表的位置不在 data.list、data.records、data.items、data.rows 或 data 时,用 `items` 写明,例如 `"items": "data.result"`。
|
|
392
|
+
- 只接什么样的卡片(类型、状态)写在参数里;接单器再按标题开头的标识筛一遍,`-s` 只是让列表短一些。
|
|
393
|
+
|
|
394
|
+
本机或团队配置中可以调整(都有默认值):
|
|
395
|
+
|
|
396
|
+
```json
|
|
397
|
+
"factory": { "pollMinutes": 5, "titleTags": ["[AI]", "【AI】"], "maxNewPerPoll": 3 }
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
运行:
|
|
401
|
+
|
|
402
|
+
| 命令 | 做什么 |
|
|
403
|
+
| --- | --- |
|
|
404
|
+
| `req factory once` | 检查一次:为新卡片开始需求,转达新回复。先用它试配置 |
|
|
405
|
+
| `req factory start` / `stop` | 在后台运行 / 停止,日志在 `~/.req-kit/factory/factory.log` |
|
|
406
|
+
| `req factory status` | 是否在运行、最近一次检查的结果、没能接的卡片和原因 |
|
|
407
|
+
| `req factory service` | 作为系统服务运行的写法(Linux systemd 用户服务、Windows 计划任务),机器重启后自动恢复 |
|
|
408
|
+
|
|
409
|
+
- 每次最多开始 `maxNewPerPoll` 个需求,其余下次再接;开发仍受 `maxConcurrentPipelines` 限制。
|
|
410
|
+
- 接不了的卡片(例如子系统没有对应的仓库)记下原因;卡片的标题和子系统改了会马上再试,否则一天后再试。
|
|
411
|
+
- Paseo daemon 要同时运行:接单器通过它创建需求会话。
|
|
412
|
+
- 现在只支持一台机器接单。几台机器同时运行接单器会重复接同一张卡片。
|
|
413
|
+
|
|
377
414
|
## 团队配置
|
|
378
415
|
|
|
379
416
|
几台机器、多个项目共用的设置(统一的模型、评审小组、超时、要求的 req-kit 版本)可以放在一份团队配置里。把它放进一个团队共用的 git 仓库,各台机器克隆下来,在本机配置 `~/.req-kit/config.json` 中写上它的路径(绝对路径,或相对 `~/.req-kit/` 的路径):
|
|
@@ -434,7 +471,7 @@ Paseo 自带的 `/paseo-advisor`、`/paseo-committee` 等编排 skill 也可以
|
|
|
434
471
|
|
|
435
472
|
**结果文件**:每步必须写一个 JSON 结果文件,流水线只看它,不看 agent 的回复文字。文件缺失或格式不对时,会在同一个会话里提醒 agent 补写,最多 2 次,仍不对则判定该步骤失败。
|
|
436
473
|
|
|
437
|
-
**等待与超时**:每步有总时限(`stepTimeoutMinutes
|
|
474
|
+
**等待与超时**:每步有总时限(`stepTimeoutMinutes`),超时判定失败,但不停止 agent。继续(`--resume`、自动重试)时先看这一步上一次的 agent:已经做完、结果文件有效就直接用它的结果;还在做就接着等它一轮;第二次仍超时才停止它、另起一个 agent。同一步不会有两个 agent 同时在做(它们会改同样的文件、在同一个工作区提交、写同一个结果文件)。连续读不到 agent 状态时同样处理。等待被拆成每段 10 分钟,连接断开时自动重试。agent 等待权限批准时,同一个请求只通知一次,然后继续等待。
|
|
438
475
|
|
|
439
476
|
**临时错误自动重试**:步骤因限流(429)、服务暂时不可用(502/503/504/529、overloaded)、网络中断等临时错误失败时,等待后用新的 agent 重做这一步,并告诉它工作区里可能留有上一次没做完的改动。默认最多重试 2 次,分别等 1 分钟和 5 分钟:
|
|
440
477
|
|
package/engine/src/card-cmd.ts
CHANGED
|
@@ -62,7 +62,7 @@ function fail(message: string): never {
|
|
|
62
62
|
throw new CardError(message);
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
function cardIntegrations(config: PipelineConfig): IntegrationConfig[] {
|
|
65
|
+
export function cardIntegrations(config: PipelineConfig): IntegrationConfig[] {
|
|
66
66
|
return config.integrations.filter((i) => i.query);
|
|
67
67
|
}
|
|
68
68
|
|
|
@@ -141,66 +141,100 @@ export interface CardCmdOptions {
|
|
|
141
141
|
out?: (line: string) => void;
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
144
|
+
export interface BeginResult {
|
|
145
|
+
status: "started" | "exists";
|
|
146
|
+
repo: string;
|
|
147
|
+
featureDir: string;
|
|
148
|
+
title: string;
|
|
149
|
+
sessionAgentId: string | null;
|
|
150
|
+
/** Set when the repository was chosen by the card's subsystem. */
|
|
151
|
+
routed: boolean;
|
|
152
|
+
}
|
|
151
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Starts a requirement from a card: reads it, picks the repository (repoArg, else by subsystem,
|
|
156
|
+
* else cwd), creates the feature directory and the requirement's chat. A card that already
|
|
157
|
+
* started is left alone, unless its chat was never created (then that is tried again).
|
|
158
|
+
*/
|
|
159
|
+
export async function beginFromCard(input: { integration: IntegrationConfig; id: string; repoArg: string | null; configPath: string; opts: CardCmdOptions }): Promise<BeginResult> {
|
|
160
|
+
const { integration, id, opts } = input;
|
|
152
161
|
const existing = findCardEntry(integration.name, id, opts.env);
|
|
162
|
+
let repo: string;
|
|
163
|
+
let featureDir: string;
|
|
164
|
+
let card: Awaited<ReturnType<typeof queryCard>> | null = null;
|
|
165
|
+
let source: SourceFile;
|
|
166
|
+
let routed = false;
|
|
153
167
|
if (existing) {
|
|
154
168
|
const where = locateCardRequirement(existing, opts.env) ?? existing.sourceRepo;
|
|
155
|
-
|
|
156
|
-
|
|
169
|
+
const known = readSource(where, existing.featureDir);
|
|
170
|
+
if (!known || known.sessionAgentId) {
|
|
171
|
+
return { status: "exists", repo: where, featureDir: existing.featureDir, title: known?.title ?? "", sessionAgentId: known?.sessionAgentId ?? null, routed: false };
|
|
172
|
+
}
|
|
173
|
+
repo = where;
|
|
174
|
+
featureDir = existing.featureDir;
|
|
175
|
+
source = known;
|
|
176
|
+
} else {
|
|
177
|
+
card = await queryCard(integration, id, opts.run);
|
|
178
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
179
|
+
const byRoute = input.repoArg ? null : routeCard(card, integration.name, opts.env);
|
|
180
|
+
routed = byRoute !== null;
|
|
181
|
+
repo = gitTop(input.repoArg ? path.resolve(cwd, input.repoArg) : (byRoute ?? cwd));
|
|
182
|
+
const parts = splitDescription(card.body);
|
|
183
|
+
featureDir = `docs/features/${today()}-${cardSlug(id)}`;
|
|
184
|
+
fs.mkdirSync(path.join(repo, featureDir), { recursive: true });
|
|
185
|
+
fs.writeFileSync(path.join(repo, featureDir, CARD_SOURCE_FILE), cardSourceText(card, parts.original));
|
|
186
|
+
source = {
|
|
187
|
+
v: 1,
|
|
188
|
+
system: integration.name,
|
|
189
|
+
id,
|
|
190
|
+
url: card.url,
|
|
191
|
+
title: card.title,
|
|
192
|
+
author: card.author,
|
|
193
|
+
subsystem: card.subsystem,
|
|
194
|
+
state: card.state,
|
|
195
|
+
fetchedAt: new Date().toISOString(),
|
|
196
|
+
sourceRepo: repo,
|
|
197
|
+
sessionAgentId: null,
|
|
198
|
+
originalHash: null,
|
|
199
|
+
replyHash: null,
|
|
200
|
+
confirmedAt: null,
|
|
201
|
+
planConfirmedAt: null,
|
|
202
|
+
progress: null,
|
|
203
|
+
publishedAt: null,
|
|
204
|
+
};
|
|
205
|
+
writeSource(repo, featureDir, source);
|
|
206
|
+
saveCardEntry({ system: integration.name, id, sourceRepo: repo, featureDir, createdAt: source.fetchedAt }, opts.env);
|
|
157
207
|
}
|
|
158
|
-
|
|
159
|
-
const card = await queryCard(integration, id, opts.run);
|
|
160
|
-
const cwd = opts.cwd ?? process.cwd();
|
|
161
|
-
const routed = values.repo ? null : routeCard(card, integration.name, opts.env);
|
|
162
|
-
const repo = gitTop(values.repo ? path.resolve(cwd, values.repo) : (routed ?? cwd));
|
|
163
|
-
if (routed) out(`按子系统「${card.subsystem}」选择仓库:${repo}`);
|
|
164
|
-
const config = loadConfig(configPath, repo).config;
|
|
165
|
-
const parts = splitDescription(card.body);
|
|
166
|
-
const featureDir = `docs/features/${today()}-${cardSlug(id)}`;
|
|
167
|
-
fs.mkdirSync(path.join(repo, featureDir), { recursive: true });
|
|
168
|
-
fs.writeFileSync(path.join(repo, featureDir, CARD_SOURCE_FILE), cardSourceText(card, parts.original));
|
|
169
|
-
const source: SourceFile = {
|
|
170
|
-
v: 1,
|
|
171
|
-
system: integration.name,
|
|
172
|
-
id,
|
|
173
|
-
url: card.url,
|
|
174
|
-
title: card.title,
|
|
175
|
-
author: card.author,
|
|
176
|
-
subsystem: card.subsystem,
|
|
177
|
-
state: card.state,
|
|
178
|
-
fetchedAt: new Date().toISOString(),
|
|
179
|
-
sourceRepo: repo,
|
|
180
|
-
sessionAgentId: null,
|
|
181
|
-
originalHash: null,
|
|
182
|
-
replyHash: null,
|
|
183
|
-
confirmedAt: null,
|
|
184
|
-
planConfirmedAt: null,
|
|
185
|
-
progress: null,
|
|
186
|
-
publishedAt: null,
|
|
187
|
-
};
|
|
188
|
-
writeSource(repo, featureDir, source);
|
|
189
|
-
saveCardEntry({ system: integration.name, id, sourceRepo: repo, featureDir, createdAt: source.fetchedAt }, opts.env);
|
|
190
|
-
|
|
208
|
+
const config = loadConfig(input.configPath, repo).config;
|
|
191
209
|
source.sessionAgentId = await withRuntime(config, (runtime, resolved) =>
|
|
192
210
|
runtime.startSessionAgent({
|
|
193
|
-
title: `需求 ${id}:${
|
|
194
|
-
prompt: sessionStartPrompt({ system: integration.name, id, title:
|
|
211
|
+
title: `需求 ${id}:${source.title}`.slice(0, 120),
|
|
212
|
+
prompt: sessionStartPrompt({ system: integration.name, id, title: source.title, featureDir }),
|
|
195
213
|
cwd: repo,
|
|
196
214
|
profile: resolved.agents.builder,
|
|
197
215
|
labels: { "req-kit": "requirement-session" },
|
|
198
216
|
}),
|
|
199
217
|
);
|
|
200
218
|
writeSource(repo, featureDir, source);
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
219
|
+
return { status: "started", repo, featureDir, title: source.title, sessionAgentId: source.sessionAgentId, routed };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function startFromCard(argv: string[], opts: CardCmdOptions): Promise<number> {
|
|
223
|
+
const out = opts.out ?? console.log;
|
|
224
|
+
const { values } = parseArgs({ args: argv, options: { from: { type: "string" }, repo: { type: "string" }, config: { type: "string" } }, allowPositionals: false });
|
|
225
|
+
if (!values.from) fail(`缺少 --from\n\n${USAGE}`);
|
|
226
|
+
const configPath = values.config ? path.resolve(values.config) : defaultConfigPath(opts.env);
|
|
227
|
+
// Integrations live in the machine or team config, so the card can be read before the repository is known.
|
|
228
|
+
const { config: integration, id } = parseCardRef(values.from, cardIntegrations(loadConfig(configPath).config));
|
|
229
|
+
const result = await beginFromCard({ integration, id, repoArg: values.repo ?? null, configPath, opts });
|
|
230
|
+
if (result.status === "exists") {
|
|
231
|
+
out(`卡片 ${id} 已经开始过:${path.join(result.repo, result.featureDir)}。用 req card check ${integration.name}:${id} 转达需求方的新回复。`);
|
|
232
|
+
return 0;
|
|
233
|
+
}
|
|
234
|
+
if (result.routed) out(`按子系统选择仓库:${result.repo}`);
|
|
235
|
+
out(`已从卡片 ${id}「${result.title}」开始需求。`);
|
|
236
|
+
out(`功能目录:${path.join(result.repo, result.featureDir)}`);
|
|
237
|
+
out(`需求会话:agent ${result.sessionAgentId}(在 Paseo 中打开它可以看到整理需求的过程)。`);
|
|
204
238
|
out(`需求说明和问题会写进卡片描述末尾的 AI 区;需求方回复后运行 req card check ${integration.name}:${id}(接单器会定时运行它)。`);
|
|
205
239
|
return 0;
|
|
206
240
|
}
|
|
@@ -309,16 +343,14 @@ export async function checkOne(target: Located, opts: CardCmdOptions, notify: (a
|
|
|
309
343
|
return `${source.id}:已转给需求会话(${changes.join(";")})`;
|
|
310
344
|
}
|
|
311
345
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
if (targets.
|
|
319
|
-
|
|
320
|
-
return 0;
|
|
321
|
-
}
|
|
346
|
+
/**
|
|
347
|
+
* Checks cards for new replies and passes them on. `skipFinished` leaves out requirements whose
|
|
348
|
+
* pipeline has completed (the intake process uses it to spare the external system).
|
|
349
|
+
*/
|
|
350
|
+
export async function checkCards(ref: string | null, opts: CardCmdOptions, configPath: string, options: { skipFinished?: boolean } = {}): Promise<{ lines: string[]; failed: number; checked: number }> {
|
|
351
|
+
let targets = locateCards(ref, opts, configPath);
|
|
352
|
+
if (options.skipFinished) targets = targets.filter((t) => readState(statePath(t.repo, t.entry.featureDir))?.status !== "completed");
|
|
353
|
+
const lines: string[] = [];
|
|
322
354
|
let runtime: AgentRuntime | null = null;
|
|
323
355
|
const notify = async (agentId: string, text: string) => {
|
|
324
356
|
runtime ??= await (await loadRuntimeFactory())({ config: targets[0].pipelineConfig, log: () => undefined });
|
|
@@ -328,16 +360,30 @@ async function check(argv: string[], opts: CardCmdOptions): Promise<number> {
|
|
|
328
360
|
try {
|
|
329
361
|
for (const target of targets) {
|
|
330
362
|
try {
|
|
331
|
-
|
|
363
|
+
lines.push(await checkOne(target, opts, notify));
|
|
332
364
|
} catch (error) {
|
|
333
365
|
failed += 1;
|
|
334
|
-
|
|
366
|
+
lines.push(`${target.source.id}:${(error as Error).message}`);
|
|
335
367
|
}
|
|
336
368
|
}
|
|
337
369
|
} finally {
|
|
338
370
|
await (runtime as AgentRuntime | null)?.close().catch(() => undefined);
|
|
339
371
|
}
|
|
340
|
-
return
|
|
372
|
+
return { lines, failed, checked: targets.length };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function check(argv: string[], opts: CardCmdOptions): Promise<number> {
|
|
376
|
+
const out = opts.out ?? console.log;
|
|
377
|
+
const { values, positionals } = parseArgs({ args: argv, options: { all: { type: "boolean" }, config: { type: "string" } }, allowPositionals: true });
|
|
378
|
+
if (!positionals[0] && !values.all) fail(`写卡号,或用 --all 检查本机所有从卡片开始的需求\n\n${USAGE}`);
|
|
379
|
+
const configPath = values.config ? path.resolve(values.config) : defaultConfigPath(opts.env);
|
|
380
|
+
const result = await checkCards(positionals[0] ?? null, opts, configPath);
|
|
381
|
+
if (result.checked === 0) {
|
|
382
|
+
out("本机没有从卡片开始的需求。");
|
|
383
|
+
return 0;
|
|
384
|
+
}
|
|
385
|
+
for (const line of result.lines) out(line);
|
|
386
|
+
return result.failed > 0 ? 1 : 0;
|
|
341
387
|
}
|
|
342
388
|
|
|
343
389
|
function route(argv: string[], opts: CardCmdOptions): number {
|
package/engine/src/config.ts
CHANGED
|
@@ -192,6 +192,12 @@ export interface PipelineConfig {
|
|
|
192
192
|
* that share a subsystem (e.g. "message-service", "后端").
|
|
193
193
|
*/
|
|
194
194
|
cardSource: { system: string; projectId: string | null; subsystems: string[]; titleKeywords: string[] } | null;
|
|
195
|
+
/**
|
|
196
|
+
* The intake process (1.11, `req factory`): how often it looks for new cards, which title tags
|
|
197
|
+
* mark a card for it, and how many requirements it starts per look at most. Machine or team
|
|
198
|
+
* config only.
|
|
199
|
+
*/
|
|
200
|
+
factory: { pollMinutes: number; titleTags: string[]; maxNewPerPoll: number };
|
|
195
201
|
/** Automatic retries of a step after a transient error (rate limit, dropped connection). */
|
|
196
202
|
retry: { attempts: number; backoffSeconds: number[] };
|
|
197
203
|
/**
|
|
@@ -298,6 +304,7 @@ export const DEFAULT_CONFIG: PipelineConfig = {
|
|
|
298
304
|
integrationDefaults: {},
|
|
299
305
|
push: { enabled: false, remote: "origin", branchPrefix: "req/" },
|
|
300
306
|
cardSource: null,
|
|
307
|
+
factory: { pollMinutes: 5, titleTags: ["[AI]", "【AI】"], maxNewPerPoll: 3 },
|
|
301
308
|
retry: { attempts: 2, backoffSeconds: [60, 300] },
|
|
302
309
|
paseoServices: "auto",
|
|
303
310
|
serviceReadySeconds: 120,
|
|
@@ -362,6 +369,7 @@ function merge(base: PipelineConfig, raw: Record<string, unknown>): PipelineConf
|
|
|
362
369
|
merged.integrationDefaults = combined;
|
|
363
370
|
}
|
|
364
371
|
merged.push = { ...base.push, ...(isRecord(raw.push) ? raw.push : {}) } as PipelineConfig["push"];
|
|
372
|
+
merged.factory = { ...base.factory, ...(isRecord(raw.factory) ? raw.factory : {}) } as PipelineConfig["factory"];
|
|
365
373
|
if (isRecord(raw.cardSource)) {
|
|
366
374
|
const c = raw.cardSource;
|
|
367
375
|
merged.cardSource = {
|
|
@@ -563,6 +571,10 @@ function validate(config: PipelineConfig): void {
|
|
|
563
571
|
if (typeof push.enabled !== "boolean") throw new ConfigError("push.enabled 必须是 true 或 false");
|
|
564
572
|
if (typeof push.remote !== "string" || !/^[\w.-]+$/.test(push.remote)) throw new ConfigError('push.remote 必须是远程仓库名,例如 "origin"');
|
|
565
573
|
if (typeof push.branchPrefix !== "string" || !/^[A-Za-z0-9._-]+\/$/.test(push.branchPrefix)) throw new ConfigError('push.branchPrefix 必须是以 / 结尾的分支前缀,例如 "req/"');
|
|
574
|
+
const f = config.factory;
|
|
575
|
+
if (typeof f.pollMinutes !== "number" || !(f.pollMinutes >= 1)) throw new ConfigError("factory.pollMinutes 必须是至少 1 的分钟数");
|
|
576
|
+
if (!Number.isInteger(f.maxNewPerPoll) || f.maxNewPerPoll < 1) throw new ConfigError("factory.maxNewPerPoll 必须是正整数");
|
|
577
|
+
if (!Array.isArray(f.titleTags) || f.titleTags.length === 0 || !f.titleTags.every((t) => typeof t === "string" && t.trim())) throw new ConfigError('factory.titleTags 必须是非空的字符串数组,例如 ["[AI]"]');
|
|
566
578
|
const cs = config.cardSource;
|
|
567
579
|
if (cs !== null) {
|
|
568
580
|
if (typeof cs.system !== "string" || !cs.system.trim()) throw new ConfigError('cardSource.system 必须是对接系统的名称,例如 "ALM"');
|
|
@@ -605,7 +617,7 @@ export interface LoadedConfig {
|
|
|
605
617
|
}
|
|
606
618
|
|
|
607
619
|
/** Keys a project file may not set: they run code or reach outside the repository. */
|
|
608
|
-
const NOT_IN_PROJECT_KEYS = ["notifiers", "integrations", "push"] as const;
|
|
620
|
+
const NOT_IN_PROJECT_KEYS = ["notifiers", "integrations", "push", "factory"] as const;
|
|
609
621
|
|
|
610
622
|
/** Notifier module paths are relative to the file that names them; "~/" is the home directory. */
|
|
611
623
|
function resolveNotifiers(raw: Record<string, unknown>, file: string): Record<string, unknown> {
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
// The intake process (1.11, ADR 0030): a shared development machine takes cards on its own.
|
|
2
|
+
//
|
|
3
|
+
// req factory once one look: start requirements for new cards, pass replies on
|
|
4
|
+
// req factory run look every factory.pollMinutes until stopped (for a service manager)
|
|
5
|
+
// req factory start | stop run it in the background on this machine
|
|
6
|
+
// req factory status whether it runs, the last look, cards it could not take
|
|
7
|
+
// req factory service how to run it as a system service
|
|
8
|
+
//
|
|
9
|
+
// A card is taken when it is listed by the integration's `list` command for a space (projectId)
|
|
10
|
+
// some registered repository takes (cardSource), and its title starts with one of
|
|
11
|
+
// factory.titleTags. Starting is `req start --from` (beginFromCard); passing replies on is
|
|
12
|
+
// `req card check --all` (checkCards). Files: ~/.req-kit/factory/.
|
|
13
|
+
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { parseArgs } from "node:util";
|
|
18
|
+
import { cardRoutes, findCardEntry, hashText, parseJsonOutput } from "./card.js";
|
|
19
|
+
import { beginFromCard, type CardCmdOptions, cardIntegrations, checkCards } from "./card-cmd.js";
|
|
20
|
+
import { loadConfig } from "./config.js";
|
|
21
|
+
import { type IntegrationConfig, renderArgs, runCommand, type Runner } from "./integrations.js";
|
|
22
|
+
import { defaultConfigPath, ENGINE_DIR, KIT_ROOT, kitHome } from "./locations.js";
|
|
23
|
+
import { isAlive } from "./state.js";
|
|
24
|
+
|
|
25
|
+
const USAGE = `用法:
|
|
26
|
+
req factory once 检查一次:为新卡片开始需求,把需求方的新回复转给需求会话
|
|
27
|
+
req factory run 每隔 factory.pollMinutes 分钟检查一次,直到停止(给系统服务用)
|
|
28
|
+
req factory start 在后台运行(日志:~/.req-kit/factory/factory.log)
|
|
29
|
+
req factory stop 停止后台运行
|
|
30
|
+
req factory status 是否在运行、最近一次检查、没能接的卡片
|
|
31
|
+
req factory service 说明怎样作为系统服务运行(开机自动启动)`;
|
|
32
|
+
|
|
33
|
+
export interface ListedCard {
|
|
34
|
+
id: string;
|
|
35
|
+
title: string;
|
|
36
|
+
state: string | null;
|
|
37
|
+
subsystem: string | null;
|
|
38
|
+
author: string | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface Skipped {
|
|
42
|
+
reason: string;
|
|
43
|
+
/** Title and subsystem when it was skipped: a changed card is tried again. */
|
|
44
|
+
key: string;
|
|
45
|
+
at: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface PollResult {
|
|
49
|
+
started: string[];
|
|
50
|
+
skipped: string[];
|
|
51
|
+
relayed: string[];
|
|
52
|
+
errors: string[];
|
|
53
|
+
notes: string[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function factoryDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
57
|
+
return path.join(kitHome(env), "factory");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readJson<T>(file: string, fallback: T): T {
|
|
61
|
+
try {
|
|
62
|
+
return JSON.parse(fs.readFileSync(file, "utf8")) as T;
|
|
63
|
+
} catch {
|
|
64
|
+
return fallback;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function writeJson(file: string, value: unknown): void {
|
|
69
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
70
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
71
|
+
fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`);
|
|
72
|
+
fs.renameSync(tmp, file);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function at(value: unknown, dotted: string): unknown {
|
|
76
|
+
let cur = value;
|
|
77
|
+
for (const part of dotted.split(".").filter(Boolean)) {
|
|
78
|
+
if (cur === null || typeof cur !== "object") return undefined;
|
|
79
|
+
cur = (cur as Record<string, unknown>)[part];
|
|
80
|
+
}
|
|
81
|
+
return cur;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The list in a `list` command's output. */
|
|
85
|
+
export function listItems(parsed: unknown, itemsPath?: string): unknown[] {
|
|
86
|
+
if (itemsPath) {
|
|
87
|
+
const v = at(parsed, itemsPath);
|
|
88
|
+
if (!Array.isArray(v)) throw new Error(`输出中 ${itemsPath} 不是列表(list.items)`);
|
|
89
|
+
return v;
|
|
90
|
+
}
|
|
91
|
+
for (const p of ["data.list", "data.records", "data.items", "data.rows", "data", ""]) {
|
|
92
|
+
const v = p ? at(parsed, p) : parsed;
|
|
93
|
+
if (Array.isArray(v)) return v;
|
|
94
|
+
}
|
|
95
|
+
throw new Error("输出中没有找到卡片列表,请在 list.items 中写明它的位置(例如 \"data.list\")");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** All cards of a space the `list` command returns, page by page. */
|
|
99
|
+
export async function listCards(config: IntegrationConfig, projectId: string, titleTag: string, run: Runner = runCommand): Promise<ListedCard[]> {
|
|
100
|
+
const list = config.list;
|
|
101
|
+
if (!list) throw new Error(`${config.name} 没有配置 list(列出卡片的命令)`);
|
|
102
|
+
const pageSize = list.pageSize ?? 100;
|
|
103
|
+
const maxPages = list.maxPages ?? 5;
|
|
104
|
+
const out: ListedCard[] = [];
|
|
105
|
+
for (let page = 1; page <= maxPages; page++) {
|
|
106
|
+
const args = renderArgs(list.args, { projectId, page: String(page), pageSize: String(pageSize), titleTag });
|
|
107
|
+
const result = await run(config.command, args, (config.timeoutSeconds ?? 60) * 1000);
|
|
108
|
+
if (!result.ok) throw new Error(`列出空间 ${projectId} 的卡片失败:${result.error}`);
|
|
109
|
+
let parsed: unknown;
|
|
110
|
+
try {
|
|
111
|
+
parsed = parseJsonOutput(result.output);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
throw new Error(`列出空间 ${projectId} 的卡片:${(error as Error).message}`);
|
|
114
|
+
}
|
|
115
|
+
if (at(parsed, "success") === false) throw new Error(`列出空间 ${projectId} 的卡片失败:${String(at(parsed, "message") ?? "success 为 false")}`);
|
|
116
|
+
const items = listItems(parsed, list.items);
|
|
117
|
+
const str = (item: unknown, p: string | undefined) => {
|
|
118
|
+
if (!p) return null;
|
|
119
|
+
const v = at(item, p);
|
|
120
|
+
return v === undefined || v === null ? null : String(v);
|
|
121
|
+
};
|
|
122
|
+
for (const item of items) {
|
|
123
|
+
const id = str(item, list.fields.id);
|
|
124
|
+
const title = str(item, list.fields.title);
|
|
125
|
+
if (!id || title === null) continue;
|
|
126
|
+
out.push({ id, title, state: str(item, list.fields.state), subsystem: str(item, list.fields.subsystem), author: str(item, list.fields.author) });
|
|
127
|
+
}
|
|
128
|
+
if (items.length < pageSize) break;
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function hasTitleTag(title: string, tags: string[]): boolean {
|
|
134
|
+
const t = title.trim().toLowerCase();
|
|
135
|
+
return tags.some((tag) => t.startsWith(tag.trim().toLowerCase()));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* One look. Never throws for one card's or one space's trouble: it is reported and the rest goes on.
|
|
140
|
+
* Cards that could not be taken are not tried again until the card changes or a day has passed.
|
|
141
|
+
*/
|
|
142
|
+
export async function pollOnce(configPath: string, opts: CardCmdOptions = {}, now = new Date()): Promise<PollResult> {
|
|
143
|
+
const env = opts.env ?? process.env;
|
|
144
|
+
const dir = factoryDir(env);
|
|
145
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
146
|
+
const result: PollResult = { started: [], skipped: [], relayed: [], errors: [], notes: [] };
|
|
147
|
+
|
|
148
|
+
// One look at a time on this machine.
|
|
149
|
+
const lock = path.join(dir, "poll.lock");
|
|
150
|
+
try {
|
|
151
|
+
fs.writeFileSync(lock, String(process.pid), { flag: "wx" });
|
|
152
|
+
} catch {
|
|
153
|
+
const holder = Number(fs.readFileSync(lock, "utf8"));
|
|
154
|
+
if (holder && holder !== process.pid && isAlive(holder)) {
|
|
155
|
+
result.notes.push(`上一次检查(进程 ${holder})还没有结束,这次跳过`);
|
|
156
|
+
return result;
|
|
157
|
+
}
|
|
158
|
+
fs.writeFileSync(lock, String(process.pid));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
const config = loadConfig(configPath).config;
|
|
163
|
+
const tags = config.factory.titleTags;
|
|
164
|
+
const skippedFile = path.join(dir, "skipped.json");
|
|
165
|
+
const skipped = readJson<Record<string, Skipped>>(skippedFile, {});
|
|
166
|
+
const { routes } = cardRoutes(env);
|
|
167
|
+
let newOnes = 0;
|
|
168
|
+
const integrations = cardIntegrations(config).filter((i) => i.list);
|
|
169
|
+
if (integrations.length === 0) result.notes.push("没有配置可以列出卡片的外部系统(integrations 中的 list),只转达回复");
|
|
170
|
+
for (const integration of integrations) {
|
|
171
|
+
const spaces = [...new Set(routes.filter((r) => r.system.toLowerCase() === integration.name.toLowerCase() && r.projectId).map((r) => r.projectId!))];
|
|
172
|
+
if (spaces.length === 0) {
|
|
173
|
+
result.notes.push(`${integration.name}:本机登记的仓库都没有写空间(cardSource.projectId),不知道去哪里找卡片`);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
for (const space of spaces) {
|
|
177
|
+
let cards: ListedCard[];
|
|
178
|
+
try {
|
|
179
|
+
cards = await listCards(integration, space, tags[0], opts.run);
|
|
180
|
+
} catch (error) {
|
|
181
|
+
result.errors.push(`${integration.name} 空间 ${space}:${(error as Error).message}`);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
for (const card of cards.filter((c) => hasTitleTag(c.title, tags))) {
|
|
185
|
+
const ref = `${integration.name}:${card.id}`;
|
|
186
|
+
if (findCardEntry(integration.name, card.id, env)) continue;
|
|
187
|
+
const key = hashText(`${card.title}\0${card.subsystem ?? ""}`);
|
|
188
|
+
const before = skipped[ref];
|
|
189
|
+
if (before && before.key === key && now.getTime() - new Date(before.at).getTime() < 24 * 3600_000) continue;
|
|
190
|
+
if (newOnes >= config.factory.maxNewPerPoll) {
|
|
191
|
+
result.notes.push(`这次已开始 ${newOnes} 个需求(factory.maxNewPerPoll),其余的下次再接`);
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
const begun = await beginFromCard({ integration, id: card.id, repoArg: null, configPath, opts: { ...opts, out: () => undefined } });
|
|
196
|
+
if (begun.status === "started") {
|
|
197
|
+
newOnes += 1;
|
|
198
|
+
result.started.push(`${ref}「${card.title}」→ ${path.join(begun.repo, begun.featureDir)}`);
|
|
199
|
+
}
|
|
200
|
+
delete skipped[ref];
|
|
201
|
+
} catch (error) {
|
|
202
|
+
skipped[ref] = { reason: (error as Error).message, key, at: now.toISOString() };
|
|
203
|
+
result.skipped.push(`${ref}「${card.title}」:${(error as Error).message}`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
writeJson(skippedFile, skipped);
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
const checked = await checkCards(null, opts, configPath, { skipFinished: true });
|
|
212
|
+
for (const line of checked.lines) {
|
|
213
|
+
if (/:没有新内容$/.test(line)) continue;
|
|
214
|
+
if (/已转给需求会话/.test(line)) result.relayed.push(line);
|
|
215
|
+
else result.errors.push(line);
|
|
216
|
+
}
|
|
217
|
+
} catch (error) {
|
|
218
|
+
result.errors.push(`转达回复:${(error as Error).message}`);
|
|
219
|
+
}
|
|
220
|
+
} catch (error) {
|
|
221
|
+
result.errors.push((error as Error).message);
|
|
222
|
+
} finally {
|
|
223
|
+
fs.rmSync(lock, { force: true });
|
|
224
|
+
}
|
|
225
|
+
writeJson(path.join(dir, "status.json"), { lastPollAt: now.toISOString(), pid: process.pid, ...result });
|
|
226
|
+
return result;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function describe(result: PollResult, now = new Date()): string[] {
|
|
230
|
+
const stamp = now.toLocaleString("zh-CN", { hour12: false });
|
|
231
|
+
const lines: string[] = [];
|
|
232
|
+
for (const s of result.started) lines.push(`[${stamp}] 已开始:${s}`);
|
|
233
|
+
for (const s of result.relayed) lines.push(`[${stamp}] ${s}`);
|
|
234
|
+
for (const s of result.skipped) lines.push(`[${stamp}] 没能接:${s}`);
|
|
235
|
+
for (const s of result.errors) lines.push(`[${stamp}] 出错:${s}`);
|
|
236
|
+
for (const s of result.notes) lines.push(`[${stamp}] ${s}`);
|
|
237
|
+
if (lines.length === 0) lines.push(`[${stamp}] 检查完毕,没有新卡片和新回复`);
|
|
238
|
+
return lines;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function pidFile(env: NodeJS.ProcessEnv): string {
|
|
242
|
+
return path.join(factoryDir(env), "factory.pid");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function runningPid(env: NodeJS.ProcessEnv): number | null {
|
|
246
|
+
const pid = Number(readJson<string | number>(pidFile(env), 0));
|
|
247
|
+
return pid && isAlive(pid) ? pid : null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function runLoop(configPath: string, opts: CardCmdOptions): Promise<number> {
|
|
251
|
+
const env = opts.env ?? process.env;
|
|
252
|
+
const out = opts.out ?? console.log;
|
|
253
|
+
const other = runningPid(env);
|
|
254
|
+
if (other && other !== process.pid) {
|
|
255
|
+
out(`接单器已经在运行(进程 ${other})。`);
|
|
256
|
+
return 1;
|
|
257
|
+
}
|
|
258
|
+
writeJson(pidFile(env), process.pid);
|
|
259
|
+
let stopping = false;
|
|
260
|
+
let wake: (() => void) | null = null;
|
|
261
|
+
const stop = () => {
|
|
262
|
+
stopping = true;
|
|
263
|
+
wake?.();
|
|
264
|
+
};
|
|
265
|
+
process.on("SIGTERM", stop);
|
|
266
|
+
process.on("SIGINT", stop);
|
|
267
|
+
const minutes = loadConfig(configPath).config.factory.pollMinutes;
|
|
268
|
+
out(`[${new Date().toLocaleString("zh-CN", { hour12: false })}] 接单器开始运行,每 ${minutes} 分钟检查一次(进程 ${process.pid})`);
|
|
269
|
+
try {
|
|
270
|
+
while (!stopping) {
|
|
271
|
+
for (const line of describe(await pollOnce(configPath, opts))) out(line);
|
|
272
|
+
if (stopping) break;
|
|
273
|
+
await new Promise<void>((resolve) => {
|
|
274
|
+
wake = resolve;
|
|
275
|
+
setTimeout(resolve, minutes * 60_000);
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
} finally {
|
|
279
|
+
if (runningPid(env) === process.pid) fs.rmSync(pidFile(env), { force: true });
|
|
280
|
+
out(`[${new Date().toLocaleString("zh-CN", { hour12: false })}] 接单器已停止`);
|
|
281
|
+
}
|
|
282
|
+
return 0;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function startBackground(configPath: string, opts: CardCmdOptions): number {
|
|
286
|
+
const env = opts.env ?? process.env;
|
|
287
|
+
const out = opts.out ?? console.log;
|
|
288
|
+
const pid = runningPid(env);
|
|
289
|
+
if (pid) {
|
|
290
|
+
out(`接单器已经在运行(进程 ${pid})。`);
|
|
291
|
+
return 0;
|
|
292
|
+
}
|
|
293
|
+
// Fail here, in front of the person, rather than silently in the background.
|
|
294
|
+
loadConfig(configPath);
|
|
295
|
+
fs.mkdirSync(factoryDir(env), { recursive: true });
|
|
296
|
+
const log = path.join(factoryDir(env), "factory.log");
|
|
297
|
+
const fd = fs.openSync(log, "a");
|
|
298
|
+
const child = spawn(process.execPath, [...process.execArgv, path.join(ENGINE_DIR, "src", "factory.ts"), "run", "--config", configPath], {
|
|
299
|
+
detached: true,
|
|
300
|
+
stdio: ["ignore", fd, fd],
|
|
301
|
+
env,
|
|
302
|
+
windowsHide: true,
|
|
303
|
+
});
|
|
304
|
+
child.unref();
|
|
305
|
+
writeJson(pidFile(env), child.pid);
|
|
306
|
+
out(`接单器已在后台运行(进程 ${child.pid})。日志:${log}`);
|
|
307
|
+
out("停止:req factory stop;查看:req factory status");
|
|
308
|
+
return 0;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function stopBackground(opts: CardCmdOptions): number {
|
|
312
|
+
const env = opts.env ?? process.env;
|
|
313
|
+
const out = opts.out ?? console.log;
|
|
314
|
+
const pid = runningPid(env);
|
|
315
|
+
if (!pid) {
|
|
316
|
+
out("接单器没有在运行。");
|
|
317
|
+
return 0;
|
|
318
|
+
}
|
|
319
|
+
process.kill(pid, "SIGTERM");
|
|
320
|
+
out(`已通知接单器(进程 ${pid})停止;正在进行的检查会做完再停。`);
|
|
321
|
+
return 0;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function status(opts: CardCmdOptions): number {
|
|
325
|
+
const env = opts.env ?? process.env;
|
|
326
|
+
const out = opts.out ?? console.log;
|
|
327
|
+
const pid = runningPid(env);
|
|
328
|
+
out(pid ? `接单器正在运行(进程 ${pid})。` : "接单器没有在运行。");
|
|
329
|
+
const last = readJson<(PollResult & { lastPollAt: string }) | null>(path.join(factoryDir(env), "status.json"), null);
|
|
330
|
+
if (last) {
|
|
331
|
+
out(`最近一次检查:${new Date(last.lastPollAt).toLocaleString("zh-CN", { hour12: false })};开始 ${last.started.length} 个需求,转达 ${last.relayed.length} 条回复,${last.errors.length} 个错误。`);
|
|
332
|
+
for (const e of last.errors) out(` 出错:${e}`);
|
|
333
|
+
for (const n of last.notes) out(` ${n}`);
|
|
334
|
+
}
|
|
335
|
+
const skipped = readJson<Record<string, Skipped>>(path.join(factoryDir(env), "skipped.json"), {});
|
|
336
|
+
const entries = Object.entries(skipped);
|
|
337
|
+
if (entries.length > 0) {
|
|
338
|
+
out("没能接的卡片(卡片修改后或一天后再试):");
|
|
339
|
+
for (const [ref, s] of entries) out(` ${ref}:${s.reason}`);
|
|
340
|
+
}
|
|
341
|
+
out(`日志:${path.join(factoryDir(env), "factory.log")}`);
|
|
342
|
+
return 0;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function service(configPath: string, opts: CardCmdOptions): number {
|
|
346
|
+
const out = opts.out ?? console.log;
|
|
347
|
+
const req = path.join(KIT_ROOT, "bin", "req.mjs");
|
|
348
|
+
const command = `"${process.execPath}" "${req}" factory run --config "${configPath}"`;
|
|
349
|
+
if (process.platform === "win32") {
|
|
350
|
+
out("在 Windows 上作为计划任务运行(登录后自动启动),在管理员 PowerShell 中执行:");
|
|
351
|
+
out(` schtasks /Create /TN "req-kit 接单器" /SC ONLOGON /RL LIMITED /TR '${command.replace(/'/g, "''")}'`);
|
|
352
|
+
out("立即启动:schtasks /Run /TN \"req-kit 接单器\";停止:schtasks /End /TN \"req-kit 接单器\"");
|
|
353
|
+
} else {
|
|
354
|
+
out("在 Linux 上作为 systemd 用户服务运行,把下面的内容保存为 ~/.config/systemd/user/req-kit-factory.service:");
|
|
355
|
+
out("");
|
|
356
|
+
out("[Unit]\nDescription=req-kit 接单器\nAfter=network-online.target\n\n[Service]");
|
|
357
|
+
out(`ExecStart=${command}\nRestart=on-failure\nRestartSec=60\n\n[Install]\nWantedBy=default.target`);
|
|
358
|
+
out("");
|
|
359
|
+
out("然后执行:systemctl --user daemon-reload && systemctl --user enable --now req-kit-factory");
|
|
360
|
+
out("(希望不登录也运行:loginctl enable-linger $USER)。macOS 可以用 launchd 或直接 req factory start。");
|
|
361
|
+
}
|
|
362
|
+
out("Paseo daemon 需要同时在运行:接单器通过它创建需求会话。");
|
|
363
|
+
return 0;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export async function main(argv: string[], opts: CardCmdOptions = {}): Promise<number> {
|
|
367
|
+
const out = opts.out ?? console.log;
|
|
368
|
+
const [command, ...rest] = argv;
|
|
369
|
+
const { values } = parseArgs({ args: rest, options: { config: { type: "string" } }, allowPositionals: false, strict: false });
|
|
370
|
+
const configPath = typeof values.config === "string" ? path.resolve(values.config) : defaultConfigPath(opts.env);
|
|
371
|
+
try {
|
|
372
|
+
switch (command) {
|
|
373
|
+
case "once": {
|
|
374
|
+
const result = await pollOnce(configPath, opts);
|
|
375
|
+
for (const line of describe(result)) out(line);
|
|
376
|
+
return result.errors.length > 0 ? 1 : 0;
|
|
377
|
+
}
|
|
378
|
+
case "run":
|
|
379
|
+
return await runLoop(configPath, opts);
|
|
380
|
+
case "start":
|
|
381
|
+
return startBackground(configPath, opts);
|
|
382
|
+
case "stop":
|
|
383
|
+
return stopBackground(opts);
|
|
384
|
+
case "status":
|
|
385
|
+
return status(opts);
|
|
386
|
+
case "service":
|
|
387
|
+
return service(configPath, opts);
|
|
388
|
+
default:
|
|
389
|
+
out(USAGE);
|
|
390
|
+
return command === undefined || command === "help" || command === "--help" ? 0 : 2;
|
|
391
|
+
}
|
|
392
|
+
} catch (error) {
|
|
393
|
+
console.error(`出错了:${(error as Error).message}`);
|
|
394
|
+
return 1;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
|
|
399
|
+
main(process.argv.slice(2)).then(
|
|
400
|
+
(code) => process.exit(code),
|
|
401
|
+
(error) => {
|
|
402
|
+
console.error(error);
|
|
403
|
+
process.exit(1);
|
|
404
|
+
},
|
|
405
|
+
);
|
|
406
|
+
}
|
|
@@ -50,6 +50,13 @@ export interface IntegrationConfig {
|
|
|
50
50
|
query?: IntegrationAction & { fields: CardQueryFields };
|
|
51
51
|
/** Replaces the card's description (1.10): {description} is the whole new description. */
|
|
52
52
|
describe?: IntegrationAction;
|
|
53
|
+
/**
|
|
54
|
+
* Lists cards for the intake process (1.11): {projectId}, {page}, {pageSize} and {titleTag}
|
|
55
|
+
* can be used. The output is JSON; `items` is where the list is (a dot path; by default the
|
|
56
|
+
* first array found at data.list, data.records, data.items, data or the top level), `fields`
|
|
57
|
+
* where each card's values are within an item.
|
|
58
|
+
*/
|
|
59
|
+
list?: IntegrationAction & { items?: string; pageSize?: number; maxPages?: number; fields: CardListFields };
|
|
53
60
|
/** Fields the requester confirms with the spec (card.json): name → what to fill in, for /req. */
|
|
54
61
|
cardFields?: Record<string, string>;
|
|
55
62
|
/** Values for {field.<name>} placeholders; a project's integrationDefaults[name] adds to these. */
|
|
@@ -67,6 +74,15 @@ export interface CardQueryFields {
|
|
|
67
74
|
url?: string;
|
|
68
75
|
}
|
|
69
76
|
|
|
77
|
+
/** Where `list` finds each card's values within an item. id and title are required. */
|
|
78
|
+
export interface CardListFields {
|
|
79
|
+
id: string;
|
|
80
|
+
title: string;
|
|
81
|
+
state?: string;
|
|
82
|
+
subsystem?: string;
|
|
83
|
+
author?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
70
86
|
/** What is known about a requirement's card, in the state file's `external[name]`. */
|
|
71
87
|
export interface ExternalRecord {
|
|
72
88
|
id: string | null;
|
|
@@ -96,7 +112,7 @@ export function validateIntegrations(value: unknown): string | null {
|
|
|
96
112
|
if (names.has(c.name)) return `${at}.name「${c.name}」重复了`;
|
|
97
113
|
names.add(c.name);
|
|
98
114
|
if (typeof c.command !== "string" || !c.command.trim()) return `${at}.command 必须是要运行的命令,例如 "shenbing"`;
|
|
99
|
-
for (const key of ["create", "status", "query", "describe"] as const) {
|
|
115
|
+
for (const key of ["create", "status", "query", "describe", "list"] as const) {
|
|
100
116
|
const action = c[key] as Record<string, unknown> | undefined;
|
|
101
117
|
if (action === undefined) continue;
|
|
102
118
|
if (!action || !Array.isArray(action.args) || !action.args.every((a) => typeof a === "string")) return `${at}.${key}.args 必须是字符串数组`;
|
|
@@ -123,6 +139,18 @@ export function validateIntegrations(value: unknown): string | null {
|
|
|
123
139
|
if (typeof v !== "string") return `${at}.query.fields.${k} 必须是字符串`;
|
|
124
140
|
}
|
|
125
141
|
}
|
|
142
|
+
const list = c.list as Record<string, unknown> | undefined;
|
|
143
|
+
if (list) {
|
|
144
|
+
const fields = list.fields as Record<string, unknown> | undefined;
|
|
145
|
+
if (!fields || typeof fields !== "object") return `${at}.list.fields 必须写明每张卡片各值的位置,例如 { "id": "globalId", "title": "name" }`;
|
|
146
|
+
for (const k of ["id", "title"]) if (typeof fields[k] !== "string") return `${at}.list.fields.${k} 必须是卡片中的位置,例如 "globalId"`;
|
|
147
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
148
|
+
if (!["id", "title", "state", "subsystem", "author"].includes(k)) return `${at}.list.fields 中的 ${k} 不认识(可用:id、title、state、subsystem、author)`;
|
|
149
|
+
if (typeof v !== "string") return `${at}.list.fields.${k} 必须是字符串`;
|
|
150
|
+
}
|
|
151
|
+
if (list.items !== undefined && typeof list.items !== "string") return `${at}.list.items 必须是输出中列表的位置,例如 "data.list"`;
|
|
152
|
+
for (const k of ["pageSize", "maxPages"]) if (list[k] !== undefined && !(Number.isInteger(list[k]) && (list[k] as number) > 0)) return `${at}.list.${k} 必须是正整数`;
|
|
153
|
+
}
|
|
126
154
|
if (c.statusMap !== undefined) {
|
|
127
155
|
if (!c.statusMap || typeof c.statusMap !== "object") return `${at}.statusMap 必须是对象`;
|
|
128
156
|
for (const [k, v] of Object.entries(c.statusMap)) {
|
|
@@ -199,6 +199,9 @@ export function createRuntimeWithClient(
|
|
|
199
199
|
async notifyAgent(agentId, text) {
|
|
200
200
|
await client.agents.ref(agentId).send(text, { activeTurnBehavior: "steer" });
|
|
201
201
|
},
|
|
202
|
+
attachAgent(agentId) {
|
|
203
|
+
return wrap(client.agents.ref(agentId));
|
|
204
|
+
},
|
|
202
205
|
async interruptAgent(agentId, text) {
|
|
203
206
|
await client.agents.ref(agentId).send(text, { activeTurnBehavior: "interrupt" });
|
|
204
207
|
},
|
package/engine/src/pipeline.ts
CHANGED
|
@@ -36,7 +36,7 @@ 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";
|
|
39
|
+
import type { AgentRuntime, ManagedService, ServiceTarget, StepAgent, WaitOutcome } from "./runtime.js";
|
|
40
40
|
import { type CheckOutcome, type GateRun, type JointRepo, type PhaseName, type PipelineState, type RunPhases, type StepRecord, writeState } from "./state.js";
|
|
41
41
|
import { type PanelStep, STEPS, type StepDef, type StepId, stepKey, validateResult, VERIFY_PHASE_STEPS } from "./steps.js";
|
|
42
42
|
|
|
@@ -124,6 +124,9 @@ 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
|
+
|
|
127
130
|
/**
|
|
128
131
|
* Errors that usually go away by themselves: provider rate limits and overload,
|
|
129
132
|
* dropped connections, a restarting daemon. A step that fails this way is started
|
|
@@ -490,9 +493,9 @@ async function waitForTurn(ctx: PipelineContext, agent: StepAgent, def: StepDef,
|
|
|
490
493
|
for (;;) {
|
|
491
494
|
const remaining = deadline - Date.now();
|
|
492
495
|
if (remaining <= 0) {
|
|
493
|
-
throw new
|
|
496
|
+
throw new AgentLeftRunning(
|
|
494
497
|
def.title,
|
|
495
|
-
`超过 ${ctx.config.stepTimeoutMinutes[def.id]}
|
|
498
|
+
`超过 ${ctx.config.stepTimeoutMinutes[def.id]} 分钟仍未完成。agent「${agentTitle}」还在运行,没有停止它;继续时会先看它的情况:做完了就用它的结果,还在做就接着等它,不会另起一个 agent 重复做。`,
|
|
496
499
|
);
|
|
497
500
|
}
|
|
498
501
|
let outcome;
|
|
@@ -503,7 +506,7 @@ async function waitForTurn(ctx: PipelineContext, agent: StepAgent, def: StepDef,
|
|
|
503
506
|
consecutiveErrors += 1;
|
|
504
507
|
ctx.log(`等待 ${agentTitle} 时出错(第 ${consecutiveErrors} 次):${(error as Error).message}`);
|
|
505
508
|
if (consecutiveErrors >= 5) {
|
|
506
|
-
throw new
|
|
509
|
+
throw new AgentLeftRunning(def.title, `连续 ${consecutiveErrors} 次无法获取 agent 状态:${(error as Error).message}`);
|
|
507
510
|
}
|
|
508
511
|
await ctx.sleep(Math.min(60_000, 5_000 * consecutiveErrors));
|
|
509
512
|
continue;
|
|
@@ -604,7 +607,11 @@ async function runStepOnce(
|
|
|
604
607
|
const resultRel = `${ctx.featureDir}/.pipeline/results/${key}.json`;
|
|
605
608
|
const resultAbs = path.join(stepRepo, resultRel);
|
|
606
609
|
fs.mkdirSync(path.dirname(resultAbs), { recursive: true });
|
|
607
|
-
|
|
610
|
+
|
|
611
|
+
// An earlier run of this step may have left its agent working: use it, never run a second one.
|
|
612
|
+
const leftover = await checkLeftover(ctx, key, def, resultAbs);
|
|
613
|
+
if (leftover.done) return leftover.done;
|
|
614
|
+
if (!leftover.agent) fs.rmSync(resultAbs, { force: true });
|
|
608
615
|
|
|
609
616
|
// Services, the browser and the deployed environment belong to the steps in this repository.
|
|
610
617
|
const managed = target
|
|
@@ -638,10 +645,14 @@ async function runStepOnce(
|
|
|
638
645
|
error: null,
|
|
639
646
|
phase: phaseOf(ctx),
|
|
640
647
|
};
|
|
641
|
-
|
|
648
|
+
if (leftover.agent) {
|
|
649
|
+
record.agentId = leftover.agent.id;
|
|
650
|
+
record.reattached = leftover.reattached;
|
|
651
|
+
}
|
|
652
|
+
ctx.log(leftover.agent ? `继续等待 ${key}(${def.title})上一次的 agent ${leftover.agent.id}` : `开始 ${key}(${def.title})`);
|
|
642
653
|
|
|
643
654
|
try {
|
|
644
|
-
const agent = await ctx.runtime.startAgent({
|
|
655
|
+
const agent = leftover.agent ?? await ctx.runtime.startAgent({
|
|
645
656
|
role: def.role,
|
|
646
657
|
title: agentTitle,
|
|
647
658
|
prompt: buildPrompt(ctx, def, round, maxRounds, resultRel, extras),
|
|
@@ -699,6 +710,7 @@ async function runStepOnce(
|
|
|
699
710
|
record.outcome = "failed";
|
|
700
711
|
record.endedAt = new Date().toISOString();
|
|
701
712
|
record.error = (error as Error).message;
|
|
713
|
+
if (error instanceof AgentLeftRunning) record.leftRunning = true;
|
|
702
714
|
if (!ctx.state.history.includes(record)) ctx.state.history.push(record);
|
|
703
715
|
save(ctx);
|
|
704
716
|
if (error instanceof StepFailure) throw error;
|
|
@@ -706,6 +718,69 @@ async function runStepOnce(
|
|
|
706
718
|
}
|
|
707
719
|
}
|
|
708
720
|
|
|
721
|
+
/**
|
|
722
|
+
* Before a step starts an agent, looks at the agent an earlier run of the same step left
|
|
723
|
+
* working (a timeout, unreadable status, or a pipeline process that died mid-step):
|
|
724
|
+
* - it has finished and written a valid result: that result is the step's result;
|
|
725
|
+
* - it is still working: the step waits for it again (once); the second time it is stopped
|
|
726
|
+
* and a new agent starts, so a stuck agent does not hold the step forever;
|
|
727
|
+
* - it cannot be reached, or finished without a valid result: a new agent starts.
|
|
728
|
+
* Two agents on one step would edit the same files, commit in the same worktree and write
|
|
729
|
+
* the same result file, so a new one never starts while the old one may be working.
|
|
730
|
+
*/
|
|
731
|
+
async function checkLeftover(
|
|
732
|
+
ctx: PipelineContext,
|
|
733
|
+
key: string,
|
|
734
|
+
def: StepDef,
|
|
735
|
+
resultAbs: string,
|
|
736
|
+
): Promise<{ done?: Record<string, unknown>; agent?: StepAgent; reattached?: number }> {
|
|
737
|
+
const last = [...ctx.state.history].reverse().find((h) => h.key === key);
|
|
738
|
+
if (!last?.agentId || last.outcome === "completed") return {};
|
|
739
|
+
if (last.outcome !== "running" && !last.leftRunning) return {};
|
|
740
|
+
last.leftRunning = false;
|
|
741
|
+
if (last.outcome === "running") {
|
|
742
|
+
last.outcome = "failed";
|
|
743
|
+
last.endedAt = new Date().toISOString();
|
|
744
|
+
last.error ??= "流水线进程在这一步运行时退出";
|
|
745
|
+
}
|
|
746
|
+
save(ctx);
|
|
747
|
+
const agent = ctx.runtime.attachAgent(last.agentId);
|
|
748
|
+
let status: WaitOutcome["status"] | "unreachable";
|
|
749
|
+
try {
|
|
750
|
+
status = (await agent.wait(Math.min(5_000, ctx.config.waitChunkSeconds * 1000))).status;
|
|
751
|
+
} catch (error) {
|
|
752
|
+
ctx.log(`无法读取 ${key} 上一次的 agent ${last.agentId} 的状态(${(error as Error).message}),新建一个 agent`);
|
|
753
|
+
status = "unreachable";
|
|
754
|
+
}
|
|
755
|
+
if (status === "idle") {
|
|
756
|
+
const validation = readResultFile(resultAbs, def);
|
|
757
|
+
if (validation.ok) {
|
|
758
|
+
last.outcome = "completed";
|
|
759
|
+
last.endedAt = new Date().toISOString();
|
|
760
|
+
last.error = null;
|
|
761
|
+
last.result = validation.value;
|
|
762
|
+
ctx.state.results[key] = validation.value;
|
|
763
|
+
save(ctx);
|
|
764
|
+
ctx.log(`${key} 上一次的 agent ${last.agentId} 已经做完,沿用它的结果:${JSON.stringify(validation.value)}`);
|
|
765
|
+
if (ctx.config.archiveStepAgents) await agent.archive().catch(() => undefined);
|
|
766
|
+
return { done: validation.value };
|
|
767
|
+
}
|
|
768
|
+
ctx.log(`${key} 上一次的 agent ${last.agentId} 已经停下,但没有有效的结果文件(${validation.error}),新建一个 agent`);
|
|
769
|
+
return {};
|
|
770
|
+
}
|
|
771
|
+
if (status === "timeout" || status === "permission") {
|
|
772
|
+
if ((last.reattached ?? 0) < 1) return { agent, reattached: (last.reattached ?? 0) + 1 };
|
|
773
|
+
ctx.log(`${key} 上一次的 agent ${last.agentId} 已经等过两轮仍未完成,停止它并新建一个 agent`);
|
|
774
|
+
await ctx.runtime
|
|
775
|
+
.interruptAgent(last.agentId, "这一步已超时,流水线会另起一个 agent 重做。请立即停止当前工作,不要再修改任何文件,也不要写结果文件,直接结束。")
|
|
776
|
+
.catch((error: Error) => ctx.log(`无法停止 agent ${last.agentId}:${error.message}`));
|
|
777
|
+
// Give it a moment to wind down before the new agent touches the same files.
|
|
778
|
+
await agent.wait(Math.min(60_000, ctx.config.waitChunkSeconds * 1000 * 2)).catch(() => undefined);
|
|
779
|
+
return {};
|
|
780
|
+
}
|
|
781
|
+
return {};
|
|
782
|
+
}
|
|
783
|
+
|
|
709
784
|
/** phases.verify.agents applies to the verify-phase steps. */
|
|
710
785
|
function phaseAgent(ctx: PipelineContext, id: StepId): AgentProfile | undefined {
|
|
711
786
|
if (!VERIFY_PHASE_STEPS.includes(id)) return undefined;
|
package/engine/src/runtime.ts
CHANGED
|
@@ -111,6 +111,8 @@ export interface AgentRuntime {
|
|
|
111
111
|
*/
|
|
112
112
|
verifyOrigin(agentId: string, cwd: string): Promise<void>;
|
|
113
113
|
startAgent(input: StartAgentInput): Promise<StepAgent>;
|
|
114
|
+
/** An agent started earlier (possibly by another pipeline process), to check on, wait for or follow up. */
|
|
115
|
+
attachAgent(agentId: string): StepAgent;
|
|
114
116
|
/**
|
|
115
117
|
* Starts a requirement's own chat (1.10): a top-level agent working in the repository, given
|
|
116
118
|
* `prompt` and not waited for. It is the chat the requirement's pipeline is later launched
|
package/engine/src/state.ts
CHANGED
|
@@ -43,6 +43,13 @@ export interface StepRecord {
|
|
|
43
43
|
usage?: AgentUsage | null;
|
|
44
44
|
/** Phase the step ran in; absent before 1.7 (develop or verify by step). */
|
|
45
45
|
phase?: PhaseName;
|
|
46
|
+
/**
|
|
47
|
+
* 1.11: the step failed while its agent was still at work (timed out, or its status could
|
|
48
|
+
* not be read). The next run of the step checks that agent before starting another one.
|
|
49
|
+
*/
|
|
50
|
+
leftRunning?: boolean;
|
|
51
|
+
/** 1.11: how many times a later run went on waiting for this record's agent instead of starting a new one. */
|
|
52
|
+
reattached?: number;
|
|
46
53
|
}
|
|
47
54
|
|
|
48
55
|
export interface PipelineState {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "paseo-req-kit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.0",
|
|
4
4
|
"description": "req-kit: a requirement-development workflow for Paseo. /req aligns a requirement in chat; the engine runs design, implementation, review and verification as Paseo agents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|