sinapse-ai 1.19.0 → 1.19.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/.sinapse-ai/core/execution/build-orchestrator.js +31 -0
- package/.sinapse-ai/core/orchestration/bob-orchestrator.js +2 -4
- package/.sinapse-ai/core/orchestration/executors/epic-4-executor.js +57 -40
- package/.sinapse-ai/core/orchestration/gate-evaluator.js +124 -3
- package/.sinapse-ai/core/orchestration/greenfield-handler.js +14 -46
- package/.sinapse-ai/core/orchestration/index.js +0 -12
- package/.sinapse-ai/core/orchestration/master-orchestrator.js +60 -4
- package/.sinapse-ai/core/orchestration/workflow-executor.js +8 -150
- package/.sinapse-ai/data/entity-registry.yaml +315 -527
- package/.sinapse-ai/development/agents/project-lead/MEMORY.md +1 -1
- package/.sinapse-ai/development/agents/project-lead.md +9 -13
- package/.sinapse-ai/development/tasks/push-and-pr.md +1 -1
- package/.sinapse-ai/development/workflows/development-cycle.yaml +1 -12
- package/.sinapse-ai/install-manifest.yaml +26 -34
- package/CHANGELOG.md +41 -26
- package/package.json +1 -1
- package/.sinapse-ai/core/orchestration/terminal-spawner.js +0 -1067
- package/.sinapse-ai/scripts/pm.sh +0 -466
|
@@ -231,6 +231,11 @@ class BuildOrchestrator extends EventEmitter {
|
|
|
231
231
|
// quality gate can inspect its honesty (degraded/stub/subtasks). Without this
|
|
232
232
|
// the epic4_to_epic6 gate has nothing real to bite on.
|
|
233
233
|
plan: ctx.plan,
|
|
234
|
+
// epic: empty-build-honesty — surface the REAL files the build wrote/modified
|
|
235
|
+
// (collected from the build loop's per-subtask results). Without this the
|
|
236
|
+
// downstream `implementation_exists` gate cannot tell a real build apart from
|
|
237
|
+
// an empty "success" that touched zero code files.
|
|
238
|
+
filesModified: this._collectModifiedFiles(ctx),
|
|
234
239
|
};
|
|
235
240
|
} catch (error) {
|
|
236
241
|
ctx.errors.push(error);
|
|
@@ -263,6 +268,32 @@ class BuildOrchestrator extends EventEmitter {
|
|
|
263
268
|
}
|
|
264
269
|
}
|
|
265
270
|
|
|
271
|
+
/**
|
|
272
|
+
* Collect the real files the build wrote/modified from the build-loop result.
|
|
273
|
+
*
|
|
274
|
+
* epic: empty-build-honesty — the per-subtask results carry `filesModified`
|
|
275
|
+
* (extracted from the agent output). This flattens + de-duplicates them into a
|
|
276
|
+
* single honest list. Returns [] when nothing was touched (an empty build), which
|
|
277
|
+
* is precisely the signal the `implementation_exists` gate needs to block.
|
|
278
|
+
*
|
|
279
|
+
* @param {Object} ctx - Build context ({ result: { results: [...] } })
|
|
280
|
+
* @returns {string[]} De-duplicated list of modified files
|
|
281
|
+
* @private
|
|
282
|
+
*/
|
|
283
|
+
_collectModifiedFiles(ctx) {
|
|
284
|
+
const results = ctx && ctx.result && Array.isArray(ctx.result.results) ? ctx.result.results : [];
|
|
285
|
+
const files = [];
|
|
286
|
+
for (const r of results) {
|
|
287
|
+
const list = r && Array.isArray(r.filesModified) ? r.filesModified : [];
|
|
288
|
+
for (const f of list) {
|
|
289
|
+
if (typeof f === 'string' && f.trim() !== '' && !files.includes(f)) {
|
|
290
|
+
files.push(f);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return files;
|
|
295
|
+
}
|
|
296
|
+
|
|
266
297
|
/**
|
|
267
298
|
* Run a phase with event emission and error handling
|
|
268
299
|
*/
|
|
@@ -10,8 +10,7 @@
|
|
|
10
10
|
*
|
|
11
11
|
* Integrates all Epic 11 modules:
|
|
12
12
|
* - ExecutorAssignment (11.1) — agent selection
|
|
13
|
-
* -
|
|
14
|
-
* - WorkflowExecutor (11.3) — development cycle
|
|
13
|
+
* - WorkflowExecutor (11.3) — development cycle (honest manual hand-off)
|
|
15
14
|
* - SurfaceChecker (11.4) — human decision criteria
|
|
16
15
|
* - SessionState (11.5) — session persistence
|
|
17
16
|
*
|
|
@@ -1252,8 +1251,7 @@ class BobOrchestrator {
|
|
|
1252
1251
|
*
|
|
1253
1252
|
* Delegates to Epic 11 modules:
|
|
1254
1253
|
* - ExecutorAssignment for agent selection (AC8)
|
|
1255
|
-
* -
|
|
1256
|
-
* - WorkflowExecutor for development cycle (AC10)
|
|
1254
|
+
* - WorkflowExecutor for development cycle, with honest manual hand-off (AC10)
|
|
1257
1255
|
*
|
|
1258
1256
|
* Story 12.5 AC5: Updates session state at each phase transition.
|
|
1259
1257
|
*
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
const fs = require('fs-extra');
|
|
15
15
|
const path = require('path');
|
|
16
|
+
const yaml = require('js-yaml');
|
|
16
17
|
const EpicExecutor = require('./epic-executor');
|
|
17
18
|
|
|
18
19
|
/**
|
|
@@ -112,13 +113,23 @@ class Epic4Executor extends EpicExecutor {
|
|
|
112
113
|
if (this._realExecutionAllowed()) {
|
|
113
114
|
const buildResult = await this._executeViaBuildOrchestrator(storyId, context);
|
|
114
115
|
if (buildResult && buildResult.success) {
|
|
116
|
+
// Honesty invariant (epic: empty-build-honesty): do NOT set
|
|
117
|
+
// `implementationPath = planPath`. A plan path ALWAYS exists and is NOT
|
|
118
|
+
// proof of implementation — the multi-story measurement caught the gate
|
|
119
|
+
// approving an empty build because of exactly this. Surface the REAL files
|
|
120
|
+
// the build touched so the epic4_to_epic6 gate can tell an implementation
|
|
121
|
+
// apart from an empty "success".
|
|
122
|
+
const codeChanges = Array.isArray(buildResult.filesModified)
|
|
123
|
+
? buildResult.filesModified
|
|
124
|
+
: [];
|
|
115
125
|
return this._completeExecution({
|
|
116
|
-
implementationPath: planPath,
|
|
117
126
|
planPath,
|
|
118
127
|
// F5 (epic: orchestration-consolidation): propagate the real plan object
|
|
119
128
|
// so the downstream epic4_to_epic6 gate can verify it is not degraded/stub.
|
|
120
129
|
plan: buildResult.plan,
|
|
121
130
|
build: buildResult,
|
|
131
|
+
codeChanges,
|
|
132
|
+
filesModified: codeChanges,
|
|
122
133
|
reportPath: buildResult.reportPath,
|
|
123
134
|
phases: buildResult.phases,
|
|
124
135
|
});
|
|
@@ -143,7 +154,10 @@ class Epic4Executor extends EpicExecutor {
|
|
|
143
154
|
return this._stubExecution(
|
|
144
155
|
'Epic 4 stub mode — real build is delegated to BuildOrchestrator outside the test runner',
|
|
145
156
|
{
|
|
146
|
-
|
|
157
|
+
// Honesty invariant (epic: empty-build-honesty): no `implementationPath`
|
|
158
|
+
// here — the plan path is not an implementation. The stub already reports
|
|
159
|
+
// success:false, and `codeChanges` carries whatever real files (if any)
|
|
160
|
+
// the subtasks touched.
|
|
147
161
|
planPath,
|
|
148
162
|
progress,
|
|
149
163
|
subtaskResults,
|
|
@@ -215,44 +229,47 @@ class Epic4Executor extends EpicExecutor {
|
|
|
215
229
|
* @private
|
|
216
230
|
*/
|
|
217
231
|
async _createStubPlan(planPath, storyId, specPath) {
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
232
|
+
const now = new Date().toISOString();
|
|
233
|
+
|
|
234
|
+
// Build the plan as an object and serialize via yaml.dump. This is robust to
|
|
235
|
+
// any content — notably Windows absolute paths in `specPath`
|
|
236
|
+
// (e.g. "C:\Users\...\AppData\...") which, when hand-written into a
|
|
237
|
+
// double-quoted YAML scalar, contain invalid escape sequences (`\U`, `\A`)
|
|
238
|
+
// that make `yaml.load` throw and kill the whole build path on Windows.
|
|
239
|
+
// yaml.dump escapes/quotes correctly for any value (Story: FIX windows-path-yaml-plan).
|
|
240
|
+
const planObject = {
|
|
241
|
+
metadata: {
|
|
242
|
+
storyId,
|
|
243
|
+
specPath: specPath || 'N/A',
|
|
244
|
+
status: 'draft',
|
|
245
|
+
createdAt: now,
|
|
246
|
+
},
|
|
247
|
+
phases: [
|
|
248
|
+
{
|
|
249
|
+
phase: 1,
|
|
250
|
+
name: 'Setup',
|
|
251
|
+
subtasks: [{ id: '1.1', name: 'Initialize project structure', status: 'pending' }],
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
phase: 2,
|
|
255
|
+
name: 'Implementation',
|
|
256
|
+
subtasks: [{ id: '2.1', name: 'Implement core functionality', status: 'pending' }],
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
phase: 3,
|
|
260
|
+
name: 'Testing',
|
|
261
|
+
subtasks: [{ id: '3.1', name: 'Write tests', status: 'pending' }],
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
phase: 4,
|
|
265
|
+
name: 'Documentation',
|
|
266
|
+
subtasks: [{ id: '4.1', name: 'Update documentation', status: 'pending' }],
|
|
267
|
+
},
|
|
268
|
+
],
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const header = `# Implementation Plan: ${storyId}\n# Generated: ${now}\n\n`;
|
|
272
|
+
const stubPlan = header + yaml.dump(planObject, { lineWidth: -1, noRefs: true });
|
|
256
273
|
|
|
257
274
|
await fs.ensureDir(path.dirname(planPath));
|
|
258
275
|
await fs.writeFile(planPath, stubPlan);
|
|
@@ -202,6 +202,22 @@ class GateEvaluator {
|
|
|
202
202
|
async _runGateChecks(fromEpic, toEpic, epicResult, gateConfig) {
|
|
203
203
|
const checks = [];
|
|
204
204
|
|
|
205
|
+
// Universal honesty guard (master/gate leak fix, epic: orchestration-consolidation):
|
|
206
|
+
// a gate must NEVER approve a result that itself signals failure. The e2e checkpoint
|
|
207
|
+
// caught the gate approving (score 5.0) a QA report whose verdict was BLOCKED, because
|
|
208
|
+
// the existence checks (qa_report_exists / verdict_generated) never inspected the VALUE.
|
|
209
|
+
// This runs on EVERY gate, but ONLY adds a (failing, critical) check when a real failure
|
|
210
|
+
// signal is present — so it never inflates an honest zero-check gate (F5 invariant).
|
|
211
|
+
const failureSignal = this._detectFailureSignal(epicResult);
|
|
212
|
+
if (failureSignal) {
|
|
213
|
+
checks.push({
|
|
214
|
+
name: 'result_not_failed',
|
|
215
|
+
passed: false,
|
|
216
|
+
message: failureSignal,
|
|
217
|
+
severity: 'critical',
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
205
221
|
// Get check list for this gate
|
|
206
222
|
const checkNames = gateConfig.checks || this._getDefaultChecks(fromEpic, toEpic);
|
|
207
223
|
|
|
@@ -254,6 +270,99 @@ class GateEvaluator {
|
|
|
254
270
|
return checks;
|
|
255
271
|
}
|
|
256
272
|
|
|
273
|
+
/**
|
|
274
|
+
* Detect whether an epic result itself signals failure (master/gate leak fix).
|
|
275
|
+
*
|
|
276
|
+
* Returns a human-readable reason string when the result is NOT sound, or null when
|
|
277
|
+
* no failure signal is present. Used by `_runGateChecks` to add a critical, blocking
|
|
278
|
+
* check so a gate can never APPROVE a failed/blocked upstream result.
|
|
279
|
+
*
|
|
280
|
+
* @param {Object} epicResult - Result from the source epic
|
|
281
|
+
* @returns {string|null} Failure reason, or null if the result shows no failure signal
|
|
282
|
+
* @private
|
|
283
|
+
*/
|
|
284
|
+
_detectFailureSignal(epicResult) {
|
|
285
|
+
if (!epicResult || typeof epicResult !== 'object') {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
if (epicResult.success === false || epicResult.status === 'failed') {
|
|
289
|
+
return 'Epic result signals failure (success:false / status:failed)';
|
|
290
|
+
}
|
|
291
|
+
const norm = (v) => (typeof v === 'string' ? v.toLowerCase() : null);
|
|
292
|
+
const verdict = norm(epicResult.verdict);
|
|
293
|
+
if (verdict === 'blocked' || verdict === 'failed') {
|
|
294
|
+
return `Epic verdict is "${epicResult.verdict}"`;
|
|
295
|
+
}
|
|
296
|
+
const qaVerdict = epicResult.qaReport ? norm(epicResult.qaReport.verdict) : null;
|
|
297
|
+
if (qaVerdict === 'blocked' || qaVerdict === 'failed') {
|
|
298
|
+
return `QA report verdict is "${epicResult.qaReport.verdict}"`;
|
|
299
|
+
}
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Collect the REAL code files an epic actually wrote/modified.
|
|
305
|
+
*
|
|
306
|
+
* Honesty invariant (epic: empty-build-honesty): a plan file is NOT an
|
|
307
|
+
* implementation. This gathers the concrete files touched from `codeChanges` /
|
|
308
|
+
* `filesModified` (and `build.filesModified` when the build result is nested),
|
|
309
|
+
* then EXCLUDES the plan artifact (`planPath` / `implementationPath`) so a build
|
|
310
|
+
* that only produced a plan yields an empty list — and the caller can block it.
|
|
311
|
+
*
|
|
312
|
+
* Entries may be strings or `{ path | file }` objects; blanks and duplicates are
|
|
313
|
+
* dropped. Returns a de-duplicated array of file paths (never the plan itself).
|
|
314
|
+
*
|
|
315
|
+
* @param {Object} epicResult - Result from the source epic
|
|
316
|
+
* @returns {string[]} Real implementation files (plan artifact excluded)
|
|
317
|
+
* @private
|
|
318
|
+
*/
|
|
319
|
+
_collectImplementationFiles(epicResult) {
|
|
320
|
+
if (!epicResult || typeof epicResult !== 'object') {
|
|
321
|
+
return [];
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const resolveSafe = (p) => {
|
|
325
|
+
try {
|
|
326
|
+
return path.resolve(p);
|
|
327
|
+
} catch {
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
// The plan artifact is explicitly NOT an implementation — exclude it.
|
|
333
|
+
const planPaths = new Set(
|
|
334
|
+
[epicResult.planPath, epicResult.implementationPath]
|
|
335
|
+
.filter((p) => typeof p === 'string' && p.trim() !== '')
|
|
336
|
+
.map(resolveSafe)
|
|
337
|
+
.filter(Boolean),
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
const raw = []
|
|
341
|
+
.concat(Array.isArray(epicResult.codeChanges) ? epicResult.codeChanges : [])
|
|
342
|
+
.concat(Array.isArray(epicResult.filesModified) ? epicResult.filesModified : [])
|
|
343
|
+
.concat(
|
|
344
|
+
epicResult.build && Array.isArray(epicResult.build.filesModified)
|
|
345
|
+
? epicResult.build.filesModified
|
|
346
|
+
: [],
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
const files = [];
|
|
350
|
+
for (const entry of raw) {
|
|
351
|
+
const file = typeof entry === 'string' ? entry : entry && (entry.path || entry.file);
|
|
352
|
+
if (typeof file !== 'string' || file.trim() === '') {
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
const resolved = resolveSafe(file);
|
|
356
|
+
if (resolved && planPaths.has(resolved)) {
|
|
357
|
+
continue; // a plan path masquerading as a code change — not implementation
|
|
358
|
+
}
|
|
359
|
+
if (!files.includes(file)) {
|
|
360
|
+
files.push(file);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return files;
|
|
364
|
+
}
|
|
365
|
+
|
|
257
366
|
/**
|
|
258
367
|
* Run a single check
|
|
259
368
|
* @private
|
|
@@ -334,11 +443,23 @@ class GateEvaluator {
|
|
|
334
443
|
break;
|
|
335
444
|
}
|
|
336
445
|
|
|
337
|
-
case 'implementation_exists':
|
|
338
|
-
|
|
339
|
-
|
|
446
|
+
case 'implementation_exists': {
|
|
447
|
+
// Honesty invariant (epic: empty-build-honesty): "implementation exists"
|
|
448
|
+
// MUST mean real code was written — not merely that a PLAN file exists. The
|
|
449
|
+
// multi-story measurement (2026-06-30) caught this gate APPROVING (score 5.0)
|
|
450
|
+
// a build that wrote ZERO code files: the old check trusted
|
|
451
|
+
// `implementationPath`, but epic-4 set that to the PLAN path (which ALWAYS
|
|
452
|
+
// exists), so an empty build slipped through. Base the check on the real list
|
|
453
|
+
// of files touched by the build, excluding the plan artifact itself. Empty
|
|
454
|
+
// build → zero real files → critical fail → gate BLOCKS (empty ≠ success).
|
|
455
|
+
const files = this._collectImplementationFiles(epicResult);
|
|
456
|
+
result.passed = files.length > 0;
|
|
457
|
+
result.message = result.passed
|
|
458
|
+
? `Implementation exists (${files.length} code file(s) changed)`
|
|
459
|
+
: 'No implementation found — build wrote zero code files (plan path is not implementation)';
|
|
340
460
|
result.severity = 'critical';
|
|
341
461
|
break;
|
|
462
|
+
}
|
|
342
463
|
|
|
343
464
|
case 'no_critical_errors': {
|
|
344
465
|
const criticalErrors = (epicResult.errors || []).filter((e) => e.severity === 'critical');
|
|
@@ -624,62 +624,30 @@ class GreenfieldHandler extends EventEmitter {
|
|
|
624
624
|
// ═══════════════════════════════════════════════════════════════════════════════════
|
|
625
625
|
|
|
626
626
|
/**
|
|
627
|
-
*
|
|
627
|
+
* Resolves agent execution to an honest manual hand-off (AC6 selection only).
|
|
628
|
+
*
|
|
629
|
+
* The legacy visual-terminal lineage (TerminalSpawner → pm.sh) was a verified stub that
|
|
630
|
+
* never invoked claude, so spawning it fabricated success in headless-with-bash. It has
|
|
631
|
+
* been removed (STORY-F3C); execution is now handed off honestly to the user. Routing to
|
|
632
|
+
* the canonical `dispatcher → claude` lineage is intentionally out of scope here.
|
|
628
633
|
*
|
|
629
634
|
* @param {string} agent - Agent ID (e.g., '@devops')
|
|
630
635
|
* @param {string} task - Task to execute
|
|
631
636
|
* @param {Object} spawnContext - Spawn context
|
|
632
|
-
* @returns {Promise<Object>}
|
|
637
|
+
* @returns {Promise<Object>} Honest manual hand-off result
|
|
633
638
|
* @private
|
|
634
639
|
*/
|
|
635
640
|
async _spawnAgent(agent, task, spawnContext = {}) {
|
|
636
|
-
this._log(`
|
|
641
|
+
this._log(`Handing off ${agent} for ${task} (manual execution)`);
|
|
637
642
|
|
|
638
643
|
this.emit('agentSpawn', { agent, task, context: spawnContext });
|
|
639
644
|
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
const result = await TerminalSpawner.spawnAgent(agentId, task, {
|
|
647
|
-
context: {
|
|
648
|
-
instructions: spawnContext.instructions || `Execute ${task}`,
|
|
649
|
-
creates: spawnContext.creates || [],
|
|
650
|
-
requires: spawnContext.requires || [],
|
|
651
|
-
metadata: spawnContext.previousResults || {},
|
|
652
|
-
},
|
|
653
|
-
timeout: 7200000, // 2 hours
|
|
654
|
-
debug: this.options.debug,
|
|
655
|
-
});
|
|
656
|
-
|
|
657
|
-
if (result.pid) {
|
|
658
|
-
this.emit('terminalSpawn', { agent, pid: result.pid, task });
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
return {
|
|
662
|
-
success: result.success !== false,
|
|
663
|
-
pid: result.pid,
|
|
664
|
-
output: result.output,
|
|
665
|
-
outputFile: result.outputFile,
|
|
666
|
-
};
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
// Fallback: Return instructions for manual execution
|
|
670
|
-
this._log(`TerminalSpawner not available, returning manual instructions for ${agent}`);
|
|
671
|
-
return {
|
|
672
|
-
success: true,
|
|
673
|
-
manual: true,
|
|
674
|
-
instructions: `Spawn ${agent} manually and execute: *${task}`,
|
|
675
|
-
};
|
|
676
|
-
} catch (error) {
|
|
677
|
-
this._log(`Failed to spawn ${agent}: ${error.message}`, 'error');
|
|
678
|
-
return {
|
|
679
|
-
success: false,
|
|
680
|
-
error: error.message,
|
|
681
|
-
};
|
|
682
|
-
}
|
|
645
|
+
// Honest hand-off: return instructions for manual execution (no fabricated success).
|
|
646
|
+
return {
|
|
647
|
+
success: true,
|
|
648
|
+
manual: true,
|
|
649
|
+
instructions: `Spawn ${agent} manually and execute: *${task}`,
|
|
650
|
+
};
|
|
683
651
|
}
|
|
684
652
|
|
|
685
653
|
// ═══════════════════════════════════════════════════════════════════════════════════
|
|
@@ -41,9 +41,6 @@ const cliCommands = require('./cli-commands');
|
|
|
41
41
|
// Story 11.1: Executor Assignment (Projeto Bob)
|
|
42
42
|
const ExecutorAssignment = require('./executor-assignment');
|
|
43
43
|
|
|
44
|
-
// Story 11.2: Terminal Spawner (Projeto Bob)
|
|
45
|
-
const TerminalSpawner = require('./terminal-spawner');
|
|
46
|
-
|
|
47
44
|
// Story 11.3: Workflow Executor (Projeto Bob)
|
|
48
45
|
const {
|
|
49
46
|
WorkflowExecutor,
|
|
@@ -216,15 +213,6 @@ module.exports = {
|
|
|
216
213
|
validateExecutorAssignment: ExecutorAssignment.validateExecutorAssignment,
|
|
217
214
|
EXECUTOR_ASSIGNMENT_TABLE: ExecutorAssignment.EXECUTOR_ASSIGNMENT_TABLE,
|
|
218
215
|
|
|
219
|
-
// Story 11.2: Terminal Spawner (Projeto Bob)
|
|
220
|
-
TerminalSpawner,
|
|
221
|
-
spawnAgent: TerminalSpawner.spawnAgent,
|
|
222
|
-
createContextFile: TerminalSpawner.createContextFile,
|
|
223
|
-
pollForOutput: TerminalSpawner.pollForOutput,
|
|
224
|
-
isSpawnerAvailable: TerminalSpawner.isSpawnerAvailable,
|
|
225
|
-
getPlatform: TerminalSpawner.getPlatform,
|
|
226
|
-
cleanupOldFiles: TerminalSpawner.cleanupOldFiles,
|
|
227
|
-
|
|
228
216
|
// Story 11.3: Workflow Executor (Projeto Bob)
|
|
229
217
|
WorkflowExecutor,
|
|
230
218
|
createWorkflowExecutor,
|
|
@@ -478,10 +478,15 @@ class MasterOrchestrator extends EventEmitter {
|
|
|
478
478
|
} else {
|
|
479
479
|
pipelineResult.epicsFailed.push(epicNum);
|
|
480
480
|
|
|
481
|
-
//
|
|
481
|
+
// Honesty invariant (master/gate leak fix): ANY failed epic means the
|
|
482
|
+
// pipeline did not fully succeed — not only critical/strict ones. A
|
|
483
|
+
// non-critical failure (e.g. QA/Epic 6) must still flip success:false so
|
|
484
|
+
// finalize() never reports an overall green build over a red epic.
|
|
485
|
+
pipelineResult.success = false;
|
|
486
|
+
|
|
487
|
+
// In strict mode or if epic is critical, also STOP the pipeline now.
|
|
482
488
|
if (this.strictGates || this._isEpicCritical(epicNum)) {
|
|
483
489
|
this._log(`Critical epic ${epicNum} failed, halting pipeline`);
|
|
484
|
-
pipelineResult.success = false;
|
|
485
490
|
break;
|
|
486
491
|
}
|
|
487
492
|
}
|
|
@@ -604,6 +609,45 @@ class MasterOrchestrator extends EventEmitter {
|
|
|
604
609
|
orchestrator: this,
|
|
605
610
|
});
|
|
606
611
|
|
|
612
|
+
// Honesty invariant (epic: orchestration-consolidation — master/gate leak fix):
|
|
613
|
+
// an executor may signal failure by RETURNING { success:false } / { status:'failed' }
|
|
614
|
+
// WITHOUT throwing. The old code fell straight through to "mark COMPLETED" + logged
|
|
615
|
+
// "completed successfully" + returned { success:true } for any non-stub result — so a
|
|
616
|
+
// failed epic leaked a green pipeline (the e2e "Epic 4 failed" → "Epic 4 completed
|
|
617
|
+
// successfully" → exit 0 symptom). The `catch` below only handles THROWN errors, so a
|
|
618
|
+
// returned failure must be detected explicitly here and treated as a real failure.
|
|
619
|
+
const isStubResult = result && result.status === 'stub';
|
|
620
|
+
const isFailedResult =
|
|
621
|
+
result && !isStubResult && (result.success === false || result.status === 'failed');
|
|
622
|
+
|
|
623
|
+
if (isFailedResult) {
|
|
624
|
+
this.executionState.epics[epicNum] = {
|
|
625
|
+
...this.executionState.epics[epicNum],
|
|
626
|
+
status: EpicStatus.FAILED,
|
|
627
|
+
completedAt: new Date().toISOString(),
|
|
628
|
+
result,
|
|
629
|
+
errors: [
|
|
630
|
+
...(this.executionState.epics[epicNum]?.errors || []),
|
|
631
|
+
{
|
|
632
|
+
message: result.error || `Epic ${epicNum} returned a non-success result`,
|
|
633
|
+
timestamp: new Date().toISOString(),
|
|
634
|
+
},
|
|
635
|
+
],
|
|
636
|
+
};
|
|
637
|
+
|
|
638
|
+
await this._saveState();
|
|
639
|
+
|
|
640
|
+
this.emit('epicComplete', { epicNum, result, gateResult: null });
|
|
641
|
+
this.onEpicComplete(epicNum, result);
|
|
642
|
+
|
|
643
|
+
this._log(`Epic ${epicNum} failed (executor returned non-success result)`, {
|
|
644
|
+
level: 'error',
|
|
645
|
+
icon: '❌',
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
return { success: false, status: 'failed', epicNum, result };
|
|
649
|
+
}
|
|
650
|
+
|
|
607
651
|
// Mark as completed
|
|
608
652
|
this.executionState.epics[epicNum] = {
|
|
609
653
|
...this.executionState.epics[epicNum],
|
|
@@ -614,8 +658,8 @@ class MasterOrchestrator extends EventEmitter {
|
|
|
614
658
|
|
|
615
659
|
// Evaluate quality gate (Story 0.6) - only in full pipeline mode
|
|
616
660
|
// Skip gate evaluation if result is from stub executor
|
|
661
|
+
// (isStubResult already computed above with the failure-detection guard).
|
|
617
662
|
let gateResult = null;
|
|
618
|
-
const isStubResult = result && result.status === 'stub';
|
|
619
663
|
if (this._inFullPipeline && result && result.success !== false && !isStubResult) {
|
|
620
664
|
gateResult = await this._evaluateGate(epicNum, result);
|
|
621
665
|
|
|
@@ -1486,13 +1530,25 @@ class MasterOrchestrator extends EventEmitter {
|
|
|
1486
1530
|
const seconds = Math.floor((duration % 60000) / 1000);
|
|
1487
1531
|
|
|
1488
1532
|
const hasStubs = pipelineResult.hasStubs || (pipelineResult.epicsStubbed || []).length > 0;
|
|
1533
|
+
// Honesty invariant (master/gate leak fix): a pipeline with ANY failed epic, or a
|
|
1534
|
+
// final state of BLOCKED/FAILED, can never report success:true — even if a stale
|
|
1535
|
+
// optimistic flag survived. `blocked` is surfaced so the CLI shows BLOCKED (exit 2)
|
|
1536
|
+
// instead of "ORCHESTRATION COMPLETE" / exit 0.
|
|
1537
|
+
const hasFailures = (pipelineResult.epicsFailed || []).length > 0;
|
|
1538
|
+
const blocked = this._state === OrchestratorState.BLOCKED;
|
|
1489
1539
|
return {
|
|
1490
1540
|
workflowId: this.executionState.workflowId,
|
|
1491
1541
|
storyId: this.storyId,
|
|
1492
1542
|
status: this._state,
|
|
1543
|
+
blocked,
|
|
1493
1544
|
// Honesty invariant (epic: orchestration-consolidation, F0a): a pipeline that ran
|
|
1494
1545
|
// any epic in STUB mode did NOT really build anything — it must not report success:true.
|
|
1495
|
-
|
|
1546
|
+
// A failed epic or a non-COMPLETE terminal state is likewise never a success.
|
|
1547
|
+
success:
|
|
1548
|
+
(pipelineResult.success ?? this._state === OrchestratorState.COMPLETE) &&
|
|
1549
|
+
!hasStubs &&
|
|
1550
|
+
!hasFailures &&
|
|
1551
|
+
!blocked,
|
|
1496
1552
|
mode: hasStubs ? 'stub' : 'real',
|
|
1497
1553
|
stubbedEpics: pipelineResult.epicsStubbed || [],
|
|
1498
1554
|
...(hasStubs
|