@northflare/runner 0.0.28 → 0.0.29

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.
Files changed (29) hide show
  1. package/dist/components/claude-sdk-manager.d.ts +4 -0
  2. package/dist/components/claude-sdk-manager.d.ts.map +1 -1
  3. package/dist/components/claude-sdk-manager.js +196 -22
  4. package/dist/components/claude-sdk-manager.js.map +1 -1
  5. package/dist/components/codex-sdk-manager.d.ts +3 -0
  6. package/dist/components/codex-sdk-manager.d.ts.map +1 -1
  7. package/dist/components/codex-sdk-manager.js +173 -7
  8. package/dist/components/codex-sdk-manager.js.map +1 -1
  9. package/dist/components/enhanced-repository-manager.d.ts +39 -0
  10. package/dist/components/enhanced-repository-manager.d.ts.map +1 -1
  11. package/dist/components/enhanced-repository-manager.js +604 -96
  12. package/dist/components/enhanced-repository-manager.js.map +1 -1
  13. package/dist/components/message-handler-sse.d.ts +1 -0
  14. package/dist/components/message-handler-sse.d.ts.map +1 -1
  15. package/dist/components/message-handler-sse.js +21 -0
  16. package/dist/components/message-handler-sse.js.map +1 -1
  17. package/dist/components/northflare-agent-sdk-manager.d.ts +3 -0
  18. package/dist/components/northflare-agent-sdk-manager.d.ts.map +1 -1
  19. package/dist/components/northflare-agent-sdk-manager.js +174 -9
  20. package/dist/components/northflare-agent-sdk-manager.js.map +1 -1
  21. package/dist/runner-sse.d.ts +3 -0
  22. package/dist/runner-sse.d.ts.map +1 -1
  23. package/dist/runner-sse.js +20 -0
  24. package/dist/runner-sse.js.map +1 -1
  25. package/dist/types/claude.d.ts +3 -0
  26. package/dist/types/claude.d.ts.map +1 -1
  27. package/dist/types/runner-interface.d.ts +2 -0
  28. package/dist/types/runner-interface.d.ts.map +1 -1
  29. package/package.json +1 -1
@@ -19,17 +19,30 @@
19
19
  import { RepositoryManager } from './repository-manager.js';
20
20
  import path from "path";
21
21
  import fs from "fs/promises";
22
+ import crypto from "crypto";
22
23
  import { simpleGit } from "simple-git";
23
24
  import { createScopedConsole } from '../utils/console.js';
25
+ import { createLogger } from '../utils/logger.js';
24
26
  const console = createScopedConsole("repo");
