@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 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.7",
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",
@@ -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
- // Log start
246
- await log(client, 'info', 'quality-gate.started', {
247
- sessionId,
248
- directory,
249
- cwd,
250
- });
251
-
252
- // Resolve commands
253
- const plan = await resolveCommands({ $, directory, config: qualityGate });
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
- // No commands found - log warning but don't block
256
- if (plan.length === 0) {
257
- await log(client, 'warn', 'quality-gate.no_commands', {
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
- // Only prompt once per session about missing config
263
- if (!state.qualityGatePassed) {
264
- await client.session.prompt({
265
- path: { id: sessionId },
266
- body: {
267
- parts: [{ type: 'text', text: buildNoCommandsPrompt({ directory }) }],
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
- // Run commands if dirty, cache expired, or no cached results
275
- let results: StepResult[] = state.lastResults;
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
- if (state.dirty || cacheExpired || results.length === 0) {
278
- const startTime = Date.now();
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
- state.lastResults = results;
283
- state.lastRunAt = now;
284
- state.dirty = false;
401
+ if (failed) {
402
+ state.qualityGatePassed = false;
285
403
 
286
- // Log execution details
287
- await log(client, 'debug', 'quality-gate.executed', {
288
- sessionId,
289
- plan: plan.map((p) => p.step),
290
- durationMs,
291
- results: results.map((r) => ({
292
- step: r.step,
293
- exitCode: r.exitCode,
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
- // Check for failures
299
- const failed = results.find((r) => r.exitCode !== 0);
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
- if (failed) {
302
- state.qualityGatePassed = false;
428
+ // All passed
429
+ state.qualityGatePassed = true;
303
430
 
304
- // Log failure
305
- await log(client, 'error', 'quality-gate.failed', {
431
+ await log(client, 'info', 'quality-gate.passed', {
306
432
  sessionId,
307
- step: failed.step,
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
- return;
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
- await runQualityGate(sessionId);
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
  },