@calltelemetry/openclaw-linear 0.7.0 → 0.8.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/LICENSE +21 -0
- package/README.md +719 -539
- package/index.ts +40 -1
- package/openclaw.plugin.json +4 -4
- package/package.json +2 -1
- package/prompts.yaml +19 -5
- package/src/__test__/fixtures/linear-responses.ts +75 -0
- package/src/__test__/fixtures/webhook-payloads.ts +113 -0
- package/src/__test__/helpers.ts +133 -0
- package/src/agent/agent.test.ts +143 -0
- package/src/api/linear-api.test.ts +586 -0
- package/src/api/linear-api.ts +50 -11
- package/src/gateway/dispatch-methods.test.ts +409 -0
- package/src/gateway/dispatch-methods.ts +243 -0
- package/src/infra/cli.ts +273 -30
- package/src/infra/codex-worktree.ts +83 -0
- package/src/infra/commands.test.ts +276 -0
- package/src/infra/commands.ts +156 -0
- package/src/infra/doctor.test.ts +19 -0
- package/src/infra/doctor.ts +28 -23
- package/src/infra/file-lock.test.ts +61 -0
- package/src/infra/file-lock.ts +49 -0
- package/src/infra/multi-repo.test.ts +163 -0
- package/src/infra/multi-repo.ts +114 -0
- package/src/infra/notify.test.ts +155 -16
- package/src/infra/notify.ts +137 -26
- package/src/infra/observability.test.ts +85 -0
- 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.test.ts +26 -3
- 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 +3 -42
- package/src/pipeline/e2e-dispatch.test.ts +584 -0
- package/src/pipeline/e2e-planning.test.ts +455 -0
- package/src/pipeline/pipeline.test.ts +69 -0
- package/src/pipeline/pipeline.ts +132 -29
- package/src/pipeline/planner.test.ts +1 -1
- package/src/pipeline/planner.ts +18 -31
- package/src/pipeline/planning-state.ts +2 -40
- package/src/pipeline/tier-assess.test.ts +264 -0
- package/src/pipeline/webhook.ts +134 -36
- package/src/tools/cli-shared.test.ts +155 -0
- package/src/tools/code-tool.test.ts +210 -0
- package/src/tools/dispatch-history-tool.test.ts +315 -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
|
|
@@ -58,25 +60,40 @@ interface PromptTemplates {
|
|
|
58
60
|
|
|
59
61
|
const DEFAULT_PROMPTS: PromptTemplates = {
|
|
60
62
|
worker: {
|
|
61
|
-
system: "You are implementing a Linear issue.
|
|
62
|
-
task: "Implement issue {{identifier}}: {{title}}\n\nIssue body:\n{{description}}\n\nWorktree: {{worktreePath}}",
|
|
63
|
+
system: "You are a coding worker implementing a Linear issue. Your ONLY job is to write code and return a text summary. Do NOT attempt to update, close, comment on, or modify the Linear issue. Do NOT mark the issue as Done.",
|
|
64
|
+
task: "Implement issue {{identifier}}: {{title}}\n\nIssue body:\n{{description}}\n\nWorktree: {{worktreePath}}\n\nImplement the solution, run tests, commit your work, and return a text summary.",
|
|
63
65
|
},
|
|
64
66
|
audit: {
|
|
65
67
|
system: "You are an independent auditor. The Linear issue body is the SOURCE OF TRUTH. Worker comments are secondary evidence.",
|
|
66
68
|
task: 'Audit issue {{identifier}}: {{title}}\n\nIssue body:\n{{description}}\n\nWorktree: {{worktreePath}}\n\nReturn JSON verdict: {"pass": true/false, "criteria": [...], "gaps": [...], "testResults": "..."}',
|
|
67
69
|
},
|
|
68
70
|
rework: {
|
|
69
|
-
addendum: "PREVIOUS AUDIT FAILED (attempt {{attempt}}). Gaps:\n{{gaps}}\n\nAddress these specific issues.",
|
|
71
|
+
addendum: "PREVIOUS AUDIT FAILED (attempt {{attempt}}). Gaps:\n{{gaps}}\n\nAddress these specific issues. Preserve correct code from prior attempts.",
|
|
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
|
+
}
|
|
77
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Load and parse the raw prompts YAML file (global promptsPath or sidecar).
|
|
92
|
+
* Returns the parsed object, or null if no file found.
|
|
93
|
+
* Shared by both pipeline and planner prompt loaders.
|
|
94
|
+
*/
|
|
95
|
+
export function loadRawPromptYaml(pluginConfig?: Record<string, unknown>): Record<string, any> | null {
|
|
78
96
|
try {
|
|
79
|
-
// Try custom path first
|
|
80
97
|
const customPath = pluginConfig?.promptsPath as string | undefined;
|
|
81
98
|
let raw: string;
|
|
82
99
|
|
|
@@ -86,27 +103,66 @@ export function loadPrompts(pluginConfig?: Record<string, unknown>): PromptTempl
|
|
|
86
103
|
: customPath;
|
|
87
104
|
raw = readFileSync(resolved, "utf-8");
|
|
88
105
|
} else {
|
|
89
|
-
// Load from plugin directory (sidecar file)
|
|
90
106
|
const pluginRoot = join(dirname(fileURLToPath(import.meta.url)), "../..");
|
|
91
107
|
raw = readFileSync(join(pluginRoot, "prompts.yaml"), "utf-8");
|
|
92
108
|
}
|
|
93
109
|
|
|
94
|
-
|
|
95
|
-
_cachedPrompts = {
|
|
96
|
-
worker: { ...DEFAULT_PROMPTS.worker, ...parsed.worker },
|
|
97
|
-
audit: { ...DEFAULT_PROMPTS.audit, ...parsed.audit },
|
|
98
|
-
rework: { ...DEFAULT_PROMPTS.rework, ...parsed.rework },
|
|
99
|
-
};
|
|
110
|
+
return parseYaml(raw) as Record<string, any>;
|
|
100
111
|
} catch {
|
|
101
|
-
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Load global prompts (layers 1+2: hardcoded defaults + global promptsPath override).
|
|
118
|
+
* Cached after first load.
|
|
119
|
+
*/
|
|
120
|
+
function loadGlobalPrompts(pluginConfig?: Record<string, unknown>): PromptTemplates {
|
|
121
|
+
if (_cachedGlobalPrompts) return _cachedGlobalPrompts;
|
|
122
|
+
|
|
123
|
+
const parsed = loadRawPromptYaml(pluginConfig);
|
|
124
|
+
if (parsed) {
|
|
125
|
+
_cachedGlobalPrompts = mergePromptLayers(DEFAULT_PROMPTS, parsed as Partial<PromptTemplates>);
|
|
126
|
+
} else {
|
|
127
|
+
_cachedGlobalPrompts = DEFAULT_PROMPTS;
|
|
102
128
|
}
|
|
103
129
|
|
|
104
|
-
return
|
|
130
|
+
return _cachedGlobalPrompts;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Load prompts with three-layer merge:
|
|
135
|
+
* 1. Built-in defaults (hardcoded DEFAULT_PROMPTS)
|
|
136
|
+
* 2. Global override (promptsPath or sidecar prompts.yaml)
|
|
137
|
+
* 3. Per-project override ({worktreePath}/.claw/prompts.yaml) — optional
|
|
138
|
+
*/
|
|
139
|
+
export function loadPrompts(pluginConfig?: Record<string, unknown>, worktreePath?: string): PromptTemplates {
|
|
140
|
+
const global = loadGlobalPrompts(pluginConfig);
|
|
141
|
+
|
|
142
|
+
if (!worktreePath) return global;
|
|
143
|
+
|
|
144
|
+
// Check per-project cache
|
|
145
|
+
const cached = _projectPromptCache.get(worktreePath);
|
|
146
|
+
if (cached) return cached;
|
|
147
|
+
|
|
148
|
+
// Try loading per-project prompts
|
|
149
|
+
try {
|
|
150
|
+
const projectPromptsPath = join(worktreePath, ".claw", "prompts.yaml");
|
|
151
|
+
const raw = readFileSync(projectPromptsPath, "utf-8");
|
|
152
|
+
const parsed = parseYaml(raw) as Partial<PromptTemplates>;
|
|
153
|
+
const merged = mergePromptLayers(global, parsed);
|
|
154
|
+
_projectPromptCache.set(worktreePath, merged);
|
|
155
|
+
return merged;
|
|
156
|
+
} catch {
|
|
157
|
+
// No per-project override — use global
|
|
158
|
+
return global;
|
|
159
|
+
}
|
|
105
160
|
}
|
|
106
161
|
|
|
107
162
|
/** Clear prompt cache (for testing or after config change) */
|
|
108
163
|
export function clearPromptCache(): void {
|
|
109
|
-
|
|
164
|
+
_cachedGlobalPrompts = null;
|
|
165
|
+
_projectPromptCache.clear();
|
|
110
166
|
}
|
|
111
167
|
|
|
112
168
|
function renderTemplate(template: string, vars: Record<string, string>): string {
|
|
@@ -137,7 +193,7 @@ export function buildWorkerTask(
|
|
|
137
193
|
worktreePath: string,
|
|
138
194
|
opts?: { attempt?: number; gaps?: string[]; pluginConfig?: Record<string, unknown> },
|
|
139
195
|
): { system: string; task: string } {
|
|
140
|
-
const prompts = loadPrompts(opts?.pluginConfig);
|
|
196
|
+
const prompts = loadPrompts(opts?.pluginConfig, worktreePath);
|
|
141
197
|
const vars: Record<string, string> = {
|
|
142
198
|
identifier: issue.identifier,
|
|
143
199
|
title: issue.title,
|
|
@@ -145,7 +201,7 @@ export function buildWorkerTask(
|
|
|
145
201
|
worktreePath,
|
|
146
202
|
tier: "",
|
|
147
203
|
attempt: String(opts?.attempt ?? 0),
|
|
148
|
-
gaps: opts?.gaps?.join("\n- ")
|
|
204
|
+
gaps: opts?.gaps?.length ? "- " + opts.gaps.join("\n- ") : "",
|
|
149
205
|
};
|
|
150
206
|
|
|
151
207
|
let task = renderTemplate(prompts.worker.task, vars);
|
|
@@ -167,7 +223,7 @@ export function buildAuditTask(
|
|
|
167
223
|
worktreePath: string,
|
|
168
224
|
pluginConfig?: Record<string, unknown>,
|
|
169
225
|
): { system: string; task: string } {
|
|
170
|
-
const prompts = loadPrompts(pluginConfig);
|
|
226
|
+
const prompts = loadPrompts(pluginConfig, worktreePath);
|
|
171
227
|
const vars: Record<string, string> = {
|
|
172
228
|
identifier: issue.identifier,
|
|
173
229
|
title: issue.title,
|
|
@@ -274,6 +330,7 @@ export async function triggerAudit(
|
|
|
274
330
|
}
|
|
275
331
|
|
|
276
332
|
api.logger.info(`${TAG} worker completed, triggering audit (attempt ${dispatch.attempt})`);
|
|
333
|
+
emitDiagnostic(api, { event: "phase_transition", identifier: dispatch.issueIdentifier, from: "working", to: "auditing", attempt: dispatch.attempt });
|
|
277
334
|
|
|
278
335
|
// Update .claw/ manifest
|
|
279
336
|
try { updateManifest(dispatch.worktreePath, { status: "auditing", attempts: dispatch.attempt }); } catch {}
|
|
@@ -288,7 +345,12 @@ export async function triggerAudit(
|
|
|
288
345
|
};
|
|
289
346
|
|
|
290
347
|
// Build audit prompt from YAML templates
|
|
291
|
-
|
|
348
|
+
// For multi-repo dispatches, render worktreePath as a list of repo→path mappings
|
|
349
|
+
const effectiveAuditPath = dispatch.worktrees
|
|
350
|
+
? dispatch.worktrees.map(w => `${w.repoName}: ${w.path}`).join("\n")
|
|
351
|
+
: dispatch.worktreePath;
|
|
352
|
+
|
|
353
|
+
const auditPrompt = buildAuditTask(issue, effectiveAuditPath, pluginConfig);
|
|
292
354
|
|
|
293
355
|
// Set Linear label
|
|
294
356
|
await linearApi.emitActivity(dispatch.agentSessionId ?? "", {
|
|
@@ -400,6 +462,11 @@ export async function processVerdict(
|
|
|
400
462
|
const verdict = parseVerdict(auditOutput);
|
|
401
463
|
if (!verdict) {
|
|
402
464
|
api.logger.warn(`${TAG} could not parse audit verdict from output (${auditOutput.length} chars)`);
|
|
465
|
+
// Post comment so user knows what happened
|
|
466
|
+
await linearApi.createComment(
|
|
467
|
+
dispatch.issueId,
|
|
468
|
+
`## Audit Inconclusive\n\nThe auditor's response couldn't be parsed as a verdict. **Retrying automatically** — this usually resolves itself.\n\n**If it keeps happening:** \`openclaw openclaw-linear prompts validate\`\n\n**Status:** Retrying audit now. No action needed.`,
|
|
469
|
+
).catch((err) => api.logger.error(`${TAG} failed to post inconclusive comment: ${err}`));
|
|
403
470
|
// Treat unparseable verdict as failure
|
|
404
471
|
await handleAuditFail(hookCtx, dispatch, {
|
|
405
472
|
pass: false,
|
|
@@ -464,7 +531,14 @@ async function handleAuditPass(
|
|
|
464
531
|
if (summary) {
|
|
465
532
|
writeSummary(dispatch.worktreePath, summary);
|
|
466
533
|
const wsDir = resolveOrchestratorWorkspace(api, pluginConfig);
|
|
467
|
-
writeDispatchMemory(dispatch.issueIdentifier, summary, wsDir
|
|
534
|
+
writeDispatchMemory(dispatch.issueIdentifier, summary, wsDir, {
|
|
535
|
+
title: dispatch.issueTitle ?? dispatch.issueIdentifier,
|
|
536
|
+
tier: dispatch.tier,
|
|
537
|
+
status: "done",
|
|
538
|
+
project: dispatch.project,
|
|
539
|
+
attempts: dispatch.attempt + 1,
|
|
540
|
+
model: dispatch.model,
|
|
541
|
+
});
|
|
468
542
|
api.logger.info(`${TAG} .claw/ summary and memory written`);
|
|
469
543
|
}
|
|
470
544
|
} catch (err) {
|
|
@@ -476,10 +550,11 @@ async function handleAuditPass(
|
|
|
476
550
|
const summaryExcerpt = summary ? `\n\n**Summary:**\n${summary.slice(0, 2000)}` : "";
|
|
477
551
|
await linearApi.createComment(
|
|
478
552
|
dispatch.issueId,
|
|
479
|
-
`##
|
|
553
|
+
`## Done\n\nThis issue has been implemented and verified.\n\n**What was checked:**\n${criteriaList}\n\n**Test results:** ${verdict.testResults || "N/A"}${summaryExcerpt}\n\n---\n*Completed on attempt ${dispatch.attempt + 1}.*\n\n**Next steps:**\n- Review the code: \`cd ${dispatch.worktreePath}\`\n- View artifacts: \`ls ${dispatch.worktreePath}/.claw/\`\n- Create a PR from the worktree branch if one wasn't opened automatically`,
|
|
480
554
|
).catch((err) => api.logger.error(`${TAG} failed to post audit pass comment: ${err}`));
|
|
481
555
|
|
|
482
556
|
api.logger.info(`${TAG} audit PASSED — dispatch completed (attempt ${dispatch.attempt})`);
|
|
557
|
+
emitDiagnostic(api, { event: "verdict_processed", identifier: dispatch.issueIdentifier, phase: "done", attempt: dispatch.attempt });
|
|
483
558
|
|
|
484
559
|
await notify("audit_pass", {
|
|
485
560
|
identifier: dispatch.issueIdentifier,
|
|
@@ -489,6 +564,12 @@ async function handleAuditPass(
|
|
|
489
564
|
verdict: { pass: true, gaps: [] },
|
|
490
565
|
});
|
|
491
566
|
|
|
567
|
+
// DAG cascade: if this issue belongs to a project dispatch, check for newly unblocked issues
|
|
568
|
+
if (dispatch.project) {
|
|
569
|
+
void onProjectIssueCompleted(hookCtx, dispatch.project, dispatch.issueIdentifier)
|
|
570
|
+
.catch((err) => api.logger.error(`${TAG} DAG cascade error: ${err}`));
|
|
571
|
+
}
|
|
572
|
+
|
|
492
573
|
clearActiveSession(dispatch.issueId);
|
|
493
574
|
}
|
|
494
575
|
|
|
@@ -531,17 +612,25 @@ async function handleAuditFail(
|
|
|
531
612
|
if (summary) {
|
|
532
613
|
writeSummary(dispatch.worktreePath, summary);
|
|
533
614
|
const wsDir = resolveOrchestratorWorkspace(api, pluginConfig);
|
|
534
|
-
writeDispatchMemory(dispatch.issueIdentifier, summary, wsDir
|
|
615
|
+
writeDispatchMemory(dispatch.issueIdentifier, summary, wsDir, {
|
|
616
|
+
title: dispatch.issueTitle ?? dispatch.issueIdentifier,
|
|
617
|
+
tier: dispatch.tier,
|
|
618
|
+
status: "stuck",
|
|
619
|
+
project: dispatch.project,
|
|
620
|
+
attempts: nextAttempt,
|
|
621
|
+
model: dispatch.model,
|
|
622
|
+
});
|
|
535
623
|
}
|
|
536
624
|
} catch {}
|
|
537
625
|
|
|
538
626
|
const gapsList = verdict.gaps.map((g) => `- ${g}`).join("\n");
|
|
539
627
|
await linearApi.createComment(
|
|
540
628
|
dispatch.issueId,
|
|
541
|
-
`##
|
|
629
|
+
`## Needs Your Help\n\nAll ${nextAttempt} attempts failed. The agent couldn't resolve these issues on its own.\n\n**What went wrong:**\n${gapsList}\n\n**Test results:** ${verdict.testResults || "N/A"}\n\n---\n\n**What you can do:**\n1. **Clarify requirements** — update the issue body with more detail, then re-assign to try again\n2. **Fix it manually** — the agent's work is in the worktree: \`cd ${dispatch.worktreePath}\`\n3. **Force retry** — \`/dispatch retry ${dispatch.issueIdentifier}\`\n4. **View logs** — worker output: \`.claw/worker-*.md\`, audit verdicts: \`.claw/audit-*.json\``,
|
|
542
630
|
).catch((err) => api.logger.error(`${TAG} failed to post escalation comment: ${err}`));
|
|
543
631
|
|
|
544
632
|
api.logger.warn(`${TAG} audit FAILED ${nextAttempt}x — escalating to human`);
|
|
633
|
+
emitDiagnostic(api, { event: "verdict_processed", identifier: dispatch.issueIdentifier, phase: "stuck", attempt: nextAttempt });
|
|
545
634
|
|
|
546
635
|
await notify("escalation", {
|
|
547
636
|
identifier: dispatch.issueIdentifier,
|
|
@@ -552,6 +641,12 @@ async function handleAuditFail(
|
|
|
552
641
|
verdict: { pass: false, gaps: verdict.gaps },
|
|
553
642
|
});
|
|
554
643
|
|
|
644
|
+
// DAG cascade: mark this issue as stuck in the project dispatch
|
|
645
|
+
if (dispatch.project) {
|
|
646
|
+
void onProjectIssueStuck(hookCtx, dispatch.project, dispatch.issueIdentifier)
|
|
647
|
+
.catch((err) => api.logger.error(`${TAG} DAG stuck cascade error: ${err}`));
|
|
648
|
+
}
|
|
649
|
+
|
|
555
650
|
return;
|
|
556
651
|
}
|
|
557
652
|
|
|
@@ -575,10 +670,11 @@ async function handleAuditFail(
|
|
|
575
670
|
const gapsList = verdict.gaps.map((g) => `- ${g}`).join("\n");
|
|
576
671
|
await linearApi.createComment(
|
|
577
672
|
dispatch.issueId,
|
|
578
|
-
`##
|
|
673
|
+
`## Needs More Work\n\nThe audit found gaps. **Retrying now** — the worker gets the feedback below as context.\n\n**Attempt ${nextAttempt} of ${maxAttempts + 1}** — ${maxAttempts + 1 - nextAttempt > 0 ? `${maxAttempts + 1 - nextAttempt} more ${maxAttempts + 1 - nextAttempt === 1 ? "retry" : "retries"} if this fails too` : "this is the last attempt"}.\n\n**What needs fixing:**\n${gapsList}\n\n**Test results:** ${verdict.testResults || "N/A"}\n\n**Status:** Worker is restarting with the gaps above as context. No action needed unless all retries fail.`,
|
|
579
674
|
).catch((err) => api.logger.error(`${TAG} failed to post rework comment: ${err}`));
|
|
580
675
|
|
|
581
676
|
api.logger.info(`${TAG} audit FAILED — rework attempt ${nextAttempt}/${maxAttempts + 1}`);
|
|
677
|
+
emitDiagnostic(api, { event: "phase_transition", identifier: dispatch.issueIdentifier, from: "auditing", to: "working", attempt: nextAttempt });
|
|
582
678
|
|
|
583
679
|
await notify("audit_fail", {
|
|
584
680
|
identifier: dispatch.issueIdentifier,
|
|
@@ -643,7 +739,12 @@ export async function spawnWorker(
|
|
|
643
739
|
};
|
|
644
740
|
|
|
645
741
|
// Build worker prompt from YAML templates
|
|
646
|
-
|
|
742
|
+
// For multi-repo dispatches, render worktreePath as a list of repo→path mappings
|
|
743
|
+
const effectiveWorkerPath = dispatch.worktrees
|
|
744
|
+
? dispatch.worktrees.map(w => `${w.repoName}: ${w.path}`).join("\n")
|
|
745
|
+
: dispatch.worktreePath;
|
|
746
|
+
|
|
747
|
+
const workerPrompt = buildWorkerTask(issue, effectiveWorkerPath, {
|
|
647
748
|
attempt: dispatch.attempt,
|
|
648
749
|
gaps: opts?.gaps,
|
|
649
750
|
pluginConfig,
|
|
@@ -698,6 +799,7 @@ export async function spawnWorker(
|
|
|
698
799
|
const thresholdSec = Math.round(wdConfig.inactivityMs / 1000);
|
|
699
800
|
|
|
700
801
|
api.logger.warn(`${TAG} worker killed by inactivity watchdog 2x — escalating to stuck`);
|
|
802
|
+
emitDiagnostic(api, { event: "watchdog_kill", identifier: dispatch.issueIdentifier, attempt: dispatch.attempt });
|
|
701
803
|
|
|
702
804
|
try {
|
|
703
805
|
appendLog(dispatch.worktreePath, {
|
|
@@ -724,8 +826,9 @@ export async function spawnWorker(
|
|
|
724
826
|
|
|
725
827
|
await linearApi.createComment(
|
|
726
828
|
dispatch.issueId,
|
|
727
|
-
`##
|
|
728
|
-
|
|
829
|
+
`## Agent Timed Out\n\nThe agent stopped responding for over ${thresholdSec}s. It was automatically restarted, but the retry also timed out.\n\n` +
|
|
830
|
+
`**What you can do:**\n1. **Try again** — re-assign this issue or \`/dispatch retry ${dispatch.issueIdentifier}\`\n2. **Break it down** — if it keeps timing out, split into smaller issues\n3. **Increase timeout** — set \`inactivitySec\` higher in your agent profile\n\n` +
|
|
831
|
+
`**Logs:** \`${dispatch.worktreePath}/.claw/log.jsonl\` (look for \`"phase": "watchdog"\`)\n\n**Current status:** Stuck — waiting for you.`,
|
|
729
832
|
).catch(() => {});
|
|
730
833
|
|
|
731
834
|
await hookCtx.notify("watchdog_kill", {
|
package/src/pipeline/planner.ts
CHANGED
|
@@ -6,11 +6,8 @@
|
|
|
6
6
|
* - handlePlannerTurn: processes each user comment during planning
|
|
7
7
|
* - runPlanAudit: validates the plan before finalizing
|
|
8
8
|
*/
|
|
9
|
-
import { readFileSync } from "node:fs";
|
|
10
|
-
import { join, dirname } from "node:path";
|
|
11
|
-
import { fileURLToPath } from "node:url";
|
|
12
|
-
import { parse as parseYaml } from "yaml";
|
|
13
9
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
10
|
+
import { loadRawPromptYaml } from "./pipeline.js";
|
|
14
11
|
import type { LinearAgentApi } from "../api/linear-api.js";
|
|
15
12
|
import { runAgent } from "../agent/agent.js";
|
|
16
13
|
import {
|
|
@@ -56,30 +53,15 @@ function loadPlannerPrompts(pluginConfig?: Record<string, unknown>): PlannerProm
|
|
|
56
53
|
welcome: "Entering planning mode for **{{projectName}}**. What are the main feature areas?",
|
|
57
54
|
};
|
|
58
55
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
} else {
|
|
69
|
-
const pluginRoot = join(dirname(fileURLToPath(import.meta.url)), "../..");
|
|
70
|
-
raw = readFileSync(join(pluginRoot, "prompts.yaml"), "utf-8");
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
const parsed = parseYaml(raw) as any;
|
|
74
|
-
if (parsed?.planner) {
|
|
75
|
-
return {
|
|
76
|
-
system: parsed.planner.system ?? defaults.system,
|
|
77
|
-
interview: parsed.planner.interview ?? defaults.interview,
|
|
78
|
-
audit_prompt: parsed.planner.audit_prompt ?? defaults.audit_prompt,
|
|
79
|
-
welcome: parsed.planner.welcome ?? defaults.welcome,
|
|
80
|
-
};
|
|
81
|
-
}
|
|
82
|
-
} catch { /* use defaults */ }
|
|
56
|
+
const parsed = loadRawPromptYaml(pluginConfig);
|
|
57
|
+
if (parsed?.planner) {
|
|
58
|
+
return {
|
|
59
|
+
system: parsed.planner.system ?? defaults.system,
|
|
60
|
+
interview: parsed.planner.interview ?? defaults.interview,
|
|
61
|
+
audit_prompt: parsed.planner.audit_prompt ?? defaults.audit_prompt,
|
|
62
|
+
welcome: parsed.planner.welcome ?? defaults.welcome,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
83
65
|
|
|
84
66
|
return defaults;
|
|
85
67
|
}
|
|
@@ -142,20 +124,21 @@ export async function initiatePlanningSession(
|
|
|
142
124
|
// Interview turn
|
|
143
125
|
// ---------------------------------------------------------------------------
|
|
144
126
|
|
|
145
|
-
const FINALIZE_PATTERN = /\b(finalize\s+plan|
|
|
146
|
-
const ABANDON_PATTERN = /\b(abandon
|
|
127
|
+
const FINALIZE_PATTERN = /\b(finalize\s+(the\s+)?plan\b|done\s+planning\b(?!\s+\w)|approve\s+(the\s+)?plan\b|plan\s+looks\s+good\b|ready\s+to\s+finalize\b|let'?s\s+finalize\b)/i;
|
|
128
|
+
const ABANDON_PATTERN = /\b(abandon\s+plan(ning)?|cancel\s+plan(ning)?|stop\s+planning|exit\s+planning|quit\s+planning)\b/i;
|
|
147
129
|
|
|
148
130
|
export async function handlePlannerTurn(
|
|
149
131
|
ctx: PlannerContext,
|
|
150
132
|
session: PlanningSession,
|
|
151
133
|
input: { issueId: string; commentBody: string; commentorName: string },
|
|
134
|
+
opts?: { onApproved?: (projectId: string) => void },
|
|
152
135
|
): Promise<void> {
|
|
153
136
|
const { api, linearApi, pluginConfig } = ctx;
|
|
154
137
|
const configPath = pluginConfig?.planningStatePath as string | undefined;
|
|
155
138
|
|
|
156
139
|
// Detect finalization intent
|
|
157
140
|
if (FINALIZE_PATTERN.test(input.commentBody)) {
|
|
158
|
-
await runPlanAudit(ctx, session);
|
|
141
|
+
await runPlanAudit(ctx, session, { onApproved: opts?.onApproved });
|
|
159
142
|
return;
|
|
160
143
|
}
|
|
161
144
|
|
|
@@ -234,6 +217,7 @@ export async function handlePlannerTurn(
|
|
|
234
217
|
export async function runPlanAudit(
|
|
235
218
|
ctx: PlannerContext,
|
|
236
219
|
session: PlanningSession,
|
|
220
|
+
opts?: { onApproved?: (projectId: string) => void },
|
|
237
221
|
): Promise<void> {
|
|
238
222
|
const { api, linearApi, pluginConfig } = ctx;
|
|
239
223
|
const configPath = pluginConfig?.planningStatePath as string | undefined;
|
|
@@ -262,6 +246,9 @@ export async function runPlanAudit(
|
|
|
262
246
|
|
|
263
247
|
await endPlanningSession(session.projectId, "approved", configPath);
|
|
264
248
|
api.logger.info(`Planning: session approved for ${session.projectName}`);
|
|
249
|
+
|
|
250
|
+
// Trigger DAG-based dispatch if callback provided
|
|
251
|
+
opts?.onApproved?.(session.projectId);
|
|
265
252
|
} else {
|
|
266
253
|
// Post problems and keep planning
|
|
267
254
|
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
|