@stilero/bankan 1.0.13 → 1.0.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -1
- package/bin/bankan.js +1 -1
- package/client/dist/assets/{index-pUZAEGtO.js → index-CHxyLFN_.js} +17 -15
- package/client/dist/index.html +1 -1
- package/docs/images/workflow/taskflow_animated.gif +0 -0
- package/package.json +14 -2
- package/scripts/setup.js +1 -5
- package/server/src/agents.js +123 -4
- package/server/src/agents.test.js +462 -76
- package/server/src/config.js +11 -4
- package/server/src/config.test.js +170 -0
- package/server/src/index.js +11 -2
- package/server/src/linting.test.js +37 -0
- package/server/src/orchestrator.js +279 -99
- package/server/src/orchestrator.test.js +431 -0
- package/server/src/paths.test.js +49 -0
- package/server/src/sessionHistory.test.js +39 -0
- package/server/src/store.js +2 -3
- package/server/src/store.test.js +186 -0
- package/server/src/workflow.js +23 -7
- package/server/src/workflow.test.js +216 -71
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { createRuntimeHarness } from '../test-utils.js';
|
|
4
|
+
|
|
5
|
+
let harness = null;
|
|
6
|
+
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
harness?.cleanup();
|
|
9
|
+
harness = null;
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe('TaskStore persistence and recovery', () => {
|
|
13
|
+
test('addTask populates default fields and update helpers persist changes', async () => {
|
|
14
|
+
harness = createRuntimeHarness();
|
|
15
|
+
const storeModule = await harness.importModule('./src/store.js');
|
|
16
|
+
const store = storeModule.default;
|
|
17
|
+
|
|
18
|
+
const task = store.addTask({
|
|
19
|
+
title: 'Add tests',
|
|
20
|
+
priority: 'high',
|
|
21
|
+
description: 'Focus on critical paths',
|
|
22
|
+
repoPath: '/repo',
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
expect(task.id).toMatch(/^T-/);
|
|
26
|
+
expect(task.status).toBe('backlog');
|
|
27
|
+
expect(task.lastActiveStage).toBe('backlog');
|
|
28
|
+
expect(task.log.at(-1).message).toBe('Task created');
|
|
29
|
+
|
|
30
|
+
store.updateTask(task.id, { status: 'planning', assignedTo: 'plan-1' });
|
|
31
|
+
store.appendLog(task.id, 'Planner started');
|
|
32
|
+
store.appendSession(task.id, { id: 'session-1' });
|
|
33
|
+
store.updateTaskTokens(task.id, 250);
|
|
34
|
+
store.updateTaskTokens(task.id, 100);
|
|
35
|
+
|
|
36
|
+
const updated = store.getTask(task.id);
|
|
37
|
+
expect(updated.assignedTo).toBe('plan-1');
|
|
38
|
+
expect(updated.lastActiveStage).toBe('planning');
|
|
39
|
+
expect(updated.totalTokens).toBe(250);
|
|
40
|
+
expect(updated.sessionHistory).toEqual([{ id: 'session-1' }]);
|
|
41
|
+
expect(updated.log.map(entry => entry.message)).toEqual([
|
|
42
|
+
'Task created',
|
|
43
|
+
'Status changed to planning',
|
|
44
|
+
'Planner started',
|
|
45
|
+
]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('savePlan, removePlan, and deleteTask update persisted task state', async () => {
|
|
49
|
+
harness = createRuntimeHarness();
|
|
50
|
+
const storeModule = await harness.importModule('./src/store.js');
|
|
51
|
+
const configModule = await harness.importModule('./src/config.js');
|
|
52
|
+
const store = storeModule.default;
|
|
53
|
+
|
|
54
|
+
const task = store.addTask({ title: 'Delete me' });
|
|
55
|
+
store.savePlan(task.id, 'Plan content');
|
|
56
|
+
|
|
57
|
+
expect(configModule.getRuntimeStatePaths().plansDir).toContain(harness.runtimeDir);
|
|
58
|
+
|
|
59
|
+
store.removePlan(task.id);
|
|
60
|
+
const removed = store.deleteTask(task.id);
|
|
61
|
+
|
|
62
|
+
expect(removed.id).toBe(task.id);
|
|
63
|
+
expect(store.getTask(task.id)).toBeNull();
|
|
64
|
+
expect(store.deleteTask('missing')).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('restartRecovery normalizes in-flight and legacy blocked tasks', async () => {
|
|
68
|
+
harness = createRuntimeHarness();
|
|
69
|
+
const storeModule = await harness.importModule('./src/store.js');
|
|
70
|
+
const store = storeModule.default;
|
|
71
|
+
|
|
72
|
+
const planningTask = store.addTask({ title: 'Planning task', repoPath: '/repo' });
|
|
73
|
+
const reviewTask = store.addTask({ title: 'Review task', repoPath: '/repo' });
|
|
74
|
+
const pausedTask = store.addTask({ title: 'Paused task', repoPath: '/repo' });
|
|
75
|
+
const doneTask = store.addTask({ title: 'Done task', repoPath: '/repo' });
|
|
76
|
+
const legacyBlockedTask = store.addTask({
|
|
77
|
+
title: 'Legacy blocker',
|
|
78
|
+
repoPath: 'https://github.com/stilero/bankan.git',
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
store.updateTask(planningTask.id, { status: 'planning', assignedTo: 'plan-1' });
|
|
82
|
+
store.updateTask(reviewTask.id, { status: 'review', assignedTo: 'rev-1' });
|
|
83
|
+
store.updateTask(pausedTask.id, { status: 'paused', assignedTo: 'imp-1' });
|
|
84
|
+
store.updateTask(doneTask.id, { status: 'awaiting_human_review', assignedTo: 'rev-2', workspacePath: '/tmp/work' });
|
|
85
|
+
store.updateTask(legacyBlockedTask.id, {
|
|
86
|
+
status: 'blocked',
|
|
87
|
+
blockedReason: 'Invalid repository path: https://github.com/stilero/bankan.git',
|
|
88
|
+
workspacePath: null,
|
|
89
|
+
assignedTo: 'plan-2',
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
store.restartRecovery();
|
|
93
|
+
|
|
94
|
+
expect(store.getTask(planningTask.id).status).toBe('backlog');
|
|
95
|
+
expect(store.getTask(reviewTask.id).status).toBe('review');
|
|
96
|
+
expect(store.getTask(reviewTask.id).assignedTo).toBeNull();
|
|
97
|
+
expect(store.getTask(pausedTask.id).status).toBe('paused');
|
|
98
|
+
expect(store.getTask(pausedTask.id).assignedTo).toBeNull();
|
|
99
|
+
expect(store.getTask(doneTask.id).status).toBe('done');
|
|
100
|
+
expect(store.getTask(doneTask.id).workspacePath).toBeNull();
|
|
101
|
+
expect(store.getTask(legacyBlockedTask.id).status).toBe('backlog');
|
|
102
|
+
expect(store.getTask(legacyBlockedTask.id).blockedReason).toBeNull();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('corrupt task files fall back to an empty store', async () => {
|
|
106
|
+
harness = createRuntimeHarness();
|
|
107
|
+
const configModule = await harness.importModule('./src/config.js');
|
|
108
|
+
const { writeFileSync } = await import('node:fs');
|
|
109
|
+
|
|
110
|
+
writeFileSync(configModule.getRuntimeStatePaths().tasksFile, '{not-json');
|
|
111
|
+
|
|
112
|
+
const storeModule = await harness.importModule('./src/store.js');
|
|
113
|
+
expect(storeModule.default.getAllTasks()).toEqual([]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('normalizes legacy task records on load and handles null update helpers', async () => {
|
|
117
|
+
harness = createRuntimeHarness();
|
|
118
|
+
const configModule = await harness.importModule('./src/config.js');
|
|
119
|
+
const { writeFileSync } = await import('node:fs');
|
|
120
|
+
|
|
121
|
+
writeFileSync(configModule.getRuntimeStatePaths().tasksFile, JSON.stringify([
|
|
122
|
+
{
|
|
123
|
+
id: 'T-LEGACY',
|
|
124
|
+
title: 'Legacy',
|
|
125
|
+
status: 'awaiting_human_review',
|
|
126
|
+
repoPath: '/repo',
|
|
127
|
+
reviewCycleCount: -2,
|
|
128
|
+
totalTokens: -10,
|
|
129
|
+
startedAt: 42,
|
|
130
|
+
completedAt: 24,
|
|
131
|
+
sessionHistory: null,
|
|
132
|
+
assignedTo: 'rev-1',
|
|
133
|
+
workspacePath: '/tmp/work',
|
|
134
|
+
log: [],
|
|
135
|
+
},
|
|
136
|
+
]));
|
|
137
|
+
|
|
138
|
+
const storeModule = await harness.importModule('./src/store.js');
|
|
139
|
+
const store = storeModule.default;
|
|
140
|
+
const task = store.getTask('T-LEGACY');
|
|
141
|
+
|
|
142
|
+
expect(task.status).toBe('done');
|
|
143
|
+
expect(task.assignedTo).toBeNull();
|
|
144
|
+
expect(task.workspacePath).toBeNull();
|
|
145
|
+
expect(task.reviewCycleCount).toBe(0);
|
|
146
|
+
expect(task.totalTokens).toBe(0);
|
|
147
|
+
expect(task.startedAt).toBeNull();
|
|
148
|
+
expect(task.completedAt).toBeNull();
|
|
149
|
+
expect(task.sessionHistory).toEqual([]);
|
|
150
|
+
|
|
151
|
+
expect(store.updateTask('missing', { status: 'done' })).toBeNull();
|
|
152
|
+
expect(store.appendLog('missing', 'nope')).toBeNull();
|
|
153
|
+
expect(store.appendSession('missing', { id: 's-1' })).toBeNull();
|
|
154
|
+
expect(store.appendSession('T-LEGACY', null)).toBeNull();
|
|
155
|
+
expect(store.updateTaskTokens('missing', 10)).toBeNull();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('restartRecovery handles planner-path blockers and invalid counters', async () => {
|
|
159
|
+
harness = createRuntimeHarness();
|
|
160
|
+
const storeModule = await harness.importModule('./src/store.js');
|
|
161
|
+
const store = storeModule.default;
|
|
162
|
+
|
|
163
|
+
const task = store.addTask({
|
|
164
|
+
title: 'Planner dir blocker',
|
|
165
|
+
repoPath: 'git@github.com:stilero/bankan.git',
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
store.updateTask(task.id, {
|
|
169
|
+
status: 'blocked',
|
|
170
|
+
blockedReason: 'Invalid planner working directory: git@github.com:stilero/bankan.git',
|
|
171
|
+
workspacePath: null,
|
|
172
|
+
reviewCycleCount: -1,
|
|
173
|
+
totalTokens: -1,
|
|
174
|
+
lastActiveStage: null,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
store.restartRecovery();
|
|
178
|
+
|
|
179
|
+
const recovered = store.getTask(task.id);
|
|
180
|
+
expect(recovered.status).toBe('backlog');
|
|
181
|
+
expect(recovered.previousStatus).toBeNull();
|
|
182
|
+
expect(recovered.lastActiveStage).toBe('backlog');
|
|
183
|
+
expect(recovered.reviewCycleCount).toBe(0);
|
|
184
|
+
expect(recovered.totalTokens).toBe(0);
|
|
185
|
+
});
|
|
186
|
+
});
|
package/server/src/workflow.js
CHANGED
|
@@ -48,10 +48,11 @@ export function isReviewResultPlaceholder(reviewText, reviewResult = parseReview
|
|
|
48
48
|
const normalized = reviewText.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
49
49
|
if (normalized.includes("(issue description, or 'none')")) return true;
|
|
50
50
|
if (normalized.includes('(2-3 sentences summarising the review)')) return true;
|
|
51
|
+
if (normalized.includes('concrete issue, or none')) return true;
|
|
51
52
|
|
|
52
53
|
const summary = (reviewResult.summary || '').replace(/\s+/g, ' ').trim().toLowerCase();
|
|
53
54
|
if (!summary) return true;
|
|
54
|
-
if (summary.includes('
|
|
55
|
+
if (summary.includes('sentences summarising the review')) return true;
|
|
55
56
|
|
|
56
57
|
return false;
|
|
57
58
|
}
|
|
@@ -59,12 +60,27 @@ export function isReviewResultPlaceholder(reviewText, reviewResult = parseReview
|
|
|
59
60
|
export function isPlanPlaceholder(planText) {
|
|
60
61
|
if (typeof planText !== 'string' || !planText.trim()) return true;
|
|
61
62
|
const normalized = planText.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
63
|
+
const hasPlaceholder =
|
|
64
|
+
normalized.includes('(one sentence describing what will be built)') ||
|
|
65
|
+
normalized.includes('(detailed, actionable step)') ||
|
|
66
|
+
normalized.includes('path/to/file.ts (reason for modification)') ||
|
|
67
|
+
normalized.includes("(test description, or 'none')") ||
|
|
68
|
+
normalized.includes("(potential issue or edge case, or 'none')");
|
|
69
|
+
|
|
70
|
+
if (!hasPlaceholder) return false;
|
|
71
|
+
|
|
72
|
+
// Placeholder patterns found — but the plan may contain real content
|
|
73
|
+
// alongside echoed prompt template text (terminal echo contamination).
|
|
74
|
+
// Check if any SUMMARY line has substantive content.
|
|
75
|
+
const summaryMatches = [...planText.matchAll(/SUMMARY:\s*(.+)/gi)];
|
|
76
|
+
const hasRealSummary = summaryMatches.some(m => {
|
|
77
|
+
const val = m[1].trim();
|
|
78
|
+
return val.length > 20 && !val.startsWith('(');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
if (hasRealSummary) return false;
|
|
82
|
+
|
|
83
|
+
return true;
|
|
68
84
|
}
|
|
69
85
|
|
|
70
86
|
export function getLiveTaskAgent(task, agentManager) {
|
|
@@ -1,16 +1,18 @@
|
|
|
1
|
-
import test from '
|
|
2
|
-
import assert from 'node:assert/strict';
|
|
1
|
+
import { describe, expect, test } from 'vitest';
|
|
3
2
|
|
|
4
3
|
import {
|
|
5
4
|
getLiveTaskAgent,
|
|
5
|
+
getAgentStage,
|
|
6
6
|
isReviewResultPlaceholder,
|
|
7
|
+
isPlanPlaceholder,
|
|
7
8
|
parseReviewResult,
|
|
8
9
|
reviewShouldPass,
|
|
9
10
|
stageToRetryStatus,
|
|
10
11
|
} from './workflow.js';
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
describe('review parsing', () => {
|
|
14
|
+
test('minor-only review output is normalized to pass', () => {
|
|
15
|
+
const reviewText = `=== REVIEW START ===
|
|
14
16
|
VERDICT: FAIL
|
|
15
17
|
CRITICAL_ISSUES:
|
|
16
18
|
- none
|
|
@@ -19,16 +21,16 @@ MINOR_ISSUES:
|
|
|
19
21
|
SUMMARY: Only minor issues were found.
|
|
20
22
|
=== REVIEW END ===`;
|
|
21
23
|
|
|
22
|
-
|
|
24
|
+
const result = parseReviewResult(reviewText);
|
|
23
25
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
});
|
|
26
|
+
expect(result.verdict).toBe('FAIL');
|
|
27
|
+
expect(result.criticalIssues).toEqual([]);
|
|
28
|
+
expect(result.minorIssues).toHaveLength(1);
|
|
29
|
+
expect(reviewShouldPass(result)).toBe(true);
|
|
30
|
+
});
|
|
29
31
|
|
|
30
|
-
test('review with critical issues still fails', () => {
|
|
31
|
-
|
|
32
|
+
test('review with critical issues still fails', () => {
|
|
33
|
+
const reviewText = `=== REVIEW START ===
|
|
32
34
|
VERDICT: FAIL
|
|
33
35
|
CRITICAL_ISSUES:
|
|
34
36
|
- server/src/orchestrator.js: review failures can loop indefinitely
|
|
@@ -37,14 +39,14 @@ MINOR_ISSUES:
|
|
|
37
39
|
SUMMARY: A must-fix issue remains.
|
|
38
40
|
=== REVIEW END ===`;
|
|
39
41
|
|
|
40
|
-
|
|
42
|
+
const result = parseReviewResult(reviewText);
|
|
41
43
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
});
|
|
44
|
+
expect(result.hasCriticalIssues).toBe(true);
|
|
45
|
+
expect(reviewShouldPass(result)).toBe(false);
|
|
46
|
+
});
|
|
45
47
|
|
|
46
|
-
test('placeholder review template is rejected', () => {
|
|
47
|
-
|
|
48
|
+
test('placeholder review template is rejected', () => {
|
|
49
|
+
const reviewText = `=== REVIEW START ===
|
|
48
50
|
VERDICT: PASS
|
|
49
51
|
CRITICAL_ISSUES:
|
|
50
52
|
- none
|
|
@@ -53,13 +55,13 @@ MINOR_ISSUES:
|
|
|
53
55
|
SUMMARY: (2-3 sentences summarising the review)
|
|
54
56
|
=== REVIEW END ===`;
|
|
55
57
|
|
|
56
|
-
|
|
58
|
+
const result = parseReviewResult(reviewText);
|
|
57
59
|
|
|
58
|
-
|
|
59
|
-
});
|
|
60
|
+
expect(isReviewResultPlaceholder(reviewText, result)).toBe(true);
|
|
61
|
+
});
|
|
60
62
|
|
|
61
|
-
test('concrete review output is not treated as placeholder', () => {
|
|
62
|
-
|
|
63
|
+
test('concrete review output is not treated as placeholder', () => {
|
|
64
|
+
const reviewText = `=== REVIEW START ===
|
|
63
65
|
VERDICT: PASS
|
|
64
66
|
CRITICAL_ISSUES:
|
|
65
67
|
- none
|
|
@@ -68,57 +70,200 @@ MINOR_ISSUES:
|
|
|
68
70
|
SUMMARY: Changed files: server/src/orchestrator.js, server/src/workflow.js. The review completion gate now rejects placeholder output and the branch otherwise looks consistent with existing task flow. Strengths: the change is narrowly scoped and adds regression coverage.
|
|
69
71
|
=== REVIEW END ===`;
|
|
70
72
|
|
|
71
|
-
|
|
73
|
+
const result = parseReviewResult(reviewText);
|
|
74
|
+
|
|
75
|
+
expect(isReviewResultPlaceholder(reviewText, result)).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('plan placeholders are detected from default prompt scaffolding', () => {
|
|
79
|
+
expect(isPlanPlaceholder(`=== PLAN START ===
|
|
80
|
+
SUMMARY: (one sentence describing what will be built)
|
|
81
|
+
BRANCH: feature/example
|
|
82
|
+
FILES_TO_MODIFY:
|
|
83
|
+
- path/to/file.ts (reason for modification)
|
|
84
|
+
STEPS:
|
|
85
|
+
1. (detailed, actionable step)
|
|
86
|
+
TESTS_NEEDED:
|
|
87
|
+
- (test description, or 'none')
|
|
88
|
+
RISKS:
|
|
89
|
+
- (potential issue or edge case, or 'none')
|
|
90
|
+
=== PLAN END ===`)).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('review prompt template is detected as placeholder', () => {
|
|
94
|
+
const templateText = `=== REVIEW START ===
|
|
95
|
+
VERDICT: PASS or FAIL
|
|
96
|
+
CRITICAL_ISSUES:
|
|
97
|
+
- concrete issue, or none
|
|
98
|
+
MINOR_ISSUES:
|
|
99
|
+
- concrete issue, or none
|
|
100
|
+
SUMMARY: 2-3 concrete sentences summarising the review, including changed files and strengths
|
|
101
|
+
=== REVIEW END ===`;
|
|
102
|
+
expect(isReviewResultPlaceholder(templateText)).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('blank and summary-free reviews are treated as placeholders', () => {
|
|
106
|
+
expect(isReviewResultPlaceholder('')).toBe(true);
|
|
107
|
+
expect(isReviewResultPlaceholder(`=== REVIEW START ===
|
|
108
|
+
VERDICT: PASS
|
|
109
|
+
CRITICAL_ISSUES:
|
|
110
|
+
- none
|
|
111
|
+
MINOR_ISSUES:
|
|
112
|
+
- none
|
|
113
|
+
SUMMARY:
|
|
114
|
+
=== REVIEW END ===`)).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('concrete plans are not treated as placeholders', () => {
|
|
118
|
+
expect(isPlanPlaceholder(`=== PLAN START ===
|
|
119
|
+
SUMMARY: Add automated tests for the workflow helpers.
|
|
120
|
+
BRANCH: feature/add-workflow-tests
|
|
121
|
+
FILES_TO_MODIFY:
|
|
122
|
+
- server/src/workflow.test.js (expand retry and placeholder coverage)
|
|
123
|
+
STEPS:
|
|
124
|
+
1. Add tests for retry status edge cases.
|
|
125
|
+
TESTS_NEEDED:
|
|
126
|
+
- Run npm run test --prefix server
|
|
127
|
+
RISKS:
|
|
128
|
+
- none
|
|
129
|
+
=== PLAN END ===`)).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('real plan contaminated with echoed prompt template is not a placeholder', () => {
|
|
133
|
+
// When Claude CLI echoes the prompt, terminal artifacts mix template
|
|
134
|
+
// placeholder text into the real plan block after ANSI stripping.
|
|
135
|
+
const contaminated = `=== PLAN START ===
|
|
136
|
+
after the delimiters: === PLAN START === SUMMARY: (one sentence describing what will be built) BRANCH: (feature/t-91eadd-short-descriptive-slug) FILES_TO_MODIFY: - path/to/file.ts (reason for modification) STEPS: 1. (detailed, actionable step) 2. (detailed, actionable step) TESTS_NEEDED: - (test description, or 'none') RISKS: - (potential issue or edge case, or 'none') === PLAN END ===
|
|
137
|
+
SUMMARY: Add a Reports modal accessible from the top bar that shows per-repo task counts, total time spent, and total tokens spent with visually stunning charts.
|
|
138
|
+
BRANCH: feature/t-91eadd-reports-dashboard
|
|
139
|
+
FILES_TO_MODIFY:
|
|
140
|
+
- client/src/ReportsModal.jsx (new reporting modal component)
|
|
141
|
+
- client/src/App.jsx (add reports button and modal state)
|
|
142
|
+
- server/src/index.js (add REST endpoint for aggregated task stats)
|
|
143
|
+
STEPS:
|
|
144
|
+
1. Create the ReportsModal component with per-repo breakdown.
|
|
145
|
+
2. Wire up the top-bar button in App.jsx.
|
|
146
|
+
3. Add GET /api/reports endpoint in index.js.
|
|
147
|
+
TESTS_NEEDED:
|
|
148
|
+
- Run npm run test to verify no regressions
|
|
149
|
+
RISKS:
|
|
150
|
+
- none
|
|
151
|
+
=== PLAN END ===`;
|
|
152
|
+
expect(isPlanPlaceholder(contaminated)).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe('retry status resolution', () => {
|
|
157
|
+
test('retry ignores stale assigned agent process from another task', () => {
|
|
158
|
+
const task = {
|
|
159
|
+
id: 'T-123',
|
|
160
|
+
assignedTo: 'imp-1',
|
|
161
|
+
blockedReason: 'Agent is awaiting user input',
|
|
162
|
+
lastActiveStage: 'implementation',
|
|
163
|
+
};
|
|
164
|
+
const agentManager = {
|
|
165
|
+
get(id) {
|
|
166
|
+
expect(id).toBe('imp-1');
|
|
167
|
+
return {
|
|
168
|
+
id: 'imp-1',
|
|
169
|
+
process: { pid: 1234 },
|
|
170
|
+
currentTask: 'T-999',
|
|
171
|
+
};
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const liveAgent = getLiveTaskAgent(task, agentManager);
|
|
176
|
+
const retryStatus = stageToRetryStatus(task, { liveAgent, planningDisabled: false });
|
|
177
|
+
|
|
178
|
+
expect(liveAgent).toBeNull();
|
|
179
|
+
expect(retryStatus).toBe('queued');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('retry reuses live agent only when it still owns the task', () => {
|
|
183
|
+
const task = {
|
|
184
|
+
id: 'T-123',
|
|
185
|
+
assignedTo: 'imp-1',
|
|
186
|
+
blockedReason: 'Agent is awaiting user input',
|
|
187
|
+
lastActiveStage: 'implementation',
|
|
188
|
+
};
|
|
189
|
+
const liveAgentDef = {
|
|
190
|
+
id: 'imp-1',
|
|
191
|
+
process: { pid: 1234 },
|
|
192
|
+
currentTask: 'T-123',
|
|
193
|
+
};
|
|
194
|
+
const agentManager = {
|
|
195
|
+
get() {
|
|
196
|
+
return liveAgentDef;
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const liveAgent = getLiveTaskAgent(task, agentManager);
|
|
201
|
+
const retryStatus = stageToRetryStatus(task, { liveAgent, planningDisabled: false });
|
|
202
|
+
|
|
203
|
+
expect(liveAgent).toBe(liveAgentDef);
|
|
204
|
+
expect(retryStatus).toBe('implementing');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('planning tasks return to approval when a plan already exists', () => {
|
|
208
|
+
expect(stageToRetryStatus({
|
|
209
|
+
lastActiveStage: 'planning',
|
|
210
|
+
plan: 'ready',
|
|
211
|
+
blockedReason: '',
|
|
212
|
+
}, { planningDisabled: false })).toBe('awaiting_approval');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('maximum review cycle blockers return to queue', () => {
|
|
216
|
+
expect(stageToRetryStatus({
|
|
217
|
+
lastActiveStage: 'review',
|
|
218
|
+
blockedReason: 'Reached maximum review cycles for this task',
|
|
219
|
+
}, { planningDisabled: false })).toBe('queued');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('live planner and reviewer agents preserve their active stage', () => {
|
|
223
|
+
expect(stageToRetryStatus({
|
|
224
|
+
lastActiveStage: 'implementation',
|
|
225
|
+
blockedReason: '',
|
|
226
|
+
}, { liveAgent: { id: 'plan-1' }, planningDisabled: false })).toBe('planning');
|
|
227
|
+
|
|
228
|
+
expect(stageToRetryStatus({
|
|
229
|
+
lastActiveStage: 'implementation',
|
|
230
|
+
blockedReason: '',
|
|
231
|
+
}, { liveAgent: { id: 'rev-1' }, planningDisabled: false })).toBe('review');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('planning-disabled tasks skip back to queue and backlog defaults respect planner availability', () => {
|
|
235
|
+
expect(stageToRetryStatus({
|
|
236
|
+
lastActiveStage: 'planning',
|
|
237
|
+
plan: null,
|
|
238
|
+
blockedReason: '',
|
|
239
|
+
}, { planningDisabled: true })).toBe('queued');
|
|
240
|
+
|
|
241
|
+
expect(stageToRetryStatus({
|
|
242
|
+
lastActiveStage: null,
|
|
243
|
+
blockedReason: '',
|
|
244
|
+
}, { planningDisabled: false })).toBe('backlog');
|
|
72
245
|
|
|
73
|
-
|
|
246
|
+
expect(stageToRetryStatus({
|
|
247
|
+
lastActiveStage: null,
|
|
248
|
+
blockedReason: '',
|
|
249
|
+
}, { planningDisabled: true })).toBe('queued');
|
|
250
|
+
});
|
|
74
251
|
});
|
|
75
252
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
assignedTo: 'imp-1',
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
};
|
|
83
|
-
const agentManager = {
|
|
84
|
-
get(id) {
|
|
85
|
-
assert.equal(id, 'imp-1');
|
|
86
|
-
return {
|
|
87
|
-
id: 'imp-1',
|
|
88
|
-
process: { pid: 1234 },
|
|
89
|
-
currentTask: 'T-999',
|
|
90
|
-
};
|
|
91
|
-
},
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
const liveAgent = getLiveTaskAgent(task, agentManager);
|
|
95
|
-
const retryStatus = stageToRetryStatus(task, { liveAgent, planningDisabled: false });
|
|
96
|
-
|
|
97
|
-
assert.equal(liveAgent, null);
|
|
98
|
-
assert.equal(retryStatus, 'queued');
|
|
253
|
+
describe('live agent lookup', () => {
|
|
254
|
+
test('returns null when task has no assigned agent or no live process', () => {
|
|
255
|
+
expect(getLiveTaskAgent({ assignedTo: null }, { get: () => null })).toBeNull();
|
|
256
|
+
expect(getLiveTaskAgent({ assignedTo: 'imp-1', id: 'T-1' }, {
|
|
257
|
+
get: () => ({ id: 'imp-1', process: null, currentTask: 'T-1' }),
|
|
258
|
+
})).toBeNull();
|
|
259
|
+
});
|
|
99
260
|
});
|
|
100
261
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
};
|
|
108
|
-
const liveAgentDef = {
|
|
109
|
-
id: 'imp-1',
|
|
110
|
-
process: { pid: 1234 },
|
|
111
|
-
currentTask: 'T-123',
|
|
112
|
-
};
|
|
113
|
-
const agentManager = {
|
|
114
|
-
get() {
|
|
115
|
-
return liveAgentDef;
|
|
116
|
-
},
|
|
117
|
-
};
|
|
118
|
-
|
|
119
|
-
const liveAgent = getLiveTaskAgent(task, agentManager);
|
|
120
|
-
const retryStatus = stageToRetryStatus(task, { liveAgent, planningDisabled: false });
|
|
121
|
-
|
|
122
|
-
assert.equal(liveAgent, liveAgentDef);
|
|
123
|
-
assert.equal(retryStatus, 'implementing');
|
|
262
|
+
describe('agent stage helper', () => {
|
|
263
|
+
test('maps known agent prefixes to stages', () => {
|
|
264
|
+
expect(getAgentStage('plan-2')).toBe('planning');
|
|
265
|
+
expect(getAgentStage('imp-2')).toBe('implementation');
|
|
266
|
+
expect(getAgentStage('rev-2')).toBe('review');
|
|
267
|
+
expect(getAgentStage('orch')).toBeNull();
|
|
268
|
+
});
|
|
124
269
|
});
|