@hyperdrive.bot/bmad-workflow 1.0.26 → 1.0.28
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/dist/commands/epics/create.d.ts +1 -0
- package/dist/commands/lock/acquire.d.ts +54 -0
- package/dist/commands/lock/acquire.js +193 -0
- package/dist/commands/lock/cleanup.d.ts +38 -0
- package/dist/commands/lock/cleanup.js +148 -0
- package/dist/commands/lock/list.d.ts +31 -0
- package/dist/commands/lock/list.js +123 -0
- package/dist/commands/lock/release.d.ts +42 -0
- package/dist/commands/lock/release.js +134 -0
- package/dist/commands/lock/status.d.ts +34 -0
- package/dist/commands/lock/status.js +109 -0
- package/dist/commands/stories/create.d.ts +1 -0
- package/dist/commands/stories/develop.d.ts +4 -0
- package/dist/commands/stories/develop.js +55 -5
- package/dist/commands/stories/qa.d.ts +1 -0
- package/dist/commands/stories/qa.js +31 -0
- package/dist/commands/stories/review.d.ts +1 -0
- package/dist/commands/workflow.d.ts +11 -0
- package/dist/commands/workflow.js +120 -4
- package/dist/models/agent-options.d.ts +33 -0
- package/dist/models/agent-result.d.ts +10 -1
- package/dist/models/dispatch.d.ts +16 -0
- package/dist/models/dispatch.js +8 -0
- package/dist/models/index.d.ts +3 -0
- package/dist/models/index.js +2 -0
- package/dist/models/lock.d.ts +80 -0
- package/dist/models/lock.js +69 -0
- package/dist/models/phase-result.d.ts +8 -0
- package/dist/models/provider.js +1 -1
- package/dist/models/workflow-callbacks.d.ts +37 -0
- package/dist/models/workflow-config.d.ts +50 -0
- package/dist/services/agents/agent-runner-factory.d.ts +14 -15
- package/dist/services/agents/agent-runner-factory.js +56 -15
- package/dist/services/agents/channel-agent-runner.d.ts +76 -0
- package/dist/services/agents/channel-agent-runner.js +246 -0
- package/dist/services/agents/channel-session-manager.d.ts +119 -0
- package/dist/services/agents/channel-session-manager.js +250 -0
- package/dist/services/agents/claude-agent-runner.d.ts +9 -50
- package/dist/services/agents/claude-agent-runner.js +221 -199
- package/dist/services/agents/gemini-agent-runner.js +3 -0
- package/dist/services/agents/index.d.ts +1 -0
- package/dist/services/agents/index.js +1 -0
- package/dist/services/agents/opencode-agent-runner.js +3 -0
- package/dist/services/file-system/file-manager.d.ts +11 -0
- package/dist/services/file-system/file-manager.js +26 -0
- package/dist/services/git/git-ops.d.ts +58 -0
- package/dist/services/git/git-ops.js +73 -0
- package/dist/services/git/index.d.ts +3 -0
- package/dist/services/git/index.js +2 -0
- package/dist/services/git/push-conflict-handler.d.ts +32 -0
- package/dist/services/git/push-conflict-handler.js +84 -0
- package/dist/services/lock/git-backed-lock-service.d.ts +76 -0
- package/dist/services/lock/git-backed-lock-service.js +173 -0
- package/dist/services/lock/lock-cleanup.d.ts +49 -0
- package/dist/services/lock/lock-cleanup.js +85 -0
- package/dist/services/lock/lock-service.d.ts +143 -0
- package/dist/services/lock/lock-service.js +290 -0
- package/dist/services/orchestration/locked-story-dispatcher.d.ts +40 -0
- package/dist/services/orchestration/locked-story-dispatcher.js +84 -0
- package/dist/services/orchestration/workflow-orchestrator.d.ts +31 -0
- package/dist/services/orchestration/workflow-orchestrator.js +181 -31
- package/dist/services/review/ai-review-scanner.js +1 -0
- package/dist/services/review/review-phase-executor.js +3 -0
- package/dist/services/review/self-heal-loop.js +1 -0
- package/dist/services/review/types.d.ts +2 -0
- package/dist/utils/errors.d.ts +17 -1
- package/dist/utils/errors.js +18 -0
- package/dist/utils/session-naming.d.ts +23 -0
- package/dist/utils/session-naming.js +30 -0
- package/dist/utils/shared-flags.d.ts +1 -0
- package/dist/utils/shared-flags.js +5 -0
- package/package.json +3 -2
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LockService
|
|
3
|
+
*
|
|
4
|
+
* Manages story file locks to prevent concurrent agent sessions from
|
|
5
|
+
* modifying the same story. Supports acquiring, checking, releasing,
|
|
6
|
+
* and stale detection of locks.
|
|
7
|
+
*/
|
|
8
|
+
import type pino from 'pino';
|
|
9
|
+
import type { LockFileContent } from '../../models/lock.js';
|
|
10
|
+
import type { FileManager } from '../file-system/file-manager.js';
|
|
11
|
+
import type { GlobMatcher } from '../file-system/glob-matcher.js';
|
|
12
|
+
/**
|
|
13
|
+
* Service for managing story file locks
|
|
14
|
+
*
|
|
15
|
+
* Locks are stored as `<storyPath>.lock` JSON files alongside the story files.
|
|
16
|
+
* Each lock contains the agent session ID, agent name, timestamp, and branch.
|
|
17
|
+
*/
|
|
18
|
+
export declare class LockService {
|
|
19
|
+
/**
|
|
20
|
+
* Default stale threshold in milliseconds (2 hours)
|
|
21
|
+
*/
|
|
22
|
+
static readonly DEFAULT_STALE_THRESHOLD_MS = 7200000;
|
|
23
|
+
private readonly fileManager;
|
|
24
|
+
private readonly globMatcher;
|
|
25
|
+
private readonly logger;
|
|
26
|
+
/**
|
|
27
|
+
* Create a new LockService instance
|
|
28
|
+
*
|
|
29
|
+
* @param fileManager - FileManager instance for file operations
|
|
30
|
+
* @param globMatcher - GlobMatcher instance for file pattern matching
|
|
31
|
+
* @param logger - Pino logger instance
|
|
32
|
+
*/
|
|
33
|
+
constructor(fileManager: FileManager, globMatcher: GlobMatcher, logger: pino.Logger);
|
|
34
|
+
/**
|
|
35
|
+
* Acquire a lock on a story file
|
|
36
|
+
*
|
|
37
|
+
* If a lock already exists, checks if it's stale. Stale locks are auto-released
|
|
38
|
+
* and re-acquired. Fresh locks cause a LockConflictError.
|
|
39
|
+
*
|
|
40
|
+
* @param storyPath - Path to the story file to lock
|
|
41
|
+
* @param agentName - Name of the agent acquiring the lock
|
|
42
|
+
* @param sessionId - Unique session ID of the agent
|
|
43
|
+
* @param branch - Git branch the agent is working on (defaults to 'unknown')
|
|
44
|
+
* @throws {LockConflictError} If an active (non-stale) lock exists
|
|
45
|
+
*/
|
|
46
|
+
acquire(storyPath: string, agentName: string, sessionId: string, branch?: string): Promise<void>;
|
|
47
|
+
/**
|
|
48
|
+
* Get lock file content for a story
|
|
49
|
+
*
|
|
50
|
+
* @param storyPath - Path to the story file
|
|
51
|
+
* @returns Lock file content if valid, null if missing or malformed
|
|
52
|
+
*/
|
|
53
|
+
getLockInfo(storyPath: string): Promise<LockFileContent | null>;
|
|
54
|
+
/**
|
|
55
|
+
* Check if a story file is currently locked
|
|
56
|
+
*
|
|
57
|
+
* @param storyPath - Path to the story file
|
|
58
|
+
* @returns True if a valid lock exists
|
|
59
|
+
*/
|
|
60
|
+
isLocked(storyPath: string): Promise<boolean>;
|
|
61
|
+
/**
|
|
62
|
+
* Check if an existing lock is stale (expired or corrupt)
|
|
63
|
+
*
|
|
64
|
+
* A lock is stale when:
|
|
65
|
+
* - The lock file exists but contains malformed/unparseable JSON
|
|
66
|
+
* - The lock's started_at timestamp + thresholdMs is less than Date.now()
|
|
67
|
+
* - The lock's started_at is an invalid date
|
|
68
|
+
*
|
|
69
|
+
* A lock is NOT stale when:
|
|
70
|
+
* - No lock file exists
|
|
71
|
+
* - The lock is within the threshold window
|
|
72
|
+
*
|
|
73
|
+
* @param storyPath - Path to the story file
|
|
74
|
+
* @param thresholdMs - Stale threshold in milliseconds (default: 2 hours)
|
|
75
|
+
* @returns True if the lock is stale
|
|
76
|
+
*/
|
|
77
|
+
isStale(storyPath: string, thresholdMs?: number): Promise<boolean>;
|
|
78
|
+
/**
|
|
79
|
+
* Release a lock on a story file
|
|
80
|
+
*
|
|
81
|
+
* Idempotent — calling on a non-existent lock does not throw.
|
|
82
|
+
*
|
|
83
|
+
* @param storyPath - Path to the story file
|
|
84
|
+
*/
|
|
85
|
+
release(storyPath: string): Promise<void>;
|
|
86
|
+
/**
|
|
87
|
+
* Find stale lock files in a directory without releasing them
|
|
88
|
+
*
|
|
89
|
+
* Scans the given directory for `.lock` files, checks each for staleness,
|
|
90
|
+
* and returns paths of stale ones. Malformed lock files are treated as stale.
|
|
91
|
+
*
|
|
92
|
+
* @param storiesDir - Directory to scan for lock files
|
|
93
|
+
* @param thresholdMs - Optional stale threshold in milliseconds
|
|
94
|
+
* @returns Array of stale lock file paths (the .lock file paths, not story paths)
|
|
95
|
+
*/
|
|
96
|
+
findStaleLocks(storiesDir: string, thresholdMs?: number): Promise<string[]>;
|
|
97
|
+
/**
|
|
98
|
+
* Batch cleanup of stale lock files in a directory
|
|
99
|
+
*
|
|
100
|
+
* Scans the given directory for `.lock` files, checks each for staleness,
|
|
101
|
+
* and removes stale ones. Malformed lock files are treated as stale.
|
|
102
|
+
*
|
|
103
|
+
* @param storiesDir - Directory to scan for lock files
|
|
104
|
+
* @param thresholdMs - Optional stale threshold in milliseconds
|
|
105
|
+
* @returns Array of removed lock file paths
|
|
106
|
+
*/
|
|
107
|
+
cleanup(storiesDir: string, thresholdMs?: number): Promise<string[]>;
|
|
108
|
+
/**
|
|
109
|
+
* List all active locks in a directory with metadata
|
|
110
|
+
*
|
|
111
|
+
* Scans the given directory for `.lock` files, reads each lock,
|
|
112
|
+
* and returns metadata including stale status. Malformed locks are skipped.
|
|
113
|
+
*
|
|
114
|
+
* @param storiesDir - Directory to scan for lock files
|
|
115
|
+
* @returns Array of lock metadata objects
|
|
116
|
+
*/
|
|
117
|
+
list(storiesDir: string): Promise<Array<{
|
|
118
|
+
storyPath: string;
|
|
119
|
+
lock: LockFileContent;
|
|
120
|
+
isStale: boolean;
|
|
121
|
+
}>>;
|
|
122
|
+
/**
|
|
123
|
+
* List all locks across multiple story directories with stale status
|
|
124
|
+
*
|
|
125
|
+
* Scans each directory for `.lock` files, reads and validates each lock,
|
|
126
|
+
* and returns metadata including stale status. Malformed locks are skipped
|
|
127
|
+
* with a warning. Results are sorted by started_at descending (newest first).
|
|
128
|
+
*
|
|
129
|
+
* @param storyDirs - Array of directories to scan for lock files
|
|
130
|
+
* @param thresholdMs - Optional stale threshold in milliseconds (default: DEFAULT_STALE_THRESHOLD_MS)
|
|
131
|
+
* @returns Array of lock metadata with stale boolean, sorted newest first
|
|
132
|
+
*/
|
|
133
|
+
listLocks(storyDirs: string[], thresholdMs?: number): Promise<Array<LockFileContent & {
|
|
134
|
+
stale: boolean;
|
|
135
|
+
}>>;
|
|
136
|
+
/**
|
|
137
|
+
* Resolve the lock file path for a given story path
|
|
138
|
+
*
|
|
139
|
+
* @param storyPath - Path to the story file
|
|
140
|
+
* @returns Path to the corresponding lock file
|
|
141
|
+
*/
|
|
142
|
+
private resolveLockPath;
|
|
143
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LockService
|
|
3
|
+
*
|
|
4
|
+
* Manages story file locks to prevent concurrent agent sessions from
|
|
5
|
+
* modifying the same story. Supports acquiring, checking, releasing,
|
|
6
|
+
* and stale detection of locks.
|
|
7
|
+
*/
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { LockConflictError, lockFileSchema } from '../../models/lock.js';
|
|
10
|
+
/**
|
|
11
|
+
* Service for managing story file locks
|
|
12
|
+
*
|
|
13
|
+
* Locks are stored as `<storyPath>.lock` JSON files alongside the story files.
|
|
14
|
+
* Each lock contains the agent session ID, agent name, timestamp, and branch.
|
|
15
|
+
*/
|
|
16
|
+
export class LockService {
|
|
17
|
+
/**
|
|
18
|
+
* Default stale threshold in milliseconds (2 hours)
|
|
19
|
+
*/
|
|
20
|
+
static DEFAULT_STALE_THRESHOLD_MS = 7_200_000;
|
|
21
|
+
fileManager;
|
|
22
|
+
globMatcher;
|
|
23
|
+
logger;
|
|
24
|
+
/**
|
|
25
|
+
* Create a new LockService instance
|
|
26
|
+
*
|
|
27
|
+
* @param fileManager - FileManager instance for file operations
|
|
28
|
+
* @param globMatcher - GlobMatcher instance for file pattern matching
|
|
29
|
+
* @param logger - Pino logger instance
|
|
30
|
+
*/
|
|
31
|
+
constructor(fileManager, globMatcher, logger) {
|
|
32
|
+
this.fileManager = fileManager;
|
|
33
|
+
this.globMatcher = globMatcher;
|
|
34
|
+
this.logger = logger;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Acquire a lock on a story file
|
|
38
|
+
*
|
|
39
|
+
* If a lock already exists, checks if it's stale. Stale locks are auto-released
|
|
40
|
+
* and re-acquired. Fresh locks cause a LockConflictError.
|
|
41
|
+
*
|
|
42
|
+
* @param storyPath - Path to the story file to lock
|
|
43
|
+
* @param agentName - Name of the agent acquiring the lock
|
|
44
|
+
* @param sessionId - Unique session ID of the agent
|
|
45
|
+
* @param branch - Git branch the agent is working on (defaults to 'unknown')
|
|
46
|
+
* @throws {LockConflictError} If an active (non-stale) lock exists
|
|
47
|
+
*/
|
|
48
|
+
async acquire(storyPath, agentName, sessionId, branch = 'unknown') {
|
|
49
|
+
this.logger.info({ agentName, sessionId, storyPath }, 'Attempting to acquire lock');
|
|
50
|
+
const lockPath = this.resolveLockPath(storyPath);
|
|
51
|
+
const exists = await this.fileManager.fileExists(lockPath);
|
|
52
|
+
if (exists) {
|
|
53
|
+
const existingLock = await this.getLockInfo(storyPath);
|
|
54
|
+
if (existingLock) {
|
|
55
|
+
const stale = await this.isStale(storyPath);
|
|
56
|
+
if (stale) {
|
|
57
|
+
this.logger.info({ storyPath }, 'Stale lock auto-released, re-acquiring');
|
|
58
|
+
await this.release(storyPath);
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
throw new LockConflictError(existingLock);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
// Lock file exists but is malformed — treat as stale and release
|
|
66
|
+
this.logger.info({ storyPath }, 'Malformed lock detected, auto-releasing and re-acquiring');
|
|
67
|
+
await this.release(storyPath);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const lockContent = {
|
|
71
|
+
agent_name: agentName,
|
|
72
|
+
agent_session_id: sessionId,
|
|
73
|
+
branch,
|
|
74
|
+
started_at: new Date().toISOString(),
|
|
75
|
+
story_file: storyPath,
|
|
76
|
+
};
|
|
77
|
+
await this.fileManager.writeFile(lockPath, JSON.stringify(lockContent, null, 2));
|
|
78
|
+
this.logger.info({ agentName, sessionId, storyPath }, 'Lock acquired successfully');
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Get lock file content for a story
|
|
82
|
+
*
|
|
83
|
+
* @param storyPath - Path to the story file
|
|
84
|
+
* @returns Lock file content if valid, null if missing or malformed
|
|
85
|
+
*/
|
|
86
|
+
async getLockInfo(storyPath) {
|
|
87
|
+
const lockPath = this.resolveLockPath(storyPath);
|
|
88
|
+
const exists = await this.fileManager.fileExists(lockPath);
|
|
89
|
+
if (!exists) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
const content = await this.fileManager.readFile(lockPath);
|
|
94
|
+
const parsed = JSON.parse(content);
|
|
95
|
+
const result = lockFileSchema.safeParse(parsed);
|
|
96
|
+
if (!result.success) {
|
|
97
|
+
this.logger.warn({ lockPath }, 'Lock file contains invalid schema');
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
return result.data;
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
this.logger.warn({ lockPath }, 'Lock file contains malformed JSON');
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Check if a story file is currently locked
|
|
109
|
+
*
|
|
110
|
+
* @param storyPath - Path to the story file
|
|
111
|
+
* @returns True if a valid lock exists
|
|
112
|
+
*/
|
|
113
|
+
async isLocked(storyPath) {
|
|
114
|
+
const lockInfo = await this.getLockInfo(storyPath);
|
|
115
|
+
return lockInfo !== null;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Check if an existing lock is stale (expired or corrupt)
|
|
119
|
+
*
|
|
120
|
+
* A lock is stale when:
|
|
121
|
+
* - The lock file exists but contains malformed/unparseable JSON
|
|
122
|
+
* - The lock's started_at timestamp + thresholdMs is less than Date.now()
|
|
123
|
+
* - The lock's started_at is an invalid date
|
|
124
|
+
*
|
|
125
|
+
* A lock is NOT stale when:
|
|
126
|
+
* - No lock file exists
|
|
127
|
+
* - The lock is within the threshold window
|
|
128
|
+
*
|
|
129
|
+
* @param storyPath - Path to the story file
|
|
130
|
+
* @param thresholdMs - Stale threshold in milliseconds (default: 2 hours)
|
|
131
|
+
* @returns True if the lock is stale
|
|
132
|
+
*/
|
|
133
|
+
async isStale(storyPath, thresholdMs = LockService.DEFAULT_STALE_THRESHOLD_MS) {
|
|
134
|
+
const lockInfo = await this.getLockInfo(storyPath);
|
|
135
|
+
const lockPath = this.resolveLockPath(storyPath);
|
|
136
|
+
if (lockInfo === null) {
|
|
137
|
+
// getLockInfo returns null for BOTH "missing file" and "malformed JSON"
|
|
138
|
+
// Distinguish: if file exists but getLockInfo is null → malformed → stale
|
|
139
|
+
const exists = await this.fileManager.fileExists(lockPath);
|
|
140
|
+
return exists; // exists + malformed = stale; !exists = not stale
|
|
141
|
+
}
|
|
142
|
+
const startedAt = new Date(lockInfo.started_at).getTime();
|
|
143
|
+
if (Number.isNaN(startedAt))
|
|
144
|
+
return true; // invalid date = stale
|
|
145
|
+
return startedAt + thresholdMs < Date.now();
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Release a lock on a story file
|
|
149
|
+
*
|
|
150
|
+
* Idempotent — calling on a non-existent lock does not throw.
|
|
151
|
+
*
|
|
152
|
+
* @param storyPath - Path to the story file
|
|
153
|
+
*/
|
|
154
|
+
async release(storyPath) {
|
|
155
|
+
const lockPath = this.resolveLockPath(storyPath);
|
|
156
|
+
await this.fileManager.deleteFile(lockPath);
|
|
157
|
+
this.logger.debug({ storyPath }, 'Lock released');
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Find stale lock files in a directory without releasing them
|
|
161
|
+
*
|
|
162
|
+
* Scans the given directory for `.lock` files, checks each for staleness,
|
|
163
|
+
* and returns paths of stale ones. Malformed lock files are treated as stale.
|
|
164
|
+
*
|
|
165
|
+
* @param storiesDir - Directory to scan for lock files
|
|
166
|
+
* @param thresholdMs - Optional stale threshold in milliseconds
|
|
167
|
+
* @returns Array of stale lock file paths (the .lock file paths, not story paths)
|
|
168
|
+
*/
|
|
169
|
+
async findStaleLocks(storiesDir, thresholdMs) {
|
|
170
|
+
const pattern = path.join(storiesDir, '**/*.lock');
|
|
171
|
+
const lockFiles = await this.globMatcher.expandPattern(pattern);
|
|
172
|
+
if (lockFiles.length === 0) {
|
|
173
|
+
return [];
|
|
174
|
+
}
|
|
175
|
+
const staleLocks = [];
|
|
176
|
+
for (const lockFile of lockFiles) {
|
|
177
|
+
const storyPath = lockFile.replace(/\.lock$/, '');
|
|
178
|
+
const stale = await this.isStale(storyPath, thresholdMs);
|
|
179
|
+
if (stale) {
|
|
180
|
+
staleLocks.push(lockFile);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
this.logger.info({ found: staleLocks.length, scanned: lockFiles.length, storiesDir }, 'Stale lock scan complete');
|
|
184
|
+
return staleLocks;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Batch cleanup of stale lock files in a directory
|
|
188
|
+
*
|
|
189
|
+
* Scans the given directory for `.lock` files, checks each for staleness,
|
|
190
|
+
* and removes stale ones. Malformed lock files are treated as stale.
|
|
191
|
+
*
|
|
192
|
+
* @param storiesDir - Directory to scan for lock files
|
|
193
|
+
* @param thresholdMs - Optional stale threshold in milliseconds
|
|
194
|
+
* @returns Array of removed lock file paths
|
|
195
|
+
*/
|
|
196
|
+
async cleanup(storiesDir, thresholdMs) {
|
|
197
|
+
const pattern = path.join(storiesDir, '**/*.lock');
|
|
198
|
+
const lockFiles = await this.globMatcher.expandPattern(pattern);
|
|
199
|
+
if (lockFiles.length === 0) {
|
|
200
|
+
return [];
|
|
201
|
+
}
|
|
202
|
+
const removed = [];
|
|
203
|
+
for (const lockFile of lockFiles) {
|
|
204
|
+
const storyPath = lockFile.replace(/\.lock$/, '');
|
|
205
|
+
const stale = await this.isStale(storyPath, thresholdMs);
|
|
206
|
+
if (stale) {
|
|
207
|
+
await this.release(storyPath);
|
|
208
|
+
removed.push(lockFile);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
this.logger.info({ removed: removed.length, scanned: lockFiles.length, storiesDir }, 'Lock cleanup complete');
|
|
212
|
+
return removed;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* List all active locks in a directory with metadata
|
|
216
|
+
*
|
|
217
|
+
* Scans the given directory for `.lock` files, reads each lock,
|
|
218
|
+
* and returns metadata including stale status. Malformed locks are skipped.
|
|
219
|
+
*
|
|
220
|
+
* @param storiesDir - Directory to scan for lock files
|
|
221
|
+
* @returns Array of lock metadata objects
|
|
222
|
+
*/
|
|
223
|
+
async list(storiesDir) {
|
|
224
|
+
const pattern = path.join(storiesDir, '**/*.lock');
|
|
225
|
+
const lockFiles = await this.globMatcher.expandPattern(pattern);
|
|
226
|
+
if (lockFiles.length === 0) {
|
|
227
|
+
return [];
|
|
228
|
+
}
|
|
229
|
+
const results = [];
|
|
230
|
+
for (const lockFile of lockFiles) {
|
|
231
|
+
const storyPath = lockFile.replace(/\.lock$/, '');
|
|
232
|
+
const lockInfo = await this.getLockInfo(storyPath);
|
|
233
|
+
if (lockInfo === null) {
|
|
234
|
+
this.logger.warn({ lockFile }, 'Skipping malformed lock file');
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const stale = await this.isStale(storyPath);
|
|
238
|
+
results.push({ isStale: stale, lock: lockInfo, storyPath });
|
|
239
|
+
}
|
|
240
|
+
return results;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* List all locks across multiple story directories with stale status
|
|
244
|
+
*
|
|
245
|
+
* Scans each directory for `.lock` files, reads and validates each lock,
|
|
246
|
+
* and returns metadata including stale status. Malformed locks are skipped
|
|
247
|
+
* with a warning. Results are sorted by started_at descending (newest first).
|
|
248
|
+
*
|
|
249
|
+
* @param storyDirs - Array of directories to scan for lock files
|
|
250
|
+
* @param thresholdMs - Optional stale threshold in milliseconds (default: DEFAULT_STALE_THRESHOLD_MS)
|
|
251
|
+
* @returns Array of lock metadata with stale boolean, sorted newest first
|
|
252
|
+
*/
|
|
253
|
+
async listLocks(storyDirs, thresholdMs = LockService.DEFAULT_STALE_THRESHOLD_MS) {
|
|
254
|
+
const allLocks = [];
|
|
255
|
+
for (const dir of storyDirs) {
|
|
256
|
+
const pattern = path.join(dir, '**/*.lock');
|
|
257
|
+
let lockFiles;
|
|
258
|
+
try {
|
|
259
|
+
lockFiles = await this.globMatcher.expandPattern(pattern);
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
this.logger.warn({ dir }, 'Could not scan directory for locks — skipping');
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
for (const lockFile of lockFiles) {
|
|
266
|
+
const storyPath = lockFile.replace(/\.lock$/, '');
|
|
267
|
+
const lockInfo = await this.getLockInfo(storyPath);
|
|
268
|
+
if (lockInfo === null) {
|
|
269
|
+
this.logger.warn({ lockFile }, 'Skipping malformed lock file in listLocks');
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
const stale = await this.isStale(storyPath, thresholdMs);
|
|
273
|
+
allLocks.push({ ...lockInfo, stale });
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
// Sort by started_at descending (newest first)
|
|
277
|
+
allLocks.sort((a, b) => new Date(b.started_at).getTime() - new Date(a.started_at).getTime());
|
|
278
|
+
this.logger.info({ count: allLocks.length, dirs: storyDirs.length }, 'listLocks scan complete');
|
|
279
|
+
return allLocks;
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Resolve the lock file path for a given story path
|
|
283
|
+
*
|
|
284
|
+
* @param storyPath - Path to the story file
|
|
285
|
+
* @returns Path to the corresponding lock file
|
|
286
|
+
*/
|
|
287
|
+
resolveLockPath(storyPath) {
|
|
288
|
+
return `${storyPath}.lock`;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LockedStoryDispatcher
|
|
3
|
+
*
|
|
4
|
+
* Lock-aware story processing wrapper that acquires a git-backed lock
|
|
5
|
+
* before processing a story and releases it after completion.
|
|
6
|
+
* When locking is disabled (lockService is null), acts as a passthrough.
|
|
7
|
+
*/
|
|
8
|
+
import type pino from 'pino';
|
|
9
|
+
import type { DispatchResult } from '../../models/dispatch.js';
|
|
10
|
+
import type { Story } from '../../models/story.js';
|
|
11
|
+
import type { GitBackedLockService } from '../lock/git-backed-lock-service.js';
|
|
12
|
+
/**
|
|
13
|
+
* Dispatches stories with optional lock coordination
|
|
14
|
+
*
|
|
15
|
+
* When a lockService is provided, acquires a lock before processing
|
|
16
|
+
* and releases it in a finally block. When null, calls processFn directly.
|
|
17
|
+
*/
|
|
18
|
+
export declare class LockedStoryDispatcher {
|
|
19
|
+
private readonly lockService;
|
|
20
|
+
private readonly logger;
|
|
21
|
+
private readonly agentName;
|
|
22
|
+
private readonly sessionId;
|
|
23
|
+
/**
|
|
24
|
+
* Create a new LockedStoryDispatcher
|
|
25
|
+
*
|
|
26
|
+
* @param lockService - GitBackedLockService instance, or null to disable locking
|
|
27
|
+
* @param logger - Pino logger instance
|
|
28
|
+
* @param agentName - Name of the agent processing stories
|
|
29
|
+
* @param sessionId - Unique session ID for this pipeline run
|
|
30
|
+
*/
|
|
31
|
+
constructor(lockService: GitBackedLockService | null, logger: pino.Logger, agentName: string, sessionId: string);
|
|
32
|
+
/**
|
|
33
|
+
* Dispatch a story with optional lock coordination
|
|
34
|
+
*
|
|
35
|
+
* @param story - Story to process
|
|
36
|
+
* @param processFn - Async function that processes the story
|
|
37
|
+
* @returns DispatchResult indicating outcome
|
|
38
|
+
*/
|
|
39
|
+
dispatch(story: Story, processFn: (story: Story) => Promise<void>): Promise<DispatchResult>;
|
|
40
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LockedStoryDispatcher
|
|
3
|
+
*
|
|
4
|
+
* Lock-aware story processing wrapper that acquires a git-backed lock
|
|
5
|
+
* before processing a story and releases it after completion.
|
|
6
|
+
* When locking is disabled (lockService is null), acts as a passthrough.
|
|
7
|
+
*/
|
|
8
|
+
import { LockConflictError, PushRejectedError } from '../../models/lock.js';
|
|
9
|
+
/**
|
|
10
|
+
* Dispatches stories with optional lock coordination
|
|
11
|
+
*
|
|
12
|
+
* When a lockService is provided, acquires a lock before processing
|
|
13
|
+
* and releases it in a finally block. When null, calls processFn directly.
|
|
14
|
+
*/
|
|
15
|
+
export class LockedStoryDispatcher {
|
|
16
|
+
lockService;
|
|
17
|
+
logger;
|
|
18
|
+
agentName;
|
|
19
|
+
sessionId;
|
|
20
|
+
/**
|
|
21
|
+
* Create a new LockedStoryDispatcher
|
|
22
|
+
*
|
|
23
|
+
* @param lockService - GitBackedLockService instance, or null to disable locking
|
|
24
|
+
* @param logger - Pino logger instance
|
|
25
|
+
* @param agentName - Name of the agent processing stories
|
|
26
|
+
* @param sessionId - Unique session ID for this pipeline run
|
|
27
|
+
*/
|
|
28
|
+
constructor(lockService, logger, agentName, sessionId) {
|
|
29
|
+
this.lockService = lockService;
|
|
30
|
+
this.logger = logger;
|
|
31
|
+
this.agentName = agentName;
|
|
32
|
+
this.sessionId = sessionId;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Dispatch a story with optional lock coordination
|
|
36
|
+
*
|
|
37
|
+
* @param story - Story to process
|
|
38
|
+
* @param processFn - Async function that processes the story
|
|
39
|
+
* @returns DispatchResult indicating outcome
|
|
40
|
+
*/
|
|
41
|
+
async dispatch(story, processFn) {
|
|
42
|
+
// Passthrough mode when locking is disabled
|
|
43
|
+
if (!this.lockService) {
|
|
44
|
+
await processFn(story);
|
|
45
|
+
return { status: 'processed' };
|
|
46
|
+
}
|
|
47
|
+
const storyPath = story.filePath;
|
|
48
|
+
const cwd = process.cwd();
|
|
49
|
+
// Attempt to acquire lock
|
|
50
|
+
try {
|
|
51
|
+
await this.lockService.acquire(storyPath, this.agentName, this.sessionId, cwd);
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
if (error instanceof LockConflictError) {
|
|
55
|
+
this.logger.info({ story: story.fullNumber, reason: error.message }, 'Story locked, skipping');
|
|
56
|
+
return { reason: error.message, status: 'skipped' };
|
|
57
|
+
}
|
|
58
|
+
if (error instanceof PushRejectedError) {
|
|
59
|
+
this.logger.warn({ error, storyId: story.fullNumber }, 'Lock acquire push failed, skipping story');
|
|
60
|
+
return { reason: `Lock acquire push failed for story "${story.fullNumber}"`, status: 'skipped' };
|
|
61
|
+
}
|
|
62
|
+
// Catch-all: any other error — skip (never crash the pipeline)
|
|
63
|
+
const err = error;
|
|
64
|
+
this.logger.error({ error: err.stack, storyId: story.fullNumber }, 'Unexpected error during lock acquire, skipping story');
|
|
65
|
+
return { reason: err.message, status: 'skipped' };
|
|
66
|
+
}
|
|
67
|
+
// Lock acquired — process and release in finally
|
|
68
|
+
try {
|
|
69
|
+
await processFn(story);
|
|
70
|
+
return { status: 'processed' };
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
return { reason: error.message, status: 'failed' };
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
try {
|
|
77
|
+
await this.lockService.release(storyPath, cwd);
|
|
78
|
+
}
|
|
79
|
+
catch (releaseError) {
|
|
80
|
+
this.logger.error({ error: releaseError.message, story: story.fullNumber }, 'Failed to release lock');
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -46,7 +46,9 @@
|
|
|
46
46
|
import type pino from 'pino';
|
|
47
47
|
import type { InputDetectionResult, WorkflowCallbacks, WorkflowConfig, WorkflowResult } from '../../models/index.js';
|
|
48
48
|
import type { AIProviderRunner } from '../agents/agent-runner.js';
|
|
49
|
+
import type { AgentRunnerFactory } from '../agents/agent-runner-factory.js';
|
|
49
50
|
import type { WorkflowLogger } from '../logging/workflow-logger.js';
|
|
51
|
+
import type { GitBackedLockService } from '../lock/git-backed-lock-service.js';
|
|
50
52
|
import { AssetResolver } from '../file-system/asset-resolver.js';
|
|
51
53
|
import { FileManager } from '../file-system/file-manager.js';
|
|
52
54
|
import { PathResolver } from '../file-system/path-resolver.js';
|
|
@@ -80,6 +82,8 @@ export interface InputDetector {
|
|
|
80
82
|
export interface WorkflowOrchestratorConfig {
|
|
81
83
|
/** Service to execute AI agents (Claude or Gemini) */
|
|
82
84
|
agentRunner: AIProviderRunner;
|
|
85
|
+
/** Optional factory for discovery-based transport selection (Channel vs subprocess) */
|
|
86
|
+
agentRunnerFactory?: AgentRunnerFactory;
|
|
83
87
|
/** Optional AssetResolver for three-level path resolution (CLI flag → config → bundled) */
|
|
84
88
|
assetResolver?: AssetResolver;
|
|
85
89
|
/** Service to handle parallel batch processing */
|
|
@@ -92,6 +96,8 @@ export interface WorkflowOrchestratorConfig {
|
|
|
92
96
|
inputDetector: InputDetector;
|
|
93
97
|
/** Logger instance for structured logging */
|
|
94
98
|
logger: pino.Logger;
|
|
99
|
+
/** Optional GitBackedLockService for multi-session story locking */
|
|
100
|
+
lockService?: GitBackedLockService;
|
|
95
101
|
/** Optional MCP context injector for tool discovery in agent prompts */
|
|
96
102
|
mcpContextInjector?: McpContextInjector;
|
|
97
103
|
/** Service to resolve file paths from config */
|
|
@@ -150,6 +156,7 @@ export interface StoryPromptOptions {
|
|
|
150
156
|
*/
|
|
151
157
|
export declare class WorkflowOrchestrator {
|
|
152
158
|
private readonly agentRunner;
|
|
159
|
+
private readonly agentRunnerFactory?;
|
|
153
160
|
private readonly assetResolver;
|
|
154
161
|
private readonly batchProcessor;
|
|
155
162
|
private readonly callbacks?;
|
|
@@ -157,6 +164,7 @@ export declare class WorkflowOrchestrator {
|
|
|
157
164
|
private readonly fileManager;
|
|
158
165
|
private readonly fileScaffolder;
|
|
159
166
|
private readonly inputDetector;
|
|
167
|
+
private readonly lockService?;
|
|
160
168
|
private readonly logger;
|
|
161
169
|
private readonly mcpContextInjector?;
|
|
162
170
|
private readonly pathResolver;
|
|
@@ -171,6 +179,29 @@ export declare class WorkflowOrchestrator {
|
|
|
171
179
|
* @param config - Configuration object containing all service dependencies
|
|
172
180
|
*/
|
|
173
181
|
constructor(config: WorkflowOrchestratorConfig);
|
|
182
|
+
/**
|
|
183
|
+
* Select the appropriate runner for the given agent type and config.
|
|
184
|
+
*
|
|
185
|
+
* When agentRunnerFactory is set and config.useChannels is true, delegates
|
|
186
|
+
* to the factory for discovery-based transport selection. Otherwise returns
|
|
187
|
+
* the default subprocess runner.
|
|
188
|
+
*/
|
|
189
|
+
private getRunner;
|
|
190
|
+
/**
|
|
191
|
+
* Build stream callback options from workflow config.
|
|
192
|
+
* Returns onStream (spinner summary) and/or onStreamVerbose (raw passthrough)
|
|
193
|
+
* based on config.stream flag.
|
|
194
|
+
*/
|
|
195
|
+
private getStreamCallbacks;
|
|
196
|
+
/**
|
|
197
|
+
* Resolve session name template with placeholders.
|
|
198
|
+
*
|
|
199
|
+
* @param config - Workflow config containing the sessionName template
|
|
200
|
+
* @param phase - Current phase name (e.g., "dev", "epic", "story")
|
|
201
|
+
* @param storyId - Optional story/epic identifier for {story} placeholder
|
|
202
|
+
* @returns Resolved session name string, or undefined if no template
|
|
203
|
+
*/
|
|
204
|
+
private resolveSessionName;
|
|
174
205
|
/**
|
|
175
206
|
* Execute the complete workflow
|
|
176
207
|
*
|