@calltelemetry/openclaw-linear 0.9.1 → 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 +390 -257
- package/package.json +1 -1
- package/src/__test__/smoke-linear-api.test.ts +147 -0
- package/src/infra/doctor.test.ts +762 -2
- package/src/pipeline/dag-dispatch.test.ts +444 -0
- package/src/pipeline/e2e-dispatch.test.ts +135 -0
- package/src/pipeline/pipeline.test.ts +1326 -1
- package/src/pipeline/planner.test.ts +457 -3
- package/src/pipeline/planning-state.test.ts +164 -3
- package/src/pipeline/webhook.test.ts +2438 -19
- package/src/tools/planner-tools.test.ts +722 -0
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
isProjectStuck,
|
|
11
11
|
readProjectDispatch,
|
|
12
12
|
writeProjectDispatch,
|
|
13
|
+
startProjectDispatch,
|
|
14
|
+
dispatchReadyIssues,
|
|
13
15
|
onProjectIssueCompleted,
|
|
14
16
|
onProjectIssueStuck,
|
|
15
17
|
type ProjectIssueStatus,
|
|
@@ -550,4 +552,446 @@ describe("onProjectIssueStuck", () => {
|
|
|
550
552
|
const result = await readProjectDispatch("proj-1", p);
|
|
551
553
|
expect(result!.status).toBe("completed");
|
|
552
554
|
});
|
|
555
|
+
|
|
556
|
+
it("does nothing if issue not found in dispatch", async () => {
|
|
557
|
+
const p = tmpStatePath();
|
|
558
|
+
const pd = makeProjectDispatch({
|
|
559
|
+
issues: { "PROJ-1": makeIssueStatus("PROJ-1", { dispatchStatus: "dispatched" }) },
|
|
560
|
+
});
|
|
561
|
+
await writeProjectDispatch(pd, p);
|
|
562
|
+
|
|
563
|
+
const hookCtx = makeHookCtx(p);
|
|
564
|
+
await onProjectIssueStuck(hookCtx, "proj-1", "PROJ-999");
|
|
565
|
+
|
|
566
|
+
// Should not crash, PROJ-1 remains dispatched
|
|
567
|
+
const result = await readProjectDispatch("proj-1", p);
|
|
568
|
+
expect(result!.issues["PROJ-1"].dispatchStatus).toBe("dispatched");
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
it("does nothing if projectDispatch is null (non-existent project)", async () => {
|
|
572
|
+
const p = tmpStatePath();
|
|
573
|
+
|
|
574
|
+
const hookCtx = makeHookCtx(p);
|
|
575
|
+
// Should not crash on non-existent project
|
|
576
|
+
await onProjectIssueStuck(hookCtx, "no-such-proj", "PROJ-1");
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
// ---------------------------------------------------------------------------
|
|
581
|
+
// buildDispatchQueue — additional branch coverage
|
|
582
|
+
// ---------------------------------------------------------------------------
|
|
583
|
+
|
|
584
|
+
describe("buildDispatchQueue — additional branches", () => {
|
|
585
|
+
it("ignores relations with null relatedIssue", () => {
|
|
586
|
+
const issues = [
|
|
587
|
+
makeIssue("PROJ-1", {
|
|
588
|
+
relations: [{ type: "blocks", relatedIssue: null as any }],
|
|
589
|
+
}),
|
|
590
|
+
];
|
|
591
|
+
const queue = buildDispatchQueue(issues);
|
|
592
|
+
expect(queue["PROJ-1"].unblocks).toHaveLength(0);
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
it("ignores relations with undefined identifier", () => {
|
|
596
|
+
const issues = [
|
|
597
|
+
makeIssue("PROJ-1", {
|
|
598
|
+
relations: [{ type: "blocks", relatedIssue: { identifier: undefined as any } }],
|
|
599
|
+
}),
|
|
600
|
+
];
|
|
601
|
+
const queue = buildDispatchQueue(issues);
|
|
602
|
+
expect(queue["PROJ-1"].unblocks).toHaveLength(0);
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
it("ignores non-blocks/non-blocked_by relation types", () => {
|
|
606
|
+
const issues = [
|
|
607
|
+
makeIssue("PROJ-1", {
|
|
608
|
+
relations: [{ type: "related", relatedIssue: { identifier: "PROJ-2" } }],
|
|
609
|
+
}),
|
|
610
|
+
makeIssue("PROJ-2"),
|
|
611
|
+
];
|
|
612
|
+
const queue = buildDispatchQueue(issues);
|
|
613
|
+
expect(queue["PROJ-1"].unblocks).toHaveLength(0);
|
|
614
|
+
expect(queue["PROJ-1"].dependsOn).toHaveLength(0);
|
|
615
|
+
expect(queue["PROJ-2"].dependsOn).toHaveLength(0);
|
|
616
|
+
expect(queue["PROJ-2"].unblocks).toHaveLength(0);
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
it("filters skipped (epic) dependencies from unblocks lists", () => {
|
|
620
|
+
const issues = [
|
|
621
|
+
makeIssue("PROJ-1", {
|
|
622
|
+
relations: [{ type: "blocks", relatedIssue: { identifier: "PROJ-2" } }],
|
|
623
|
+
}),
|
|
624
|
+
makeIssue("PROJ-2", { labels: ["Epic"] }),
|
|
625
|
+
];
|
|
626
|
+
const queue = buildDispatchQueue(issues);
|
|
627
|
+
// PROJ-1 unblocks PROJ-2 but PROJ-2 is skipped — should be filtered
|
|
628
|
+
expect(queue["PROJ-1"].unblocks).toHaveLength(0);
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
it("handles issues with null labels gracefully", () => {
|
|
632
|
+
const issue = {
|
|
633
|
+
id: "id-PROJ-1",
|
|
634
|
+
identifier: "PROJ-1",
|
|
635
|
+
labels: null as any,
|
|
636
|
+
relations: { nodes: [] },
|
|
637
|
+
};
|
|
638
|
+
const queue = buildDispatchQueue([issue as any]);
|
|
639
|
+
// Should not crash; labels?.nodes?.some returns falsy
|
|
640
|
+
expect(queue["PROJ-1"].dispatchStatus).toBe("pending");
|
|
641
|
+
});
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
// ---------------------------------------------------------------------------
|
|
645
|
+
// getReadyIssues — additional branch coverage
|
|
646
|
+
// ---------------------------------------------------------------------------
|
|
647
|
+
|
|
648
|
+
describe("getReadyIssues — additional branches", () => {
|
|
649
|
+
it("excludes stuck issues", () => {
|
|
650
|
+
const issues: Record<string, ProjectIssueStatus> = {
|
|
651
|
+
A: makeIssueStatus("A", { dispatchStatus: "stuck" }),
|
|
652
|
+
};
|
|
653
|
+
expect(getReadyIssues(issues)).toHaveLength(0);
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
it("excludes skipped issues", () => {
|
|
657
|
+
const issues: Record<string, ProjectIssueStatus> = {
|
|
658
|
+
A: makeIssueStatus("A", { dispatchStatus: "skipped" }),
|
|
659
|
+
};
|
|
660
|
+
expect(getReadyIssues(issues)).toHaveLength(0);
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
it("excludes pending issues where a dep is stuck", () => {
|
|
664
|
+
const issues: Record<string, ProjectIssueStatus> = {
|
|
665
|
+
A: makeIssueStatus("A", { dispatchStatus: "stuck" }),
|
|
666
|
+
B: makeIssueStatus("B", { dependsOn: ["A"] }),
|
|
667
|
+
};
|
|
668
|
+
expect(getReadyIssues(issues)).toHaveLength(0);
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
it("returns empty for empty issues", () => {
|
|
672
|
+
expect(getReadyIssues({})).toHaveLength(0);
|
|
673
|
+
});
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
// ---------------------------------------------------------------------------
|
|
677
|
+
// isProjectStuck — additional branch coverage
|
|
678
|
+
// ---------------------------------------------------------------------------
|
|
679
|
+
|
|
680
|
+
describe("isProjectStuck — additional branches", () => {
|
|
681
|
+
it("false when no issues at all", () => {
|
|
682
|
+
expect(isProjectStuck({})).toBe(false);
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
it("false when all issues are done (no stuck)", () => {
|
|
686
|
+
const issues: Record<string, ProjectIssueStatus> = {
|
|
687
|
+
A: makeIssueStatus("A", { dispatchStatus: "done" }),
|
|
688
|
+
B: makeIssueStatus("B", { dispatchStatus: "done" }),
|
|
689
|
+
};
|
|
690
|
+
expect(isProjectStuck(issues)).toBe(false);
|
|
691
|
+
});
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
// ---------------------------------------------------------------------------
|
|
695
|
+
// dispatchReadyIssues
|
|
696
|
+
// ---------------------------------------------------------------------------
|
|
697
|
+
|
|
698
|
+
describe("dispatchReadyIssues", () => {
|
|
699
|
+
it("returns early when no ready issues", async () => {
|
|
700
|
+
const p = tmpStatePath();
|
|
701
|
+
const hookCtx = makeHookCtx(p);
|
|
702
|
+
const pd = makeProjectDispatch({
|
|
703
|
+
issues: {
|
|
704
|
+
"PROJ-1": makeIssueStatus("PROJ-1", { dispatchStatus: "dispatched" }),
|
|
705
|
+
"PROJ-2": makeIssueStatus("PROJ-2", { dependsOn: ["PROJ-1"] }),
|
|
706
|
+
},
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
await dispatchReadyIssues(hookCtx, pd);
|
|
710
|
+
|
|
711
|
+
// No issues dispatched — logger.info not called for dispatching
|
|
712
|
+
expect(hookCtx.api.logger.info).not.toHaveBeenCalledWith(
|
|
713
|
+
expect.stringContaining("dispatching PROJ"),
|
|
714
|
+
);
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
it("returns early when all slots are filled", async () => {
|
|
718
|
+
const p = tmpStatePath();
|
|
719
|
+
const hookCtx = makeHookCtx(p);
|
|
720
|
+
const pd = makeProjectDispatch({
|
|
721
|
+
maxConcurrent: 1,
|
|
722
|
+
issues: {
|
|
723
|
+
"PROJ-1": makeIssueStatus("PROJ-1", { dispatchStatus: "dispatched" }),
|
|
724
|
+
"PROJ-2": makeIssueStatus("PROJ-2"),
|
|
725
|
+
},
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
await dispatchReadyIssues(hookCtx, pd);
|
|
729
|
+
|
|
730
|
+
// PROJ-2 is ready but no slots — should not be dispatched
|
|
731
|
+
expect(pd.issues["PROJ-2"].dispatchStatus).toBe("pending");
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
it("dispatches ready issues up to max concurrent", async () => {
|
|
735
|
+
const p = tmpStatePath();
|
|
736
|
+
const hookCtx = makeHookCtx(p);
|
|
737
|
+
const pd = makeProjectDispatch({
|
|
738
|
+
maxConcurrent: 2,
|
|
739
|
+
issues: {
|
|
740
|
+
"PROJ-1": makeIssueStatus("PROJ-1"),
|
|
741
|
+
"PROJ-2": makeIssueStatus("PROJ-2"),
|
|
742
|
+
"PROJ-3": makeIssueStatus("PROJ-3"),
|
|
743
|
+
},
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
await dispatchReadyIssues(hookCtx, pd);
|
|
747
|
+
|
|
748
|
+
const dispatched = Object.values(pd.issues).filter((i) => i.dispatchStatus === "dispatched");
|
|
749
|
+
expect(dispatched).toHaveLength(2);
|
|
750
|
+
});
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
// ---------------------------------------------------------------------------
|
|
754
|
+
// startProjectDispatch
|
|
755
|
+
// ---------------------------------------------------------------------------
|
|
756
|
+
|
|
757
|
+
describe("startProjectDispatch", () => {
|
|
758
|
+
it("creates dispatch state and dispatches leaf issues", async () => {
|
|
759
|
+
const p = tmpStatePath();
|
|
760
|
+
const mockIssues = [
|
|
761
|
+
makeIssue("PROJ-1", {
|
|
762
|
+
relations: [{ type: "blocks", relatedIssue: { identifier: "PROJ-2" } }],
|
|
763
|
+
}),
|
|
764
|
+
makeIssue("PROJ-2"),
|
|
765
|
+
];
|
|
766
|
+
|
|
767
|
+
const hookCtx = {
|
|
768
|
+
api: {
|
|
769
|
+
logger: {
|
|
770
|
+
info: vi.fn(),
|
|
771
|
+
warn: vi.fn(),
|
|
772
|
+
error: vi.fn(),
|
|
773
|
+
},
|
|
774
|
+
} as any,
|
|
775
|
+
linearApi: {
|
|
776
|
+
getProject: vi.fn().mockResolvedValue({ id: "proj-1", name: "Test Project" }),
|
|
777
|
+
getProjectIssues: vi.fn().mockResolvedValue(mockIssues),
|
|
778
|
+
} as any,
|
|
779
|
+
notify: vi.fn().mockResolvedValue(undefined),
|
|
780
|
+
pluginConfig: { planningStatePath: p },
|
|
781
|
+
configPath: p,
|
|
782
|
+
};
|
|
783
|
+
|
|
784
|
+
await startProjectDispatch(hookCtx, "proj-1");
|
|
785
|
+
|
|
786
|
+
const result = await readProjectDispatch("proj-1", p);
|
|
787
|
+
expect(result).not.toBeNull();
|
|
788
|
+
expect(result!.status).toBe("dispatching");
|
|
789
|
+
expect(result!.projectName).toBe("Test Project");
|
|
790
|
+
expect(Object.keys(result!.issues)).toHaveLength(2);
|
|
791
|
+
expect(hookCtx.notify).toHaveBeenCalledWith("project_progress", expect.any(Object));
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
it("returns early when project has no issues", async () => {
|
|
795
|
+
const p = tmpStatePath();
|
|
796
|
+
const hookCtx = {
|
|
797
|
+
api: {
|
|
798
|
+
logger: {
|
|
799
|
+
info: vi.fn(),
|
|
800
|
+
warn: vi.fn(),
|
|
801
|
+
error: vi.fn(),
|
|
802
|
+
},
|
|
803
|
+
} as any,
|
|
804
|
+
linearApi: {
|
|
805
|
+
getProject: vi.fn().mockResolvedValue({ id: "proj-1", name: "Empty Project" }),
|
|
806
|
+
getProjectIssues: vi.fn().mockResolvedValue([]),
|
|
807
|
+
} as any,
|
|
808
|
+
notify: vi.fn().mockResolvedValue(undefined),
|
|
809
|
+
pluginConfig: { planningStatePath: p },
|
|
810
|
+
configPath: p,
|
|
811
|
+
};
|
|
812
|
+
|
|
813
|
+
await startProjectDispatch(hookCtx, "proj-1");
|
|
814
|
+
|
|
815
|
+
expect(hookCtx.api.logger.warn).toHaveBeenCalledWith(
|
|
816
|
+
expect.stringContaining("no issues found"),
|
|
817
|
+
);
|
|
818
|
+
const result = await readProjectDispatch("proj-1", p);
|
|
819
|
+
expect(result).toBeNull();
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
it("uses maxConcurrent from opts when provided", async () => {
|
|
823
|
+
const p = tmpStatePath();
|
|
824
|
+
const hookCtx = {
|
|
825
|
+
api: {
|
|
826
|
+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
827
|
+
} as any,
|
|
828
|
+
linearApi: {
|
|
829
|
+
getProject: vi.fn().mockResolvedValue({ id: "proj-1", name: "Test" }),
|
|
830
|
+
getProjectIssues: vi.fn().mockResolvedValue([makeIssue("PROJ-1")]),
|
|
831
|
+
} as any,
|
|
832
|
+
notify: vi.fn().mockResolvedValue(undefined),
|
|
833
|
+
pluginConfig: { planningStatePath: p },
|
|
834
|
+
configPath: p,
|
|
835
|
+
};
|
|
836
|
+
|
|
837
|
+
await startProjectDispatch(hookCtx, "proj-1", { maxConcurrent: 5 });
|
|
838
|
+
|
|
839
|
+
const result = await readProjectDispatch("proj-1", p);
|
|
840
|
+
expect(result!.maxConcurrent).toBe(5);
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
it("uses maxConcurrent from pluginConfig when opts not provided", async () => {
|
|
844
|
+
const p = tmpStatePath();
|
|
845
|
+
const hookCtx = {
|
|
846
|
+
api: {
|
|
847
|
+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
848
|
+
} as any,
|
|
849
|
+
linearApi: {
|
|
850
|
+
getProject: vi.fn().mockResolvedValue({ id: "proj-1", name: "Test" }),
|
|
851
|
+
getProjectIssues: vi.fn().mockResolvedValue([makeIssue("PROJ-1")]),
|
|
852
|
+
} as any,
|
|
853
|
+
notify: vi.fn().mockResolvedValue(undefined),
|
|
854
|
+
pluginConfig: { planningStatePath: p, maxConcurrentDispatches: 7 },
|
|
855
|
+
configPath: p,
|
|
856
|
+
};
|
|
857
|
+
|
|
858
|
+
await startProjectDispatch(hookCtx, "proj-1");
|
|
859
|
+
|
|
860
|
+
const result = await readProjectDispatch("proj-1", p);
|
|
861
|
+
expect(result!.maxConcurrent).toBe(7);
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
it("defaults maxConcurrent to 3 when no config", async () => {
|
|
865
|
+
const p = tmpStatePath();
|
|
866
|
+
const hookCtx = {
|
|
867
|
+
api: {
|
|
868
|
+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
869
|
+
} as any,
|
|
870
|
+
linearApi: {
|
|
871
|
+
getProject: vi.fn().mockResolvedValue({ id: "proj-1", name: "Test" }),
|
|
872
|
+
getProjectIssues: vi.fn().mockResolvedValue([makeIssue("PROJ-1")]),
|
|
873
|
+
} as any,
|
|
874
|
+
notify: vi.fn().mockResolvedValue(undefined),
|
|
875
|
+
pluginConfig: { planningStatePath: p },
|
|
876
|
+
configPath: p,
|
|
877
|
+
};
|
|
878
|
+
|
|
879
|
+
await startProjectDispatch(hookCtx, "proj-1");
|
|
880
|
+
|
|
881
|
+
const result = await readProjectDispatch("proj-1", p);
|
|
882
|
+
expect(result!.maxConcurrent).toBe(3);
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
it("uses rootIdentifier from planning session when available", async () => {
|
|
886
|
+
const p = tmpStatePath();
|
|
887
|
+
|
|
888
|
+
// Pre-register a planning session
|
|
889
|
+
const { registerPlanningSession } = await import("./planning-state.js");
|
|
890
|
+
await registerPlanningSession("proj-1", {
|
|
891
|
+
projectId: "proj-1",
|
|
892
|
+
projectName: "Test",
|
|
893
|
+
rootIssueId: "issue-1",
|
|
894
|
+
rootIdentifier: "PLAN-100",
|
|
895
|
+
teamId: "team-1",
|
|
896
|
+
status: "approved",
|
|
897
|
+
startedAt: new Date().toISOString(),
|
|
898
|
+
turnCount: 5,
|
|
899
|
+
}, p);
|
|
900
|
+
|
|
901
|
+
const hookCtx = {
|
|
902
|
+
api: {
|
|
903
|
+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
904
|
+
} as any,
|
|
905
|
+
linearApi: {
|
|
906
|
+
getProject: vi.fn().mockResolvedValue({ id: "proj-1", name: "Test Project" }),
|
|
907
|
+
getProjectIssues: vi.fn().mockResolvedValue([makeIssue("PROJ-1")]),
|
|
908
|
+
} as any,
|
|
909
|
+
notify: vi.fn().mockResolvedValue(undefined),
|
|
910
|
+
pluginConfig: { planningStatePath: p },
|
|
911
|
+
configPath: p,
|
|
912
|
+
};
|
|
913
|
+
|
|
914
|
+
await startProjectDispatch(hookCtx, "proj-1");
|
|
915
|
+
|
|
916
|
+
const result = await readProjectDispatch("proj-1", p);
|
|
917
|
+
expect(result!.rootIdentifier).toBe("PLAN-100");
|
|
918
|
+
});
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
// ---------------------------------------------------------------------------
|
|
922
|
+
// onProjectIssueCompleted — additional branches
|
|
923
|
+
// ---------------------------------------------------------------------------
|
|
924
|
+
|
|
925
|
+
describe("onProjectIssueCompleted — additional branches", () => {
|
|
926
|
+
it("does nothing when projectDispatch is null (non-existent project)", async () => {
|
|
927
|
+
const p = tmpStatePath();
|
|
928
|
+
const hookCtx = makeHookCtx(p);
|
|
929
|
+
|
|
930
|
+
// Should not crash
|
|
931
|
+
await onProjectIssueCompleted(hookCtx, "nonexistent", "PROJ-1");
|
|
932
|
+
expect(hookCtx.notify).not.toHaveBeenCalled();
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
it("dispatches newly ready issues after completion", async () => {
|
|
936
|
+
const p = tmpStatePath();
|
|
937
|
+
const pd = makeProjectDispatch({
|
|
938
|
+
issues: {
|
|
939
|
+
"PROJ-1": makeIssueStatus("PROJ-1", { dispatchStatus: "dispatched", unblocks: ["PROJ-2"] }),
|
|
940
|
+
"PROJ-2": makeIssueStatus("PROJ-2", { dependsOn: ["PROJ-1"] }),
|
|
941
|
+
},
|
|
942
|
+
});
|
|
943
|
+
await writeProjectDispatch(pd, p);
|
|
944
|
+
|
|
945
|
+
const hookCtx = makeHookCtx(p);
|
|
946
|
+
await onProjectIssueCompleted(hookCtx, "proj-1", "PROJ-1");
|
|
947
|
+
|
|
948
|
+
// PROJ-2 should now be dispatchable (its dep PROJ-1 is done)
|
|
949
|
+
const result = await readProjectDispatch("proj-1", p);
|
|
950
|
+
expect(result!.issues["PROJ-1"].dispatchStatus).toBe("done");
|
|
951
|
+
// Progress notification should be sent
|
|
952
|
+
expect(hookCtx.notify).toHaveBeenCalledWith("project_progress", expect.objectContaining({
|
|
953
|
+
status: expect.stringContaining("1/2"),
|
|
954
|
+
}));
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
it("counts only non-skipped issues in progress totals", async () => {
|
|
958
|
+
const p = tmpStatePath();
|
|
959
|
+
const pd = makeProjectDispatch({
|
|
960
|
+
issues: {
|
|
961
|
+
"PROJ-1": makeIssueStatus("PROJ-1", { dispatchStatus: "dispatched" }),
|
|
962
|
+
"PROJ-2": makeIssueStatus("PROJ-2", { dispatchStatus: "skipped" }),
|
|
963
|
+
},
|
|
964
|
+
});
|
|
965
|
+
await writeProjectDispatch(pd, p);
|
|
966
|
+
|
|
967
|
+
const hookCtx = makeHookCtx(p);
|
|
968
|
+
await onProjectIssueCompleted(hookCtx, "proj-1", "PROJ-1");
|
|
969
|
+
|
|
970
|
+
// Should count 1/1 (PROJ-2 is skipped)
|
|
971
|
+
const result = await readProjectDispatch("proj-1", p);
|
|
972
|
+
expect(result!.status).toBe("completed");
|
|
973
|
+
expect(hookCtx.notify).toHaveBeenCalledWith("project_complete", expect.objectContaining({
|
|
974
|
+
status: expect.stringContaining("1/1"),
|
|
975
|
+
}));
|
|
976
|
+
});
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
// ---------------------------------------------------------------------------
|
|
980
|
+
// writeProjectDispatch — additional branches
|
|
981
|
+
// ---------------------------------------------------------------------------
|
|
982
|
+
|
|
983
|
+
describe("writeProjectDispatch — additional branches", () => {
|
|
984
|
+
it("initializes projectDispatches when not present in state", async () => {
|
|
985
|
+
const p = tmpStatePath();
|
|
986
|
+
// Write some base planning state first (no projectDispatches key)
|
|
987
|
+
const { writePlanningState } = await import("./planning-state.js");
|
|
988
|
+
await writePlanningState({ sessions: {}, processedEvents: [] }, p);
|
|
989
|
+
|
|
990
|
+
const pd = makeProjectDispatch();
|
|
991
|
+
await writeProjectDispatch(pd, p);
|
|
992
|
+
|
|
993
|
+
const result = await readProjectDispatch("proj-1", p);
|
|
994
|
+
expect(result).not.toBeNull();
|
|
995
|
+
expect(result!.projectId).toBe("proj-1");
|
|
996
|
+
});
|
|
553
997
|
});
|
|
@@ -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
|
});
|