edsger 0.55.1 → 0.55.3

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.
@@ -24,8 +24,3 @@ export declare function listProducts(verbose?: boolean): Promise<ProductSummary[
24
24
  * Iterates through all accessible products and collects their issues
25
25
  */
26
26
  export declare function listAllReadyIssues(verbose?: boolean): Promise<IssueWithProduct[]>;
27
- /**
28
- * Claim a specific issue for processing
29
- * Unlike the product-level claim, this claims a specific issue by ID
30
- */
31
- export declare function claimIssueById(issueId: string, productId: string, verbose?: boolean): Promise<IssueInfo | null>;
@@ -83,29 +83,3 @@ export async function listAllReadyIssues(verbose) {
83
83
  throw error;
84
84
  }
85
85
  }
86
- /**
87
- * Claim a specific issue for processing
88
- * Unlike the product-level claim, this claims a specific issue by ID
89
- */
90
- export async function claimIssueById(issueId, productId, verbose) {
91
- if (verbose) {
92
- logInfo(`Claiming issue: ${issueId}`);
93
- }
94
- try {
95
- const result = (await callMcpEndpoint('issues/claim', {
96
- product_id: productId,
97
- issue_id: issueId,
98
- }));
99
- if (result.issue) {
100
- if (verbose) {
101
- logInfo(`Claimed issue: ${result.issue.name}`);
102
- }
103
- return result.issue;
104
- }
105
- return null;
106
- }
107
- catch (error) {
108
- logError(`Failed to claim issue: ${error instanceof Error ? error.message : String(error)}`);
109
- return null;
110
- }
111
- }
@@ -1,14 +1,16 @@
1
1
  import { type IssueInfo } from '../../types/issues.js';
2
2
  /**
3
3
  * Claim the next available ready_for_ai issue for processing.
4
- * Uses database-level locking (FOR UPDATE SKIP LOCKED) to prevent
5
- * race conditions between multiple workers.
4
+ * Uses database-level locking (FOR UPDATE SKIP LOCKED) inside the RPC to
5
+ * prevent race conditions between multiple workers.
6
6
  *
7
- * @param productId - The product ID to claim an issue from
7
+ * @param productId - Optional product ID to scope the claim. When omitted,
8
+ * claims the next ready issue assigned to this worker
9
+ * across any product (cross-product agent mode).
8
10
  * @param verbose - Whether to log verbose output
9
11
  * @returns The claimed issue or null if no issues available
10
12
  */
11
- export declare function claimNextIssue(productId: string, verbose?: boolean): Promise<IssueInfo | null>;
13
+ export declare function claimNextIssue(productId?: string, verbose?: boolean): Promise<IssueInfo | null>;
12
14
  /**
13
15
  * Filter issues by status
14
16
  */
@@ -2,21 +2,23 @@ import { logError, logInfo } from '../../utils/logger.js';
2
2
  import { callMcpEndpoint } from '../mcp-client.js';
3
3
  /**
4
4
  * Claim the next available ready_for_ai issue for processing.
5
- * Uses database-level locking (FOR UPDATE SKIP LOCKED) to prevent
6
- * race conditions between multiple workers.
5
+ * Uses database-level locking (FOR UPDATE SKIP LOCKED) inside the RPC to
6
+ * prevent race conditions between multiple workers.
7
7
  *
8
- * @param productId - The product ID to claim an issue from
8
+ * @param productId - Optional product ID to scope the claim. When omitted,
9
+ * claims the next ready issue assigned to this worker
10
+ * across any product (cross-product agent mode).
9
11
  * @param verbose - Whether to log verbose output
10
12
  * @returns The claimed issue or null if no issues available
11
13
  */
