@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 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
- - [ ] Audit rework loop (gaps fed back, automatic retry)
31
- - [ ] Watchdog timeout + escalation
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
- - [ ] Issue closure with summary report
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
- ### Timeout recovery
552
+ ### Watchdog & timeout recovery
552
553
 
553
- If an agent produces no output for 2 minutes (configurable), the watchdog kills it and retries once. If the retry also times out, the issue is escalated.
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
- **Notification:** `⚡ ENG-100 timed out (no activity for 120s). Will retry.`
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
- 551 tests covering the full pipeline — triage, dispatch, audit, planning, intent classification, native issue tools, cross-model review, notifications, and infrastructure:
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@calltelemetry/openclaw-linear",
3
- "version": "0.9.2",
3
+ "version": "0.9.3",
4
4
  "description": "Linear Agent plugin for OpenClaw — webhook-driven AI pipeline with OAuth, multi-agent routing, and issue triage",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
  });