@calltelemetry/openclaw-linear 0.4.0 → 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 +108 -1
- package/openclaw.plugin.json +6 -1
- package/package.json +5 -1
- package/prompts.yaml +61 -0
- package/src/active-session.ts +40 -0
- package/src/cli.ts +103 -0
- package/src/code-tool.ts +2 -2
- package/src/codex-worktree.ts +162 -36
- package/src/dispatch-service.ts +161 -0
- package/src/dispatch-state.ts +497 -0
- package/src/notify.ts +91 -0
- package/src/pipeline.ts +582 -198
- package/src/tier-assess.ts +157 -0
- package/src/webhook.ts +232 -197
package/src/pipeline.ts
CHANGED
|
@@ -1,270 +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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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";
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Prompt loading
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
interface PromptTemplates {
|
|
43
|
+
worker: { system: string; task: string };
|
|
44
|
+
audit: { system: string; task: string };
|
|
45
|
+
rework: { addendum: string };
|
|
22
46
|
}
|
|
23
47
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
+
|
|
67
|
+
try {
|
|
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
|
+
};
|
|
89
|
+
} catch {
|
|
90
|
+
_cachedPrompts = DEFAULT_PROMPTS;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return _cachedPrompts;
|
|
28
94
|
}
|
|
29
95
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
`When calling \`code_run\`, you MUST pass these parameters:`,
|
|
34
|
-
`- \`agentSessionId\`: \`"${ctx.agentSessionId}"\``,
|
|
35
|
-
`- \`issueIdentifier\`: \`"${ctx.issue.identifier}"\``,
|
|
36
|
-
`This enables real-time progress streaming to Linear and isolated worktree creation.`,
|
|
37
|
-
].join("\n");
|
|
96
|
+
/** Clear prompt cache (for testing or after config change) */
|
|
97
|
+
export function clearPromptCache(): void {
|
|
98
|
+
_cachedPrompts = null;
|
|
38
99
|
}
|
|
39
100
|
|
|
40
|
-
|
|
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);
|
|
105
|
+
}
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
41
108
|
|
|
42
|
-
|
|
43
|
-
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Task builders
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
44
112
|
|
|
45
|
-
|
|
113
|
+
export interface IssueContext {
|
|
114
|
+
id: string;
|
|
115
|
+
identifier: string;
|
|
116
|
+
title: string;
|
|
117
|
+
description?: string | null;
|
|
118
|
+
}
|
|
46
119
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
+
};
|
|
53
139
|
|
|
54
|
-
|
|
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
|
+
}
|
|
55
144
|
|
|
56
|
-
|
|
145
|
+
return {
|
|
146
|
+
system: renderTemplate(prompts.worker.system, vars),
|
|
147
|
+
task,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
57
150
|
|
|
58
|
-
|
|
59
|
-
|
|
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
|
+
};
|
|
60
169
|
|
|
61
|
-
|
|
170
|
+
return {
|
|
171
|
+
system: renderTemplate(prompts.audit.system, vars),
|
|
172
|
+
task: renderTemplate(prompts.audit.task, vars),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
62
175
|
|
|
63
|
-
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// Verdict parsing
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
64
179
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
180
|
+
export interface AuditVerdict {
|
|
181
|
+
pass: boolean;
|
|
182
|
+
criteria: string[];
|
|
183
|
+
gaps: string[];
|
|
184
|
+
testResults: string;
|
|
185
|
+
}
|
|
71
186
|
|
|
72
|
-
|
|
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
|
+
}
|
|
73
209
|
|
|
74
|
-
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
75
212
|
|
|
76
|
-
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
// Hook handlers (called by agent_end hook in index.ts)
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
77
216
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
217
|
+
export interface HookContext {
|
|
218
|
+
api: OpenClawPluginApi;
|
|
219
|
+
linearApi: LinearAgentApi;
|
|
220
|
+
notify: NotifyFn;
|
|
221
|
+
pluginConfig?: Record<string, unknown>;
|
|
222
|
+
configPath?: string;
|
|
223
|
+
}
|
|
84
224
|
|
|
85
|
-
|
|
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
|
+
}
|
|
86
247
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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;
|
|
90
263
|
}
|
|
91
264
|
|
|
92
|
-
|
|
265
|
+
api.logger.info(`${TAG} worker completed, triggering audit (attempt ${dispatch.attempt})`);
|
|
93
266
|
|
|
94
|
-
//
|
|
95
|
-
await
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
+
};
|
|
275
|
+
|
|
276
|
+
// Build audit prompt from YAML templates
|
|
277
|
+
const auditPrompt = buildAuditTask(issue, dispatch.worktreePath, pluginConfig);
|
|
278
|
+
|
|
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(() => {});
|
|
99
284
|
|
|
100
|
-
await
|
|
101
|
-
|
|
102
|
-
|
|
285
|
+
await notify("auditing", {
|
|
286
|
+
identifier: dispatch.issueIdentifier,
|
|
287
|
+
title: issue.title,
|
|
288
|
+
status: "auditing",
|
|
289
|
+
attempt: dispatch.attempt,
|
|
103
290
|
});
|
|
104
291
|
|
|
105
|
-
|
|
106
|
-
}
|
|
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
|
+
}
|
|
107
308
|
|
|
108
|
-
|
|
309
|
+
api.logger.info(`${TAG} spawning audit agent session=${auditSessionId}`);
|
|
109
310
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
311
|
+
const result = await runAgent({
|
|
312
|
+
api,
|
|
313
|
+
agentId: (pluginConfig?.defaultAgentId as string) ?? "default",
|
|
314
|
+
sessionId: auditSessionId,
|
|
315
|
+
message: `${auditPrompt.system}\n\n${auditPrompt.task}`,
|
|
316
|
+
timeoutMs: 5 * 60_000,
|
|
317
|
+
});
|
|
115
318
|
|
|
116
|
-
|
|
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})`);
|
|
117
322
|
|
|
118
|
-
|
|
323
|
+
await processVerdict(hookCtx, dispatch, {
|
|
324
|
+
success: result.success,
|
|
325
|
+
output: result.output,
|
|
326
|
+
}, auditSessionId);
|
|
327
|
+
}
|
|
119
328
|
|
|
120
|
-
|
|
121
|
-
|
|
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
|
+
}
|
|
122
352
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
+
}
|
|
372
|
+
}
|
|
131
373
|
|
|
132
|
-
|
|
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
|
+
}
|
|
133
387
|
|
|
134
|
-
|
|
388
|
+
api.logger.info(
|
|
389
|
+
`${TAG} audit verdict: ${verdict.pass ? "PASS" : "FAIL"} ` +
|
|
390
|
+
`(criteria: ${verdict.criteria.length}, gaps: ${verdict.gaps.length})`,
|
|
391
|
+
);
|
|
135
392
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
393
|
+
if (verdict.pass) {
|
|
394
|
+
await handleAuditPass(hookCtx, dispatch, verdict);
|
|
395
|
+
} else {
|
|
396
|
+
await handleAuditFail(hookCtx, dispatch, verdict);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
142
399
|
|
|
143
|
-
|
|
400
|
+
// ---------------------------------------------------------------------------
|
|
401
|
+
// Verdict handlers
|
|
402
|
+
// ---------------------------------------------------------------------------
|
|
403
|
+
|
|
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}]`;
|
|
144
411
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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;
|
|
148
421
|
}
|
|
149
422
|
|
|
150
|
-
//
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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: [] },
|
|
446
|
+
});
|
|
155
447
|
|
|
156
|
-
|
|
157
|
-
return result.output;
|
|
448
|
+
clearActiveSession(dispatch.issueId);
|
|
158
449
|
}
|
|
159
450
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
plan: string,
|
|
165
|
-
implResult: string,
|
|
451
|
+
async function handleAuditFail(
|
|
452
|
+
hookCtx: HookContext,
|
|
453
|
+
dispatch: ActiveDispatch,
|
|
454
|
+
verdict: AuditVerdict,
|
|
166
455
|
): Promise<void> {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
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
|
+
}
|
|
172
478
|
|
|
173
|
-
|
|
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}`));
|
|
174
484
|
|
|
175
|
-
|
|
485
|
+
api.logger.warn(`${TAG} audit FAILED ${nextAttempt}x — escalating to human`);
|
|
176
486
|
|
|
177
|
-
|
|
178
|
-
|
|
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 },
|
|
494
|
+
});
|
|
179
495
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
${worktreeInfo}
|
|
183
|
-
## Instructions
|
|
184
|
-
1. Verify each plan step was completed
|
|
185
|
-
2. Check for any missed items — use \`ask_agent\` / \`spawn_agent\` for specialized review if needed
|
|
186
|
-
3. Note any concerns or improvements needed
|
|
187
|
-
4. Provide a pass/fail verdict with reasoning
|
|
188
|
-
5. Output a concise audit summary in markdown
|
|
189
|
-
${toolContext(ctx)}
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
190
498
|
|
|
191
|
-
|
|
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,
|
|
507
|
+
);
|
|
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;
|
|
514
|
+
}
|
|
192
515
|
|
|
193
|
-
|
|
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}`));
|
|
194
521
|
|
|
195
|
-
|
|
196
|
-
api: ctx.api,
|
|
197
|
-
agentId: ctx.agentId,
|
|
198
|
-
sessionId: `linear-audit-${ctx.agentSessionId}`,
|
|
199
|
-
message,
|
|
200
|
-
timeoutMs: 5 * 60_000,
|
|
522
|
+
api.logger.info(`${TAG} audit FAILED — rework attempt ${nextAttempt}/${maxAttempts + 1}`);
|
|
201
523
|
|
|
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 },
|
|
202
530
|
});
|
|
203
531
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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(", ")}`,
|
|
209
537
|
);
|
|
538
|
+
}
|
|
210
539
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
540
|
+
// ---------------------------------------------------------------------------
|
|
541
|
+
// Worker phase (called by handleDispatch in webhook.ts)
|
|
542
|
+
// ---------------------------------------------------------------------------
|
|
543
|
+
|
|
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[] },
|
|
554
|
+
): Promise<void> {
|
|
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
|
+
}
|
|
576
|
+
|
|
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
|
+
};
|
|
585
|
+
|
|
586
|
+
// Build worker prompt from YAML templates
|
|
587
|
+
const workerPrompt = buildWorkerTask(issue, dispatch.worktreePath, {
|
|
588
|
+
attempt: dispatch.attempt,
|
|
589
|
+
gaps: opts?.gaps,
|
|
590
|
+
pluginConfig,
|
|
214
591
|
});
|
|
215
|
-
}
|
|
216
592
|
|
|
217
|
-
|
|
593
|
+
const workerSessionId = `linear-worker-${dispatch.issueIdentifier}-${dispatch.attempt}`;
|
|
594
|
+
|
|
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);
|
|
218
601
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
issueId: ctx.issue.id,
|
|
225
|
-
agentId: ctx.agentId,
|
|
226
|
-
startedAt: Date.now(),
|
|
602
|
+
await hookCtx.notify("working", {
|
|
603
|
+
identifier: dispatch.issueIdentifier,
|
|
604
|
+
title: issue.title,
|
|
605
|
+
status: "working",
|
|
606
|
+
attempt: dispatch.attempt,
|
|
227
607
|
});
|
|
228
608
|
|
|
229
|
-
|
|
230
|
-
// Stage 1: Plan
|
|
231
|
-
const plan = await runPlannerStage(ctx);
|
|
232
|
-
if (!plan) {
|
|
233
|
-
clearActiveSession(ctx.issue.id);
|
|
234
|
-
return;
|
|
235
|
-
}
|
|
609
|
+
api.logger.info(`${TAG} spawning worker agent session=${workerSessionId} (attempt ${dispatch.attempt})`);
|
|
236
610
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
611
|
+
const result = await runAgent({
|
|
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,
|
|
617
|
+
});
|
|
618
|
+
|
|
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;
|
|
244
626
|
}
|
|
627
|
+
|
|
628
|
+
api.logger.info(`${TAG} worker completed (success=${result.success}, ${result.output.length} chars) — triggering audit`);
|
|
629
|
+
|
|
630
|
+
await triggerAudit(hookCtx, freshDispatch, {
|
|
631
|
+
success: result.success,
|
|
632
|
+
output: result.output,
|
|
633
|
+
}, workerSessionId);
|
|
245
634
|
}
|
|
246
635
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
agentSessionId: ctx.agentSessionId,
|
|
251
|
-
issueIdentifier: ctx.issue.identifier,
|
|
252
|
-
issueId: ctx.issue.id,
|
|
253
|
-
agentId: ctx.agentId,
|
|
254
|
-
startedAt: Date.now(),
|
|
255
|
-
});
|
|
636
|
+
// ---------------------------------------------------------------------------
|
|
637
|
+
// Exports for backward compatibility (v1 pipeline)
|
|
638
|
+
// ---------------------------------------------------------------------------
|
|
256
639
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
const implResult = await runImplementorStage(ctx, plan);
|
|
260
|
-
if (!implResult) return;
|
|
640
|
+
// Re-export v1 types and functions that other files may still use
|
|
641
|
+
export type { Tier } from "./dispatch-state.js";
|
|
261
642
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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;
|
|
270
654
|
}
|