12
14
  export async function claimNextIssue(productId, verbose) {
13
15
  if (verbose) {
14
- logInfo(`Attempting to claim next ready_for_ai issue for product: ${productId}`);
16
+ logInfo(productId
17
+ ? `Attempting to claim next ready_for_ai issue for product: ${productId}`
18
+ : 'Attempting to claim next ready_for_ai issue across all products');
15
19
  }
16
20
  try {
17
- const result = (await callMcpEndpoint('issues/claim', {
18
- product_id: productId,
19
- }));
21
+ const result = (await callMcpEndpoint('issues/claim', productId ? { product_id: productId } : {}));
20
22
  if (result.issue) {
21
23
  if (verbose) {
22
24
  logInfo(`✅ Claimed issue: ${result.issue.name} (${result.issue.id})`);
@@ -44,9 +44,38 @@ export declare class AgentWorkflowProcessor {
44
44
  private processNextIssues;
45
45
  /**
46
46
  * Start a child process worker for a single issue.
47
- * Handles: GitHub config lookup, repo cloning, then forks the worker.
47
+ * Handles: atomic claim, GitHub config lookup, repo cloning, then forks
48
+ * the worker.
49
+ *
50
+ * The candidate `issue` here comes from the listed ready_for_ai pool but is
51
+ * advisory — between list time and claim time another agent (or another
52
+ * process) may have grabbed it. We re-claim atomically per product. The
53
+ * RPC returns whichever ready issue is next in that product (most often
54
+ * the same one we listed, but not guaranteed); we operate on what claim
55
+ * actually returns. If claim returns null, someone else won the race —
56
+ * skip this slot for this cycle.
48
57
  */
49
58
  private startIssueWorker;
59
+ /**
60
+ * After a successful claim moved the issue ready_for_ai → assigned_to_ai,
61
+ * any post-claim setup failure (GitHub config, clone, fork) leaves the
62
+ * issue stuck at assigned_to_ai with no worker — an orphan that the user
63
+ * can't fix from the UI. Push it forward to 'failed' so it shows up in
64
+ * the failed bucket and can be inspected / re-set to ready_for_ai by a
65
+ * human.
66
+ *
67
+ * Status guard: only force-progress if the issue is still at
68
+ * assigned_to_ai or in_progress. A worker may have already advanced the
69
+ * status to shipped / failed via a separate code path before we got the
70
+ * non-success IPC signal; clobbering those would lose real outcome data.
71
+ * STATUS_PROGRESSION_ORDER classifies shipped→failed as a forward move
72
+ * (so updateIssueStatus wouldn't reject it), which is why this guard
73
+ * lives here rather than at the RPC layer.
74
+ *
75
+ * Errors are swallowed: if the status fetch or update fails the issue
76
+ * stays orphaned but logging is the most we can do here.
77
+ */
78
+ private releaseClaim;
50
79
  private handleWorkerResult;
51
80
  getStats(): WorkflowStats;
52
81
  getActiveWorkerCount(): number;
@@ -12,6 +12,8 @@ import { dirname, join } from 'path';
12
12
  import { fileURLToPath } from 'url';
13
13
  import { listAllReadyIssues, } from '../../api/cross-product.js';
14
14
  import { getGitHubConfig } from '../../api/github.js';
15
+ import { claimNextIssue, getIssue } from '../../api/issues/index.js';
16
+ import { callMcpEndpoint } from '../../api/mcp-client.js';
15
17
  import { WorkerTimeoutError } from '../../errors/index.js';
16
18
  import { sendHeartbeat, shouldProcess } from '../../system/session-manager.js';
17
19
  import { logError, logInfo, logSuccess, logWarning, } from '../../utils/logger.js';
@@ -255,9 +257,41 @@ export class AgentWorkflowProcessor {
255
257
  }
256
258
  /**
257
259
  * Start a child process worker for a single issue.
258
- * Handles: GitHub config lookup, repo cloning, then forks the worker.
260
+ * Handles: atomic claim, GitHub config lookup, repo cloning, then forks
261
+ * the worker.
262
+ *
263
+ * The candidate `issue` here comes from the listed ready_for_ai pool but is
264
+ * advisory — between list time and claim time another agent (or another
265
+ * process) may have grabbed it. We re-claim atomically per product. The
266
+ * RPC returns whichever ready issue is next in that product (most often
267
+ * the same one we listed, but not guaranteed); we operate on what claim
268
+ * actually returns. If claim returns null, someone else won the race —
269
+ * skip this slot for this cycle.
259
270
  */
260
- async startIssueWorker(issue) {
271
+ async startIssueWorker(candidate) {
272
+ // In-process dedup runs BEFORE the claim. If we already have a worker
273
+ // for the listed candidate, skipping here avoids successfully claiming
274
+ // an issue we'd then refuse to start — which would leave it stuck at
275
+ // assigned_to_ai with no worker (an orphan, since the claim has no
276
+ // release path). Cross-process races are still handled by the DB.
277
+ if (this.activeWorkers.has(candidate.id)) {
278
+ return;
279
+ }
280
+ // Atomic claim transitions ready_for_ai → assigned_to_ai inside the RPC
281
+ // (FOR UPDATE SKIP LOCKED), so concurrent workers never share the same
282
+ // issue. Scoped to the candidate's product so a race within one product
283
+ // resolves locally instead of pulling work from elsewhere.
284
+ const claimResult = await claimNextIssue(candidate.product_id, this.options.verbose);
285
+ if (!claimResult) {
286
+ if (this.options.verbose) {
287
+ logInfo(`No claimable issue in product ${candidate.product_name ?? candidate.product_id} (raced or no longer ready)`);
288
+ }
289
+ return;
290
+ }
291
+ const issue = {
292
+ ...claimResult,
293
+ product_name: candidate.product_name,
294
+ };
261
295
  const issueId = issue.id;
262
296
  // Update state to processing
263
297
  this.processedIssues = updateIssueState(this.processedIssues, issueId, (currentState) => createProcessingState(issueId, currentState));
@@ -276,6 +310,7 @@ export class AgentWorkflowProcessor {
276
310
  !githubConfig.repo) {
277
311
  logWarning(`GitHub not configured for issue: ${issue.name}. ${githubConfig.message || ''}`);
278
312
  this.processedIssues = updateIssueState(this.processedIssues, issueId, (currentState) => createFailedState(issueId, currentState));
313
+ await this.releaseClaim(issueId, issue.name);
279
314
  return;
280
315
  }
281
316
  // Step 2: Clone or reuse repo
@@ -403,6 +438,42 @@ export class AgentWorkflowProcessor {
403
438
  this.activeWorkers.delete(issueId);
404
439
  this.processedIssues = updateIssueState(this.processedIssues, issueId, (currentState) => createFailedState(issueId, currentState));
405
440
  logError(`Issue error: ${issue.name} - ${error instanceof Error ? error.message : String(error)}`);
441
+ await this.releaseClaim(issueId, issue.name);
442
+ }
443
+ }
444
+ /**
445
+ * After a successful claim moved the issue ready_for_ai → assigned_to_ai,
446
+ * any post-claim setup failure (GitHub config, clone, fork) leaves the
447
+ * issue stuck at assigned_to_ai with no worker — an orphan that the user
448
+ * can't fix from the UI. Push it forward to 'failed' so it shows up in
449
+ * the failed bucket and can be inspected / re-set to ready_for_ai by a
450
+ * human.
451
+ *
452
+ * Status guard: only force-progress if the issue is still at
453
+ * assigned_to_ai or in_progress. A worker may have already advanced the
454
+ * status to shipped / failed via a separate code path before we got the
455
+ * non-success IPC signal; clobbering those would lose real outcome data.
456
+ * STATUS_PROGRESSION_ORDER classifies shipped→failed as a forward move
457
+ * (so updateIssueStatus wouldn't reject it), which is why this guard
458
+ * lives here rather than at the RPC layer.
459
+ *
460
+ * Errors are swallowed: if the status fetch or update fails the issue
461
+ * stays orphaned but logging is the most we can do here.
462
+ */
463
+ async releaseClaim(issueId, issueName) {
464
+ try {
465
+ const current = await getIssue(issueId, false);
466
+ if (current.status !== 'assigned_to_ai' &&
467
+ current.status !== 'in_progress') {
468
+ return;
469
+ }
470
+ await callMcpEndpoint('issues/update', {
471
+ issue_id: issueId,
472
+ status: 'failed',
473
+ });
474
+ }
475
+ catch (releaseError) {
476
+ logWarning(`Failed to release claim on ${issueName} (${issueId}); issue may be orphaned at assigned_to_ai: ${releaseError instanceof Error ? releaseError.message : String(releaseError)}`);
406
477
  }
407
478
  }
408
479
  handleWorkerResult(issueId, issueName, success, error) {
@@ -419,6 +490,15 @@ export class AgentWorkflowProcessor {
419
490
  else {
420
491
  this.processedIssues = updateIssueState(this.processedIssues, issueId, (currentState) => createFailedState(issueId, currentState));
421
492
  logError(`Issue failed: ${issueName}${error ? ` - ${error}` : ''}`);
493
+ // No releaseClaim here: this path runs after the worker reported a
494
+ // non-success result, but the worker may still have been mid-way
495
+ // through writing its own status (e.g. setting 'shipped' or 'failed'
496
+ // before exiting). A read-then-write release would race with the
497
+ // worker's final DB write. Worker code is responsible for advancing
498
+ // the lifecycle status before it exits; a worker that crashes
499
+ // without doing so leaves the issue at assigned_to_ai, which is a
500
+ // separate, pre-existing orphan class outside the scope of the
501
+ // pre-fork cleanup that releaseClaim handles.
422
502
  // Only notify chat on first failure to avoid flooding with duplicate messages
423
503
  const failedState = this.processedIssues.get(issueId);
424
504
  if (failedState && failedState.retryCount <= 1) {
@@ -12,6 +12,7 @@
12
12
  import fs from 'node:fs/promises';
13
13
  import os from 'node:os';
14
14
  import path from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
15
16
  import matter from 'gray-matter';
16
17
  import { logDebug } from '../utils/logger.js';
17
18
  import { loadSkillFile } from './phase-hooks/plugin-loader.js';
@@ -23,9 +24,10 @@ let _bundledSkillsDir = null;
23
24
  function getBundledSkillsDir() {
24
25
  if (!_bundledSkillsDir) {
25
26
  // dist/services/skill-resolver.js → dist/skills/
26
- const thisDir = path.dirname(
27
- // @ts-ignore -- import.meta.url is available in ESM
28
- new URL(import.meta.url).pathname);
27
+ // Use fileURLToPath, not URL.pathname — the latter is percent-encoded,
28
+ // which breaks fs.readFile when the install path contains spaces (e.g.
29
+ // macOS `~/Library/Application Support/edsger-desktop/...`).
30
+ const thisDir = path.dirname(fileURLToPath(import.meta.url));
29
31
  _bundledSkillsDir = path.resolve(thisDir, '..', 'skills');
30
32
  }
31
33
  return _bundledSkillsDir;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "edsger",
3
- "version": "0.55.1",
3
+ "version": "0.55.3",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "edsger": "dist/index.js"