orchestrix-yuri 4.6.4 โ 4.6.6
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/lib/gateway/engine/phase-orchestrator.js +476 -20
- package/lib/gateway/router.js +88 -12
- package/package.json +1 -1
|
@@ -38,7 +38,7 @@ class PhaseOrchestrator {
|
|
|
38
38
|
this.onError = opts.onError || (() => {});
|
|
39
39
|
this.config = opts.config || {};
|
|
40
40
|
|
|
41
|
-
this._phase = null; // 'plan' | 'develop' | null
|
|
41
|
+
this._phase = null; // 'plan' | 'develop' | 'test' | 'iterate' | 'change' | null
|
|
42
42
|
this._step = 0; // current agent index
|
|
43
43
|
this._session = null; // tmux session name
|
|
44
44
|
this._projectRoot = null;
|
|
@@ -117,6 +117,62 @@ class PhaseOrchestrator {
|
|
|
117
117
|
}
|
|
118
118
|
}
|
|
119
119
|
}
|
|
120
|
+
|
|
121
|
+
// Check phase4 (test)
|
|
122
|
+
const phase4Path = path.join(projectRoot, '.yuri', 'state', 'phase4.yaml');
|
|
123
|
+
if (fs.existsSync(phase4Path)) {
|
|
124
|
+
const phase4 = yaml.load(fs.readFileSync(phase4Path, 'utf8')) || {};
|
|
125
|
+
if (phase4.status === 'in_progress' && Array.isArray(phase4.epics)) {
|
|
126
|
+
// Find a live orchestrix tmux session
|
|
127
|
+
let session = null;
|
|
128
|
+
try {
|
|
129
|
+
const sessions = execSync('tmux list-sessions -F "#{session_name}" 2>/dev/null', { encoding: 'utf8' }).trim();
|
|
130
|
+
session = sessions.split('\n').find((s) => s.startsWith('orchestrix-'));
|
|
131
|
+
} catch { /* no sessions */ }
|
|
132
|
+
|
|
133
|
+
if (session && tmx.hasSession(session)) {
|
|
134
|
+
const epicIds = phase4.epics.map((e) => String(e.id));
|
|
135
|
+
const firstPending = epicIds.findIndex((id) => {
|
|
136
|
+
const e = phase4.epics.find((ep) => String(ep.id) === id);
|
|
137
|
+
return !e || (e.status !== 'passed' && e.status !== 'failed');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
if (firstPending >= 0) {
|
|
141
|
+
this._phase = 'test';
|
|
142
|
+
this._projectRoot = projectRoot;
|
|
143
|
+
this._session = session;
|
|
144
|
+
this._lastHash = '';
|
|
145
|
+
this._stableCount = 0;
|
|
146
|
+
this._testBusy = false;
|
|
147
|
+
|
|
148
|
+
// Rebuild results from already-tested epics
|
|
149
|
+
const results = {};
|
|
150
|
+
for (const e of phase4.epics) {
|
|
151
|
+
if (e.status === 'passed' || e.status === 'failed') {
|
|
152
|
+
results[e.id] = { status: e.status, rounds: e.rounds || 0 };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
this._testContext = {
|
|
157
|
+
epicIds,
|
|
158
|
+
epicIdx: firstPending,
|
|
159
|
+
round: 0,
|
|
160
|
+
maxRounds: 3,
|
|
161
|
+
subPhase: null,
|
|
162
|
+
results,
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
this._loadAndTestEpic();
|
|
166
|
+
|
|
167
|
+
const pollInterval = this.config.phase_poll_interval || 30000;
|
|
168
|
+
this._timer = setInterval(() => this._pollTest(), pollInterval);
|
|
169
|
+
|
|
170
|
+
log.engine(`Recovered test phase: session=${session}, resuming at epic idx ${firstPending}`);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
120
176
|
}
|
|
121
177
|
|
|
122
178
|
/**
|
|
@@ -182,7 +238,8 @@ class PhaseOrchestrator {
|
|
|
182
238
|
}
|
|
183
239
|
|
|
184
240
|
if (this._phase === 'test') {
|
|
185
|
-
|
|
241
|
+
const card = this._buildTestProgressCard();
|
|
242
|
+
return { phase: 'test', message: card };
|
|
186
243
|
}
|
|
187
244
|
|
|
188
245
|
if (this._phase === 'iterate') {
|
|
@@ -763,6 +820,29 @@ class PhaseOrchestrator {
|
|
|
763
820
|
}
|
|
764
821
|
|
|
765
822
|
this._phase = null;
|
|
823
|
+
|
|
824
|
+
// Update state files so auto-reporter stops
|
|
825
|
+
try {
|
|
826
|
+
const phase3Path = path.join(this._projectRoot, '.yuri', 'state', 'phase3.yaml');
|
|
827
|
+
if (fs.existsSync(phase3Path)) {
|
|
828
|
+
const phase3 = yaml.load(fs.readFileSync(phase3Path, 'utf8')) || {};
|
|
829
|
+
phase3.status = 'complete';
|
|
830
|
+
phase3.completed_at = new Date().toISOString();
|
|
831
|
+
fs.writeFileSync(phase3Path, yaml.dump(phase3, { lineWidth: -1 }));
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
const focusPath = path.join(this._projectRoot, '.yuri', 'focus.yaml');
|
|
835
|
+
if (fs.existsSync(focusPath)) {
|
|
836
|
+
const focus = yaml.load(fs.readFileSync(focusPath, 'utf8')) || {};
|
|
837
|
+
focus.step = 'phase3.complete';
|
|
838
|
+
focus.pulse = 'Phase 3 complete, all stories done';
|
|
839
|
+
focus.updated_at = new Date().toISOString();
|
|
840
|
+
fs.writeFileSync(focusPath, yaml.dump(focus, { lineWidth: -1 }));
|
|
841
|
+
}
|
|
842
|
+
} catch (err) {
|
|
843
|
+
log.warn(`Failed to update state files on dev complete: ${err.message}`);
|
|
844
|
+
}
|
|
845
|
+
|
|
766
846
|
log.engine('Dev phase complete');
|
|
767
847
|
this.onComplete('develop', '๐ Development complete! All stories finished.\n\nRun *test to start smoke testing.');
|
|
768
848
|
}
|
|
@@ -772,6 +852,8 @@ class PhaseOrchestrator {
|
|
|
772
852
|
/**
|
|
773
853
|
* Start test phase: QA smoke test per epic, auto fix-retest loop.
|
|
774
854
|
* Uses existing dev session's QA window (3) and Dev window (2).
|
|
855
|
+
*
|
|
856
|
+
* State machine subPhases: qa-loading โ qa-testing โ (on fail) dev-loading โ dev-fixing โ qa-loading ...
|
|
775
857
|
*/
|
|
776
858
|
startTest(projectRoot) {
|
|
777
859
|
if (this._phase) {
|
|
@@ -780,11 +862,11 @@ class PhaseOrchestrator {
|
|
|
780
862
|
|
|
781
863
|
this._projectRoot = projectRoot;
|
|
782
864
|
this._phase = 'test';
|
|
783
|
-
this._step = 0;
|
|
784
865
|
this._lastHash = '';
|
|
785
866
|
this._stableCount = 0;
|
|
867
|
+
this._testBusy = false;
|
|
786
868
|
|
|
787
|
-
// Ensure dev session exists (QA is window 3)
|
|
869
|
+
// Ensure dev session exists (QA is window 3, Dev is window 2)
|
|
788
870
|
try {
|
|
789
871
|
const scriptPath = path.join(SKILL_DIR, 'scripts', 'ensure-session.sh');
|
|
790
872
|
const result = execSync(`bash "${scriptPath}" dev "${projectRoot}"`, {
|
|
@@ -797,44 +879,418 @@ class PhaseOrchestrator {
|
|
|
797
879
|
return `โ Failed to ensure dev session: ${err.message}`;
|
|
798
880
|
}
|
|
799
881
|
|
|
800
|
-
//
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
882
|
+
// Collect epic list from PRD files
|
|
883
|
+
const prdDir = path.join(projectRoot, 'docs', 'prd');
|
|
884
|
+
let epicIds = [];
|
|
885
|
+
try {
|
|
886
|
+
if (fs.existsSync(prdDir)) {
|
|
887
|
+
epicIds = fs.readdirSync(prdDir)
|
|
888
|
+
.filter((f) => /^epic-\d+/.test(f) && f.endsWith('.yaml'))
|
|
889
|
+
.map((f) => f.replace(/^epic-/, '').replace(/\.yaml$/, ''))
|
|
890
|
+
.sort((a, b) => parseInt(a, 10) - parseInt(b, 10));
|
|
891
|
+
}
|
|
892
|
+
} catch { /* ignore */ }
|
|
893
|
+
|
|
894
|
+
if (epicIds.length === 0) {
|
|
895
|
+
this._phase = null;
|
|
896
|
+
return 'โ No epic files found in docs/prd/. Nothing to test.';
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// Initialize or resume phase4.yaml state
|
|
900
|
+
const phase4Path = path.join(projectRoot, '.yuri', 'state', 'phase4.yaml');
|
|
901
|
+
let startIdx = 0;
|
|
902
|
+
try {
|
|
903
|
+
if (fs.existsSync(phase4Path)) {
|
|
904
|
+
const existing = yaml.load(fs.readFileSync(phase4Path, 'utf8')) || {};
|
|
905
|
+
if (existing.status === 'in_progress' && Array.isArray(existing.epics)) {
|
|
906
|
+
// Resume: find first non-passed epic
|
|
907
|
+
const idx = epicIds.findIndex((id) => {
|
|
908
|
+
const e = existing.epics.find((ep) => String(ep.id) === String(id));
|
|
909
|
+
return !e || e.status !== 'passed';
|
|
910
|
+
});
|
|
911
|
+
if (idx >= 0) startIdx = idx;
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// Write initial state
|
|
916
|
+
const epicsState = epicIds.map((id, i) => ({
|
|
917
|
+
id, status: i < startIdx ? 'passed' : 'pending', rounds: 0, last_tested_at: '',
|
|
918
|
+
}));
|
|
919
|
+
const state = { status: 'in_progress', started_at: new Date().toISOString(), completed_at: '', epics: epicsState, regression_rounds: 0 };
|
|
920
|
+
fs.writeFileSync(phase4Path, yaml.dump(state, { lineWidth: -1 }));
|
|
921
|
+
} catch (err) {
|
|
922
|
+
log.warn(`Failed to init phase4.yaml: ${err.message}`);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// Update focus.yaml
|
|
926
|
+
try {
|
|
927
|
+
const focusPath = path.join(projectRoot, '.yuri', 'focus.yaml');
|
|
928
|
+
if (fs.existsSync(focusPath)) {
|
|
929
|
+
const focus = yaml.load(fs.readFileSync(focusPath, 'utf8')) || {};
|
|
930
|
+
focus.phase = 4;
|
|
931
|
+
focus.step = 'testing';
|
|
932
|
+
focus.action = 'starting smoke tests';
|
|
933
|
+
focus.pulse = `Phase 4: testing 0/${epicIds.length} epics`;
|
|
934
|
+
focus.updated_at = new Date().toISOString();
|
|
935
|
+
fs.writeFileSync(focusPath, yaml.dump(focus, { lineWidth: -1 }));
|
|
936
|
+
}
|
|
937
|
+
} catch { /* ignore */ }
|
|
938
|
+
|
|
939
|
+
// Initialize test context
|
|
940
|
+
this._testContext = {
|
|
941
|
+
epicIds,
|
|
942
|
+
epicIdx: startIdx,
|
|
943
|
+
round: 0,
|
|
944
|
+
maxRounds: 3,
|
|
945
|
+
subPhase: null, // set by _loadAndTestEpic
|
|
946
|
+
results: {},
|
|
947
|
+
};
|
|
948
|
+
|
|
949
|
+
// Start first epic test
|
|
950
|
+
this._loadAndTestEpic();
|
|
951
|
+
this._testStartedAt = Date.now();
|
|
952
|
+
this._lastReportTime = Date.now();
|
|
806
953
|
|
|
807
954
|
const pollInterval = this.config.phase_poll_interval || 30000;
|
|
808
955
|
this._timer = setInterval(() => this._pollTest(), pollInterval);
|
|
809
956
|
|
|
810
|
-
|
|
811
|
-
|
|
957
|
+
const reportMin = Math.round(this._reportInterval / 60000);
|
|
958
|
+
log.engine(`Test phase started: session=${this._session}, epics=${epicIds.join(',')}, startIdx=${startIdx}, report every ${reportMin}min`);
|
|
959
|
+
return `๐งช Testing started! ${epicIds.length} epic(s) to test.\n\nQA will smoke-test each epic. Failed tests auto-trigger Dev fixes (max 3 rounds).\nI'll notify you of results.`;
|
|
812
960
|
}
|
|
813
961
|
|
|
962
|
+
/**
|
|
963
|
+
* Reload QA agent and send *smoke-test for current epic.
|
|
964
|
+
*/
|
|
965
|
+
_loadAndTestEpic() {
|
|
966
|
+
const ctx = this._testContext;
|
|
967
|
+
const epicId = ctx.epicIds[ctx.epicIdx];
|
|
968
|
+
|
|
969
|
+
this._testBusy = true;
|
|
970
|
+
this._lastHash = '';
|
|
971
|
+
this._stableCount = 0;
|
|
972
|
+
|
|
973
|
+
try {
|
|
974
|
+
tmx.sendKeysWithEnter(this._session, 3, '/clear');
|
|
975
|
+
execSync('sleep 2');
|
|
976
|
+
tmx.sendKeysWithEnter(this._session, 3, '/o qa');
|
|
977
|
+
execSync('sleep 12');
|
|
978
|
+
tmx.sendKeysWithEnter(this._session, 3, `*smoke-test ${epicId}`);
|
|
979
|
+
} catch (err) {
|
|
980
|
+
this._testBusy = false;
|
|
981
|
+
this._handleError('test', `Failed to start QA for epic ${epicId}: ${err.message}`);
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
ctx.subPhase = 'qa-testing';
|
|
986
|
+
this._testBusy = false;
|
|
987
|
+
log.engine(`QA testing epic ${epicId} (round ${ctx.round + 1}/${ctx.maxRounds})`);
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/**
|
|
991
|
+
* Reload Dev agent and send *quick-fix for the detected bug.
|
|
992
|
+
*/
|
|
993
|
+
_loadAndFixEpic(bugDesc) {
|
|
994
|
+
this._testBusy = true;
|
|
995
|
+
this._lastHash = '';
|
|
996
|
+
this._stableCount = 0;
|
|
997
|
+
|
|
998
|
+
try {
|
|
999
|
+
tmx.sendKeysWithEnter(this._session, 2, '/clear');
|
|
1000
|
+
execSync('sleep 2');
|
|
1001
|
+
tmx.sendKeysWithEnter(this._session, 2, '/o dev');
|
|
1002
|
+
execSync('sleep 12');
|
|
1003
|
+
|
|
1004
|
+
// Escape quotes in bug description
|
|
1005
|
+
const safeBug = (bugDesc || 'smoke test failure').replace(/"/g, '\\"');
|
|
1006
|
+
tmx.sendKeysWithEnter(this._session, 2, `*quick-fix "${safeBug}"`);
|
|
1007
|
+
} catch (err) {
|
|
1008
|
+
this._testBusy = false;
|
|
1009
|
+
this._handleError('test', `Failed to start Dev fix: ${err.message}`);
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
this._testContext.subPhase = 'dev-fixing';
|
|
1014
|
+
this._testBusy = false;
|
|
1015
|
+
log.engine(`Dev fixing: ${bugDesc}`);
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/**
|
|
1019
|
+
* Capture QA pane output and determine PASS/FAIL.
|
|
1020
|
+
*/
|
|
1021
|
+
_evaluateTestResult() {
|
|
1022
|
+
const output = tmx.capturePane(this._session, 3, 200);
|
|
1023
|
+
const lines = output.split('\n');
|
|
1024
|
+
const tail = lines.slice(-50).join('\n');
|
|
1025
|
+
|
|
1026
|
+
// PASS indicators
|
|
1027
|
+
if (/all\s+tests?\s+passed/i.test(tail) || /โ
.*pass/i.test(tail) || /smoke.*pass/i.test(tail) || /PASS/i.test(tail)) {
|
|
1028
|
+
return { passed: true, bugDesc: null };
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// FAIL: extract bug description near failure markers
|
|
1032
|
+
let bugDesc = 'smoke test failure';
|
|
1033
|
+
for (let i = lines.length - 1; i >= Math.max(0, lines.length - 50); i--) {
|
|
1034
|
+
if (/fail|โ|error|FAIL/i.test(lines[i])) {
|
|
1035
|
+
// Take up to 3 lines around the failure for context
|
|
1036
|
+
const start = Math.max(0, i - 1);
|
|
1037
|
+
const end = Math.min(lines.length, i + 2);
|
|
1038
|
+
bugDesc = lines.slice(start, end).join(' ').trim().substring(0, 200);
|
|
1039
|
+
break;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
return { passed: false, bugDesc };
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
/**
|
|
1047
|
+
* State machine poll for test phase.
|
|
1048
|
+
*/
|
|
814
1049
|
_pollTest() {
|
|
815
1050
|
if (this._phase !== 'test') return;
|
|
1051
|
+
if (this._testBusy) return;
|
|
1052
|
+
|
|
1053
|
+
const ctx = this._testContext;
|
|
1054
|
+
if (!ctx) return;
|
|
816
1055
|
|
|
817
1056
|
if (!tmx.hasSession(this._session)) {
|
|
818
1057
|
this._handleError('test', 'tmux session died');
|
|
819
1058
|
return;
|
|
820
1059
|
}
|
|
821
1060
|
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
this.
|
|
1061
|
+
// Periodic progress report (reuses dev phase's _reportInterval)
|
|
1062
|
+
const now = Date.now();
|
|
1063
|
+
if (now - this._lastReportTime >= this._reportInterval) {
|
|
1064
|
+
this._lastReportTime = now;
|
|
1065
|
+
const card = this._buildTestProgressCard();
|
|
1066
|
+
this.onProgress(card);
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// Determine which window to watch
|
|
1070
|
+
const window = ctx.subPhase === 'dev-fixing' ? 2 : 3;
|
|
1071
|
+
const result = tmx.checkCompletion(this._session, window, this._lastHash);
|
|
1072
|
+
|
|
1073
|
+
// Not done yet
|
|
1074
|
+
if (result.status !== 'complete' && !(result.status === 'stable' && ++this._stableCount >= 3)) {
|
|
1075
|
+
if (result.status !== 'stable') { this._stableCount = 0; this._lastHash = result.hash || ''; }
|
|
1076
|
+
else { this._lastHash = result.hash; }
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
// Agent finished โ reset counters
|
|
1081
|
+
this._stableCount = 0;
|
|
1082
|
+
this._lastHash = '';
|
|
1083
|
+
|
|
1084
|
+
const epicId = ctx.epicIds[ctx.epicIdx];
|
|
1085
|
+
|
|
1086
|
+
if (ctx.subPhase === 'qa-testing') {
|
|
1087
|
+
const testResult = this._evaluateTestResult();
|
|
1088
|
+
|
|
1089
|
+
if (testResult.passed) {
|
|
1090
|
+
ctx.results[epicId] = { status: 'passed', rounds: ctx.round + 1 };
|
|
1091
|
+
this._updateTestState(epicId, 'passed', ctx.round + 1);
|
|
1092
|
+
this.onProgress(`โ
Epic ${epicId} passed (${ctx.round + 1} round(s))`);
|
|
1093
|
+
log.engine(`Epic ${epicId} PASSED after ${ctx.round + 1} round(s)`);
|
|
1094
|
+
this._advanceToNextEpic();
|
|
1095
|
+
} else if (ctx.round + 1 >= ctx.maxRounds) {
|
|
1096
|
+
ctx.results[epicId] = { status: 'failed', rounds: ctx.round + 1 };
|
|
1097
|
+
this._updateTestState(epicId, 'failed', ctx.round + 1);
|
|
1098
|
+
this.onProgress(`โ Epic ${epicId} failed after ${ctx.round + 1} rounds.\nLast error: ${testResult.bugDesc}`);
|
|
1099
|
+
log.engine(`Epic ${epicId} FAILED after ${ctx.round + 1} rounds`);
|
|
1100
|
+
this._advanceToNextEpic();
|
|
1101
|
+
} else {
|
|
1102
|
+
ctx.round++;
|
|
1103
|
+
this.onProgress(`๏ฟฝ๏ฟฝ๏ฟฝ๏ธ Epic ${epicId} failed (round ${ctx.round}/${ctx.maxRounds}). Sending to Dev for fix...`);
|
|
1104
|
+
log.engine(`Epic ${epicId} failed round ${ctx.round}, sending to Dev`);
|
|
1105
|
+
this._loadAndFixEpic(testResult.bugDesc);
|
|
1106
|
+
}
|
|
1107
|
+
} else if (ctx.subPhase === 'dev-fixing') {
|
|
1108
|
+
this.onProgress(`๐ง Dev fix done for epic ${epicId}. Retesting...`);
|
|
1109
|
+
log.engine(`Dev fix done for epic ${epicId}, retesting`);
|
|
1110
|
+
this._loadAndTestEpic();
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
/**
|
|
1115
|
+
* Advance to next epic or complete test phase.
|
|
1116
|
+
*/
|
|
1117
|
+
_advanceToNextEpic() {
|
|
1118
|
+
const ctx = this._testContext;
|
|
1119
|
+
ctx.epicIdx++;
|
|
1120
|
+
ctx.round = 0;
|
|
1121
|
+
|
|
1122
|
+
const tested = ctx.epicIdx;
|
|
1123
|
+
const total = ctx.epicIds.length;
|
|
1124
|
+
|
|
1125
|
+
// Update focus pulse
|
|
1126
|
+
try {
|
|
1127
|
+
const focusPath = path.join(this._projectRoot, '.yuri', 'focus.yaml');
|
|
1128
|
+
if (fs.existsSync(focusPath)) {
|
|
1129
|
+
const focus = yaml.load(fs.readFileSync(focusPath, 'utf8')) || {};
|
|
1130
|
+
focus.pulse = `Phase 4: ${tested}/${total} epics tested`;
|
|
1131
|
+
focus.updated_at = new Date().toISOString();
|
|
1132
|
+
fs.writeFileSync(focusPath, yaml.dump(focus, { lineWidth: -1 }));
|
|
1133
|
+
}
|
|
1134
|
+
} catch { /* ignore */ }
|
|
1135
|
+
|
|
1136
|
+
if (ctx.epicIdx >= ctx.epicIds.length) {
|
|
826
1137
|
this._completeTest();
|
|
827
1138
|
return;
|
|
828
1139
|
}
|
|
829
|
-
|
|
830
|
-
|
|
1140
|
+
|
|
1141
|
+
// Start next epic
|
|
1142
|
+
this._loadAndTestEpic();
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
/**
|
|
1146
|
+
* Update a single epic's status in phase4.yaml.
|
|
1147
|
+
*/
|
|
1148
|
+
_updateTestState(epicId, status, rounds) {
|
|
1149
|
+
try {
|
|
1150
|
+
const phase4Path = path.join(this._projectRoot, '.yuri', 'state', 'phase4.yaml');
|
|
1151
|
+
if (!fs.existsSync(phase4Path)) return;
|
|
1152
|
+
|
|
1153
|
+
const state = yaml.load(fs.readFileSync(phase4Path, 'utf8')) || {};
|
|
1154
|
+
if (!Array.isArray(state.epics)) return;
|
|
1155
|
+
|
|
1156
|
+
const epic = state.epics.find((e) => String(e.id) === String(epicId));
|
|
1157
|
+
if (epic) {
|
|
1158
|
+
epic.status = status;
|
|
1159
|
+
epic.rounds = rounds;
|
|
1160
|
+
epic.last_tested_at = new Date().toISOString();
|
|
1161
|
+
}
|
|
1162
|
+
state.regression_rounds = (state.regression_rounds || 0) + rounds;
|
|
1163
|
+
fs.writeFileSync(phase4Path, yaml.dump(state, { lineWidth: -1 }));
|
|
1164
|
+
} catch (err) {
|
|
1165
|
+
log.warn(`Failed to update phase4.yaml for epic ${epicId}: ${err.message}`);
|
|
1166
|
+
}
|
|
831
1167
|
}
|
|
832
1168
|
|
|
1169
|
+
/**
|
|
1170
|
+
* Build a progress card for the test phase (used by getStatus + periodic reports).
|
|
1171
|
+
*/
|
|
1172
|
+
_buildTestProgressCard() {
|
|
1173
|
+
const ctx = this._testContext;
|
|
1174
|
+
if (!ctx) return '๐งช Testing in progress.';
|
|
1175
|
+
|
|
1176
|
+
const total = ctx.epicIds.length;
|
|
1177
|
+
const passed = Object.values(ctx.results).filter((r) => r.status === 'passed').length;
|
|
1178
|
+
const failed = Object.values(ctx.results).filter((r) => r.status === 'failed').length;
|
|
1179
|
+
const tested = passed + failed;
|
|
1180
|
+
const pct = total > 0 ? Math.round((tested / total) * 100) : 0;
|
|
1181
|
+
|
|
1182
|
+
const currentEpic = ctx.epicIds[ctx.epicIdx];
|
|
1183
|
+
const subPhaseLabel = {
|
|
1184
|
+
'qa-testing': '๐ QA smoke-testing',
|
|
1185
|
+
'dev-fixing': '๐ง Dev fixing bug',
|
|
1186
|
+
}[ctx.subPhase] || 'โณ Loading agent';
|
|
1187
|
+
|
|
1188
|
+
const elapsed = this._devStartedAt ? this._formatDuration(Date.now() - this._devStartedAt) : '';
|
|
1189
|
+
const startedAt = this._testStartedAt ? this._formatDuration(Date.now() - this._testStartedAt) : '';
|
|
1190
|
+
|
|
1191
|
+
const lines = [
|
|
1192
|
+
`๐งช **Test Phase Progress**`,
|
|
1193
|
+
``,
|
|
1194
|
+
this._progressBar(pct),
|
|
1195
|
+
``,
|
|
1196
|
+
`๐ Epics: ${tested}/${total} tested (โ
${passed} passed, โ ${failed} failed)`,
|
|
1197
|
+
];
|
|
1198
|
+
|
|
1199
|
+
if (ctx.epicIdx < total) {
|
|
1200
|
+
lines.push(`๐ฏ Current: Epic ${currentEpic} โ ${subPhaseLabel} (round ${ctx.round + 1}/${ctx.maxRounds})`);
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
// Per-epic summary
|
|
1204
|
+
if (tested > 0) {
|
|
1205
|
+
lines.push('', '๐ Results:');
|
|
1206
|
+
for (const epicId of ctx.epicIds) {
|
|
1207
|
+
const r = ctx.results[epicId];
|
|
1208
|
+
if (r && r.status === 'passed') {
|
|
1209
|
+
lines.push(` โ
Epic ${epicId} โ passed (${r.rounds} round(s))`);
|
|
1210
|
+
} else if (r && r.status === 'failed') {
|
|
1211
|
+
lines.push(` โ Epic ${epicId} โ failed (${r.rounds} rounds)`);
|
|
1212
|
+
} else if (epicId === currentEpic) {
|
|
1213
|
+
lines.push(` โณ Epic ${epicId} โ in progress`);
|
|
1214
|
+
} else {
|
|
1215
|
+
lines.push(` โฌ Epic ${epicId} โ pending`);
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
if (startedAt) lines.push(`\nโฑ๏ธ Elapsed: ${startedAt}`);
|
|
1221
|
+
|
|
1222
|
+
return lines.join('\n');
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
/**
|
|
1226
|
+
* Complete test phase: aggregate results, update state files, send summary.
|
|
1227
|
+
*/
|
|
833
1228
|
_completeTest() {
|
|
834
1229
|
if (this._timer) { clearInterval(this._timer); this._timer = null; }
|
|
1230
|
+
|
|
1231
|
+
const ctx = this._testContext;
|
|
1232
|
+
const passed = Object.values(ctx.results).filter((r) => r.status === 'passed').length;
|
|
1233
|
+
const failed = Object.values(ctx.results).filter((r) => r.status === 'failed').length;
|
|
1234
|
+
const total = ctx.epicIds.length;
|
|
1235
|
+
|
|
1236
|
+
// Update phase4.yaml
|
|
1237
|
+
try {
|
|
1238
|
+
const phase4Path = path.join(this._projectRoot, '.yuri', 'state', 'phase4.yaml');
|
|
1239
|
+
if (fs.existsSync(phase4Path)) {
|
|
1240
|
+
const state = yaml.load(fs.readFileSync(phase4Path, 'utf8')) || {};
|
|
1241
|
+
state.status = failed > 0 ? 'complete_with_failures' : 'complete';
|
|
1242
|
+
state.completed_at = new Date().toISOString();
|
|
1243
|
+
fs.writeFileSync(phase4Path, yaml.dump(state, { lineWidth: -1 }));
|
|
1244
|
+
}
|
|
1245
|
+
} catch (err) {
|
|
1246
|
+
log.warn(`Failed to finalize phase4.yaml: ${err.message}`);
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
// Update focus.yaml
|
|
1250
|
+
try {
|
|
1251
|
+
const focusPath = path.join(this._projectRoot, '.yuri', 'focus.yaml');
|
|
1252
|
+
if (fs.existsSync(focusPath)) {
|
|
1253
|
+
const focus = yaml.load(fs.readFileSync(focusPath, 'utf8')) || {};
|
|
1254
|
+
focus.step = 'phase4.complete';
|
|
1255
|
+
focus.pulse = `Phase 4 complete: ${passed}/${total} passed`;
|
|
1256
|
+
focus.updated_at = new Date().toISOString();
|
|
1257
|
+
fs.writeFileSync(focusPath, yaml.dump(focus, { lineWidth: -1 }));
|
|
1258
|
+
}
|
|
1259
|
+
} catch { /* ignore */ }
|
|
1260
|
+
|
|
1261
|
+
// Append timeline event
|
|
1262
|
+
try {
|
|
1263
|
+
const timelinePath = path.join(this._projectRoot, '.yuri', 'timeline', 'events.jsonl');
|
|
1264
|
+
const dir = path.dirname(timelinePath);
|
|
1265
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
1266
|
+
const event = JSON.stringify({ ts: new Date().toISOString(), type: 'phase_completed', phase: 4, passed, failed, total });
|
|
1267
|
+
fs.appendFileSync(timelinePath, event + '\n');
|
|
1268
|
+
} catch { /* ignore */ }
|
|
1269
|
+
|
|
1270
|
+
// Build summary
|
|
1271
|
+
const lines = [`๐งช Testing complete! ${passed}/${total} epics passed.\n`];
|
|
1272
|
+
for (const epicId of ctx.epicIds) {
|
|
1273
|
+
const r = ctx.results[epicId];
|
|
1274
|
+
if (r && r.status === 'passed') {
|
|
1275
|
+
lines.push(` โ
Epic ${epicId} โ passed (${r.rounds} round(s))`);
|
|
1276
|
+
} else if (r && r.status === 'failed') {
|
|
1277
|
+
lines.push(` โ Epic ${epicId} โ failed after ${r.rounds} rounds`);
|
|
1278
|
+
} else {
|
|
1279
|
+
lines.push(` โญ๏ธ Epic ${epicId} โ skipped`);
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
if (failed === 0) {
|
|
1284
|
+
lines.push('\n๐ All tests passed! Run *deploy when ready.');
|
|
1285
|
+
} else {
|
|
1286
|
+
lines.push(`\nโ ๏ธ ${failed} epic(s) failed. Review and fix manually, or run *test again.`);
|
|
1287
|
+
}
|
|
1288
|
+
|
|
835
1289
|
this._phase = null;
|
|
836
|
-
|
|
837
|
-
this.
|
|
1290
|
+
this._testContext = null;
|
|
1291
|
+
this._testStartedAt = null;
|
|
1292
|
+
log.engine(`Test phase complete: ${passed}/${total} passed, ${failed} failed`);
|
|
1293
|
+
this.onComplete('test', lines.join('\n'));
|
|
838
1294
|
}
|
|
839
1295
|
|
|
840
1296
|
// โโ Iterate (New Iteration) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
package/lib/gateway/router.js
CHANGED
|
@@ -99,18 +99,38 @@ class Router {
|
|
|
99
99
|
|
|
100
100
|
try {
|
|
101
101
|
const focus = yaml.load(fs.readFileSync(focusPath, 'utf8')) || {};
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
102
|
+
const phaseNum = parseInt(focus.phase, 10);
|
|
103
|
+
|
|
104
|
+
if (phaseNum === 3) {
|
|
105
|
+
// Skip if dev phase already complete
|
|
106
|
+
const phase3Path = path.join(projectRoot, '.yuri', 'state', 'phase3.yaml');
|
|
107
|
+
if (fs.existsSync(phase3Path)) {
|
|
108
|
+
const phase3 = yaml.load(fs.readFileSync(phase3Path, 'utf8')) || {};
|
|
109
|
+
if (phase3.status === 'complete') return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Check tmux session is alive
|
|
113
|
+
const { execSync } = require('child_process');
|
|
114
|
+
const sessions = execSync('tmux list-sessions -F "#{session_name}" 2>/dev/null', { encoding: 'utf8' }).trim();
|
|
115
|
+
if (!sessions.split('\n').some((s) => s.startsWith('orchestrix-'))) return;
|
|
116
|
+
|
|
117
|
+
const card = this._buildStatusCard(projectRoot, focus);
|
|
118
|
+
if (card) {
|
|
119
|
+
log.router('Auto-reporting dev progress');
|
|
120
|
+
this._sendProactive(card);
|
|
121
|
+
}
|
|
122
|
+
} else if (phaseNum === 4) {
|
|
123
|
+
// Skip if test phase already complete
|
|
124
|
+
const phase4Path = path.join(projectRoot, '.yuri', 'state', 'phase4.yaml');
|
|
125
|
+
if (!fs.existsSync(phase4Path)) return;
|
|
126
|
+
const phase4 = yaml.load(fs.readFileSync(phase4Path, 'utf8')) || {};
|
|
127
|
+
if (phase4.status === 'complete' || phase4.status === 'complete_with_failures') return;
|
|
128
|
+
|
|
129
|
+
const card = this._buildTestStatusCard(projectRoot);
|
|
130
|
+
if (card) {
|
|
131
|
+
log.router('Auto-reporting test progress');
|
|
132
|
+
this._sendProactive(card);
|
|
133
|
+
}
|
|
114
134
|
}
|
|
115
135
|
} catch { /* silent */ }
|
|
116
136
|
}, interval);
|
|
@@ -400,6 +420,16 @@ Reply with ONLY one word: small, medium, or large. Nothing else.`;
|
|
|
400
420
|
}
|
|
401
421
|
}
|
|
402
422
|
|
|
423
|
+
// Test phase: generate test progress card from phase4.yaml
|
|
424
|
+
if (phaseNum === 4 && parts.length === 0) {
|
|
425
|
+
try {
|
|
426
|
+
const card = this._buildTestStatusCard(projectRoot);
|
|
427
|
+
if (card) parts.push(card);
|
|
428
|
+
} catch (err) {
|
|
429
|
+
log.warn(`Test progress card failed: ${err.message}`);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
403
433
|
// Fallback or non-dev phase
|
|
404
434
|
if (parts.length === 0) {
|
|
405
435
|
if (focus.pulse) parts.push(`Pulse: ${focus.pulse}`);
|
|
@@ -817,6 +847,52 @@ Reply with ONLY one word: small, medium, or large. Nothing else.`;
|
|
|
817
847
|
return lines.join('\n');
|
|
818
848
|
}
|
|
819
849
|
|
|
850
|
+
// โโ Test Progress Card (from phase4.yaml, for *status when orchestrator is not tracking) โโ
|
|
851
|
+
|
|
852
|
+
_buildTestStatusCard(projectRoot) {
|
|
853
|
+
const phase4Path = path.join(projectRoot, '.yuri', 'state', 'phase4.yaml');
|
|
854
|
+
if (!fs.existsSync(phase4Path)) return null;
|
|
855
|
+
|
|
856
|
+
const state = yaml.load(fs.readFileSync(phase4Path, 'utf8')) || {};
|
|
857
|
+
if (!Array.isArray(state.epics) || state.epics.length === 0) return null;
|
|
858
|
+
|
|
859
|
+
const total = state.epics.length;
|
|
860
|
+
const passed = state.epics.filter((e) => e.status === 'passed').length;
|
|
861
|
+
const failed = state.epics.filter((e) => e.status === 'failed').length;
|
|
862
|
+
const tested = passed + failed;
|
|
863
|
+
const pct = total > 0 ? Math.round((tested / total) * 100) : 0;
|
|
864
|
+
|
|
865
|
+
const barTotal = 20;
|
|
866
|
+
const filled = Math.round(pct / 100 * barTotal);
|
|
867
|
+
const bar = 'โ'.repeat(filled) + 'โ'.repeat(barTotal - filled) + ` ${pct}%`;
|
|
868
|
+
|
|
869
|
+
const lines = [
|
|
870
|
+
'๐งช **Test Phase Progress**',
|
|
871
|
+
'',
|
|
872
|
+
bar,
|
|
873
|
+
'',
|
|
874
|
+
`๐ Epics: ${tested}/${total} tested (โ
${passed} passed, โ ${failed} failed)`,
|
|
875
|
+
'',
|
|
876
|
+
'๐ Results:',
|
|
877
|
+
];
|
|
878
|
+
|
|
879
|
+
for (const epic of state.epics) {
|
|
880
|
+
if (epic.status === 'passed') {
|
|
881
|
+
lines.push(` โ
Epic ${epic.id} โ passed (${epic.rounds || 0} round(s))`);
|
|
882
|
+
} else if (epic.status === 'failed') {
|
|
883
|
+
lines.push(` โ Epic ${epic.id} โ failed (${epic.rounds || 0} rounds)`);
|
|
884
|
+
} else {
|
|
885
|
+
lines.push(` โฌ Epic ${epic.id} โ ${epic.status || 'pending'}`);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
if (state.status === 'complete' || state.status === 'complete_with_failures') {
|
|
890
|
+
lines.push(`\nโ
Testing finished at ${state.completed_at || 'unknown'}`);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
return lines.join('\n');
|
|
894
|
+
}
|
|
895
|
+
|
|
820
896
|
// โโ Help โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
821
897
|
|
|
822
898
|
_buildHelpText() {
|