@stilero/bankan 1.0.21 → 1.1.0
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/client/dist/assets/index-DBbtVfOb.js +50 -0
- package/client/dist/index.html +1 -1
- package/package.json +1 -1
- package/server/src/capabilities.js +42 -0
- package/server/src/capabilities.test.js +50 -0
- package/server/src/index.js +67 -1
- package/server/src/index.test.js +65 -0
- package/server/src/orchestrator.js +80 -5
- package/server/src/orchestrator.pr.test.js +177 -0
- package/server/src/store.js +10 -1
- package/server/src/store.test.js +5 -0
- package/server/src/workflow.js +35 -1
- package/server/src/workflow.test.js +51 -2
- package/client/dist/assets/index-loJ8KKB-.js +0 -50
package/client/dist/index.html
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
8
8
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
9
9
|
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Syne:wght@600;700;800&display=swap" rel="stylesheet" />
|
|
10
|
-
<script type="module" crossorigin src="/assets/index-
|
|
10
|
+
<script type="module" crossorigin src="/assets/index-DBbtVfOb.js"></script>
|
|
11
11
|
<link rel="stylesheet" crossorigin href="/assets/index-BZkAflU1.css">
|
|
12
12
|
</head>
|
|
13
13
|
<body>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stilero/bankan",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Run AI coding agents like a Kanban board. Plan, implement, review and ship code using parallel AI agents across your local repositories.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
const CAPABILITIES_TTL_MS = 30_000;
|
|
4
|
+
|
|
5
|
+
let cachedCapabilities = null;
|
|
6
|
+
let cachedAt = 0;
|
|
7
|
+
|
|
8
|
+
function canRunGhCommand(args) {
|
|
9
|
+
try {
|
|
10
|
+
execFileSync('gh', args, { stdio: 'pipe', encoding: 'utf-8' });
|
|
11
|
+
return true;
|
|
12
|
+
} catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function getGithubCapabilities() {
|
|
18
|
+
const now = Date.now();
|
|
19
|
+
if (cachedCapabilities && now - cachedAt < CAPABILITIES_TTL_MS) {
|
|
20
|
+
return cachedCapabilities;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const ghAvailable = canRunGhCommand(['--version']);
|
|
24
|
+
const ghAuthenticated = ghAvailable ? canRunGhCommand(['auth', 'status']) : false;
|
|
25
|
+
|
|
26
|
+
cachedCapabilities = {
|
|
27
|
+
ghAvailable,
|
|
28
|
+
ghAuthenticated,
|
|
29
|
+
canCreatePullRequests: ghAvailable && ghAuthenticated,
|
|
30
|
+
};
|
|
31
|
+
cachedAt = now;
|
|
32
|
+
return cachedCapabilities;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function isManualPullRequestRequired(capabilities = getGithubCapabilities()) {
|
|
36
|
+
return !capabilities.canCreatePullRequests;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function resetGithubCapabilitiesCache() {
|
|
40
|
+
cachedCapabilities = null;
|
|
41
|
+
cachedAt = 0;
|
|
42
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const execFileSyncMock = vi.fn();
|
|
4
|
+
|
|
5
|
+
vi.mock('node:child_process', () => ({
|
|
6
|
+
execFileSync: execFileSyncMock,
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
describe('GitHub capabilities', () => {
|
|
10
|
+
beforeEach(async () => {
|
|
11
|
+
vi.resetModules();
|
|
12
|
+
execFileSyncMock.mockReset();
|
|
13
|
+
const { resetGithubCapabilitiesCache } = await import('./capabilities.js');
|
|
14
|
+
resetGithubCapabilitiesCache();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('caches capability checks within the TTL', async () => {
|
|
18
|
+
execFileSyncMock.mockImplementation(() => 'ok');
|
|
19
|
+
const { getGithubCapabilities } = await import('./capabilities.js');
|
|
20
|
+
|
|
21
|
+
const first = getGithubCapabilities();
|
|
22
|
+
const second = getGithubCapabilities();
|
|
23
|
+
|
|
24
|
+
expect(first).toEqual({
|
|
25
|
+
ghAvailable: true,
|
|
26
|
+
ghAuthenticated: true,
|
|
27
|
+
canCreatePullRequests: true,
|
|
28
|
+
});
|
|
29
|
+
expect(second).toEqual(first);
|
|
30
|
+
expect(execFileSyncMock).toHaveBeenCalledTimes(2);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('recomputes capabilities after cache reset', async () => {
|
|
34
|
+
execFileSyncMock
|
|
35
|
+
.mockImplementationOnce(() => 'ok')
|
|
36
|
+
.mockImplementationOnce(() => 'ok')
|
|
37
|
+
.mockImplementationOnce(() => {
|
|
38
|
+
throw new Error('gh missing');
|
|
39
|
+
});
|
|
40
|
+
const { getGithubCapabilities, resetGithubCapabilitiesCache } = await import('./capabilities.js');
|
|
41
|
+
|
|
42
|
+
expect(getGithubCapabilities().canCreatePullRequests).toBe(true);
|
|
43
|
+
resetGithubCapabilitiesCache();
|
|
44
|
+
expect(getGithubCapabilities()).toEqual({
|
|
45
|
+
ghAvailable: false,
|
|
46
|
+
ghAuthenticated: false,
|
|
47
|
+
canCreatePullRequests: false,
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
});
|
package/server/src/index.js
CHANGED
|
@@ -8,10 +8,16 @@ import { resolve, dirname as pathDirname, join } from 'node:path';
|
|
|
8
8
|
import { execFileSync } from 'node:child_process';
|
|
9
9
|
import { fileURLToPath } from 'node:url';
|
|
10
10
|
import config, { loadSettings, saveSettings, validateSettings, getWorkspacesDir, getRuntimeStatePaths } from './config.js';
|
|
11
|
+
import { getGithubCapabilities } from './capabilities.js';
|
|
11
12
|
import store from './store.js';
|
|
12
13
|
import agentManager from './agents.js';
|
|
13
14
|
import bus from './events.js';
|
|
14
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
buildMaxReviewBlockerApprovalUpdate,
|
|
17
|
+
buildMaxReviewBlockerExtensionUpdate,
|
|
18
|
+
getLiveTaskAgent,
|
|
19
|
+
stageToRetryStatus,
|
|
20
|
+
} from './workflow.js';
|
|
15
21
|
import { createSessionEntry } from './sessionHistory.js';
|
|
16
22
|
|
|
17
23
|
const app = express();
|
|
@@ -55,6 +61,29 @@ function resolveRetryStatus(task) {
|
|
|
55
61
|
return stageToRetryStatus(task, { planningDisabled, liveAgent });
|
|
56
62
|
}
|
|
57
63
|
|
|
64
|
+
export function approveMaxReviewBlocker(taskId) {
|
|
65
|
+
const task = store.getTask(taskId);
|
|
66
|
+
const updates = buildMaxReviewBlockerApprovalUpdate(task);
|
|
67
|
+
if (!updates) return false;
|
|
68
|
+
if (task?.workspacePath && existsSync(task.workspacePath)) {
|
|
69
|
+
rmSync(task.workspacePath, { recursive: true, force: true });
|
|
70
|
+
}
|
|
71
|
+
store.updateTask(taskId, updates);
|
|
72
|
+
store.appendLog(taskId, 'Human override: approved task to done after max review cycles.');
|
|
73
|
+
bus.emit('max-review-blocker:approved', { taskId });
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function extendMaxReviewBlocker(taskId) {
|
|
78
|
+
const task = store.getTask(taskId);
|
|
79
|
+
const updates = buildMaxReviewBlockerExtensionUpdate(task);
|
|
80
|
+
if (!updates) return false;
|
|
81
|
+
store.updateTask(taskId, updates);
|
|
82
|
+
store.appendLog(taskId, `Human override: increased review limit to ${updates.maxReviewCycles}.`);
|
|
83
|
+
bus.emit('max-review-blocker:extended', { taskId, maxReviewCycles: updates.maxReviewCycles });
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
|
|
58
87
|
// REST API
|
|
59
88
|
app.get('/api/status', (req, res) => {
|
|
60
89
|
res.json({
|
|
@@ -121,6 +150,24 @@ app.patch('/api/tasks/:id/reject', (req, res) => {
|
|
|
121
150
|
res.json({ ok: true });
|
|
122
151
|
});
|
|
123
152
|
|
|
153
|
+
app.patch('/api/tasks/:id/approve-max-review-blocker', (req, res) => {
|
|
154
|
+
const task = store.getTask(req.params.id);
|
|
155
|
+
if (!task) return res.status(404).json({ error: 'Task not found' });
|
|
156
|
+
if (!approveMaxReviewBlocker(task.id)) {
|
|
157
|
+
return res.status(400).json({ error: 'Task is not blocked by maximum review cycles' });
|
|
158
|
+
}
|
|
159
|
+
res.json({ ok: true });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
app.patch('/api/tasks/:id/extend-max-review-blocker', (req, res) => {
|
|
163
|
+
const task = store.getTask(req.params.id);
|
|
164
|
+
if (!task) return res.status(404).json({ error: 'Task not found' });
|
|
165
|
+
if (!extendMaxReviewBlocker(task.id)) {
|
|
166
|
+
return res.status(400).json({ error: 'Task is not blocked by maximum review cycles' });
|
|
167
|
+
}
|
|
168
|
+
res.json({ ok: true });
|
|
169
|
+
});
|
|
170
|
+
|
|
124
171
|
app.delete('/api/tasks/:id', async (req, res) => {
|
|
125
172
|
const task = store.getTask(req.params.id);
|
|
126
173
|
if (!task) return res.status(404).json({ error: 'Task not found' });
|
|
@@ -445,6 +492,7 @@ wss.on('connection', (ws) => {
|
|
|
445
492
|
agents: agentManager.getAllStatus(),
|
|
446
493
|
repos: loadSettings().repos || [],
|
|
447
494
|
settings: loadSettings(),
|
|
495
|
+
capabilities: getGithubCapabilities(),
|
|
448
496
|
},
|
|
449
497
|
ts: Date.now(),
|
|
450
498
|
}));
|
|
@@ -539,6 +587,11 @@ wss.on('connection', (ws) => {
|
|
|
539
587
|
}
|
|
540
588
|
break;
|
|
541
589
|
}
|
|
590
|
+
case 'COMPLETE_MANUAL_PR': {
|
|
591
|
+
const { taskId } = msg.payload || {};
|
|
592
|
+
if (taskId) orchestrator.completeManualPr(taskId);
|
|
593
|
+
break;
|
|
594
|
+
}
|
|
542
595
|
case 'RETRY_TASK': {
|
|
543
596
|
const { taskId } = msg.payload || {};
|
|
544
597
|
const task = store.getTask(taskId);
|
|
@@ -560,6 +613,16 @@ wss.on('connection', (ws) => {
|
|
|
560
613
|
}
|
|
561
614
|
break;
|
|
562
615
|
}
|
|
616
|
+
case 'APPROVE_MAX_REVIEW_BLOCKER': {
|
|
617
|
+
const { taskId } = msg.payload || {};
|
|
618
|
+
if (taskId) approveMaxReviewBlocker(taskId);
|
|
619
|
+
break;
|
|
620
|
+
}
|
|
621
|
+
case 'EXTEND_MAX_REVIEW_BLOCKER': {
|
|
622
|
+
const { taskId } = msg.payload || {};
|
|
623
|
+
if (taskId) extendMaxReviewBlocker(taskId);
|
|
624
|
+
break;
|
|
625
|
+
}
|
|
563
626
|
case 'EDIT_TASK': {
|
|
564
627
|
const { taskId, updates } = msg.payload || {};
|
|
565
628
|
const task = store.getTask(taskId);
|
|
@@ -693,6 +756,9 @@ bus.on('review:passed', (data) => broadcast('REVIEW_PASSED', data));
|
|
|
693
756
|
bus.on('review:failed', (data) => broadcast('REVIEW_FAILED', data));
|
|
694
757
|
bus.on('pr:created', (data) => broadcast('PR_CREATED', data));
|
|
695
758
|
bus.on('task:blocked', (data) => broadcast('TASK_BLOCKED', data));
|
|
759
|
+
bus.on('task:manual-pr-required', (data) => broadcast('TASK_MANUAL_PR_REQUIRED', data));
|
|
760
|
+
bus.on('max-review-blocker:approved', (data) => broadcast('MAX_REVIEW_BLOCKER_APPROVED', data));
|
|
761
|
+
bus.on('max-review-blocker:extended', (data) => broadcast('MAX_REVIEW_BLOCKER_EXTENDED', data));
|
|
696
762
|
bus.on('repos:updated', (repos) => broadcast('REPOS_UPDATED', { repos }));
|
|
697
763
|
bus.on('plan:partial', (data) => broadcast('PLAN_PARTIAL', data));
|
|
698
764
|
bus.on('task:aborted', (data) => broadcast('TASK_ABORTED', data));
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
|
2
|
+
import { existsSync, mkdirSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
const mockStore = {
|
|
7
|
+
getTask: vi.fn(),
|
|
8
|
+
updateTask: vi.fn(),
|
|
9
|
+
appendLog: vi.fn(),
|
|
10
|
+
restartRecovery: vi.fn(),
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const mockBus = {
|
|
14
|
+
emit: vi.fn(),
|
|
15
|
+
on: vi.fn(),
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
vi.mock('./store.js', () => ({
|
|
19
|
+
default: mockStore,
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
vi.mock('./events.js', () => ({
|
|
23
|
+
default: mockBus,
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
const { approveMaxReviewBlocker } = await import('./index.js');
|
|
27
|
+
|
|
28
|
+
describe('approveMaxReviewBlocker', () => {
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
mockStore.getTask.mockReset();
|
|
31
|
+
mockStore.updateTask.mockReset();
|
|
32
|
+
mockStore.appendLog.mockReset();
|
|
33
|
+
mockStore.restartRecovery.mockReset();
|
|
34
|
+
mockBus.emit.mockReset();
|
|
35
|
+
mockBus.on.mockClear();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('cleans up the workspace directory and clears workspacePath when approving to done', () => {
|
|
39
|
+
const workspacePath = join(tmpdir(), `bankan-approve-${Date.now()}`);
|
|
40
|
+
mkdirSync(workspacePath, { recursive: true });
|
|
41
|
+
const task = {
|
|
42
|
+
id: 'T-1',
|
|
43
|
+
status: 'blocked',
|
|
44
|
+
blockedReason: 'Reached maximum review cycles (3). Human input required.',
|
|
45
|
+
workspacePath,
|
|
46
|
+
};
|
|
47
|
+
mockStore.getTask.mockReturnValue(task);
|
|
48
|
+
|
|
49
|
+
expect(existsSync(workspacePath)).toBe(true);
|
|
50
|
+
|
|
51
|
+
expect(approveMaxReviewBlocker('T-1')).toBe(true);
|
|
52
|
+
|
|
53
|
+
expect(existsSync(workspacePath)).toBe(false);
|
|
54
|
+
expect(mockStore.updateTask).toHaveBeenCalledWith('T-1', expect.objectContaining({
|
|
55
|
+
status: 'done',
|
|
56
|
+
workspacePath: null,
|
|
57
|
+
}));
|
|
58
|
+
expect(mockStore.appendLog).toHaveBeenCalledWith(
|
|
59
|
+
'T-1',
|
|
60
|
+
'Human override: approved task to done after max review cycles.'
|
|
61
|
+
);
|
|
62
|
+
expect(mockBus.emit).toHaveBeenCalledWith('max-review-blocker:approved', { taskId: 'T-1' });
|
|
63
|
+
rmSync(workspacePath, { recursive: true, force: true });
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -4,10 +4,18 @@ import { join } from 'node:path';
|
|
|
4
4
|
import { execFileSync } from 'node:child_process';
|
|
5
5
|
import { simpleGit } from 'simple-git';
|
|
6
6
|
import { loadSettings, getWorkspacesDir } from './config.js';
|
|
7
|
+
import { getGithubCapabilities, isManualPullRequestRequired } from './capabilities.js';
|
|
7
8
|
import store from './store.js';
|
|
8
9
|
import agentManager from './agents.js';
|
|
9
10
|
import bus from './events.js';
|
|
10
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
isReviewResultPlaceholder,
|
|
13
|
+
isPlanPlaceholder,
|
|
14
|
+
isImplementationPlaceholder,
|
|
15
|
+
parseReviewResult,
|
|
16
|
+
resolveTaskMaxReviewCycles,
|
|
17
|
+
reviewShouldPass,
|
|
18
|
+
} from './workflow.js';
|
|
11
19
|
import { createSessionEntry } from './sessionHistory.js';
|
|
12
20
|
|
|
13
21
|
const POLL_INTERVAL = 4000;
|
|
@@ -16,7 +24,7 @@ const PLANNER_TIMEOUT = 5 * 60 * 1000;
|
|
|
16
24
|
const IMPLEMENTOR_TIMEOUT = 60 * 60 * 1000;
|
|
17
25
|
const REVIEWER_TIMEOUT = 30 * 60 * 1000;
|
|
18
26
|
const STUCK_TIMEOUT = 10 * 60 * 1000;
|
|
19
|
-
const
|
|
27
|
+
const DEFAULT_MAX_REVIEW_CYCLES = 3;
|
|
20
28
|
|
|
21
29
|
let pollTimer = null;
|
|
22
30
|
let signalTimer = null;
|
|
@@ -701,6 +709,42 @@ async function cleanupWorkspace(task) {
|
|
|
701
709
|
}
|
|
702
710
|
}
|
|
703
711
|
|
|
712
|
+
function buildManualPrGuidance(task, capabilities = getGithubCapabilities()) {
|
|
713
|
+
const reason = !capabilities.ghAvailable
|
|
714
|
+
? 'GitHub CLI is not installed'
|
|
715
|
+
: !capabilities.ghAuthenticated
|
|
716
|
+
? 'GitHub CLI is not authenticated'
|
|
717
|
+
: 'Automatic pull request creation is unavailable';
|
|
718
|
+
|
|
719
|
+
return `${reason}, so Ban Kan could not create the pull request automatically. Your branch has been pushed${task?.branch ? ` (${task.branch})` : ''}. Open the workspace, create the PR manually, then mark this task done.`;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function isManualPrAutomationError(err) {
|
|
723
|
+
if (!err) return false;
|
|
724
|
+
const message = typeof err.message === 'string' ? err.message : '';
|
|
725
|
+
const path = typeof err.path === 'string' ? err.path : '';
|
|
726
|
+
const spawnargs = Array.isArray(err.spawnargs) ? err.spawnargs : [];
|
|
727
|
+
const firstSpawnArg = typeof spawnargs[0] === 'string' ? spawnargs[0] : '';
|
|
728
|
+
return path === 'gh'
|
|
729
|
+
|| firstSpawnArg === 'gh'
|
|
730
|
+
|| /spawn(?:sync)? gh ENOENT/i.test(message)
|
|
731
|
+
|| /gh.*not authenticated/i.test(message);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
async function transitionTaskToManualPr(taskId, capabilities = getGithubCapabilities()) {
|
|
735
|
+
const task = store.getTask(taskId);
|
|
736
|
+
if (!task) return;
|
|
737
|
+
|
|
738
|
+
const blockedReason = buildManualPrGuidance(task, capabilities);
|
|
739
|
+
store.updateTask(taskId, {
|
|
740
|
+
status: 'awaiting_manual_pr',
|
|
741
|
+
assignedTo: null,
|
|
742
|
+
blockedReason,
|
|
743
|
+
});
|
|
744
|
+
store.appendLog(taskId, 'Automatic PR creation unavailable; waiting for manual PR completion.');
|
|
745
|
+
bus.emit('task:manual-pr-required', { taskId, reason: blockedReason });
|
|
746
|
+
}
|
|
747
|
+
|
|
704
748
|
function retireAgentSession(agent, {
|
|
705
749
|
taskId = agent?.currentTask || null,
|
|
706
750
|
outcome = 'completed',
|
|
@@ -738,6 +782,7 @@ async function startPlanning(task) {
|
|
|
738
782
|
review: null,
|
|
739
783
|
reviewFeedback: null,
|
|
740
784
|
reviewCycleCount: 0,
|
|
785
|
+
maxReviewCycles: resolveTaskMaxReviewCycles(task, DEFAULT_MAX_REVIEW_CYCLES),
|
|
741
786
|
blockedReason: null,
|
|
742
787
|
assignedTo: null,
|
|
743
788
|
});
|
|
@@ -810,6 +855,7 @@ function onPlanComplete(agentId, taskId) {
|
|
|
810
855
|
|
|
811
856
|
// Save plan
|
|
812
857
|
store.savePlan(taskId, planText);
|
|
858
|
+
const task = store.getTask(taskId);
|
|
813
859
|
store.updateTask(taskId, {
|
|
814
860
|
status: 'awaiting_approval',
|
|
815
861
|
plan: planText,
|
|
@@ -817,6 +863,7 @@ function onPlanComplete(agentId, taskId) {
|
|
|
817
863
|
review: null,
|
|
818
864
|
reviewFeedback: null,
|
|
819
865
|
reviewCycleCount: 0,
|
|
866
|
+
maxReviewCycles: resolveTaskMaxReviewCycles(task, DEFAULT_MAX_REVIEW_CYCLES),
|
|
820
867
|
blockedReason: null,
|
|
821
868
|
assignedTo: null,
|
|
822
869
|
});
|
|
@@ -997,13 +1044,14 @@ async function onReviewComplete(agentId, taskId) {
|
|
|
997
1044
|
|
|
998
1045
|
const task = store.getTask(taskId);
|
|
999
1046
|
const nextReviewCycleCount = (task?.reviewCycleCount || 0) + 1;
|
|
1047
|
+
const maxReviewCycles = Math.max(1, task?.maxReviewCycles || DEFAULT_MAX_REVIEW_CYCLES);
|
|
1000
1048
|
|
|
1001
|
-
if (nextReviewCycleCount >=
|
|
1049
|
+
if (nextReviewCycleCount >= maxReviewCycles) {
|
|
1002
1050
|
store.updateTask(taskId, {
|
|
1003
1051
|
status: 'blocked',
|
|
1004
1052
|
reviewFeedback: criticalIssues,
|
|
1005
1053
|
reviewCycleCount: nextReviewCycleCount,
|
|
1006
|
-
blockedReason: `Reached maximum review cycles (${
|
|
1054
|
+
blockedReason: `Reached maximum review cycles (${maxReviewCycles}). Human input required.`,
|
|
1007
1055
|
assignedTo: null,
|
|
1008
1056
|
});
|
|
1009
1057
|
bus.emit('task:blocked', { taskId, reason: 'Reached maximum review cycles' });
|
|
@@ -1021,7 +1069,7 @@ async function onReviewComplete(agentId, taskId) {
|
|
|
1021
1069
|
}
|
|
1022
1070
|
}
|
|
1023
1071
|
|
|
1024
|
-
async function createPR(taskId) {
|
|
1072
|
+
export async function createPR(taskId) {
|
|
1025
1073
|
const task = store.getTask(taskId);
|
|
1026
1074
|
try {
|
|
1027
1075
|
if (!task?.workspacePath || !existsSync(task.workspacePath)) {
|
|
@@ -1040,6 +1088,12 @@ async function createPR(taskId) {
|
|
|
1040
1088
|
}
|
|
1041
1089
|
|
|
1042
1090
|
await git.raw(['push', '--force-with-lease', 'origin', task.branch]);
|
|
1091
|
+
const githubCapabilities = getGithubCapabilities();
|
|
1092
|
+
if (isManualPullRequestRequired(githubCapabilities)) {
|
|
1093
|
+
await transitionTaskToManualPr(taskId, githubCapabilities);
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1043
1097
|
const prBody = buildPullRequestBody(task);
|
|
1044
1098
|
const prUrl = execFileSync('gh', [
|
|
1045
1099
|
'pr', 'create',
|
|
@@ -1058,6 +1112,10 @@ async function createPR(taskId) {
|
|
|
1058
1112
|
await cleanupWorkspace(store.getTask(taskId));
|
|
1059
1113
|
store.updateTask(taskId, { status: 'done', assignedTo: null });
|
|
1060
1114
|
} catch (err) {
|
|
1115
|
+
if (isManualPrAutomationError(err)) {
|
|
1116
|
+
await transitionTaskToManualPr(taskId);
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1061
1119
|
console.error('PR creation error:', err.message);
|
|
1062
1120
|
store.updateTask(taskId, {
|
|
1063
1121
|
status: 'blocked',
|
|
@@ -1068,6 +1126,20 @@ async function createPR(taskId) {
|
|
|
1068
1126
|
}
|
|
1069
1127
|
}
|
|
1070
1128
|
|
|
1129
|
+
async function completeManualPr(taskId) {
|
|
1130
|
+
const task = store.getTask(taskId);
|
|
1131
|
+
if (!task || task.status !== 'awaiting_manual_pr') return;
|
|
1132
|
+
|
|
1133
|
+
await cleanupWorkspace(task);
|
|
1134
|
+
store.updateTask(taskId, {
|
|
1135
|
+
status: 'done',
|
|
1136
|
+
assignedTo: null,
|
|
1137
|
+
blockedReason: null,
|
|
1138
|
+
completedAt: new Date().toISOString(),
|
|
1139
|
+
});
|
|
1140
|
+
bus.emit('task:manual-pr-completed', { taskId });
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1071
1143
|
async function abortTask(taskId) {
|
|
1072
1144
|
const task = store.getTask(taskId);
|
|
1073
1145
|
if (!task || task.status === 'done') return;
|
|
@@ -1087,6 +1159,7 @@ async function abortTask(taskId) {
|
|
|
1087
1159
|
reviewFeedback: null,
|
|
1088
1160
|
previousStatus: null,
|
|
1089
1161
|
reviewCycleCount: 0,
|
|
1162
|
+
maxReviewCycles: DEFAULT_MAX_REVIEW_CYCLES,
|
|
1090
1163
|
});
|
|
1091
1164
|
|
|
1092
1165
|
bus.emit('task:aborted', { taskId });
|
|
@@ -1118,6 +1191,7 @@ async function resetTask(taskId) {
|
|
|
1118
1191
|
planFeedback: null,
|
|
1119
1192
|
previousStatus: null,
|
|
1120
1193
|
reviewCycleCount: 0,
|
|
1194
|
+
maxReviewCycles: DEFAULT_MAX_REVIEW_CYCLES,
|
|
1121
1195
|
sessionHistory: [],
|
|
1122
1196
|
progress: 0,
|
|
1123
1197
|
totalTokens: 0,
|
|
@@ -1476,6 +1550,7 @@ const orchestrator = {
|
|
|
1476
1550
|
abortTask,
|
|
1477
1551
|
resetTask,
|
|
1478
1552
|
deleteTask,
|
|
1553
|
+
completeManualPr,
|
|
1479
1554
|
};
|
|
1480
1555
|
|
|
1481
1556
|
export default orchestrator;
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const task = {
|
|
4
|
+
id: 'T-42',
|
|
5
|
+
title: 'Manual PR fallback',
|
|
6
|
+
description: 'Fallback when gh is missing',
|
|
7
|
+
priority: 'high',
|
|
8
|
+
branch: 'feature/t-42-manual-pr',
|
|
9
|
+
workspacePath: '/tmp/workspaces/T-42',
|
|
10
|
+
assignedTo: 'orch',
|
|
11
|
+
status: 'review',
|
|
12
|
+
repoPath: '/repo',
|
|
13
|
+
review: 'Looks good',
|
|
14
|
+
plan: 'Do the thing',
|
|
15
|
+
reviewFeedback: null,
|
|
16
|
+
blockedReason: null,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const getTask = vi.fn();
|
|
20
|
+
const updateTask = vi.fn();
|
|
21
|
+
const appendLog = vi.fn();
|
|
22
|
+
const emit = vi.fn();
|
|
23
|
+
const fetchMock = vi.fn();
|
|
24
|
+
const checkoutMock = vi.fn();
|
|
25
|
+
const rebaseMock = vi.fn();
|
|
26
|
+
const rawMock = vi.fn();
|
|
27
|
+
const existsSyncMock = vi.fn();
|
|
28
|
+
const execFileSyncMock = vi.fn();
|
|
29
|
+
|
|
30
|
+
vi.mock('./store.js', () => ({
|
|
31
|
+
default: {
|
|
32
|
+
getTask,
|
|
33
|
+
updateTask,
|
|
34
|
+
appendLog,
|
|
35
|
+
},
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
vi.mock('./events.js', () => ({
|
|
39
|
+
default: {
|
|
40
|
+
emit,
|
|
41
|
+
on: vi.fn(),
|
|
42
|
+
},
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
vi.mock('./agents.js', () => ({
|
|
46
|
+
default: {
|
|
47
|
+
get: vi.fn(),
|
|
48
|
+
getAvailablePlanner: vi.fn(),
|
|
49
|
+
getAvailableImplementor: vi.fn(),
|
|
50
|
+
getAvailableReviewer: vi.fn(),
|
|
51
|
+
getAllStatus: vi.fn(() => []),
|
|
52
|
+
agents: new Map(),
|
|
53
|
+
reconfigure: vi.fn(),
|
|
54
|
+
},
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
vi.mock('./config.js', () => ({
|
|
58
|
+
loadSettings: vi.fn(() => ({
|
|
59
|
+
agents: {
|
|
60
|
+
planners: { max: 1 },
|
|
61
|
+
implementors: { max: 1 },
|
|
62
|
+
reviewers: { max: 1 },
|
|
63
|
+
},
|
|
64
|
+
})),
|
|
65
|
+
getWorkspacesDir: vi.fn(() => '/tmp/workspaces'),
|
|
66
|
+
}));
|
|
67
|
+
|
|
68
|
+
vi.mock('simple-git', () => ({
|
|
69
|
+
simpleGit: vi.fn(() => ({
|
|
70
|
+
fetch: fetchMock,
|
|
71
|
+
checkout: checkoutMock,
|
|
72
|
+
rebase: rebaseMock,
|
|
73
|
+
raw: rawMock,
|
|
74
|
+
})),
|
|
75
|
+
}));
|
|
76
|
+
|
|
77
|
+
vi.mock('node:fs', async () => {
|
|
78
|
+
const actual = await vi.importActual('node:fs');
|
|
79
|
+
return {
|
|
80
|
+
...actual,
|
|
81
|
+
existsSync: existsSyncMock,
|
|
82
|
+
mkdirSync: vi.fn(),
|
|
83
|
+
readFileSync: vi.fn(() => ''),
|
|
84
|
+
readdirSync: vi.fn(() => []),
|
|
85
|
+
unlinkSync: vi.fn(),
|
|
86
|
+
};
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
vi.mock('node:fs/promises', () => ({
|
|
90
|
+
rm: vi.fn(),
|
|
91
|
+
}));
|
|
92
|
+
|
|
93
|
+
vi.mock('node:child_process', () => ({
|
|
94
|
+
execFileSync: execFileSyncMock,
|
|
95
|
+
}));
|
|
96
|
+
|
|
97
|
+
vi.mock('./workflow.js', () => ({
|
|
98
|
+
isReviewResultPlaceholder: vi.fn(() => false),
|
|
99
|
+
isPlanPlaceholder: vi.fn(() => false),
|
|
100
|
+
isImplementationPlaceholder: vi.fn(() => false),
|
|
101
|
+
parseReviewResult: vi.fn(() => ({ verdict: 'PASS', criticalIssues: [], minorIssues: [] })),
|
|
102
|
+
resolveTaskMaxReviewCycles: vi.fn((task, fallback = 3) => task?.maxReviewCycles || fallback),
|
|
103
|
+
reviewShouldPass: vi.fn(() => true),
|
|
104
|
+
}));
|
|
105
|
+
|
|
106
|
+
vi.mock('./sessionHistory.js', () => ({
|
|
107
|
+
createSessionEntry: vi.fn(() => ({ id: 'session-1' })),
|
|
108
|
+
}));
|
|
109
|
+
|
|
110
|
+
describe('createPR', () => {
|
|
111
|
+
beforeEach(() => {
|
|
112
|
+
vi.resetModules();
|
|
113
|
+
getTask.mockReset();
|
|
114
|
+
updateTask.mockReset();
|
|
115
|
+
appendLog.mockReset();
|
|
116
|
+
emit.mockReset();
|
|
117
|
+
fetchMock.mockReset();
|
|
118
|
+
checkoutMock.mockReset();
|
|
119
|
+
rebaseMock.mockReset();
|
|
120
|
+
rawMock.mockReset();
|
|
121
|
+
existsSyncMock.mockReset();
|
|
122
|
+
execFileSyncMock.mockReset();
|
|
123
|
+
|
|
124
|
+
getTask.mockReturnValue(task);
|
|
125
|
+
existsSyncMock.mockReturnValue(true);
|
|
126
|
+
execFileSyncMock.mockImplementation((cmd) => {
|
|
127
|
+
if (cmd === 'gh') {
|
|
128
|
+
const error = new Error('spawn gh ENOENT');
|
|
129
|
+
error.code = 'ENOENT';
|
|
130
|
+
error.path = 'gh';
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
return '';
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('falls back to awaiting manual PR instead of blocking when gh is unavailable', async () => {
|
|
138
|
+
const { createPR } = await import('./orchestrator.js');
|
|
139
|
+
|
|
140
|
+
await createPR('T-42');
|
|
141
|
+
|
|
142
|
+
expect(fetchMock).toHaveBeenCalledWith('origin', 'main');
|
|
143
|
+
expect(checkoutMock).toHaveBeenCalledWith('feature/t-42-manual-pr');
|
|
144
|
+
expect(rebaseMock).toHaveBeenCalledWith(['origin/main']);
|
|
145
|
+
expect(rawMock).toHaveBeenCalledWith(['push', '--force-with-lease', 'origin', 'feature/t-42-manual-pr']);
|
|
146
|
+
expect(updateTask).toHaveBeenCalledWith('T-42', expect.objectContaining({
|
|
147
|
+
status: 'awaiting_manual_pr',
|
|
148
|
+
assignedTo: null,
|
|
149
|
+
blockedReason: expect.stringContaining('create the PR manually'),
|
|
150
|
+
}));
|
|
151
|
+
expect(emit).toHaveBeenCalledWith('task:manual-pr-required', expect.objectContaining({ taskId: 'T-42' }));
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('keeps non-gh ENOENT failures blocked instead of treating them as manual PR fallback', async () => {
|
|
155
|
+
fetchMock.mockRejectedValueOnce(Object.assign(new Error('spawn git ENOENT'), {
|
|
156
|
+
code: 'ENOENT',
|
|
157
|
+
path: 'git',
|
|
158
|
+
spawnargs: ['git', 'fetch'],
|
|
159
|
+
}));
|
|
160
|
+
execFileSyncMock.mockImplementation(() => '');
|
|
161
|
+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
162
|
+
|
|
163
|
+
const { createPR } = await import('./orchestrator.js');
|
|
164
|
+
|
|
165
|
+
await createPR('T-42');
|
|
166
|
+
|
|
167
|
+
expect(updateTask).toHaveBeenCalledWith('T-42', expect.objectContaining({
|
|
168
|
+
status: 'blocked',
|
|
169
|
+
assignedTo: null,
|
|
170
|
+
blockedReason: expect.stringContaining('PR finalization failed'),
|
|
171
|
+
}));
|
|
172
|
+
expect(emit).toHaveBeenCalledWith('task:blocked', { taskId: 'T-42', reason: 'PR finalization failed' });
|
|
173
|
+
expect(emit).not.toHaveBeenCalledWith('task:manual-pr-required', expect.anything());
|
|
174
|
+
|
|
175
|
+
consoleError.mockRestore();
|
|
176
|
+
});
|
|
177
|
+
});
|