agent-relay-orchestrator 0.78.6 → 0.78.8
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-orchestrator",
|
|
3
|
-
"version": "0.78.
|
|
3
|
+
"version": "0.78.8",
|
|
4
4
|
"description": "Agent Relay orchestrator — manages agent lifecycle across hosts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"test": "bun test"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"agent-relay-sdk": "0.2.
|
|
19
|
+
"agent-relay-sdk": "0.2.58"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"@types/bun": "latest",
|
package/src/control.ts
CHANGED
|
@@ -95,6 +95,8 @@ export function createControlHandler(
|
|
|
95
95
|
repoRoot: typeof command.params.repoRoot === "string" ? command.params.repoRoot : undefined,
|
|
96
96
|
worktreePath: typeof command.params.worktreePath === "string" ? command.params.worktreePath : undefined,
|
|
97
97
|
branch: typeof command.params.branch === "string" ? command.params.branch : undefined,
|
|
98
|
+
baseRef: typeof command.params.baseRef === "string" ? command.params.baseRef : undefined,
|
|
99
|
+
baseSha: typeof command.params.baseSha === "string" ? command.params.baseSha : undefined,
|
|
98
100
|
deleteBranch: command.params.deleteBranch !== false,
|
|
99
101
|
workspacesRoot: workspacesRoot(config.baseDir),
|
|
100
102
|
});
|
package/src/quota-poller.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
|
-
import { existsSync,
|
|
2
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
3
3
|
import { createServer } from "node:net";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import {
|
|
@@ -7,14 +7,10 @@ import {
|
|
|
7
7
|
QUOTA_FAILURE_LOG_INTERVAL_MS,
|
|
8
8
|
QUOTA_FAST_RETRY_MS,
|
|
9
9
|
QuotaCollectionError,
|
|
10
|
-
collectClaudeQuotaSample,
|
|
11
10
|
collectCodexQuotaSample,
|
|
12
|
-
credentialAccountKey,
|
|
13
11
|
normalizeProviderQuotaConfig,
|
|
14
12
|
providerQuotaErrorFromCollectorError,
|
|
15
13
|
quotaRetryAfterMs,
|
|
16
|
-
readClaudeOAuthAccessToken,
|
|
17
|
-
resolveStableClaudeQuotaIdentity,
|
|
18
14
|
resolveStableCodexQuotaIdentityFromHome,
|
|
19
15
|
type ProviderQuotaIdentity,
|
|
20
16
|
type ProviderQuotaSample,
|
|
@@ -177,11 +173,6 @@ export class OrchestratorQuotaPoller {
|
|
|
177
173
|
// A disabled provider (#605) is collected from at all: no discovery → no
|
|
178
174
|
// polling/API calls, leases released by releaseRemovedCandidates, and no
|
|
179
175
|
// skip-marker row (disabled is intentional, not a credential failure).
|
|
180
|
-
if (this.config.providers.includes("claude") && this.configFor("claude").enabled) {
|
|
181
|
-
const found = await this.discoverClaudeCandidates();
|
|
182
|
-
candidates.push(...found.candidates);
|
|
183
|
-
if (found.skipReason) skips.push({ provider: "claude", reason: found.skipReason });
|
|
184
|
-
}
|
|
185
176
|
if (this.config.providers.includes("codex") && this.configFor("codex").enabled) {
|
|
186
177
|
const found = await this.discoverCodexCandidates();
|
|
187
178
|
candidates.push(...found.candidates);
|
|
@@ -194,33 +185,6 @@ export class OrchestratorQuotaPoller {
|
|
|
194
185
|
return { candidates: [...deduped.values()], skips };
|
|
195
186
|
}
|
|
196
187
|
|
|
197
|
-
private async discoverClaudeCandidates(): Promise<{ candidates: QuotaCandidate[]; skipReason?: string }> {
|
|
198
|
-
const credentialsPaths = [
|
|
199
|
-
join(this.config.env.CLAUDE_CONFIG_DIR || process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"), ".credentials.json"),
|
|
200
|
-
...providerHomeCredentialPaths("claude", ".credentials.json"),
|
|
201
|
-
];
|
|
202
|
-
const candidates: QuotaCandidate[] = [];
|
|
203
|
-
for (const accessToken of [
|
|
204
|
-
this.config.env.CLAUDE_CODE_OAUTH_TOKEN,
|
|
205
|
-
process.env.CLAUDE_CODE_OAUTH_TOKEN,
|
|
206
|
-
...claudeSetupTokenEnvPaths(this.config).map((path) => readEnvFileValue(path, "CLAUDE_CODE_OAUTH_TOKEN")),
|
|
207
|
-
]) {
|
|
208
|
-
if (accessToken) candidates.push(claudeBearerCandidate(accessToken));
|
|
209
|
-
}
|
|
210
|
-
for (const credentialsPath of credentialsPaths) {
|
|
211
|
-
const accessToken = await readClaudeOAuthAccessToken(credentialsPath);
|
|
212
|
-
if (!accessToken) continue;
|
|
213
|
-
const identity = await resolveStableClaudeQuotaIdentity({ credentialsPath });
|
|
214
|
-
candidates.push({ ...(identity ?? claudeBearerCandidate(accessToken)), credentialsPath, accessToken });
|
|
215
|
-
}
|
|
216
|
-
if (candidates.length === 0) {
|
|
217
|
-
const reason = "no usable Claude bearer token on this host";
|
|
218
|
-
this.logOnce("claude:no-usable-bearer", `quota refresh skipped for claude: ${reason}`);
|
|
219
|
-
return { candidates, skipReason: reason };
|
|
220
|
-
}
|
|
221
|
-
return { candidates };
|
|
222
|
-
}
|
|
223
|
-
|
|
224
188
|
private async discoverCodexCandidates(): Promise<{ candidates: QuotaCandidate[]; skipReason?: string }> {
|
|
225
189
|
const homes = [
|
|
226
190
|
this.config.env.CODEX_HOME || process.env.CODEX_HOME || join(homedir(), ".codex"),
|
|
@@ -355,14 +319,6 @@ export class OrchestratorQuotaPoller {
|
|
|
355
319
|
}
|
|
356
320
|
|
|
357
321
|
private async collect(candidate: QuotaCandidate): Promise<ProviderQuotaSample> {
|
|
358
|
-
if (candidate.provider === "claude") {
|
|
359
|
-
return collectClaudeQuotaSample({
|
|
360
|
-
agentId: this.sourceAgentId(),
|
|
361
|
-
accessToken: candidate.accessToken,
|
|
362
|
-
credentialsPath: candidate.credentialsPath,
|
|
363
|
-
fetchImpl: this.options.fetchImpl,
|
|
364
|
-
});
|
|
365
|
-
}
|
|
366
322
|
if (candidate.provider === "codex") {
|
|
367
323
|
return collectCodexQuotaSample({
|
|
368
324
|
agentId: this.sourceAgentId(),
|
|
@@ -497,49 +453,6 @@ function providerHomeConfigDirs(provider: "claude" | "codex", markerFile: string
|
|
|
497
453
|
return dirs;
|
|
498
454
|
}
|
|
499
455
|
|
|
500
|
-
function providerHomeCredentialPaths(provider: "claude", markerFile: string): string[] {
|
|
501
|
-
return providerHomeConfigDirs(provider, markerFile).map((dir) => join(dir, markerFile));
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
function claudeSetupTokenEnvPaths(config: OrchestratorConfig): string[] {
|
|
505
|
-
const configDir = config.env.CLAUDE_CONFIG_DIR || process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
|
|
506
|
-
return [
|
|
507
|
-
join(configDir, "setup-token.env"),
|
|
508
|
-
...providerHomeConfigDirs("claude", "setup-token.env").map((dir) => join(dir, "setup-token.env")),
|
|
509
|
-
];
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
function claudeBearerCandidate(accessToken: string): QuotaCandidate {
|
|
513
|
-
return {
|
|
514
|
-
provider: "claude",
|
|
515
|
-
accountKey: credentialAccountKey(accessToken),
|
|
516
|
-
accessToken,
|
|
517
|
-
};
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
function readEnvFileValue(path: string, key: string): string | undefined {
|
|
521
|
-
try {
|
|
522
|
-
for (const line of readFileSync(path, "utf8").split(/\r?\n/)) {
|
|
523
|
-
const match = new RegExp(`^\\s*(?:export\\s+)?${key}\\s*=\\s*(.*)\\s*$`).exec(line);
|
|
524
|
-
if (!match) continue;
|
|
525
|
-
return cleanEnvValue(match[1] ?? "");
|
|
526
|
-
}
|
|
527
|
-
} catch {
|
|
528
|
-
return undefined;
|
|
529
|
-
}
|
|
530
|
-
return undefined;
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
function cleanEnvValue(value: string): string | undefined {
|
|
534
|
-
let cleaned = value.trim();
|
|
535
|
-
if (!cleaned) return undefined;
|
|
536
|
-
const quote = cleaned[0];
|
|
537
|
-
if ((quote === "'" || quote === "\"") && cleaned.endsWith(quote)) {
|
|
538
|
-
cleaned = cleaned.slice(1, -1);
|
|
539
|
-
}
|
|
540
|
-
return cleaned.trim() || undefined;
|
|
541
|
-
}
|
|
542
|
-
|
|
543
456
|
function safeReadDir(path: string): string[] {
|
|
544
457
|
try {
|
|
545
458
|
return readdirSync(path, { withFileTypes: true })
|
|
@@ -22,7 +22,7 @@ function owningRepoRoot(worktreePath: string, fallback: string): string {
|
|
|
22
22
|
return existsSync(root) ? root : fallback;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
export function cleanupWorkspace(workspace: { repoRoot?: string; worktreePath?: string; id?: string; branch?: string; deleteBranch?: boolean; workspacesRoot?: string }): { workspaceId?: string; removed: boolean; worktreePath?: string; branchDeleted?: boolean; containerRemoved?: boolean } {
|
|
25
|
+
export function cleanupWorkspace(workspace: { repoRoot?: string; worktreePath?: string; id?: string; branch?: string; baseRef?: string; baseSha?: string; deleteBranch?: boolean; workspacesRoot?: string }): { workspaceId?: string; removed: boolean; worktreePath?: string; branchDeleted?: boolean; branchPreservedReason?: string; containerRemoved?: boolean } {
|
|
26
26
|
if (!workspace.worktreePath) throw new Error("worktreePath required");
|
|
27
27
|
const path = resolve(workspace.worktreePath);
|
|
28
28
|
const recordedRepo = workspace.repoRoot ? resolve(workspace.repoRoot) : path;
|
|
@@ -38,14 +38,61 @@ export function cleanupWorkspace(workspace: { repoRoot?: string; worktreePath?:
|
|
|
38
38
|
throw new Error(result.stderr || `worktree ${path} still present after \`git worktree remove\` (repo ${repo})`);
|
|
39
39
|
}
|
|
40
40
|
// Once the worktree is gone the agent/... branch is litter — delete it so
|
|
41
|
-
// branches don't accumulate.
|
|
42
|
-
//
|
|
41
|
+
// branches don't accumulate. First prove it has no unlanded commits, because
|
|
42
|
+
// deleting the branch ref is what can make committed-but-unlanded work
|
|
43
|
+
// unreachable after a crashed worker (#614).
|
|
43
44
|
let branchDeleted = false;
|
|
45
|
+
let branchPreservedReason: string | undefined;
|
|
44
46
|
if (workspace.branch && workspace.deleteBranch !== false) {
|
|
45
|
-
|
|
47
|
+
const safety = branchSafeToDelete(repo, workspace.branch, workspace.baseRef, workspace.baseSha);
|
|
48
|
+
if (safety.safe) {
|
|
49
|
+
branchDeleted = git(["branch", "-D", workspace.branch], repo).ok;
|
|
50
|
+
if (!branchDeleted) branchPreservedReason = "branch delete failed";
|
|
51
|
+
} else {
|
|
52
|
+
branchPreservedReason = safety.reason;
|
|
53
|
+
}
|
|
46
54
|
}
|
|
47
55
|
const containerRemoved = workspace.workspacesRoot ? removeEmptyContainer(dirname(path), resolve(workspace.workspacesRoot)) : false;
|
|
48
|
-
return { workspaceId: workspace.id, removed: true, worktreePath: path, branchDeleted, containerRemoved };
|
|
56
|
+
return { workspaceId: workspace.id, removed: true, worktreePath: path, branchDeleted, ...(branchPreservedReason ? { branchPreservedReason } : {}), containerRemoved };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function branchSafeToDelete(repo: string, branch: string, baseRef?: string, baseSha?: string): { safe: boolean; reason?: string } {
|
|
60
|
+
const branchRef = resolveCommit(repo, branch) ? branch : resolveCommit(repo, `refs/heads/${branch}`) ? `refs/heads/${branch}` : undefined;
|
|
61
|
+
if (!branchRef) return { safe: true };
|
|
62
|
+
|
|
63
|
+
const base = resolveCleanupBase(repo, baseRef, baseSha);
|
|
64
|
+
if (!base) return { safe: false, reason: "base ref unavailable" };
|
|
65
|
+
|
|
66
|
+
const counts = git(["rev-list", "--left-right", "--count", `${base}...${branchRef}`], repo);
|
|
67
|
+
if (!counts.ok || !counts.stdout) return { safe: false, reason: counts.stderr || "ahead count unavailable" };
|
|
68
|
+
const ahead = Number(counts.stdout.split(/\s+/)[1]);
|
|
69
|
+
if (!Number.isFinite(ahead)) return { safe: false, reason: "ahead count unavailable" };
|
|
70
|
+
if (ahead === 0) return { safe: true };
|
|
71
|
+
|
|
72
|
+
const cherryBase = upstreamRef(repo, base) ?? base;
|
|
73
|
+
if (git(["diff", "--quiet", cherryBase, branchRef], repo).ok) return { safe: true };
|
|
74
|
+
const cherry = git(["cherry", cherryBase, branchRef], repo);
|
|
75
|
+
if (!cherry.ok) return { safe: false, reason: cherry.stderr || "unmerged commit check unavailable" };
|
|
76
|
+
const unmergedAhead = cherry.stdout ? cherry.stdout.split("\n").filter((line) => line.startsWith("+")).length : 0;
|
|
77
|
+
return unmergedAhead === 0
|
|
78
|
+
? { safe: true }
|
|
79
|
+
: { safe: false, reason: `${unmergedAhead} unlanded commit(s)` };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function resolveCleanupBase(repo: string, baseRef?: string, baseSha?: string): string | undefined {
|
|
83
|
+
for (const candidate of [baseRef, baseSha, "main", "master"]) {
|
|
84
|
+
if (candidate && resolveCommit(repo, candidate)) return candidate;
|
|
85
|
+
}
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function resolveCommit(repo: string, ref: string): boolean {
|
|
90
|
+
return git(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], repo).ok;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function upstreamRef(repo: string, base: string): string | undefined {
|
|
94
|
+
const res = git(["rev-parse", "--abbrev-ref", `${base}@{upstream}`], repo);
|
|
95
|
+
return res.ok && res.stdout ? res.stdout : undefined;
|
|
49
96
|
}
|
|
50
97
|
|
|
51
98
|
export function sweepEmptyWorkspaceContainers(wsRoot: string): string[] {
|
|
@@ -138,7 +138,7 @@ export function reconcileWorkspace(workspace: { id?: string; repoRoot?: string;
|
|
|
138
138
|
// detection can only under-report, so this never deletes unmerged work.
|
|
139
139
|
const empty = gitState.error === undefined && gitState.dirtyCount === 0 && ((gitState.ahead ?? 0) === 0 || gitState.landed === true);
|
|
140
140
|
if (empty) {
|
|
141
|
-
cleanupWorkspace({ id: workspace.id, repoRoot: workspace.repoRoot, worktreePath: workspace.worktreePath, branch: workspace.branch });
|
|
141
|
+
cleanupWorkspace({ id: workspace.id, repoRoot: workspace.repoRoot, worktreePath: workspace.worktreePath, branch: workspace.branch, baseRef: workspace.baseRef, baseSha: workspace.baseSha });
|
|
142
142
|
return { workspaceId: workspace.id, removed: true, status: "cleaned", gitState };
|
|
143
143
|
}
|
|
144
144
|
return { workspaceId: workspace.id, removed: false, status: "review_requested", gitState };
|