@northflare/runner 0.0.28 → 0.0.30

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 +206 -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 +183 -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 +597 -96
  12. package/dist/components/enhanced-repository-manager.js.map +1 -1
  13. package/dist/components/message-handler-sse.d.ts +18 -0
  14. package/dist/components/message-handler-sse.d.ts.map +1 -1
  15. package/dist/components/message-handler-sse.js +147 -1
  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 +184 -9
  20. package/dist/components/northflare-agent-sdk-manager.js.map +1 -1
  21. package/dist/runner-sse.d.ts +4 -0
  22. package/dist/runner-sse.d.ts.map +1 -1
  23. package/dist/runner-sse.js +28 -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 +20 -19
@@ -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,50 @@ 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
+ void hasOrigin;
358
+ }
359
+ else {
360
+ await git.raw(["checkout", "--orphan", branchName]);
361
+ await git.raw(["commit", "--allow-empty", "-m", "Initial commit"]);
362
+ }
363
+ }
199
364
  /**
200
365
  * Remove a task worktree
201
366
  */
202
367
  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) {
368
+ const entry = this.findTaskStateEntry(taskId);
369
+ if (!entry) {
215
370
  console.log(`No worktree found for task ${taskId}`);
216
371
  return;
217
372
  }
373
+ const { repoKey, repo, taskState } = entry;
218
374
  // Handle local workspaces differently
219
375
  if (repoKey === "__local__") {
220
376
  // Just remove from state, don't touch the filesystem
@@ -227,13 +383,24 @@ export class EnhancedRepositoryManager extends RepositoryManager {
227
383
  console.log(`Removed local task handle for task ${taskId}`);
228
384
  return;
229
385
  }
230
- const { key } = this.getRepoKey(repoKey);
231
- const controlPath = path.join(this.repoBasePath, "repos", key, "control");
386
+ const controlPath = repo.controlPath;
232
387
  try {
233
388
  console.log(`Removing worktree for task ${taskId}...`);
234
- // Remove the worktree
235
389
  const git = simpleGit(controlPath);
236
- await git.raw(["worktree", "remove", taskState.worktreePath, "--force"]);
390
+ // Remove the worktree (when present)
391
+ if (taskState.worktreePath) {
392
+ const worktreePath = taskState.worktreePath;
393
+ try {
394
+ await fs.access(worktreePath);
395
+ await git.raw(["worktree", "remove", worktreePath, "--force"]);
396
+ }
397
+ catch {
398
+ // Worktree path already gone; continue so we can still delete the branch if needed
399
+ }
400
+ finally {
401
+ await this.cleanupDirectory(worktreePath);
402
+ }
403
+ }
237
404
  // Delete the branch if not preserving
238
405
  if (!options.preserveBranch) {
239
406
  try {
@@ -243,10 +410,19 @@ export class EnhancedRepositoryManager extends RepositoryManager {
243
410
  console.warn(`Failed to delete branch ${taskState.branch}:`, error);
244
411
  }
245
412
  }
246
- // Remove from state
413
+ // Update state
247
414
  const repoState = this.repoStates.get(repoKey);
248
- if (repoState?.repositories[key]?.worktrees) {
249
- delete repoState.repositories[key].worktrees[taskId];
415
+ if (repoState?.repositories[repoKey]?.worktrees) {
416
+ if (options.preserveBranch) {
417
+ repoState.repositories[repoKey].worktrees[taskId] = {
418
+ ...taskState,
419
+ worktreePath: "",
420
+ updatedAt: new Date(),
421
+ };
422
+ }
423
+ else {
424
+ delete repoState.repositories[repoKey].worktrees[taskId];
425
+ }
250
426
  }
251
427
  await this.persistState();
252
428
  console.log(`Successfully removed worktree for task ${taskId}`);
@@ -303,9 +479,17 @@ export class EnhancedRepositoryManager extends RepositoryManager {
303
479
  const commitHash = result.commit;
304
480
  // Update task state
305
481
  taskState.lastCommit = commitHash;
482
+ taskState.commitCount = (taskState.commitCount ?? 0) + 1;
306
483
  taskState.updatedAt = new Date();
307
484
  await this.updateTaskStateByTaskId(taskId, taskState);
308
485
  console.log(`Created commit ${commitHash} for task ${taskId}`);
486
+ logger.info(`Committed task ${taskId} (${taskState.branch}) -> ${commitHash}`, {
487
+ taskId,
488
+ branch: taskState.branch,
489
+ baseBranch: taskState.baseBranch,
490
+ commit: commitHash,
491
+ worktreePath: taskState.worktreePath,
492
+ });
309
493
  return commitHash;
310
494
  }
311
495
  /**
@@ -344,19 +528,44 @@ export class EnhancedRepositoryManager extends RepositoryManager {
344
528
  }
345
529
  const git = simpleGit(taskState.worktreePath);
346
530
  try {
347
- // Fetch latest changes
348
- await git.fetch("origin", targetBranch);
349
- // Perform rebase
350
- await git.rebase([`origin/${targetBranch}`]);
531
+ const remotes = await git.getRemotes(true);
532
+ const hasOrigin = remotes.some((r) => r.name === "origin");
533
+ logger.info(`Rebasing task ${taskId} (${taskState.branch}) onto ${targetBranch}`, {
534
+ taskId,
535
+ branch: taskState.branch,
536
+ targetBranch,
537
+ hasOrigin,
538
+ worktreePath: taskState.worktreePath,
539
+ });
540
+ if (hasOrigin) {
541
+ // Fetch latest changes
542
+ await git.fetch("origin", targetBranch);
543
+ // Perform rebase
544
+ await git.rebase([`origin/${targetBranch}`]);
545
+ }
546
+ else {
547
+ await git.rebase([targetBranch]);
548
+ }
351
549
  // Update base branch in state
352
550
  taskState.baseBranch = targetBranch;
353
551
  taskState.updatedAt = new Date();
354
552
  await this.updateTaskStateByTaskId(taskId, taskState);
355
553
  console.log(`Successfully rebased task ${taskId} onto ${targetBranch}`);
554
+ logger.info(`Rebased task ${taskId} (${taskState.branch}) onto ${targetBranch}`, {
555
+ taskId,
556
+ branch: taskState.branch,
557
+ targetBranch,
558
+ });
356
559
  return { success: true };
357
560
  }
358
561
  catch (error) {
359
562
  console.error(`Rebase failed for task ${taskId}:`, error);
563
+ logger.error(`Rebase failed for task ${taskId} (${taskState.branch})`, {
564
+ taskId,
565
+ branch: taskState.branch,
566
+ targetBranch,
567
+ error: error instanceof Error ? error.message : String(error),
568
+ });
360
569
  // Check for conflicts
361
570
  const status = await git.status();
362
571
  if (status.conflicted.length > 0) {
@@ -385,22 +594,83 @@ export class EnhancedRepositoryManager extends RepositoryManager {
385
594
  * Merge a task branch into target branch
386
595
  */
387
596
  async mergeTask(taskId, targetBranch, mode = "no-ff") {
388
- const taskState = await this.getTaskState(taskId);
389
- if (!taskState) {
597
+ const entry = this.findTaskStateEntry(taskId);
598
+ if (!entry) {
390
599
  throw new Error(`No worktree found for task ${taskId}`);
391
600
  }
601
+ const { repoKey, taskState } = entry;
392
602
  // Skip Git operations for local workspaces
393
603
  if (taskState.branch === "local") {
394
604
  console.log(`Skipping merge operation for local workspace task ${taskId}`);
395
605
  return { success: true, mergedCommit: "local-merge" }; // Return success for local workspaces
396
606
  }
397
- const git = simpleGit(taskState.worktreePath);
607
+ return await this.withRepoLock(repoKey, async () => {
608
+ return this.mergeTaskUnlocked(entry, targetBranch, mode);
609
+ });
610
+ }
611
+ async mergeTaskUnlocked(entry, targetBranch, mode) {
612
+ const { repoKey, repo, taskState } = entry;
613
+ const isLocalRepo = repoKey.startsWith("local__");
614
+ const mergePath = await (async () => {
615
+ if (isLocalRepo)
616
+ return repo.controlPath;
617
+ if (!taskState.workspaceId) {
618
+ throw new Error(`Cannot merge task ${taskState.taskId}: missing workspaceId in task Git state`);
619
+ }
620
+ const worktreesPath = path.join(this.repoBasePath, "repos", repoKey, "worktrees");
621
+ const workspaceWorktreePath = path.join(worktreesPath, `workspace_${taskState.workspaceId}`);
622
+ const exists = await fs
623
+ .access(workspaceWorktreePath)
624
+ .then(() => true)
625
+ .catch(() => false);
626
+ if (exists)
627
+ return workspaceWorktreePath;
628
+ await this.ensureDirectory(worktreesPath);
629
+ const controlGit = simpleGit(repo.controlPath);
630
+ await controlGit.fetch("origin", targetBranch);
631
+ await controlGit.raw([
632
+ "worktree",
633
+ "add",
634
+ workspaceWorktreePath,
635
+ `origin/${targetBranch}`,
636
+ ]);
637
+ console.log(`Created merge worktree for workspace ${taskState.workspaceId} at ${workspaceWorktreePath}`);
638
+ return workspaceWorktreePath;
639
+ })();
640
+ const git = simpleGit(mergePath);
398
641
  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);
642
+ const remotes = await git.getRemotes(true);
643
+ const hasOrigin = remotes.some((r) => r.name === "origin");
644
+ logger.info(`Merging task ${taskState.taskId} (${taskState.branch}) into ${targetBranch}`, {
645
+ taskId: taskState.taskId,
646
+ taskBranch: taskState.branch,
647
+ targetBranch,
648
+ mergePath,
649
+ hasOrigin,
650
+ isLocalRepo,
651
+ mode,
652
+ });
653
+ if (hasOrigin) {
654
+ // Fetch latest changes and ensure a local targetBranch exists + is up-to-date
655
+ await git.fetch("origin", targetBranch);
656
+ const localBranches = await git.branchLocal();
657
+ const hasLocalTarget = localBranches.all.includes(targetBranch);
658
+ if (!hasLocalTarget) {
659
+ await git.raw(["checkout", "-B", targetBranch, `origin/${targetBranch}`]);
660
+ }
661
+ else {
662
+ await git.checkout(targetBranch);
663
+ try {
664
+ await git.merge(["--ff-only", `origin/${targetBranch}`]);
665
+ }
666
+ catch (error) {
667
+ throw new Error(`Base branch ${targetBranch} has diverged from origin/${targetBranch}; cannot fast-forward`);
668
+ }
669
+ }
670
+ }
671
+ else {
672
+ await git.checkout(targetBranch);
673
+ }
404
674
  // Perform merge based on mode
405
675
  let mergeArgs = [];
406
676
  switch (mode) {
@@ -414,34 +684,52 @@ export class EnhancedRepositoryManager extends RepositoryManager {
414
684
  mergeArgs = ["--squash"];
415
685
  break;
416
686
  }
417
- const result = await git.merge([taskState.branch, ...mergeArgs]);
687
+ const result = await git.merge([...mergeArgs, taskState.branch]);
418
688
  // For squash merge, we need to commit
419
689
  if (mode === "squash") {
420
- const commitResult = await git.commit(`Merge task ${taskId} (${taskState.branch})`);
690
+ const commitResult = await git.commit(`Merge task ${taskState.taskId} (${taskState.branch})`);
421
691
  result.result = commitResult.commit;
422
692
  }
423
693
  // Update task state
424
694
  taskState.status = "merged";
425
695
  taskState.updatedAt = new Date();
426
- await this.updateTaskStateByTaskId(taskId, taskState);
427
- console.log(`Successfully merged task ${taskId} into ${targetBranch}`);
696
+ await this.updateTaskStateByTaskId(taskState.taskId, taskState);
697
+ console.log(`Successfully merged task ${taskState.taskId} into ${targetBranch}`);
698
+ logger.info(`Merged task ${taskState.taskId} (${taskState.branch}) into ${targetBranch}`, {
699
+ taskId: taskState.taskId,
700
+ taskBranch: taskState.branch,
701
+ targetBranch,
702
+ mergedCommit: result.result,
703
+ });
428
704
  return {
429
705
  success: true,
430
706
  mergedCommit: result.result,
431
707
  };
432
708
  }
433
709
  catch (error) {
434
- console.error(`Merge failed for task ${taskId}:`, error);
710
+ console.error(`Merge failed for task ${taskState.taskId}:`, error);
711
+ logger.error(`Merge failed for task ${taskState.taskId} (${taskState.branch})`, {
712
+ taskId: taskState.taskId,
713
+ taskBranch: taskState.branch,
714
+ targetBranch,
715
+ mergePath,
716
+ error: error instanceof Error ? error.message : String(error),
717
+ });
435
718
  // 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
- };
719
+ try {
720
+ const status = await git.status();
721
+ if (status.conflicted.length > 0) {
722
+ taskState.status = "conflicted";
723
+ await this.updateTaskStateByTaskId(taskState.taskId, taskState);
724
+ return {
725
+ success: false,
726
+ conflicts: status.conflicted,
727
+ error: "Merge resulted in conflicts",
728
+ };
729
+ }
730
+ }
731
+ catch {
732
+ // Ignore status errors (e.g. bare repositories)
445
733
  }
446
734
  return {
447
735
  success: false,
@@ -449,23 +737,205 @@ export class EnhancedRepositoryManager extends RepositoryManager {
449
737
  };
450
738
  }
451
739
  }
740
+ async getTaskRepoInfo(taskId) {
741
+ const entry = this.findTaskStateEntry(taskId);
742
+ if (!entry)
743
+ return null;
744
+ return {
745
+ repoKey: entry.repoKey,
746
+ controlPath: entry.repo.controlPath,
747
+ worktreePath: entry.taskState.worktreePath,
748
+ branch: entry.taskState.branch,
749
+ baseBranch: entry.taskState.baseBranch,
750
+ };
751
+ }
452
752
  /**
453
- * Get task Git state
753
+ * Fast-forward (or hard reset) the shared workspace worktree to a branch tip after a task merge.
754
+ * This keeps the "workspace" checkout in sync with concurrent task integrations.
755
+ *
756
+ * We only update existing workspace worktrees and only when they're clean to avoid
757
+ * clobbering any active non-concurrent task that might be using the workspace checkout.
454
758
  */
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];
759
+ async syncWorkspaceWorktree(workspaceId, repoUrl, branch) {
760
+ if (!workspaceId || !repoUrl)
761
+ return;
762
+ // Local repos merge directly into the primary working tree (controlPath), nothing to sync.
763
+ if (repoUrl.startsWith("file://"))
764
+ return;
765
+ const { key } = this.getRepoKey(repoUrl);
766
+ const workspaceWorktreePath = path.join(this.repoBasePath, "repos", key, "worktrees", `workspace_${workspaceId}`);
767
+ const exists = await fs
768
+ .access(workspaceWorktreePath)
769
+ .then(() => true)
770
+ .catch(() => false);
771
+ await this.withRepoLock(key, async () => {
772
+ if (!exists) {
773
+ const controlPath = path.join(this.repoBasePath, "repos", key, "control");
774
+ const controlExists = await fs
775
+ .access(controlPath)
776
+ .then(() => true)
777
+ .catch(() => false);
778
+ if (!controlExists) {
779
+ console.warn(`Skipping workspace worktree sync for ${workspaceId}: control repo missing`, { controlPath, repoUrl });
780
+ return;
460
781
  }
782
+ await this.ensureDirectory(path.dirname(workspaceWorktreePath));
783
+ const controlGit = simpleGit(controlPath);
784
+ const remotes = await controlGit.getRemotes(true);
785
+ const hasOrigin = remotes.some((remote) => remote.name === "origin");
786
+ const checkoutRef = hasOrigin ? `origin/${branch}` : branch;
787
+ await controlGit.raw(["worktree", "add", workspaceWorktreePath, checkoutRef]);
788
+ console.log(`Created workspace worktree for ${workspaceId} at ${workspaceWorktreePath}`);
789
+ }
790
+ const git = simpleGit(workspaceWorktreePath);
791
+ const status = await git.status();
792
+ if (status.conflicted.length > 0 || !status.isClean()) {
793
+ console.warn(`Skipping workspace worktree sync for ${workspaceId}: worktree is not clean`, {
794
+ path: workspaceWorktreePath,
795
+ branch,
796
+ conflicted: status.conflicted,
797
+ fileCount: status.files.length,
798
+ });
799
+ return;
461
800
  }
801
+ const remotes = await git.getRemotes(true);
802
+ const hasOrigin = remotes.some((remote) => remote.name === "origin");
803
+ if (hasOrigin) {
804
+ await git.fetch("origin", branch);
805
+ await git.reset(["--hard", `origin/${branch}`]);
806
+ }
807
+ else {
808
+ await git.reset(["--hard", branch]);
809
+ }
810
+ await git.clean("f", ["-d"]);
811
+ console.log(`Synced workspace worktree for ${workspaceId} to ${branch} at ${workspaceWorktreePath}`);
812
+ });
813
+ }
814
+ async isTaskBranchMerged(taskId, targetBranch) {
815
+ const entry = this.findTaskStateEntry(taskId);
816
+ if (!entry)
817
+ return false;
818
+ const { repo, taskState } = entry;
819
+ const git = simpleGit(repo.controlPath);
820
+ try {
821
+ // Ensure targetBranch exists locally (avoid erroring for repos that only have origin/<branch>)
822
+ const showRef = await git.raw([
823
+ "show-ref",
824
+ "--verify",
825
+ `refs/heads/${targetBranch}`,
826
+ ]);
827
+ if (!showRef?.trim())
828
+ return false;
829
+ }
830
+ catch {
831
+ return false;
832
+ }
833
+ try {
834
+ await git.raw([
835
+ "merge-base",
836
+ "--is-ancestor",
837
+ taskState.branch,
838
+ targetBranch,
839
+ ]);
840
+ return true;
841
+ }
842
+ catch {
843
+ return false;
462
844
  }
463
- return null;
845
+ }
846
+ async pushBranch(taskId, branch) {
847
+ const entry = this.findTaskStateEntry(taskId);
848
+ if (!entry)
849
+ return;
850
+ const git = simpleGit(entry.repo.controlPath);
851
+ const remotes = await git.getRemotes(true);
852
+ const hasOrigin = remotes.some((r) => r.name === "origin");
853
+ if (!hasOrigin)
854
+ return;
855
+ await git.push("origin", branch);
856
+ }
857
+ async integrateTask(taskId, targetBranch, mode = "no-ff") {
858
+ const entry = this.findTaskStateEntry(taskId);
859
+ if (!entry) {
860
+ return {
861
+ success: false,
862
+ phase: "merge",
863
+ error: `No worktree found for task ${taskId}`,
864
+ conflictWorkdir: "",
865
+ };
866
+ }
867
+ const { repoKey, repo, taskState } = entry;
868
+ const isLocalRepo = repoKey.startsWith("local__");
869
+ const mergeWorkdir = isLocalRepo
870
+ ? repo.controlPath
871
+ : taskState.workspaceId
872
+ ? path.join(this.repoBasePath, "repos", repoKey, "worktrees", `workspace_${taskState.workspaceId}`)
873
+ : taskState.worktreePath;
874
+ logger.info(`Integrating task ${taskId} (${taskState.branch}) into ${targetBranch}`, {
875
+ taskId,
876
+ taskBranch: taskState.branch,
877
+ targetBranch,
878
+ mode,
879
+ isLocalRepo,
880
+ controlPath: repo.controlPath,
881
+ worktreePath: taskState.worktreePath,
882
+ });
883
+ return await this.withRepoLock(repoKey, async () => {
884
+ const rebaseResult = await this.rebaseTask(taskId, targetBranch);
885
+ if (!rebaseResult.success) {
886
+ logger.warn(`Task ${taskId} rebase failed`, {
887
+ taskId,
888
+ phase: "rebase",
889
+ targetBranch,
890
+ conflicts: rebaseResult.conflicts ?? [],
891
+ error: rebaseResult.error,
892
+ });
893
+ return {
894
+ success: false,
895
+ phase: "rebase",
896
+ conflicts: rebaseResult.conflicts ?? [],
897
+ error: rebaseResult.error,
898
+ conflictWorkdir: taskState.worktreePath,
899
+ };
900
+ }
901
+ const mergeResult = await this.mergeTaskUnlocked(entry, targetBranch, mode);
902
+ if (!mergeResult.success) {
903
+ logger.warn(`Task ${taskId} merge failed`, {
904
+ taskId,
905
+ phase: "merge",
906
+ targetBranch,
907
+ conflicts: mergeResult.conflicts ?? [],
908
+ error: mergeResult.error,
909
+ });
910
+ return {
911
+ success: false,
912
+ phase: "merge",
913
+ conflicts: mergeResult.conflicts ?? [],
914
+ error: mergeResult.error,
915
+ conflictWorkdir: mergeWorkdir,
916
+ };
917
+ }
918
+ logger.info(`Integrated task ${taskId} into ${targetBranch}`, {
919
+ taskId,
920
+ targetBranch,
921
+ mergedCommit: mergeResult.mergedCommit,
922
+ });
923
+ return {
924
+ success: true,
925
+ mergedCommit: mergeResult.mergedCommit,
926
+ };
927
+ });
928
+ }
929
+ /**
930
+ * Get task Git state
931
+ */
932
+ async getTaskState(taskId) {
933
+ return this.findTaskStateEntry(taskId)?.taskState ?? null;
464
934
  }
465
935
  /**
466
936
  * Update task state
467
937
  */
468
- async updateTaskState(repoKey, state) {
938
+ async updateTaskState(repoKey, state, repoInfo) {
469
939
  if (!this.repoStates.has(repoKey)) {
470
940
  this.repoStates.set(repoKey, {
471
941
  repositories: {},
@@ -482,8 +952,16 @@ export class EnhancedRepositoryManager extends RepositoryManager {
482
952
  worktrees: {},
483
953
  };
484
954
  }
955
+ else if (repoInfo) {
956
+ repoState.repositories[repoKey] = {
957
+ owner: repoInfo.owner,
958
+ repo: repoInfo.repo,
959
+ controlPath: repoInfo.controlPath,
960
+ worktrees: {},
961
+ };
962
+ }
485
963
  else {
486
- const { owner, repo } = this.getRepoKey(repoKey);
964
+ const { owner, repo } = this.parseRepoKey(repoKey);
487
965
  repoState.repositories[repoKey] = {
488
966
  owner,
489
967
  repo,
@@ -492,6 +970,10 @@ export class EnhancedRepositoryManager extends RepositoryManager {
492
970
  };
493
971
  }
494
972
  }
973
+ else if (repoInfo?.controlPath) {
974
+ // Ensure we persist the latest controlPath (important for local repos)
975
+ repoState.repositories[repoKey].controlPath = repoInfo.controlPath;
976
+ }
495
977
  repoState.repositories[repoKey].worktrees[state.taskId] = state;
496
978
  await this.persistState();
497
979
  }
@@ -518,6 +1000,7 @@ export class EnhancedRepositoryManager extends RepositoryManager {
518
1000
  for (const [key, value] of this.repoStates) {
519
1001
  stateData[key] = value;
520
1002
  }
1003
+ await this.ensureDirectory(path.dirname(this.stateFilePath));
521
1004
  await fs.writeFile(this.stateFilePath, JSON.stringify(stateData, null, 2));
522
1005
  console.log("Persisted repository state");
523
1006
  }
@@ -529,6 +1012,7 @@ export class EnhancedRepositoryManager extends RepositoryManager {
529
1012
  * Restore state from disk
530
1013
  */
531
1014
  async restoreState() {
1015
+ await this.migrateLegacyStateFileIfNeeded();
532
1016
  try {
533
1017
  const data = await fs.readFile(this.stateFilePath, "utf-8");
534
1018
  const stateData = JSON.parse(data);
@@ -552,6 +1036,23 @@ export class EnhancedRepositoryManager extends RepositoryManager {
552
1036
  // If file doesn't exist, that's okay - we'll start fresh
553
1037
  }
554
1038
  }
1039
+ async migrateLegacyStateFileIfNeeded() {
1040
+ const hasNewStateFile = await fs
1041
+ .access(this.stateFilePath)
1042
+ .then(() => true)
1043
+ .catch(() => false);
1044
+ if (hasNewStateFile)
1045
+ return;
1046
+ const hasLegacyStateFile = await fs
1047
+ .access(this.legacyStateFilePath)
1048
+ .then(() => true)
1049
+ .catch(() => false);
1050
+ if (!hasLegacyStateFile)
1051
+ return;
1052
+ await this.ensureDirectory(path.dirname(this.stateFilePath));
1053
+ await fs.rename(this.legacyStateFilePath, this.stateFilePath);
1054
+ console.log(`Moved legacy repository state file to runner data directory: ${this.stateFilePath}`);
1055
+ }
555
1056
  /**
556
1057
  * Override parent's checkoutRepository to use worktrees for backward compatibility
557
1058
  */