@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,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitOps — Git operations abstraction
|
|
3
|
+
*
|
|
4
|
+
* Wraps git CLI commands for pull, add, commit, push, and remove operations.
|
|
5
|
+
* All methods use execSync with { cwd, encoding: 'utf8', stdio: 'pipe' }.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Result of a git push operation
|
|
9
|
+
*/
|
|
10
|
+
export interface PushResult {
|
|
11
|
+
isNonFastForward: boolean;
|
|
12
|
+
stderr: string;
|
|
13
|
+
success: boolean;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Git operations class for repository management
|
|
17
|
+
*
|
|
18
|
+
* All methods accept a `cwd` parameter to scope operations to a specific directory.
|
|
19
|
+
*/
|
|
20
|
+
export declare class GitOps {
|
|
21
|
+
/**
|
|
22
|
+
* Pull latest changes from remote with rebase and autostash
|
|
23
|
+
*
|
|
24
|
+
* @param cwd - Working directory for the git command
|
|
25
|
+
*/
|
|
26
|
+
pull(cwd: string): void;
|
|
27
|
+
/**
|
|
28
|
+
* Stage a file for commit
|
|
29
|
+
*
|
|
30
|
+
* @param filePath - Path to the file to stage (relative to cwd)
|
|
31
|
+
* @param cwd - Working directory for the git command
|
|
32
|
+
*/
|
|
33
|
+
add(filePath: string, cwd: string): void;
|
|
34
|
+
/**
|
|
35
|
+
* Create a commit with the [lock] prefix prepended to the message
|
|
36
|
+
*
|
|
37
|
+
* @param message - Commit message suffix (e.g., "acquire: story-1.001.md")
|
|
38
|
+
* @param cwd - Working directory for the git command
|
|
39
|
+
*/
|
|
40
|
+
commit(message: string, cwd: string): void;
|
|
41
|
+
/**
|
|
42
|
+
* Push commits to remote
|
|
43
|
+
*
|
|
44
|
+
* Returns a typed PushResult. NEVER throws on non-fast-forward rejection.
|
|
45
|
+
* Throws PushRejectedError only on unexpected errors (e.g., no remote, network timeout).
|
|
46
|
+
*
|
|
47
|
+
* @param cwd - Working directory for the git command
|
|
48
|
+
* @returns Push result with success status and error details
|
|
49
|
+
*/
|
|
50
|
+
push(cwd: string): PushResult;
|
|
51
|
+
/**
|
|
52
|
+
* Remove a file from git index (safe even if file is already deleted from disk)
|
|
53
|
+
*
|
|
54
|
+
* @param filePath - Path to the file to remove (relative to cwd)
|
|
55
|
+
* @param cwd - Working directory for the git command
|
|
56
|
+
*/
|
|
57
|
+
remove(filePath: string, cwd: string): void;
|
|
58
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitOps — Git operations abstraction
|
|
3
|
+
*
|
|
4
|
+
* Wraps git CLI commands for pull, add, commit, push, and remove operations.
|
|
5
|
+
* All methods use execSync with { cwd, encoding: 'utf8', stdio: 'pipe' }.
|
|
6
|
+
*/
|
|
7
|
+
import { execSync } from 'node:child_process';
|
|
8
|
+
import { PushRejectedError } from '../../utils/errors.js';
|
|
9
|
+
/**
|
|
10
|
+
* Git operations class for repository management
|
|
11
|
+
*
|
|
12
|
+
* All methods accept a `cwd` parameter to scope operations to a specific directory.
|
|
13
|
+
*/
|
|
14
|
+
export class GitOps {
|
|
15
|
+
/**
|
|
16
|
+
* Pull latest changes from remote with rebase and autostash
|
|
17
|
+
*
|
|
18
|
+
* @param cwd - Working directory for the git command
|
|
19
|
+
*/
|
|
20
|
+
pull(cwd) {
|
|
21
|
+
execSync('git pull --rebase --autostash', { cwd, encoding: 'utf8', stdio: 'pipe' });
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Stage a file for commit
|
|
25
|
+
*
|
|
26
|
+
* @param filePath - Path to the file to stage (relative to cwd)
|
|
27
|
+
* @param cwd - Working directory for the git command
|
|
28
|
+
*/
|
|
29
|
+
add(filePath, cwd) {
|
|
30
|
+
execSync(`git add ${filePath}`, { cwd, encoding: 'utf8', stdio: 'pipe' });
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Create a commit with the [lock] prefix prepended to the message
|
|
34
|
+
*
|
|
35
|
+
* @param message - Commit message suffix (e.g., "acquire: story-1.001.md")
|
|
36
|
+
* @param cwd - Working directory for the git command
|
|
37
|
+
*/
|
|
38
|
+
commit(message, cwd) {
|
|
39
|
+
execSync(`git commit -m "[lock] ${message}"`, { cwd, encoding: 'utf8', stdio: 'pipe' });
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Push commits to remote
|
|
43
|
+
*
|
|
44
|
+
* Returns a typed PushResult. NEVER throws on non-fast-forward rejection.
|
|
45
|
+
* Throws PushRejectedError only on unexpected errors (e.g., no remote, network timeout).
|
|
46
|
+
*
|
|
47
|
+
* @param cwd - Working directory for the git command
|
|
48
|
+
* @returns Push result with success status and error details
|
|
49
|
+
*/
|
|
50
|
+
push(cwd) {
|
|
51
|
+
try {
|
|
52
|
+
execSync('git push', { cwd, encoding: 'utf8', stdio: 'pipe' });
|
|
53
|
+
return { isNonFastForward: false, stderr: '', success: true };
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
const err = error;
|
|
57
|
+
const stderr = err.stderr ? err.stderr.toString() : err.message;
|
|
58
|
+
if (/non-fast-forward|rejected/i.test(stderr)) {
|
|
59
|
+
return { isNonFastForward: true, stderr, success: false };
|
|
60
|
+
}
|
|
61
|
+
throw new PushRejectedError(`Git push failed: ${stderr}`, stderr);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Remove a file from git index (safe even if file is already deleted from disk)
|
|
66
|
+
*
|
|
67
|
+
* @param filePath - Path to the file to remove (relative to cwd)
|
|
68
|
+
* @param cwd - Working directory for the git command
|
|
69
|
+
*/
|
|
70
|
+
remove(filePath, cwd) {
|
|
71
|
+
execSync(`git rm --cached --ignore-unmatch ${filePath}`, { cwd, encoding: 'utf8', stdio: 'pipe' });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PushConflictHandler — Handles non-fast-forward push rejections
|
|
3
|
+
*
|
|
4
|
+
* When a git push is rejected due to a non-fast-forward error (race condition),
|
|
5
|
+
* this handler pulls the remote state, checks if a competing lock file exists,
|
|
6
|
+
* and either throws a LockConflictError (competing lock found) or retries the push.
|
|
7
|
+
*/
|
|
8
|
+
import type { GitOps } from './git-ops.js';
|
|
9
|
+
/**
|
|
10
|
+
* Handles non-fast-forward push rejections during lock acquisition
|
|
11
|
+
*/
|
|
12
|
+
export declare class PushConflictHandler {
|
|
13
|
+
private readonly gitOps;
|
|
14
|
+
constructor(gitOps: GitOps);
|
|
15
|
+
/**
|
|
16
|
+
* Handle a non-fast-forward push rejection
|
|
17
|
+
*
|
|
18
|
+
* Pulls remote state, checks for competing lock files, and either:
|
|
19
|
+
* 1. Throws LockConflictError if a competing lock was found
|
|
20
|
+
* 2. Retries the push if the conflict was unrelated
|
|
21
|
+
* 3. Throws PushRejectedError if the retry also fails
|
|
22
|
+
*
|
|
23
|
+
* @param storyPath - Path to the story file being locked
|
|
24
|
+
* @param localLockPath - Path to the .lock file on disk
|
|
25
|
+
* @param cwd - Working directory for git operations
|
|
26
|
+
*/
|
|
27
|
+
handleConflict(storyPath: string, localLockPath: string, cwd: string): void;
|
|
28
|
+
/**
|
|
29
|
+
* Clean up local lock file state
|
|
30
|
+
*/
|
|
31
|
+
private cleanup;
|
|
32
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PushConflictHandler — Handles non-fast-forward push rejections
|
|
3
|
+
*
|
|
4
|
+
* When a git push is rejected due to a non-fast-forward error (race condition),
|
|
5
|
+
* this handler pulls the remote state, checks if a competing lock file exists,
|
|
6
|
+
* and either throws a LockConflictError (competing lock found) or retries the push.
|
|
7
|
+
*/
|
|
8
|
+
import { execSync } from 'node:child_process';
|
|
9
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
10
|
+
import fsExtra from 'fs-extra';
|
|
11
|
+
const { removeSync } = fsExtra;
|
|
12
|
+
import { LockConflictError, lockFileSchema } from '../../models/lock.js';
|
|
13
|
+
import { PushRejectedError } from '../../utils/errors.js';
|
|
14
|
+
/**
|
|
15
|
+
* Handles non-fast-forward push rejections during lock acquisition
|
|
16
|
+
*/
|
|
17
|
+
export class PushConflictHandler {
|
|
18
|
+
gitOps;
|
|
19
|
+
constructor(gitOps) {
|
|
20
|
+
this.gitOps = gitOps;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Handle a non-fast-forward push rejection
|
|
24
|
+
*
|
|
25
|
+
* Pulls remote state, checks for competing lock files, and either:
|
|
26
|
+
* 1. Throws LockConflictError if a competing lock was found
|
|
27
|
+
* 2. Retries the push if the conflict was unrelated
|
|
28
|
+
* 3. Throws PushRejectedError if the retry also fails
|
|
29
|
+
*
|
|
30
|
+
* @param storyPath - Path to the story file being locked
|
|
31
|
+
* @param localLockPath - Path to the .lock file on disk
|
|
32
|
+
* @param cwd - Working directory for git operations
|
|
33
|
+
*/
|
|
34
|
+
handleConflict(storyPath, localLockPath, cwd) {
|
|
35
|
+
// Step 1: Pull remote state that caused the non-fast-forward
|
|
36
|
+
this.gitOps.pull(cwd);
|
|
37
|
+
// Step 2: Check if the pull brought in a competing .lock file
|
|
38
|
+
if (existsSync(localLockPath)) {
|
|
39
|
+
// Competing lock found — read and validate
|
|
40
|
+
const raw = readFileSync(localLockPath, 'utf8');
|
|
41
|
+
let parsed;
|
|
42
|
+
try {
|
|
43
|
+
parsed = JSON.parse(raw);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// Invalid JSON — corrupt lock
|
|
47
|
+
this.cleanup(localLockPath, cwd);
|
|
48
|
+
throw new PushRejectedError('Corrupt lock file found after pull — manual resolution required', raw);
|
|
49
|
+
}
|
|
50
|
+
const result = lockFileSchema.safeParse(parsed);
|
|
51
|
+
if (!result.success) {
|
|
52
|
+
// Schema validation failed — corrupt lock
|
|
53
|
+
this.cleanup(localLockPath, cwd);
|
|
54
|
+
throw new PushRejectedError('Corrupt lock file found after pull — manual resolution required', JSON.stringify(result.error));
|
|
55
|
+
}
|
|
56
|
+
const lockContent = result.data;
|
|
57
|
+
// Clean up losing agent's local state before throwing
|
|
58
|
+
this.cleanup(localLockPath, cwd);
|
|
59
|
+
// Throw with the winner's identity
|
|
60
|
+
throw new LockConflictError(lockContent);
|
|
61
|
+
}
|
|
62
|
+
// No competing lock — conflict was on a different file. Retry push.
|
|
63
|
+
const retryResult = this.gitOps.push(cwd);
|
|
64
|
+
if (retryResult.success) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
// Retry also failed — clean up and throw
|
|
68
|
+
this.cleanup(localLockPath, cwd);
|
|
69
|
+
throw new PushRejectedError('Push rejected after retry — manual resolution required', retryResult.stderr);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Clean up local lock file state
|
|
73
|
+
*/
|
|
74
|
+
cleanup(localLockPath, cwd) {
|
|
75
|
+
try {
|
|
76
|
+
removeSync(localLockPath);
|
|
77
|
+
}
|
|
78
|
+
catch { /* ignore */ }
|
|
79
|
+
try {
|
|
80
|
+
execSync('git reset HEAD -- ' + localLockPath, { cwd, stdio: 'pipe' });
|
|
81
|
+
}
|
|
82
|
+
catch { /* ignore */ }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitBackedLockService
|
|
3
|
+
*
|
|
4
|
+
* Orchestrates lock acquisition with git persistence: pull → check → create → add → commit → push.
|
|
5
|
+
* Ensures distributed agents on different machines see each other's locks by persisting
|
|
6
|
+
* lock files to the remote git branch.
|
|
7
|
+
*
|
|
8
|
+
* Delegates to:
|
|
9
|
+
* - LockService for filesystem lock operations (acquire, release, isLocked, isStale, getLockInfo)
|
|
10
|
+
* - GitOps for git operations (pull, add, commit, push)
|
|
11
|
+
* - PushConflictHandler for non-fast-forward push resolution
|
|
12
|
+
*/
|
|
13
|
+
import type { GitOps } from '../git/git-ops.js';
|
|
14
|
+
import type { PushConflictHandler } from '../git/push-conflict-handler.js';
|
|
15
|
+
import type { LockService } from './lock-service.js';
|
|
16
|
+
/**
|
|
17
|
+
* Git-backed lock service that persists locks to remote via git
|
|
18
|
+
*
|
|
19
|
+
* Wraps LockService with a git pull → check → create → add → commit → push flow
|
|
20
|
+
* to ensure distributed lock visibility across machines.
|
|
21
|
+
*/
|
|
22
|
+
export declare class GitBackedLockService {
|
|
23
|
+
private readonly lockService;
|
|
24
|
+
private readonly gitOps;
|
|
25
|
+
private readonly conflictHandler;
|
|
26
|
+
/**
|
|
27
|
+
* Create a new GitBackedLockService
|
|
28
|
+
*
|
|
29
|
+
* @param lockService - LockService instance for filesystem lock operations
|
|
30
|
+
* @param gitOps - GitOps instance for git operations
|
|
31
|
+
* @param conflictHandler - PushConflictHandler for non-fast-forward resolution
|
|
32
|
+
*/
|
|
33
|
+
constructor(lockService: LockService, gitOps: GitOps, conflictHandler: PushConflictHandler);
|
|
34
|
+
/**
|
|
35
|
+
* Acquire a lock on a story file with full git persistence
|
|
36
|
+
*
|
|
37
|
+
* Executes: pull → isLocked check → acquire → git add → git commit → git push
|
|
38
|
+
*
|
|
39
|
+
* On push non-fast-forward, delegates to PushConflictHandler.
|
|
40
|
+
* On any failure after lock file creation, cleans up the lock file from disk and git state.
|
|
41
|
+
*
|
|
42
|
+
* @param storyPath - Path to the story file to lock (relative to cwd)
|
|
43
|
+
* @param agentName - Name of the agent acquiring the lock
|
|
44
|
+
* @param sessionId - Unique session ID of the agent
|
|
45
|
+
* @param cwd - Working directory (git repository root)
|
|
46
|
+
* @throws {LockConflictError} If an active (non-stale) lock exists or another agent won the race
|
|
47
|
+
*/
|
|
48
|
+
acquire(storyPath: string, agentName: string, sessionId: string, cwd: string): Promise<void>;
|
|
49
|
+
/**
|
|
50
|
+
* Release a lock on a story file with git persistence
|
|
51
|
+
*
|
|
52
|
+
* Executes: LockService.release() → GitOps.remove() → GitOps.commit() → GitOps.push()
|
|
53
|
+
*
|
|
54
|
+
* On push failure, logs a warning but does NOT throw — the lock file is already
|
|
55
|
+
* deleted locally, and the story is considered unlocked for the releasing agent.
|
|
56
|
+
*
|
|
57
|
+
* @param storyPath - Path to the story file to unlock (relative to cwd)
|
|
58
|
+
* @param cwd - Working directory (git repository root)
|
|
59
|
+
*/
|
|
60
|
+
release(storyPath: string, cwd: string): Promise<void>;
|
|
61
|
+
/**
|
|
62
|
+
* Clean up a lock file from disk and git staging area
|
|
63
|
+
*
|
|
64
|
+
* Handles 3 possible states:
|
|
65
|
+
* 1. File on disk, not staged → removeSync is sufficient
|
|
66
|
+
* 2. File on disk, staged → removeSync + git reset HEAD
|
|
67
|
+
* 3. File on disk, committed but not pushed → removeSync + git reset HEAD
|
|
68
|
+
*
|
|
69
|
+
* Each cleanup step is wrapped in its own try/catch — a failure in one
|
|
70
|
+
* should not prevent the others from executing.
|
|
71
|
+
*
|
|
72
|
+
* @param lockFilePath - Path to the lock file (relative to cwd)
|
|
73
|
+
* @param cwd - Working directory (git repository root)
|
|
74
|
+
*/
|
|
75
|
+
private cleanupLockFile;
|
|
76
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitBackedLockService
|
|
3
|
+
*
|
|
4
|
+
* Orchestrates lock acquisition with git persistence: pull → check → create → add → commit → push.
|
|
5
|
+
* Ensures distributed agents on different machines see each other's locks by persisting
|
|
6
|
+
* lock files to the remote git branch.
|
|
7
|
+
*
|
|
8
|
+
* Delegates to:
|
|
9
|
+
* - LockService for filesystem lock operations (acquire, release, isLocked, isStale, getLockInfo)
|
|
10
|
+
* - GitOps for git operations (pull, add, commit, push)
|
|
11
|
+
* - PushConflictHandler for non-fast-forward push resolution
|
|
12
|
+
*/
|
|
13
|
+
import { execSync } from 'node:child_process';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import fsExtra from 'fs-extra';
|
|
16
|
+
const { removeSync } = fsExtra;
|
|
17
|
+
import { LockConflictError, PushRejectedError } from '../../models/lock.js';
|
|
18
|
+
/**
|
|
19
|
+
* Git-backed lock service that persists locks to remote via git
|
|
20
|
+
*
|
|
21
|
+
* Wraps LockService with a git pull → check → create → add → commit → push flow
|
|
22
|
+
* to ensure distributed lock visibility across machines.
|
|
23
|
+
*/
|
|
24
|
+
export class GitBackedLockService {
|
|
25
|
+
lockService;
|
|
26
|
+
gitOps;
|
|
27
|
+
conflictHandler;
|
|
28
|
+
/**
|
|
29
|
+
* Create a new GitBackedLockService
|
|
30
|
+
*
|
|
31
|
+
* @param lockService - LockService instance for filesystem lock operations
|
|
32
|
+
* @param gitOps - GitOps instance for git operations
|
|
33
|
+
* @param conflictHandler - PushConflictHandler for non-fast-forward resolution
|
|
34
|
+
*/
|
|
35
|
+
constructor(lockService, gitOps, conflictHandler) {
|
|
36
|
+
this.lockService = lockService;
|
|
37
|
+
this.gitOps = gitOps;
|
|
38
|
+
this.conflictHandler = conflictHandler;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Acquire a lock on a story file with full git persistence
|
|
42
|
+
*
|
|
43
|
+
* Executes: pull → isLocked check → acquire → git add → git commit → git push
|
|
44
|
+
*
|
|
45
|
+
* On push non-fast-forward, delegates to PushConflictHandler.
|
|
46
|
+
* On any failure after lock file creation, cleans up the lock file from disk and git state.
|
|
47
|
+
*
|
|
48
|
+
* @param storyPath - Path to the story file to lock (relative to cwd)
|
|
49
|
+
* @param agentName - Name of the agent acquiring the lock
|
|
50
|
+
* @param sessionId - Unique session ID of the agent
|
|
51
|
+
* @param cwd - Working directory (git repository root)
|
|
52
|
+
* @throws {LockConflictError} If an active (non-stale) lock exists or another agent won the race
|
|
53
|
+
*/
|
|
54
|
+
async acquire(storyPath, agentName, sessionId, cwd) {
|
|
55
|
+
const lockFilePath = storyPath + '.lock';
|
|
56
|
+
// Step 1: Sync latest remote state
|
|
57
|
+
this.gitOps.pull(cwd);
|
|
58
|
+
// Step 2: Check for existing lock
|
|
59
|
+
const locked = await this.lockService.isLocked(storyPath);
|
|
60
|
+
if (locked) {
|
|
61
|
+
// Check if the existing lock is stale
|
|
62
|
+
const stale = await this.lockService.isStale(storyPath);
|
|
63
|
+
if (!stale) {
|
|
64
|
+
// Active lock — throw conflict with owner details
|
|
65
|
+
const lockInfo = await this.lockService.getLockInfo(storyPath);
|
|
66
|
+
if (lockInfo) {
|
|
67
|
+
throw new LockConflictError(lockInfo);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// Stale lock — release it before proceeding
|
|
71
|
+
await this.lockService.release(storyPath);
|
|
72
|
+
}
|
|
73
|
+
// Steps 3–8: Create lock, stage, commit, push (with cleanup on failure)
|
|
74
|
+
try {
|
|
75
|
+
// Step 3: Create .lock file on disk
|
|
76
|
+
await this.lockService.acquire(storyPath, agentName, sessionId);
|
|
77
|
+
// Step 5: Stage the lock file
|
|
78
|
+
this.gitOps.add(lockFilePath, cwd);
|
|
79
|
+
// Step 6: Commit
|
|
80
|
+
this.gitOps.commit('[lock] acquire: ' + path.basename(storyPath), cwd);
|
|
81
|
+
// Step 7: Push
|
|
82
|
+
const pushResult = this.gitOps.push(cwd);
|
|
83
|
+
// Step 8: Handle push result
|
|
84
|
+
if (!pushResult.success) {
|
|
85
|
+
if (pushResult.isNonFastForward) {
|
|
86
|
+
// Delegate to conflict handler (Story 2.3)
|
|
87
|
+
this.conflictHandler.handleConflict(storyPath, lockFilePath, cwd);
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
// Non-recoverable push error
|
|
91
|
+
throw new PushRejectedError({ storyFile: storyPath, reason: pushResult.stderr || 'unknown error' });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
// Cleanup: remove lock file from disk and git state
|
|
97
|
+
this.cleanupLockFile(lockFilePath, cwd);
|
|
98
|
+
// Re-throw the original error
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Release a lock on a story file with git persistence
|
|
104
|
+
*
|
|
105
|
+
* Executes: LockService.release() → GitOps.remove() → GitOps.commit() → GitOps.push()
|
|
106
|
+
*
|
|
107
|
+
* On push failure, logs a warning but does NOT throw — the lock file is already
|
|
108
|
+
* deleted locally, and the story is considered unlocked for the releasing agent.
|
|
109
|
+
*
|
|
110
|
+
* @param storyPath - Path to the story file to unlock (relative to cwd)
|
|
111
|
+
* @param cwd - Working directory (git repository root)
|
|
112
|
+
*/
|
|
113
|
+
async release(storyPath, cwd) {
|
|
114
|
+
const lockFilePath = storyPath + '.lock';
|
|
115
|
+
const storyFileName = path.basename(storyPath);
|
|
116
|
+
// Step 1: Delete lock file from disk
|
|
117
|
+
await this.lockService.release(storyPath);
|
|
118
|
+
// Step 2: Stage the removal in git
|
|
119
|
+
this.gitOps.remove(lockFilePath, cwd);
|
|
120
|
+
// Step 3: Commit the removal
|
|
121
|
+
this.gitOps.commit('release: ' + storyFileName, cwd);
|
|
122
|
+
// Step 4: Push — non-fatal on failure
|
|
123
|
+
try {
|
|
124
|
+
const pushResult = this.gitOps.push(cwd);
|
|
125
|
+
if (!pushResult.success) {
|
|
126
|
+
// Log warning but don't throw — lock is already deleted locally
|
|
127
|
+
console.warn(`[lock] Push after release failed for ${storyFileName}: ${pushResult.stderr || 'unknown error'}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
// Network error or other unexpected failure — still non-fatal
|
|
132
|
+
console.warn(`[lock] Push after release failed for ${storyFileName}: ${error.message}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Clean up a lock file from disk and git staging area
|
|
137
|
+
*
|
|
138
|
+
* Handles 3 possible states:
|
|
139
|
+
* 1. File on disk, not staged → removeSync is sufficient
|
|
140
|
+
* 2. File on disk, staged → removeSync + git reset HEAD
|
|
141
|
+
* 3. File on disk, committed but not pushed → removeSync + git reset HEAD
|
|
142
|
+
*
|
|
143
|
+
* Each cleanup step is wrapped in its own try/catch — a failure in one
|
|
144
|
+
* should not prevent the others from executing.
|
|
145
|
+
*
|
|
146
|
+
* @param lockFilePath - Path to the lock file (relative to cwd)
|
|
147
|
+
* @param cwd - Working directory (git repository root)
|
|
148
|
+
*/
|
|
149
|
+
cleanupLockFile(lockFilePath, cwd) {
|
|
150
|
+
const absoluteLockPath = path.resolve(cwd, lockFilePath);
|
|
151
|
+
// Remove file from disk
|
|
152
|
+
try {
|
|
153
|
+
removeSync(absoluteLockPath);
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
// File may not exist — that's OK
|
|
157
|
+
}
|
|
158
|
+
// Unstage from git index (git checkout)
|
|
159
|
+
try {
|
|
160
|
+
execSync('git checkout -- ' + lockFilePath, { cwd, stdio: 'pipe' });
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
// May fail if file wasn't staged — that's OK
|
|
164
|
+
}
|
|
165
|
+
// Reset from staging area
|
|
166
|
+
try {
|
|
167
|
+
execSync('git reset HEAD -- ' + lockFilePath, { cwd, stdio: 'pipe' });
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
// May fail if nothing to reset — that's OK
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LockCleanupService
|
|
3
|
+
*
|
|
4
|
+
* Batch cleanup of stale lock files with git persistence.
|
|
5
|
+
* Pulls latest state, scans for stale locks, deletes them all,
|
|
6
|
+
* then commits and pushes in a single atomic operation.
|
|
7
|
+
*/
|
|
8
|
+
import type pino from 'pino';
|
|
9
|
+
import type { GitOps } from '../git/git-ops.js';
|
|
10
|
+
import type { LockService } from './lock-service.js';
|
|
11
|
+
/**
|
|
12
|
+
* Service for batch cleanup of stale locks with git persistence
|
|
13
|
+
*
|
|
14
|
+
* Orchestrates: pull → findStaleLocks → release each → stage each → single commit → push
|
|
15
|
+
*/
|
|
16
|
+
export declare class LockCleanupService {
|
|
17
|
+
private readonly lockService;
|
|
18
|
+
private readonly gitOps;
|
|
19
|
+
private readonly logger;
|
|
20
|
+
private readonly storiesDir;
|
|
21
|
+
/**
|
|
22
|
+
* Create a new LockCleanupService
|
|
23
|
+
*
|
|
24
|
+
* @param lockService - LockService instance for filesystem lock operations
|
|
25
|
+
* @param gitOps - GitOps instance for git operations
|
|
26
|
+
* @param logger - Pino logger instance
|
|
27
|
+
* @param storiesDir - Directory to scan for lock files (relative to cwd)
|
|
28
|
+
*/
|
|
29
|
+
constructor(lockService: LockService, gitOps: GitOps, logger: pino.Logger, storiesDir: string);
|
|
30
|
+
/**
|
|
31
|
+
* Clean up all stale locks with git persistence
|
|
32
|
+
*
|
|
33
|
+
* Flow:
|
|
34
|
+
* 1. Pull latest remote state
|
|
35
|
+
* 2. Find all stale locks via LockService.findStaleLocks()
|
|
36
|
+
* 3. If none found, return { cleaned: 0 } with no git operations
|
|
37
|
+
* 4. Delete each stale lock from disk and stage for removal
|
|
38
|
+
* 5. Commit all removals in a single commit
|
|
39
|
+
* 6. Push to remote — throws PushRejectedError on failure
|
|
40
|
+
*
|
|
41
|
+
* @param staleThresholdMs - Threshold in milliseconds for considering a lock stale
|
|
42
|
+
* @param cwd - Working directory (git repository root)
|
|
43
|
+
* @returns Object with count of cleaned locks
|
|
44
|
+
* @throws {PushRejectedError} If git push fails after cleanup
|
|
45
|
+
*/
|
|
46
|
+
cleanupStaleLocks(staleThresholdMs: number, cwd: string): Promise<{
|
|
47
|
+
cleaned: number;
|
|
48
|
+
}>;
|
|
49
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LockCleanupService
|
|
3
|
+
*
|
|
4
|
+
* Batch cleanup of stale lock files with git persistence.
|
|
5
|
+
* Pulls latest state, scans for stale locks, deletes them all,
|
|
6
|
+
* then commits and pushes in a single atomic operation.
|
|
7
|
+
*/
|
|
8
|
+
import { PushRejectedError } from '../../utils/errors.js';
|
|
9
|
+
/**
|
|
10
|
+
* Service for batch cleanup of stale locks with git persistence
|
|
11
|
+
*
|
|
12
|
+
* Orchestrates: pull → findStaleLocks → release each → stage each → single commit → push
|
|
13
|
+
*/
|
|
14
|
+
export class LockCleanupService {
|
|
15
|
+
lockService;
|
|
16
|
+
gitOps;
|
|
17
|
+
logger;
|
|
18
|
+
storiesDir;
|
|
19
|
+
/**
|
|
20
|
+
* Create a new LockCleanupService
|
|
21
|
+
*
|
|
22
|
+
* @param lockService - LockService instance for filesystem lock operations
|
|
23
|
+
* @param gitOps - GitOps instance for git operations
|
|
24
|
+
* @param logger - Pino logger instance
|
|
25
|
+
* @param storiesDir - Directory to scan for lock files (relative to cwd)
|
|
26
|
+
*/
|
|
27
|
+
constructor(lockService, gitOps, logger, storiesDir) {
|
|
28
|
+
this.lockService = lockService;
|
|
29
|
+
this.gitOps = gitOps;
|
|
30
|
+
this.logger = logger;
|
|
31
|
+
this.storiesDir = storiesDir;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Clean up all stale locks with git persistence
|
|
35
|
+
*
|
|
36
|
+
* Flow:
|
|
37
|
+
* 1. Pull latest remote state
|
|
38
|
+
* 2. Find all stale locks via LockService.findStaleLocks()
|
|
39
|
+
* 3. If none found, return { cleaned: 0 } with no git operations
|
|
40
|
+
* 4. Delete each stale lock from disk and stage for removal
|
|
41
|
+
* 5. Commit all removals in a single commit
|
|
42
|
+
* 6. Push to remote — throws PushRejectedError on failure
|
|
43
|
+
*
|
|
44
|
+
* @param staleThresholdMs - Threshold in milliseconds for considering a lock stale
|
|
45
|
+
* @param cwd - Working directory (git repository root)
|
|
46
|
+
* @returns Object with count of cleaned locks
|
|
47
|
+
* @throws {PushRejectedError} If git push fails after cleanup
|
|
48
|
+
*/
|
|
49
|
+
async cleanupStaleLocks(staleThresholdMs, cwd) {
|
|
50
|
+
// Step 1: Get latest remote state
|
|
51
|
+
this.gitOps.pull(cwd);
|
|
52
|
+
// Step 2: Find all stale locks
|
|
53
|
+
const staleLocks = await this.lockService.findStaleLocks(this.storiesDir, staleThresholdMs);
|
|
54
|
+
// Step 3: No stale locks — early return, no git operations
|
|
55
|
+
if (staleLocks.length === 0) {
|
|
56
|
+
this.logger.info('No stale locks found');
|
|
57
|
+
return { cleaned: 0 };
|
|
58
|
+
}
|
|
59
|
+
this.logger.info({ count: staleLocks.length }, 'Found stale locks, cleaning up');
|
|
60
|
+
// Step 4: Delete each stale lock from disk and stage the removal
|
|
61
|
+
for (const lockFilePath of staleLocks) {
|
|
62
|
+
const storyPath = lockFilePath.replace(/\.lock$/, '');
|
|
63
|
+
await this.lockService.release(storyPath);
|
|
64
|
+
this.gitOps.remove(lockFilePath, cwd);
|
|
65
|
+
}
|
|
66
|
+
// Step 5: Single commit for all removals
|
|
67
|
+
this.gitOps.commit(`cleanup: ${staleLocks.length} stale locks`, cwd);
|
|
68
|
+
// Step 6: Push — throws on failure
|
|
69
|
+
try {
|
|
70
|
+
const pushResult = this.gitOps.push(cwd);
|
|
71
|
+
if (!pushResult.success) {
|
|
72
|
+
throw new PushRejectedError(`Push failed after stale lock cleanup: ${pushResult.stderr || 'unknown error'}`, pushResult.stderr || '');
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
if (error instanceof PushRejectedError) {
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
// Unexpected error — wrap in PushRejectedError
|
|
80
|
+
throw new PushRejectedError(`Push failed after stale lock cleanup: ${error.message}`, error.message);
|
|
81
|
+
}
|
|
82
|
+
this.logger.info({ cleaned: staleLocks.length }, 'Stale lock cleanup complete');
|
|
83
|
+
return { cleaned: staleLocks.length };
|
|
84
|
+
}
|
|
85
|
+
}
|