@calltelemetry/openclaw-linear 0.9.0 → 0.9.2

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.
@@ -24,7 +24,7 @@ vi.mock("./artifacts.js", () => ({
24
24
  writeSummary: vi.fn(),
25
25
  buildSummaryFromArtifacts: vi.fn(),
26
26
  writeDispatchMemory: vi.fn(),
27
- resolveOrchestratorWorkspace: vi.fn(),
27
+ resolveOrchestratorWorkspace: vi.fn(() => "/tmp/ws"),
28
28
  }));
29
29
  vi.mock("../agent/watchdog.js", () => ({
30
30
  resolveWatchdogConfig: vi.fn(() => ({
@@ -33,15 +33,57 @@ vi.mock("../agent/watchdog.js", () => ({
33
33
  toolTimeoutMs: 600000,
34
34
  })),
35
35
  }));
36
+ vi.mock("./guidance.js", () => ({
37
+ getCachedGuidanceForTeam: vi.fn(() => null),
38
+ isGuidanceEnabled: vi.fn(() => false),
39
+ }));
40
+ vi.mock("./dag-dispatch.js", () => ({
41
+ onProjectIssueCompleted: vi.fn().mockResolvedValue(undefined),
42
+ onProjectIssueStuck: vi.fn().mockResolvedValue(undefined),
43
+ }));
44
+ vi.mock("../infra/observability.js", () => ({
45
+ emitDiagnostic: vi.fn(),
46
+ }));
36
47
 
37
48
  import {
38
49
  parseVerdict,
39
50
  buildWorkerTask,
40
51
  buildAuditTask,
41
52
  loadPrompts,
53
+ loadRawPromptYaml,
42
54
  clearPromptCache,
55
+ triggerAudit,
56
+ processVerdict,
57
+ spawnWorker,
43
58
  type IssueContext,
59
+ type HookContext,
60
+ type AuditVerdict,
44
61
  } from "./pipeline.js";
62
+ import { runAgent } from "../agent/agent.js";
63
+ import {
64
+ transitionDispatch,
65
+ registerSessionMapping,
66
+ markEventProcessed,
67
+ completeDispatch,
68
+ readDispatchState,
69
+ getActiveDispatch,
70
+ TransitionError,
71
+ type ActiveDispatch,
72
+ } from "./dispatch-state.js";
73
+ import { clearActiveSession } from "./active-session.js";
74
+ import {
75
+ saveWorkerOutput,
76
+ saveAuditVerdict,
77
+ appendLog,
78
+ updateManifest,
79
+ buildSummaryFromArtifacts,
80
+ writeSummary,
81
+ writeDispatchMemory,
82
+ resolveOrchestratorWorkspace,
83
+ } from "./artifacts.js";
84
+ import { emitDiagnostic } from "../infra/observability.js";
85
+ import { onProjectIssueCompleted, onProjectIssueStuck } from "./dag-dispatch.js";
86
+ import { isGuidanceEnabled, getCachedGuidanceForTeam } from "./guidance.js";
45
87
 
46
88
  // ---------------------------------------------------------------------------
47
89
  // parseVerdict
@@ -293,3 +335,1207 @@ describe("loadPrompts", () => {
293
335
  clearPromptCache();
294
336
  });
295
337
  });
338
+
339
+ // ---------------------------------------------------------------------------
340
+ // loadRawPromptYaml
341
+ // ---------------------------------------------------------------------------
342
+
343
+ describe("loadRawPromptYaml", () => {
344
+ beforeEach(() => {
345
+ clearPromptCache();
346
+ });
347
+
348
+ it("returns null when no file exists and no custom path", () => {
349
+ // With no prompts.yaml sidecar in the plugin root (test env), returns null
350
+ const result = loadRawPromptYaml();
351
+ // Could be non-null if a sidecar exists; the point is it doesn't throw
352
+ expect(result === null || typeof result === "object").toBe(true);
353
+ });
354
+
355
+ it("loads YAML from a custom promptsPath", () => {
356
+ const { writeFileSync, mkdtempSync } = require("node:fs");
357
+ const { join } = require("node:path");
358
+ const { tmpdir } = require("node:os");
359
+ const dir = mkdtempSync(join(tmpdir(), "claw-rawprompt-"));
360
+ const yamlPath = join(dir, "custom.yaml");
361
+ writeFileSync(yamlPath, "worker:\n system: my custom system\n");
362
+
363
+ const result = loadRawPromptYaml({ promptsPath: yamlPath });
364
+ expect(result).not.toBeNull();
365
+ expect(result!.worker.system).toBe("my custom system");
366
+ });
367
+
368
+ it("returns null for non-existent custom path", () => {
369
+ const result = loadRawPromptYaml({ promptsPath: "/tmp/nonexistent-prompt-file.yaml" });
370
+ expect(result).toBeNull();
371
+ });
372
+
373
+ it("resolves ~ in promptsPath to HOME", () => {
374
+ const { writeFileSync, mkdtempSync } = require("node:fs");
375
+ const { join } = require("node:path");
376
+ const { tmpdir } = require("node:os");
377
+
378
+ // We can't easily test ~ expansion without writing to HOME,
379
+ // but we can verify it doesn't throw with a ~ path that doesn't exist
380
+ const result = loadRawPromptYaml({ promptsPath: "~/nonexistent-claw-prompt-test.yaml" });
381
+ expect(result).toBeNull();
382
+ });
383
+ });
384
+
385
+ // ---------------------------------------------------------------------------
386
+ // buildWorkerTask — additional branch coverage
387
+ // ---------------------------------------------------------------------------
388
+
389
+ describe("buildWorkerTask (additional branches)", () => {
390
+ const issue: IssueContext = {
391
+ id: "id-1",
392
+ identifier: "API-42",
393
+ title: "Fix auth",
394
+ description: "The login endpoint fails.",
395
+ };
396
+
397
+ beforeEach(() => {
398
+ clearPromptCache();
399
+ });
400
+
401
+ it("includes guidance when provided", () => {
402
+ const { task } = buildWorkerTask(issue, "/wt/API-42", {
403
+ guidance: "Always use TypeScript strict mode",
404
+ });
405
+ expect(task).toContain("Additional Guidance");
406
+ expect(task).toContain("Always use TypeScript strict mode");
407
+ });
408
+
409
+ it("does not include guidance section when guidance is undefined", () => {
410
+ const { task } = buildWorkerTask(issue, "/wt/API-42", {
411
+ guidance: undefined,
412
+ });
413
+ expect(task).not.toContain("Additional Guidance");
414
+ });
415
+
416
+ it("uses undefined description as (no description)", () => {
417
+ const noDesc: IssueContext = { ...issue, description: undefined };
418
+ const { task } = buildWorkerTask(noDesc, "/wt/API-42");
419
+ expect(task).toContain("(no description)");
420
+ });
421
+ });
422
+
423
+ // ---------------------------------------------------------------------------
424
+ // buildAuditTask — additional branch coverage
425
+ // ---------------------------------------------------------------------------
426
+
427
+ describe("buildAuditTask (additional branches)", () => {
428
+ const issue: IssueContext = {
429
+ id: "id-2",
430
+ identifier: "API-99",
431
+ title: "Add caching",
432
+ description: "Cache API responses.",
433
+ };
434
+
435
+ beforeEach(() => {
436
+ clearPromptCache();
437
+ });
438
+
439
+ it("includes guidance when provided", () => {
440
+ const { task } = buildAuditTask(issue, "/wt/API-99", undefined, {
441
+ guidance: "Focus on security",
442
+ });
443
+ expect(task).toContain("Additional Guidance");
444
+ expect(task).toContain("Focus on security");
445
+ });
446
+
447
+ it("does not include guidance section when guidance is undefined", () => {
448
+ const { task } = buildAuditTask(issue, "/wt/API-99", undefined, {
449
+ guidance: undefined,
450
+ });
451
+ expect(task).not.toContain("Additional Guidance");
452
+ });
453
+
454
+ it("handles undefined description", () => {
455
+ const noDesc: IssueContext = { ...issue, description: undefined };
456
+ const { task } = buildAuditTask(noDesc, "/wt/API-99");
457
+ expect(task).toContain("(no description)");
458
+ });
459
+ });
460
+
461
+ // ---------------------------------------------------------------------------
462
+ // Shared helpers for async pipeline tests
463
+ // ---------------------------------------------------------------------------
464
+
465
+ function makeApi() {
466
+ return {
467
+ logger: {
468
+ info: vi.fn(),
469
+ warn: vi.fn(),
470
+ error: vi.fn(),
471
+ debug: vi.fn(),
472
+ },
473
+ pluginConfig: {},
474
+ runtime: {},
475
+ } as any;
476
+ }
477
+
478
+ function makeMockLinearApi() {
479
+ return {
480
+ getIssueDetails: vi.fn().mockResolvedValue({
481
+ id: "issue-1",
482
+ identifier: "ENG-100",
483
+ title: "Test Issue",
484
+ description: "Issue description.",
485
+ team: { id: "team-1", name: "Engineering" },
486
+ }),
487
+ createComment: vi.fn().mockResolvedValue("comment-id"),
488
+ emitActivity: vi.fn().mockResolvedValue(undefined),
489
+ };
490
+ }
491
+
492
+ function makeHookCtx(overrides?: Partial<HookContext>): HookContext {
493
+ return {
494
+ api: makeApi(),
495
+ linearApi: makeMockLinearApi() as any,
496
+ notify: vi.fn().mockResolvedValue(undefined),
497
+ pluginConfig: {},
498
+ configPath: "/tmp/test-state.json",
499
+ ...overrides,
500
+ };
501
+ }
502
+
503
+ function makeDispatch(overrides?: Partial<ActiveDispatch>): ActiveDispatch {
504
+ return {
505
+ issueId: "issue-1",
506
+ issueIdentifier: "ENG-100",
507
+ issueTitle: "Test Issue",
508
+ worktreePath: "/tmp/wt/ENG-100",
509
+ branch: "codex/ENG-100",
510
+ tier: "small" as const,
511
+ model: "test-model",
512
+ status: "working" as const,
513
+ dispatchedAt: new Date().toISOString(),
514
+ attempt: 0,
515
+ agentSessionId: "session-1",
516
+ ...overrides,
517
+ };
518
+ }
519
+
520
+ // ---------------------------------------------------------------------------
521
+ // triggerAudit
522
+ // ---------------------------------------------------------------------------
523
+
524
+ describe("triggerAudit", () => {
525
+ beforeEach(() => {
526
+ vi.clearAllMocks();
527
+ clearPromptCache();
528
+ (markEventProcessed as any).mockResolvedValue(true);
529
+ (transitionDispatch as any).mockResolvedValue({});
530
+ (readDispatchState as any).mockResolvedValue({
531
+ version: 2,
532
+ dispatches: { active: {}, completed: {} },
533
+ sessionMap: {},
534
+ processedEvents: [],
535
+ });
536
+ (getActiveDispatch as any).mockReturnValue(makeDispatch());
537
+ (registerSessionMapping as any).mockResolvedValue(undefined);
538
+ (runAgent as any).mockResolvedValue({ success: true, output: '{"pass": true, "criteria": ["ok"], "gaps": [], "testResults": "pass"}' });
539
+ // processVerdict is called internally — it needs its own mocks too
540
+ (completeDispatch as any).mockResolvedValue(undefined);
541
+ });
542
+
543
+ it("skips when event is duplicate (markEventProcessed returns false)", async () => {
544
+ (markEventProcessed as any).mockResolvedValue(false);
545
+ const ctx = makeHookCtx();
546
+ const dispatch = makeDispatch();
547
+
548
+ await triggerAudit(ctx, dispatch, { success: true, output: "done" }, "session-key-1");
549
+
550
+ expect(ctx.api.logger.info).toHaveBeenCalledWith(
551
+ expect.stringContaining("duplicate worker agent_end"),
552
+ );
553
+ expect(transitionDispatch).not.toHaveBeenCalled();
554
+ });
555
+
556
+ it("returns on CAS TransitionError (working → auditing)", async () => {
557
+ (transitionDispatch as any).mockRejectedValue(new TransitionError("err"));
558
+ const ctx = makeHookCtx();
559
+ const dispatch = makeDispatch();
560
+
561
+ await triggerAudit(ctx, dispatch, { success: true, output: "done" }, "session-key-2");
562
+
563
+ expect(ctx.api.logger.warn).toHaveBeenCalledWith(
564
+ expect.stringContaining("CAS failed for audit trigger"),
565
+ );
566
+ // Should NOT spawn runAgent
567
+ expect(runAgent).not.toHaveBeenCalled();
568
+ });
569
+
570
+ it("re-throws non-TransitionError from transitionDispatch", async () => {
571
+ (transitionDispatch as any).mockRejectedValue(new Error("disk error"));
572
+ const ctx = makeHookCtx();
573
+ const dispatch = makeDispatch();
574
+
575
+ await expect(
576
+ triggerAudit(ctx, dispatch, { success: true }, "session-key-3"),
577
+ ).rejects.toThrow("disk error");
578
+ });
579
+
580
+ it("fetches issue details and spawns audit agent on success path", async () => {
581
+ const ctx = makeHookCtx();
582
+ const dispatch = makeDispatch({ attempt: 1 });
583
+
584
+ await triggerAudit(ctx, dispatch, { success: true, output: "worker output" }, "session-key-4");
585
+
586
+ // Should transition working → auditing
587
+ expect(transitionDispatch).toHaveBeenCalledWith(
588
+ "ENG-100", "working", "auditing", undefined, "/tmp/test-state.json",
589
+ );
590
+ // Should register session mapping for audit
591
+ expect(registerSessionMapping).toHaveBeenCalledWith(
592
+ "linear-audit-ENG-100-1",
593
+ { dispatchId: "ENG-100", phase: "audit", attempt: 1 },
594
+ "/tmp/test-state.json",
595
+ );
596
+ // Should spawn runAgent
597
+ expect(runAgent).toHaveBeenCalledWith(
598
+ expect.objectContaining({
599
+ sessionId: "linear-audit-ENG-100-1",
600
+ }),
601
+ );
602
+ // Should emit diagnostic
603
+ expect(emitDiagnostic).toHaveBeenCalledWith(
604
+ ctx.api,
605
+ expect.objectContaining({ event: "phase_transition", from: "working", to: "auditing" }),
606
+ );
607
+ // Should notify
608
+ expect(ctx.notify).toHaveBeenCalledWith(
609
+ "auditing",
610
+ expect.objectContaining({ identifier: "ENG-100", status: "auditing" }),
611
+ );
612
+ });
613
+
614
+ it("handles getIssueDetails failure gracefully", async () => {
615
+ const linearApi = makeMockLinearApi();
616
+ linearApi.getIssueDetails.mockRejectedValue(new Error("API down"));
617
+ const ctx = makeHookCtx({ linearApi: linearApi as any });
618
+ const dispatch = makeDispatch();
619
+
620
+ // Should not throw — getIssueDetails failure is caught
621
+ await triggerAudit(ctx, dispatch, { success: true, output: "output" }, "session-key-5");
622
+
623
+ expect(runAgent).toHaveBeenCalled();
624
+ });
625
+
626
+ it("uses multi-repo worktree paths when dispatch.worktrees is set", async () => {
627
+ const ctx = makeHookCtx();
628
+ const dispatch = makeDispatch({
629
+ worktrees: [
630
+ { repoName: "frontend", path: "/tmp/wt/frontend", branch: "main" },
631
+ { repoName: "backend", path: "/tmp/wt/backend", branch: "main" },
632
+ ],
633
+ });
634
+
635
+ await triggerAudit(ctx, dispatch, { success: true, output: "output" }, "session-key-6");
636
+
637
+ // The runAgent call message should contain both repo paths
638
+ const runAgentCall = (runAgent as any).mock.calls[0][0];
639
+ expect(runAgentCall.message).toContain("frontend: /tmp/wt/frontend");
640
+ expect(runAgentCall.message).toContain("backend: /tmp/wt/backend");
641
+ });
642
+
643
+ it("does not set streaming when agentSessionId is absent", async () => {
644
+ const ctx = makeHookCtx();
645
+ const dispatch = makeDispatch({ agentSessionId: undefined });
646
+
647
+ await triggerAudit(ctx, dispatch, { success: true, output: "output" }, "session-key-7");
648
+
649
+ const runAgentCall = (runAgent as any).mock.calls[0][0];
650
+ expect(runAgentCall.streaming).toBeUndefined();
651
+ });
652
+
653
+ it("sets streaming when agentSessionId is present", async () => {
654
+ const ctx = makeHookCtx();
655
+ const dispatch = makeDispatch({ agentSessionId: "linear-session-1" });
656
+
657
+ await triggerAudit(ctx, dispatch, { success: true, output: "output" }, "session-key-8");
658
+
659
+ const runAgentCall = (runAgent as any).mock.calls[0][0];
660
+ expect(runAgentCall.streaming).toBeDefined();
661
+ expect(runAgentCall.streaming.agentSessionId).toBe("linear-session-1");
662
+ });
663
+ });
664
+
665
+ // ---------------------------------------------------------------------------
666
+ // processVerdict
667
+ // ---------------------------------------------------------------------------
668
+
669
+ describe("processVerdict", () => {
670
+ beforeEach(() => {
671
+ vi.clearAllMocks();
672
+ clearPromptCache();
673
+ (markEventProcessed as any).mockResolvedValue(true);
674
+ (transitionDispatch as any).mockResolvedValue({});
675
+ (completeDispatch as any).mockResolvedValue(undefined);
676
+ (readDispatchState as any).mockResolvedValue({
677
+ version: 2,
678
+ dispatches: { active: {}, completed: {} },
679
+ sessionMap: {},
680
+ processedEvents: [],
681
+ });
682
+ (buildSummaryFromArtifacts as any).mockReturnValue(null);
683
+ (resolveOrchestratorWorkspace as any).mockReturnValue("/tmp/ws");
684
+ });
685
+
686
+ it("skips when event is duplicate (markEventProcessed returns false)", async () => {
687
+ (markEventProcessed as any).mockResolvedValue(false);
688
+ const ctx = makeHookCtx();
689
+ const dispatch = makeDispatch({ status: "auditing" as any });
690
+
691
+ await processVerdict(ctx, dispatch, { success: true, output: '{"pass": true}' }, "audit-key-1");
692
+
693
+ expect(ctx.api.logger.info).toHaveBeenCalledWith(
694
+ expect.stringContaining("duplicate audit agent_end"),
695
+ );
696
+ expect(transitionDispatch).not.toHaveBeenCalled();
697
+ });
698
+
699
+ it("extracts output from event.messages when event.output is empty", async () => {
700
+ const ctx = makeHookCtx();
701
+ const dispatch = makeDispatch({ status: "auditing" as any });
702
+
703
+ await processVerdict(ctx, dispatch, {
704
+ success: true,
705
+ output: "",
706
+ messages: [
707
+ { role: "user", content: "audit this" },
708
+ { role: "assistant", content: '{"pass": true, "criteria": ["tests pass"], "gaps": [], "testResults": "ok"}' },
709
+ ],
710
+ }, "audit-key-2");
711
+
712
+ // Should parse the verdict from messages
713
+ expect(ctx.api.logger.info).toHaveBeenCalledWith(
714
+ expect.stringContaining("audit verdict: PASS"),
715
+ );
716
+ });
717
+
718
+ it("extracts output from assistant array content blocks", async () => {
719
+ const ctx = makeHookCtx();
720
+ const dispatch = makeDispatch({ status: "auditing" as any });
721
+
722
+ await processVerdict(ctx, dispatch, {
723
+ success: true,
724
+ output: "",
725
+ messages: [
726
+ {
727
+ role: "assistant",
728
+ content: [
729
+ { type: "tool_use", id: "t1" },
730
+ { type: "text", text: '{"pass": false, "criteria": [], "gaps": ["missing test"], "testResults": "fail"}' },
731
+ ],
732
+ },
733
+ ],
734
+ }, "audit-key-3");
735
+
736
+ expect(ctx.api.logger.info).toHaveBeenCalledWith(
737
+ expect.stringContaining("audit verdict: FAIL"),
738
+ );
739
+ });
740
+
741
+ it("handles unparseable verdict — posts comment and treats as failure", async () => {
742
+ const ctx = makeHookCtx();
743
+ const dispatch = makeDispatch({ status: "auditing" as any });
744
+
745
+ await processVerdict(ctx, dispatch, {
746
+ success: true,
747
+ output: "no json here at all",
748
+ }, "audit-key-4");
749
+
750
+ expect(ctx.api.logger.warn).toHaveBeenCalledWith(
751
+ expect.stringContaining("could not parse audit verdict"),
752
+ );
753
+ // Should post an "Audit Inconclusive" comment
754
+ expect((ctx.linearApi as any).createComment).toHaveBeenCalledWith(
755
+ dispatch.issueId,
756
+ expect.stringContaining("Audit Inconclusive"),
757
+ );
758
+ });
759
+
760
+ it("handles audit PASS — transitions to done and completes dispatch", async () => {
761
+ const ctx = makeHookCtx();
762
+ const dispatch = makeDispatch({ status: "auditing" as any, attempt: 0 });
763
+
764
+ await processVerdict(ctx, dispatch, {
765
+ success: true,
766
+ output: '{"pass": true, "criteria": ["tests pass", "code review"], "gaps": [], "testResults": "all green"}',
767
+ }, "audit-key-5");
768
+
769
+ // Transition auditing → done
770
+ expect(transitionDispatch).toHaveBeenCalledWith(
771
+ "ENG-100", "auditing", "done", undefined, "/tmp/test-state.json",
772
+ );
773
+ // Complete dispatch
774
+ expect(completeDispatch).toHaveBeenCalledWith(
775
+ "ENG-100",
776
+ expect.objectContaining({ tier: "small", status: "done" }),
777
+ "/tmp/test-state.json",
778
+ );
779
+ // Should post "Done" comment
780
+ expect((ctx.linearApi as any).createComment).toHaveBeenCalledWith(
781
+ dispatch.issueId,
782
+ expect.stringContaining("Done"),
783
+ );
784
+ // Should notify audit_pass
785
+ expect(ctx.notify).toHaveBeenCalledWith(
786
+ "audit_pass",
787
+ expect.objectContaining({ identifier: "ENG-100", status: "done" }),
788
+ );
789
+ // Should clear active session
790
+ expect(clearActiveSession).toHaveBeenCalledWith("issue-1");
791
+ });
792
+
793
+ it("handles audit PASS with summary from artifacts", async () => {
794
+ (buildSummaryFromArtifacts as any).mockReturnValue("## Summary\nImplemented feature X");
795
+ const ctx = makeHookCtx();
796
+ const dispatch = makeDispatch({ status: "auditing" as any });
797
+
798
+ await processVerdict(ctx, dispatch, {
799
+ success: true,
800
+ output: '{"pass": true, "criteria": ["done"], "gaps": [], "testResults": "pass"}',
801
+ }, "audit-key-6");
802
+
803
+ expect(writeSummary).toHaveBeenCalledWith("/tmp/wt/ENG-100", "## Summary\nImplemented feature X");
804
+ expect(writeDispatchMemory).toHaveBeenCalled();
805
+ // Comment should include summary excerpt
806
+ expect((ctx.linearApi as any).createComment).toHaveBeenCalledWith(
807
+ dispatch.issueId,
808
+ expect.stringContaining("Summary"),
809
+ );
810
+ });
811
+
812
+ it("handles audit PASS with project — triggers DAG cascade", async () => {
813
+ const ctx = makeHookCtx();
814
+ const dispatch = makeDispatch({ status: "auditing" as any, project: "project-1" });
815
+
816
+ await processVerdict(ctx, dispatch, {
817
+ success: true,
818
+ output: '{"pass": true, "criteria": [], "gaps": [], "testResults": ""}',
819
+ }, "audit-key-7");
820
+
821
+ // Should call onProjectIssueCompleted (fire-and-forget)
822
+ // Wait a tick for the void promise
823
+ await new Promise((r) => setTimeout(r, 10));
824
+ expect(onProjectIssueCompleted).toHaveBeenCalledWith(
825
+ ctx, "project-1", "ENG-100",
826
+ );
827
+ });
828
+
829
+ it("handles audit PASS — CAS TransitionError returns silently", async () => {
830
+ (transitionDispatch as any).mockRejectedValue(new TransitionError("cas err"));
831
+ const ctx = makeHookCtx();
832
+ const dispatch = makeDispatch({ status: "auditing" as any });
833
+
834
+ await processVerdict(ctx, dispatch, {
835
+ success: true,
836
+ output: '{"pass": true, "criteria": [], "gaps": [], "testResults": ""}',
837
+ }, "audit-key-8");
838
+
839
+ expect(ctx.api.logger.warn).toHaveBeenCalledWith(
840
+ expect.stringContaining("CAS failed for audit pass"),
841
+ );
842
+ // Should NOT call completeDispatch
843
+ expect(completeDispatch).not.toHaveBeenCalled();
844
+ });
845
+
846
+ it("handles audit FAIL with rework allowed (attempt < maxAttempts)", async () => {
847
+ const ctx = makeHookCtx({ pluginConfig: { maxReworkAttempts: 2 } });
848
+ const dispatch = makeDispatch({ status: "auditing" as any, attempt: 0 });
849
+
850
+ await processVerdict(ctx, dispatch, {
851
+ success: true,
852
+ output: '{"pass": false, "criteria": ["tests"], "gaps": ["missing validation"], "testResults": "1 failing"}',
853
+ }, "audit-key-9");
854
+
855
+ // Transition auditing → working (rework)
856
+ expect(transitionDispatch).toHaveBeenCalledWith(
857
+ "ENG-100", "auditing", "working",
858
+ { attempt: 1 },
859
+ "/tmp/test-state.json",
860
+ );
861
+ // Should post "Needs More Work" comment
862
+ expect((ctx.linearApi as any).createComment).toHaveBeenCalledWith(
863
+ dispatch.issueId,
864
+ expect.stringContaining("Needs More Work"),
865
+ );
866
+ // Should notify audit_fail
867
+ expect(ctx.notify).toHaveBeenCalledWith(
868
+ "audit_fail",
869
+ expect.objectContaining({ identifier: "ENG-100", attempt: 1 }),
870
+ );
871
+ });
872
+
873
+ it("handles audit FAIL with escalation (attempt >= maxAttempts)", async () => {
874
+ const ctx = makeHookCtx({ pluginConfig: { maxReworkAttempts: 1 } });
875
+ const dispatch = makeDispatch({ status: "auditing" as any, attempt: 1 });
876
+
877
+ await processVerdict(ctx, dispatch, {
878
+ success: true,
879
+ output: '{"pass": false, "criteria": [], "gaps": ["still broken"], "testResults": "fail"}',
880
+ }, "audit-key-10");
881
+
882
+ // Transition auditing → stuck
883
+ expect(transitionDispatch).toHaveBeenCalledWith(
884
+ "ENG-100", "auditing", "stuck",
885
+ { stuckReason: "audit_failed_2x" },
886
+ "/tmp/test-state.json",
887
+ );
888
+ // Should post "Needs Your Help" comment
889
+ expect((ctx.linearApi as any).createComment).toHaveBeenCalledWith(
890
+ dispatch.issueId,
891
+ expect.stringContaining("Needs Your Help"),
892
+ );
893
+ // Should notify escalation
894
+ expect(ctx.notify).toHaveBeenCalledWith(
895
+ "escalation",
896
+ expect.objectContaining({ identifier: "ENG-100", status: "stuck" }),
897
+ );
898
+ });
899
+
900
+ it("handles audit FAIL escalation with project — triggers DAG stuck cascade", async () => {
901
+ const ctx = makeHookCtx({ pluginConfig: { maxReworkAttempts: 0 } });
902
+ const dispatch = makeDispatch({ status: "auditing" as any, attempt: 0, project: "project-2" });
903
+
904
+ await processVerdict(ctx, dispatch, {
905
+ success: true,
906
+ output: '{"pass": false, "criteria": [], "gaps": ["broken"], "testResults": ""}',
907
+ }, "audit-key-11");
908
+
909
+ await new Promise((r) => setTimeout(r, 10));
910
+ expect(onProjectIssueStuck).toHaveBeenCalledWith(
911
+ ctx, "project-2", "ENG-100",
912
+ );
913
+ });
914
+
915
+ it("handles rework CAS TransitionError — returns silently", async () => {
916
+ // First call succeeds (for non-existent earlier transition), second fails
917
+ (transitionDispatch as any).mockRejectedValue(new TransitionError("rework cas"));
918
+ const ctx = makeHookCtx({ pluginConfig: { maxReworkAttempts: 2 } });
919
+ const dispatch = makeDispatch({ status: "auditing" as any, attempt: 0 });
920
+
921
+ await processVerdict(ctx, dispatch, {
922
+ success: true,
923
+ output: '{"pass": false, "criteria": [], "gaps": ["fix"], "testResults": ""}',
924
+ }, "audit-key-12");
925
+
926
+ expect(ctx.api.logger.warn).toHaveBeenCalledWith(
927
+ expect.stringContaining("CAS failed for rework transition"),
928
+ );
929
+ });
930
+
931
+ it("handles stuck CAS TransitionError — returns silently", async () => {
932
+ (transitionDispatch as any).mockRejectedValue(new TransitionError("stuck cas"));
933
+ const ctx = makeHookCtx({ pluginConfig: { maxReworkAttempts: 0 } });
934
+ const dispatch = makeDispatch({ status: "auditing" as any, attempt: 0 });
935
+
936
+ await processVerdict(ctx, dispatch, {
937
+ success: true,
938
+ output: '{"pass": false, "criteria": [], "gaps": ["broken"], "testResults": ""}',
939
+ }, "audit-key-13");
940
+
941
+ expect(ctx.api.logger.warn).toHaveBeenCalledWith(
942
+ expect.stringContaining("CAS failed for stuck transition"),
943
+ );
944
+ });
945
+
946
+ it("re-throws non-TransitionError from stuck transition", async () => {
947
+ (transitionDispatch as any).mockRejectedValue(new Error("io error"));
948
+ const ctx = makeHookCtx({ pluginConfig: { maxReworkAttempts: 0 } });
949
+ const dispatch = makeDispatch({ status: "auditing" as any, attempt: 0 });
950
+
951
+ await expect(
952
+ processVerdict(ctx, dispatch, {
953
+ success: true,
954
+ output: '{"pass": false, "criteria": [], "gaps": ["x"], "testResults": ""}',
955
+ }, "audit-key-14"),
956
+ ).rejects.toThrow("io error");
957
+ });
958
+
959
+ it("re-throws non-TransitionError from rework transition", async () => {
960
+ (transitionDispatch as any).mockRejectedValue(new Error("disk full"));
961
+ const ctx = makeHookCtx({ pluginConfig: { maxReworkAttempts: 5 } });
962
+ const dispatch = makeDispatch({ status: "auditing" as any, attempt: 0 });
963
+
964
+ await expect(
965
+ processVerdict(ctx, dispatch, {
966
+ success: true,
967
+ output: '{"pass": false, "criteria": [], "gaps": ["x"], "testResults": ""}',
968
+ }, "audit-key-15"),
969
+ ).rejects.toThrow("disk full");
970
+ });
971
+
972
+ it("re-throws non-TransitionError from done transition", async () => {
973
+ (transitionDispatch as any).mockRejectedValue(new Error("perm denied"));
974
+ const ctx = makeHookCtx();
975
+ const dispatch = makeDispatch({ status: "auditing" as any });
976
+
977
+ await expect(
978
+ processVerdict(ctx, dispatch, {
979
+ success: true,
980
+ output: '{"pass": true, "criteria": [], "gaps": [], "testResults": ""}',
981
+ }, "audit-key-16"),
982
+ ).rejects.toThrow("perm denied");
983
+ });
984
+
985
+ it("uses default maxReworkAttempts=2 when not configured", async () => {
986
+ const ctx = makeHookCtx({ pluginConfig: {} });
987
+ // attempt=2, so nextAttempt=3, which exceeds default maxReworkAttempts=2 → escalation
988
+ const dispatch = makeDispatch({ status: "auditing" as any, attempt: 2 });
989
+
990
+ await processVerdict(ctx, dispatch, {
991
+ success: true,
992
+ output: '{"pass": false, "criteria": [], "gaps": ["broken"], "testResults": ""}',
993
+ }, "audit-key-17");
994
+
995
+ // Should escalate (stuck), not rework
996
+ expect(transitionDispatch).toHaveBeenCalledWith(
997
+ "ENG-100", "auditing", "stuck",
998
+ expect.objectContaining({ stuckReason: "audit_failed_3x" }),
999
+ "/tmp/test-state.json",
1000
+ );
1001
+ });
1002
+
1003
+ it("writes summary and memory for stuck dispatches", async () => {
1004
+ (buildSummaryFromArtifacts as any).mockReturnValue("## Stuck summary\nFailed after 2 attempts");
1005
+ const ctx = makeHookCtx({ pluginConfig: { maxReworkAttempts: 0 } });
1006
+ const dispatch = makeDispatch({ status: "auditing" as any, attempt: 0 });
1007
+
1008
+ await processVerdict(ctx, dispatch, {
1009
+ success: true,
1010
+ output: '{"pass": false, "criteria": [], "gaps": ["broken"], "testResults": ""}',
1011
+ }, "audit-key-18");
1012
+
1013
+ expect(writeSummary).toHaveBeenCalledWith("/tmp/wt/ENG-100", "## Stuck summary\nFailed after 2 attempts");
1014
+ expect(writeDispatchMemory).toHaveBeenCalledWith(
1015
+ "ENG-100",
1016
+ "## Stuck summary\nFailed after 2 attempts",
1017
+ "/tmp/ws",
1018
+ expect.objectContaining({ status: "stuck" }),
1019
+ );
1020
+ });
1021
+ });
1022
+
1023
+ // ---------------------------------------------------------------------------
1024
+ // spawnWorker
1025
+ // ---------------------------------------------------------------------------
1026
+
1027
+ describe("spawnWorker", () => {
1028
+ beforeEach(() => {
1029
+ vi.clearAllMocks();
1030
+ clearPromptCache();
1031
+ (transitionDispatch as any).mockResolvedValue({});
1032
+ (registerSessionMapping as any).mockResolvedValue(undefined);
1033
+ (markEventProcessed as any).mockResolvedValue(true);
1034
+ (completeDispatch as any).mockResolvedValue(undefined);
1035
+ (readDispatchState as any).mockResolvedValue({
1036
+ version: 2,
1037
+ dispatches: { active: { "ENG-100": makeDispatch() }, completed: {} },
1038
+ sessionMap: {},
1039
+ processedEvents: [],
1040
+ });
1041
+ (getActiveDispatch as any).mockReturnValue(makeDispatch());
1042
+ (runAgent as any).mockResolvedValue({
1043
+ success: true,
1044
+ output: "worker done",
1045
+ watchdogKilled: false,
1046
+ });
1047
+ (buildSummaryFromArtifacts as any).mockReturnValue(null);
1048
+ (resolveOrchestratorWorkspace as any).mockReturnValue("/tmp/ws");
1049
+ });
1050
+
1051
+ it("transitions dispatched → working for first run", async () => {
1052
+ const ctx = makeHookCtx();
1053
+ const dispatch = makeDispatch({ status: "dispatched" as any });
1054
+
1055
+ await spawnWorker(ctx, dispatch);
1056
+
1057
+ expect(transitionDispatch).toHaveBeenCalledWith(
1058
+ "ENG-100", "dispatched", "working", undefined, "/tmp/test-state.json",
1059
+ );
1060
+ });
1061
+
1062
+ it("skips transition if status is already working (rework)", async () => {
1063
+ const ctx = makeHookCtx();
1064
+ const dispatch = makeDispatch({ status: "working" as any, attempt: 1 });
1065
+
1066
+ await spawnWorker(ctx, dispatch);
1067
+
1068
+ // transitionDispatch should NOT be called for dispatched→working
1069
+ // (it may be called later by triggerAudit, but not the dispatched→working one)
1070
+ const dispatchedToWorkingCalls = (transitionDispatch as any).mock.calls.filter(
1071
+ (c: any[]) => c[1] === "dispatched" && c[2] === "working",
1072
+ );
1073
+ expect(dispatchedToWorkingCalls).toHaveLength(0);
1074
+ });
1075
+
1076
+ it("returns on CAS TransitionError for dispatched → working", async () => {
1077
+ (transitionDispatch as any).mockRejectedValueOnce(new TransitionError("cas"));
1078
+ const ctx = makeHookCtx();
1079
+ const dispatch = makeDispatch({ status: "dispatched" as any });
1080
+
1081
+ await spawnWorker(ctx, dispatch);
1082
+
1083
+ expect(ctx.api.logger.warn).toHaveBeenCalledWith(
1084
+ expect.stringContaining("CAS failed for worker spawn"),
1085
+ );
1086
+ // Should NOT spawn runAgent
1087
+ expect(runAgent).not.toHaveBeenCalled();
1088
+ });
1089
+
1090
+ it("re-throws non-TransitionError from dispatch transition", async () => {
1091
+ (transitionDispatch as any).mockRejectedValueOnce(new Error("broken"));
1092
+ const ctx = makeHookCtx();
1093
+ const dispatch = makeDispatch({ status: "dispatched" as any });
1094
+
1095
+ await expect(spawnWorker(ctx, dispatch)).rejects.toThrow("broken");
1096
+ });
1097
+
1098
+ it("spawns worker agent with correct session ID and message", async () => {
1099
+ const ctx = makeHookCtx();
1100
+ const dispatch = makeDispatch({ status: "working" as any, attempt: 1 });
1101
+
1102
+ await spawnWorker(ctx, dispatch);
1103
+
1104
+ expect(runAgent).toHaveBeenCalledWith(
1105
+ expect.objectContaining({
1106
+ sessionId: "linear-worker-ENG-100-1",
1107
+ }),
1108
+ );
1109
+ // Should register session mapping
1110
+ expect(registerSessionMapping).toHaveBeenCalledWith(
1111
+ "linear-worker-ENG-100-1",
1112
+ { dispatchId: "ENG-100", phase: "worker", attempt: 1 },
1113
+ "/tmp/test-state.json",
1114
+ );
1115
+ });
1116
+
1117
+ it("sends notify working", async () => {
1118
+ const ctx = makeHookCtx();
1119
+ const dispatch = makeDispatch({ status: "working" as any });
1120
+
1121
+ await spawnWorker(ctx, dispatch);
1122
+
1123
+ expect(ctx.notify).toHaveBeenCalledWith(
1124
+ "working",
1125
+ expect.objectContaining({ identifier: "ENG-100", status: "working" }),
1126
+ );
1127
+ });
1128
+
1129
+ it("saves worker output and appends log after agent run", async () => {
1130
+ const ctx = makeHookCtx();
1131
+ const dispatch = makeDispatch({ status: "working" as any });
1132
+
1133
+ await spawnWorker(ctx, dispatch);
1134
+
1135
+ expect(saveWorkerOutput).toHaveBeenCalledWith("/tmp/wt/ENG-100", 0, "worker done");
1136
+ expect(appendLog).toHaveBeenCalled();
1137
+ });
1138
+
1139
+ it("handles watchdog kill — escalates to stuck", async () => {
1140
+ (runAgent as any).mockResolvedValue({
1141
+ success: false,
1142
+ output: "timed out",
1143
+ watchdogKilled: true,
1144
+ });
1145
+ const ctx = makeHookCtx();
1146
+ const dispatch = makeDispatch({ status: "working" as any });
1147
+
1148
+ await spawnWorker(ctx, dispatch);
1149
+
1150
+ // Should transition working → stuck
1151
+ expect(transitionDispatch).toHaveBeenCalledWith(
1152
+ "ENG-100", "working", "stuck",
1153
+ { stuckReason: "watchdog_kill_2x" },
1154
+ "/tmp/test-state.json",
1155
+ );
1156
+ // Should post "Agent Timed Out" comment
1157
+ expect((ctx.linearApi as any).createComment).toHaveBeenCalledWith(
1158
+ dispatch.issueId,
1159
+ expect.stringContaining("Agent Timed Out"),
1160
+ );
1161
+ // Should notify watchdog_kill
1162
+ expect(ctx.notify).toHaveBeenCalledWith(
1163
+ "watchdog_kill",
1164
+ expect.objectContaining({ identifier: "ENG-100", status: "stuck" }),
1165
+ );
1166
+ // Should emit watchdog diagnostic
1167
+ expect(emitDiagnostic).toHaveBeenCalledWith(
1168
+ ctx.api,
1169
+ expect.objectContaining({ event: "watchdog_kill" }),
1170
+ );
1171
+ // Should clear active session
1172
+ expect(clearActiveSession).toHaveBeenCalledWith("issue-1");
1173
+ // Should NOT trigger audit
1174
+ // runAgent is only called once (the worker), not a second time (no audit)
1175
+ expect(runAgent).toHaveBeenCalledTimes(1);
1176
+ });
1177
+
1178
+ it("watchdog kill handles CAS TransitionError gracefully", async () => {
1179
+ (runAgent as any).mockResolvedValue({
1180
+ success: false,
1181
+ output: "timed out",
1182
+ watchdogKilled: true,
1183
+ });
1184
+ // The first transitionDispatch (dispatched→working) succeeds,
1185
+ // the second (working→stuck) fails
1186
+ (transitionDispatch as any)
1187
+ .mockResolvedValueOnce({}) // dispatched→working
1188
+ .mockRejectedValueOnce(new TransitionError("cas stuck"));
1189
+ const ctx = makeHookCtx();
1190
+ const dispatch = makeDispatch({ status: "dispatched" as any });
1191
+
1192
+ // Should NOT throw — CAS error is caught
1193
+ await spawnWorker(ctx, dispatch);
1194
+
1195
+ expect(ctx.api.logger.warn).toHaveBeenCalledWith(
1196
+ expect.stringContaining("CAS failed for watchdog stuck transition"),
1197
+ );
1198
+ });
1199
+
1200
+ it("skips audit when dispatch disappears during worker run", async () => {
1201
+ (getActiveDispatch as any).mockReturnValue(null);
1202
+ const ctx = makeHookCtx();
1203
+ const dispatch = makeDispatch({ status: "working" as any });
1204
+
1205
+ await spawnWorker(ctx, dispatch);
1206
+
1207
+ expect(ctx.api.logger.warn).toHaveBeenCalledWith(
1208
+ expect.stringContaining("dispatch disappeared during worker run"),
1209
+ );
1210
+ // runAgent called once for worker, but NOT for audit
1211
+ expect(runAgent).toHaveBeenCalledTimes(1);
1212
+ });
1213
+
1214
+ it("uses multi-repo worktree paths when dispatch.worktrees is set", async () => {
1215
+ const ctx = makeHookCtx();
1216
+ const dispatch = makeDispatch({
1217
+ status: "working" as any,
1218
+ worktrees: [
1219
+ { repoName: "api", path: "/tmp/wt/api", branch: "main" },
1220
+ { repoName: "web", path: "/tmp/wt/web", branch: "main" },
1221
+ ],
1222
+ });
1223
+
1224
+ await spawnWorker(ctx, dispatch);
1225
+
1226
+ const runAgentCall = (runAgent as any).mock.calls[0][0];
1227
+ expect(runAgentCall.message).toContain("api: /tmp/wt/api");
1228
+ expect(runAgentCall.message).toContain("web: /tmp/wt/web");
1229
+ });
1230
+
1231
+ it("passes gaps to worker task on rework", async () => {
1232
+ const ctx = makeHookCtx();
1233
+ const dispatch = makeDispatch({ status: "working" as any, attempt: 1 });
1234
+
1235
+ await spawnWorker(ctx, dispatch, { gaps: ["missing tests", "no error handling"] });
1236
+
1237
+ const runAgentCall = (runAgent as any).mock.calls[0][0];
1238
+ expect(runAgentCall.message).toContain("PREVIOUS AUDIT FAILED");
1239
+ expect(runAgentCall.message).toContain("missing tests");
1240
+ });
1241
+
1242
+ it("uses defaultAgentId from pluginConfig", async () => {
1243
+ const ctx = makeHookCtx({ pluginConfig: { defaultAgentId: "kaylee" } });
1244
+ const dispatch = makeDispatch({ status: "working" as any });
1245
+
1246
+ await spawnWorker(ctx, dispatch);
1247
+
1248
+ expect(runAgent).toHaveBeenCalledWith(
1249
+ expect.objectContaining({ agentId: "kaylee" }),
1250
+ );
1251
+ });
1252
+
1253
+ it("sets streaming when agentSessionId is present", async () => {
1254
+ const ctx = makeHookCtx();
1255
+ const dispatch = makeDispatch({ status: "working" as any, agentSessionId: "lin-session" });
1256
+
1257
+ await spawnWorker(ctx, dispatch);
1258
+
1259
+ const runAgentCall = (runAgent as any).mock.calls[0][0];
1260
+ expect(runAgentCall.streaming).toBeDefined();
1261
+ expect(runAgentCall.streaming.agentSessionId).toBe("lin-session");
1262
+ });
1263
+
1264
+ it("does not set streaming when agentSessionId is absent", async () => {
1265
+ const ctx = makeHookCtx();
1266
+ const dispatch = makeDispatch({ status: "working" as any, agentSessionId: undefined });
1267
+
1268
+ await spawnWorker(ctx, dispatch);
1269
+
1270
+ const runAgentCall = (runAgent as any).mock.calls[0][0];
1271
+ expect(runAgentCall.streaming).toBeUndefined();
1272
+ });
1273
+
1274
+ it("handles getIssueDetails failure gracefully", async () => {
1275
+ const linearApi = makeMockLinearApi();
1276
+ linearApi.getIssueDetails.mockRejectedValue(new Error("network"));
1277
+ const ctx = makeHookCtx({ linearApi: linearApi as any });
1278
+ const dispatch = makeDispatch({ status: "working" as any });
1279
+
1280
+ // Should not throw
1281
+ await spawnWorker(ctx, dispatch);
1282
+
1283
+ // Should still spawn worker
1284
+ expect(runAgent).toHaveBeenCalled();
1285
+ });
1286
+ });
1287
+
1288
+ // ---------------------------------------------------------------------------
1289
+ // parseVerdict — additional edge cases
1290
+ // ---------------------------------------------------------------------------
1291
+
1292
+ describe("parseVerdict (additional edge cases)", () => {
1293
+ it("ignores JSON without pass field", () => {
1294
+ const output = '{"criteria": ["test"], "gaps": []}';
1295
+ expect(parseVerdict(output)).toBeNull();
1296
+ });
1297
+
1298
+ it("handles pass field with non-boolean criteria/gaps/testResults", () => {
1299
+ const output = '{"pass": false, "criteria": "not-array", "gaps": 42, "testResults": 123}';
1300
+ const v = parseVerdict(output)!;
1301
+ expect(v.pass).toBe(false);
1302
+ expect(v.criteria).toEqual([]);
1303
+ expect(v.gaps).toEqual([]);
1304
+ expect(v.testResults).toBe("");
1305
+ });
1306
+
1307
+ it("handles JSON with extra whitespace and formatting", () => {
1308
+ const output = `
1309
+ {
1310
+ "pass" : true ,
1311
+ "criteria" : ["a", "b"],
1312
+ "gaps":[],
1313
+ "testResults":"all pass"
1314
+ }
1315
+ `;
1316
+ const v = parseVerdict(output)!;
1317
+ expect(v.pass).toBe(true);
1318
+ expect(v.criteria).toEqual(["a", "b"]);
1319
+ });
1320
+ });
1321
+
1322
+ // ---------------------------------------------------------------------------
1323
+ // processVerdict — .catch() error branches
1324
+ // ---------------------------------------------------------------------------
1325
+
1326
+ describe("processVerdict (error branch coverage)", () => {
1327
+ beforeEach(() => {
1328
+ vi.clearAllMocks();
1329
+ clearPromptCache();
1330
+ (markEventProcessed as any).mockResolvedValue(true);
1331
+ (transitionDispatch as any).mockResolvedValue({});
1332
+ (completeDispatch as any).mockResolvedValue(undefined);
1333
+ (readDispatchState as any).mockResolvedValue({
1334
+ version: 2,
1335
+ dispatches: { active: {}, completed: {} },
1336
+ sessionMap: {},
1337
+ processedEvents: [],
1338
+ });
1339
+ (buildSummaryFromArtifacts as any).mockReturnValue(null);
1340
+ (resolveOrchestratorWorkspace as any).mockReturnValue("/tmp/ws");
1341
+ });
1342
+
1343
+ it("audit PASS — handles createComment rejection gracefully", async () => {
1344
+ const linearApi = makeMockLinearApi();
1345
+ linearApi.createComment.mockRejectedValue(new Error("comment failed"));
1346
+ const ctx = makeHookCtx({ linearApi: linearApi as any });
1347
+ const dispatch = makeDispatch({ status: "auditing" as any });
1348
+
1349
+ // Should NOT throw — .catch() handles it
1350
+ await processVerdict(ctx, dispatch, {
1351
+ success: true,
1352
+ output: '{"pass": true, "criteria": [], "gaps": [], "testResults": ""}',
1353
+ }, "audit-err-1");
1354
+
1355
+ expect(ctx.api.logger.error).toHaveBeenCalledWith(
1356
+ expect.stringContaining("failed to post audit pass comment"),
1357
+ );
1358
+ });
1359
+
1360
+ it("audit PASS with project — handles DAG cascade rejection gracefully", async () => {
1361
+ (onProjectIssueCompleted as any).mockRejectedValue(new Error("dag error"));
1362
+ const ctx = makeHookCtx();
1363
+ const dispatch = makeDispatch({ status: "auditing" as any, project: "proj-err" });
1364
+
1365
+ await processVerdict(ctx, dispatch, {
1366
+ success: true,
1367
+ output: '{"pass": true, "criteria": [], "gaps": [], "testResults": ""}',
1368
+ }, "audit-err-2");
1369
+
1370
+ // Wait for the void promise .catch() to fire
1371
+ await new Promise((r) => setTimeout(r, 50));
1372
+ expect(ctx.api.logger.error).toHaveBeenCalledWith(
1373
+ expect.stringContaining("DAG cascade error"),
1374
+ );
1375
+ });
1376
+
1377
+ it("audit FAIL escalation — handles createComment rejection gracefully", async () => {
1378
+ const linearApi = makeMockLinearApi();
1379
+ linearApi.createComment.mockRejectedValue(new Error("comment api down"));
1380
+ const ctx = makeHookCtx({ linearApi: linearApi as any, pluginConfig: { maxReworkAttempts: 0 } });
1381
+ const dispatch = makeDispatch({ status: "auditing" as any, attempt: 0 });
1382
+
1383
+ await processVerdict(ctx, dispatch, {
1384
+ success: true,
1385
+ output: '{"pass": false, "criteria": [], "gaps": ["broken"], "testResults": ""}',
1386
+ }, "audit-err-3");
1387
+
1388
+ expect(ctx.api.logger.error).toHaveBeenCalledWith(
1389
+ expect.stringContaining("failed to post escalation comment"),
1390
+ );
1391
+ });
1392
+
1393
+ it("audit FAIL escalation with project — handles DAG stuck cascade rejection", async () => {
1394
+ (onProjectIssueStuck as any).mockRejectedValue(new Error("dag stuck error"));
1395
+ const ctx = makeHookCtx({ pluginConfig: { maxReworkAttempts: 0 } });
1396
+ const dispatch = makeDispatch({ status: "auditing" as any, attempt: 0, project: "proj-stuck" });
1397
+
1398
+ await processVerdict(ctx, dispatch, {
1399
+ success: true,
1400
+ output: '{"pass": false, "criteria": [], "gaps": ["broken"], "testResults": ""}',
1401
+ }, "audit-err-4");
1402
+
1403
+ await new Promise((r) => setTimeout(r, 50));
1404
+ expect(ctx.api.logger.error).toHaveBeenCalledWith(
1405
+ expect.stringContaining("DAG stuck cascade error"),
1406
+ );
1407
+ });
1408
+
1409
+ it("audit FAIL rework — handles createComment rejection gracefully", async () => {
1410
+ const linearApi = makeMockLinearApi();
1411
+ linearApi.createComment.mockRejectedValue(new Error("api timeout"));
1412
+ const ctx = makeHookCtx({ linearApi: linearApi as any, pluginConfig: { maxReworkAttempts: 3 } });
1413
+ const dispatch = makeDispatch({ status: "auditing" as any, attempt: 0 });
1414
+
1415
+ await processVerdict(ctx, dispatch, {
1416
+ success: true,
1417
+ output: '{"pass": false, "criteria": [], "gaps": ["fix it"], "testResults": ""}',
1418
+ }, "audit-err-5");
1419
+
1420
+ expect(ctx.api.logger.error).toHaveBeenCalledWith(
1421
+ expect.stringContaining("failed to post rework comment"),
1422
+ );
1423
+ });
1424
+
1425
+ it("unparseable verdict — handles createComment rejection gracefully", async () => {
1426
+ const linearApi = makeMockLinearApi();
1427
+ linearApi.createComment.mockRejectedValue(new Error("rate limited"));
1428
+ const ctx = makeHookCtx({ linearApi: linearApi as any });
1429
+ const dispatch = makeDispatch({ status: "auditing" as any });
1430
+
1431
+ // Should not throw
1432
+ await processVerdict(ctx, dispatch, {
1433
+ success: true,
1434
+ output: "no json verdict at all",
1435
+ }, "audit-err-6");
1436
+
1437
+ expect(ctx.api.logger.error).toHaveBeenCalledWith(
1438
+ expect.stringContaining("failed to post inconclusive comment"),
1439
+ );
1440
+ });
1441
+
1442
+ it("audit PASS — handles buildSummaryFromArtifacts throw gracefully", async () => {
1443
+ (buildSummaryFromArtifacts as any).mockImplementation(() => { throw new Error("fs error"); });
1444
+ const ctx = makeHookCtx();
1445
+ const dispatch = makeDispatch({ status: "auditing" as any });
1446
+
1447
+ // Should not throw — error is caught
1448
+ await processVerdict(ctx, dispatch, {
1449
+ success: true,
1450
+ output: '{"pass": true, "criteria": [], "gaps": [], "testResults": ""}',
1451
+ }, "audit-err-7");
1452
+
1453
+ expect(ctx.api.logger.warn).toHaveBeenCalledWith(
1454
+ expect.stringContaining("failed to write summary/memory"),
1455
+ );
1456
+ });
1457
+ });
1458
+
1459
+ // ---------------------------------------------------------------------------
1460
+ // triggerAudit — additional branch coverage for emitActivity.catch
1461
+ // ---------------------------------------------------------------------------
1462
+
1463
+ describe("triggerAudit (error branch coverage)", () => {
1464
+ beforeEach(() => {
1465
+ vi.clearAllMocks();
1466
+ clearPromptCache();
1467
+ (markEventProcessed as any).mockResolvedValue(true);
1468
+ (transitionDispatch as any).mockResolvedValue({});
1469
+ (readDispatchState as any).mockResolvedValue({
1470
+ version: 2,
1471
+ dispatches: { active: {}, completed: {} },
1472
+ sessionMap: {},
1473
+ processedEvents: [],
1474
+ });
1475
+ (getActiveDispatch as any).mockReturnValue(makeDispatch());
1476
+ (registerSessionMapping as any).mockResolvedValue(undefined);
1477
+ (completeDispatch as any).mockResolvedValue(undefined);
1478
+ (buildSummaryFromArtifacts as any).mockReturnValue(null);
1479
+ (resolveOrchestratorWorkspace as any).mockReturnValue("/tmp/ws");
1480
+ (runAgent as any).mockResolvedValue({ success: true, output: '{"pass": true, "criteria": [], "gaps": [], "testResults": ""}' });
1481
+ });
1482
+
1483
+ it("handles emitActivity rejection gracefully", async () => {
1484
+ const linearApi = makeMockLinearApi();
1485
+ linearApi.emitActivity.mockRejectedValue(new Error("activity api down"));
1486
+ const ctx = makeHookCtx({ linearApi: linearApi as any });
1487
+ const dispatch = makeDispatch({ agentSessionId: "session-1" });
1488
+
1489
+ // Should not throw — .catch() swallows
1490
+ await triggerAudit(ctx, dispatch, { success: true, output: "output" }, "trig-err-1");
1491
+
1492
+ // Should still proceed to spawn audit agent
1493
+ expect(runAgent).toHaveBeenCalled();
1494
+ });
1495
+ });
1496
+
1497
+ // ---------------------------------------------------------------------------
1498
+ // spawnWorker — .catch() error branches
1499
+ // ---------------------------------------------------------------------------
1500
+
1501
+ describe("spawnWorker (error branch coverage)", () => {
1502
+ beforeEach(() => {
1503
+ vi.clearAllMocks();
1504
+ clearPromptCache();
1505
+ (transitionDispatch as any).mockResolvedValue({});
1506
+ (registerSessionMapping as any).mockResolvedValue(undefined);
1507
+ (markEventProcessed as any).mockResolvedValue(true);
1508
+ (completeDispatch as any).mockResolvedValue(undefined);
1509
+ (readDispatchState as any).mockResolvedValue({
1510
+ version: 2,
1511
+ dispatches: { active: { "ENG-100": makeDispatch() }, completed: {} },
1512
+ sessionMap: {},
1513
+ processedEvents: [],
1514
+ });
1515
+ (getActiveDispatch as any).mockReturnValue(makeDispatch());
1516
+ (buildSummaryFromArtifacts as any).mockReturnValue(null);
1517
+ (resolveOrchestratorWorkspace as any).mockReturnValue("/tmp/ws");
1518
+ });
1519
+
1520
+ it("watchdog kill — handles createComment rejection gracefully", async () => {
1521
+ (runAgent as any).mockResolvedValue({
1522
+ success: false,
1523
+ output: "timed out",
1524
+ watchdogKilled: true,
1525
+ });
1526
+ const linearApi = makeMockLinearApi();
1527
+ linearApi.createComment.mockRejectedValue(new Error("api down"));
1528
+ const ctx = makeHookCtx({ linearApi: linearApi as any });
1529
+ const dispatch = makeDispatch({ status: "working" as any });
1530
+
1531
+ // Should not throw — .catch() swallows
1532
+ await spawnWorker(ctx, dispatch);
1533
+
1534
+ // The function should still complete (notify, clearActiveSession)
1535
+ expect(ctx.notify).toHaveBeenCalledWith(
1536
+ "watchdog_kill",
1537
+ expect.objectContaining({ status: "stuck" }),
1538
+ );
1539
+ expect(clearActiveSession).toHaveBeenCalled();
1540
+ });
1541
+ });