@calltelemetry/openclaw-linear 0.7.0 → 0.7.1
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/LICENSE +21 -0
- package/index.ts +39 -0
- package/openclaw.plugin.json +3 -3
- package/package.json +2 -1
- package/src/api/linear-api.test.ts +494 -0
- package/src/api/linear-api.ts +14 -11
- package/src/gateway/dispatch-methods.ts +243 -0
- package/src/infra/cli.ts +97 -29
- package/src/infra/codex-worktree.ts +83 -0
- package/src/infra/commands.ts +156 -0
- package/src/infra/file-lock.test.ts +61 -0
- package/src/infra/file-lock.ts +49 -0
- package/src/infra/multi-repo.ts +85 -0
- package/src/infra/notify.ts +115 -15
- package/src/infra/observability.ts +48 -0
- package/src/infra/resilience.test.ts +94 -0
- package/src/infra/resilience.ts +101 -0
- package/src/pipeline/artifacts.ts +38 -2
- package/src/pipeline/dag-dispatch.test.ts +553 -0
- package/src/pipeline/dag-dispatch.ts +390 -0
- package/src/pipeline/dispatch-service.ts +48 -1
- package/src/pipeline/dispatch-state.ts +2 -42
- package/src/pipeline/pipeline.ts +91 -17
- package/src/pipeline/planner.ts +6 -1
- package/src/pipeline/planning-state.ts +2 -40
- package/src/pipeline/tier-assess.test.ts +175 -0
- package/src/pipeline/webhook.ts +21 -0
- package/src/tools/dispatch-history-tool.ts +201 -0
- package/src/tools/orchestration-tools.test.ts +158 -0
package/src/pipeline/pipeline.ts
CHANGED
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
getActiveDispatch,
|
|
35
35
|
} from "./dispatch-state.js";
|
|
36
36
|
import { type NotifyFn } from "../infra/notify.js";
|
|
37
|
+
import { onProjectIssueCompleted, onProjectIssueStuck } from "./dag-dispatch.js";
|
|
37
38
|
import {
|
|
38
39
|
saveWorkerOutput,
|
|
39
40
|
saveAuditVerdict,
|
|
@@ -45,6 +46,7 @@ import {
|
|
|
45
46
|
resolveOrchestratorWorkspace,
|
|
46
47
|
} from "./artifacts.js";
|
|
47
48
|
import { resolveWatchdogConfig } from "../agent/watchdog.js";
|
|
49
|
+
import { emitDiagnostic } from "../infra/observability.js";
|
|
48
50
|
|
|
49
51
|
// ---------------------------------------------------------------------------
|
|
50
52
|
// Prompt loading
|
|
@@ -70,13 +72,29 @@ const DEFAULT_PROMPTS: PromptTemplates = {
|
|
|
70
72
|
},
|
|
71
73
|
};
|
|
72
74
|
|
|
73
|
-
let
|
|
75
|
+
let _cachedGlobalPrompts: PromptTemplates | null = null;
|
|
76
|
+
const _projectPromptCache = new Map<string, PromptTemplates>();
|
|
74
77
|
|
|
75
|
-
|
|
76
|
-
|
|
78
|
+
/**
|
|
79
|
+
* Merge two prompt layers. Overlay replaces individual fields per section
|
|
80
|
+
* (shallow section-level merge, not deep).
|
|
81
|
+
*/
|
|
82
|
+
function mergePromptLayers(base: PromptTemplates, overlay: Partial<PromptTemplates>): PromptTemplates {
|
|
83
|
+
return {
|
|
84
|
+
worker: { ...base.worker, ...overlay.worker },
|
|
85
|
+
audit: { ...base.audit, ...overlay.audit },
|
|
86
|
+
rework: { ...base.rework, ...overlay.rework },
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Load global prompts (layers 1+2: hardcoded defaults + global promptsPath override).
|
|
92
|
+
* Cached after first load.
|
|
93
|
+
*/
|
|
94
|
+
function loadGlobalPrompts(pluginConfig?: Record<string, unknown>): PromptTemplates {
|
|
95
|
+
if (_cachedGlobalPrompts) return _cachedGlobalPrompts;
|
|
77
96
|
|
|
78
97
|
try {
|
|
79
|
-
// Try custom path first
|
|
80
98
|
const customPath = pluginConfig?.promptsPath as string | undefined;
|
|
81
99
|
let raw: string;
|
|
82
100
|
|
|
@@ -86,27 +104,52 @@ export function loadPrompts(pluginConfig?: Record<string, unknown>): PromptTempl
|
|
|
86
104
|
: customPath;
|
|
87
105
|
raw = readFileSync(resolved, "utf-8");
|
|
88
106
|
} else {
|
|
89
|
-
// Load from plugin directory (sidecar file)
|
|
90
107
|
const pluginRoot = join(dirname(fileURLToPath(import.meta.url)), "../..");
|
|
91
108
|
raw = readFileSync(join(pluginRoot, "prompts.yaml"), "utf-8");
|
|
92
109
|
}
|
|
93
110
|
|
|
94
111
|
const parsed = parseYaml(raw) as Partial<PromptTemplates>;
|
|
95
|
-
|
|
96
|
-
worker: { ...DEFAULT_PROMPTS.worker, ...parsed.worker },
|
|
97
|
-
audit: { ...DEFAULT_PROMPTS.audit, ...parsed.audit },
|
|
98
|
-
rework: { ...DEFAULT_PROMPTS.rework, ...parsed.rework },
|
|
99
|
-
};
|
|
112
|
+
_cachedGlobalPrompts = mergePromptLayers(DEFAULT_PROMPTS, parsed);
|
|
100
113
|
} catch {
|
|
101
|
-
|
|
114
|
+
_cachedGlobalPrompts = DEFAULT_PROMPTS;
|
|
102
115
|
}
|
|
103
116
|
|
|
104
|
-
return
|
|
117
|
+
return _cachedGlobalPrompts;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Load prompts with three-layer merge:
|
|
122
|
+
* 1. Built-in defaults (hardcoded DEFAULT_PROMPTS)
|
|
123
|
+
* 2. Global override (promptsPath or sidecar prompts.yaml)
|
|
124
|
+
* 3. Per-project override ({worktreePath}/.claw/prompts.yaml) — optional
|
|
125
|
+
*/
|
|
126
|
+
export function loadPrompts(pluginConfig?: Record<string, unknown>, worktreePath?: string): PromptTemplates {
|
|
127
|
+
const global = loadGlobalPrompts(pluginConfig);
|
|
128
|
+
|
|
129
|
+
if (!worktreePath) return global;
|
|
130
|
+
|
|
131
|
+
// Check per-project cache
|
|
132
|
+
const cached = _projectPromptCache.get(worktreePath);
|
|
133
|
+
if (cached) return cached;
|
|
134
|
+
|
|
135
|
+
// Try loading per-project prompts
|
|
136
|
+
try {
|
|
137
|
+
const projectPromptsPath = join(worktreePath, ".claw", "prompts.yaml");
|
|
138
|
+
const raw = readFileSync(projectPromptsPath, "utf-8");
|
|
139
|
+
const parsed = parseYaml(raw) as Partial<PromptTemplates>;
|
|
140
|
+
const merged = mergePromptLayers(global, parsed);
|
|
141
|
+
_projectPromptCache.set(worktreePath, merged);
|
|
142
|
+
return merged;
|
|
143
|
+
} catch {
|
|
144
|
+
// No per-project override — use global
|
|
145
|
+
return global;
|
|
146
|
+
}
|
|
105
147
|
}
|
|
106
148
|
|
|
107
149
|
/** Clear prompt cache (for testing or after config change) */
|
|
108
150
|
export function clearPromptCache(): void {
|
|
109
|
-
|
|
151
|
+
_cachedGlobalPrompts = null;
|
|
152
|
+
_projectPromptCache.clear();
|
|
110
153
|
}
|
|
111
154
|
|
|
112
155
|
function renderTemplate(template: string, vars: Record<string, string>): string {
|
|
@@ -137,7 +180,7 @@ export function buildWorkerTask(
|
|
|
137
180
|
worktreePath: string,
|
|
138
181
|
opts?: { attempt?: number; gaps?: string[]; pluginConfig?: Record<string, unknown> },
|
|
139
182
|
): { system: string; task: string } {
|
|
140
|
-
const prompts = loadPrompts(opts?.pluginConfig);
|
|
183
|
+
const prompts = loadPrompts(opts?.pluginConfig, worktreePath);
|
|
141
184
|
const vars: Record<string, string> = {
|
|
142
185
|
identifier: issue.identifier,
|
|
143
186
|
title: issue.title,
|
|
@@ -167,7 +210,7 @@ export function buildAuditTask(
|
|
|
167
210
|
worktreePath: string,
|
|
168
211
|
pluginConfig?: Record<string, unknown>,
|
|
169
212
|
): { system: string; task: string } {
|
|
170
|
-
const prompts = loadPrompts(pluginConfig);
|
|
213
|
+
const prompts = loadPrompts(pluginConfig, worktreePath);
|
|
171
214
|
const vars: Record<string, string> = {
|
|
172
215
|
identifier: issue.identifier,
|
|
173
216
|
title: issue.title,
|
|
@@ -274,6 +317,7 @@ export async function triggerAudit(
|
|
|
274
317
|
}
|
|
275
318
|
|
|
276
319
|
api.logger.info(`${TAG} worker completed, triggering audit (attempt ${dispatch.attempt})`);
|
|
320
|
+
emitDiagnostic(api, { event: "phase_transition", identifier: dispatch.issueIdentifier, from: "working", to: "auditing", attempt: dispatch.attempt });
|
|
277
321
|
|
|
278
322
|
// Update .claw/ manifest
|
|
279
323
|
try { updateManifest(dispatch.worktreePath, { status: "auditing", attempts: dispatch.attempt }); } catch {}
|
|
@@ -464,7 +508,14 @@ async function handleAuditPass(
|
|
|
464
508
|
if (summary) {
|
|
465
509
|
writeSummary(dispatch.worktreePath, summary);
|
|
466
510
|
const wsDir = resolveOrchestratorWorkspace(api, pluginConfig);
|
|
467
|
-
writeDispatchMemory(dispatch.issueIdentifier, summary, wsDir
|
|
511
|
+
writeDispatchMemory(dispatch.issueIdentifier, summary, wsDir, {
|
|
512
|
+
title: dispatch.issueTitle ?? dispatch.issueIdentifier,
|
|
513
|
+
tier: dispatch.tier,
|
|
514
|
+
status: "done",
|
|
515
|
+
project: dispatch.project,
|
|
516
|
+
attempts: dispatch.attempt + 1,
|
|
517
|
+
model: dispatch.model,
|
|
518
|
+
});
|
|
468
519
|
api.logger.info(`${TAG} .claw/ summary and memory written`);
|
|
469
520
|
}
|
|
470
521
|
} catch (err) {
|
|
@@ -480,6 +531,7 @@ async function handleAuditPass(
|
|
|
480
531
|
).catch((err) => api.logger.error(`${TAG} failed to post audit pass comment: ${err}`));
|
|
481
532
|
|
|
482
533
|
api.logger.info(`${TAG} audit PASSED — dispatch completed (attempt ${dispatch.attempt})`);
|
|
534
|
+
emitDiagnostic(api, { event: "verdict_processed", identifier: dispatch.issueIdentifier, phase: "done", attempt: dispatch.attempt });
|
|
483
535
|
|
|
484
536
|
await notify("audit_pass", {
|
|
485
537
|
identifier: dispatch.issueIdentifier,
|
|
@@ -489,6 +541,12 @@ async function handleAuditPass(
|
|
|
489
541
|
verdict: { pass: true, gaps: [] },
|
|
490
542
|
});
|
|
491
543
|
|
|
544
|
+
// DAG cascade: if this issue belongs to a project dispatch, check for newly unblocked issues
|
|
545
|
+
if (dispatch.project) {
|
|
546
|
+
void onProjectIssueCompleted(hookCtx, dispatch.project, dispatch.issueIdentifier)
|
|
547
|
+
.catch((err) => api.logger.error(`${TAG} DAG cascade error: ${err}`));
|
|
548
|
+
}
|
|
549
|
+
|
|
492
550
|
clearActiveSession(dispatch.issueId);
|
|
493
551
|
}
|
|
494
552
|
|
|
@@ -531,7 +589,14 @@ async function handleAuditFail(
|
|
|
531
589
|
if (summary) {
|
|
532
590
|
writeSummary(dispatch.worktreePath, summary);
|
|
533
591
|
const wsDir = resolveOrchestratorWorkspace(api, pluginConfig);
|
|
534
|
-
writeDispatchMemory(dispatch.issueIdentifier, summary, wsDir
|
|
592
|
+
writeDispatchMemory(dispatch.issueIdentifier, summary, wsDir, {
|
|
593
|
+
title: dispatch.issueTitle ?? dispatch.issueIdentifier,
|
|
594
|
+
tier: dispatch.tier,
|
|
595
|
+
status: "stuck",
|
|
596
|
+
project: dispatch.project,
|
|
597
|
+
attempts: nextAttempt,
|
|
598
|
+
model: dispatch.model,
|
|
599
|
+
});
|
|
535
600
|
}
|
|
536
601
|
} catch {}
|
|
537
602
|
|
|
@@ -542,6 +607,7 @@ async function handleAuditFail(
|
|
|
542
607
|
).catch((err) => api.logger.error(`${TAG} failed to post escalation comment: ${err}`));
|
|
543
608
|
|
|
544
609
|
api.logger.warn(`${TAG} audit FAILED ${nextAttempt}x — escalating to human`);
|
|
610
|
+
emitDiagnostic(api, { event: "verdict_processed", identifier: dispatch.issueIdentifier, phase: "stuck", attempt: nextAttempt });
|
|
545
611
|
|
|
546
612
|
await notify("escalation", {
|
|
547
613
|
identifier: dispatch.issueIdentifier,
|
|
@@ -552,6 +618,12 @@ async function handleAuditFail(
|
|
|
552
618
|
verdict: { pass: false, gaps: verdict.gaps },
|
|
553
619
|
});
|
|
554
620
|
|
|
621
|
+
// DAG cascade: mark this issue as stuck in the project dispatch
|
|
622
|
+
if (dispatch.project) {
|
|
623
|
+
void onProjectIssueStuck(hookCtx, dispatch.project, dispatch.issueIdentifier)
|
|
624
|
+
.catch((err) => api.logger.error(`${TAG} DAG stuck cascade error: ${err}`));
|
|
625
|
+
}
|
|
626
|
+
|
|
555
627
|
return;
|
|
556
628
|
}
|
|
557
629
|
|
|
@@ -579,6 +651,7 @@ async function handleAuditFail(
|
|
|
579
651
|
).catch((err) => api.logger.error(`${TAG} failed to post rework comment: ${err}`));
|
|
580
652
|
|
|
581
653
|
api.logger.info(`${TAG} audit FAILED — rework attempt ${nextAttempt}/${maxAttempts + 1}`);
|
|
654
|
+
emitDiagnostic(api, { event: "phase_transition", identifier: dispatch.issueIdentifier, from: "auditing", to: "working", attempt: nextAttempt });
|
|
582
655
|
|
|
583
656
|
await notify("audit_fail", {
|
|
584
657
|
identifier: dispatch.issueIdentifier,
|
|
@@ -698,6 +771,7 @@ export async function spawnWorker(
|
|
|
698
771
|
const thresholdSec = Math.round(wdConfig.inactivityMs / 1000);
|
|
699
772
|
|
|
700
773
|
api.logger.warn(`${TAG} worker killed by inactivity watchdog 2x — escalating to stuck`);
|
|
774
|
+
emitDiagnostic(api, { event: "watchdog_kill", identifier: dispatch.issueIdentifier, attempt: dispatch.attempt });
|
|
701
775
|
|
|
702
776
|
try {
|
|
703
777
|
appendLog(dispatch.worktreePath, {
|
package/src/pipeline/planner.ts
CHANGED
|
@@ -149,13 +149,14 @@ export async function handlePlannerTurn(
|
|
|
149
149
|
ctx: PlannerContext,
|
|
150
150
|
session: PlanningSession,
|
|
151
151
|
input: { issueId: string; commentBody: string; commentorName: string },
|
|
152
|
+
opts?: { onApproved?: (projectId: string) => void },
|
|
152
153
|
): Promise<void> {
|
|
153
154
|
const { api, linearApi, pluginConfig } = ctx;
|
|
154
155
|
const configPath = pluginConfig?.planningStatePath as string | undefined;
|
|
155
156
|
|
|
156
157
|
// Detect finalization intent
|
|
157
158
|
if (FINALIZE_PATTERN.test(input.commentBody)) {
|
|
158
|
-
await runPlanAudit(ctx, session);
|
|
159
|
+
await runPlanAudit(ctx, session, { onApproved: opts?.onApproved });
|
|
159
160
|
return;
|
|
160
161
|
}
|
|
161
162
|
|
|
@@ -234,6 +235,7 @@ export async function handlePlannerTurn(
|
|
|
234
235
|
export async function runPlanAudit(
|
|
235
236
|
ctx: PlannerContext,
|
|
236
237
|
session: PlanningSession,
|
|
238
|
+
opts?: { onApproved?: (projectId: string) => void },
|
|
237
239
|
): Promise<void> {
|
|
238
240
|
const { api, linearApi, pluginConfig } = ctx;
|
|
239
241
|
const configPath = pluginConfig?.planningStatePath as string | undefined;
|
|
@@ -262,6 +264,9 @@ export async function runPlanAudit(
|
|
|
262
264
|
|
|
263
265
|
await endPlanningSession(session.projectId, "approved", configPath);
|
|
264
266
|
api.logger.info(`Planning: session approved for ${session.projectName}`);
|
|
267
|
+
|
|
268
|
+
// Trigger DAG-based dispatch if callback provided
|
|
269
|
+
opts?.onApproved?.(session.projectId);
|
|
265
270
|
} else {
|
|
266
271
|
// Post problems and keep planning
|
|
267
272
|
const problemsList = result.problems.map((p) => `- ${p}`).join("\n");
|
|
@@ -48,48 +48,10 @@ function resolveStatePath(configPath?: string): string {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
// ---------------------------------------------------------------------------
|
|
51
|
-
// File locking
|
|
51
|
+
// File locking (shared utility)
|
|
52
52
|
// ---------------------------------------------------------------------------
|
|
53
53
|
|
|
54
|
-
|
|
55
|
-
const LOCK_RETRY_MS = 50;
|
|
56
|
-
const LOCK_TIMEOUT_MS = 10_000;
|
|
57
|
-
|
|
58
|
-
function lockPath(statePath: string): string {
|
|
59
|
-
return statePath + ".lock";
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
async function acquireLock(statePath: string): Promise<void> {
|
|
63
|
-
const lock = lockPath(statePath);
|
|
64
|
-
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
65
|
-
|
|
66
|
-
while (Date.now() < deadline) {
|
|
67
|
-
try {
|
|
68
|
-
await fs.writeFile(lock, String(Date.now()), { flag: "wx" });
|
|
69
|
-
return;
|
|
70
|
-
} catch (err: any) {
|
|
71
|
-
if (err.code !== "EEXIST") throw err;
|
|
72
|
-
|
|
73
|
-
try {
|
|
74
|
-
const content = await fs.readFile(lock, "utf-8");
|
|
75
|
-
const lockTime = Number(content);
|
|
76
|
-
if (Date.now() - lockTime > LOCK_STALE_MS) {
|
|
77
|
-
try { await fs.unlink(lock); } catch { /* race */ }
|
|
78
|
-
continue;
|
|
79
|
-
}
|
|
80
|
-
} catch { /* lock disappeared — retry */ }
|
|
81
|
-
|
|
82
|
-
await new Promise((r) => setTimeout(r, LOCK_RETRY_MS));
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
try { await fs.unlink(lockPath(statePath)); } catch { /* ignore */ }
|
|
87
|
-
await fs.writeFile(lock, String(Date.now()), { flag: "wx" });
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
async function releaseLock(statePath: string): Promise<void> {
|
|
91
|
-
try { await fs.unlink(lockPath(statePath)); } catch { /* already removed */ }
|
|
92
|
-
}
|
|
54
|
+
import { acquireLock, releaseLock } from "../infra/file-lock.js";
|
|
93
55
|
|
|
94
56
|
// ---------------------------------------------------------------------------
|
|
95
57
|
// Read / Write
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { TIER_MODELS, assessTier, type IssueContext } from "./tier-assess.js";
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Mock runAgent
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
const mockRunAgent = vi.fn();
|
|
9
|
+
|
|
10
|
+
vi.mock("../agent/agent.js", () => ({
|
|
11
|
+
runAgent: (...args: unknown[]) => mockRunAgent(...args),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Helpers
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
function makeApi(overrides?: { defaultAgentId?: string }) {
|
|
19
|
+
return {
|
|
20
|
+
logger: {
|
|
21
|
+
info: vi.fn(),
|
|
22
|
+
warn: vi.fn(),
|
|
23
|
+
error: vi.fn(),
|
|
24
|
+
},
|
|
25
|
+
pluginConfig: {
|
|
26
|
+
defaultAgentId: overrides?.defaultAgentId,
|
|
27
|
+
},
|
|
28
|
+
} as any;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function makeIssue(overrides?: Partial<IssueContext>): IssueContext {
|
|
32
|
+
return {
|
|
33
|
+
identifier: "CT-123",
|
|
34
|
+
title: "Fix login bug",
|
|
35
|
+
description: "Users cannot log in when using SSO",
|
|
36
|
+
labels: ["bug"],
|
|
37
|
+
commentCount: 2,
|
|
38
|
+
...overrides,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Tests
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
describe("TIER_MODELS", () => {
|
|
47
|
+
it("maps junior to haiku, medior to sonnet, senior to opus", () => {
|
|
48
|
+
expect(TIER_MODELS.junior).toBe("anthropic/claude-haiku-4-5");
|
|
49
|
+
expect(TIER_MODELS.medior).toBe("anthropic/claude-sonnet-4-6");
|
|
50
|
+
expect(TIER_MODELS.senior).toBe("anthropic/claude-opus-4-6");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("assessTier", () => {
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
mockRunAgent.mockReset();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("returns parsed tier from agent response", async () => {
|
|
60
|
+
mockRunAgent.mockResolvedValue({
|
|
61
|
+
success: true,
|
|
62
|
+
output: '{"tier":"senior","reasoning":"Multi-service architecture change"}',
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const api = makeApi({ defaultAgentId: "mal" });
|
|
66
|
+
const result = await assessTier(api, makeIssue());
|
|
67
|
+
|
|
68
|
+
expect(result.tier).toBe("senior");
|
|
69
|
+
expect(result.model).toBe(TIER_MODELS.senior);
|
|
70
|
+
expect(result.reasoning).toBe("Multi-service architecture change");
|
|
71
|
+
expect(api.logger.info).toHaveBeenCalled();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("falls back to medior when agent fails (success: false) with no parseable JSON", async () => {
|
|
75
|
+
mockRunAgent.mockResolvedValue({
|
|
76
|
+
success: false,
|
|
77
|
+
output: "Agent process exited with code 1",
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const api = makeApi({ defaultAgentId: "mal" });
|
|
81
|
+
const result = await assessTier(api, makeIssue());
|
|
82
|
+
|
|
83
|
+
expect(result.tier).toBe("medior");
|
|
84
|
+
expect(result.model).toBe(TIER_MODELS.medior);
|
|
85
|
+
expect(result.reasoning).toBe("Assessment failed — defaulting to medior");
|
|
86
|
+
expect(api.logger.warn).toHaveBeenCalled();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("falls back to medior when output has no JSON", async () => {
|
|
90
|
+
mockRunAgent.mockResolvedValue({
|
|
91
|
+
success: true,
|
|
92
|
+
output: "I think this is a medium complexity issue because it involves multiple files.",
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const api = makeApi({ defaultAgentId: "mal" });
|
|
96
|
+
const result = await assessTier(api, makeIssue());
|
|
97
|
+
|
|
98
|
+
expect(result.tier).toBe("medior");
|
|
99
|
+
expect(result.model).toBe(TIER_MODELS.medior);
|
|
100
|
+
expect(result.reasoning).toBe("Assessment failed — defaulting to medior");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("falls back to medior when JSON has invalid tier", async () => {
|
|
104
|
+
mockRunAgent.mockResolvedValue({
|
|
105
|
+
success: true,
|
|
106
|
+
output: '{"tier":"expert","reasoning":"Very hard problem"}',
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const api = makeApi({ defaultAgentId: "mal" });
|
|
110
|
+
const result = await assessTier(api, makeIssue());
|
|
111
|
+
|
|
112
|
+
expect(result.tier).toBe("medior");
|
|
113
|
+
expect(result.model).toBe(TIER_MODELS.medior);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("handles agent throwing an error", async () => {
|
|
117
|
+
mockRunAgent.mockRejectedValue(new Error("Connection refused"));
|
|
118
|
+
|
|
119
|
+
const api = makeApi({ defaultAgentId: "mal" });
|
|
120
|
+
const result = await assessTier(api, makeIssue());
|
|
121
|
+
|
|
122
|
+
expect(result.tier).toBe("medior");
|
|
123
|
+
expect(result.model).toBe(TIER_MODELS.medior);
|
|
124
|
+
expect(result.reasoning).toBe("Assessment failed — defaulting to medior");
|
|
125
|
+
expect(api.logger.warn).toHaveBeenCalledWith(
|
|
126
|
+
expect.stringContaining("Tier assessment error for CT-123"),
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("truncates long descriptions to 1500 chars", async () => {
|
|
131
|
+
const longDescription = "A".repeat(3000);
|
|
132
|
+
mockRunAgent.mockResolvedValue({
|
|
133
|
+
success: true,
|
|
134
|
+
output: '{"tier":"junior","reasoning":"Simple copy change"}',
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const api = makeApi({ defaultAgentId: "mal" });
|
|
138
|
+
await assessTier(api, makeIssue({ description: longDescription }));
|
|
139
|
+
|
|
140
|
+
// Verify the message sent to runAgent has the description truncated
|
|
141
|
+
const callArgs = mockRunAgent.mock.calls[0][0];
|
|
142
|
+
const message: string = callArgs.message;
|
|
143
|
+
// The description in the prompt should be at most 1500 chars of the original
|
|
144
|
+
// "Description: " prefix + 1500 chars = the truncated form
|
|
145
|
+
expect(message).toContain("Description: " + "A".repeat(1500));
|
|
146
|
+
expect(message).not.toContain("A".repeat(1501));
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("uses configured agentId when provided", async () => {
|
|
150
|
+
mockRunAgent.mockResolvedValue({
|
|
151
|
+
success: true,
|
|
152
|
+
output: '{"tier":"junior","reasoning":"Typo fix"}',
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const api = makeApi({ defaultAgentId: "mal" });
|
|
156
|
+
await assessTier(api, makeIssue(), "kaylee");
|
|
157
|
+
|
|
158
|
+
const callArgs = mockRunAgent.mock.calls[0][0];
|
|
159
|
+
expect(callArgs.agentId).toBe("kaylee");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("parses JSON even when wrapped in markdown fences", async () => {
|
|
163
|
+
mockRunAgent.mockResolvedValue({
|
|
164
|
+
success: true,
|
|
165
|
+
output: '```json\n{"tier":"junior","reasoning":"Config tweak"}\n```',
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const api = makeApi({ defaultAgentId: "mal" });
|
|
169
|
+
const result = await assessTier(api, makeIssue());
|
|
170
|
+
|
|
171
|
+
expect(result.tier).toBe("junior");
|
|
172
|
+
expect(result.model).toBe(TIER_MODELS.junior);
|
|
173
|
+
expect(result.reasoning).toBe("Config tweak");
|
|
174
|
+
});
|
|
175
|
+
});
|
package/src/pipeline/webhook.ts
CHANGED
|
@@ -12,6 +12,8 @@ import { createWorktree, prepareWorkspace } from "../infra/codex-worktree.js";
|
|
|
12
12
|
import { ensureClawDir, writeManifest } from "./artifacts.js";
|
|
13
13
|
import { readPlanningState, isInPlanningMode, getPlanningSession } from "./planning-state.js";
|
|
14
14
|
import { initiatePlanningSession, handlePlannerTurn } from "./planner.js";
|
|
15
|
+
import { startProjectDispatch } from "./dag-dispatch.js";
|
|
16
|
+
import { emitDiagnostic } from "../infra/observability.js";
|
|
15
17
|
|
|
16
18
|
// ── Agent profiles (loaded from config, no hardcoded names) ───────
|
|
17
19
|
interface AgentProfile {
|
|
@@ -148,6 +150,7 @@ export async function handleLinearWebhook(
|
|
|
148
150
|
// Debug: log full payload structure for diagnosing webhook types
|
|
149
151
|
const payloadKeys = Object.keys(payload).join(", ");
|
|
150
152
|
api.logger.info(`Linear webhook received: type=${payload.type} action=${payload.action} keys=[${payloadKeys}]`);
|
|
153
|
+
emitDiagnostic(api, { event: "webhook_received", webhookType: payload.type, webhookAction: payload.action });
|
|
151
154
|
|
|
152
155
|
|
|
153
156
|
// ── AppUserNotification — OAuth app webhook for agent mentions/assignments
|
|
@@ -666,6 +669,8 @@ export async function handleLinearWebhook(
|
|
|
666
669
|
const issue = comment?.issue ?? payload.issue;
|
|
667
670
|
|
|
668
671
|
// ── Planning mode intercept ──────────────────────────────────
|
|
672
|
+
const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
|
|
673
|
+
|
|
669
674
|
if (issue?.id) {
|
|
670
675
|
const linearApiForPlanning = createLinearApi(api);
|
|
671
676
|
if (linearApiForPlanning) {
|
|
@@ -698,6 +703,20 @@ export async function handleLinearWebhook(
|
|
|
698
703
|
{ api, linearApi: linearApiForPlanning, pluginConfig },
|
|
699
704
|
session,
|
|
700
705
|
{ issueId: issue.id, commentBody, commentorName: commentor },
|
|
706
|
+
{
|
|
707
|
+
onApproved: (approvedProjectId) => {
|
|
708
|
+
const notify = createNotifierFromConfig(pluginConfig, api.runtime);
|
|
709
|
+
const hookCtx: HookContext = {
|
|
710
|
+
api,
|
|
711
|
+
linearApi: linearApiForPlanning,
|
|
712
|
+
notify,
|
|
713
|
+
pluginConfig,
|
|
714
|
+
configPath: pluginConfig?.dispatchStatePath as string | undefined,
|
|
715
|
+
};
|
|
716
|
+
void startProjectDispatch(hookCtx, approvedProjectId)
|
|
717
|
+
.catch((err) => api.logger.error(`Project dispatch start error: ${err}`));
|
|
718
|
+
},
|
|
719
|
+
},
|
|
701
720
|
).catch((err) => api.logger.error(`Planner turn error: ${err}`));
|
|
702
721
|
}
|
|
703
722
|
return true;
|
|
@@ -1283,6 +1302,7 @@ async function handleDispatch(
|
|
|
1283
1302
|
});
|
|
1284
1303
|
|
|
1285
1304
|
api.logger.info(`@dispatch: ${identifier} assessed as ${assessment.tier} (${assessment.model}) — ${assessment.reasoning}`);
|
|
1305
|
+
emitDiagnostic(api, { event: "dispatch_started", identifier, tier: assessment.tier, issueId: issue.id });
|
|
1286
1306
|
|
|
1287
1307
|
// 5. Create persistent worktree
|
|
1288
1308
|
let worktree;
|
|
@@ -1351,6 +1371,7 @@ async function handleDispatch(
|
|
|
1351
1371
|
dispatchedAt: now,
|
|
1352
1372
|
agentSessionId,
|
|
1353
1373
|
attempt: 0,
|
|
1374
|
+
project: enrichedIssue?.project?.id,
|
|
1354
1375
|
}, statePath);
|
|
1355
1376
|
|
|
1356
1377
|
// 8. Register active session for tool resolution
|