@calltelemetry/openclaw-linear 0.7.1 → 0.8.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/README.md +834 -536
- package/index.ts +1 -1
- package/openclaw.plugin.json +3 -2
- package/package.json +1 -1
- package/prompts.yaml +46 -6
- 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 +192 -0
- package/src/agent/agent.ts +26 -1
- package/src/api/linear-api.test.ts +93 -1
- package/src/api/linear-api.ts +37 -1
- package/src/gateway/dispatch-methods.test.ts +409 -0
- package/src/infra/cli.ts +176 -1
- package/src/infra/commands.test.ts +276 -0
- package/src/infra/doctor.test.ts +19 -0
- package/src/infra/doctor.ts +30 -25
- package/src/infra/multi-repo.test.ts +163 -0
- package/src/infra/multi-repo.ts +29 -0
- package/src/infra/notify.test.ts +155 -16
- package/src/infra/notify.ts +26 -15
- package/src/infra/observability.test.ts +85 -0
- package/src/pipeline/artifacts.test.ts +26 -3
- package/src/pipeline/dispatch-state.ts +1 -0
- package/src/pipeline/e2e-dispatch.test.ts +584 -0
- package/src/pipeline/e2e-planning.test.ts +478 -0
- package/src/pipeline/intent-classify.test.ts +285 -0
- package/src/pipeline/intent-classify.ts +259 -0
- package/src/pipeline/pipeline.test.ts +69 -0
- package/src/pipeline/pipeline.ts +47 -18
- package/src/pipeline/planner.test.ts +159 -40
- package/src/pipeline/planner.ts +108 -60
- package/src/pipeline/tier-assess.test.ts +89 -0
- package/src/pipeline/webhook.ts +424 -251
- package/src/tools/claude-tool.ts +6 -0
- package/src/tools/cli-shared.test.ts +155 -0
- package/src/tools/code-tool.test.ts +210 -0
- package/src/tools/code-tool.ts +2 -2
- package/src/tools/dispatch-history-tool.test.ts +315 -0
- package/src/tools/planner-tools.test.ts +1 -1
- package/src/tools/planner-tools.ts +10 -2
package/src/pipeline/webhook.ts
CHANGED
|
@@ -8,12 +8,14 @@ import { setActiveSession, clearActiveSession } from "./active-session.js";
|
|
|
8
8
|
import { readDispatchState, getActiveDispatch, registerDispatch, updateDispatchStatus, completeDispatch, removeActiveDispatch } from "./dispatch-state.js";
|
|
9
9
|
import { createNotifierFromConfig, type NotifyFn } from "../infra/notify.js";
|
|
10
10
|
import { assessTier } from "./tier-assess.js";
|
|
11
|
-
import { createWorktree, prepareWorkspace } from "../infra/codex-worktree.js";
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
11
|
+
import { createWorktree, createMultiWorktree, prepareWorkspace } from "../infra/codex-worktree.js";
|
|
12
|
+
import { resolveRepos, isMultiRepo } from "../infra/multi-repo.js";
|
|
13
|
+
import { ensureClawDir, writeManifest, writeDispatchMemory, resolveOrchestratorWorkspace } from "./artifacts.js";
|
|
14
|
+
import { readPlanningState, isInPlanningMode, getPlanningSession, endPlanningSession } from "./planning-state.js";
|
|
15
|
+
import { initiatePlanningSession, handlePlannerTurn, runPlanAudit } from "./planner.js";
|
|
15
16
|
import { startProjectDispatch } from "./dag-dispatch.js";
|
|
16
17
|
import { emitDiagnostic } from "../infra/observability.js";
|
|
18
|
+
import { classifyIntent } from "./intent-classify.js";
|
|
17
19
|
|
|
18
20
|
// ── Agent profiles (loaded from config, no hardcoded names) ───────
|
|
19
21
|
interface AgentProfile {
|
|
@@ -262,7 +264,7 @@ export async function handleLinearWebhook(
|
|
|
262
264
|
|
|
263
265
|
const responseBody = result.success
|
|
264
266
|
? result.output
|
|
265
|
-
: `
|
|
267
|
+
: `Something went wrong while processing this. The system will retry automatically if possible. If this keeps happening, run \`openclaw openclaw-linear doctor\` to check for issues.`;
|
|
266
268
|
|
|
267
269
|
// 5. Post branded comment (fallback to prefix)
|
|
268
270
|
const brandingOpts = avatarUrl
|
|
@@ -438,7 +440,7 @@ export async function handleLinearWebhook(
|
|
|
438
440
|
|
|
439
441
|
const responseBody = result.success
|
|
440
442
|
? result.output
|
|
441
|
-
: `
|
|
443
|
+
: `Something went wrong while processing this. The system will retry automatically if possible. If this keeps happening, run \`openclaw openclaw-linear doctor\` to check for issues.`;
|
|
442
444
|
|
|
443
445
|
// Post as comment
|
|
444
446
|
const avatarUrl = defaultProfile?.[1]?.avatarUrl ?? profiles[agentId]?.avatarUrl;
|
|
@@ -616,7 +618,7 @@ export async function handleLinearWebhook(
|
|
|
616
618
|
|
|
617
619
|
const responseBody = result.success
|
|
618
620
|
? result.output
|
|
619
|
-
: `
|
|
621
|
+
: `Something went wrong while processing this. The system will retry automatically if possible. If this keeps happening, run \`openclaw openclaw-linear doctor\` to check for issues.`;
|
|
620
622
|
|
|
621
623
|
const avatarUrl = defaultProfile?.[1]?.avatarUrl ?? profiles[agentId]?.avatarUrl;
|
|
622
624
|
const brandingOpts = avatarUrl
|
|
@@ -658,7 +660,7 @@ export async function handleLinearWebhook(
|
|
|
658
660
|
return true;
|
|
659
661
|
}
|
|
660
662
|
|
|
661
|
-
// ── Comment.create —
|
|
663
|
+
// ── Comment.create — intent-based routing ────────────────────────
|
|
662
664
|
if (payload.type === "Comment" && payload.action === "create") {
|
|
663
665
|
res.statusCode = 200;
|
|
664
666
|
res.end("ok");
|
|
@@ -667,257 +669,211 @@ export async function handleLinearWebhook(
|
|
|
667
669
|
const commentBody = comment?.body ?? "";
|
|
668
670
|
const commentor = comment?.user?.name ?? "Unknown";
|
|
669
671
|
const issue = comment?.issue ?? payload.issue;
|
|
670
|
-
|
|
671
|
-
// ── Planning mode intercept ──────────────────────────────────
|
|
672
672
|
const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
|
|
673
673
|
|
|
674
|
-
if (issue?.id) {
|
|
675
|
-
const linearApiForPlanning = createLinearApi(api);
|
|
676
|
-
if (linearApiForPlanning) {
|
|
677
|
-
try {
|
|
678
|
-
const enriched = await linearApiForPlanning.getIssueDetails(issue.id);
|
|
679
|
-
const projectId = enriched?.project?.id;
|
|
680
|
-
const planStatePath = pluginConfig?.planningStatePath as string | undefined;
|
|
681
|
-
|
|
682
|
-
if (projectId) {
|
|
683
|
-
const planState = await readPlanningState(planStatePath);
|
|
684
|
-
|
|
685
|
-
// Check if this is a plan initiation request
|
|
686
|
-
const isPlanRequest = /\b(plan|planning)\s+(this\s+)?(project|out)\b/i.test(commentBody);
|
|
687
|
-
if (isPlanRequest && !isInPlanningMode(planState, projectId)) {
|
|
688
|
-
api.logger.info(`Planning: initiation requested on ${issue.identifier ?? issue.id}`);
|
|
689
|
-
void initiatePlanningSession(
|
|
690
|
-
{ api, linearApi: linearApiForPlanning, pluginConfig },
|
|
691
|
-
projectId,
|
|
692
|
-
{ id: issue.id, identifier: enriched.identifier, title: enriched.title, team: enriched.team },
|
|
693
|
-
).catch((err) => api.logger.error(`Planning initiation error: ${err}`));
|
|
694
|
-
return true;
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
// Route to planner if project is in planning mode
|
|
698
|
-
if (isInPlanningMode(planState, projectId)) {
|
|
699
|
-
const session = getPlanningSession(planState, projectId);
|
|
700
|
-
if (session && comment?.id && !wasRecentlyProcessed(`plan-comment:${comment.id}`)) {
|
|
701
|
-
api.logger.info(`Planning: routing comment to planner for ${session.projectName}`);
|
|
702
|
-
void handlePlannerTurn(
|
|
703
|
-
{ api, linearApi: linearApiForPlanning, pluginConfig },
|
|
704
|
-
session,
|
|
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
|
-
},
|
|
720
|
-
).catch((err) => api.logger.error(`Planner turn error: ${err}`));
|
|
721
|
-
}
|
|
722
|
-
return true;
|
|
723
|
-
}
|
|
724
|
-
}
|
|
725
|
-
} catch (err) {
|
|
726
|
-
api.logger.warn(`Planning mode check failed: ${err}`);
|
|
727
|
-
}
|
|
728
|
-
}
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
// Load agent profiles and build mention pattern dynamically.
|
|
732
|
-
// Default agent (app mentions) is handled by AgentSessionEvent — never here.
|
|
733
|
-
const profiles = loadAgentProfiles();
|
|
734
|
-
const mentionPattern = buildMentionPattern(profiles);
|
|
735
|
-
if (!mentionPattern) {
|
|
736
|
-
api.logger.info("Comment webhook: no sub-agent profiles configured, ignoring");
|
|
737
|
-
return true;
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
const matches = commentBody.match(mentionPattern);
|
|
741
|
-
if (!matches || matches.length === 0) {
|
|
742
|
-
api.logger.info("Comment webhook: no sub-agent mentions found, ignoring");
|
|
743
|
-
return true;
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
const alias = matches[0].replace("@", "");
|
|
747
|
-
const resolved = resolveAgentFromAlias(alias, profiles);
|
|
748
|
-
if (!resolved) {
|
|
749
|
-
api.logger.info(`Comment webhook: alias "${alias}" not found in profiles, ignoring`);
|
|
750
|
-
return true;
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
const mentionedAgent = resolved.agentId;
|
|
754
|
-
|
|
755
674
|
if (!issue?.id) {
|
|
756
675
|
api.logger.error("Comment webhook: missing issue data");
|
|
757
676
|
return true;
|
|
758
677
|
}
|
|
759
678
|
|
|
760
|
-
|
|
761
|
-
if (!linearApi) {
|
|
762
|
-
api.logger.error("No Linear access token — cannot process comment mention");
|
|
763
|
-
return true;
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
// Dedup on comment ID — prevent processing same comment twice
|
|
679
|
+
// Dedup on comment ID
|
|
767
680
|
if (comment?.id && wasRecentlyProcessed(`comment:${comment.id}`)) {
|
|
768
681
|
api.logger.info(`Comment ${comment.id} already processed — skipping`);
|
|
769
682
|
return true;
|
|
770
683
|
}
|
|
771
684
|
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
// (prevents dual-dispatch when both Comment.create and AgentSessionEvent fire)
|
|
776
|
-
if (activeRuns.has(issue.id)) {
|
|
777
|
-
api.logger.info(`Comment mention: agent already running for ${issue.identifier ?? issue.id} — skipping`);
|
|
685
|
+
const linearApi = createLinearApi(api);
|
|
686
|
+
if (!linearApi) {
|
|
687
|
+
api.logger.error("No Linear access token — cannot process comment");
|
|
778
688
|
return true;
|
|
779
689
|
}
|
|
780
|
-
activeRuns.add(issue.id);
|
|
781
690
|
|
|
782
|
-
//
|
|
783
|
-
|
|
784
|
-
linearApi.
|
|
691
|
+
// Skip bot's own comments
|
|
692
|
+
try {
|
|
693
|
+
const viewerId = await linearApi.getViewerId();
|
|
694
|
+
if (viewerId && comment?.user?.id === viewerId) {
|
|
695
|
+
api.logger.info(`Comment webhook: skipping our own comment on ${issue.identifier ?? issue.id}`);
|
|
696
|
+
return true;
|
|
697
|
+
}
|
|
698
|
+
} catch { /* proceed if viewerId check fails */ }
|
|
699
|
+
|
|
700
|
+
// Load agent profiles
|
|
701
|
+
const profiles = loadAgentProfiles();
|
|
702
|
+
const agentNames = Object.keys(profiles);
|
|
703
|
+
|
|
704
|
+
// ── @mention fast path — skip classifier ────────────────────
|
|
705
|
+
const mentionPattern = buildMentionPattern(profiles);
|
|
706
|
+
const mentionMatches = mentionPattern ? commentBody.match(mentionPattern) : null;
|
|
707
|
+
if (mentionMatches && mentionMatches.length > 0) {
|
|
708
|
+
const alias = mentionMatches[0].replace("@", "");
|
|
709
|
+
const resolved = resolveAgentFromAlias(alias, profiles);
|
|
710
|
+
if (resolved) {
|
|
711
|
+
api.logger.info(`Comment @mention fast path: @${resolved.agentId} on ${issue.identifier ?? issue.id}`);
|
|
712
|
+
void dispatchCommentToAgent(api, linearApi, profiles, resolved.agentId, issue, comment, commentBody, commentor, pluginConfig)
|
|
713
|
+
.catch((err) => api.logger.error(`Comment dispatch error: ${err}`));
|
|
714
|
+
return true;
|
|
715
|
+
}
|
|
785
716
|
}
|
|
786
717
|
|
|
787
|
-
//
|
|
788
|
-
|
|
789
|
-
let
|
|
718
|
+
// ── Intent classification ─────────────────────────────────────
|
|
719
|
+
// Fetch issue details for context
|
|
720
|
+
let enrichedIssue: any = issue;
|
|
790
721
|
try {
|
|
791
|
-
|
|
792
|
-
enrichedIssue = { ...issue, ...full };
|
|
793
|
-
// Include last few comments for context (excluding the triggering comment)
|
|
794
|
-
const comments = full.comments?.nodes ?? [];
|
|
795
|
-
const relevant = comments
|
|
796
|
-
.filter((c: any) => c.body !== commentBody)
|
|
797
|
-
.slice(-3)
|
|
798
|
-
.map((c: any) => ` - **${c.user?.name ?? "Unknown"}**: ${c.body.slice(0, 200)}`)
|
|
799
|
-
.join("\n");
|
|
800
|
-
if (relevant) recentComments = `\n**Recent Comments:**\n${relevant}\n`;
|
|
722
|
+
enrichedIssue = await linearApi.getIssueDetails(issue.id);
|
|
801
723
|
} catch (err) {
|
|
802
724
|
api.logger.warn(`Could not fetch issue details: ${err}`);
|
|
803
725
|
}
|
|
804
726
|
|
|
805
|
-
const
|
|
806
|
-
const
|
|
807
|
-
const state = enrichedIssue.state?.name ?? "Unknown";
|
|
808
|
-
const assignee = enrichedIssue.assignee?.name ?? "Unassigned";
|
|
809
|
-
|
|
810
|
-
const taskMessage = [
|
|
811
|
-
`You are an orchestrator responding to a Linear issue comment. Your text output will be automatically posted as a comment on the issue (do NOT post a comment yourself — the handler does it).`,
|
|
812
|
-
``,
|
|
813
|
-
`**Tool access:**`,
|
|
814
|
-
`- \`linearis\` CLI: READ ONLY. You can read issues (\`linearis issues read ${enrichedIssue.identifier ?? "API-XXX"}\`), list issues (\`linearis issues list\`), and search (\`linearis issues search "..."\`). Do NOT use linearis to update, close, comment, or modify issues.`,
|
|
815
|
-
`- \`code_run\`: Dispatch coding work to a worker. Workers return text — they cannot access linearis.`,
|
|
816
|
-
`- Standard tools: exec, read, edit, write, web_search, etc.`,
|
|
817
|
-
``,
|
|
818
|
-
`**Your role:** You are the dispatcher. For any coding or implementation work, use \`code_run\` to dispatch it. Workers return text output. You summarize results. You do NOT update issue status or post linearis comments — the audit system handles lifecycle transitions.`,
|
|
819
|
-
``,
|
|
820
|
-
`**Issue:** ${enrichedIssue.identifier ?? enrichedIssue.id} — ${enrichedIssue.title ?? "(untitled)"}`,
|
|
821
|
-
`**Status:** ${state} | **Priority:** ${priority} | **Assignee:** ${assignee} | **Labels:** ${labels}`,
|
|
822
|
-
`**URL:** ${enrichedIssue.url ?? "N/A"}`,
|
|
823
|
-
``,
|
|
824
|
-
enrichedIssue.description ? `**Description:**\n${enrichedIssue.description}\n` : "",
|
|
825
|
-
recentComments,
|
|
826
|
-
`**${commentor} wrote:**`,
|
|
827
|
-
`> ${commentBody}`,
|
|
828
|
-
``,
|
|
829
|
-
`Respond to their message. Be concise and direct. For work requests, dispatch via \`code_run\` and summarize the result.`,
|
|
830
|
-
].filter(Boolean).join("\n");
|
|
831
|
-
|
|
832
|
-
// Dispatch to agent with full session lifecycle (non-blocking)
|
|
833
|
-
void (async () => {
|
|
834
|
-
const label = resolved.label;
|
|
835
|
-
const profile = profiles[mentionedAgent];
|
|
836
|
-
let agentSessionId: string | null = null;
|
|
727
|
+
const projectId = enrichedIssue?.project?.id;
|
|
728
|
+
const planStatePath = pluginConfig?.planningStatePath as string | undefined;
|
|
837
729
|
|
|
730
|
+
// Determine planning state
|
|
731
|
+
let isPlanning = false;
|
|
732
|
+
let planSession: any = null;
|
|
733
|
+
if (projectId) {
|
|
838
734
|
try {
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
api.logger.info(`Created agent session ${agentSessionId} for @${mentionedAgent}`);
|
|
844
|
-
// Register active session so code_run can resolve it automatically
|
|
845
|
-
setActiveSession({
|
|
846
|
-
agentSessionId,
|
|
847
|
-
issueIdentifier: enrichedIssue.identifier ?? issue.id,
|
|
848
|
-
issueId: issue.id,
|
|
849
|
-
agentId: mentionedAgent,
|
|
850
|
-
startedAt: Date.now(),
|
|
851
|
-
});
|
|
852
|
-
} else {
|
|
853
|
-
api.logger.warn(`Could not create agent session for @${mentionedAgent}: ${sessionResult.error ?? "unknown"} — falling back to flat comment`);
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
// 2. Emit thought
|
|
857
|
-
if (agentSessionId) {
|
|
858
|
-
await linearApi.emitActivity(agentSessionId, {
|
|
859
|
-
type: "thought",
|
|
860
|
-
body: `Reviewing ${enrichedIssue.identifier ?? issue.id}...`,
|
|
861
|
-
}).catch(() => {});
|
|
735
|
+
const planState = await readPlanningState(planStatePath);
|
|
736
|
+
isPlanning = isInPlanningMode(planState, projectId);
|
|
737
|
+
if (isPlanning) {
|
|
738
|
+
planSession = getPlanningSession(planState, projectId);
|
|
862
739
|
}
|
|
740
|
+
} catch { /* proceed without planning context */ }
|
|
741
|
+
}
|
|
863
742
|
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
timeoutMs: 3 * 60_000,
|
|
873
|
-
streaming: agentSessionId ? { linearApi, agentSessionId } : undefined,
|
|
874
|
-
});
|
|
743
|
+
const intentResult = await classifyIntent(api, {
|
|
744
|
+
commentBody,
|
|
745
|
+
issueTitle: enrichedIssue?.title ?? "(untitled)",
|
|
746
|
+
issueStatus: enrichedIssue?.state?.name,
|
|
747
|
+
isPlanning,
|
|
748
|
+
agentNames,
|
|
749
|
+
hasProject: !!projectId,
|
|
750
|
+
}, pluginConfig);
|
|
875
751
|
|
|
876
|
-
|
|
877
|
-
? result.output
|
|
878
|
-
: `I encountered an error processing this request. Please try again or check the logs.`;
|
|
752
|
+
api.logger.info(`Comment intent: ${intentResult.intent}${intentResult.agentId ? ` (agent: ${intentResult.agentId})` : ""} — ${intentResult.reasoning} (fallback: ${intentResult.fromFallback})`);
|
|
879
753
|
|
|
880
|
-
|
|
881
|
-
const brandingOpts = profile?.avatarUrl
|
|
882
|
-
? { createAsUser: label, displayIconUrl: profile.avatarUrl }
|
|
883
|
-
: undefined;
|
|
754
|
+
// ── Route by intent ────────────────────────────────────────────
|
|
884
755
|
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
756
|
+
switch (intentResult.intent) {
|
|
757
|
+
case "plan_start": {
|
|
758
|
+
if (!projectId) {
|
|
759
|
+
api.logger.info("Comment intent plan_start but no project — ignoring");
|
|
760
|
+
break;
|
|
761
|
+
}
|
|
762
|
+
if (isPlanning) {
|
|
763
|
+
api.logger.info("Comment intent plan_start but already planning — treating as plan_continue");
|
|
764
|
+
// Fall through to plan_continue
|
|
765
|
+
if (planSession) {
|
|
766
|
+
void handlePlannerTurn(
|
|
767
|
+
{ api, linearApi, pluginConfig },
|
|
768
|
+
planSession,
|
|
769
|
+
{ issueId: issue.id, commentBody, commentorName: commentor },
|
|
770
|
+
).catch((err) => api.logger.error(`Planner turn error: ${err}`));
|
|
890
771
|
}
|
|
891
|
-
|
|
892
|
-
api.logger.warn(`Branded comment failed, falling back to prefix: ${brandErr}`);
|
|
893
|
-
await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
|
|
772
|
+
break;
|
|
894
773
|
}
|
|
774
|
+
api.logger.info(`Planning: initiation requested on ${issue.identifier ?? issue.id}`);
|
|
775
|
+
void initiatePlanningSession(
|
|
776
|
+
{ api, linearApi, pluginConfig },
|
|
777
|
+
projectId,
|
|
778
|
+
{ id: issue.id, identifier: enrichedIssue.identifier, title: enrichedIssue.title, team: enrichedIssue.team },
|
|
779
|
+
).catch((err) => api.logger.error(`Planning initiation error: ${err}`));
|
|
780
|
+
break;
|
|
781
|
+
}
|
|
895
782
|
|
|
896
|
-
|
|
897
|
-
if (
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
: responseBody;
|
|
901
|
-
await linearApi.emitActivity(agentSessionId, {
|
|
902
|
-
type: "response",
|
|
903
|
-
body: truncated,
|
|
904
|
-
}).catch(() => {});
|
|
783
|
+
case "plan_finalize": {
|
|
784
|
+
if (!isPlanning || !planSession) {
|
|
785
|
+
api.logger.info("Comment intent plan_finalize but not in planning mode — ignoring");
|
|
786
|
+
break;
|
|
905
787
|
}
|
|
788
|
+
if (planSession.status === "plan_review") {
|
|
789
|
+
// Already passed audit + cross-model review — approve directly
|
|
790
|
+
api.logger.info(`Planning: approving plan for ${planSession.projectName} (from plan_review)`);
|
|
791
|
+
void (async () => {
|
|
792
|
+
try {
|
|
793
|
+
await endPlanningSession(planSession.projectId, "approved", planStatePath);
|
|
794
|
+
await linearApi.createComment(
|
|
795
|
+
planSession.rootIssueId,
|
|
796
|
+
`## Plan Approved\n\nPlan for **${planSession.projectName}** has been approved. Dispatching to workers.`,
|
|
797
|
+
);
|
|
798
|
+
// Trigger DAG dispatch
|
|
799
|
+
const notify = createNotifierFromConfig(pluginConfig, api.runtime, api);
|
|
800
|
+
const hookCtx: HookContext = {
|
|
801
|
+
api, linearApi, notify, pluginConfig,
|
|
802
|
+
configPath: pluginConfig?.dispatchStatePath as string | undefined,
|
|
803
|
+
};
|
|
804
|
+
await startProjectDispatch(hookCtx, planSession.projectId);
|
|
805
|
+
} catch (err) {
|
|
806
|
+
api.logger.error(`Plan approval error: ${err}`);
|
|
807
|
+
}
|
|
808
|
+
})();
|
|
809
|
+
} else {
|
|
810
|
+
// Still interviewing — run audit (which transitions to plan_review)
|
|
811
|
+
void runPlanAudit(
|
|
812
|
+
{ api, linearApi, pluginConfig },
|
|
813
|
+
planSession,
|
|
814
|
+
).catch((err) => api.logger.error(`Plan audit error: ${err}`));
|
|
815
|
+
}
|
|
816
|
+
break;
|
|
817
|
+
}
|
|
906
818
|
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
await linearApi.emitActivity(agentSessionId, {
|
|
912
|
-
type: "error",
|
|
913
|
-
body: `Failed to process mention: ${String(err).slice(0, 500)}`,
|
|
914
|
-
}).catch(() => {});
|
|
819
|
+
case "plan_abandon": {
|
|
820
|
+
if (!isPlanning || !planSession) {
|
|
821
|
+
api.logger.info("Comment intent plan_abandon but not in planning mode — ignoring");
|
|
822
|
+
break;
|
|
915
823
|
}
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
824
|
+
void (async () => {
|
|
825
|
+
try {
|
|
826
|
+
await endPlanningSession(planSession.projectId, "abandoned", planStatePath);
|
|
827
|
+
await linearApi.createComment(
|
|
828
|
+
planSession.rootIssueId,
|
|
829
|
+
`Planning mode ended for **${planSession.projectName}**. Session abandoned.`,
|
|
830
|
+
);
|
|
831
|
+
api.logger.info(`Planning: session abandoned for ${planSession.projectName}`);
|
|
832
|
+
} catch (err) {
|
|
833
|
+
api.logger.error(`Plan abandon error: ${err}`);
|
|
834
|
+
}
|
|
835
|
+
})();
|
|
836
|
+
break;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
case "plan_continue": {
|
|
840
|
+
if (!isPlanning || !planSession) {
|
|
841
|
+
// Not in planning mode — treat as general
|
|
842
|
+
api.logger.info("Comment intent plan_continue but not in planning mode — dispatching to default agent");
|
|
843
|
+
void dispatchCommentToAgent(api, linearApi, profiles, resolveAgentId(api), issue, comment, commentBody, commentor, pluginConfig)
|
|
844
|
+
.catch((err) => api.logger.error(`Comment dispatch error: ${err}`));
|
|
845
|
+
break;
|
|
846
|
+
}
|
|
847
|
+
void handlePlannerTurn(
|
|
848
|
+
{ api, linearApi, pluginConfig },
|
|
849
|
+
planSession,
|
|
850
|
+
{ issueId: issue.id, commentBody, commentorName: commentor },
|
|
851
|
+
).catch((err) => api.logger.error(`Planner turn error: ${err}`));
|
|
852
|
+
break;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
case "ask_agent": {
|
|
856
|
+
const targetAgent = intentResult.agentId ?? resolveAgentId(api);
|
|
857
|
+
api.logger.info(`Comment intent ask_agent: routing to ${targetAgent}`);
|
|
858
|
+
void dispatchCommentToAgent(api, linearApi, profiles, targetAgent, issue, comment, commentBody, commentor, pluginConfig)
|
|
859
|
+
.catch((err) => api.logger.error(`Comment dispatch error: ${err}`));
|
|
860
|
+
break;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
case "request_work":
|
|
864
|
+
case "question": {
|
|
865
|
+
const defaultAgent = resolveAgentId(api);
|
|
866
|
+
api.logger.info(`Comment intent ${intentResult.intent}: routing to default agent ${defaultAgent}`);
|
|
867
|
+
void dispatchCommentToAgent(api, linearApi, profiles, defaultAgent, issue, comment, commentBody, commentor, pluginConfig)
|
|
868
|
+
.catch((err) => api.logger.error(`Comment dispatch error: ${err}`));
|
|
869
|
+
break;
|
|
919
870
|
}
|
|
920
|
-
|
|
871
|
+
|
|
872
|
+
case "general":
|
|
873
|
+
default:
|
|
874
|
+
api.logger.info(`Comment intent general: no action taken for ${issue.identifier ?? issue.id}`);
|
|
875
|
+
break;
|
|
876
|
+
}
|
|
921
877
|
|
|
922
878
|
return true;
|
|
923
879
|
}
|
|
@@ -1000,6 +956,7 @@ export async function handleLinearWebhook(
|
|
|
1000
956
|
|
|
1001
957
|
api.logger.info(`Issue.create: ${issue.identifier ?? issue.id} — ${issue.title ?? "(untitled)"}`);
|
|
1002
958
|
|
|
959
|
+
const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
|
|
1003
960
|
const linearApi = createLinearApi(api);
|
|
1004
961
|
if (!linearApi) {
|
|
1005
962
|
api.logger.error("No Linear access token — cannot triage new issue");
|
|
@@ -1029,6 +986,27 @@ export async function handleLinearWebhook(
|
|
|
1029
986
|
api.logger.warn(`Could not fetch issue details for triage: ${err}`);
|
|
1030
987
|
}
|
|
1031
988
|
|
|
989
|
+
// Skip triage for issues in projects that are actively being planned —
|
|
990
|
+
// the planner creates issues and triage would overwrite its estimates/labels.
|
|
991
|
+
const triageProjectId = enrichedIssue?.project?.id;
|
|
992
|
+
if (triageProjectId) {
|
|
993
|
+
const planStatePath = pluginConfig?.planningStatePath as string | undefined;
|
|
994
|
+
try {
|
|
995
|
+
const planState = await readPlanningState(planStatePath);
|
|
996
|
+
if (isInPlanningMode(planState, triageProjectId)) {
|
|
997
|
+
api.logger.info(`Issue.create: ${issue.identifier ?? issue.id} belongs to project in planning mode — skipping triage`);
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
} catch { /* proceed with triage if planning state check fails */ }
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
// Skip triage for issues created by our own bot user
|
|
1004
|
+
const viewerId = await linearApi.getViewerId();
|
|
1005
|
+
if (viewerId && issue.creatorId === viewerId) {
|
|
1006
|
+
api.logger.info(`Issue.create: ${issue.identifier ?? issue.id} created by our bot — skipping triage`);
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1032
1010
|
const description = enrichedIssue?.description ?? issue?.description ?? "(no description)";
|
|
1033
1011
|
const estimationType = enrichedIssue?.team?.issueEstimationType ?? "fibonacci";
|
|
1034
1012
|
const currentLabels = enrichedIssue?.labels?.nodes ?? [];
|
|
@@ -1113,7 +1091,7 @@ export async function handleLinearWebhook(
|
|
|
1113
1091
|
|
|
1114
1092
|
const responseBody = result.success
|
|
1115
1093
|
? result.output
|
|
1116
|
-
: `
|
|
1094
|
+
: `Something went wrong while triaging this issue. You may need to set the estimate and labels manually.`;
|
|
1117
1095
|
|
|
1118
1096
|
// Parse triage JSON and apply to issue
|
|
1119
1097
|
let commentBody = responseBody;
|
|
@@ -1207,6 +1185,154 @@ export async function handleLinearWebhook(
|
|
|
1207
1185
|
return true;
|
|
1208
1186
|
}
|
|
1209
1187
|
|
|
1188
|
+
// ── Comment dispatch helper ───────────────────────────────────────
|
|
1189
|
+
//
|
|
1190
|
+
// Dispatches a comment to a specific agent. Used by intent-based routing
|
|
1191
|
+
// and @mention fast path.
|
|
1192
|
+
|
|
1193
|
+
async function dispatchCommentToAgent(
|
|
1194
|
+
api: OpenClawPluginApi,
|
|
1195
|
+
linearApi: LinearAgentApi,
|
|
1196
|
+
profiles: Record<string, AgentProfile>,
|
|
1197
|
+
agentId: string,
|
|
1198
|
+
issue: any,
|
|
1199
|
+
comment: any,
|
|
1200
|
+
commentBody: string,
|
|
1201
|
+
commentor: string,
|
|
1202
|
+
pluginConfig?: Record<string, unknown>,
|
|
1203
|
+
): Promise<void> {
|
|
1204
|
+
const profile = profiles[agentId];
|
|
1205
|
+
const label = profile?.label ?? agentId;
|
|
1206
|
+
const avatarUrl = profile?.avatarUrl;
|
|
1207
|
+
|
|
1208
|
+
// Guard: prevent concurrent runs on same issue
|
|
1209
|
+
if (activeRuns.has(issue.id)) {
|
|
1210
|
+
api.logger.info(`dispatchCommentToAgent: ${issue.identifier ?? issue.id} has active run — skipping`);
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
// Fetch full issue details
|
|
1215
|
+
let enrichedIssue: any = issue;
|
|
1216
|
+
try {
|
|
1217
|
+
enrichedIssue = await linearApi.getIssueDetails(issue.id);
|
|
1218
|
+
} catch (err) {
|
|
1219
|
+
api.logger.warn(`Could not fetch issue details: ${err}`);
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
const description = enrichedIssue?.description ?? issue?.description ?? "(no description)";
|
|
1223
|
+
const comments = enrichedIssue?.comments?.nodes ?? [];
|
|
1224
|
+
const commentSummary = comments
|
|
1225
|
+
.slice(-5)
|
|
1226
|
+
.map((c: any) => `**${c.user?.name ?? "Unknown"}**: ${(c.body ?? "").slice(0, 200)}`)
|
|
1227
|
+
.join("\n");
|
|
1228
|
+
|
|
1229
|
+
const issueRef = enrichedIssue?.identifier ?? issue.identifier ?? issue.id;
|
|
1230
|
+
const message = [
|
|
1231
|
+
`You are an orchestrator responding to a Linear comment. Your text output will be automatically posted as a comment on the issue (do NOT post a comment yourself — the handler does it).`,
|
|
1232
|
+
``,
|
|
1233
|
+
`**Tool access:**`,
|
|
1234
|
+
`- \`linearis\` CLI: READ ONLY. You can read issues (\`linearis issues read ${issueRef}\`), list, and search. Do NOT use linearis to update, close, comment, or modify issues.`,
|
|
1235
|
+
`- \`code_run\`: Dispatch coding work to a worker. Workers return text — they cannot access linearis.`,
|
|
1236
|
+
`- Standard tools: exec, read, edit, write, web_search, etc.`,
|
|
1237
|
+
``,
|
|
1238
|
+
`**Your role:** Dispatcher. For work requests, use \`code_run\`. You do NOT update issue status — the audit system handles lifecycle.`,
|
|
1239
|
+
``,
|
|
1240
|
+
`## Issue: ${issueRef} — ${enrichedIssue?.title ?? issue.title ?? "(untitled)"}`,
|
|
1241
|
+
`**Status:** ${enrichedIssue?.state?.name ?? "Unknown"} | **Assignee:** ${enrichedIssue?.assignee?.name ?? "Unassigned"}`,
|
|
1242
|
+
``,
|
|
1243
|
+
`**Description:**`,
|
|
1244
|
+
description,
|
|
1245
|
+
commentSummary ? `\n**Recent comments:**\n${commentSummary}` : "",
|
|
1246
|
+
`\n**${commentor} says:**\n> ${commentBody}`,
|
|
1247
|
+
``,
|
|
1248
|
+
`Respond concisely. For work requests, dispatch via \`code_run\` and summarize the result.`,
|
|
1249
|
+
].filter(Boolean).join("\n");
|
|
1250
|
+
|
|
1251
|
+
// Dispatch with session lifecycle
|
|
1252
|
+
activeRuns.add(issue.id);
|
|
1253
|
+
let agentSessionId: string | null = null;
|
|
1254
|
+
|
|
1255
|
+
try {
|
|
1256
|
+
// Create agent session (non-fatal)
|
|
1257
|
+
const sessionResult = await linearApi.createSessionOnIssue(issue.id);
|
|
1258
|
+
agentSessionId = sessionResult.sessionId;
|
|
1259
|
+
if (agentSessionId) {
|
|
1260
|
+
wasRecentlyProcessed(`session:${agentSessionId}`);
|
|
1261
|
+
setActiveSession({
|
|
1262
|
+
agentSessionId,
|
|
1263
|
+
issueIdentifier: enrichedIssue?.identifier ?? issue.identifier ?? issue.id,
|
|
1264
|
+
issueId: issue.id,
|
|
1265
|
+
agentId,
|
|
1266
|
+
startedAt: Date.now(),
|
|
1267
|
+
});
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
// Emit thought
|
|
1271
|
+
if (agentSessionId) {
|
|
1272
|
+
await linearApi.emitActivity(agentSessionId, {
|
|
1273
|
+
type: "thought",
|
|
1274
|
+
body: `Processing comment on ${issueRef}...`,
|
|
1275
|
+
}).catch(() => {});
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
// Run agent
|
|
1279
|
+
const sessionId = `linear-comment-${agentId}-${Date.now()}`;
|
|
1280
|
+
const { runAgent } = await import("../agent/agent.js");
|
|
1281
|
+
const result = await runAgent({
|
|
1282
|
+
api,
|
|
1283
|
+
agentId,
|
|
1284
|
+
sessionId,
|
|
1285
|
+
message,
|
|
1286
|
+
timeoutMs: 3 * 60_000,
|
|
1287
|
+
streaming: agentSessionId ? { linearApi, agentSessionId } : undefined,
|
|
1288
|
+
});
|
|
1289
|
+
|
|
1290
|
+
const responseBody = result.success
|
|
1291
|
+
? result.output
|
|
1292
|
+
: `Something went wrong while processing this. The system will retry automatically if possible.`;
|
|
1293
|
+
|
|
1294
|
+
// Post branded comment
|
|
1295
|
+
const brandingOpts = avatarUrl
|
|
1296
|
+
? { createAsUser: label, displayIconUrl: avatarUrl }
|
|
1297
|
+
: undefined;
|
|
1298
|
+
|
|
1299
|
+
try {
|
|
1300
|
+
if (brandingOpts) {
|
|
1301
|
+
await linearApi.createComment(issue.id, responseBody, brandingOpts);
|
|
1302
|
+
} else {
|
|
1303
|
+
await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
|
|
1304
|
+
}
|
|
1305
|
+
} catch (brandErr) {
|
|
1306
|
+
api.logger.warn(`Branded comment failed, falling back to prefix: ${brandErr}`);
|
|
1307
|
+
await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
// Emit response (closes session)
|
|
1311
|
+
if (agentSessionId) {
|
|
1312
|
+
const truncated = responseBody.length > 2000
|
|
1313
|
+
? responseBody.slice(0, 2000) + "…"
|
|
1314
|
+
: responseBody;
|
|
1315
|
+
await linearApi.emitActivity(agentSessionId, {
|
|
1316
|
+
type: "response",
|
|
1317
|
+
body: truncated,
|
|
1318
|
+
}).catch(() => {});
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
api.logger.info(`Posted ${agentId} response to ${issueRef}`);
|
|
1322
|
+
} catch (err) {
|
|
1323
|
+
api.logger.error(`dispatchCommentToAgent error: ${err}`);
|
|
1324
|
+
if (agentSessionId) {
|
|
1325
|
+
await linearApi.emitActivity(agentSessionId, {
|
|
1326
|
+
type: "error",
|
|
1327
|
+
body: `Failed to process comment: ${String(err).slice(0, 500)}`,
|
|
1328
|
+
}).catch(() => {});
|
|
1329
|
+
}
|
|
1330
|
+
} finally {
|
|
1331
|
+
clearActiveSession(issue.id);
|
|
1332
|
+
activeRuns.delete(issue.id);
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1210
1336
|
// ── @dispatch handler ─────────────────────────────────────────────
|
|
1211
1337
|
//
|
|
1212
1338
|
// Triggered by `@dispatch` in a Linear comment. Assesses issue complexity,
|
|
@@ -1237,7 +1363,7 @@ async function handleDispatch(
|
|
|
1237
1363
|
api.logger.info(`dispatch: ${identifier} is in planning-mode project — skipping`);
|
|
1238
1364
|
await linearApi.createComment(
|
|
1239
1365
|
issue.id,
|
|
1240
|
-
|
|
1366
|
+
`**Can't dispatch yet** — this project is in planning mode.\n\n**To continue:** Comment on the planning issue with your requirements, then say **"finalize plan"** when ready.\n\n**To cancel planning:** Comment **"abandon"** on the planning issue.`,
|
|
1241
1367
|
);
|
|
1242
1368
|
return;
|
|
1243
1369
|
}
|
|
@@ -1260,7 +1386,7 @@ async function handleDispatch(
|
|
|
1260
1386
|
api.logger.info(`dispatch: ${identifier} actively running (status: ${existing.status}, age: ${Math.round(ageMs / 1000)}s) — skipping`);
|
|
1261
1387
|
await linearApi.createComment(
|
|
1262
1388
|
issue.id,
|
|
1263
|
-
|
|
1389
|
+
`**Already running** as **${existing.tier}** — status: **${existing.status}**, started ${Math.round(ageMs / 60_000)}m ago.\n\nWorktree: \`${existing.worktreePath}\`\n\n**Options:**\n- Check progress: \`/dispatch status ${identifier}\`\n- Force restart: \`/dispatch retry ${identifier}\` (only works when stuck)\n- Escalate: \`/dispatch escalate ${identifier} "reason"\``,
|
|
1264
1390
|
);
|
|
1265
1391
|
return;
|
|
1266
1392
|
}
|
|
@@ -1292,6 +1418,9 @@ async function handleDispatch(
|
|
|
1292
1418
|
const labels = enrichedIssue.labels?.nodes?.map((l: any) => l.name) ?? [];
|
|
1293
1419
|
const commentCount = enrichedIssue.comments?.nodes?.length ?? 0;
|
|
1294
1420
|
|
|
1421
|
+
// Resolve repos for this dispatch (issue body markers, labels, or config default)
|
|
1422
|
+
const repoResolution = resolveRepos(enrichedIssue.description, labels, pluginConfig);
|
|
1423
|
+
|
|
1295
1424
|
// 4. Assess complexity tier
|
|
1296
1425
|
const assessment = await assessTier(api, {
|
|
1297
1426
|
identifier,
|
|
@@ -1304,28 +1433,51 @@ async function handleDispatch(
|
|
|
1304
1433
|
api.logger.info(`@dispatch: ${identifier} assessed as ${assessment.tier} (${assessment.model}) — ${assessment.reasoning}`);
|
|
1305
1434
|
emitDiagnostic(api, { event: "dispatch_started", identifier, tier: assessment.tier, issueId: issue.id });
|
|
1306
1435
|
|
|
1307
|
-
// 5. Create persistent worktree
|
|
1308
|
-
let
|
|
1436
|
+
// 5. Create persistent worktree(s)
|
|
1437
|
+
let worktreePath: string;
|
|
1438
|
+
let worktreeBranch: string;
|
|
1439
|
+
let worktreeResumed: boolean;
|
|
1440
|
+
let worktrees: Array<{ repoName: string; path: string; branch: string }> | undefined;
|
|
1441
|
+
|
|
1309
1442
|
try {
|
|
1310
|
-
|
|
1311
|
-
|
|
1443
|
+
if (isMultiRepo(repoResolution)) {
|
|
1444
|
+
const multi = createMultiWorktree(identifier, repoResolution.repos, { baseDir: worktreeBaseDir });
|
|
1445
|
+
worktreePath = multi.parentPath;
|
|
1446
|
+
worktreeBranch = `codex/${identifier}`;
|
|
1447
|
+
worktreeResumed = multi.worktrees.some(w => w.resumed);
|
|
1448
|
+
worktrees = multi.worktrees.map(w => ({ repoName: w.repoName, path: w.path, branch: w.branch }));
|
|
1449
|
+
api.logger.info(`@dispatch: multi-repo worktrees ${worktreeResumed ? "resumed" : "created"} at ${worktreePath} (${repoResolution.repos.map(r => r.name).join(", ")})`);
|
|
1450
|
+
} else {
|
|
1451
|
+
const single = createWorktree(identifier, { baseRepo, baseDir: worktreeBaseDir });
|
|
1452
|
+
worktreePath = single.path;
|
|
1453
|
+
worktreeBranch = single.branch;
|
|
1454
|
+
worktreeResumed = single.resumed;
|
|
1455
|
+
api.logger.info(`@dispatch: worktree ${worktreeResumed ? "resumed" : "created"} at ${worktreePath}`);
|
|
1456
|
+
}
|
|
1312
1457
|
} catch (err) {
|
|
1313
1458
|
api.logger.error(`@dispatch: worktree creation failed: ${err}`);
|
|
1314
1459
|
await linearApi.createComment(
|
|
1315
1460
|
issue.id,
|
|
1316
|
-
|
|
1461
|
+
`**Dispatch failed** — couldn't create the worktree.\n\n> ${String(err).slice(0, 200)}\n\n**What to try:**\n- Check that the base repo exists\n- Re-assign this issue to try again\n- Check logs: \`journalctl --user -u openclaw-gateway --since "5 min ago"\``,
|
|
1317
1462
|
);
|
|
1318
1463
|
return;
|
|
1319
1464
|
}
|
|
1320
1465
|
|
|
1321
|
-
// 5b. Prepare workspace
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1466
|
+
// 5b. Prepare workspace(s)
|
|
1467
|
+
if (worktrees) {
|
|
1468
|
+
for (const wt of worktrees) {
|
|
1469
|
+
const prep = prepareWorkspace(wt.path, wt.branch);
|
|
1470
|
+
if (prep.errors.length > 0) {
|
|
1471
|
+
api.logger.warn(`@dispatch: workspace prep for ${wt.repoName} had errors: ${prep.errors.join("; ")}`);
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1325
1474
|
} else {
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1475
|
+
const prep = prepareWorkspace(worktreePath, worktreeBranch);
|
|
1476
|
+
if (prep.errors.length > 0) {
|
|
1477
|
+
api.logger.warn(`@dispatch: workspace prep had errors: ${prep.errors.join("; ")}`);
|
|
1478
|
+
} else {
|
|
1479
|
+
api.logger.info(`@dispatch: workspace prepared — pulled=${prep.pulled}, submodules=${prep.submodulesInitialized}`);
|
|
1480
|
+
}
|
|
1329
1481
|
}
|
|
1330
1482
|
|
|
1331
1483
|
// 6. Create agent session on Linear
|
|
@@ -1339,16 +1491,16 @@ async function handleDispatch(
|
|
|
1339
1491
|
|
|
1340
1492
|
// 6b. Initialize .claw/ artifact directory
|
|
1341
1493
|
try {
|
|
1342
|
-
ensureClawDir(
|
|
1343
|
-
writeManifest(
|
|
1494
|
+
ensureClawDir(worktreePath);
|
|
1495
|
+
writeManifest(worktreePath, {
|
|
1344
1496
|
issueIdentifier: identifier,
|
|
1345
1497
|
issueTitle: enrichedIssue.title ?? "(untitled)",
|
|
1346
1498
|
issueId: issue.id,
|
|
1347
1499
|
tier: assessment.tier,
|
|
1348
1500
|
model: assessment.model,
|
|
1349
1501
|
dispatchedAt: new Date().toISOString(),
|
|
1350
|
-
worktreePath
|
|
1351
|
-
branch:
|
|
1502
|
+
worktreePath,
|
|
1503
|
+
branch: worktreeBranch,
|
|
1352
1504
|
attempts: 0,
|
|
1353
1505
|
status: "dispatched",
|
|
1354
1506
|
plugin: "openclaw-linear",
|
|
@@ -1363,8 +1515,8 @@ async function handleDispatch(
|
|
|
1363
1515
|
issueId: issue.id,
|
|
1364
1516
|
issueIdentifier: identifier,
|
|
1365
1517
|
issueTitle: enrichedIssue.title ?? "(untitled)",
|
|
1366
|
-
worktreePath
|
|
1367
|
-
branch:
|
|
1518
|
+
worktreePath,
|
|
1519
|
+
branch: worktreeBranch,
|
|
1368
1520
|
tier: assessment.tier,
|
|
1369
1521
|
model: assessment.model,
|
|
1370
1522
|
status: "dispatched",
|
|
@@ -1372,6 +1524,7 @@ async function handleDispatch(
|
|
|
1372
1524
|
agentSessionId,
|
|
1373
1525
|
attempt: 0,
|
|
1374
1526
|
project: enrichedIssue?.project?.id,
|
|
1527
|
+
worktrees,
|
|
1375
1528
|
}, statePath);
|
|
1376
1529
|
|
|
1377
1530
|
// 8. Register active session for tool resolution
|
|
@@ -1384,16 +1537,24 @@ async function handleDispatch(
|
|
|
1384
1537
|
});
|
|
1385
1538
|
|
|
1386
1539
|
// 9. Post dispatch confirmation comment
|
|
1387
|
-
const
|
|
1388
|
-
?
|
|
1389
|
-
:
|
|
1540
|
+
const worktreeDesc = worktrees
|
|
1541
|
+
? worktrees.map(wt => `\`${wt.repoName}\`: \`${wt.path}\``).join("\n")
|
|
1542
|
+
: `\`${worktreePath}\``;
|
|
1390
1543
|
const statusComment = [
|
|
1391
1544
|
`**Dispatched** as **${assessment.tier}** (${assessment.model})`,
|
|
1392
1545
|
`> ${assessment.reasoning}`,
|
|
1393
1546
|
``,
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1547
|
+
worktrees
|
|
1548
|
+
? `Worktrees ${worktreeResumed ? "(resumed)" : "(fresh)"}:\n${worktreeDesc}`
|
|
1549
|
+
: `Worktree: ${worktreeDesc} ${worktreeResumed ? "(resumed)" : "(fresh)"}`,
|
|
1550
|
+
`Branch: \`${worktreeBranch}\``,
|
|
1551
|
+
``,
|
|
1552
|
+
`**Status:** Worker is starting now. An independent audit runs automatically after implementation.`,
|
|
1553
|
+
``,
|
|
1554
|
+
`**While you wait:**`,
|
|
1555
|
+
`- Check progress: \`/dispatch status ${identifier}\``,
|
|
1556
|
+
`- Cancel: \`/dispatch escalate ${identifier} "reason"\``,
|
|
1557
|
+
`- All dispatches: \`/dispatch list\``,
|
|
1397
1558
|
].join("\n");
|
|
1398
1559
|
|
|
1399
1560
|
await linearApi.createComment(issue.id, statusComment);
|
|
@@ -1425,7 +1586,7 @@ async function handleDispatch(
|
|
|
1425
1586
|
activeRuns.add(issue.id);
|
|
1426
1587
|
|
|
1427
1588
|
// Instantiate notifier (Discord, Slack, or both — config-driven)
|
|
1428
|
-
const notify: NotifyFn = createNotifierFromConfig(pluginConfig, api.runtime);
|
|
1589
|
+
const notify: NotifyFn = createNotifierFromConfig(pluginConfig, api.runtime, api);
|
|
1429
1590
|
|
|
1430
1591
|
const hookCtx: HookContext = {
|
|
1431
1592
|
api,
|
|
@@ -1450,6 +1611,18 @@ async function handleDispatch(
|
|
|
1450
1611
|
.catch(async (err) => {
|
|
1451
1612
|
api.logger.error(`@dispatch: pipeline v2 failed for ${identifier}: ${err}`);
|
|
1452
1613
|
await updateDispatchStatus(identifier, "failed", statePath);
|
|
1614
|
+
// Write memory for failed dispatches so they're searchable in dispatch history
|
|
1615
|
+
try {
|
|
1616
|
+
const wsDir = resolveOrchestratorWorkspace(api, pluginConfig);
|
|
1617
|
+
writeDispatchMemory(identifier, `Pipeline failed: ${String(err).slice(0, 500)}`, wsDir, {
|
|
1618
|
+
title: enrichedIssue.title ?? identifier,
|
|
1619
|
+
tier: assessment.tier,
|
|
1620
|
+
status: "failed",
|
|
1621
|
+
project: enrichedIssue?.project?.id,
|
|
1622
|
+
attempts: 1,
|
|
1623
|
+
model: assessment.model,
|
|
1624
|
+
});
|
|
1625
|
+
} catch { /* best effort */ }
|
|
1453
1626
|
})
|
|
1454
1627
|
.finally(() => {
|
|
1455
1628
|
activeRuns.delete(issue.id);
|