@syntesseraai/opencode-feature-factory 0.1.7 → 0.1.9
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/src/stop-quality-gate.ts +210 -75
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "@syntesseraai/opencode-feature-factory",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.9",
|
|
5
5
|
"description": "OpenCode plugin for Feature Factory agents - provides planning, implementation, review, testing, and validation agents",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"license": "MIT",
|
package/src/stop-quality-gate.ts
CHANGED
|
@@ -41,6 +41,87 @@ function getSessionState(sessionId: string): ExtendedSessionState {
|
|
|
41
41
|
return state;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
// ============================================================================
|
|
45
|
+
// Global Execution Lock
|
|
46
|
+
// ============================================================================
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Global lock to prevent concurrent quality gate executions.
|
|
50
|
+
* This prevents multiple builds from running simultaneously when
|
|
51
|
+
* multiple session.idle events fire in quick succession.
|
|
52
|
+
*/
|
|
53
|
+
interface ExecutionLock {
|
|
54
|
+
/** Whether a quality gate is currently running */
|
|
55
|
+
isRunning: boolean;
|
|
56
|
+
/** Session ID that holds the lock */
|
|
57
|
+
heldBy: string | null;
|
|
58
|
+
/** Timestamp when the lock was acquired */
|
|
59
|
+
acquiredAt: number;
|
|
60
|
+
/** Queue of sessions waiting to run */
|
|
61
|
+
queue: string[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const executionLock: ExecutionLock = {
|
|
65
|
+
isRunning: false,
|
|
66
|
+
heldBy: null,
|
|
67
|
+
acquiredAt: 0,
|
|
68
|
+
queue: [],
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** Maximum time a lock can be held before it's considered stale (5 minutes) */
|
|
72
|
+
const LOCK_TIMEOUT_MS = 5 * 60 * 1000;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Attempt to acquire the execution lock.
|
|
76
|
+
* Returns true if lock acquired, false if another execution is in progress.
|
|
77
|
+
*/
|
|
78
|
+
function tryAcquireLock(sessionId: string): boolean {
|
|
79
|
+
const now = Date.now();
|
|
80
|
+
|
|
81
|
+
// Check for stale lock (process crashed or hung)
|
|
82
|
+
if (executionLock.isRunning && now - executionLock.acquiredAt > LOCK_TIMEOUT_MS) {
|
|
83
|
+
// Force release stale lock
|
|
84
|
+
executionLock.isRunning = false;
|
|
85
|
+
executionLock.heldBy = null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (executionLock.isRunning) {
|
|
89
|
+
// Add to queue if not already queued
|
|
90
|
+
if (!executionLock.queue.includes(sessionId)) {
|
|
91
|
+
executionLock.queue.push(sessionId);
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
executionLock.isRunning = true;
|
|
97
|
+
executionLock.heldBy = sessionId;
|
|
98
|
+
executionLock.acquiredAt = now;
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Release the execution lock and return the next session in queue (if any).
|
|
104
|
+
*/
|
|
105
|
+
function releaseLock(sessionId: string): string | null {
|
|
106
|
+
// Only release if we hold the lock
|
|
107
|
+
if (executionLock.heldBy !== sessionId) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
executionLock.isRunning = false;
|
|
112
|
+
executionLock.heldBy = null;
|
|
113
|
+
|
|
114
|
+
// Return next session in queue
|
|
115
|
+
return executionLock.queue.shift() ?? null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Check if the lock is currently held (for logging/debugging)
|
|
120
|
+
*/
|
|
121
|
+
function _isLockHeld(): boolean {
|
|
122
|
+
return executionLock.isRunning;
|
|
123
|
+
}
|
|
124
|
+
|
|
44
125
|
// ============================================================================
|
|
45
126
|
// Prompt Builders
|
|
46
127
|
// ============================================================================
|
|
@@ -229,10 +310,18 @@ export async function createQualityGateHooks(input: PluginInput): Promise<Partia
|
|
|
229
310
|
const cwd = qualityGate.cwd ? `${directory}/${qualityGate.cwd}` : directory;
|
|
230
311
|
|
|
231
312
|
/**
|
|
232
|
-
* Run the quality gate checks for a session
|
|
313
|
+
* Run the quality gate checks for a session.
|
|
314
|
+
* Uses a global lock to prevent concurrent executions.
|
|
233
315
|
*/
|
|
234
316
|
async function runQualityGate(sessionId: string): Promise<void> {
|
|
235
317
|
const state = getSessionState(sessionId);
|
|
318
|
+
|
|
319
|
+
// Check if session was deleted while waiting
|
|
320
|
+
if (!sessions.has(sessionId)) {
|
|
321
|
+
await log(client, 'debug', 'quality-gate.skipped (session deleted)', { sessionId });
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
236
325
|
const now = Date.now();
|
|
237
326
|
const cacheExpired = now - state.lastRunAt > cacheSeconds * 1000;
|
|
238
327
|
|
|
@@ -242,96 +331,126 @@ export async function createQualityGateHooks(input: PluginInput): Promise<Partia
|
|
|
242
331
|
return;
|
|
243
332
|
}
|
|
244
333
|
|
|
245
|
-
//
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
334
|
+
// Try to acquire the global execution lock
|
|
335
|
+
if (!tryAcquireLock(sessionId)) {
|
|
336
|
+
await log(client, 'debug', 'quality-gate.queued (another run in progress)', {
|
|
337
|
+
sessionId,
|
|
338
|
+
heldBy: executionLock.heldBy,
|
|
339
|
+
queuePosition: executionLock.queue.indexOf(sessionId) + 1,
|
|
340
|
+
});
|
|
341
|
+
return; // Will be triggered again when current run completes
|
|
342
|
+
}
|
|
254
343
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
await log(client, '
|
|
344
|
+
try {
|
|
345
|
+
// Log start
|
|
346
|
+
await log(client, 'info', 'quality-gate.started', {
|
|
258
347
|
sessionId,
|
|
259
348
|
directory,
|
|
349
|
+
cwd,
|
|
260
350
|
});
|
|
261
351
|
|
|
262
|
-
//
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
352
|
+
// Resolve commands
|
|
353
|
+
const plan = await resolveCommands({ $, directory, config: qualityGate });
|
|
354
|
+
|
|
355
|
+
// No commands found - log warning but don't block
|
|
356
|
+
if (plan.length === 0) {
|
|
357
|
+
await log(client, 'warn', 'quality-gate.no_commands', {
|
|
358
|
+
sessionId,
|
|
359
|
+
directory,
|
|
269
360
|
});
|
|
361
|
+
|
|
362
|
+
// Only prompt once per session about missing config
|
|
363
|
+
if (!state.qualityGatePassed) {
|
|
364
|
+
await client.session.prompt({
|
|
365
|
+
path: { id: sessionId },
|
|
366
|
+
body: {
|
|
367
|
+
parts: [{ type: 'text', text: buildNoCommandsPrompt({ directory }) }],
|
|
368
|
+
},
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
return;
|
|
270
372
|
}
|
|
271
|
-
return;
|
|
272
|
-
}
|
|
273
373
|
|
|
274
|
-
|
|
275
|
-
|
|
374
|
+
// Run commands if dirty, cache expired, or no cached results
|
|
375
|
+
let results: StepResult[] = state.lastResults;
|
|
376
|
+
|
|
377
|
+
if (state.dirty || cacheExpired || results.length === 0) {
|
|
378
|
+
const startTime = Date.now();
|
|
379
|
+
results = await runAllCommands($, plan, cwd);
|
|
380
|
+
const durationMs = Date.now() - startTime;
|
|
381
|
+
|
|
382
|
+
state.lastResults = results;
|
|
383
|
+
state.lastRunAt = Date.now(); // Use fresh timestamp after execution
|
|
384
|
+
state.dirty = false;
|
|
385
|
+
|
|
386
|
+
// Log execution details
|
|
387
|
+
await log(client, 'debug', 'quality-gate.executed', {
|
|
388
|
+
sessionId,
|
|
389
|
+
plan: plan.map((p) => p.step),
|
|
390
|
+
durationMs,
|
|
391
|
+
results: results.map((r) => ({
|
|
392
|
+
step: r.step,
|
|
393
|
+
exitCode: r.exitCode,
|
|
394
|
+
})),
|
|
395
|
+
});
|
|
396
|
+
}
|
|
276
397
|
|
|
277
|
-
|
|
278
|
-
const
|
|
279
|
-
results = await runAllCommands($, plan, cwd);
|
|
280
|
-
const durationMs = Date.now() - startTime;
|
|
398
|
+
// Check for failures
|
|
399
|
+
const failed = results.find((r) => r.exitCode !== 0);
|
|
281
400
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
state.dirty = false;
|
|
401
|
+
if (failed) {
|
|
402
|
+
state.qualityGatePassed = false;
|
|
285
403
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
})),
|
|
295
|
-
});
|
|
296
|
-
}
|
|
404
|
+
// Log failure
|
|
405
|
+
await log(client, 'error', 'quality-gate.failed', {
|
|
406
|
+
sessionId,
|
|
407
|
+
step: failed.step,
|
|
408
|
+
cmd: failed.cmd,
|
|
409
|
+
exitCode: failed.exitCode,
|
|
410
|
+
outputTail: tailLines(failed.output, 50),
|
|
411
|
+
});
|
|
297
412
|
|
|
298
|
-
|
|
299
|
-
|
|
413
|
+
// Prompt agent with failure details to continue working
|
|
414
|
+
await client.session.prompt({
|
|
415
|
+
path: { id: sessionId },
|
|
416
|
+
body: {
|
|
417
|
+
parts: [
|
|
418
|
+
{
|
|
419
|
+
type: 'text',
|
|
420
|
+
text: buildFailurePrompt({ config: qualityGate, failed }),
|
|
421
|
+
},
|
|
422
|
+
],
|
|
423
|
+
},
|
|
424
|
+
});
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
300
427
|
|
|
301
|
-
|
|
302
|
-
state.qualityGatePassed =
|
|
428
|
+
// All passed
|
|
429
|
+
state.qualityGatePassed = true;
|
|
303
430
|
|
|
304
|
-
|
|
305
|
-
await log(client, 'error', 'quality-gate.failed', {
|
|
431
|
+
await log(client, 'info', 'quality-gate.passed', {
|
|
306
432
|
sessionId,
|
|
307
|
-
|
|
308
|
-
cmd: failed.cmd,
|
|
309
|
-
exitCode: failed.exitCode,
|
|
310
|
-
outputTail: tailLines(failed.output, 50),
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
// Prompt agent with failure details to continue working
|
|
314
|
-
await client.session.prompt({
|
|
315
|
-
path: { id: sessionId },
|
|
316
|
-
body: {
|
|
317
|
-
parts: [
|
|
318
|
-
{
|
|
319
|
-
type: 'text',
|
|
320
|
-
text: buildFailurePrompt({ config: qualityGate, failed }),
|
|
321
|
-
},
|
|
322
|
-
],
|
|
323
|
-
},
|
|
433
|
+
steps: results.map((r) => r.step),
|
|
324
434
|
});
|
|
325
|
-
|
|
435
|
+
} finally {
|
|
436
|
+
// Always release the lock and trigger next queued session
|
|
437
|
+
const nextSession = releaseLock(sessionId);
|
|
438
|
+
if (nextSession) {
|
|
439
|
+
await log(client, 'debug', 'quality-gate.triggering-next', {
|
|
440
|
+
sessionId,
|
|
441
|
+
nextSession,
|
|
442
|
+
});
|
|
443
|
+
// Schedule the next session's run (don't await to avoid blocking)
|
|
444
|
+
setTimeout(() => {
|
|
445
|
+
runQualityGate(nextSession).catch((err) => {
|
|
446
|
+
log(client, 'error', 'quality-gate.queue-error', {
|
|
447
|
+
sessionId: nextSession,
|
|
448
|
+
error: String(err),
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
}, 100);
|
|
452
|
+
}
|
|
326
453
|
}
|
|
327
|
-
|
|
328
|
-
// All passed
|
|
329
|
-
state.qualityGatePassed = true;
|
|
330
|
-
|
|
331
|
-
await log(client, 'info', 'quality-gate.passed', {
|
|
332
|
-
sessionId,
|
|
333
|
-
steps: results.map((r) => r.step),
|
|
334
|
-
});
|
|
335
454
|
}
|
|
336
455
|
|
|
337
456
|
return {
|
|
@@ -365,6 +484,12 @@ export async function createQualityGateHooks(input: PluginInput): Promise<Partia
|
|
|
365
484
|
if (event.type === 'session.idle' && sessionId) {
|
|
366
485
|
const state = getSessionState(sessionId);
|
|
367
486
|
|
|
487
|
+
// Skip if this session already holds the lock (already running)
|
|
488
|
+
if (executionLock.heldBy === sessionId) {
|
|
489
|
+
await log(client, 'debug', 'quality-gate.skipped (already running)', { sessionId });
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
|
|
368
493
|
// Debounce to avoid running multiple times
|
|
369
494
|
if (state.idleDebounce) {
|
|
370
495
|
clearTimeout(state.idleDebounce);
|
|
@@ -372,7 +497,17 @@ export async function createQualityGateHooks(input: PluginInput): Promise<Partia
|
|
|
372
497
|
|
|
373
498
|
state.idleDebounce = setTimeout(async () => {
|
|
374
499
|
state.idleDebounce = null;
|
|
375
|
-
|
|
500
|
+
// Double-check session still exists before running
|
|
501
|
+
if (sessions.has(sessionId)) {
|
|
502
|
+
try {
|
|
503
|
+
await runQualityGate(sessionId);
|
|
504
|
+
} catch (err) {
|
|
505
|
+
await log(client, 'error', 'quality-gate.unhandled-error', {
|
|
506
|
+
sessionId,
|
|
507
|
+
error: String(err),
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
}
|
|
376
511
|
}, IDLE_DEBOUNCE_MS);
|
|
377
512
|
}
|
|
378
513
|
},
|