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.
- package/dist/api/cross-product.d.ts +0 -5
- package/dist/api/cross-product.js +0 -26
- package/dist/api/issues/issue-utils.d.ts +6 -4
- package/dist/api/issues/issue-utils.js +9 -7
- package/dist/commands/agent-workflow/processor.d.ts +30 -1
- package/dist/commands/agent-workflow/processor.js +82 -2
- package/dist/services/skill-resolver.js +5 -3
- package/package.json +1 -1
|
@@ -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
|
|
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 -
|
|
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
|
|
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
|
|
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 -
|
|
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(
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
27
|
-
//
|
|
28
|
-
|
|
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;
|