@stilero/bankan 1.0.6 → 1.0.7
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/package.json +1 -1
- package/server/src/index.js +8 -24
- package/server/src/orchestrator.js +8 -6
- package/server/src/workflow.js +71 -0
- package/server/src/workflow.test.js +93 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stilero/bankan",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
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",
|
package/server/src/index.js
CHANGED
|
@@ -11,6 +11,7 @@ import config, { loadSettings, saveSettings, validateSettings, getWorkspacesDir,
|
|
|
11
11
|
import store from './store.js';
|
|
12
12
|
import agentManager from './agents.js';
|
|
13
13
|
import bus from './events.js';
|
|
14
|
+
import { getLiveTaskAgent, stageToRetryStatus } from './workflow.js';
|
|
14
15
|
|
|
15
16
|
const app = express();
|
|
16
17
|
app.use(cors());
|
|
@@ -46,28 +47,11 @@ function stageToResumeStatus(task) {
|
|
|
46
47
|
return planningDisabled ? 'queued' : 'backlog';
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
function
|
|
50
|
+
function resolveRetryStatus(task) {
|
|
50
51
|
const settings = loadSettings();
|
|
51
52
|
const planningDisabled = settings.agents?.planners?.max === 0;
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
if (task.assignedTo.startsWith('imp-')) return 'implementing';
|
|
55
|
-
if (task.assignedTo.startsWith('rev-')) return 'review';
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
if ((task.blockedReason || '').includes('maximum review cycles')) {
|
|
59
|
-
return 'queued';
|
|
60
|
-
}
|
|
61
|
-
if (task.lastActiveStage === 'review') {
|
|
62
|
-
return 'review';
|
|
63
|
-
}
|
|
64
|
-
if (task.lastActiveStage === 'implementation') {
|
|
65
|
-
return 'queued';
|
|
66
|
-
}
|
|
67
|
-
if (task.lastActiveStage === 'planning') {
|
|
68
|
-
return task.plan ? 'awaiting_approval' : (planningDisabled ? 'queued' : 'backlog');
|
|
69
|
-
}
|
|
70
|
-
return planningDisabled ? 'queued' : 'backlog';
|
|
53
|
+
const liveAgent = getLiveTaskAgent(task, agentManager);
|
|
54
|
+
return stageToRetryStatus(task, { planningDisabled, liveAgent });
|
|
71
55
|
}
|
|
72
56
|
|
|
73
57
|
// REST API
|
|
@@ -545,10 +529,10 @@ wss.on('connection', (ws) => {
|
|
|
545
529
|
const { taskId } = msg.payload || {};
|
|
546
530
|
const task = store.getTask(taskId);
|
|
547
531
|
if (task && task.status === 'blocked') {
|
|
548
|
-
const retryStatus =
|
|
549
|
-
const agent = task
|
|
532
|
+
const retryStatus = resolveRetryStatus(task);
|
|
533
|
+
const agent = getLiveTaskAgent(task, agentManager);
|
|
550
534
|
|
|
551
|
-
if (agent
|
|
535
|
+
if (agent) {
|
|
552
536
|
agent.status = 'active';
|
|
553
537
|
bus.emit('agent:updated', agent.getStatus());
|
|
554
538
|
}
|
|
@@ -556,7 +540,7 @@ wss.on('connection', (ws) => {
|
|
|
556
540
|
store.updateTask(taskId, {
|
|
557
541
|
status: retryStatus,
|
|
558
542
|
blockedReason: null,
|
|
559
|
-
assignedTo: agent
|
|
543
|
+
assignedTo: agent ? task.assignedTo : null,
|
|
560
544
|
});
|
|
561
545
|
broadcast('TASK_RETRIED', { taskId, retryStatus });
|
|
562
546
|
}
|
|
@@ -7,6 +7,7 @@ import config, { loadSettings, getWorkspacesDir } from './config.js';
|
|
|
7
7
|
import store from './store.js';
|
|
8
8
|
import agentManager from './agents.js';
|
|
9
9
|
import bus from './events.js';
|
|
10
|
+
import { parseReviewResult, reviewShouldPass } from './workflow.js';
|
|
10
11
|
|
|
11
12
|
const POLL_INTERVAL = 4000;
|
|
12
13
|
const SIGNAL_CHECK_INTERVAL = 2500;
|
|
@@ -719,20 +720,21 @@ async function onReviewComplete(agentId, taskId) {
|
|
|
719
720
|
const sourceText = captured || bufStr;
|
|
720
721
|
const reviewText = getLastStructuredBlock(sourceText, '=== REVIEW START ===', '=== REVIEW END ===');
|
|
721
722
|
if (!reviewText) return;
|
|
722
|
-
const
|
|
723
|
-
const
|
|
723
|
+
const reviewResult = parseReviewResult(reviewText);
|
|
724
|
+
const shouldPass = reviewShouldPass(reviewResult);
|
|
724
725
|
|
|
725
726
|
store.updateTask(taskId, { review: reviewText });
|
|
726
727
|
reviewer.kill();
|
|
727
728
|
if (reviewer.draining) agentManager.removeAgent(agentId);
|
|
728
729
|
|
|
729
|
-
if (
|
|
730
|
+
if (shouldPass) {
|
|
731
|
+
if (reviewResult.verdict !== 'PASS') {
|
|
732
|
+
store.appendLog(taskId, 'Reviewer returned FAIL without critical issues; normalized to PASS.');
|
|
733
|
+
}
|
|
730
734
|
bus.emit('review:passed', { taskId });
|
|
731
735
|
await createPR(taskId);
|
|
732
736
|
} else {
|
|
733
|
-
|
|
734
|
-
const issuesMatch = reviewText.match(/CRITICAL_ISSUES:\s*([\s\S]*?)(?=MINOR_ISSUES:|SUMMARY:|=== REVIEW END ===)/i);
|
|
735
|
-
const criticalIssues = issuesMatch ? issuesMatch[1].trim() : 'Critical issues found';
|
|
737
|
+
const criticalIssues = reviewResult.criticalIssues.join('\n');
|
|
736
738
|
|
|
737
739
|
const task = store.getTask(taskId);
|
|
738
740
|
const nextReviewCycleCount = (task?.reviewCycleCount || 0) + 1;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
function parseBulletList(sectionText) {
|
|
2
|
+
return (sectionText || '')
|
|
3
|
+
.split('\n')
|
|
4
|
+
.map(line => line.trim())
|
|
5
|
+
.filter(line => line.startsWith('- '))
|
|
6
|
+
.map(line => line.slice(2).trim())
|
|
7
|
+
.filter(Boolean);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function extractSection(text, label, nextLabels = []) {
|
|
11
|
+
if (typeof text !== 'string' || !text) return '';
|
|
12
|
+
const escapedLabel = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
13
|
+
const nextPattern = nextLabels.length > 0
|
|
14
|
+
? `(?=${nextLabels.map(item => item.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})`
|
|
15
|
+
: '$';
|
|
16
|
+
const regex = new RegExp(`${escapedLabel}\\s*([\\s\\S]*?)${nextPattern}`, 'i');
|
|
17
|
+
const match = text.match(regex);
|
|
18
|
+
return match ? match[1].trim() : '';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function parseReviewResult(reviewText) {
|
|
22
|
+
const verdictMatch = reviewText.match(/VERDICT:\s*(PASS|FAIL)/i);
|
|
23
|
+
const verdict = verdictMatch ? verdictMatch[1].toUpperCase() : 'FAIL';
|
|
24
|
+
const criticalIssues = parseBulletList(
|
|
25
|
+
extractSection(reviewText, 'CRITICAL_ISSUES:', ['MINOR_ISSUES:', 'SUMMARY:', '=== REVIEW END ==='])
|
|
26
|
+
).filter(item => item.toLowerCase() !== 'none');
|
|
27
|
+
const minorIssues = parseBulletList(
|
|
28
|
+
extractSection(reviewText, 'MINOR_ISSUES:', ['SUMMARY:', '=== REVIEW END ==='])
|
|
29
|
+
).filter(item => item.toLowerCase() !== 'none');
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
verdict,
|
|
33
|
+
criticalIssues,
|
|
34
|
+
minorIssues,
|
|
35
|
+
hasCriticalIssues: criticalIssues.length > 0,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function reviewShouldPass(reviewResult) {
|
|
40
|
+
return reviewResult.verdict === 'PASS' || !reviewResult.hasCriticalIssues;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function getLiveTaskAgent(task, agentManager) {
|
|
44
|
+
if (!task?.assignedTo) return null;
|
|
45
|
+
const agent = agentManager.get(task.assignedTo);
|
|
46
|
+
if (!agent?.process) return null;
|
|
47
|
+
if (agent.currentTask !== task.id) return null;
|
|
48
|
+
return agent;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function stageToRetryStatus(task, { planningDisabled = false, liveAgent = null } = {}) {
|
|
52
|
+
if (liveAgent) {
|
|
53
|
+
if (liveAgent.id.startsWith('plan-')) return 'planning';
|
|
54
|
+
if (liveAgent.id.startsWith('imp-')) return 'implementing';
|
|
55
|
+
if (liveAgent.id.startsWith('rev-')) return 'review';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if ((task.blockedReason || '').includes('maximum review cycles')) {
|
|
59
|
+
return 'queued';
|
|
60
|
+
}
|
|
61
|
+
if (task.lastActiveStage === 'review') {
|
|
62
|
+
return 'review';
|
|
63
|
+
}
|
|
64
|
+
if (task.lastActiveStage === 'implementation') {
|
|
65
|
+
return 'queued';
|
|
66
|
+
}
|
|
67
|
+
if (task.lastActiveStage === 'planning') {
|
|
68
|
+
return task.plan ? 'awaiting_approval' : (planningDisabled ? 'queued' : 'backlog');
|
|
69
|
+
}
|
|
70
|
+
return planningDisabled ? 'queued' : 'backlog';
|
|
71
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
getLiveTaskAgent,
|
|
6
|
+
parseReviewResult,
|
|
7
|
+
reviewShouldPass,
|
|
8
|
+
stageToRetryStatus,
|
|
9
|
+
} from './workflow.js';
|
|
10
|
+
|
|
11
|
+
test('minor-only review output is normalized to pass', () => {
|
|
12
|
+
const reviewText = `=== REVIEW START ===
|
|
13
|
+
VERDICT: FAIL
|
|
14
|
+
CRITICAL_ISSUES:
|
|
15
|
+
- none
|
|
16
|
+
MINOR_ISSUES:
|
|
17
|
+
- server/src/index.js: stale status label in toast copy
|
|
18
|
+
SUMMARY: Only minor issues were found.
|
|
19
|
+
=== REVIEW END ===`;
|
|
20
|
+
|
|
21
|
+
const result = parseReviewResult(reviewText);
|
|
22
|
+
|
|
23
|
+
assert.equal(result.verdict, 'FAIL');
|
|
24
|
+
assert.deepEqual(result.criticalIssues, []);
|
|
25
|
+
assert.equal(result.minorIssues.length, 1);
|
|
26
|
+
assert.equal(reviewShouldPass(result), true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('review with critical issues still fails', () => {
|
|
30
|
+
const reviewText = `=== REVIEW START ===
|
|
31
|
+
VERDICT: FAIL
|
|
32
|
+
CRITICAL_ISSUES:
|
|
33
|
+
- server/src/orchestrator.js: review failures can loop indefinitely
|
|
34
|
+
MINOR_ISSUES:
|
|
35
|
+
- none
|
|
36
|
+
SUMMARY: A must-fix issue remains.
|
|
37
|
+
=== REVIEW END ===`;
|
|
38
|
+
|
|
39
|
+
const result = parseReviewResult(reviewText);
|
|
40
|
+
|
|
41
|
+
assert.equal(result.hasCriticalIssues, true);
|
|
42
|
+
assert.equal(reviewShouldPass(result), false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('retry ignores stale assigned agent process from another task', () => {
|
|
46
|
+
const task = {
|
|
47
|
+
id: 'T-123',
|
|
48
|
+
assignedTo: 'imp-1',
|
|
49
|
+
blockedReason: 'Agent is awaiting user input',
|
|
50
|
+
lastActiveStage: 'implementation',
|
|
51
|
+
};
|
|
52
|
+
const agentManager = {
|
|
53
|
+
get(id) {
|
|
54
|
+
assert.equal(id, 'imp-1');
|
|
55
|
+
return {
|
|
56
|
+
id: 'imp-1',
|
|
57
|
+
process: { pid: 1234 },
|
|
58
|
+
currentTask: 'T-999',
|
|
59
|
+
};
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const liveAgent = getLiveTaskAgent(task, agentManager);
|
|
64
|
+
const retryStatus = stageToRetryStatus(task, { liveAgent, planningDisabled: false });
|
|
65
|
+
|
|
66
|
+
assert.equal(liveAgent, null);
|
|
67
|
+
assert.equal(retryStatus, 'queued');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('retry reuses live agent only when it still owns the task', () => {
|
|
71
|
+
const task = {
|
|
72
|
+
id: 'T-123',
|
|
73
|
+
assignedTo: 'imp-1',
|
|
74
|
+
blockedReason: 'Agent is awaiting user input',
|
|
75
|
+
lastActiveStage: 'implementation',
|
|
76
|
+
};
|
|
77
|
+
const liveAgentDef = {
|
|
78
|
+
id: 'imp-1',
|
|
79
|
+
process: { pid: 1234 },
|
|
80
|
+
currentTask: 'T-123',
|
|
81
|
+
};
|
|
82
|
+
const agentManager = {
|
|
83
|
+
get() {
|
|
84
|
+
return liveAgentDef;
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const liveAgent = getLiveTaskAgent(task, agentManager);
|
|
89
|
+
const retryStatus = stageToRetryStatus(task, { liveAgent, planningDisabled: false });
|
|
90
|
+
|
|
91
|
+
assert.equal(liveAgent, liveAgentDef);
|
|
92
|
+
assert.equal(retryStatus, 'implementing');
|
|
93
|
+
});
|