@stilero/bankan 1.0.21 → 1.1.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/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 +85 -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.
|
|
3
|
+
"version": "1.1.2",
|
|
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)) {
|
|
@@ -1032,6 +1080,11 @@ async function createPR(taskId) {
|
|
|
1032
1080
|
await git.fetch('origin', 'main');
|
|
1033
1081
|
await git.checkout(task.branch);
|
|
1034
1082
|
|
|
1083
|
+
// Discard any uncommitted changes left by the agent (e.g. package-lock.json
|
|
1084
|
+
// from npm installs during review) so they don't block the rebase.
|
|
1085
|
+
await git.raw(['checkout', '--', '.']);
|
|
1086
|
+
await git.raw(['clean', '-fd']);
|
|
1087
|
+
|
|
1035
1088
|
try {
|
|
1036
1089
|
await git.rebase(['origin/main']);
|
|
1037
1090
|
} catch (err) {
|
|
@@ -1040,6 +1093,12 @@ async function createPR(taskId) {
|
|
|
1040
1093
|
}
|
|
1041
1094
|
|
|
1042
1095
|
await git.raw(['push', '--force-with-lease', 'origin', task.branch]);
|
|
1096
|
+
const githubCapabilities = getGithubCapabilities();
|
|
1097
|
+
if (isManualPullRequestRequired(githubCapabilities)) {
|
|
1098
|
+
await transitionTaskToManualPr(taskId, githubCapabilities);
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1043
1102
|
const prBody = buildPullRequestBody(task);
|
|
1044
1103
|
const prUrl = execFileSync('gh', [
|
|
1045
1104
|
'pr', 'create',
|
|
@@ -1058,6 +1117,10 @@ async function createPR(taskId) {
|
|
|
1058
1117
|
await cleanupWorkspace(store.getTask(taskId));
|
|
1059
1118
|
store.updateTask(taskId, { status: 'done', assignedTo: null });
|
|
1060
1119
|
} catch (err) {
|
|
1120
|
+
if (isManualPrAutomationError(err)) {
|
|
1121
|
+
await transitionTaskToManualPr(taskId);
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1061
1124
|
console.error('PR creation error:', err.message);
|
|
1062
1125
|
store.updateTask(taskId, {
|
|
1063
1126
|
status: 'blocked',
|
|
@@ -1068,6 +1131,20 @@ async function createPR(taskId) {
|
|
|
1068
1131
|
}
|
|
1069
1132
|
}
|
|
1070
1133
|
|
|
1134
|
+
async function completeManualPr(taskId) {
|
|
1135
|
+
const task = store.getTask(taskId);
|
|
1136
|
+
if (!task || task.status !== 'awaiting_manual_pr') return;
|
|
1137
|
+
|
|
1138
|
+
await cleanupWorkspace(task);
|
|
1139
|
+
store.updateTask(taskId, {
|
|
1140
|
+
status: 'done',
|
|
1141
|
+
assignedTo: null,
|
|
1142
|
+
blockedReason: null,
|
|
1143
|
+
completedAt: new Date().toISOString(),
|
|
1144
|
+
});
|
|
1145
|
+
bus.emit('task:manual-pr-completed', { taskId });
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1071
1148
|
async function abortTask(taskId) {
|
|
1072
1149
|
const task = store.getTask(taskId);
|
|
1073
1150
|
if (!task || task.status === 'done') return;
|
|
@@ -1087,6 +1164,7 @@ async function abortTask(taskId) {
|
|
|
1087
1164
|
reviewFeedback: null,
|
|
1088
1165
|
previousStatus: null,
|
|
1089
1166
|
reviewCycleCount: 0,
|
|
1167
|
+
maxReviewCycles: DEFAULT_MAX_REVIEW_CYCLES,
|
|
1090
1168
|
});
|
|
1091
1169
|
|
|
1092
1170
|
bus.emit('task:aborted', { taskId });
|
|
@@ -1118,6 +1196,7 @@ async function resetTask(taskId) {
|
|
|
1118
1196
|
planFeedback: null,
|
|
1119
1197
|
previousStatus: null,
|
|
1120
1198
|
reviewCycleCount: 0,
|
|
1199
|
+
maxReviewCycles: DEFAULT_MAX_REVIEW_CYCLES,
|
|
1121
1200
|
sessionHistory: [],
|
|
1122
1201
|
progress: 0,
|
|
1123
1202
|
totalTokens: 0,
|
|
@@ -1476,6 +1555,7 @@ const orchestrator = {
|
|
|
1476
1555
|
abortTask,
|
|
1477
1556
|
resetTask,
|
|
1478
1557
|
deleteTask,
|
|
1558
|
+
completeManualPr,
|
|
1479
1559
|
};
|
|
1480
1560
|
|
|
1481
1561
|
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
|
+
});
|