27
+ const logger = createLogger("EnhancedRepositoryManager", "repo");
25
28
  export class EnhancedRepositoryManager extends RepositoryManager {
26
29
  repoStates;
27
30
  stateFilePath;
31
+ legacyStateFilePath;
32
+ repoLocks;
33
+ taskWorktreesBasePath;
28
34
  constructor(runner) {
29
35
  super(runner);
30
36
  this.repoStates = new Map();
31
- // this.repoBasePath is set by parent constructor, so we can use it now
32
- this.stateFilePath = path.join(this.repoBasePath, "repository-state.json");
37
+ this.repoLocks = new Map();
38
+ const dataDir = runner.config_.dataDir;
39
+ const runnerId = runner.getRunnerId();
40
+ const stateFileName = runnerId
41
+ ? `repository-state-${runnerId}.json`
42
+ : "repository-state.json";
43
+ this.stateFilePath = path.join(dataDir, "git", stateFileName);
44
+ this.legacyStateFilePath = path.join(this.repoBasePath, "repository-state.json");
45
+ this.taskWorktreesBasePath = path.join(dataDir, "git", "worktrees");
33
46
  // Load persisted state on initialization
34
47
  this.restoreState().catch((err) => {
35
48
  console.error("Failed to restore repository state:", err);
@@ -42,9 +55,13 @@ export class EnhancedRepositoryManager extends RepositoryManager {
42
55
  // Handle local repository URLs
43
56
  if (repoUrl.startsWith("file://")) {
44
57
  const localPath = repoUrl.replace("file://", "");
45
- const pathParts = localPath.split("/");
46
- const repoName = pathParts[pathParts.length - 1] || "local";
47
- return { key: `local__${repoName}`, owner: "local", repo: repoName };
58
+ const repoName = path.basename(localPath) || "local";
59
+ const digest = crypto
60
+ .createHash("sha1")
61
+ .update(localPath)
62
+ .digest("hex")
63
+ .slice(0, 12);
64
+ return { key: `local__${repoName}__${digest}`, owner: "local", repo: repoName };
48
65
  }
49
66
  // Extract owner and repo from URL
50
67
  const match = repoUrl.match(/github\.com[:/]([^/]+)\/([^/.]+)(\.git)?$/);
@@ -56,6 +73,41 @@ export class EnhancedRepositoryManager extends RepositoryManager {
56
73
  const key = `${owner}__${repo}`;
57
74
  return { key, owner, repo };
58
75
  }
76
+ parseRepoKey(repoKey) {
77
+ const parts = repoKey.split("__");
78
+ const owner = parts[0] || repoKey;
79
+ const repo = parts[1] || repoKey;
80
+ return { owner, repo };
81
+ }
82
+ findTaskStateEntry(taskId) {
83
+ for (const [repoKey, state] of this.repoStates) {
84
+ const repo = state.repositories[repoKey];
85
+ const taskState = repo?.worktrees?.[taskId];
86
+ if (repo && taskState) {
87
+ return { repoKey, repo, taskState };
88
+ }
89
+ }
90
+ return null;
91
+ }
92
+ async withRepoLock(repoKey, fn) {
93
+ const previous = this.repoLocks.get(repoKey) ?? Promise.resolve();
94
+ let release;
95
+ const current = new Promise((resolve) => {
96
+ release = resolve;
97
+ });
98
+ const chained = previous.then(() => current);
99
+ this.repoLocks.set(repoKey, chained);
100
+ await previous;
101
+ try {
102
+ return await fn();
103
+ }
104
+ finally {
105
+ release();
106
+ if (this.repoLocks.get(repoKey) === chained) {
107
+ this.repoLocks.delete(repoKey);
108
+ }
109
+ }
110
+ }
59
111
  /**
60
112
  * Ensure control repository exists
61
113
  */
@@ -64,7 +116,18 @@ export class EnhancedRepositoryManager extends RepositoryManager {
64
116
  const controlPath = path.join(this.repoBasePath, "repos", key, "control");
65
117
  // Check if control repository already exists
66
118
  try {
67
- await fs.access(path.join(controlPath, ".git"));
119
+ // Support both bare repos (config/objects at root) and legacy non-bare repos (.git/)
120
+ const hasGitDir = await fs
121
+ .access(path.join(controlPath, ".git"))
122
+ .then(() => true)
123
+ .catch(() => false);
124
+ const hasBareLayout = await fs
125
+ .access(path.join(controlPath, "config"))
126
+ .then(() => fs.access(path.join(controlPath, "objects")).then(() => true))
127
+ .catch(() => false);
128
+ if (!hasGitDir && !hasBareLayout) {
129
+ throw new Error("missing");
130
+ }
68
131
  console.log(`Control repository already exists for ${key}`);
69
132
  // Update remote URL if token changed
70
133
  if (githubToken) {
@@ -83,9 +146,6 @@ export class EnhancedRepositoryManager extends RepositoryManager {
83
146
  await this.ensureDirectory(path.dirname(controlPath));
84
147
  const authUrl = this.getAuthenticatedUrl(repoUrl, githubToken);
85
148
  await this.executeGit(["clone", "--bare", authUrl, controlPath]);
86
- // Convert to regular repository for worktree support
87
- const git = simpleGit(controlPath);
88
- await git.raw(["config", "--bool", "core.bare", "false"]);
89
149
  console.log(`Successfully created control repository for ${key}`);
90
150
  return controlPath;
91
151
  }
@@ -121,51 +181,121 @@ export class EnhancedRepositoryManager extends RepositoryManager {
121
181
  * Create a task-specific worktree
122
182
  */
123
183
  async createTaskWorktree(taskId, workspaceId, repoUrl, baseBranch = "main", githubToken) {
124
- // For local repositories, just return the path without creating worktrees
125
- if (repoUrl.startsWith("file://")) {
126
- const localPath = repoUrl.replace("file://", "");
127
- console.log(`Using local repository for task ${taskId}: ${localPath}`);
128
- // Verify the path exists
184
+ const { key, owner, repo } = this.getRepoKey(repoUrl);
185
+ // Reuse an existing worktree/branch for this task when possible
186
+ const existing = this.findTaskStateEntry(taskId);
187
+ if (existing && existing.repoKey !== "__local__" && existing.taskState.branch !== "local") {
188
+ const expectedWorktreePath = path.join(this.taskWorktreesBasePath, existing.repoKey, `task_${taskId}`);
189
+ const worktreePath = existing.taskState.worktreePath || expectedWorktreePath;
190
+ const shouldPersistMetadata = existing.taskState.workspaceId !== workspaceId ||
191
+ existing.taskState.baseBranch !== baseBranch;
192
+ if (shouldPersistMetadata) {
193
+ existing.taskState.workspaceId = workspaceId;
194
+ existing.taskState.baseBranch = baseBranch;
195
+ existing.taskState.updatedAt = new Date();
196
+ await this.updateTaskStateByTaskId(taskId, existing.taskState);
197
+ }
129
198
  try {
130
- const stats = await fs.stat(localPath);
131
- if (!stats.isDirectory()) {
132
- throw new Error(`Path is not a directory: ${localPath}`);
133
- }
199
+ await fs.access(worktreePath);
200
+ return {
201
+ taskId,
202
+ worktreePath,
203
+ branch: existing.taskState.branch,
204
+ baseBranch: existing.taskState.baseBranch,
205
+ };
134
206
  }
135
- catch (error) {
136
- console.error(`Local repository path not found: ${localPath}`);
137
- throw new Error(`Local repository path not found: ${localPath}`);
207
+ catch {
208
+ // Recreate missing worktree from the existing branch
209
+ const git = simpleGit(existing.repo.controlPath);
210
+ await this.ensureDirectory(path.dirname(worktreePath));
211
+ try {
212
+ await git.raw(["worktree", "add", worktreePath, existing.taskState.branch]);
213
+ }
214
+ catch (error) {
215
+ console.error(`Failed to recreate worktree for task ${taskId}:`, error);
216
+ await this.cleanupDirectory(worktreePath);
217
+ throw error;
218
+ }
219
+ const worktreeGit = simpleGit(worktreePath);
220
+ await worktreeGit.addConfig("user.name", "Northflare");
221
+ await worktreeGit.addConfig("user.email", "runner@northflare.ai");
222
+ existing.taskState.worktreePath = worktreePath;
223
+ existing.taskState.workspaceId = workspaceId;
224
+ existing.taskState.baseBranch = baseBranch;
225
+ existing.taskState.status = "active";
226
+ existing.taskState.updatedAt = new Date();
227
+ await this.updateTaskState(existing.repoKey, existing.taskState);
228
+ return {
229
+ taskId,
230
+ worktreePath,
231
+ branch: existing.taskState.branch,
232
+ baseBranch: existing.taskState.baseBranch,
233
+ };
138
234
  }
139
- return {
140
- taskId,
141
- worktreePath: localPath,
142
- branch: "local",
143
- baseBranch: "local",
144
- };
145
235
  }
146
- const { key } = this.getRepoKey(repoUrl);
236
+ const isLocalRepo = repoUrl.startsWith("file://");
147
237
  // Ensure control repository exists
148
- const controlPath = await this.ensureControlRepository(repoUrl, githubToken);
149
- const worktreesPath = path.join(this.repoBasePath, "repos", key, "worktrees");
238
+ let controlPath;
239
+ if (isLocalRepo) {
240
+ controlPath = repoUrl.replace("file://", "");
241
+ // Verify local path exists and has a Git directory
242
+ const stats = await fs.stat(controlPath);
243
+ if (!stats.isDirectory()) {
244
+ throw new Error(`Local repository path is not a directory: ${controlPath}`);
245
+ }
246
+ await fs.access(path.join(controlPath, ".git"));
247
+ }
248
+ else {
249
+ controlPath = await this.ensureControlRepository(repoUrl, githubToken);
250
+ }
251
+ const worktreesPath = path.join(this.taskWorktreesBasePath, key);
150
252
  const taskWorktreePath = path.join(worktreesPath, `task_${taskId}`);
151
253
  // Create unique branch name for the task
152
- const timestamp = Date.now();
153
- const shortTaskId = taskId.substring(0, 8);
154
- const branchName = `task/${shortTaskId}-${timestamp}`;
254
+ const branchName = `task/${taskId}`;
155
255
  try {
156
256
  console.log(`Creating worktree for task ${taskId} on branch ${branchName}...`);
157
257
  // Create worktree directory
158
258
  await this.ensureDirectory(worktreesPath);
159
259
  // Add worktree with new branch based on baseBranch
160
260
  const git = simpleGit(controlPath);
161
- await git.raw([
162
- "worktree",
163
- "add",
164
- "-b",
165
- branchName,
166
- taskWorktreePath,
167
- `origin/${baseBranch}`,
168
- ]);
261
+ const baseRef = isLocalRepo ? baseBranch : `origin/${baseBranch}`;
262
+ let baseRefIsValid = await git
263
+ .raw(["rev-parse", "--verify", `${baseRef}^{commit}`])
264
+ .then(() => true)
265
+ .catch(() => false);
266
+ if (!baseRefIsValid) {
267
+ const hasAnyCommit = await git
268
+ .raw(["rev-parse", "--verify", "HEAD^{commit}"])
269
+ .then(() => true)
270
+ .catch(() => false);
271
+ if (!hasAnyCommit) {
272
+ await this.ensureInitialCommitOnBranch(controlPath, baseBranch);
273
+ baseRefIsValid = await git
274
+ .raw(["rev-parse", "--verify", `${baseRef}^{commit}`])
275
+ .then(() => true)
276
+ .catch(() => false);
277
+ }
278
+ if (!baseRefIsValid) {
279
+ throw new Error(`Cannot create a task worktree for ${taskId}: base branch '${baseBranch}' is not a valid Git reference in ${controlPath}. Set Workspace → Settings → Git branch to an existing branch name and retry.`);
280
+ }
281
+ }
282
+ const branchExists = await git
283
+ .raw(["show-ref", "--verify", `refs/heads/${branchName}`])
284
+ .then(() => true)
285
+ .catch(() => false);
286
+ if (branchExists) {
287
+ await git.raw(["worktree", "add", taskWorktreePath, branchName]);
288
+ }
289
+ else {
290
+ await git.raw([
291
+ "worktree",
292
+ "add",
293
+ "-b",
294
+ branchName,
295
+ taskWorktreePath,
296
+ baseRef,
297
+ ]);
298
+ }
169
299
  // Configure the worktree
170
300
  const worktreeGit = simpleGit(taskWorktreePath);
171
301
  await worktreeGit.addConfig("user.name", "Northflare");
@@ -173,6 +303,7 @@ export class EnhancedRepositoryManager extends RepositoryManager {
173
303
  // Create and persist state
174
304
  const state = {
175
305
  taskId,
306
+ workspaceId,
176
307
  branch: branchName,
177
308
  baseBranch,
178
309
  worktreePath: taskWorktreePath,
@@ -180,7 +311,7 @@ export class EnhancedRepositoryManager extends RepositoryManager {
180
311
  createdAt: new Date(),
181
312
  updatedAt: new Date(),
182
313
  };
183
- await this.updateTaskState(key, state);
314
+ await this.updateTaskState(key, state, { owner, repo, controlPath });
184
315
  console.log(`Successfully created worktree for task ${taskId}`);
185
316
  return {
186
317
  taskId,
@@ -196,25 +327,53 @@ export class EnhancedRepositoryManager extends RepositoryManager {
196
327
  throw error;
197
328
  }
198
329
  }
330
+ async ensureInitialCommitOnBranch(repoPath, branchName) {
331
+ const git = simpleGit(repoPath);
332
+ await git.addConfig("user.name", "Northflare");
333
+ await git.addConfig("user.email", "runner@northflare.ai");
334
+ const hasAnyCommit = await git
335
+ .raw(["rev-parse", "--verify", "HEAD^{commit}"])
336
+ .then(() => true)
337
+ .catch(() => false);
338
+ if (hasAnyCommit)
339
+ return;
340
+ console.log(`Repository at ${repoPath} has no commits; creating an empty initial commit on '${branchName}'...`);
341
+ const isBareRepo = await git
342
+ .raw(["rev-parse", "--is-bare-repository"])
343
+ .then((output) => output.trim() === "true")
344
+ .catch(() => false);
345
+ if (isBareRepo) {
346
+ const emptyTreeHash = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
347
+ const commitHash = (await git.raw([
348
+ "commit-tree",
349
+ emptyTreeHash,
350
+ "-m",
351
+ "Initial commit",
352
+ ])).trim();
353
+ await git.raw(["update-ref", `refs/heads/${branchName}`, commitHash]);
354
+ await git.raw(["symbolic-ref", "HEAD", `refs/heads/${branchName}`]);
355
+ const remotes = await git.getRemotes(true);
356
+ const hasOrigin = remotes.some((remote) => remote.name === "origin");
357
+ if (hasOrigin) {
358
+ await git.raw(["push", "origin", `${branchName}:${branchName}`]);
359
+ await git.fetch("origin", branchName);
360
+ }
361
+ }
362
+ else {
363
+ await git.raw(["checkout", "--orphan", branchName]);
364
+ await git.raw(["commit", "--allow-empty", "-m", "Initial commit"]);
365
+ }
366
+ }
199
367
  /**
200
368
  * Remove a task worktree
201
369
  */
202
370
  async removeTaskWorktree(taskId, options = {}) {
203
- // Find the task state across all repositories
204
- let repoKey = null;
205
- let taskState = null;
206
- for (const [key, state] of this.repoStates) {
207
- const repo = state.repositories[key];
208
- if (repo?.worktrees[taskId]) {
209
- repoKey = key;
210
- taskState = repo.worktrees[taskId];
211
- break;
212
- }
213
- }
214
- if (!repoKey || !taskState) {
371
+ const entry = this.findTaskStateEntry(taskId);
372
+ if (!entry) {
215
373
  console.log(`No worktree found for task ${taskId}`);
216
374
  return;
217
375
  }
376
+ const { repoKey, repo, taskState } = entry;
218
377
  // Handle local workspaces differently
219
378
  if (repoKey === "__local__") {
220
379
  // Just remove from state, don't touch the filesystem
@@ -227,13 +386,24 @@ export class EnhancedRepositoryManager extends RepositoryManager {
227
386
  console.log(`Removed local task handle for task ${taskId}`);
228
387
  return;
229
388
  }
230
- const { key } = this.getRepoKey(repoKey);
231
- const controlPath = path.join(this.repoBasePath, "repos", key, "control");
389
+ const controlPath = repo.controlPath;
232
390
  try {
233
391
  console.log(`Removing worktree for task ${taskId}...`);
234
- // Remove the worktree
235
392
  const git = simpleGit(controlPath);
236
- await git.raw(["worktree", "remove", taskState.worktreePath, "--force"]);
393
+ // Remove the worktree (when present)
394
+ if (taskState.worktreePath) {
395
+ const worktreePath = taskState.worktreePath;
396
+ try {
397
+ await fs.access(worktreePath);
398
+ await git.raw(["worktree", "remove", worktreePath, "--force"]);
399
+ }
400
+ catch {
401
+ // Worktree path already gone; continue so we can still delete the branch if needed
402
+ }
403
+ finally {
404
+ await this.cleanupDirectory(worktreePath);
405
+ }
406
+ }
237
407
  // Delete the branch if not preserving
238
408
  if (!options.preserveBranch) {
239
409
  try {
@@ -243,10 +413,19 @@ export class EnhancedRepositoryManager extends RepositoryManager {
243
413
  console.warn(`Failed to delete branch ${taskState.branch}:`, error);
244
414
  }
245
415
  }
246
- // Remove from state
416
+ // Update state
247
417
  const repoState = this.repoStates.get(repoKey);
248
- if (repoState?.repositories[key]?.worktrees) {
249
- delete repoState.repositories[key].worktrees[taskId];
418
+ if (repoState?.repositories[repoKey]?.worktrees) {
419
+ if (options.preserveBranch) {
420
+ repoState.repositories[repoKey].worktrees[taskId] = {
421
+ ...taskState,
422
+ worktreePath: "",
423
+ updatedAt: new Date(),
424
+ };
425
+ }
426
+ else {
427
+ delete repoState.repositories[repoKey].worktrees[taskId];
428
+ }
250
429
  }
251
430
  await this.persistState();
252
431
  console.log(`Successfully removed worktree for task ${taskId}`);
@@ -303,9 +482,17 @@ export class EnhancedRepositoryManager extends RepositoryManager {
303
482
  const commitHash = result.commit;
304
483
  // Update task state
305
484
  taskState.lastCommit = commitHash;
485
+ taskState.commitCount = (taskState.commitCount ?? 0) + 1;
306
486
  taskState.updatedAt = new Date();
307
487
  await this.updateTaskStateByTaskId(taskId, taskState);
308
488
  console.log(`Created commit ${commitHash} for task ${taskId}`);
489
+ logger.info(`Committed task ${taskId} (${taskState.branch}) -> ${commitHash}`, {
490
+ taskId,
491
+ branch: taskState.branch,
492
+ baseBranch: taskState.baseBranch,
493
+ commit: commitHash,
494
+ worktreePath: taskState.worktreePath,
495
+ });
309
496
  return commitHash;
310
497
  }
311
498
  /**
@@ -344,19 +531,44 @@ export class EnhancedRepositoryManager extends RepositoryManager {
344
531
  }
345
532
  const git = simpleGit(taskState.worktreePath);
346
533
  try {
347
- // Fetch latest changes
348
- await git.fetch("origin", targetBranch);
349
- // Perform rebase
350
- await git.rebase([`origin/${targetBranch}`]);
534
+ const remotes = await git.getRemotes(true);
535
+ const hasOrigin = remotes.some((r) => r.name === "origin");
536
+ logger.info(`Rebasing task ${taskId} (${taskState.branch}) onto ${targetBranch}`, {
537
+ taskId,
538
+ branch: taskState.branch,
539
+ targetBranch,
540
+ hasOrigin,
541
+ worktreePath: taskState.worktreePath,
542
+ });
543
+ if (hasOrigin) {
544
+ // Fetch latest changes
545
+ await git.fetch("origin", targetBranch);
546
+ // Perform rebase
547
+ await git.rebase([`origin/${targetBranch}`]);
548
+ }
549
+ else {
550
+ await git.rebase([targetBranch]);
551
+ }
351
552
  // Update base branch in state
352
553
  taskState.baseBranch = targetBranch;
353
554
  taskState.updatedAt = new Date();
354
555
  await this.updateTaskStateByTaskId(taskId, taskState);
355
556
  console.log(`Successfully rebased task ${taskId} onto ${targetBranch}`);
557
+ logger.info(`Rebased task ${taskId} (${taskState.branch}) onto ${targetBranch}`, {
558
+ taskId,
559
+ branch: taskState.branch,
560
+ targetBranch,
561
+ });
356
562
  return { success: true };
357
563
  }
358
564
  catch (error) {
359
565
  console.error(`Rebase failed for task ${taskId}:`, error);
566
+ logger.error(`Rebase failed for task ${taskId} (${taskState.branch})`, {
567
+ taskId,
568
+ branch: taskState.branch,
569
+ targetBranch,
570
+ error: error instanceof Error ? error.message : String(error),
571
+ });
360
572
  // Check for conflicts
361
573
  const status = await git.status();
362
574
  if (status.conflicted.length > 0) {
@@ -385,22 +597,83 @@ export class EnhancedRepositoryManager extends RepositoryManager {
385
597
  * Merge a task branch into target branch
386
598
  */
387
599
  async mergeTask(taskId, targetBranch, mode = "no-ff") {
388
- const taskState = await this.getTaskState(taskId);
389
- if (!taskState) {
600
+ const entry = this.findTaskStateEntry(taskId);
601
+ if (!entry) {
390
602
  throw new Error(`No worktree found for task ${taskId}`);
391
603
  }
604
+ const { repoKey, taskState } = entry;
392
605
  // Skip Git operations for local workspaces
393
606
  if (taskState.branch === "local") {
394
607
  console.log(`Skipping merge operation for local workspace task ${taskId}`);
395
608
  return { success: true, mergedCommit: "local-merge" }; // Return success for local workspaces
396
609
  }
397
- const git = simpleGit(taskState.worktreePath);
610
+ return await this.withRepoLock(repoKey, async () => {
611
+ return this.mergeTaskUnlocked(entry, targetBranch, mode);
612
+ });
613
+ }
614
+ async mergeTaskUnlocked(entry, targetBranch, mode) {
615
+ const { repoKey, repo, taskState } = entry;
616
+ const isLocalRepo = repoKey.startsWith("local__");
617
+ const mergePath = await (async () => {
618
+ if (isLocalRepo)
619
+ return repo.controlPath;
620
+ if (!taskState.workspaceId) {
621
+ throw new Error(`Cannot merge task ${taskState.taskId}: missing workspaceId in task Git state`);
622
+ }
623
+ const worktreesPath = path.join(this.repoBasePath, "repos", repoKey, "worktrees");
624
+ const workspaceWorktreePath = path.join(worktreesPath, `workspace_${taskState.workspaceId}`);
625
+ const exists = await fs
626
+ .access(workspaceWorktreePath)
627
+ .then(() => true)
628
+ .catch(() => false);
629
+ if (exists)
630
+ return workspaceWorktreePath;
631
+ await this.ensureDirectory(worktreesPath);
632
+ const controlGit = simpleGit(repo.controlPath);
633
+ await controlGit.fetch("origin", targetBranch);
634
+ await controlGit.raw([
635
+ "worktree",
636
+ "add",
637
+ workspaceWorktreePath,
638
+ `origin/${targetBranch}`,
639
+ ]);
640
+ console.log(`Created merge worktree for workspace ${taskState.workspaceId} at ${workspaceWorktreePath}`);
641
+ return workspaceWorktreePath;
642
+ })();
643
+ const git = simpleGit(mergePath);
398
644
  try {
399
- // Fetch latest changes
400
- await git.fetch("origin", targetBranch);
401
- // Checkout target branch
402
- await git.checkout(targetBranch);
403
- await git.pull("origin", targetBranch);
645
+ const remotes = await git.getRemotes(true);
646
+ const hasOrigin = remotes.some((r) => r.name === "origin");
647
+ logger.info(`Merging task ${taskState.taskId} (${taskState.branch}) into ${targetBranch}`, {
648
+ taskId: taskState.taskId,
649
+ taskBranch: taskState.branch,
650
+ targetBranch,
651
+ mergePath,
652
+ hasOrigin,
653
+ isLocalRepo,
654
+ mode,
655
+ });
656
+ if (hasOrigin) {
657
+ // Fetch latest changes and ensure a local targetBranch exists + is up-to-date
658
+ await git.fetch("origin", targetBranch);
659
+ const localBranches = await git.branchLocal();
660
+ const hasLocalTarget = localBranches.all.includes(targetBranch);
661
+ if (!hasLocalTarget) {
662
+ await git.raw(["checkout", "-B", targetBranch, `origin/${targetBranch}`]);
663
+ }
664
+ else {
665
+ await git.checkout(targetBranch);
666
+ try {
667
+ await git.merge(["--ff-only", `origin/${targetBranch}`]);
668
+ }
669
+ catch (error) {
670
+ throw new Error(`Base branch ${targetBranch} has diverged from origin/${targetBranch}; cannot fast-forward`);
671
+ }
672
+ }
673
+ }
674
+ else {
675
+ await git.checkout(targetBranch);
676
+ }
404
677
  // Perform merge based on mode
405
678
  let mergeArgs = [];
406
679
  switch (mode) {
@@ -414,34 +687,56 @@ export class EnhancedRepositoryManager extends RepositoryManager {
414
687
  mergeArgs = ["--squash"];
415
688
  break;
416
689
  }
417
- const result = await git.merge([taskState.branch, ...mergeArgs]);
690
+ const result = await git.merge([...mergeArgs, taskState.branch]);
418
691
  // For squash merge, we need to commit
419
692
  if (mode === "squash") {
420
- const commitResult = await git.commit(`Merge task ${taskId} (${taskState.branch})`);
693
+ const commitResult = await git.commit(`Merge task ${taskState.taskId} (${taskState.branch})`);
421
694
  result.result = commitResult.commit;
422
695
  }
696
+ if (hasOrigin) {
697
+ await git.push("origin", targetBranch);
698
+ }
423
699
  // Update task state
424
700
  taskState.status = "merged";
425
701
  taskState.updatedAt = new Date();
426
- await this.updateTaskStateByTaskId(taskId, taskState);
427
- console.log(`Successfully merged task ${taskId} into ${targetBranch}`);
702
+ await this.updateTaskStateByTaskId(taskState.taskId, taskState);
703
+ console.log(`Successfully merged task ${taskState.taskId} into ${targetBranch}`);
704
+ logger.info(`Merged task ${taskState.taskId} (${taskState.branch}) into ${targetBranch}`, {
705
+ taskId: taskState.taskId,
706
+ taskBranch: taskState.branch,
707
+ targetBranch,
708
+ mergedCommit: result.result,
709
+ pushed: hasOrigin,
710
+ });
428
711
  return {
429
712
  success: true,
430
713
  mergedCommit: result.result,
431
714
  };
432
715
  }
433
716
  catch (error) {
434
- console.error(`Merge failed for task ${taskId}:`, error);
717
+ console.error(`Merge failed for task ${taskState.taskId}:`, error);
718
+ logger.error(`Merge failed for task ${taskState.taskId} (${taskState.branch})`, {
719
+ taskId: taskState.taskId,
720
+ taskBranch: taskState.branch,
721
+ targetBranch,
722
+ mergePath,
723
+ error: error instanceof Error ? error.message : String(error),
724
+ });
435
725
  // Check for conflicts
436
- const status = await git.status();
437
- if (status.conflicted.length > 0) {
438
- taskState.status = "conflicted";
439
- await this.updateTaskStateByTaskId(taskId, taskState);
440
- return {
441
- success: false,
442
- conflicts: status.conflicted,
443
- error: "Merge resulted in conflicts",
444
- };
726
+ try {
727
+ const status = await git.status();
728
+ if (status.conflicted.length > 0) {
729
+ taskState.status = "conflicted";
730
+ await this.updateTaskStateByTaskId(taskState.taskId, taskState);
731
+ return {
732
+ success: false,
733
+ conflicts: status.conflicted,
734
+ error: "Merge resulted in conflicts",
735
+ };
736
+ }
737
+ }
738
+ catch {
739
+ // Ignore status errors (e.g. bare repositories)
445
740
  }
446
741
  return {
447
742
  success: false,
@@ -449,23 +744,205 @@ export class EnhancedRepositoryManager extends RepositoryManager {
449
744
  };
450
745
  }
451
746
  }
747
+ async getTaskRepoInfo(taskId) {
748
+ const entry = this.findTaskStateEntry(taskId);
749
+ if (!entry)
750
+ return null;
751
+ return {
752
+ repoKey: entry.repoKey,
753
+ controlPath: entry.repo.controlPath,
754
+ worktreePath: entry.taskState.worktreePath,
755
+ branch: entry.taskState.branch,
756
+ baseBranch: entry.taskState.baseBranch,
757
+ };
758
+ }
452
759
  /**
453
- * Get task Git state
760
+ * Fast-forward (or hard reset) the shared workspace worktree to a branch tip after a task merge.
761
+ * This keeps the "workspace" checkout in sync with concurrent task integrations.
762
+ *
763
+ * We only update existing workspace worktrees and only when they're clean to avoid
764
+ * clobbering any active non-concurrent task that might be using the workspace checkout.
454
765
  */
455
- async getTaskState(taskId) {
456
- for (const state of this.repoStates.values()) {
457
- for (const repo of Object.values(state.repositories)) {
458
- if (repo.worktrees[taskId]) {
459
- return repo.worktrees[taskId];
766
+ async syncWorkspaceWorktree(workspaceId, repoUrl, branch) {
767
+ if (!workspaceId || !repoUrl)
768
+ return;
769
+ // Local repos merge directly into the primary working tree (controlPath), nothing to sync.
770
+ if (repoUrl.startsWith("file://"))
771
+ return;
772
+ const { key } = this.getRepoKey(repoUrl);
773
+ const workspaceWorktreePath = path.join(this.repoBasePath, "repos", key, "worktrees", `workspace_${workspaceId}`);
774
+ const exists = await fs
775
+ .access(workspaceWorktreePath)
776
+ .then(() => true)
777
+ .catch(() => false);
778
+ await this.withRepoLock(key, async () => {
779
+ if (!exists) {
780
+ const controlPath = path.join(this.repoBasePath, "repos", key, "control");
781
+ const controlExists = await fs
782
+ .access(controlPath)
783
+ .then(() => true)
784
+ .catch(() => false);
785
+ if (!controlExists) {
786
+ console.warn(`Skipping workspace worktree sync for ${workspaceId}: control repo missing`, { controlPath, repoUrl });
787
+ return;
460
788
  }
789
+ await this.ensureDirectory(path.dirname(workspaceWorktreePath));
790
+ const controlGit = simpleGit(controlPath);
791
+ const remotes = await controlGit.getRemotes(true);
792
+ const hasOrigin = remotes.some((remote) => remote.name === "origin");
793
+ const checkoutRef = hasOrigin ? `origin/${branch}` : branch;
794
+ await controlGit.raw(["worktree", "add", workspaceWorktreePath, checkoutRef]);
795
+ console.log(`Created workspace worktree for ${workspaceId} at ${workspaceWorktreePath}`);
796
+ }
797
+ const git = simpleGit(workspaceWorktreePath);
798
+ const status = await git.status();
799
+ if (status.conflicted.length > 0 || !status.isClean()) {
800
+ console.warn(`Skipping workspace worktree sync for ${workspaceId}: worktree is not clean`, {
801
+ path: workspaceWorktreePath,
802
+ branch,
803
+ conflicted: status.conflicted,
804
+ fileCount: status.files.length,
805
+ });
806
+ return;
807
+ }
808
+ const remotes = await git.getRemotes(true);
809
+ const hasOrigin = remotes.some((remote) => remote.name === "origin");
810
+ if (hasOrigin) {
811
+ await git.fetch("origin", branch);
812
+ await git.reset(["--hard", `origin/${branch}`]);
813
+ }
814
+ else {
815
+ await git.reset(["--hard", branch]);
461
816
  }
817
+ await git.clean("f", ["-d"]);
818
+ console.log(`Synced workspace worktree for ${workspaceId} to ${branch} at ${workspaceWorktreePath}`);
819
+ });
820
+ }
821
+ async isTaskBranchMerged(taskId, targetBranch) {
822
+ const entry = this.findTaskStateEntry(taskId);
823
+ if (!entry)
824
+ return false;
825
+ const { repo, taskState } = entry;
826
+ const git = simpleGit(repo.controlPath);
827
+ try {
828
+ // Ensure targetBranch exists locally (avoid erroring for repos that only have origin/<branch>)
829
+ const showRef = await git.raw([
830
+ "show-ref",
831
+ "--verify",
832
+ `refs/heads/${targetBranch}`,
833
+ ]);
834
+ if (!showRef?.trim())
835
+ return false;
462
836
  }
463
- return null;
837
+ catch {
838
+ return false;
839
+ }
840
+ try {
841
+ await git.raw([
842
+ "merge-base",
843
+ "--is-ancestor",
844
+ taskState.branch,
845
+ targetBranch,
846
+ ]);
847
+ return true;
848
+ }
849
+ catch {
850
+ return false;
851
+ }
852
+ }
853
+ async pushBranch(taskId, branch) {
854
+ const entry = this.findTaskStateEntry(taskId);
855
+ if (!entry)
856
+ return;
857
+ const git = simpleGit(entry.repo.controlPath);
858
+ const remotes = await git.getRemotes(true);
859
+ const hasOrigin = remotes.some((r) => r.name === "origin");
860
+ if (!hasOrigin)
861
+ return;
862
+ await git.push("origin", branch);
863
+ }
864
+ async integrateTask(taskId, targetBranch, mode = "no-ff") {
865
+ const entry = this.findTaskStateEntry(taskId);
866
+ if (!entry) {
867
+ return {
868
+ success: false,
869
+ phase: "merge",
870
+ error: `No worktree found for task ${taskId}`,
871
+ conflictWorkdir: "",
872
+ };
873
+ }
874
+ const { repoKey, repo, taskState } = entry;
875
+ const isLocalRepo = repoKey.startsWith("local__");
876
+ const mergeWorkdir = isLocalRepo
877
+ ? repo.controlPath
878
+ : taskState.workspaceId
879
+ ? path.join(this.repoBasePath, "repos", repoKey, "worktrees", `workspace_${taskState.workspaceId}`)
880
+ : taskState.worktreePath;
881
+ logger.info(`Integrating task ${taskId} (${taskState.branch}) into ${targetBranch}`, {
882
+ taskId,
883
+ taskBranch: taskState.branch,
884
+ targetBranch,
885
+ mode,
886
+ isLocalRepo,
887
+ controlPath: repo.controlPath,
888
+ worktreePath: taskState.worktreePath,
889
+ });
890
+ return await this.withRepoLock(repoKey, async () => {
891
+ const rebaseResult = await this.rebaseTask(taskId, targetBranch);
892
+ if (!rebaseResult.success) {
893
+ logger.warn(`Task ${taskId} rebase failed`, {
894
+ taskId,
895
+ phase: "rebase",
896
+ targetBranch,
897
+ conflicts: rebaseResult.conflicts ?? [],
898
+ error: rebaseResult.error,
899
+ });
900
+ return {
901
+ success: false,
902
+ phase: "rebase",
903
+ conflicts: rebaseResult.conflicts ?? [],
904
+ error: rebaseResult.error,
905
+ conflictWorkdir: taskState.worktreePath,
906
+ };
907
+ }
908
+ const mergeResult = await this.mergeTaskUnlocked(entry, targetBranch, mode);
909
+ if (!mergeResult.success) {
910
+ logger.warn(`Task ${taskId} merge failed`, {
911
+ taskId,
912
+ phase: "merge",
913
+ targetBranch,
914
+ conflicts: mergeResult.conflicts ?? [],
915
+ error: mergeResult.error,
916
+ });
917
+ return {
918
+ success: false,
919
+ phase: "merge",
920
+ conflicts: mergeResult.conflicts ?? [],
921
+ error: mergeResult.error,
922
+ conflictWorkdir: mergeWorkdir,
923
+ };
924
+ }
925
+ logger.info(`Integrated task ${taskId} into ${targetBranch}`, {
926
+ taskId,
927
+ targetBranch,
928
+ mergedCommit: mergeResult.mergedCommit,
929
+ });
930
+ return {
931
+ success: true,
932
+ mergedCommit: mergeResult.mergedCommit,
933
+ };
934
+ });
935
+ }
936
+ /**
937
+ * Get task Git state
938
+ */
939
+ async getTaskState(taskId) {
940
+ return this.findTaskStateEntry(taskId)?.taskState ?? null;
464
941
  }
465
942
  /**
466
943
  * Update task state
467
944
  */
468
- async updateTaskState(repoKey, state) {
945
+ async updateTaskState(repoKey, state, repoInfo) {
469
946
  if (!this.repoStates.has(repoKey)) {
470
947
  this.repoStates.set(repoKey, {
471
948
  repositories: {},
@@ -482,8 +959,16 @@ export class EnhancedRepositoryManager extends RepositoryManager {
482
959
  worktrees: {},
483
960
  };
484
961
  }
962
+ else if (repoInfo) {
963
+ repoState.repositories[repoKey] = {
964
+ owner: repoInfo.owner,
965
+ repo: repoInfo.repo,
966
+ controlPath: repoInfo.controlPath,
967
+ worktrees: {},
968
+ };
969
+ }
485
970
  else {
486
- const { owner, repo } = this.getRepoKey(repoKey);
971
+ const { owner, repo } = this.parseRepoKey(repoKey);
487
972
  repoState.repositories[repoKey] = {
488
973
  owner,
489
974
  repo,
@@ -492,6 +977,10 @@ export class EnhancedRepositoryManager extends RepositoryManager {
492
977
  };
493
978
  }
494
979
  }
980
+ else if (repoInfo?.controlPath) {
981
+ // Ensure we persist the latest controlPath (important for local repos)
982
+ repoState.repositories[repoKey].controlPath = repoInfo.controlPath;
983
+ }
495
984
  repoState.repositories[repoKey].worktrees[state.taskId] = state;
496
985
  await this.persistState();
497
986
  }
@@ -518,6 +1007,7 @@ export class EnhancedRepositoryManager extends RepositoryManager {
518
1007
  for (const [key, value] of this.repoStates) {
519
1008
  stateData[key] = value;
520
1009
  }
1010
+ await this.ensureDirectory(path.dirname(this.stateFilePath));
521
1011
  await fs.writeFile(this.stateFilePath, JSON.stringify(stateData, null, 2));
522
1012
  console.log("Persisted repository state");
523
1013
  }
@@ -529,6 +1019,7 @@ export class EnhancedRepositoryManager extends RepositoryManager {
529
1019
  * Restore state from disk
530
1020
  */
531
1021
  async restoreState() {
1022
+ await this.migrateLegacyStateFileIfNeeded();
532
1023
  try {
533
1024
  const data = await fs.readFile(this.stateFilePath, "utf-8");
534
1025
  const stateData = JSON.parse(data);
@@ -552,6 +1043,23 @@ export class EnhancedRepositoryManager extends RepositoryManager {
552
1043
  // If file doesn't exist, that's okay - we'll start fresh
553
1044
  }
554
1045
  }
1046
+ async migrateLegacyStateFileIfNeeded() {
1047
+ const hasNewStateFile = await fs
1048
+ .access(this.stateFilePath)
1049
+ .then(() => true)
1050
+ .catch(() => false);
1051
+ if (hasNewStateFile)
1052
+ return;
1053
+ const hasLegacyStateFile = await fs
1054
+ .access(this.legacyStateFilePath)
1055
+ .then(() => true)
1056
+ .catch(() => false);
1057
+ if (!hasLegacyStateFile)
1058
+ return;
1059
+ await this.ensureDirectory(path.dirname(this.stateFilePath));
1060
+ await fs.rename(this.legacyStateFilePath, this.stateFilePath);
1061
+ console.log(`Moved legacy repository state file to runner data directory: ${this.stateFilePath}`);
1062
+ }
555
1063
  /**
556
1064
  * Override parent's checkoutRepository to use worktrees for backward compatibility
557
1065
  */