@calltelemetry/openclaw-linear 0.9.2 → 0.9.3
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 +95 -8
- package/package.json +1 -1
- package/src/pipeline/e2e-dispatch.test.ts +135 -0
- package/src/pipeline/pipeline.test.ts +79 -0
package/README.md
CHANGED
|
@@ -20,22 +20,23 @@ Connect Linear to AI agents. Issues get triaged, implemented, and audited — au
|
|
|
20
20
|
|
|
21
21
|
- [x] Cloudflare tunnel setup (webhook ingress, no inbound ports)
|
|
22
22
|
- [x] Linear webhook sync (Comment + Issue events)
|
|
23
|
+
- [x] Linear OAuth app webhook (AgentSessionEvent created/prompted)
|
|
23
24
|
- [x] Linear API integration (issues, comments, labels, state transitions)
|
|
24
25
|
- [x] Agent routing (`@mentions`, natural language intent classifier)
|
|
25
|
-
- [ ] Linear OAuth app webhook (AgentSessionEvent created/prompted)
|
|
26
26
|
- [x] Auto-triage (story points, labels, priority — read-only)
|
|
27
27
|
- [x] Complexity-tier dispatch (small → Haiku, medium → Sonnet, high → Opus)
|
|
28
28
|
- [x] Isolated git worktrees per dispatch
|
|
29
29
|
- [x] Worker → Auditor pipeline (hard-enforced, not LLM-mediated)
|
|
30
|
-
- [
|
|
31
|
-
- [
|
|
30
|
+
- [x] Audit rework loop (gaps fed back, automatic retry)
|
|
31
|
+
- [x] Watchdog timeout + escalation
|
|
32
32
|
- [x] Webhook deduplication (60s sliding window across session/comment/assignment)
|
|
33
33
|
- [ ] Multi-repo worktree support
|
|
34
34
|
- [ ] Project planner (interview → user stories → sub-issues → DAG dispatch)
|
|
35
35
|
- [ ] Cross-model plan review (Claude ↔ Codex ↔ Gemini)
|
|
36
|
-
- [
|
|
36
|
+
- [x] Issue closure with summary report
|
|
37
37
|
- [ ] Sub-issue decomposition (orchestrator-level only)
|
|
38
38
|
- [x] `spawn_agent` / `ask_agent` sub-agent tools
|
|
39
|
+
- [x] CI + coverage badges (1000+ tests, Codecov integration)
|
|
39
40
|
- [ ] **Worktree → PR merge** — `createPullRequest()` exists but is not wired into the pipeline. After audit pass, commits sit on a `codex/{identifier}` branch. You create the PR manually.
|
|
40
41
|
- [ ] **Sub-agent worktree sharing** — Sub-agents spawned via `spawn_agent`/`ask_agent` do not inherit the parent worktree. They run in their own session without code access.
|
|
41
42
|
- [ ] **Parallel worktree conflict resolution** — DAG dispatch runs up to 3 issues concurrently in separate worktrees, but there's no merge conflict detection across them.
|
|
@@ -548,11 +549,97 @@ flowchart LR
|
|
|
548
549
|
>
|
|
549
550
|
> **Summary:** The search API endpoint was implemented with pagination, input validation, and error handling. All 14 tests pass. The frontend search page renders results correctly.
|
|
550
551
|
|
|
551
|
-
###
|
|
552
|
+
### Watchdog & timeout recovery
|
|
552
553
|
|
|
553
|
-
|
|
554
|
+
Every running agent has an inactivity watchdog. If the agent goes silent — no text, no tool calls, no thinking — the watchdog kills it.
|
|
554
555
|
|
|
555
|
-
|
|
556
|
+
```
|
|
557
|
+
Agent runs ─────────── output ──→ timer resets (120s default)
|
|
558
|
+
output ──→ timer resets
|
|
559
|
+
...
|
|
560
|
+
silence ─→ 120s passes ─→ KILL
|
|
561
|
+
│
|
|
562
|
+
┌────────┴────────┐
|
|
563
|
+
▼ ▼
|
|
564
|
+
Retry (auto) Already retried?
|
|
565
|
+
│ │
|
|
566
|
+
▼ ▼
|
|
567
|
+
Agent runs again STUCK → you're notified
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
**What resets the timer:** any agent output — partial text, tool call start/result, reasoning stream, or error.
|
|
571
|
+
|
|
572
|
+
**What triggers a kill:** LLM hangs, API timeouts, CLI lockups, rate limiting — anything that causes the agent to stop producing output.
|
|
573
|
+
|
|
574
|
+
**After a kill:**
|
|
575
|
+
1. First timeout → automatic retry (new attempt, same worktree)
|
|
576
|
+
2. Second timeout → dispatch transitions to `stuck`, Linear comment posted with remediation steps, you get a notification
|
|
577
|
+
|
|
578
|
+
**The "Agent Timed Out" comment includes:**
|
|
579
|
+
- `/dispatch retry ENG-100` command to try again
|
|
580
|
+
- Suggestion to break the issue into smaller pieces
|
|
581
|
+
- How to increase `inactivitySec` in agent profiles
|
|
582
|
+
- Path to `.claw/log.jsonl` for debugging
|
|
583
|
+
|
|
584
|
+
**Configure per agent** in `~/.openclaw/agent-profiles.json`:
|
|
585
|
+
```json
|
|
586
|
+
{ "agents": { "mal": { "watchdog": { "inactivitySec": 180 } } } }
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
### Audit rework loop
|
|
590
|
+
|
|
591
|
+
When the auditor finds problems, it doesn't just fail — it tells the worker exactly what's wrong, and the worker tries again automatically.
|
|
592
|
+
|
|
593
|
+
```
|
|
594
|
+
Worker implements ──→ Auditor reviews
|
|
595
|
+
│
|
|
596
|
+
┌────┴────┐
|
|
597
|
+
▼ ▼
|
|
598
|
+
PASS FAIL
|
|
599
|
+
│ │
|
|
600
|
+
▼ ▼
|
|
601
|
+
Done Gaps extracted
|
|
602
|
+
│
|
|
603
|
+
▼
|
|
604
|
+
Worker gets gaps as context ──→ "PREVIOUS AUDIT FAILED:
|
|
605
|
+
│ - Missing input validation
|
|
606
|
+
│ - No test for empty query"
|
|
607
|
+
▼
|
|
608
|
+
Rework attempt (same worktree)
|
|
609
|
+
│
|
|
610
|
+
┌────┴────┐
|
|
611
|
+
▼ ▼
|
|
612
|
+
PASS FAIL again?
|
|
613
|
+
│ │
|
|
614
|
+
▼ ▼
|
|
615
|
+
Done Retries left?
|
|
616
|
+
│
|
|
617
|
+
┌────┴────┐
|
|
618
|
+
▼ ▼
|
|
619
|
+
Retry STUCK → you're notified
|
|
620
|
+
```
|
|
621
|
+
|
|
622
|
+
**How gaps flow back:**
|
|
623
|
+
1. Auditor returns a structured verdict: `{ pass: false, gaps: ["missing validation", "no empty query test"], criteria: [...] }`
|
|
624
|
+
2. Pipeline extracts the `gaps` array
|
|
625
|
+
3. Next worker prompt gets a "PREVIOUS AUDIT FAILED" addendum with the gap list
|
|
626
|
+
4. Worker sees exactly what to fix — no guessing
|
|
627
|
+
|
|
628
|
+
**What you control:**
|
|
629
|
+
- `maxReworkAttempts` (default: `2`) — how many audit failures before escalation
|
|
630
|
+
- After max attempts, issue goes to `stuck` with reason `audit_failed_Nx`
|
|
631
|
+
- You get a Linear comment with what went wrong and a notification
|
|
632
|
+
|
|
633
|
+
**What the worker sees on rework:**
|
|
634
|
+
```
|
|
635
|
+
PREVIOUS AUDIT FAILED — fix these gaps before proceeding:
|
|
636
|
+
1. Missing input validation on the search endpoint
|
|
637
|
+
2. No test for empty query string
|
|
638
|
+
|
|
639
|
+
Your previous work is still in the worktree. Fix the issues above and run tests again.
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
**Artifacts per attempt:** Each rework cycle writes `worker-{N}.md` and `audit-{N}.json` to `.claw/`, so you can see what happened at every attempt.
|
|
556
643
|
|
|
557
644
|
### Project-level progress
|
|
558
645
|
|
|
@@ -1488,7 +1575,7 @@ This is separate from the main `doctor` because each live test spawns a real CLI
|
|
|
1488
1575
|
|
|
1489
1576
|
### Unit tests
|
|
1490
1577
|
|
|
1491
|
-
|
|
1578
|
+
1000+ tests covering the full pipeline — triage, dispatch, audit, planning, intent classification, native issue tools, cross-model review, notifications, watchdog, and infrastructure:
|
|
1492
1579
|
|
|
1493
1580
|
```bash
|
|
1494
1581
|
cd ~/claw-extensions/linear
|
package/package.json
CHANGED
|
@@ -581,4 +581,139 @@ describe("E2E dispatch pipeline", () => {
|
|
|
581
581
|
expect(call[0]).toBe("telegram-chat-1");
|
|
582
582
|
}
|
|
583
583
|
});
|
|
584
|
+
|
|
585
|
+
// =========================================================================
|
|
586
|
+
// Test 9: Watchdog kill → stuck with artifact verification
|
|
587
|
+
// =========================================================================
|
|
588
|
+
it("watchdog kill writes correct artifacts (log.jsonl, manifest)", async () => {
|
|
589
|
+
const hookCtx = makeHookCtx();
|
|
590
|
+
const dispatch = makeDispatch(worktree);
|
|
591
|
+
|
|
592
|
+
await registerDispatch(dispatch.issueIdentifier, dispatch, hookCtx.configPath);
|
|
593
|
+
|
|
594
|
+
// Pre-create manifest (as webhook.ts handleDispatch would)
|
|
595
|
+
const { ensureClawDir, writeManifest } = await import("./artifacts.js");
|
|
596
|
+
ensureClawDir(worktree);
|
|
597
|
+
writeManifest(worktree, {
|
|
598
|
+
issueIdentifier: "ENG-100",
|
|
599
|
+
issueId: "issue-1",
|
|
600
|
+
tier: "small",
|
|
601
|
+
status: "dispatched",
|
|
602
|
+
attempts: 0,
|
|
603
|
+
dispatchedAt: new Date().toISOString(),
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
hookCtx.mockLinearApi.getIssueDetails.mockResolvedValue(
|
|
607
|
+
makeIssueDetails({ id: "issue-1", identifier: "ENG-100", title: "Fix auth" }),
|
|
608
|
+
);
|
|
609
|
+
|
|
610
|
+
runAgentMock.mockResolvedValue({
|
|
611
|
+
success: false,
|
|
612
|
+
output: "partial work before timeout",
|
|
613
|
+
watchdogKilled: true,
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
await spawnWorker(hookCtx, dispatch);
|
|
617
|
+
|
|
618
|
+
// State should be stuck
|
|
619
|
+
const state = await readDispatchState(hookCtx.configPath);
|
|
620
|
+
expect(state.dispatches.active["ENG-100"].status).toBe("stuck");
|
|
621
|
+
expect(state.dispatches.active["ENG-100"].stuckReason).toBe("watchdog_kill_2x");
|
|
622
|
+
|
|
623
|
+
// Artifacts should exist
|
|
624
|
+
const clawDir = join(worktree, ".claw");
|
|
625
|
+
expect(existsSync(join(clawDir, "manifest.json"))).toBe(true);
|
|
626
|
+
expect(existsSync(join(clawDir, "log.jsonl"))).toBe(true);
|
|
627
|
+
|
|
628
|
+
// Manifest should reflect stuck status
|
|
629
|
+
const manifest = JSON.parse(readFileSync(join(clawDir, "manifest.json"), "utf8"));
|
|
630
|
+
expect(manifest.status).toBe("stuck");
|
|
631
|
+
expect(manifest.attempts).toBe(1);
|
|
632
|
+
|
|
633
|
+
// Log should contain watchdog phase entry
|
|
634
|
+
const logContent = readFileSync(join(clawDir, "log.jsonl"), "utf8");
|
|
635
|
+
const logLines = logContent.trim().split("\n").map((l) => JSON.parse(l));
|
|
636
|
+
const wdEntry = logLines.find((e) => e.phase === "watchdog");
|
|
637
|
+
expect(wdEntry).toBeDefined();
|
|
638
|
+
expect(wdEntry.success).toBe(false);
|
|
639
|
+
expect(wdEntry.watchdog).toBeDefined();
|
|
640
|
+
expect(wdEntry.watchdog.reason).toBe("inactivity");
|
|
641
|
+
expect(wdEntry.watchdog.retried).toBe(true);
|
|
642
|
+
expect(wdEntry.watchdog.thresholdSec).toBeGreaterThan(0);
|
|
643
|
+
expect(wdEntry.outputPreview).toBe("partial work before timeout");
|
|
644
|
+
|
|
645
|
+
// Should NOT have audit artifacts (no audit on watchdog kill)
|
|
646
|
+
expect(existsSync(join(clawDir, "audit-0.json"))).toBe(false);
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
// =========================================================================
|
|
650
|
+
// Test 10: Watchdog kill comment includes remediation steps
|
|
651
|
+
// =========================================================================
|
|
652
|
+
it("watchdog kill comment includes remediation guidance", async () => {
|
|
653
|
+
const hookCtx = makeHookCtx();
|
|
654
|
+
const dispatch = makeDispatch(worktree);
|
|
655
|
+
|
|
656
|
+
await registerDispatch(dispatch.issueIdentifier, dispatch, hookCtx.configPath);
|
|
657
|
+
|
|
658
|
+
hookCtx.mockLinearApi.getIssueDetails.mockResolvedValue(
|
|
659
|
+
makeIssueDetails({ id: "issue-1", identifier: "ENG-100", title: "Fix auth" }),
|
|
660
|
+
);
|
|
661
|
+
|
|
662
|
+
runAgentMock.mockResolvedValue({
|
|
663
|
+
success: false,
|
|
664
|
+
output: "",
|
|
665
|
+
watchdogKilled: true,
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
await spawnWorker(hookCtx, dispatch);
|
|
669
|
+
|
|
670
|
+
// Comment should include all remediation steps
|
|
671
|
+
const commentCall = hookCtx.mockLinearApi.createComment.mock.calls[0];
|
|
672
|
+
expect(commentCall).toBeDefined();
|
|
673
|
+
const [issueId, comment] = commentCall;
|
|
674
|
+
expect(issueId).toBe("issue-1");
|
|
675
|
+
expect(comment).toContain("Agent Timed Out");
|
|
676
|
+
expect(comment).toContain("Try again");
|
|
677
|
+
expect(comment).toContain("/dispatch retry ENG-100");
|
|
678
|
+
expect(comment).toContain("Break it down");
|
|
679
|
+
expect(comment).toContain("Increase timeout");
|
|
680
|
+
expect(comment).toContain("inactivitySec");
|
|
681
|
+
expect(comment).toContain("log.jsonl");
|
|
682
|
+
expect(comment).toContain("Stuck — waiting for you");
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
// =========================================================================
|
|
686
|
+
// Test 11: Watchdog notification payload shape
|
|
687
|
+
// =========================================================================
|
|
688
|
+
it("watchdog kill sends notification with correct payload", async () => {
|
|
689
|
+
const hookCtx = makeHookCtx();
|
|
690
|
+
const dispatch = makeDispatch(worktree);
|
|
691
|
+
|
|
692
|
+
await registerDispatch(dispatch.issueIdentifier, dispatch, hookCtx.configPath);
|
|
693
|
+
|
|
694
|
+
hookCtx.mockLinearApi.getIssueDetails.mockResolvedValue(
|
|
695
|
+
makeIssueDetails({ id: "issue-1", identifier: "ENG-100", title: "Fix auth" }),
|
|
696
|
+
);
|
|
697
|
+
|
|
698
|
+
runAgentMock.mockResolvedValue({
|
|
699
|
+
success: false,
|
|
700
|
+
output: "",
|
|
701
|
+
watchdogKilled: true,
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
await spawnWorker(hookCtx, dispatch);
|
|
705
|
+
|
|
706
|
+
// Find the watchdog_kill notification
|
|
707
|
+
const wdNotify = hookCtx.notifyCalls.find(([k]) => k === "watchdog_kill");
|
|
708
|
+
expect(wdNotify).toBeDefined();
|
|
709
|
+
const [kind, payload] = wdNotify!;
|
|
710
|
+
expect(kind).toBe("watchdog_kill");
|
|
711
|
+
expect(payload).toMatchObject({
|
|
712
|
+
identifier: "ENG-100",
|
|
713
|
+
title: "Fix auth",
|
|
714
|
+
status: "stuck",
|
|
715
|
+
attempt: 0,
|
|
716
|
+
});
|
|
717
|
+
expect((payload as any).reason).toMatch(/no I\/O for \d+s/);
|
|
718
|
+
});
|
|
584
719
|
});
|
|
@@ -1538,4 +1538,83 @@ describe("spawnWorker (error branch coverage)", () => {
|
|
|
1538
1538
|
);
|
|
1539
1539
|
expect(clearActiveSession).toHaveBeenCalled();
|
|
1540
1540
|
});
|
|
1541
|
+
|
|
1542
|
+
it("watchdog kill writes log entry with correct shape", async () => {
|
|
1543
|
+
(runAgent as any).mockResolvedValue({
|
|
1544
|
+
success: false,
|
|
1545
|
+
output: "partial output before hang",
|
|
1546
|
+
watchdogKilled: true,
|
|
1547
|
+
});
|
|
1548
|
+
const ctx = makeHookCtx();
|
|
1549
|
+
const dispatch = makeDispatch({ status: "working" as any });
|
|
1550
|
+
|
|
1551
|
+
await spawnWorker(ctx, dispatch);
|
|
1552
|
+
|
|
1553
|
+
// appendLog should be called with watchdog phase
|
|
1554
|
+
expect(appendLog).toHaveBeenCalledWith(
|
|
1555
|
+
dispatch.worktreePath,
|
|
1556
|
+
expect.objectContaining({
|
|
1557
|
+
phase: "watchdog",
|
|
1558
|
+
attempt: dispatch.attempt,
|
|
1559
|
+
prompt: "(watchdog kill)",
|
|
1560
|
+
success: false,
|
|
1561
|
+
watchdog: expect.objectContaining({
|
|
1562
|
+
reason: "inactivity",
|
|
1563
|
+
retried: true,
|
|
1564
|
+
thresholdSec: expect.any(Number),
|
|
1565
|
+
}),
|
|
1566
|
+
}),
|
|
1567
|
+
);
|
|
1568
|
+
|
|
1569
|
+
// Verify output preview is included
|
|
1570
|
+
const logCall = (appendLog as any).mock.calls.find(
|
|
1571
|
+
(c: any[]) => c[1]?.phase === "watchdog",
|
|
1572
|
+
);
|
|
1573
|
+
expect(logCall).toBeDefined();
|
|
1574
|
+
expect(logCall[1].outputPreview).toBe("partial output before hang");
|
|
1575
|
+
expect(logCall[1].durationMs).toBeGreaterThanOrEqual(0);
|
|
1576
|
+
});
|
|
1577
|
+
|
|
1578
|
+
it("watchdog kill updates manifest to stuck", async () => {
|
|
1579
|
+
(runAgent as any).mockResolvedValue({
|
|
1580
|
+
success: false,
|
|
1581
|
+
output: "",
|
|
1582
|
+
watchdogKilled: true,
|
|
1583
|
+
});
|
|
1584
|
+
const ctx = makeHookCtx();
|
|
1585
|
+
const dispatch = makeDispatch({ status: "working" as any });
|
|
1586
|
+
|
|
1587
|
+
await spawnWorker(ctx, dispatch);
|
|
1588
|
+
|
|
1589
|
+
expect(updateManifest).toHaveBeenCalledWith(
|
|
1590
|
+
dispatch.worktreePath,
|
|
1591
|
+
{ status: "stuck", attempts: dispatch.attempt + 1 },
|
|
1592
|
+
);
|
|
1593
|
+
});
|
|
1594
|
+
|
|
1595
|
+
it("watchdog kill does not trigger audit", async () => {
|
|
1596
|
+
(runAgent as any).mockResolvedValue({
|
|
1597
|
+
success: false,
|
|
1598
|
+
output: "",
|
|
1599
|
+
watchdogKilled: true,
|
|
1600
|
+
});
|
|
1601
|
+
const ctx = makeHookCtx();
|
|
1602
|
+
const dispatch = makeDispatch({ status: "working" as any });
|
|
1603
|
+
|
|
1604
|
+
await spawnWorker(ctx, dispatch);
|
|
1605
|
+
|
|
1606
|
+
// runAgent should only be called ONCE (for the worker)
|
|
1607
|
+
// NOT a second time (no audit spawned)
|
|
1608
|
+
expect(runAgent).toHaveBeenCalledTimes(1);
|
|
1609
|
+
|
|
1610
|
+
// No audit artifacts should be saved
|
|
1611
|
+
expect(saveAuditVerdict).not.toHaveBeenCalled();
|
|
1612
|
+
|
|
1613
|
+
// Transition should be to stuck, never to auditing
|
|
1614
|
+
expect(transitionDispatch).toHaveBeenCalledWith(
|
|
1615
|
+
"ENG-100", "working", "stuck",
|
|
1616
|
+
expect.objectContaining({ stuckReason: "watchdog_kill_2x" }),
|
|
1617
|
+
expect.any(String),
|
|
1618
|
+
);
|
|
1619
|
+
});
|
|
1541
1620
|
});
|