@router-agent/runtime 0.3.31 → 0.3.32
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/package.json +3 -3
- package/dist/orca/commands.js +0 -60
- package/dist/orca/commands.js.map +0 -1
- package/dist/orca/index.js +0 -452
- package/dist/orca/index.js.map +0 -1
- package/dist/orca/options.js +0 -33
- package/dist/orca/options.js.map +0 -1
- package/dist/orca/report-poller.js +0 -143
- package/dist/orca/report-poller.js.map +0 -1
- package/dist/runtimes/acp/admin.js +0 -470
- package/dist/runtimes/acp/admin.js.map +0 -1
- package/dist/runtimes/acp/agents.js +0 -167
- package/dist/runtimes/acp/agents.js.map +0 -1
- package/dist/runtimes/acp/detect.js +0 -379
- package/dist/runtimes/acp/detect.js.map +0 -1
- package/dist/runtimes/acp/index.js +0 -633
- package/dist/runtimes/acp/index.js.map +0 -1
- package/dist/runtimes/acp/install.js +0 -264
- package/dist/runtimes/acp/install.js.map +0 -1
- package/dist/runtimes/acp/permissions.js +0 -83
- package/dist/runtimes/acp/permissions.js.map +0 -1
- package/dist/runtimes/acp/protocol.js +0 -207
- package/dist/runtimes/acp/protocol.js.map +0 -1
- package/dist/runtimes/claude-code/index.js +0 -544
- package/dist/runtimes/claude-code/index.js.map +0 -1
- package/dist/runtimes/claude-code/inject.js +0 -71
- package/dist/runtimes/claude-code/inject.js.map +0 -1
- package/dist/runtimes/codex/client.js +0 -203
- package/dist/runtimes/codex/client.js.map +0 -1
- package/dist/runtimes/codex/detect.js +0 -240
- package/dist/runtimes/codex/detect.js.map +0 -1
- package/dist/runtimes/codex/index.js +0 -1066
- package/dist/runtimes/codex/index.js.map +0 -1
- package/dist/runtimes/herdr/agents.js +0 -38
- package/dist/runtimes/herdr/agents.js.map +0 -1
- package/dist/runtimes/herdr/commands.js +0 -68
- package/dist/runtimes/herdr/commands.js.map +0 -1
- package/dist/runtimes/herdr/index.js +0 -712
- package/dist/runtimes/herdr/index.js.map +0 -1
- package/dist/runtimes/herdr/options.js +0 -29
- package/dist/runtimes/herdr/options.js.map +0 -1
- package/dist/runtimes/herdr/server.js +0 -75
- package/dist/runtimes/herdr/server.js.map +0 -1
|
@@ -1,712 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Herdr 运行时:用 Herdr 的终端 workspace 承载工单。
|
|
3
|
-
*
|
|
4
|
-
* 职责切得很窄 —— **检出归 ra 自己管**(GitWorktreeProvider),Herdr 只负责
|
|
5
|
-
* 「在终端里把 Agent 跑起来」这件基础事:
|
|
6
|
-
* - `workspace create` 在检出目录上开一个终端 workspace;
|
|
7
|
-
* - `agent start` 在 workspace 的 pane 里拉起 Agent;
|
|
8
|
-
* - `agent prompt` 投递工单契约与追问;
|
|
9
|
-
* - `agent get` / `agent read` 读状态与输出;
|
|
10
|
-
* - `pane close` / `workspace close` 关掉窗口。
|
|
11
|
-
*
|
|
12
|
-
* 为什么不走 Herdr 自带的 `herdr worktree create`:
|
|
13
|
-
* 那条命令会把仓库登记进 Herdr 的全局仓库表;仓库目录一旦被删,之后**所有**
|
|
14
|
-
* `herdr worktree *` 命令都会报
|
|
15
|
-
* `worktree_list_failed: cannot change to '<path>'` —— 一颗老鼠屎坏一锅汤。
|
|
16
|
-
* 检出与回收交给 git worktree,Herdr 侧就不会再被登记状态拖住。
|
|
17
|
-
*
|
|
18
|
-
* 另外两点与 Orca 不同:
|
|
19
|
-
* 1. Herdr 的 Agent 状态是**从屏幕识别的**(idle / working / done / blocked / unknown),
|
|
20
|
-
* 不是结构化会话记录;结构化活动仍从 Agent 自己的会话记录里读(见 activity())。
|
|
21
|
-
* 2. Herdr 需要一个**在跑的服务端**;没有服务端时所有命令都返回 `server_not_running`。
|
|
22
|
-
* 服务端由 ensureHerdrServer() 在探测 / 派工时自动拉起。
|
|
23
|
-
*/
|
|
24
|
-
import { createHash } from 'node:crypto';
|
|
25
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
26
|
-
import { basename, join } from 'node:path';
|
|
27
|
-
import { WorkerRuntimeError } from '../../port.js';
|
|
28
|
-
import { buildWorkerPrompt } from '../../spec/index.js';
|
|
29
|
-
import { workBranch, worktreeName } from '../../naming.js';
|
|
30
|
-
import { resolveSession } from '../../agents/registry.js';
|
|
31
|
-
import { GitWorktreeProvider } from '../../workspaces/git-worktree.js';
|
|
32
|
-
import { runtimeOptionDefault } from '../options.js';
|
|
33
|
-
import { HERDR_RUNTIME_ID } from '../catalog.js';
|
|
34
|
-
import { herdrAgentKindOf } from './agents.js';
|
|
35
|
-
import { ensureHerdrServer } from './server.js';
|
|
36
|
-
import { agentGet, agentPrompt, agentRead, agentSendKeys, agentStart, paneClose, paneRead, version, workspaceClose, workspaceCreate, } from './commands.js';
|
|
37
|
-
/** Herdr 的 Agent 名字长度上限;Herdr 要求匹配 `[a-z][a-z0-9_-]{0,31}`。 */
|
|
38
|
-
const AGENT_NAME_MAX = 32;
|
|
39
|
-
/** 名字里项目名与分支名各自保留的长度。 */
|
|
40
|
-
const REPO_PART_MAX = 12;
|
|
41
|
-
const BRANCH_PART_MAX = 10;
|
|
42
|
-
/**
|
|
43
|
-
* Claude Code 首次在新目录启动会弹「是否信任此文件夹」;跳过权限确认不会一并跳过它。
|
|
44
|
-
* 命中就把弹窗当成「可以自动确认」处理。
|
|
45
|
-
*/
|
|
46
|
-
const TRUST_PROMPT_PATTERN = /trust this folder|quick safety check|是否信任|信任此文件夹/i;
|
|
47
|
-
export class HerdrRuntime {
|
|
48
|
-
id = HERDR_RUNTIME_ID;
|
|
49
|
-
capabilities = {
|
|
50
|
-
// Agent 活在 Herdr 的 pane 里,Router 重启不影响它 —— 与 orca / claude-code 一致。
|
|
51
|
-
agent: { liveStatus: true, processExit: false, output: true, activity: true, structuredInput: true, nudge: false },
|
|
52
|
-
// Herdr 不跟踪 PR,也没有仓库发现(项目按本地绝对路径登记)。
|
|
53
|
-
workspace: { cleanup: true, prLink: false, discovery: false },
|
|
54
|
-
};
|
|
55
|
-
herdr;
|
|
56
|
-
git;
|
|
57
|
-
workersDir;
|
|
58
|
-
home;
|
|
59
|
-
agent;
|
|
60
|
-
model;
|
|
61
|
-
claudeProjectsDir;
|
|
62
|
-
worktreesRoot;
|
|
63
|
-
gitProvider;
|
|
64
|
-
spawnServer;
|
|
65
|
-
constructor(opts) {
|
|
66
|
-
this.herdr = opts.herdr;
|
|
67
|
-
this.git = opts.git;
|
|
68
|
-
this.workersDir = opts.workersDir;
|
|
69
|
-
this.home = opts.home;
|
|
70
|
-
this.agent = String(opts.agent || runtimeOptionDefault(HERDR_RUNTIME_ID, 'agent') || '').trim();
|
|
71
|
-
this.model = opts.model?.trim() || undefined;
|
|
72
|
-
this.claudeProjectsDir = opts.claudeProjectsDir;
|
|
73
|
-
this.worktreesRoot = opts.worktreesRoot ?? join(opts.home ?? opts.workersDir, 'worktrees');
|
|
74
|
-
this.gitProvider = new GitWorktreeProvider({
|
|
75
|
-
git: opts.git,
|
|
76
|
-
worktreesRoot: this.worktreesRoot,
|
|
77
|
-
});
|
|
78
|
-
this.spawnServer = opts.spawnServer;
|
|
79
|
-
}
|
|
80
|
-
async start(ctx) {
|
|
81
|
-
const ticket = ctx.ticket;
|
|
82
|
-
const repo = ticket.repo;
|
|
83
|
-
const baseBranch = ticket.branch;
|
|
84
|
-
if (!repo)
|
|
85
|
-
throw new WorkerRuntimeError('workspace:missing-repo', '工单没有指定仓库,拒绝创建 worktree');
|
|
86
|
-
if (!baseBranch)
|
|
87
|
-
throw new WorkerRuntimeError('workspace:missing-branch', '工单没有指定起始分支,拒绝创建 worktree');
|
|
88
|
-
// Herdr 只认本地路径:不像 Orca 那样有 name:/id: 选择器。
|
|
89
|
-
if (!repo.startsWith('/')) {
|
|
90
|
-
throw new WorkerRuntimeError('workspace:missing-repo', `herdr 运行时需要本地仓库绝对路径,收到: ${repo}(请在 runtime.projects.herdr 里登记本地路径)`);
|
|
91
|
-
}
|
|
92
|
-
const statePath = ctx.report?.statePath ?? join(this.workersDir, ticket.id, 'state.json');
|
|
93
|
-
const eventsPath = ctx.report?.eventsPath ?? join(this.workersDir, ticket.id, 'events.log');
|
|
94
|
-
const branch = workBranch(ticket);
|
|
95
|
-
const name = worktreeName(ticket);
|
|
96
|
-
const workdir = ctx.workdir || join(this.worktreesRoot, name);
|
|
97
|
-
const prompt = ctx.brief?.contract ?? buildWorkerPrompt({
|
|
98
|
-
ticket,
|
|
99
|
-
mandateScope: ticket.title,
|
|
100
|
-
statePath,
|
|
101
|
-
eventsPath,
|
|
102
|
-
repo,
|
|
103
|
-
branch: baseBranch,
|
|
104
|
-
workBranch: branch,
|
|
105
|
-
workdir: ctx.workdir,
|
|
106
|
-
...(this.home ? { home: this.home } : {}),
|
|
107
|
-
});
|
|
108
|
-
// Herdr 的每条命令都走本地 socket 服务端;服务端不在,后面每一步都会失败。
|
|
109
|
-
await this.ensureServer();
|
|
110
|
-
// 1) 检出归 ra 自己管:纯 git worktree,不碰 Herdr 的仓库登记。
|
|
111
|
-
const workspace = await this.gitProvider.create({
|
|
112
|
-
ticketId: ticket.id,
|
|
113
|
-
repo,
|
|
114
|
-
baseBranch,
|
|
115
|
-
workBranch: branch,
|
|
116
|
-
name,
|
|
117
|
-
workdir,
|
|
118
|
-
});
|
|
119
|
-
// 2) Herdr 只做最基础的事:在检出目录上开一个终端窗口。
|
|
120
|
-
let created;
|
|
121
|
-
try {
|
|
122
|
-
created = await this.createWorkspace(workspace.workdir, `ra-${ticket.id}`);
|
|
123
|
-
}
|
|
124
|
-
catch (error) {
|
|
125
|
-
await this.gitProvider.remove(workspace.workdir).catch(() => { });
|
|
126
|
-
throw error;
|
|
127
|
-
}
|
|
128
|
-
const paneId = created.paneId;
|
|
129
|
-
if (!paneId) {
|
|
130
|
-
await this.gitProvider.remove(workspace.workdir).catch(() => { });
|
|
131
|
-
throw new WorkerRuntimeError('workspace:create-failed', 'herdr workspace create 没有返回 pane id');
|
|
132
|
-
}
|
|
133
|
-
const kind = this.agentKind();
|
|
134
|
-
const agentName = agentNameFor({ kind: kind.value, repo, branch });
|
|
135
|
-
const meta = {
|
|
136
|
-
schemaVersion: 3,
|
|
137
|
-
ticketId: ticket.id,
|
|
138
|
-
assignmentId: ctx.assignment.id,
|
|
139
|
-
runtime: this.id,
|
|
140
|
-
handle: paneId,
|
|
141
|
-
agentName,
|
|
142
|
-
paneId,
|
|
143
|
-
...(created.workspaceId ? { workspaceId: created.workspaceId } : {}),
|
|
144
|
-
workdir: workspace.workdir,
|
|
145
|
-
repo,
|
|
146
|
-
branch: baseBranch,
|
|
147
|
-
workBranch: workspace.branch || branch,
|
|
148
|
-
agent: kind.value,
|
|
149
|
-
status: 'starting',
|
|
150
|
-
startedAt: new Date().toISOString(),
|
|
151
|
-
};
|
|
152
|
-
this.writeMeta(ticket.id, meta);
|
|
153
|
-
try {
|
|
154
|
-
await this.startAgent(meta, kind);
|
|
155
|
-
// 契约作为首轮输入投进去;Herdr 的 agent prompt 会补上 Enter。
|
|
156
|
-
await this.run(agentPrompt(paneId, withWorkdir(prompt, workspace.workdir)));
|
|
157
|
-
return {
|
|
158
|
-
handle: paneId,
|
|
159
|
-
workdir: workspace.workdir,
|
|
160
|
-
branch: meta.workBranch,
|
|
161
|
-
...(created.workspaceId ? { workspaceRef: created.workspaceId } : {}),
|
|
162
|
-
};
|
|
163
|
-
}
|
|
164
|
-
catch (error) {
|
|
165
|
-
await this.rollback(meta).catch(() => { });
|
|
166
|
-
if (error instanceof WorkerRuntimeError) {
|
|
167
|
-
throw new WorkerRuntimeError(error.code, error.reason, {
|
|
168
|
-
retryable: error.retryable,
|
|
169
|
-
cause: error.cause,
|
|
170
|
-
detail: { ...error.detail, workdir: workspace.workdir, paneId },
|
|
171
|
-
});
|
|
172
|
-
}
|
|
173
|
-
throw new WorkerRuntimeError('agent:launch-failed', error instanceof Error ? error.message : String(error), {
|
|
174
|
-
cause: error instanceof Error ? error.stack : undefined,
|
|
175
|
-
detail: { workdir: workspace.workdir, paneId },
|
|
176
|
-
});
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
async deliver(ref, inputs, _opts) {
|
|
180
|
-
const accepted = [];
|
|
181
|
-
for (const input of inputs) {
|
|
182
|
-
if (!input.text.trim())
|
|
183
|
-
continue;
|
|
184
|
-
let res = await this.herdr.run(agentPrompt(ref.handle, renderInput(input)));
|
|
185
|
-
if (res.code !== 0) {
|
|
186
|
-
const error = parseCliError(res);
|
|
187
|
-
// Agent 正卡在确认弹窗上:Herdr 拒收输入。先看看是不是「信任此文件夹」,
|
|
188
|
-
// 能自动确认就替用户点掉再投一次;否则让 Router 稍后重投。
|
|
189
|
-
if (error?.code === 'agent_blocked' && await this.acceptTrustPrompt(ref.handle)) {
|
|
190
|
-
res = await this.herdr.run(agentPrompt(ref.handle, renderInput(input)));
|
|
191
|
-
}
|
|
192
|
-
if (res.code !== 0) {
|
|
193
|
-
const retryError = parseCliError(res);
|
|
194
|
-
if (retryError?.code === 'agent_blocked') {
|
|
195
|
-
return {
|
|
196
|
-
status: 'rejected',
|
|
197
|
-
inputIds: accepted,
|
|
198
|
-
reason: 'herdr:agent-blocked',
|
|
199
|
-
retryable: true,
|
|
200
|
-
retryAfterMs: 30_000,
|
|
201
|
-
};
|
|
202
|
-
}
|
|
203
|
-
if (retryError?.code === 'agent_not_found' || retryError?.code === 'agent_not_running') {
|
|
204
|
-
return { status: 'rejected', inputIds: accepted, reason: `herdr:${retryError.code}`, retryable: false };
|
|
205
|
-
}
|
|
206
|
-
throw new WorkerRuntimeError('agent:not-accepting-input', `herdr agent prompt 失败: ${retryError?.message ?? res.stderr.trim()}`, {
|
|
207
|
-
cause: retryError?.message ?? res.stderr,
|
|
208
|
-
detail: { handle: ref.handle },
|
|
209
|
-
});
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
accepted.push(input.id);
|
|
213
|
-
}
|
|
214
|
-
return { status: 'accepted', inputIds: accepted };
|
|
215
|
-
}
|
|
216
|
-
/**
|
|
217
|
-
* 停止:关掉承载 Agent 的 pane(连带 workspace)。
|
|
218
|
-
*
|
|
219
|
-
* 回收检出不在这里做 —— 那是 removeWorkspace 的事,拆开是为了让
|
|
220
|
-
* 「先停 Agent、稍后再回收目录」这种节奏也能成立。
|
|
221
|
-
*/
|
|
222
|
-
async stop(ref, _reason) {
|
|
223
|
-
const paneId = ref.handle;
|
|
224
|
-
if (!paneId)
|
|
225
|
-
return;
|
|
226
|
-
const res = await this.herdr.run(paneClose(paneId));
|
|
227
|
-
if (res.code !== 0) {
|
|
228
|
-
const error = parseCliError(res);
|
|
229
|
-
// pane 已经不在了(重复 stop / 已被回收)视为幂等成功。
|
|
230
|
-
if (!error || !/not_found|already/i.test(error.code)) {
|
|
231
|
-
throw new WorkerRuntimeError('agent:gone', `herdr pane close 失败: ${error?.message ?? res.stderr.trim()}`, {
|
|
232
|
-
cause: error?.message ?? res.stderr,
|
|
233
|
-
detail: { handle: paneId },
|
|
234
|
-
});
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
const meta = this.readMeta(ref.ticketId);
|
|
238
|
-
// 关掉最后一个 pane 会连带关掉 workspace;再关一次是幂等的,能收掉残骸。
|
|
239
|
-
if (meta?.workspaceId) {
|
|
240
|
-
await this.herdr.run(workspaceClose(meta.workspaceId)).catch(() => ({ code: 1, stdout: '', stderr: '' }));
|
|
241
|
-
}
|
|
242
|
-
if (meta) {
|
|
243
|
-
meta.status = 'stopped';
|
|
244
|
-
this.writeMeta(ref.ticketId, meta);
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
/** 状态:把 Herdr 的屏幕识别结果映射成中立的 AgentStatus。 */
|
|
248
|
-
async status(ref) {
|
|
249
|
-
const res = await this.herdr.run(agentGet(ref.handle));
|
|
250
|
-
if (res.code !== 0) {
|
|
251
|
-
const error = parseCliError(res);
|
|
252
|
-
// 目标不存在 = Agent 已经没了;查询本身失败 = 判断不了,返回 null。
|
|
253
|
-
if (error?.code === 'agent_not_found' || error?.code === 'agent_not_running') {
|
|
254
|
-
return { alive: false, phase: 'lost', acceptingInput: false, detail: { source: 'herdr' } };
|
|
255
|
-
}
|
|
256
|
-
return null;
|
|
257
|
-
}
|
|
258
|
-
const agent = parseAgent(res.stdout);
|
|
259
|
-
if (!agent)
|
|
260
|
-
return null;
|
|
261
|
-
const detail = {
|
|
262
|
-
source: 'herdr',
|
|
263
|
-
agentStatus: agent.status,
|
|
264
|
-
agentKind: agent.kind,
|
|
265
|
-
...(agent.stateChangeSeq !== undefined ? { stateChangeSeq: agent.stateChangeSeq } : {}),
|
|
266
|
-
...(agent.completionSeq !== undefined ? { completionSeq: agent.completionSeq } : {}),
|
|
267
|
-
};
|
|
268
|
-
switch (agent.status) {
|
|
269
|
-
case 'blocked':
|
|
270
|
-
// Herdr 认出的确认 / 提问弹窗:Agent 活着但在等人,Router 不该往里投输入。
|
|
271
|
-
return { alive: true, phase: 'blocked', acceptingInput: false, detail };
|
|
272
|
-
case 'idle':
|
|
273
|
-
case 'done':
|
|
274
|
-
case 'working':
|
|
275
|
-
// working 时 Herdr 仍然接受 prompt(可以边干边插话),所以 acceptingInput 保持 true。
|
|
276
|
-
return { alive: true, phase: 'running', acceptingInput: true, detail };
|
|
277
|
-
case 'unknown':
|
|
278
|
-
return { alive: true, phase: 'unknown', acceptingInput: false, detail };
|
|
279
|
-
default:
|
|
280
|
-
return { alive: true, phase: 'unknown', acceptingInput: false, detail };
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
/** 输出尾部:读终端渲染文本;Agent 正在忙时退回 visible 源。 */
|
|
284
|
-
async output(ref, opts = {}) {
|
|
285
|
-
const limit = opts.limit ?? 60;
|
|
286
|
-
const res = await this.herdr.run(agentRead(ref.handle, { lines: limit }));
|
|
287
|
-
if (res.code !== 0) {
|
|
288
|
-
const error = parseCliError(res);
|
|
289
|
-
// Agent 忙 / 被阻塞时,带 --lines 的 recent 源会拒绝读取;visible 源不需要。
|
|
290
|
-
if (error?.code !== 'agent_not_idle')
|
|
291
|
-
return null;
|
|
292
|
-
const fallback = await this.herdr.run(agentRead(ref.handle, { lines: limit, source: 'visible' }));
|
|
293
|
-
if (fallback.code !== 0)
|
|
294
|
-
return null;
|
|
295
|
-
return { status: 'running', lines: splitLines(fallback.stdout, limit), source: 'herdr-visible' };
|
|
296
|
-
}
|
|
297
|
-
return { status: 'running', lines: splitLines(res.stdout, limit), source: 'herdr' };
|
|
298
|
-
}
|
|
299
|
-
/** 结构化活动:读 Agent 自己的会话记录,比终端屏幕干净。 */
|
|
300
|
-
async activity(query) {
|
|
301
|
-
const workdir = query.ref.workdir;
|
|
302
|
-
if (!workdir)
|
|
303
|
-
return null;
|
|
304
|
-
const meta = this.readMeta(query.ref.ticketId);
|
|
305
|
-
const resolved = resolveSession(workdir, meta?.agent ?? this.agent, {
|
|
306
|
-
...(this.claudeProjectsDir ? { roots: { claude: this.claudeProjectsDir } } : {}),
|
|
307
|
-
});
|
|
308
|
-
if (!resolved)
|
|
309
|
-
return null;
|
|
310
|
-
return resolved.reader.read(workdir, query.opts ?? {});
|
|
311
|
-
}
|
|
312
|
-
async inspectWorkspace(ref) {
|
|
313
|
-
if (!ref.workdir)
|
|
314
|
-
return null;
|
|
315
|
-
return this.gitProvider.inspect(ref.workdir);
|
|
316
|
-
}
|
|
317
|
-
/** 回收检出:先关 Herdr 窗口,再让 git 删掉 worktree。 */
|
|
318
|
-
async removeWorkspace(ref) {
|
|
319
|
-
const meta = this.readMeta(ref.ticketId);
|
|
320
|
-
const paneId = meta?.paneId;
|
|
321
|
-
const workspaceId = meta?.workspaceId ?? ref.workspaceRef;
|
|
322
|
-
if (paneId)
|
|
323
|
-
await this.herdr.run(paneClose(paneId)).catch(() => ({ code: 1, stdout: '', stderr: '' }));
|
|
324
|
-
if (workspaceId)
|
|
325
|
-
await this.herdr.run(workspaceClose(workspaceId)).catch(() => ({ code: 1, stdout: '', stderr: '' }));
|
|
326
|
-
const workdir = ref.workdir || meta?.workdir;
|
|
327
|
-
if (workdir)
|
|
328
|
-
await this.removeWorktree(workdir, meta?.repo);
|
|
329
|
-
this.clearMeta(ref.ticketId);
|
|
330
|
-
}
|
|
331
|
-
async doctor() {
|
|
332
|
-
const checks = [];
|
|
333
|
-
const ver = await this.herdr.run(version());
|
|
334
|
-
const cliOk = ver.code === 0;
|
|
335
|
-
checks.push({
|
|
336
|
-
name: 'Herdr CLI',
|
|
337
|
-
status: cliOk ? 'ok' : 'fail',
|
|
338
|
-
detail: cliOk ? ver.stdout.trim() : `${ver.stderr || ver.stdout}`.trim().slice(0, 160),
|
|
339
|
-
...(cliOk ? {} : { hint: '安装 Herdr,并确保 herdr 在 PATH 中(https://herdr.dev/docs/install)' }),
|
|
340
|
-
});
|
|
341
|
-
if (!cliOk) {
|
|
342
|
-
checks.push({
|
|
343
|
-
name: 'Herdr server',
|
|
344
|
-
status: 'fail',
|
|
345
|
-
detail: '未检测(Herdr CLI 不可用)',
|
|
346
|
-
hint: '安装 Herdr,并确保 herdr 在 PATH 中(https://herdr.dev/docs/install)',
|
|
347
|
-
});
|
|
348
|
-
return checks;
|
|
349
|
-
}
|
|
350
|
-
// 检测时顺手把服务端拉起来:没有服务端,herdr 的每条命令都会返回 server_not_running。
|
|
351
|
-
const server = await ensureHerdrServer({
|
|
352
|
-
runner: this.herdr,
|
|
353
|
-
...(this.spawnServer ? { spawnFn: this.spawnServer } : {}),
|
|
354
|
-
});
|
|
355
|
-
checks.push({
|
|
356
|
-
name: 'Herdr server',
|
|
357
|
-
status: server.running ? 'ok' : 'fail',
|
|
358
|
-
detail: server.running
|
|
359
|
-
? `${server.version ?? 'running'}${server.started ? '(已自动拉起)' : ''}`
|
|
360
|
-
: (server.reason ?? '未找到运行中的 Herdr 服务端'),
|
|
361
|
-
...(server.running ? {} : { hint: '启动 Herdr 服务端(herdr server),否则所有 herdr 命令都会返回 server_not_running' }),
|
|
362
|
-
});
|
|
363
|
-
return checks;
|
|
364
|
-
}
|
|
365
|
-
/**
|
|
366
|
-
* 本地路径仓库的 PR 跟踪需要 owner/repo。
|
|
367
|
-
*
|
|
368
|
-
* 检出是源仓库的 linked worktree,origin 与源仓库一致,
|
|
369
|
-
* 所以直接在检出目录里读 remote 就行。
|
|
370
|
-
*/
|
|
371
|
-
async repoSlug(ref) {
|
|
372
|
-
const workdir = ref.workdir;
|
|
373
|
-
if (!workdir)
|
|
374
|
-
return null;
|
|
375
|
-
return this.gitProvider.remoteRepo(workdir);
|
|
376
|
-
}
|
|
377
|
-
/** 服务端不在就把它拉起来;拉不起来时给出可重试的 runtime:unavailable。 */
|
|
378
|
-
async ensureServer() {
|
|
379
|
-
const state = await ensureHerdrServer({
|
|
380
|
-
runner: this.herdr,
|
|
381
|
-
...(this.spawnServer ? { spawnFn: this.spawnServer } : {}),
|
|
382
|
-
});
|
|
383
|
-
if (!state.running) {
|
|
384
|
-
throw new WorkerRuntimeError('runtime:unavailable', `herdr 服务端不可用:${state.reason ?? '未知原因'}`, { retryable: true, detail: { reason: state.reason } });
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
async createWorkspace(cwd, label) {
|
|
388
|
-
const res = await this.run(workspaceCreate({ cwd, label }));
|
|
389
|
-
const created = parseWorkspaceResult(res.stdout);
|
|
390
|
-
if (!created.paneId) {
|
|
391
|
-
throw new WorkerRuntimeError('workspace:create-failed', 'herdr workspace create 没有返回 pane id', {
|
|
392
|
-
detail: { cwd },
|
|
393
|
-
});
|
|
394
|
-
}
|
|
395
|
-
return created;
|
|
396
|
-
}
|
|
397
|
-
/**
|
|
398
|
-
* 拉起 Agent,并处理两种「起不来」:
|
|
399
|
-
* - 名字被上一轮残留的 Agent 占了:把那个 pane 关掉再试一次;
|
|
400
|
-
* - Agent 卡在启动前的确认弹窗(Claude Code 的「信任此文件夹」):替用户点掉。
|
|
401
|
-
*/
|
|
402
|
-
async startAgent(meta, kind) {
|
|
403
|
-
const paneId = meta.paneId;
|
|
404
|
-
const name = meta.agentName;
|
|
405
|
-
const args = this.launchArgs(kind);
|
|
406
|
-
const res = await this.herdr.run(agentStart({ name, kind: kind.value, pane: paneId, args }));
|
|
407
|
-
if (res.code === 0) {
|
|
408
|
-
meta.status = 'running';
|
|
409
|
-
this.writeMeta(meta.ticketId, meta);
|
|
410
|
-
return;
|
|
411
|
-
}
|
|
412
|
-
const error = parseCliError(res);
|
|
413
|
-
if (error?.code === 'agent_name_taken') {
|
|
414
|
-
const stalePane = parseStalePaneId(error.message);
|
|
415
|
-
if (!stalePane || stalePane === paneId) {
|
|
416
|
-
throw this.startError(error, res, paneId);
|
|
417
|
-
}
|
|
418
|
-
await this.herdr.run(paneClose(stalePane)).catch(() => ({ code: 1, stdout: '', stderr: '' }));
|
|
419
|
-
const retry = await this.herdr.run(agentStart({ name, kind: kind.value, pane: paneId, args }));
|
|
420
|
-
if (retry.code === 0) {
|
|
421
|
-
meta.status = 'running';
|
|
422
|
-
this.writeMeta(meta.ticketId, meta);
|
|
423
|
-
return;
|
|
424
|
-
}
|
|
425
|
-
throw this.startError(parseCliError(retry), retry, paneId);
|
|
426
|
-
}
|
|
427
|
-
if (error?.code === 'agent_not_ready') {
|
|
428
|
-
if (!await this.acceptTrustPrompt(paneId)) {
|
|
429
|
-
throw this.startError(error, res, paneId);
|
|
430
|
-
}
|
|
431
|
-
await this.waitForAgentReady(paneId);
|
|
432
|
-
meta.status = 'running';
|
|
433
|
-
this.writeMeta(meta.ticketId, meta);
|
|
434
|
-
return;
|
|
435
|
-
}
|
|
436
|
-
throw this.startError(error, res, paneId);
|
|
437
|
-
}
|
|
438
|
-
/**
|
|
439
|
-
* 自动确认「是否信任此文件夹」。
|
|
440
|
-
*
|
|
441
|
-
* 弹窗默认选中「No, exit」,所以往下挪一格选「Yes, I trust this folder」再回车。
|
|
442
|
-
* 只在文本确实命中时才动手,避免误触别的确认框。
|
|
443
|
-
*/
|
|
444
|
-
async acceptTrustPrompt(paneId) {
|
|
445
|
-
const res = await this.herdr.run(paneRead(paneId, { lines: 40 }));
|
|
446
|
-
if (res.code !== 0)
|
|
447
|
-
return false;
|
|
448
|
-
if (!TRUST_PROMPT_PATTERN.test(res.stdout))
|
|
449
|
-
return false;
|
|
450
|
-
const down = await this.herdr.run(agentSendKeys(paneId, ['down']));
|
|
451
|
-
if (down.code !== 0)
|
|
452
|
-
return false;
|
|
453
|
-
const enter = await this.herdr.run(agentSendKeys(paneId, ['enter']));
|
|
454
|
-
return enter.code === 0;
|
|
455
|
-
}
|
|
456
|
-
/** 等 Agent 从 blocked 变成能收输入的状态。 */
|
|
457
|
-
async waitForAgentReady(paneId, timeoutMs = 30_000) {
|
|
458
|
-
const deadline = Date.now() + timeoutMs;
|
|
459
|
-
while (Date.now() < deadline) {
|
|
460
|
-
const res = await this.herdr.run(agentGet(paneId));
|
|
461
|
-
if (res.code === 0) {
|
|
462
|
-
const agent = parseAgent(res.stdout);
|
|
463
|
-
if (agent && agent.status !== 'blocked' && agent.status !== 'unknown')
|
|
464
|
-
return;
|
|
465
|
-
}
|
|
466
|
-
await sleep(300);
|
|
467
|
-
}
|
|
468
|
-
throw new WorkerRuntimeError('agent:launch-failed', `herdr Agent 在 ${timeoutMs}ms 内没有就绪`, {
|
|
469
|
-
retryable: true,
|
|
470
|
-
detail: { paneId },
|
|
471
|
-
});
|
|
472
|
-
}
|
|
473
|
-
agentKind() {
|
|
474
|
-
const kind = herdrAgentKindOf(this.agent);
|
|
475
|
-
if (kind)
|
|
476
|
-
return kind;
|
|
477
|
-
const fallback = herdrAgentKindOf(String(runtimeOptionDefault(HERDR_RUNTIME_ID, 'agent') ?? ''));
|
|
478
|
-
if (fallback)
|
|
479
|
-
return fallback;
|
|
480
|
-
throw new WorkerRuntimeError('runtime:unknown', `未知的 Herdr Agent kind: ${this.agent}`);
|
|
481
|
-
}
|
|
482
|
-
launchArgs(kind) {
|
|
483
|
-
const args = [...kind.args];
|
|
484
|
-
if (this.model && kind.modelFlag)
|
|
485
|
-
args.push(kind.modelFlag, this.model);
|
|
486
|
-
return args;
|
|
487
|
-
}
|
|
488
|
-
startError(error, res, paneId) {
|
|
489
|
-
const reason = error?.message ?? res.stderr.trim();
|
|
490
|
-
if (error?.code === 'server_not_running') {
|
|
491
|
-
return new WorkerRuntimeError('runtime:unavailable', 'herdr 服务端没有在运行,请先启动 Herdr(herdr server)', {
|
|
492
|
-
retryable: true,
|
|
493
|
-
cause: reason,
|
|
494
|
-
detail: { paneId },
|
|
495
|
-
});
|
|
496
|
-
}
|
|
497
|
-
if (error?.code === 'agent_not_ready') {
|
|
498
|
-
return new WorkerRuntimeError('agent:launch-failed', `herdr agent start 未就绪(Agent 被阻塞,通常是首次运行的确认弹窗): ${reason}`, {
|
|
499
|
-
cause: reason,
|
|
500
|
-
detail: { paneId },
|
|
501
|
-
});
|
|
502
|
-
}
|
|
503
|
-
return new WorkerRuntimeError('agent:launch-failed', `herdr agent start 失败: ${reason}`, {
|
|
504
|
-
cause: reason,
|
|
505
|
-
detail: { paneId },
|
|
506
|
-
});
|
|
507
|
-
}
|
|
508
|
-
async run(argv) {
|
|
509
|
-
const res = await this.herdr.run(argv);
|
|
510
|
-
if (res.code !== 0) {
|
|
511
|
-
const error = parseCliError(res);
|
|
512
|
-
const reason = error?.message ?? res.stderr.trim() ?? '';
|
|
513
|
-
if (error?.code === 'server_not_running') {
|
|
514
|
-
throw new WorkerRuntimeError('runtime:unavailable', 'herdr 服务端没有在运行,请先启动 Herdr(herdr server)', {
|
|
515
|
-
retryable: true,
|
|
516
|
-
cause: reason,
|
|
517
|
-
detail: { argv: argv.slice(0, 2).join(' ') },
|
|
518
|
-
});
|
|
519
|
-
}
|
|
520
|
-
throw new WorkerRuntimeError('workspace:create-failed', `herdr ${argv.slice(0, 2).join(' ')} 失败: ${reason}`, {
|
|
521
|
-
cause: reason,
|
|
522
|
-
detail: { argv: argv.slice(0, 2).join(' ') },
|
|
523
|
-
});
|
|
524
|
-
}
|
|
525
|
-
return res;
|
|
526
|
-
}
|
|
527
|
-
/** 启动半失败时把窗口与检出回收掉,避免重试撞分支。 */
|
|
528
|
-
async rollback(meta) {
|
|
529
|
-
if (meta.paneId)
|
|
530
|
-
await this.herdr.run(paneClose(meta.paneId)).catch(() => ({ code: 1, stdout: '', stderr: '' }));
|
|
531
|
-
if (meta.workspaceId) {
|
|
532
|
-
await this.herdr.run(workspaceClose(meta.workspaceId)).catch(() => ({ code: 1, stdout: '', stderr: '' }));
|
|
533
|
-
}
|
|
534
|
-
if (meta.workdir)
|
|
535
|
-
await this.removeWorktree(meta.workdir, meta.repo).catch(() => { });
|
|
536
|
-
}
|
|
537
|
-
/**
|
|
538
|
-
* 删检出。
|
|
539
|
-
*
|
|
540
|
-
* `git worktree remove` 必须在仓库上下文里跑,所以 cwd 给源仓库而不是检出目录;
|
|
541
|
-
* 检出里有改动时先温和地删,失败了再强制删。
|
|
542
|
-
*/
|
|
543
|
-
async removeWorktree(workdir, repo) {
|
|
544
|
-
// 先确认它确实是一个受管的检出,避免误删别的路径。
|
|
545
|
-
const info = await this.gitProvider.inspect(workdir).catch(() => null);
|
|
546
|
-
if (!info)
|
|
547
|
-
return;
|
|
548
|
-
const opts = repo && repo.startsWith('/') ? { cwd: repo } : {};
|
|
549
|
-
const res = await this.git.run(['worktree', 'remove', '--', workdir], opts);
|
|
550
|
-
if (res.code === 0)
|
|
551
|
-
return;
|
|
552
|
-
const forced = await this.git.run(['worktree', 'remove', '--force', '--', workdir], opts);
|
|
553
|
-
if (forced.code !== 0) {
|
|
554
|
-
throw new WorkerRuntimeError('workspace:remove-failed', `git worktree remove 失败: ${forced.stderr || forced.stdout}`, {
|
|
555
|
-
cause: forced.stderr || forced.stdout,
|
|
556
|
-
detail: { workdir, repo },
|
|
557
|
-
});
|
|
558
|
-
}
|
|
559
|
-
}
|
|
560
|
-
metaPath(ticketId) {
|
|
561
|
-
return join(this.workersDir, ticketId, 'meta.json');
|
|
562
|
-
}
|
|
563
|
-
readMeta(ticketId) {
|
|
564
|
-
if (!ticketId)
|
|
565
|
-
return null;
|
|
566
|
-
try {
|
|
567
|
-
return JSON.parse(readFileSync(this.metaPath(ticketId), 'utf8'));
|
|
568
|
-
}
|
|
569
|
-
catch {
|
|
570
|
-
return null;
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
writeMeta(ticketId, meta) {
|
|
574
|
-
const path = this.metaPath(ticketId);
|
|
575
|
-
mkdirSync(join(this.workersDir, ticketId), { recursive: true });
|
|
576
|
-
const tmp = `${path}.tmp`;
|
|
577
|
-
writeFileSync(tmp, `${JSON.stringify(meta, null, 2)}\n`, 'utf8');
|
|
578
|
-
renameSync(tmp, path);
|
|
579
|
-
}
|
|
580
|
-
clearMeta(ticketId) {
|
|
581
|
-
const path = this.metaPath(ticketId);
|
|
582
|
-
if (existsSync(path)) {
|
|
583
|
-
try {
|
|
584
|
-
renameSync(path, `${path}.removed`);
|
|
585
|
-
}
|
|
586
|
-
catch {
|
|
587
|
-
// 删不掉就留着;下一轮 start 会覆盖它。
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
/** 投递时带上输入 id:worker 只有看到 id 才能写进 acked_inputs,Router 才不会重投。 */
|
|
593
|
-
export function renderInput(input) {
|
|
594
|
-
return `【Router 输入 ${input.id}】\n${input.text}`;
|
|
595
|
-
}
|
|
596
|
-
/**
|
|
597
|
-
* 把契约里的「工作目录待定」占位换成 Herdr 实际建出来的检出路径。
|
|
598
|
-
*
|
|
599
|
-
* 中立契约里写的是「(由运行时决定)」,Orca 的手册里写的是「(由 Orca 决定)」;
|
|
600
|
-
* 两种都换成真实路径,worker 才知道自己在哪。
|
|
601
|
-
*/
|
|
602
|
-
export function withWorkdir(text, workdir) {
|
|
603
|
-
if (!workdir)
|
|
604
|
-
return text;
|
|
605
|
-
return text.replaceAll('(由运行时决定)', workdir).replaceAll('(由 Orca 决定)', workdir);
|
|
606
|
-
}
|
|
607
|
-
/**
|
|
608
|
-
* Agent 名字:`<项目>-<分支>-<短哈希>`。
|
|
609
|
-
*
|
|
610
|
-
* Herdr 要求名字匹配 `[a-z][a-z0-9_-]{0,31}` 且存活期间唯一。
|
|
611
|
-
* 末尾的短哈希由「工具 + 项目路径 + 分支」算出:同一个组合永远得到同一个名字,
|
|
612
|
-
* 换个工具或换个分支立刻不同 —— 比工单号这种递增数字稳定,
|
|
613
|
-
* 重派工时也能算出同名(撞名就回收旧窗口)。
|
|
614
|
-
*/
|
|
615
|
-
export function agentNameFor(opts) {
|
|
616
|
-
const hash = shortHash(`${opts.kind}\n${opts.repo}\n${opts.branch}`);
|
|
617
|
-
const repoPart = slugOf(basename(opts.repo)).slice(0, REPO_PART_MAX);
|
|
618
|
-
const branchPart = slugOf(opts.branch.split('/').pop() ?? opts.branch).slice(0, BRANCH_PART_MAX);
|
|
619
|
-
const head = [repoPart, branchPart].filter(Boolean).join('-');
|
|
620
|
-
const room = AGENT_NAME_MAX - 1 - hash.length;
|
|
621
|
-
const base = `ra-${head}`.slice(0, room).replace(/-+$/, '');
|
|
622
|
-
return `${base}-${hash}`;
|
|
623
|
-
}
|
|
624
|
-
/** 内容哈希:短、稳定、只做唯一后缀。 */
|
|
625
|
-
export function shortHash(input, length = 8) {
|
|
626
|
-
return createHash('sha256').update(String(input ?? '')).digest('hex').slice(0, length);
|
|
627
|
-
}
|
|
628
|
-
/** 解析 `herdr workspace create` 的返回。 */
|
|
629
|
-
export function parseWorkspaceResult(stdout) {
|
|
630
|
-
const root = parseJson(stdout);
|
|
631
|
-
const result = (root?.result ?? {});
|
|
632
|
-
const pane = (result.root_pane ?? {});
|
|
633
|
-
const workspace = (result.workspace ?? {});
|
|
634
|
-
return {
|
|
635
|
-
...(pane.pane_id ? { paneId: String(pane.pane_id) } : {}),
|
|
636
|
-
...(workspace.workspace_id ? { workspaceId: String(workspace.workspace_id) } : {}),
|
|
637
|
-
...(pane.tab_id ? { tabId: String(pane.tab_id) } : {}),
|
|
638
|
-
};
|
|
639
|
-
}
|
|
640
|
-
/** 从「名字被占用」的错误里捞出占用者所在的 pane。 */
|
|
641
|
-
export function parseStalePaneId(message) {
|
|
642
|
-
return String(message ?? '').match(/pane_id=([A-Za-z0-9:_-]+)/)?.[1] ?? null;
|
|
643
|
-
}
|
|
644
|
-
/** 解析 `herdr agent get/start/prompt` 里的 Agent 快照。 */
|
|
645
|
-
export function parseAgent(stdout) {
|
|
646
|
-
const root = parseJson(stdout);
|
|
647
|
-
const result = (root?.result ?? {});
|
|
648
|
-
const agent = (result.agent ?? {});
|
|
649
|
-
if (!agent || typeof agent !== 'object' || !agent.agent_status)
|
|
650
|
-
return null;
|
|
651
|
-
const session = (agent.agent_session ?? {});
|
|
652
|
-
return {
|
|
653
|
-
status: String(agent.agent_status),
|
|
654
|
-
...(agent.agent ? { kind: String(agent.agent) } : {}),
|
|
655
|
-
...(agent.name ? { name: String(agent.name) } : {}),
|
|
656
|
-
...(agent.pane_id ? { paneId: String(agent.pane_id) } : {}),
|
|
657
|
-
...(agent.state_change_seq !== undefined ? { stateChangeSeq: Number(agent.state_change_seq) } : {}),
|
|
658
|
-
...(agent.completion_seq !== undefined ? { completionSeq: Number(agent.completion_seq) } : {}),
|
|
659
|
-
...(session.value ? { sessionId: String(session.value) } : {}),
|
|
660
|
-
...(agent.interactive_ready !== undefined ? { interactiveReady: Boolean(agent.interactive_ready) } : {}),
|
|
661
|
-
};
|
|
662
|
-
}
|
|
663
|
-
/** 解析 Herdr CLI 的错误体。 */
|
|
664
|
-
export function parseCliError(res) {
|
|
665
|
-
for (const raw of [res.stderr, res.stdout]) {
|
|
666
|
-
const text = raw?.trim();
|
|
667
|
-
if (!text)
|
|
668
|
-
continue;
|
|
669
|
-
try {
|
|
670
|
-
const parsed = JSON.parse(text);
|
|
671
|
-
const error = parsed?.error;
|
|
672
|
-
if (error) {
|
|
673
|
-
return {
|
|
674
|
-
code: String(error.code ?? 'unknown'),
|
|
675
|
-
message: String(error.message ?? text).slice(0, 300),
|
|
676
|
-
};
|
|
677
|
-
}
|
|
678
|
-
}
|
|
679
|
-
catch {
|
|
680
|
-
// 不是 JSON(例如 CLI 用法错误),按纯文本处理。
|
|
681
|
-
}
|
|
682
|
-
}
|
|
683
|
-
return null;
|
|
684
|
-
}
|
|
685
|
-
function slugOf(text) {
|
|
686
|
-
return String(text ?? '')
|
|
687
|
-
.toLowerCase()
|
|
688
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
689
|
-
.replace(/^-+|-+$/g, '');
|
|
690
|
-
}
|
|
691
|
-
function parseJson(stdout) {
|
|
692
|
-
const text = stdout?.trim();
|
|
693
|
-
if (!text)
|
|
694
|
-
return null;
|
|
695
|
-
try {
|
|
696
|
-
return JSON.parse(text);
|
|
697
|
-
}
|
|
698
|
-
catch {
|
|
699
|
-
return null;
|
|
700
|
-
}
|
|
701
|
-
}
|
|
702
|
-
function splitLines(text, limit) {
|
|
703
|
-
return text
|
|
704
|
-
.replace(/\r/g, '')
|
|
705
|
-
.split('\n')
|
|
706
|
-
.filter(line => line.trim().length > 0)
|
|
707
|
-
.slice(-limit);
|
|
708
|
-
}
|
|
709
|
-
function sleep(ms) {
|
|
710
|
-
return new Promise(resolve => setTimeout(resolve, ms));
|
|
711
|
-
}
|
|
712
|
-
//# sourceMappingURL=index.js.map
|