rafcode 1.1.2 → 1.3.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.
- package/RAF/018-workflow-forge/decisions.md +13 -0
- package/RAF/018-workflow-forge/input.md +2 -0
- package/RAF/018-workflow-forge/outcomes/001-add-task-number-progress.md +61 -0
- package/RAF/018-workflow-forge/outcomes/002-update-plan-do-prompts.md +62 -0
- package/RAF/018-workflow-forge/plans/001-add-task-number-progress.md +30 -0
- package/RAF/018-workflow-forge/plans/002-update-plan-do-prompts.md +34 -0
- package/RAF/019-verbose-chronicle/decisions.md +25 -0
- package/RAF/019-verbose-chronicle/input.md +3 -0
- package/RAF/019-verbose-chronicle/outcomes/001-amend-iteration-references.md +25 -0
- package/RAF/019-verbose-chronicle/outcomes/002-verbose-task-name-display.md +31 -0
- package/RAF/019-verbose-chronicle/outcomes/003-verbose-streaming-fix.md +48 -0
- package/RAF/019-verbose-chronicle/outcomes/004-commit-verification-before-halt.md +56 -0
- package/RAF/019-verbose-chronicle/plans/001-amend-iteration-references.md +35 -0
- package/RAF/019-verbose-chronicle/plans/002-verbose-task-name-display.md +38 -0
- package/RAF/019-verbose-chronicle/plans/003-verbose-streaming-fix.md +45 -0
- package/RAF/019-verbose-chronicle/plans/004-commit-verification-before-halt.md +62 -0
- package/dist/commands/do.js +24 -15
- package/dist/commands/do.js.map +1 -1
- package/dist/core/claude-runner.d.ts +52 -1
- package/dist/core/claude-runner.d.ts.map +1 -1
- package/dist/core/claude-runner.js +195 -17
- package/dist/core/claude-runner.js.map +1 -1
- package/dist/core/git.d.ts +15 -0
- package/dist/core/git.d.ts.map +1 -1
- package/dist/core/git.js +44 -0
- package/dist/core/git.js.map +1 -1
- package/dist/parsers/stream-renderer.d.ts +42 -0
- package/dist/parsers/stream-renderer.d.ts.map +1 -0
- package/dist/parsers/stream-renderer.js +100 -0
- package/dist/parsers/stream-renderer.js.map +1 -0
- package/dist/prompts/amend.d.ts.map +1 -1
- package/dist/prompts/amend.js +27 -3
- package/dist/prompts/amend.js.map +1 -1
- package/dist/prompts/execution.d.ts.map +1 -1
- package/dist/prompts/execution.js +1 -2
- package/dist/prompts/execution.js.map +1 -1
- package/dist/prompts/planning.d.ts.map +1 -1
- package/dist/prompts/planning.js +16 -3
- package/dist/prompts/planning.js.map +1 -1
- package/dist/utils/terminal-symbols.d.ts +3 -2
- package/dist/utils/terminal-symbols.d.ts.map +1 -1
- package/dist/utils/terminal-symbols.js +6 -4
- package/dist/utils/terminal-symbols.js.map +1 -1
- package/package.json +1 -1
- package/src/commands/do.ts +25 -15
- package/src/core/claude-runner.ts +270 -17
- package/src/core/git.ts +44 -0
- package/src/parsers/stream-renderer.ts +139 -0
- package/src/prompts/amend.ts +28 -3
- package/src/prompts/execution.ts +1 -2
- package/src/prompts/planning.ts +16 -3
- package/src/utils/terminal-symbols.ts +7 -4
- package/tests/unit/claude-runner.test.ts +567 -1
- package/tests/unit/git-commit-helpers.test.ts +103 -0
- package/tests/unit/plan-command.test.ts +51 -0
- package/tests/unit/stream-renderer.test.ts +286 -0
- package/tests/unit/terminal-symbols.test.ts +20 -0
|
@@ -5,13 +5,40 @@ import { EventEmitter } from 'events';
|
|
|
5
5
|
const mockSpawn = jest.fn();
|
|
6
6
|
const mockExecSync = jest.fn();
|
|
7
7
|
|
|
8
|
+
// Create mock fs functions
|
|
9
|
+
const mockExistsSync = jest.fn();
|
|
10
|
+
const mockStatSync = jest.fn();
|
|
11
|
+
const mockReadFileSync = jest.fn();
|
|
12
|
+
|
|
13
|
+
// Create mock git functions
|
|
14
|
+
const mockGetHeadCommitHash = jest.fn();
|
|
15
|
+
const mockGetHeadCommitMessage = jest.fn();
|
|
16
|
+
const mockIsFileCommittedInHead = jest.fn();
|
|
17
|
+
|
|
8
18
|
jest.unstable_mockModule('node:child_process', () => ({
|
|
9
19
|
spawn: mockSpawn,
|
|
10
20
|
execSync: mockExecSync,
|
|
11
21
|
}));
|
|
12
22
|
|
|
23
|
+
jest.unstable_mockModule('node:fs', () => ({
|
|
24
|
+
default: {
|
|
25
|
+
existsSync: mockExistsSync,
|
|
26
|
+
statSync: mockStatSync,
|
|
27
|
+
readFileSync: mockReadFileSync,
|
|
28
|
+
},
|
|
29
|
+
existsSync: mockExistsSync,
|
|
30
|
+
statSync: mockStatSync,
|
|
31
|
+
readFileSync: mockReadFileSync,
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
jest.unstable_mockModule('../../src/core/git.js', () => ({
|
|
35
|
+
getHeadCommitHash: mockGetHeadCommitHash,
|
|
36
|
+
getHeadCommitMessage: mockGetHeadCommitMessage,
|
|
37
|
+
isFileCommittedInHead: mockIsFileCommittedInHead,
|
|
38
|
+
}));
|
|
39
|
+
|
|
13
40
|
// Import after mocking
|
|
14
|
-
const { ClaudeRunner } = await import('../../src/core/claude-runner.js');
|
|
41
|
+
const { ClaudeRunner, COMPLETION_GRACE_PERIOD_MS, COMPLETION_HARD_MAX_MS, COMMIT_POLL_INTERVAL_MS, OUTCOME_POLL_INTERVAL_MS } = await import('../../src/core/claude-runner.js');
|
|
15
42
|
|
|
16
43
|
describe('ClaudeRunner', () => {
|
|
17
44
|
beforeEach(() => {
|
|
@@ -539,6 +566,93 @@ describe('ClaudeRunner', () => {
|
|
|
539
566
|
});
|
|
540
567
|
});
|
|
541
568
|
|
|
569
|
+
describe('verbose stream-json output', () => {
|
|
570
|
+
function createMockProcess() {
|
|
571
|
+
const stdout = new EventEmitter();
|
|
572
|
+
const stderr = new EventEmitter();
|
|
573
|
+
const proc = new EventEmitter() as any;
|
|
574
|
+
proc.stdout = stdout;
|
|
575
|
+
proc.stderr = stderr;
|
|
576
|
+
proc.kill = jest.fn();
|
|
577
|
+
return proc;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
it('should include --output-format stream-json and --verbose flags in runVerbose()', async () => {
|
|
581
|
+
const mockProc = createMockProcess();
|
|
582
|
+
mockSpawn.mockReturnValue(mockProc);
|
|
583
|
+
|
|
584
|
+
const runner = new ClaudeRunner();
|
|
585
|
+
const runPromise = runner.runVerbose('test prompt', { timeout: 60 });
|
|
586
|
+
|
|
587
|
+
mockProc.emit('close', 0);
|
|
588
|
+
await runPromise;
|
|
589
|
+
|
|
590
|
+
const spawnArgs = mockSpawn.mock.calls[0][1] as string[];
|
|
591
|
+
expect(spawnArgs).toContain('--output-format');
|
|
592
|
+
expect(spawnArgs).toContain('stream-json');
|
|
593
|
+
expect(spawnArgs).toContain('--verbose');
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
it('should NOT include --output-format or --verbose flags in run()', async () => {
|
|
597
|
+
const mockProc = createMockProcess();
|
|
598
|
+
mockSpawn.mockReturnValue(mockProc);
|
|
599
|
+
|
|
600
|
+
const runner = new ClaudeRunner();
|
|
601
|
+
const runPromise = runner.run('test prompt', { timeout: 60 });
|
|
602
|
+
|
|
603
|
+
mockProc.emit('close', 0);
|
|
604
|
+
await runPromise;
|
|
605
|
+
|
|
606
|
+
const spawnArgs = mockSpawn.mock.calls[0][1] as string[];
|
|
607
|
+
expect(spawnArgs).not.toContain('--output-format');
|
|
608
|
+
expect(spawnArgs).not.toContain('stream-json');
|
|
609
|
+
expect(spawnArgs).not.toContain('--verbose');
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
it('should extract text from NDJSON assistant events', async () => {
|
|
613
|
+
const mockProc = createMockProcess();
|
|
614
|
+
mockSpawn.mockReturnValue(mockProc);
|
|
615
|
+
|
|
616
|
+
const runner = new ClaudeRunner();
|
|
617
|
+
const runPromise = runner.runVerbose('test prompt', { timeout: 60 });
|
|
618
|
+
|
|
619
|
+
// Emit NDJSON lines like real stream-json output
|
|
620
|
+
const systemEvent = JSON.stringify({ type: 'system', subtype: 'init' });
|
|
621
|
+
const assistantEvent = JSON.stringify({
|
|
622
|
+
type: 'assistant',
|
|
623
|
+
message: { content: [{ type: 'text', text: 'Task complete.\n<promise>COMPLETE</promise>' }] },
|
|
624
|
+
});
|
|
625
|
+
const resultEvent = JSON.stringify({ type: 'result', subtype: 'success', result: 'Task complete.' });
|
|
626
|
+
|
|
627
|
+
mockProc.stdout.emit('data', Buffer.from(systemEvent + '\n' + assistantEvent + '\n' + resultEvent + '\n'));
|
|
628
|
+
mockProc.emit('close', 0);
|
|
629
|
+
|
|
630
|
+
const result = await runPromise;
|
|
631
|
+
expect(result.output).toContain('Task complete.');
|
|
632
|
+
expect(result.output).toContain('<promise>COMPLETE</promise>');
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
it('should extract text from tool_use NDJSON events without adding to output', async () => {
|
|
636
|
+
const mockProc = createMockProcess();
|
|
637
|
+
mockSpawn.mockReturnValue(mockProc);
|
|
638
|
+
|
|
639
|
+
const runner = new ClaudeRunner();
|
|
640
|
+
const runPromise = runner.runVerbose('test prompt', { timeout: 60 });
|
|
641
|
+
|
|
642
|
+
const toolEvent = JSON.stringify({
|
|
643
|
+
type: 'assistant',
|
|
644
|
+
message: { content: [{ type: 'tool_use', name: 'Read', input: { file_path: '/test.ts' } }] },
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
mockProc.stdout.emit('data', Buffer.from(toolEvent + '\n'));
|
|
648
|
+
mockProc.emit('close', 0);
|
|
649
|
+
|
|
650
|
+
const result = await runPromise;
|
|
651
|
+
// Tool use events don't add text to output
|
|
652
|
+
expect(result.output).toBe('');
|
|
653
|
+
});
|
|
654
|
+
});
|
|
655
|
+
|
|
542
656
|
describe('retry isolation (timeout per attempt)', () => {
|
|
543
657
|
function createMockProcess() {
|
|
544
658
|
const stdout = new EventEmitter();
|
|
@@ -626,4 +740,456 @@ describe('ClaudeRunner', () => {
|
|
|
626
740
|
expect(result2.timedOut).toBe(true);
|
|
627
741
|
});
|
|
628
742
|
});
|
|
743
|
+
|
|
744
|
+
describe('completion detection', () => {
|
|
745
|
+
function createMockProcess() {
|
|
746
|
+
const stdout = new EventEmitter();
|
|
747
|
+
const stderr = new EventEmitter();
|
|
748
|
+
const proc = new EventEmitter() as any;
|
|
749
|
+
proc.stdout = stdout;
|
|
750
|
+
proc.stderr = stderr;
|
|
751
|
+
proc.kill = jest.fn().mockImplementation(() => {
|
|
752
|
+
setImmediate(() => proc.emit('close', 1));
|
|
753
|
+
});
|
|
754
|
+
return proc;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
beforeEach(() => {
|
|
758
|
+
// Default: no outcome file exists
|
|
759
|
+
mockExistsSync.mockReturnValue(false);
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
it('should start grace period when COMPLETE marker detected in stdout', async () => {
|
|
763
|
+
const mockProc = createMockProcess();
|
|
764
|
+
mockSpawn.mockReturnValue(mockProc);
|
|
765
|
+
|
|
766
|
+
const runner = new ClaudeRunner();
|
|
767
|
+
const runPromise = runner.run('test prompt', { timeout: 60 });
|
|
768
|
+
|
|
769
|
+
// Emit output with completion marker
|
|
770
|
+
mockProc.stdout.emit('data', Buffer.from('Writing outcome...\n<promise>COMPLETE</promise>\n'));
|
|
771
|
+
|
|
772
|
+
// Process should not be killed immediately
|
|
773
|
+
expect(mockProc.kill).not.toHaveBeenCalled();
|
|
774
|
+
|
|
775
|
+
// Advance to just before grace period expires
|
|
776
|
+
jest.advanceTimersByTime(COMPLETION_GRACE_PERIOD_MS - 1);
|
|
777
|
+
expect(mockProc.kill).not.toHaveBeenCalled();
|
|
778
|
+
|
|
779
|
+
// Advance past grace period
|
|
780
|
+
jest.advanceTimersByTime(2);
|
|
781
|
+
expect(mockProc.kill).toHaveBeenCalledWith('SIGTERM');
|
|
782
|
+
|
|
783
|
+
const result = await runPromise;
|
|
784
|
+
expect(result.timedOut).toBe(false); // Not a timeout, it's a grace period kill
|
|
785
|
+
expect(result.output).toContain('<promise>COMPLETE</promise>');
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
it('should start grace period when FAILED marker detected in stdout', async () => {
|
|
789
|
+
const mockProc = createMockProcess();
|
|
790
|
+
mockSpawn.mockReturnValue(mockProc);
|
|
791
|
+
|
|
792
|
+
const runner = new ClaudeRunner();
|
|
793
|
+
const runPromise = runner.run('test prompt', { timeout: 60 });
|
|
794
|
+
|
|
795
|
+
// Emit output with failed marker
|
|
796
|
+
mockProc.stdout.emit('data', Buffer.from('<promise>FAILED</promise>\nReason: test error'));
|
|
797
|
+
|
|
798
|
+
// Advance past grace period
|
|
799
|
+
jest.advanceTimersByTime(COMPLETION_GRACE_PERIOD_MS + 1);
|
|
800
|
+
expect(mockProc.kill).toHaveBeenCalledWith('SIGTERM');
|
|
801
|
+
|
|
802
|
+
const result = await runPromise;
|
|
803
|
+
expect(result.timedOut).toBe(false);
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
it('should detect marker across multiple stdout chunks', async () => {
|
|
807
|
+
const mockProc = createMockProcess();
|
|
808
|
+
mockSpawn.mockReturnValue(mockProc);
|
|
809
|
+
|
|
810
|
+
const runner = new ClaudeRunner();
|
|
811
|
+
const runPromise = runner.run('test prompt', { timeout: 60 });
|
|
812
|
+
|
|
813
|
+
// Emit partial output (no marker yet)
|
|
814
|
+
mockProc.stdout.emit('data', Buffer.from('Working on task...\n'));
|
|
815
|
+
jest.advanceTimersByTime(10000);
|
|
816
|
+
expect(mockProc.kill).not.toHaveBeenCalled();
|
|
817
|
+
|
|
818
|
+
// Emit marker in second chunk
|
|
819
|
+
mockProc.stdout.emit('data', Buffer.from('<promise>COMPLETE</promise>\n'));
|
|
820
|
+
|
|
821
|
+
// Grace period starts now - advance past it
|
|
822
|
+
jest.advanceTimersByTime(COMPLETION_GRACE_PERIOD_MS + 1);
|
|
823
|
+
expect(mockProc.kill).toHaveBeenCalledWith('SIGTERM');
|
|
824
|
+
|
|
825
|
+
await runPromise;
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
it('should not start grace period if process exits before grace period expires', async () => {
|
|
829
|
+
const mockProc = createMockProcess();
|
|
830
|
+
// Override kill to NOT auto-close (so we can test natural close)
|
|
831
|
+
mockProc.kill = jest.fn();
|
|
832
|
+
mockSpawn.mockReturnValue(mockProc);
|
|
833
|
+
|
|
834
|
+
const runner = new ClaudeRunner();
|
|
835
|
+
const runPromise = runner.run('test prompt', { timeout: 60 });
|
|
836
|
+
|
|
837
|
+
// Emit completion marker
|
|
838
|
+
mockProc.stdout.emit('data', Buffer.from('<promise>COMPLETE</promise>\n'));
|
|
839
|
+
|
|
840
|
+
// Process exits naturally before grace period
|
|
841
|
+
jest.advanceTimersByTime(5000);
|
|
842
|
+
mockProc.emit('close', 0);
|
|
843
|
+
|
|
844
|
+
const result = await runPromise;
|
|
845
|
+
expect(result.exitCode).toBe(0);
|
|
846
|
+
expect(result.output).toContain('<promise>COMPLETE</promise>');
|
|
847
|
+
|
|
848
|
+
// Grace period should have been cleaned up - advancing further should not call kill
|
|
849
|
+
jest.advanceTimersByTime(COMPLETION_GRACE_PERIOD_MS);
|
|
850
|
+
expect(mockProc.kill).not.toHaveBeenCalled();
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
it('should detect completion via outcome file polling', async () => {
|
|
854
|
+
const mockProc = createMockProcess();
|
|
855
|
+
mockSpawn.mockReturnValue(mockProc);
|
|
856
|
+
|
|
857
|
+
const outcomePath = '/test/project/outcomes/001-task.md';
|
|
858
|
+
|
|
859
|
+
// Outcome file doesn't exist initially
|
|
860
|
+
mockExistsSync.mockReturnValue(false);
|
|
861
|
+
|
|
862
|
+
const runner = new ClaudeRunner();
|
|
863
|
+
const runPromise = runner.run('test prompt', {
|
|
864
|
+
timeout: 60,
|
|
865
|
+
outcomeFilePath: outcomePath,
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
// No output marker in stdout - just regular output
|
|
869
|
+
mockProc.stdout.emit('data', Buffer.from('Working on task...'));
|
|
870
|
+
|
|
871
|
+
// After some time, outcome file appears with marker
|
|
872
|
+
jest.advanceTimersByTime(OUTCOME_POLL_INTERVAL_MS - 1);
|
|
873
|
+
expect(mockProc.kill).not.toHaveBeenCalled();
|
|
874
|
+
|
|
875
|
+
// Mock that file now exists with COMPLETE marker
|
|
876
|
+
mockExistsSync.mockReturnValue(true);
|
|
877
|
+
mockStatSync.mockReturnValue({ mtimeMs: Date.now() + 1000 });
|
|
878
|
+
mockReadFileSync.mockReturnValue('# Outcome\n\n<promise>COMPLETE</promise>\n');
|
|
879
|
+
|
|
880
|
+
// Advance to trigger poll
|
|
881
|
+
jest.advanceTimersByTime(2);
|
|
882
|
+
|
|
883
|
+
// Grace period started - advance past it
|
|
884
|
+
jest.advanceTimersByTime(COMPLETION_GRACE_PERIOD_MS + 1);
|
|
885
|
+
expect(mockProc.kill).toHaveBeenCalledWith('SIGTERM');
|
|
886
|
+
|
|
887
|
+
await runPromise;
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
it('should not trigger on pre-existing outcome file from previous run', async () => {
|
|
891
|
+
const mockProc = createMockProcess();
|
|
892
|
+
// Override kill to NOT auto-close
|
|
893
|
+
mockProc.kill = jest.fn();
|
|
894
|
+
mockSpawn.mockReturnValue(mockProc);
|
|
895
|
+
|
|
896
|
+
const outcomePath = '/test/project/outcomes/001-task.md';
|
|
897
|
+
|
|
898
|
+
// Outcome file already exists from previous failed run
|
|
899
|
+
mockExistsSync.mockReturnValue(true);
|
|
900
|
+
mockStatSync.mockReturnValue({ mtimeMs: 1000 }); // Old timestamp
|
|
901
|
+
mockReadFileSync.mockReturnValue('# Outcome\n\n<promise>FAILED</promise>\n');
|
|
902
|
+
|
|
903
|
+
const runner = new ClaudeRunner();
|
|
904
|
+
const runPromise = runner.run('test prompt', {
|
|
905
|
+
timeout: 60,
|
|
906
|
+
outcomeFilePath: outcomePath,
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
// Advance past several poll intervals - should not trigger because mtime unchanged
|
|
910
|
+
jest.advanceTimersByTime(OUTCOME_POLL_INTERVAL_MS * 5);
|
|
911
|
+
expect(mockProc.kill).not.toHaveBeenCalled();
|
|
912
|
+
|
|
913
|
+
// Complete normally
|
|
914
|
+
mockProc.stdout.emit('data', Buffer.from('done'));
|
|
915
|
+
mockProc.emit('close', 0);
|
|
916
|
+
|
|
917
|
+
await runPromise;
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
it('should work with runVerbose() too', async () => {
|
|
921
|
+
const mockProc = createMockProcess();
|
|
922
|
+
mockSpawn.mockReturnValue(mockProc);
|
|
923
|
+
|
|
924
|
+
const runner = new ClaudeRunner();
|
|
925
|
+
const runPromise = runner.runVerbose('test prompt', { timeout: 60 });
|
|
926
|
+
|
|
927
|
+
// Emit output with completion marker
|
|
928
|
+
mockProc.stdout.emit('data', Buffer.from('<promise>COMPLETE</promise>\n'));
|
|
929
|
+
|
|
930
|
+
// Advance past grace period
|
|
931
|
+
jest.advanceTimersByTime(COMPLETION_GRACE_PERIOD_MS + 1);
|
|
932
|
+
expect(mockProc.kill).toHaveBeenCalledWith('SIGTERM');
|
|
933
|
+
|
|
934
|
+
const result = await runPromise;
|
|
935
|
+
expect(result.timedOut).toBe(false);
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
it('should not start multiple grace periods for repeated markers', async () => {
|
|
939
|
+
const mockProc = createMockProcess();
|
|
940
|
+
// Override kill to NOT auto-close (to test multiple markers)
|
|
941
|
+
mockProc.kill = jest.fn();
|
|
942
|
+
mockSpawn.mockReturnValue(mockProc);
|
|
943
|
+
|
|
944
|
+
const runner = new ClaudeRunner();
|
|
945
|
+
const runPromise = runner.run('test prompt', { timeout: 60 });
|
|
946
|
+
|
|
947
|
+
// Emit first marker
|
|
948
|
+
mockProc.stdout.emit('data', Buffer.from('<promise>COMPLETE</promise>\n'));
|
|
949
|
+
|
|
950
|
+
// Advance halfway through grace period
|
|
951
|
+
jest.advanceTimersByTime(COMPLETION_GRACE_PERIOD_MS / 2);
|
|
952
|
+
|
|
953
|
+
// Emit second marker (e.g., Claude reading back what it wrote)
|
|
954
|
+
mockProc.stdout.emit('data', Buffer.from('Verified: <promise>COMPLETE</promise>\n'));
|
|
955
|
+
|
|
956
|
+
// Advance remaining grace period from FIRST detection
|
|
957
|
+
jest.advanceTimersByTime(COMPLETION_GRACE_PERIOD_MS / 2 + 1);
|
|
958
|
+
|
|
959
|
+
// Should be killed once (from the first grace period)
|
|
960
|
+
expect(mockProc.kill).toHaveBeenCalledTimes(1);
|
|
961
|
+
expect(mockProc.kill).toHaveBeenCalledWith('SIGTERM');
|
|
962
|
+
|
|
963
|
+
// Close process manually for cleanup
|
|
964
|
+
mockProc.emit('close', 1);
|
|
965
|
+
await runPromise;
|
|
966
|
+
});
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
describe('commit verification during grace period', () => {
|
|
970
|
+
function createMockProcess() {
|
|
971
|
+
const stdout = new EventEmitter();
|
|
972
|
+
const stderr = new EventEmitter();
|
|
973
|
+
const proc = new EventEmitter() as any;
|
|
974
|
+
proc.stdout = stdout;
|
|
975
|
+
proc.stderr = stderr;
|
|
976
|
+
proc.kill = jest.fn().mockImplementation(() => {
|
|
977
|
+
setImmediate(() => proc.emit('close', 1));
|
|
978
|
+
});
|
|
979
|
+
return proc;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
const commitContext = {
|
|
983
|
+
preExecutionHead: 'aaa111',
|
|
984
|
+
expectedPrefix: 'RAF[005:001]',
|
|
985
|
+
outcomeFilePath: '/project/outcomes/001-task.md',
|
|
986
|
+
};
|
|
987
|
+
|
|
988
|
+
beforeEach(() => {
|
|
989
|
+
mockExistsSync.mockReturnValue(false);
|
|
990
|
+
mockGetHeadCommitHash.mockReturnValue('aaa111');
|
|
991
|
+
mockGetHeadCommitMessage.mockReturnValue(null);
|
|
992
|
+
mockIsFileCommittedInHead.mockReturnValue(false);
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
it('should kill immediately after grace period when commit is verified within grace period', async () => {
|
|
996
|
+
const mockProc = createMockProcess();
|
|
997
|
+
mockSpawn.mockReturnValue(mockProc);
|
|
998
|
+
|
|
999
|
+
// Commit lands during grace period
|
|
1000
|
+
mockGetHeadCommitHash.mockReturnValue('bbb222');
|
|
1001
|
+
mockGetHeadCommitMessage.mockReturnValue('RAF[005:001] Add feature');
|
|
1002
|
+
mockIsFileCommittedInHead.mockReturnValue(true);
|
|
1003
|
+
|
|
1004
|
+
const runner = new ClaudeRunner();
|
|
1005
|
+
const runPromise = runner.run('test prompt', {
|
|
1006
|
+
timeout: 60,
|
|
1007
|
+
commitContext,
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
// Emit COMPLETE marker
|
|
1011
|
+
mockProc.stdout.emit('data', Buffer.from('<promise>COMPLETE</promise>\n'));
|
|
1012
|
+
|
|
1013
|
+
// Advance to grace period expiry - commit already verified
|
|
1014
|
+
jest.advanceTimersByTime(COMPLETION_GRACE_PERIOD_MS + 1);
|
|
1015
|
+
expect(mockProc.kill).toHaveBeenCalledWith('SIGTERM');
|
|
1016
|
+
|
|
1017
|
+
await runPromise;
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
it('should extend grace period and find commit during extended polling', async () => {
|
|
1021
|
+
const mockProc = createMockProcess();
|
|
1022
|
+
// Override kill to NOT auto-close so we can test polling
|
|
1023
|
+
mockProc.kill = jest.fn();
|
|
1024
|
+
mockSpawn.mockReturnValue(mockProc);
|
|
1025
|
+
|
|
1026
|
+
// Commit not yet made
|
|
1027
|
+
mockGetHeadCommitHash.mockReturnValue('aaa111');
|
|
1028
|
+
|
|
1029
|
+
const runner = new ClaudeRunner();
|
|
1030
|
+
const runPromise = runner.run('test prompt', {
|
|
1031
|
+
timeout: 60,
|
|
1032
|
+
commitContext,
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
// Emit COMPLETE marker
|
|
1036
|
+
mockProc.stdout.emit('data', Buffer.from('<promise>COMPLETE</promise>\n'));
|
|
1037
|
+
|
|
1038
|
+
// Advance past initial grace period - commit not found, should extend
|
|
1039
|
+
jest.advanceTimersByTime(COMPLETION_GRACE_PERIOD_MS + 1);
|
|
1040
|
+
// Should NOT be killed yet because commit verification failed and we extend
|
|
1041
|
+
expect(mockProc.kill).not.toHaveBeenCalled();
|
|
1042
|
+
|
|
1043
|
+
// Now simulate commit landing during extended polling
|
|
1044
|
+
mockGetHeadCommitHash.mockReturnValue('bbb222');
|
|
1045
|
+
mockGetHeadCommitMessage.mockReturnValue('RAF[005:001] Add feature');
|
|
1046
|
+
mockIsFileCommittedInHead.mockReturnValue(true);
|
|
1047
|
+
|
|
1048
|
+
// Advance to next commit poll interval
|
|
1049
|
+
jest.advanceTimersByTime(COMMIT_POLL_INTERVAL_MS);
|
|
1050
|
+
expect(mockProc.kill).toHaveBeenCalledWith('SIGTERM');
|
|
1051
|
+
|
|
1052
|
+
// Clean up
|
|
1053
|
+
mockProc.emit('close', 1);
|
|
1054
|
+
await runPromise;
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
it('should kill after hard maximum when commit never lands', async () => {
|
|
1058
|
+
const mockProc = createMockProcess();
|
|
1059
|
+
mockSpawn.mockReturnValue(mockProc);
|
|
1060
|
+
|
|
1061
|
+
// Commit never lands
|
|
1062
|
+
mockGetHeadCommitHash.mockReturnValue('aaa111');
|
|
1063
|
+
|
|
1064
|
+
const runner = new ClaudeRunner();
|
|
1065
|
+
const runPromise = runner.run('test prompt', {
|
|
1066
|
+
timeout: 60,
|
|
1067
|
+
commitContext,
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
// Emit COMPLETE marker
|
|
1071
|
+
mockProc.stdout.emit('data', Buffer.from('<promise>COMPLETE</promise>\n'));
|
|
1072
|
+
|
|
1073
|
+
// Advance past initial grace period
|
|
1074
|
+
jest.advanceTimersByTime(COMPLETION_GRACE_PERIOD_MS + 1);
|
|
1075
|
+
expect(mockProc.kill).not.toHaveBeenCalled();
|
|
1076
|
+
|
|
1077
|
+
// Advance to hard maximum
|
|
1078
|
+
jest.advanceTimersByTime(COMPLETION_HARD_MAX_MS - COMPLETION_GRACE_PERIOD_MS);
|
|
1079
|
+
expect(mockProc.kill).toHaveBeenCalledWith('SIGTERM');
|
|
1080
|
+
|
|
1081
|
+
await runPromise;
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
it('should NOT extend grace period for FAILED markers', async () => {
|
|
1085
|
+
const mockProc = createMockProcess();
|
|
1086
|
+
mockSpawn.mockReturnValue(mockProc);
|
|
1087
|
+
|
|
1088
|
+
// Commit never lands (doesn't matter - FAILED shouldn't check)
|
|
1089
|
+
mockGetHeadCommitHash.mockReturnValue('aaa111');
|
|
1090
|
+
|
|
1091
|
+
const runner = new ClaudeRunner();
|
|
1092
|
+
const runPromise = runner.run('test prompt', {
|
|
1093
|
+
timeout: 60,
|
|
1094
|
+
commitContext,
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
// Emit FAILED marker (not COMPLETE)
|
|
1098
|
+
mockProc.stdout.emit('data', Buffer.from('<promise>FAILED</promise>\n'));
|
|
1099
|
+
|
|
1100
|
+
// Grace period should expire normally without extension
|
|
1101
|
+
jest.advanceTimersByTime(COMPLETION_GRACE_PERIOD_MS + 1);
|
|
1102
|
+
expect(mockProc.kill).toHaveBeenCalledWith('SIGTERM');
|
|
1103
|
+
|
|
1104
|
+
await runPromise;
|
|
1105
|
+
});
|
|
1106
|
+
|
|
1107
|
+
it('should work without commitContext (backward compatible)', async () => {
|
|
1108
|
+
const mockProc = createMockProcess();
|
|
1109
|
+
mockSpawn.mockReturnValue(mockProc);
|
|
1110
|
+
|
|
1111
|
+
const runner = new ClaudeRunner();
|
|
1112
|
+
const runPromise = runner.run('test prompt', {
|
|
1113
|
+
timeout: 60,
|
|
1114
|
+
// No commitContext provided
|
|
1115
|
+
});
|
|
1116
|
+
|
|
1117
|
+
// Emit COMPLETE marker
|
|
1118
|
+
mockProc.stdout.emit('data', Buffer.from('<promise>COMPLETE</promise>\n'));
|
|
1119
|
+
|
|
1120
|
+
// Grace period should expire normally (no commit check)
|
|
1121
|
+
jest.advanceTimersByTime(COMPLETION_GRACE_PERIOD_MS + 1);
|
|
1122
|
+
expect(mockProc.kill).toHaveBeenCalledWith('SIGTERM');
|
|
1123
|
+
|
|
1124
|
+
await runPromise;
|
|
1125
|
+
});
|
|
1126
|
+
|
|
1127
|
+
it('should verify commit message prefix matches', async () => {
|
|
1128
|
+
const mockProc = createMockProcess();
|
|
1129
|
+
mockProc.kill = jest.fn();
|
|
1130
|
+
mockSpawn.mockReturnValue(mockProc);
|
|
1131
|
+
|
|
1132
|
+
// HEAD changed but message doesn't match expected prefix
|
|
1133
|
+
mockGetHeadCommitHash.mockReturnValue('bbb222');
|
|
1134
|
+
mockGetHeadCommitMessage.mockReturnValue('RAF[005:002] Wrong task');
|
|
1135
|
+
mockIsFileCommittedInHead.mockReturnValue(true);
|
|
1136
|
+
|
|
1137
|
+
const runner = new ClaudeRunner();
|
|
1138
|
+
const runPromise = runner.run('test prompt', {
|
|
1139
|
+
timeout: 60,
|
|
1140
|
+
commitContext,
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
// Emit COMPLETE marker
|
|
1144
|
+
mockProc.stdout.emit('data', Buffer.from('<promise>COMPLETE</promise>\n'));
|
|
1145
|
+
|
|
1146
|
+
// Grace period expires - commit message doesn't match, should extend
|
|
1147
|
+
jest.advanceTimersByTime(COMPLETION_GRACE_PERIOD_MS + 1);
|
|
1148
|
+
expect(mockProc.kill).not.toHaveBeenCalled();
|
|
1149
|
+
|
|
1150
|
+
// Fix commit message
|
|
1151
|
+
mockGetHeadCommitMessage.mockReturnValue('RAF[005:001] Correct task');
|
|
1152
|
+
|
|
1153
|
+
// Next poll finds it
|
|
1154
|
+
jest.advanceTimersByTime(COMMIT_POLL_INTERVAL_MS);
|
|
1155
|
+
expect(mockProc.kill).toHaveBeenCalledWith('SIGTERM');
|
|
1156
|
+
|
|
1157
|
+
mockProc.emit('close', 1);
|
|
1158
|
+
await runPromise;
|
|
1159
|
+
});
|
|
1160
|
+
|
|
1161
|
+
it('should verify outcome file is committed', async () => {
|
|
1162
|
+
const mockProc = createMockProcess();
|
|
1163
|
+
mockProc.kill = jest.fn();
|
|
1164
|
+
mockSpawn.mockReturnValue(mockProc);
|
|
1165
|
+
|
|
1166
|
+
// HEAD changed and message matches, but file not committed
|
|
1167
|
+
mockGetHeadCommitHash.mockReturnValue('bbb222');
|
|
1168
|
+
mockGetHeadCommitMessage.mockReturnValue('RAF[005:001] Add feature');
|
|
1169
|
+
mockIsFileCommittedInHead.mockReturnValue(false);
|
|
1170
|
+
|
|
1171
|
+
const runner = new ClaudeRunner();
|
|
1172
|
+
const runPromise = runner.run('test prompt', {
|
|
1173
|
+
timeout: 60,
|
|
1174
|
+
commitContext,
|
|
1175
|
+
});
|
|
1176
|
+
|
|
1177
|
+
// Emit COMPLETE marker
|
|
1178
|
+
mockProc.stdout.emit('data', Buffer.from('<promise>COMPLETE</promise>\n'));
|
|
1179
|
+
|
|
1180
|
+
// Grace period expires - file not committed, should extend
|
|
1181
|
+
jest.advanceTimersByTime(COMPLETION_GRACE_PERIOD_MS + 1);
|
|
1182
|
+
expect(mockProc.kill).not.toHaveBeenCalled();
|
|
1183
|
+
|
|
1184
|
+
// File now committed
|
|
1185
|
+
mockIsFileCommittedInHead.mockReturnValue(true);
|
|
1186
|
+
|
|
1187
|
+
// Next poll finds it
|
|
1188
|
+
jest.advanceTimersByTime(COMMIT_POLL_INTERVAL_MS);
|
|
1189
|
+
expect(mockProc.kill).toHaveBeenCalledWith('SIGTERM');
|
|
1190
|
+
|
|
1191
|
+
mockProc.emit('close', 1);
|
|
1192
|
+
await runPromise;
|
|
1193
|
+
});
|
|
1194
|
+
});
|
|
629
1195
|
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { jest } from '@jest/globals';
|
|
2
|
+
|
|
3
|
+
const mockExecSync = jest.fn();
|
|
4
|
+
|
|
5
|
+
jest.unstable_mockModule('node:child_process', () => ({
|
|
6
|
+
execSync: mockExecSync,
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
const { getHeadCommitHash, getHeadCommitMessage, isFileCommittedInHead } = await import('../../src/core/git.js');
|
|
10
|
+
|
|
11
|
+
describe('git commit helper functions', () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
jest.clearAllMocks();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe('getHeadCommitHash', () => {
|
|
17
|
+
it('should return the current HEAD hash', () => {
|
|
18
|
+
mockExecSync.mockReturnValue('abc123def456\n');
|
|
19
|
+
expect(getHeadCommitHash()).toBe('abc123def456');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should return null if not in a git repo', () => {
|
|
23
|
+
mockExecSync.mockImplementation(() => {
|
|
24
|
+
throw new Error('not a git repository');
|
|
25
|
+
});
|
|
26
|
+
expect(getHeadCommitHash()).toBeNull();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should return null for empty output', () => {
|
|
30
|
+
mockExecSync.mockReturnValue('');
|
|
31
|
+
expect(getHeadCommitHash()).toBeNull();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should trim whitespace from hash', () => {
|
|
35
|
+
mockExecSync.mockReturnValue(' abc123 \n');
|
|
36
|
+
expect(getHeadCommitHash()).toBe('abc123');
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('getHeadCommitMessage', () => {
|
|
41
|
+
it('should return the HEAD commit message', () => {
|
|
42
|
+
mockExecSync.mockReturnValue('RAF[005:001] Add validation\n');
|
|
43
|
+
expect(getHeadCommitMessage()).toBe('RAF[005:001] Add validation');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should return null if not in a git repo', () => {
|
|
47
|
+
mockExecSync.mockImplementation(() => {
|
|
48
|
+
throw new Error('not a git repository');
|
|
49
|
+
});
|
|
50
|
+
expect(getHeadCommitMessage()).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should return null for empty output', () => {
|
|
54
|
+
mockExecSync.mockReturnValue('');
|
|
55
|
+
expect(getHeadCommitMessage()).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('isFileCommittedInHead', () => {
|
|
60
|
+
it('should return true when file exists in HEAD', () => {
|
|
61
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
62
|
+
if (typeof cmd === 'string' && cmd.includes('--show-toplevel')) {
|
|
63
|
+
return '/project\n';
|
|
64
|
+
}
|
|
65
|
+
if (typeof cmd === 'string' && cmd.includes('ls-tree')) {
|
|
66
|
+
return '100644 blob abc123\tRAF/outcomes/001-task.md\n';
|
|
67
|
+
}
|
|
68
|
+
return '';
|
|
69
|
+
});
|
|
70
|
+
expect(isFileCommittedInHead('/project/RAF/outcomes/001-task.md')).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should return false when file does not exist in HEAD', () => {
|
|
74
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
75
|
+
if (typeof cmd === 'string' && cmd.includes('--show-toplevel')) {
|
|
76
|
+
return '/project\n';
|
|
77
|
+
}
|
|
78
|
+
if (typeof cmd === 'string' && cmd.includes('ls-tree')) {
|
|
79
|
+
return '';
|
|
80
|
+
}
|
|
81
|
+
return '';
|
|
82
|
+
});
|
|
83
|
+
expect(isFileCommittedInHead('/project/RAF/outcomes/001-task.md')).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should return false if not in a git repo', () => {
|
|
87
|
+
mockExecSync.mockImplementation(() => {
|
|
88
|
+
throw new Error('not a git repository');
|
|
89
|
+
});
|
|
90
|
+
expect(isFileCommittedInHead('/any/path')).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should return false on git command failure', () => {
|
|
94
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
95
|
+
if (typeof cmd === 'string' && cmd.includes('--show-toplevel')) {
|
|
96
|
+
return '/project\n';
|
|
97
|
+
}
|
|
98
|
+
throw new Error('git ls-tree failed');
|
|
99
|
+
});
|
|
100
|
+
expect(isFileCommittedInHead('/project/file.ts')).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
});
|