@calltelemetry/openclaw-linear 0.4.1 → 0.5.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/README.md +263 -249
- package/index.ts +104 -1
- package/openclaw.plugin.json +4 -1
- package/package.json +5 -1
- package/prompts.yaml +61 -0
- package/src/active-session.ts +1 -1
- package/src/cli.ts +103 -0
- package/src/dispatch-service.ts +50 -2
- package/src/dispatch-state.ts +240 -8
- package/src/notify.ts +91 -0
- package/src/pipeline.ts +561 -406
- package/src/tier-assess.ts +1 -1
- package/src/webhook.ts +39 -30
package/src/pipeline.ts
CHANGED
|
@@ -1,499 +1,654 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pipeline.ts — Dispatch pipeline v2: hook-driven with hard-enforced audit.
|
|
3
|
+
*
|
|
4
|
+
* v1 (runFullPipeline) ran plan→implement→audit in a single synchronous flow
|
|
5
|
+
* with the same agent self-certifying its own work.
|
|
6
|
+
*
|
|
7
|
+
* v2 splits into:
|
|
8
|
+
* - Worker phase: orchestrator spawns worker via plugin code, worker plans + implements
|
|
9
|
+
* - Audit phase: agent_end hook auto-triggers independent audit (runAgent)
|
|
10
|
+
* - Verdict phase: agent_end hook processes audit result → done/rework/stuck
|
|
11
|
+
*
|
|
12
|
+
* Prompts are loaded from prompts.yaml (sidecar file, customizable).
|
|
13
|
+
*/
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
15
|
+
import { join, dirname } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { parse as parseYaml } from "yaml";
|
|
1
18
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
19
|
import type { LinearAgentApi, ActivityContent } from "./linear-api.js";
|
|
3
20
|
import { runAgent } from "./agent.js";
|
|
4
21
|
import { setActiveSession, clearActiveSession } from "./active-session.js";
|
|
5
|
-
import
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
title: string;
|
|
21
|
-
description?: string | null;
|
|
22
|
-
};
|
|
23
|
-
promptContext?: unknown;
|
|
24
|
-
/** Populated by implementor stage if Codex creates a worktree */
|
|
25
|
-
worktreePath?: string | null;
|
|
26
|
-
/** Codex branch name, e.g. codex/UAT-123 */
|
|
27
|
-
codexBranch?: string | null;
|
|
28
|
-
/** Complexity tier selected by tier assessment */
|
|
29
|
-
tier?: Tier;
|
|
30
|
-
/** Tier model ID — for display/tracking only, NOT passed to coding CLI */
|
|
31
|
-
model?: string;
|
|
32
|
-
}
|
|
22
|
+
import {
|
|
23
|
+
type Tier,
|
|
24
|
+
type DispatchStatus,
|
|
25
|
+
type ActiveDispatch,
|
|
26
|
+
type DispatchState,
|
|
27
|
+
type SessionMapping,
|
|
28
|
+
transitionDispatch,
|
|
29
|
+
registerSessionMapping,
|
|
30
|
+
markEventProcessed,
|
|
31
|
+
completeDispatch,
|
|
32
|
+
TransitionError,
|
|
33
|
+
readDispatchState,
|
|
34
|
+
getActiveDispatch,
|
|
35
|
+
} from "./dispatch-state.js";
|
|
36
|
+
import { type NotifyFn } from "./notify.js";
|
|
33
37
|
|
|
34
38
|
// ---------------------------------------------------------------------------
|
|
35
|
-
//
|
|
39
|
+
// Prompt loading
|
|
36
40
|
// ---------------------------------------------------------------------------
|
|
37
41
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
+
interface PromptTemplates {
|
|
43
|
+
worker: { system: string; task: string };
|
|
44
|
+
audit: { system: string; task: string };
|
|
45
|
+
rework: { addendum: string };
|
|
42
46
|
}
|
|
43
47
|
|
|
44
|
-
|
|
45
|
-
|
|
48
|
+
const DEFAULT_PROMPTS: PromptTemplates = {
|
|
49
|
+
worker: {
|
|
50
|
+
system: "You are implementing a Linear issue. Post an implementation summary as a Linear comment when done. DO NOT mark the issue as Done.",
|
|
51
|
+
task: "Implement issue {{identifier}}: {{title}}\n\nIssue body:\n{{description}}\n\nWorktree: {{worktreePath}}",
|
|
52
|
+
},
|
|
53
|
+
audit: {
|
|
54
|
+
system: "You are an independent auditor. The Linear issue body is the SOURCE OF TRUTH. Worker comments are secondary evidence.",
|
|
55
|
+
task: 'Audit issue {{identifier}}: {{title}}\n\nIssue body:\n{{description}}\n\nWorktree: {{worktreePath}}\n\nReturn JSON verdict: {"pass": true/false, "criteria": [...], "gaps": [...], "testResults": "..."}',
|
|
56
|
+
},
|
|
57
|
+
rework: {
|
|
58
|
+
addendum: "PREVIOUS AUDIT FAILED (attempt {{attempt}}). Gaps:\n{{gaps}}\n\nAddress these specific issues.",
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
let _cachedPrompts: PromptTemplates | null = null;
|
|
63
|
+
|
|
64
|
+
export function loadPrompts(pluginConfig?: Record<string, unknown>): PromptTemplates {
|
|
65
|
+
if (_cachedPrompts) return _cachedPrompts;
|
|
66
|
+
|
|
46
67
|
try {
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
68
|
+
// Try custom path first
|
|
69
|
+
const customPath = pluginConfig?.promptsPath as string | undefined;
|
|
70
|
+
let raw: string;
|
|
71
|
+
|
|
72
|
+
if (customPath) {
|
|
73
|
+
const resolved = customPath.startsWith("~")
|
|
74
|
+
? customPath.replace("~", process.env.HOME ?? "")
|
|
75
|
+
: customPath;
|
|
76
|
+
raw = readFileSync(resolved, "utf-8");
|
|
77
|
+
} else {
|
|
78
|
+
// Load from plugin directory (sidecar file)
|
|
79
|
+
const pluginRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
80
|
+
raw = readFileSync(join(pluginRoot, "prompts.yaml"), "utf-8");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const parsed = parseYaml(raw) as Partial<PromptTemplates>;
|
|
84
|
+
_cachedPrompts = {
|
|
85
|
+
worker: { ...DEFAULT_PROMPTS.worker, ...parsed.worker },
|
|
86
|
+
audit: { ...DEFAULT_PROMPTS.audit, ...parsed.audit },
|
|
87
|
+
rework: { ...DEFAULT_PROMPTS.rework, ...parsed.rework },
|
|
88
|
+
};
|
|
57
89
|
} catch {
|
|
58
|
-
|
|
90
|
+
_cachedPrompts = DEFAULT_PROMPTS;
|
|
59
91
|
}
|
|
92
|
+
|
|
93
|
+
return _cachedPrompts;
|
|
60
94
|
}
|
|
61
95
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
96
|
+
/** Clear prompt cache (for testing or after config change) */
|
|
97
|
+
export function clearPromptCache(): void {
|
|
98
|
+
_cachedPrompts = null;
|
|
65
99
|
}
|
|
66
100
|
|
|
67
|
-
function
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
];
|
|
72
|
-
lines.push(`- \`prompt\`: describe what to implement (be specific — file paths, function names, expected behavior)`);
|
|
73
|
-
if (ctx.worktreePath) {
|
|
74
|
-
lines.push(`- \`workingDir\`: \`"${ctx.worktreePath}"\``);
|
|
101
|
+
function renderTemplate(template: string, vars: Record<string, string>): string {
|
|
102
|
+
let result = template;
|
|
103
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
104
|
+
result = result.replaceAll(`{{${key}}}`, value);
|
|
75
105
|
}
|
|
76
|
-
|
|
77
|
-
lines.push(`Progress streams to Linear automatically. The worktree is an isolated git branch for this issue.`);
|
|
78
|
-
return lines.join("\n");
|
|
106
|
+
return result;
|
|
79
107
|
}
|
|
80
108
|
|
|
81
|
-
const TAG = (ctx: PipelineContext) => `Pipeline [${ctx.issue.identifier}]`;
|
|
82
|
-
|
|
83
109
|
// ---------------------------------------------------------------------------
|
|
84
|
-
//
|
|
110
|
+
// Task builders
|
|
85
111
|
// ---------------------------------------------------------------------------
|
|
86
112
|
|
|
87
|
-
export
|
|
88
|
-
|
|
89
|
-
|
|
113
|
+
export interface IssueContext {
|
|
114
|
+
id: string;
|
|
115
|
+
identifier: string;
|
|
116
|
+
title: string;
|
|
117
|
+
description?: string | null;
|
|
118
|
+
}
|
|
90
119
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
120
|
+
/**
|
|
121
|
+
* Build the task prompt for a worker sub-agent (sessions_spawn).
|
|
122
|
+
* Includes rework addendum if attempt > 0.
|
|
123
|
+
*/
|
|
124
|
+
export function buildWorkerTask(
|
|
125
|
+
issue: IssueContext,
|
|
126
|
+
worktreePath: string,
|
|
127
|
+
opts?: { attempt?: number; gaps?: string[]; pluginConfig?: Record<string, unknown> },
|
|
128
|
+
): { system: string; task: string } {
|
|
129
|
+
const prompts = loadPrompts(opts?.pluginConfig);
|
|
130
|
+
const vars: Record<string, string> = {
|
|
131
|
+
identifier: issue.identifier,
|
|
132
|
+
title: issue.title,
|
|
133
|
+
description: issue.description ?? "(no description)",
|
|
134
|
+
worktreePath,
|
|
135
|
+
tier: "",
|
|
136
|
+
attempt: String(opts?.attempt ?? 0),
|
|
137
|
+
gaps: opts?.gaps?.join("\n- ") ?? "",
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
let task = renderTemplate(prompts.worker.task, vars);
|
|
141
|
+
if ((opts?.attempt ?? 0) > 0 && opts?.gaps?.length) {
|
|
142
|
+
task += "\n\n" + renderTemplate(prompts.rework.addendum, vars);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
system: renderTemplate(prompts.worker.system, vars),
|
|
147
|
+
task,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Build the task prompt for an audit sub-agent (runAgent).
|
|
153
|
+
*/
|
|
154
|
+
export function buildAuditTask(
|
|
155
|
+
issue: IssueContext,
|
|
156
|
+
worktreePath: string,
|
|
157
|
+
pluginConfig?: Record<string, unknown>,
|
|
158
|
+
): { system: string; task: string } {
|
|
159
|
+
const prompts = loadPrompts(pluginConfig);
|
|
160
|
+
const vars: Record<string, string> = {
|
|
161
|
+
identifier: issue.identifier,
|
|
162
|
+
title: issue.title,
|
|
163
|
+
description: issue.description ?? "(no description)",
|
|
164
|
+
worktreePath,
|
|
165
|
+
tier: "",
|
|
166
|
+
attempt: "0",
|
|
167
|
+
gaps: "",
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
system: renderTemplate(prompts.audit.system, vars),
|
|
172
|
+
task: renderTemplate(prompts.audit.task, vars),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// Verdict parsing
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
96
179
|
|
|
97
|
-
|
|
180
|
+
export interface AuditVerdict {
|
|
181
|
+
pass: boolean;
|
|
182
|
+
criteria: string[];
|
|
183
|
+
gaps: string[];
|
|
184
|
+
testResults: string;
|
|
185
|
+
}
|
|
98
186
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
187
|
+
/**
|
|
188
|
+
* Parse the audit verdict JSON from the agent's output.
|
|
189
|
+
* Looks for the last JSON object in the output that matches the verdict shape.
|
|
190
|
+
*/
|
|
191
|
+
export function parseVerdict(output: string): AuditVerdict | null {
|
|
192
|
+
// Try to find JSON in the output (last occurrence)
|
|
193
|
+
const jsonMatches = output.match(/\{[^{}]*"pass"\s*:\s*(true|false)[^{}]*\}/g);
|
|
194
|
+
if (!jsonMatches?.length) return null;
|
|
195
|
+
|
|
196
|
+
for (const match of jsonMatches.reverse()) {
|
|
197
|
+
try {
|
|
198
|
+
const parsed = JSON.parse(match);
|
|
199
|
+
if (typeof parsed.pass === "boolean") {
|
|
200
|
+
return {
|
|
201
|
+
pass: parsed.pass,
|
|
202
|
+
criteria: Array.isArray(parsed.criteria) ? parsed.criteria : [],
|
|
203
|
+
gaps: Array.isArray(parsed.gaps) ? parsed.gaps : [],
|
|
204
|
+
testResults: typeof parsed.testResults === "string" ? parsed.testResults : "",
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
} catch { /* try next match */ }
|
|
208
|
+
}
|
|
105
209
|
|
|
106
|
-
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
107
212
|
|
|
108
|
-
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
// Hook handlers (called by agent_end hook in index.ts)
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
109
216
|
|
|
110
|
-
|
|
111
|
-
|
|
217
|
+
export interface HookContext {
|
|
218
|
+
api: OpenClawPluginApi;
|
|
219
|
+
linearApi: LinearAgentApi;
|
|
220
|
+
notify: NotifyFn;
|
|
221
|
+
pluginConfig?: Record<string, unknown>;
|
|
222
|
+
configPath?: string;
|
|
223
|
+
}
|
|
112
224
|
|
|
113
|
-
|
|
225
|
+
/**
|
|
226
|
+
* Triggered by agent_end hook when a worker sub-agent completes.
|
|
227
|
+
* Transitions dispatch to "auditing" and spawns an independent audit agent.
|
|
228
|
+
*
|
|
229
|
+
* Idempotent: uses CAS transition + event dedup.
|
|
230
|
+
*/
|
|
231
|
+
export async function triggerAudit(
|
|
232
|
+
hookCtx: HookContext,
|
|
233
|
+
dispatch: ActiveDispatch,
|
|
234
|
+
event: { messages?: unknown[]; success: boolean; output?: string },
|
|
235
|
+
sessionKey: string,
|
|
236
|
+
): Promise<void> {
|
|
237
|
+
const { api, linearApi, notify, pluginConfig, configPath } = hookCtx;
|
|
238
|
+
const TAG = `[${dispatch.issueIdentifier}]`;
|
|
239
|
+
|
|
240
|
+
// Dedup check
|
|
241
|
+
const eventKey = `worker-end:${sessionKey}`;
|
|
242
|
+
const isNew = await markEventProcessed(eventKey, configPath);
|
|
243
|
+
if (!isNew) {
|
|
244
|
+
api.logger.info(`${TAG} duplicate worker agent_end, skipping`);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
114
247
|
|
|
115
|
-
|
|
248
|
+
// CAS transition: working → auditing
|
|
249
|
+
try {
|
|
250
|
+
await transitionDispatch(
|
|
251
|
+
dispatch.issueIdentifier,
|
|
252
|
+
"working",
|
|
253
|
+
"auditing",
|
|
254
|
+
undefined,
|
|
255
|
+
configPath,
|
|
256
|
+
);
|
|
257
|
+
} catch (err) {
|
|
258
|
+
if (err instanceof TransitionError) {
|
|
259
|
+
api.logger.warn(`${TAG} CAS failed for audit trigger: ${err.message}`);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
throw err;
|
|
263
|
+
}
|
|
116
264
|
|
|
117
|
-
|
|
118
|
-
1. Analyze the issue thoroughly
|
|
119
|
-
2. Break it into concrete implementation steps
|
|
120
|
-
3. Identify files that need to change
|
|
121
|
-
4. Note any risks or dependencies
|
|
122
|
-
5. Output your plan in markdown format
|
|
265
|
+
api.logger.info(`${TAG} worker completed, triggering audit (attempt ${dispatch.attempt})`);
|
|
123
266
|
|
|
124
|
-
|
|
267
|
+
// Fetch fresh issue details for audit context
|
|
268
|
+
const issueDetails = await linearApi.getIssueDetails(dispatch.issueId).catch(() => null);
|
|
269
|
+
const issue: IssueContext = {
|
|
270
|
+
id: dispatch.issueId,
|
|
271
|
+
identifier: dispatch.issueIdentifier,
|
|
272
|
+
title: issueDetails?.title ?? dispatch.issueIdentifier,
|
|
273
|
+
description: issueDetails?.description,
|
|
274
|
+
};
|
|
125
275
|
|
|
126
|
-
|
|
276
|
+
// Build audit prompt from YAML templates
|
|
277
|
+
const auditPrompt = buildAuditTask(issue, dispatch.worktreePath, pluginConfig);
|
|
127
278
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
279
|
+
// Set Linear label
|
|
280
|
+
await linearApi.emitActivity(dispatch.agentSessionId ?? "", {
|
|
281
|
+
type: "thought",
|
|
282
|
+
body: `Audit triggered for ${dispatch.issueIdentifier} (attempt ${dispatch.attempt})`,
|
|
283
|
+
}).catch(() => {});
|
|
284
|
+
|
|
285
|
+
await notify("auditing", {
|
|
286
|
+
identifier: dispatch.issueIdentifier,
|
|
287
|
+
title: issue.title,
|
|
288
|
+
status: "auditing",
|
|
289
|
+
attempt: dispatch.attempt,
|
|
132
290
|
});
|
|
133
291
|
|
|
134
|
-
|
|
135
|
-
|
|
292
|
+
// Spawn audit agent via runAgent (deterministic, plugin-level — NOT sessions_spawn)
|
|
293
|
+
const auditSessionId = `linear-audit-${dispatch.issueIdentifier}-${dispatch.attempt}`;
|
|
294
|
+
|
|
295
|
+
// Register session mapping so the agent_end hook can find this dispatch
|
|
296
|
+
await registerSessionMapping(auditSessionId, {
|
|
297
|
+
dispatchId: dispatch.issueIdentifier,
|
|
298
|
+
phase: "audit",
|
|
299
|
+
attempt: dispatch.attempt,
|
|
300
|
+
}, configPath);
|
|
301
|
+
|
|
302
|
+
// Update dispatch with audit session key
|
|
303
|
+
const state = await readDispatchState(configPath);
|
|
304
|
+
const activeDispatch = getActiveDispatch(state, dispatch.issueIdentifier);
|
|
305
|
+
if (activeDispatch) {
|
|
306
|
+
activeDispatch.auditSessionKey = auditSessionId;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
api.logger.info(`${TAG} spawning audit agent session=${auditSessionId}`);
|
|
136
310
|
|
|
137
311
|
const result = await runAgent({
|
|
138
|
-
api
|
|
139
|
-
agentId:
|
|
140
|
-
sessionId,
|
|
141
|
-
message
|
|
312
|
+
api,
|
|
313
|
+
agentId: (pluginConfig?.defaultAgentId as string) ?? "default",
|
|
314
|
+
sessionId: auditSessionId,
|
|
315
|
+
message: `${auditPrompt.system}\n\n${auditPrompt.task}`,
|
|
142
316
|
timeoutMs: 5 * 60_000,
|
|
143
317
|
});
|
|
144
318
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
319
|
+
// runAgent returns inline (embedded runner) — process verdict directly.
|
|
320
|
+
// The agent_end hook in index.ts serves as safety net for sessions_spawn.
|
|
321
|
+
api.logger.info(`${TAG} audit completed inline (${result.output.length} chars, success=${result.success})`);
|
|
322
|
+
|
|
323
|
+
await processVerdict(hookCtx, dispatch, {
|
|
324
|
+
success: result.success,
|
|
325
|
+
output: result.output,
|
|
326
|
+
}, auditSessionId);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Triggered by agent_end hook when an audit sub-agent completes.
|
|
331
|
+
* Parses the verdict and transitions dispatch accordingly.
|
|
332
|
+
*
|
|
333
|
+
* Idempotent: uses CAS transition + event dedup.
|
|
334
|
+
*/
|
|
335
|
+
export async function processVerdict(
|
|
336
|
+
hookCtx: HookContext,
|
|
337
|
+
dispatch: ActiveDispatch,
|
|
338
|
+
event: { messages?: unknown[]; success: boolean; output?: string },
|
|
339
|
+
sessionKey: string,
|
|
340
|
+
): Promise<void> {
|
|
341
|
+
const { api, linearApi, notify, pluginConfig, configPath } = hookCtx;
|
|
342
|
+
const TAG = `[${dispatch.issueIdentifier}]`;
|
|
343
|
+
const maxAttempts = (pluginConfig?.maxReworkAttempts as number) ?? 2;
|
|
344
|
+
|
|
345
|
+
// Dedup check
|
|
346
|
+
const eventKey = `audit-end:${sessionKey}`;
|
|
347
|
+
const isNew = await markEventProcessed(eventKey, configPath);
|
|
348
|
+
if (!isNew) {
|
|
349
|
+
api.logger.info(`${TAG} duplicate audit agent_end, skipping`);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Extract output from event messages or direct output
|
|
354
|
+
let auditOutput = event.output ?? "";
|
|
355
|
+
if (!auditOutput && Array.isArray(event.messages)) {
|
|
356
|
+
// Get the last assistant message
|
|
357
|
+
for (const msg of [...(event.messages as any[])].reverse()) {
|
|
358
|
+
if (msg?.role === "assistant" && typeof msg?.content === "string") {
|
|
359
|
+
auditOutput = msg.content;
|
|
360
|
+
break;
|
|
361
|
+
}
|
|
362
|
+
// Handle array content (tool use + text blocks)
|
|
363
|
+
if (msg?.role === "assistant" && Array.isArray(msg?.content)) {
|
|
364
|
+
for (const block of msg.content) {
|
|
365
|
+
if (block?.type === "text" && typeof block?.text === "string") {
|
|
366
|
+
auditOutput = block.text;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
if (auditOutput) break;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
152
372
|
}
|
|
153
373
|
|
|
154
|
-
|
|
155
|
-
|
|
374
|
+
// Parse verdict
|
|
375
|
+
const verdict = parseVerdict(auditOutput);
|
|
376
|
+
if (!verdict) {
|
|
377
|
+
api.logger.warn(`${TAG} could not parse audit verdict from output (${auditOutput.length} chars)`);
|
|
378
|
+
// Treat unparseable verdict as failure
|
|
379
|
+
await handleAuditFail(hookCtx, dispatch, {
|
|
380
|
+
pass: false,
|
|
381
|
+
criteria: [],
|
|
382
|
+
gaps: ["Audit produced no parseable verdict"],
|
|
383
|
+
testResults: "",
|
|
384
|
+
});
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
156
387
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
`## Implementation Plan\n\n${plan}\n\n---\n*Proceeding to implementation...*`,
|
|
388
|
+
api.logger.info(
|
|
389
|
+
`${TAG} audit verdict: ${verdict.pass ? "PASS" : "FAIL"} ` +
|
|
390
|
+
`(criteria: ${verdict.criteria.length}, gaps: ${verdict.gaps.length})`,
|
|
161
391
|
);
|
|
162
392
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
return plan;
|
|
393
|
+
if (verdict.pass) {
|
|
394
|
+
await handleAuditPass(hookCtx, dispatch, verdict);
|
|
395
|
+
} else {
|
|
396
|
+
await handleAuditFail(hookCtx, dispatch, verdict);
|
|
397
|
+
}
|
|
170
398
|
}
|
|
171
399
|
|
|
172
400
|
// ---------------------------------------------------------------------------
|
|
173
|
-
//
|
|
401
|
+
// Verdict handlers
|
|
174
402
|
// ---------------------------------------------------------------------------
|
|
175
|
-
//
|
|
176
|
-
// Deterministic: pipeline CODE calls the coding CLI directly.
|
|
177
|
-
// The agent model only evaluates results between runs.
|
|
178
|
-
|
|
179
|
-
const BACKEND_RUNNERS: Record<
|
|
180
|
-
CodingBackend,
|
|
181
|
-
(api: OpenClawPluginApi, params: any, pluginConfig?: Record<string, unknown>) => Promise<CliResult>
|
|
182
|
-
> = {
|
|
183
|
-
codex: runCodex,
|
|
184
|
-
claude: runClaude,
|
|
185
|
-
gemini: runGemini,
|
|
186
|
-
};
|
|
187
403
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
const
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
// Resolve coding backend from config (coding-tools.json)
|
|
197
|
-
const codingConfig = loadCodingConfig();
|
|
198
|
-
const backend = resolveCodingBackend(codingConfig);
|
|
199
|
-
const runner = BACKEND_RUNNERS[backend];
|
|
200
|
-
const backendName = backend.charAt(0).toUpperCase() + backend.slice(1);
|
|
201
|
-
|
|
202
|
-
ctx.api.logger.info(
|
|
203
|
-
`${TAG(ctx)} stage 2/3: implementor starting ` +
|
|
204
|
-
`(coding_cli=${backendName}, tier=${ctx.tier ?? "unknown"}, ` +
|
|
205
|
-
`worktree=${ctx.worktreePath ?? "default"}, ` +
|
|
206
|
-
`eval_agent=${ctx.agentId}, eval_model=${agentModel})`,
|
|
207
|
-
);
|
|
404
|
+
async function handleAuditPass(
|
|
405
|
+
hookCtx: HookContext,
|
|
406
|
+
dispatch: ActiveDispatch,
|
|
407
|
+
verdict: AuditVerdict,
|
|
408
|
+
): Promise<void> {
|
|
409
|
+
const { api, linearApi, notify, configPath } = hookCtx;
|
|
410
|
+
const TAG = `[${dispatch.issueIdentifier}]`;
|
|
208
411
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
})
|
|
412
|
+
// CAS transition: auditing → done
|
|
413
|
+
try {
|
|
414
|
+
await transitionDispatch(dispatch.issueIdentifier, "auditing", "done", undefined, configPath);
|
|
415
|
+
} catch (err) {
|
|
416
|
+
if (err instanceof TransitionError) {
|
|
417
|
+
api.logger.warn(`${TAG} CAS failed for audit pass: ${err.message}`);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
throw err;
|
|
421
|
+
}
|
|
213
422
|
|
|
214
|
-
//
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
423
|
+
// Move to completed
|
|
424
|
+
await completeDispatch(dispatch.issueIdentifier, {
|
|
425
|
+
tier: dispatch.tier,
|
|
426
|
+
status: "done",
|
|
427
|
+
completedAt: new Date().toISOString(),
|
|
428
|
+
project: dispatch.project,
|
|
429
|
+
}, configPath);
|
|
430
|
+
|
|
431
|
+
// Post approval comment
|
|
432
|
+
const criteriaList = verdict.criteria.map((c) => `- ${c}`).join("\n");
|
|
433
|
+
await linearApi.createComment(
|
|
434
|
+
dispatch.issueId,
|
|
435
|
+
`## Audit Passed\n\n**Criteria verified:**\n${criteriaList}\n\n**Tests:** ${verdict.testResults || "N/A"}\n\n---\n*Attempt ${dispatch.attempt + 1} — audit passed.*`,
|
|
436
|
+
).catch((err) => api.logger.error(`${TAG} failed to post audit pass comment: ${err}`));
|
|
437
|
+
|
|
438
|
+
api.logger.info(`${TAG} audit PASSED — dispatch completed (attempt ${dispatch.attempt})`);
|
|
439
|
+
|
|
440
|
+
await notify("audit_pass", {
|
|
441
|
+
identifier: dispatch.issueIdentifier,
|
|
442
|
+
title: dispatch.issueIdentifier,
|
|
443
|
+
status: "done",
|
|
444
|
+
attempt: dispatch.attempt,
|
|
445
|
+
verdict: { pass: true, gaps: [] },
|
|
232
446
|
});
|
|
233
447
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
// is for tracking/display only. Each coding CLI uses its own configured model.
|
|
237
|
-
ctx.api.logger.info(`${TAG(ctx)} implementor: invoking ${backendName} CLI (no model override — CLI uses its own config)`);
|
|
238
|
-
const cliStart = Date.now();
|
|
239
|
-
|
|
240
|
-
const codeResult = await runner(ctx.api, {
|
|
241
|
-
prompt: codePrompt,
|
|
242
|
-
workingDir: ctx.worktreePath ?? undefined,
|
|
243
|
-
timeoutMs: 10 * 60_000,
|
|
244
|
-
}, pluginConfig);
|
|
245
|
-
|
|
246
|
-
const cliElapsed = elapsed(cliStart);
|
|
247
|
-
|
|
248
|
-
if (!codeResult.success) {
|
|
249
|
-
ctx.api.logger.warn(
|
|
250
|
-
`${TAG(ctx)} implementor: ${backendName} CLI failed after ${cliElapsed} — ` +
|
|
251
|
-
`error: ${codeResult.error ?? "unknown"}, output: ${codeResult.output.slice(0, 300)}`,
|
|
252
|
-
);
|
|
253
|
-
await emit(ctx, {
|
|
254
|
-
type: "error",
|
|
255
|
-
body: `[2/3 Implement] ${backendName} failed after ${cliElapsed}: ${(codeResult.error ?? codeResult.output).slice(0, 400)}`,
|
|
256
|
-
});
|
|
448
|
+
clearActiveSession(dispatch.issueId);
|
|
449
|
+
}
|
|
257
450
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
451
|
+
async function handleAuditFail(
|
|
452
|
+
hookCtx: HookContext,
|
|
453
|
+
dispatch: ActiveDispatch,
|
|
454
|
+
verdict: AuditVerdict,
|
|
455
|
+
): Promise<void> {
|
|
456
|
+
const { api, linearApi, notify, pluginConfig, configPath } = hookCtx;
|
|
457
|
+
const TAG = `[${dispatch.issueIdentifier}]`;
|
|
458
|
+
const maxAttempts = (pluginConfig?.maxReworkAttempts as number) ?? 2;
|
|
459
|
+
const nextAttempt = dispatch.attempt + 1;
|
|
460
|
+
|
|
461
|
+
if (nextAttempt > maxAttempts) {
|
|
462
|
+
// Escalate — too many failures
|
|
463
|
+
try {
|
|
464
|
+
await transitionDispatch(
|
|
465
|
+
dispatch.issueIdentifier,
|
|
466
|
+
"auditing",
|
|
467
|
+
"stuck",
|
|
468
|
+
{ stuckReason: `audit_failed_${nextAttempt}x` },
|
|
469
|
+
configPath,
|
|
470
|
+
);
|
|
471
|
+
} catch (err) {
|
|
472
|
+
if (err instanceof TransitionError) {
|
|
473
|
+
api.logger.warn(`${TAG} CAS failed for stuck transition: ${err.message}`);
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
throw err;
|
|
477
|
+
}
|
|
265
478
|
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
479
|
+
const gapsList = verdict.gaps.map((g) => `- ${g}`).join("\n");
|
|
480
|
+
await linearApi.createComment(
|
|
481
|
+
dispatch.issueId,
|
|
482
|
+
`## Audit Failed — Escalating\n\n**Attempt ${nextAttempt} of ${maxAttempts + 1}**\n\n**Gaps:**\n${gapsList}\n\n**Tests:** ${verdict.testResults || "N/A"}\n\n---\n*Max rework attempts reached. Needs human review.*`,
|
|
483
|
+
).catch((err) => api.logger.error(`${TAG} failed to post escalation comment: ${err}`));
|
|
484
|
+
|
|
485
|
+
api.logger.warn(`${TAG} audit FAILED ${nextAttempt}x — escalating to human`);
|
|
486
|
+
|
|
487
|
+
await notify("escalation", {
|
|
488
|
+
identifier: dispatch.issueIdentifier,
|
|
489
|
+
title: dispatch.issueIdentifier,
|
|
490
|
+
status: "stuck",
|
|
491
|
+
attempt: nextAttempt,
|
|
492
|
+
reason: `audit failed ${nextAttempt}x`,
|
|
493
|
+
verdict: { pass: false, gaps: verdict.gaps },
|
|
272
494
|
});
|
|
273
495
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
: `Implementation failed and evaluation also failed: ${codeResult.output.slice(0, 500)}`;
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
277
498
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
499
|
+
// Rework — transition back to working with incremented attempt
|
|
500
|
+
try {
|
|
501
|
+
await transitionDispatch(
|
|
502
|
+
dispatch.issueIdentifier,
|
|
503
|
+
"auditing",
|
|
504
|
+
"working",
|
|
505
|
+
{ attempt: nextAttempt },
|
|
506
|
+
configPath,
|
|
281
507
|
);
|
|
282
|
-
|
|
283
|
-
|
|
508
|
+
} catch (err) {
|
|
509
|
+
if (err instanceof TransitionError) {
|
|
510
|
+
api.logger.warn(`${TAG} CAS failed for rework transition: ${err.message}`);
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
throw err;
|
|
284
514
|
}
|
|
285
515
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
``,
|
|
292
|
-
`## Original Plan`,
|
|
293
|
-
plan,
|
|
294
|
-
``,
|
|
295
|
-
`## ${backendName} Output`,
|
|
296
|
-
codeResult.output.slice(0, 5000),
|
|
297
|
-
``,
|
|
298
|
-
`## Worktree`,
|
|
299
|
-
`Path: ${ctx.worktreePath ?? "default"}`,
|
|
300
|
-
`Branch: ${ctx.codexBranch ?? "unknown"}`,
|
|
301
|
-
``,
|
|
302
|
-
`Summarize what was implemented, any issues found, and whether the plan was fully executed. Be concise.`,
|
|
303
|
-
].join("\n");
|
|
304
|
-
|
|
305
|
-
ctx.api.logger.info(`${TAG(ctx)} implementor: spawning ${ctx.agentId} (${agentModel}) to evaluate results`);
|
|
306
|
-
await emit(ctx, {
|
|
307
|
-
type: "action",
|
|
308
|
-
action: "Evaluating results",
|
|
309
|
-
parameter: `${ctx.agentId} (${agentModel}) reviewing ${backendName} output`,
|
|
310
|
-
});
|
|
516
|
+
const gapsList = verdict.gaps.map((g) => `- ${g}`).join("\n");
|
|
517
|
+
await linearApi.createComment(
|
|
518
|
+
dispatch.issueId,
|
|
519
|
+
`## Audit Failed — Rework\n\n**Attempt ${nextAttempt} of ${maxAttempts + 1}**\n\n**Gaps:**\n${gapsList}\n\n**Tests:** ${verdict.testResults || "N/A"}\n\n---\n*Reworking: addressing gaps above.*`,
|
|
520
|
+
).catch((err) => api.logger.error(`${TAG} failed to post rework comment: ${err}`));
|
|
311
521
|
|
|
312
|
-
|
|
313
|
-
const evalResult = await runAgent({
|
|
314
|
-
api: ctx.api,
|
|
315
|
-
agentId: ctx.agentId,
|
|
316
|
-
sessionId: `linear-impl-eval-${ctx.agentSessionId}`,
|
|
317
|
-
message: evalMessage,
|
|
318
|
-
timeoutMs: 3 * 60_000,
|
|
319
|
-
});
|
|
320
|
-
|
|
321
|
-
const summary = evalResult.success
|
|
322
|
-
? evalResult.output
|
|
323
|
-
: `Implementation completed but evaluation failed. ${backendName} output:\n${codeResult.output.slice(0, 2000)}`;
|
|
324
|
-
|
|
325
|
-
ctx.api.logger.info(
|
|
326
|
-
`${TAG(ctx)} implementor: evaluation ${evalResult.success ? "succeeded" : "failed"} in ${elapsed(evalStart)}, ` +
|
|
327
|
-
`total stage time: ${elapsed(t0)}`,
|
|
328
|
-
);
|
|
522
|
+
api.logger.info(`${TAG} audit FAILED — rework attempt ${nextAttempt}/${maxAttempts + 1}`);
|
|
329
523
|
|
|
330
|
-
await
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
524
|
+
await notify("audit_fail", {
|
|
525
|
+
identifier: dispatch.issueIdentifier,
|
|
526
|
+
title: dispatch.issueIdentifier,
|
|
527
|
+
status: "working",
|
|
528
|
+
attempt: nextAttempt,
|
|
529
|
+
verdict: { pass: false, gaps: verdict.gaps },
|
|
334
530
|
});
|
|
335
531
|
|
|
336
|
-
|
|
532
|
+
// The webhook handler or dispatch service should re-spawn a worker
|
|
533
|
+
// with the rework context. Log emitted for observability.
|
|
534
|
+
api.logger.info(
|
|
535
|
+
`${TAG} dispatch is back in "working" state (attempt ${nextAttempt}). ` +
|
|
536
|
+
`Orchestrator should re-spawn worker with gaps: ${verdict.gaps.join(", ")}`,
|
|
537
|
+
);
|
|
337
538
|
}
|
|
338
539
|
|
|
339
540
|
// ---------------------------------------------------------------------------
|
|
340
|
-
//
|
|
541
|
+
// Worker phase (called by handleDispatch in webhook.ts)
|
|
341
542
|
// ---------------------------------------------------------------------------
|
|
342
543
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
544
|
+
/**
|
|
545
|
+
* Spawn the worker agent for a dispatch.
|
|
546
|
+
* Transitions dispatched→working, builds task, runs agent, then triggers audit.
|
|
547
|
+
*
|
|
548
|
+
* This is the main entry point for the v2 pipeline — replaces runFullPipeline.
|
|
549
|
+
*/
|
|
550
|
+
export async function spawnWorker(
|
|
551
|
+
hookCtx: HookContext,
|
|
552
|
+
dispatch: ActiveDispatch,
|
|
553
|
+
opts?: { gaps?: string[] },
|
|
347
554
|
): Promise<void> {
|
|
348
|
-
const
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
555
|
+
const { api, linearApi, pluginConfig, configPath } = hookCtx;
|
|
556
|
+
const TAG = `[${dispatch.issueIdentifier}]`;
|
|
557
|
+
|
|
558
|
+
// Transition dispatched → working (first run) — skip if already working (rework)
|
|
559
|
+
if (dispatch.status === "dispatched") {
|
|
560
|
+
try {
|
|
561
|
+
await transitionDispatch(
|
|
562
|
+
dispatch.issueIdentifier,
|
|
563
|
+
"dispatched",
|
|
564
|
+
"working",
|
|
565
|
+
undefined,
|
|
566
|
+
configPath,
|
|
567
|
+
);
|
|
568
|
+
} catch (err) {
|
|
569
|
+
if (err instanceof TransitionError) {
|
|
570
|
+
api.logger.warn(`${TAG} CAS failed for worker spawn: ${err.message}`);
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
throw err;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
366
576
|
|
|
367
|
-
|
|
368
|
-
|
|
577
|
+
// Fetch fresh issue details
|
|
578
|
+
const issueDetails = await linearApi.getIssueDetails(dispatch.issueId).catch(() => null);
|
|
579
|
+
const issue: IssueContext = {
|
|
580
|
+
id: dispatch.issueId,
|
|
581
|
+
identifier: dispatch.issueIdentifier,
|
|
582
|
+
title: issueDetails?.title ?? dispatch.issueIdentifier,
|
|
583
|
+
description: issueDetails?.description,
|
|
584
|
+
};
|
|
369
585
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
3. Note any concerns or improvements needed
|
|
377
|
-
4. Provide a pass/fail verdict with reasoning
|
|
378
|
-
5. Output a concise audit summary in markdown
|
|
379
|
-
${toolContext(ctx)}
|
|
586
|
+
// Build worker prompt from YAML templates
|
|
587
|
+
const workerPrompt = buildWorkerTask(issue, dispatch.worktreePath, {
|
|
588
|
+
attempt: dispatch.attempt,
|
|
589
|
+
gaps: opts?.gaps,
|
|
590
|
+
pluginConfig,
|
|
591
|
+
});
|
|
380
592
|
|
|
381
|
-
|
|
593
|
+
const workerSessionId = `linear-worker-${dispatch.issueIdentifier}-${dispatch.attempt}`;
|
|
382
594
|
|
|
383
|
-
|
|
384
|
-
|
|
595
|
+
// Register session mapping for agent_end hook lookup
|
|
596
|
+
await registerSessionMapping(workerSessionId, {
|
|
597
|
+
dispatchId: dispatch.issueIdentifier,
|
|
598
|
+
phase: "worker",
|
|
599
|
+
attempt: dispatch.attempt,
|
|
600
|
+
}, configPath);
|
|
385
601
|
|
|
386
|
-
await
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
602
|
+
await hookCtx.notify("working", {
|
|
603
|
+
identifier: dispatch.issueIdentifier,
|
|
604
|
+
title: issue.title,
|
|
605
|
+
status: "working",
|
|
606
|
+
attempt: dispatch.attempt,
|
|
390
607
|
});
|
|
391
608
|
|
|
609
|
+
api.logger.info(`${TAG} spawning worker agent session=${workerSessionId} (attempt ${dispatch.attempt})`);
|
|
610
|
+
|
|
392
611
|
const result = await runAgent({
|
|
393
|
-
api
|
|
394
|
-
agentId:
|
|
395
|
-
sessionId,
|
|
396
|
-
message
|
|
397
|
-
timeoutMs:
|
|
612
|
+
api,
|
|
613
|
+
agentId: (pluginConfig?.defaultAgentId as string) ?? "default",
|
|
614
|
+
sessionId: workerSessionId,
|
|
615
|
+
message: `${workerPrompt.system}\n\n${workerPrompt.task}`,
|
|
616
|
+
timeoutMs: (pluginConfig?.codexTimeoutMs as number) ?? 10 * 60_000,
|
|
398
617
|
});
|
|
399
618
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
619
|
+
// runAgent returns inline — trigger audit directly.
|
|
620
|
+
// Re-read dispatch state since it may have changed during worker run.
|
|
621
|
+
const freshState = await readDispatchState(configPath);
|
|
622
|
+
const freshDispatch = getActiveDispatch(freshState, dispatch.issueIdentifier);
|
|
623
|
+
if (!freshDispatch) {
|
|
624
|
+
api.logger.warn(`${TAG} dispatch disappeared during worker run — skipping audit`);
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
403
627
|
|
|
404
|
-
|
|
405
|
-
`${TAG(ctx)} auditor: ${result.success ? "completed" : "failed"} in ${elapsed(t0)} (${auditSummary.length} chars)`,
|
|
406
|
-
);
|
|
628
|
+
api.logger.info(`${TAG} worker completed (success=${result.success}, ${result.output.length} chars) — triggering audit`);
|
|
407
629
|
|
|
408
|
-
await
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
);
|
|
412
|
-
|
|
413
|
-
await emit(ctx, {
|
|
414
|
-
type: "response",
|
|
415
|
-
body: `[3/3 Audit] ${result.success ? "Complete" : "Failed"} (${elapsed(t0)}). ` +
|
|
416
|
-
`All stages done for ${ctx.issue.identifier}. Plan, implementation, and audit posted as comments.`,
|
|
417
|
-
});
|
|
630
|
+
await triggerAudit(hookCtx, freshDispatch, {
|
|
631
|
+
success: result.success,
|
|
632
|
+
output: result.output,
|
|
633
|
+
}, workerSessionId);
|
|
418
634
|
}
|
|
419
635
|
|
|
420
636
|
// ---------------------------------------------------------------------------
|
|
421
|
-
//
|
|
637
|
+
// Exports for backward compatibility (v1 pipeline)
|
|
422
638
|
// ---------------------------------------------------------------------------
|
|
423
|
-
//
|
|
424
|
-
// Runs all three stages sequentially: plan → implement → audit.
|
|
425
|
-
// Assignment is the trigger AND the approval — no pause between stages.
|
|
426
|
-
// Each stage's result feeds into the next. If any stage fails, the
|
|
427
|
-
// pipeline stops and reports the error.
|
|
428
|
-
|
|
429
|
-
export async function runFullPipeline(ctx: PipelineContext): Promise<void> {
|
|
430
|
-
const t0 = Date.now();
|
|
431
|
-
const agentModel = resolveAgentModel(ctx.api, ctx.agentId);
|
|
432
|
-
const codingConfig = loadCodingConfig();
|
|
433
|
-
const codingBackend = resolveCodingBackend(codingConfig);
|
|
434
|
-
|
|
435
|
-
ctx.api.logger.info(
|
|
436
|
-
`${TAG(ctx)} === PIPELINE START === ` +
|
|
437
|
-
`agent=${ctx.agentId}, agent_model=${agentModel}, ` +
|
|
438
|
-
`coding_cli=${codingBackend}, tier=${ctx.tier ?? "unknown"}, ` +
|
|
439
|
-
`worktree=${ctx.worktreePath ?? "none"}, ` +
|
|
440
|
-
`branch=${ctx.codexBranch ?? "none"}, ` +
|
|
441
|
-
`session=${ctx.agentSessionId}`,
|
|
442
|
-
);
|
|
443
|
-
|
|
444
|
-
// Register active session so tools (code_run) can resolve it
|
|
445
|
-
setActiveSession({
|
|
446
|
-
agentSessionId: ctx.agentSessionId,
|
|
447
|
-
issueIdentifier: ctx.issue.identifier,
|
|
448
|
-
issueId: ctx.issue.id,
|
|
449
|
-
agentId: ctx.agentId,
|
|
450
|
-
startedAt: Date.now(),
|
|
451
|
-
});
|
|
452
639
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
body: `Pipeline started for ${ctx.issue.identifier} — ` +
|
|
456
|
-
`agent: ${ctx.agentId} (${agentModel}), ` +
|
|
457
|
-
`coding: ${codingBackend}, ` +
|
|
458
|
-
`tier: ${ctx.tier ?? "unknown"}`,
|
|
459
|
-
});
|
|
460
|
-
|
|
461
|
-
try {
|
|
462
|
-
// Stage 1: Plan
|
|
463
|
-
const plan = await runPlannerStage(ctx);
|
|
464
|
-
if (!plan) {
|
|
465
|
-
ctx.api.logger.error(`${TAG(ctx)} planner produced no plan — aborting after ${elapsed(t0)}`);
|
|
466
|
-
await emit(ctx, {
|
|
467
|
-
type: "error",
|
|
468
|
-
body: `Pipeline aborted — planning stage failed after ${elapsed(t0)}. No plan produced.`,
|
|
469
|
-
});
|
|
470
|
-
return;
|
|
471
|
-
}
|
|
640
|
+
// Re-export v1 types and functions that other files may still use
|
|
641
|
+
export type { Tier } from "./dispatch-state.js";
|
|
472
642
|
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
// Stage 3: Audit
|
|
485
|
-
await runAuditorStage(ctx, plan, implResult);
|
|
486
|
-
|
|
487
|
-
ctx.api.logger.info(
|
|
488
|
-
`${TAG(ctx)} === PIPELINE COMPLETE === total time: ${elapsed(t0)}`,
|
|
489
|
-
);
|
|
490
|
-
} catch (err) {
|
|
491
|
-
ctx.api.logger.error(`${TAG(ctx)} === PIPELINE ERROR === after ${elapsed(t0)}: ${err}`);
|
|
492
|
-
await emit(ctx, {
|
|
493
|
-
type: "error",
|
|
494
|
-
body: `Pipeline crashed after ${elapsed(t0)}: ${String(err).slice(0, 400)}`,
|
|
495
|
-
});
|
|
496
|
-
} finally {
|
|
497
|
-
clearActiveSession(ctx.issue.id);
|
|
498
|
-
}
|
|
643
|
+
export interface PipelineContext {
|
|
644
|
+
api: OpenClawPluginApi;
|
|
645
|
+
linearApi: LinearAgentApi;
|
|
646
|
+
agentSessionId: string;
|
|
647
|
+
agentId: string;
|
|
648
|
+
issue: IssueContext;
|
|
649
|
+
promptContext?: unknown;
|
|
650
|
+
worktreePath?: string | null;
|
|
651
|
+
codexBranch?: string | null;
|
|
652
|
+
tier?: Tier;
|
|
653
|
+
model?: string;
|
|
499
654
|
}
|