agents-relay 1.0.0

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 (62) hide show
  1. package/.github/workflows/publish.yml +91 -0
  2. package/AGENTS.md +16 -0
  3. package/LICENSE +21 -0
  4. package/README.md +102 -0
  5. package/dist/adapters.js +311 -0
  6. package/dist/cli.js +455 -0
  7. package/dist/continuation.js +21 -0
  8. package/dist/dashboard.js +446 -0
  9. package/dist/events.js +36 -0
  10. package/dist/github-auth.js +34 -0
  11. package/dist/github-webhook.js +47 -0
  12. package/dist/markers.js +42 -0
  13. package/dist/planner.js +172 -0
  14. package/dist/pool.js +98 -0
  15. package/dist/reconciler.js +434 -0
  16. package/dist/registry.js +27 -0
  17. package/dist/relayd.js +177 -0
  18. package/dist/scheduler.js +49 -0
  19. package/dist/store.js +586 -0
  20. package/dist/types.js +6 -0
  21. package/dist/usage.js +370 -0
  22. package/dist/workspace.js +76 -0
  23. package/docs/agent-network.md +34 -0
  24. package/docs/architecture.md +120 -0
  25. package/docs/autonomous-objective-jobs.md +121 -0
  26. package/docs/example.md +30 -0
  27. package/docs/github-app-rate-limit.md +124 -0
  28. package/docs/service.md +43 -0
  29. package/pack.json +326 -0
  30. package/package.json +14 -0
  31. package/scripts/npm-version.mjs +11 -0
  32. package/skills/agents-relay/SKILL.md +77 -0
  33. package/skills/agents-relay/agents/planner.agent.md +28 -0
  34. package/src/adapters.ts +231 -0
  35. package/src/cli.ts +324 -0
  36. package/src/continuation.ts +6 -0
  37. package/src/dashboard.ts +421 -0
  38. package/src/events.ts +25 -0
  39. package/src/github-auth.ts +35 -0
  40. package/src/github-webhook.ts +37 -0
  41. package/src/markers.ts +33 -0
  42. package/src/planner.ts +150 -0
  43. package/src/pool.ts +87 -0
  44. package/src/reconciler.ts +235 -0
  45. package/src/registry.ts +35 -0
  46. package/src/relayd.ts +137 -0
  47. package/src/scheduler.ts +27 -0
  48. package/src/store.ts +526 -0
  49. package/src/types.ts +45 -0
  50. package/src/usage.ts +385 -0
  51. package/src/workspace.ts +62 -0
  52. package/test/adapters.test.js +303 -0
  53. package/test/autonomous.test.js +119 -0
  54. package/test/core.test.js +363 -0
  55. package/test/dashboard.test.js +178 -0
  56. package/test/github-auth.test.js +51 -0
  57. package/test/github-webhook.test.js +21 -0
  58. package/test/service.test.js +116 -0
  59. package/test/store.test.js +390 -0
  60. package/test/usage.test.js +88 -0
  61. package/test/workspace.test.js +95 -0
  62. package/tsconfig.json +4 -0
package/src/planner.ts ADDED
@@ -0,0 +1,150 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { dirname, join } from 'node:path';
5
+ import { Job, Priority, Task, AdapterName, RoutingDecision } from './types.js';
6
+
7
+ export type ObjectiveStatus = 'in_progress' | 'satisfied';
8
+ export type PlannerTaskSpec = {
9
+ id: string;
10
+ input: string;
11
+ priority?: Priority;
12
+ projectName?: string;
13
+ agentName?: string;
14
+ dependencies?: string[];
15
+ capabilities?: string[];
16
+ adapter?: AdapterName;
17
+ routing?: RoutingDecision;
18
+ continuation?: Task['continuation'];
19
+ maxAttempts?: number;
20
+ timeoutMs?: number;
21
+ };
22
+ export type PlannerResult = { objective_status: ObjectiveStatus; assessment: string; next_tasks: PlannerTaskSpec[] };
23
+ export type PlannerContext = { job: Job; tasks: readonly Task[]; objective: string; previousPlannerTaskId: string | null };
24
+ export interface ObjectivePlanner { plan(context: PlannerContext, signal: AbortSignal): Promise<PlannerResult>; }
25
+
26
+ function record(value: unknown): Record<string, unknown> {
27
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Planner result must be an object');
28
+ return value as Record<string, unknown>;
29
+ }
30
+ function optionalString(value: unknown, field: string): string | undefined {
31
+ if (value === undefined || value === null) return undefined;
32
+ if (typeof value !== 'string' || value.length === 0) throw new Error(`Planner ${field} must be a non-empty string`);
33
+ return value;
34
+ }
35
+ function stringArray(value: unknown, field: string, fallback: string[] = []): string[] {
36
+ if (value === undefined) return fallback;
37
+ if (!Array.isArray(value) || value.some(item => typeof item !== 'string' || item.length === 0)) throw new Error(`Planner ${field} must be an array of non-empty strings`);
38
+ return [...value];
39
+ }
40
+ function routing(value: unknown, field: string): RoutingDecision | undefined {
41
+ if (value === undefined || value === null) return undefined;
42
+ const route = record(value);
43
+ for (const key of ['provider', 'model', 'decidedBy', 'decidedAt']) if (typeof route[key] !== 'string' || String(route[key]).length === 0) throw new Error(`Planner ${field}.${key} is required`);
44
+ return { provider: route.provider as string, model: route.model as string, profile: optionalString(route.profile, `${field}.profile`), reasoning: optionalString(route.reasoning, `${field}.reasoning`), cwd: optionalString(route.cwd, `${field}.cwd`), projectId: optionalString(route.projectId, `${field}.projectId`), decidedBy: route.decidedBy as string, decidedAt: route.decidedAt as string };
45
+ }
46
+
47
+ export function parsePlannerResult(value: unknown): PlannerResult {
48
+ const root = record(value);
49
+ const status = root.objective_status;
50
+ if (status !== 'in_progress' && status !== 'satisfied') throw new Error('Planner objective_status must be in_progress or satisfied');
51
+ if (typeof root.assessment !== 'string') throw new Error('Planner assessment must be a string');
52
+ if (!Array.isArray(root.next_tasks)) throw new Error('Planner next_tasks must be an array');
53
+ const ids = new Set<string>();
54
+ const next_tasks = root.next_tasks.map((item, index): PlannerTaskSpec => {
55
+ const task = record(item);
56
+ const id = task.id;
57
+ if (typeof id !== 'string' || id.length === 0) throw new Error(`Planner next_tasks[${index}].id is required`);
58
+ if (ids.has(id)) throw new Error(`Planner next_tasks contains duplicate id ${id}`);
59
+ ids.add(id);
60
+ if (typeof task.input !== 'string' || task.input.length === 0) throw new Error(`Planner next_tasks[${index}].input is required`);
61
+ const adapter = task.adapter === undefined ? 'shell' : task.adapter;
62
+ if (!['shell', 'codex', 'chatgpt', 'orchestrator'].includes(String(adapter))) throw new Error(`Planner next_tasks[${index}].adapter is invalid`);
63
+ if (task.priority !== undefined && !['P0', 'P1', 'P2', 'P3'].includes(String(task.priority))) throw new Error(`Planner next_tasks[${index}].priority is invalid`);
64
+ const maxAttempts = task.maxAttempts === undefined ? 3 : task.maxAttempts;
65
+ const timeoutMs = task.timeoutMs === undefined ? 300000 : task.timeoutMs;
66
+ if (typeof maxAttempts !== 'number' || !Number.isInteger(maxAttempts) || maxAttempts < 1) throw new Error(`Planner next_tasks[${index}].maxAttempts is invalid`);
67
+ if (typeof timeoutMs !== 'number' || !Number.isFinite(timeoutMs) || timeoutMs < 0) throw new Error(`Planner next_tasks[${index}].timeoutMs is invalid`);
68
+ return {
69
+ id, input: task.input, priority: task.priority as Priority | undefined,
70
+ projectName: optionalString(task.projectName, 'projectName'), agentName: optionalString(task.agentName, 'agentName'),
71
+ dependencies: stringArray(task.dependencies, 'dependencies'), capabilities: stringArray(task.capabilities, 'capabilities'),
72
+ adapter: adapter as AdapterName, routing: routing(task.routing, `next_tasks[${index}].routing`),
73
+ continuation: task.continuation as Task['continuation'], maxAttempts, timeoutMs
74
+ };
75
+ });
76
+ return { objective_status: status, assessment: root.assessment, next_tasks };
77
+ }
78
+
79
+ export function parsePlannerOutput(output: string): PlannerResult {
80
+ const trimmed = output.trim();
81
+ const candidate = trimmed.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '').trim();
82
+ return parsePlannerResult(JSON.parse(candidate) as unknown);
83
+ }
84
+
85
+ export function plannerInput(context: PlannerContext): string {
86
+ return JSON.stringify({ objective: context.objective, previous_planner_task_id: context.previousPlannerTaskId, tasks: context.tasks.map(task => ({ id: task.id, kind: task.kind ?? 'work', parent_task_id: task.parentTaskId, state: task.state, input: task.input, result: task.result, planner_result: task.plannerResult, error: task.error })) });
87
+ }
88
+
89
+ export class CommandObjectivePlanner implements ObjectivePlanner {
90
+ constructor(private readonly command: string) { if (!command) throw new Error('Planner command is required'); }
91
+ async plan(context: PlannerContext, signal: AbortSignal): Promise<PlannerResult> {
92
+ return await new Promise<PlannerResult>((resolve, reject) => {
93
+ const child = spawn(this.command, [], { stdio: ['pipe', 'pipe', 'pipe'] });
94
+ let stdout = ''; let stderr = '';
95
+ child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString(); });
96
+ child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString(); });
97
+ child.on('error', reject);
98
+ child.on('close', code => code === 0 ? (() => { try { resolve(parsePlannerOutput(stdout)); } catch (error) { reject(error); } })() : reject(new Error(stderr.trim() || `Planner exited ${code}`)));
99
+ signal.addEventListener('abort', () => child.kill('SIGTERM'), { once: true });
100
+ child.stdin.end(plannerInput(context));
101
+ });
102
+ }
103
+ }
104
+
105
+
106
+ const DEFAULT_PLANNER_AGENT = join(dirname(fileURLToPath(import.meta.url)), '..', 'skills', 'agents-relay', 'agents', 'planner.agent.md');
107
+
108
+ function parsePlannerAgentMessage(line: string): string | undefined {
109
+ try {
110
+ const value = JSON.parse(line) as Record<string, unknown>;
111
+ const item = value.item as Record<string, unknown> | undefined;
112
+ if (value.type === 'item.completed' && item?.type === 'agent_message' && typeof item.text === 'string') return item.text.trim() || undefined;
113
+ const payload = value.payload as Record<string, unknown> | undefined;
114
+ if (value.type === 'event_msg' && payload?.type === 'task_complete' && typeof payload.last_agent_message === 'string') return payload.last_agent_message.trim() || undefined;
115
+ } catch { /* ignore non-JSON runtime diagnostics */ }
116
+ return undefined;
117
+ }
118
+
119
+ export class AgentObjectivePlanner implements ObjectivePlanner {
120
+ constructor(private readonly command = 'codex', private readonly agentFile = DEFAULT_PLANNER_AGENT) {}
121
+ async plan(context: PlannerContext, signal: AbortSignal): Promise<PlannerResult> {
122
+ const instructions = await readFile(this.agentFile, 'utf8');
123
+ const prompt = instructions.trim() + '\n\n## Durable planner input\n\n' + plannerInput(context);
124
+ return await new Promise<PlannerResult>((resolve, reject) => {
125
+ const child = spawn(this.command, ['exec', '--json', '--', prompt], { stdio: ['ignore', 'pipe', 'pipe'] });
126
+ let stdoutBuffer = '';
127
+ let finalMessage = '';
128
+ let stderrTail = '';
129
+ const consume = (line: string): void => {
130
+ const message = parsePlannerAgentMessage(line);
131
+ if (message) finalMessage = message;
132
+ };
133
+ child.stdout.on('data', (chunk: Buffer) => {
134
+ stdoutBuffer += chunk.toString();
135
+ const lines = stdoutBuffer.split(/\r?\n/);
136
+ stdoutBuffer = lines.pop() ?? '';
137
+ for (const line of lines) consume(line);
138
+ });
139
+ child.stderr.on('data', (chunk: Buffer) => { stderrTail = (stderrTail + chunk.toString()).slice(-16384); });
140
+ child.on('error', reject);
141
+ child.on('close', code => {
142
+ if (stdoutBuffer) consume(stdoutBuffer);
143
+ if (code !== 0) { reject(new Error(stderrTail.trim() || 'Planner agent runtime exited ' + code)); return; }
144
+ if (!finalMessage) { reject(new Error('Planner agent returned no final message')); return; }
145
+ try { resolve(parsePlannerOutput(finalMessage)); } catch (error) { reject(error); }
146
+ });
147
+ signal.addEventListener('abort', () => child.kill('SIGTERM'), { once: true });
148
+ });
149
+ }
150
+ }
package/src/pool.ts ADDED
@@ -0,0 +1,87 @@
1
+ import { GitHubClient, GitHubStore, ManagedJobOverview, listManagedGitHubJobs } from './store.js';
2
+ import { EventBus, eventFor } from './events.js';
3
+ import { Reconciler } from './reconciler.js';
4
+ import { Job, Priority } from './types.js';
5
+
6
+ const RANK: Record<Priority, number> = { P0: 0, P1: 1, P2: 2, P3: 3 };
7
+ export function compareJobPriority(a: Job, b: Job): number { return RANK[a.priority ?? 'P2'] - RANK[b.priority ?? 'P2'] || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id); }
8
+ export function runnableManagedJobs(items: ManagedJobOverview[]): Job[] { return items.filter(item => item.githubState === 'OPEN' && !item.draft && !['COMPLETED','CANCELLED'].includes(item.job.state)).map(item => item.job).sort(compareJobPriority); }
9
+ export function needsLifecycleReconciliation(job: Job, now = Date.now()): boolean {
10
+ if (job.tasks.some(task => ['READY','QUEUED','WAITING'].includes(task.state))) return true;
11
+ return job.tasks.some(task => task.state === 'RUNNING' && task.leaseExpiresAt !== null && new Date(task.leaseExpiresAt).getTime() <= now);
12
+ }
13
+ export function terminalManagedJobs(items: ManagedJobOverview[]): Job[] { return items.filter(item => item.githubState !== 'OPEN' && ((item.githubState === 'MERGED' && item.job.state !== 'COMPLETED') || (item.githubState === 'CLOSED' && item.job.state !== 'CANCELLED'))).map(item => item.job).sort(compareJobPriority); }
14
+ export async function aggregateManagedGitHubJobs(client: GitHubClient, repositories: readonly string[], trustedAuthors: ReadonlySet<string>): Promise<ManagedJobOverview[]> {
15
+ const uniqueRepositories = [...new Set(repositories)];
16
+ const groups = await Promise.all(uniqueRepositories.map(repository => listManagedGitHubJobs(client, repository, trustedAuthors)));
17
+ return groups.flat().sort((a, b) => b.job.updatedAt.localeCompare(a.job.updatedAt) || b.job.prNumber - a.job.prNumber);
18
+ }
19
+
20
+ export class RepositoryWorkerPool {
21
+ private running = false;
22
+ private pending = false;
23
+ constructor(
24
+ private readonly client: GitHubClient,
25
+ repository: string | readonly string[],
26
+ private readonly trustedAuthors: ReadonlySet<string>,
27
+ private readonly concurrency: number,
28
+ private readonly makeReconciler: (store: GitHubStore, maxConcurrent: number) => Reconciler,
29
+ ) { this.repositories = [...new Set(typeof repository === 'string' ? [repository] : repository)]; }
30
+ private readonly repositories: string[];
31
+ private async reconcileDiscovered(discovered: ManagedJobOverview[]): Promise<void> {
32
+ // Terminal PRs must be reconciled before OPEN-job scheduling. Otherwise the
33
+ // OPEN-only runnable filter can strand durable jobs after GitHub merges/closes them.
34
+ for (const job of terminalManagedJobs(discovered)) {
35
+ const store = new GitHubStore(this.client, job.repository, job.prNumber, this.trustedAuthors);
36
+ await this.makeReconciler(store, 0).reconcile(job.id);
37
+ }
38
+ const jobs = runnableManagedJobs(discovered);
39
+ let capacity = Math.max(1, this.concurrency);
40
+ for (const job of jobs) {
41
+ if (capacity <= 0) break;
42
+ const store = new GitHubStore(this.client, job.repository, job.prNumber, this.trustedAuthors);
43
+ const ready = job.tasks.filter(task => ['READY','QUEUED','WAITING'].includes(task.state)).length;
44
+ const expiredRunning = job.tasks.some(task => task.state === 'RUNNING' && task.leaseExpiresAt !== null && new Date(task.leaseExpiresAt).getTime() <= Date.now());
45
+ if (!needsLifecycleReconciliation(job) && job.tasks.length > 0) continue;
46
+ const allowance = Math.max(1, Math.min(capacity, ready || (expiredRunning ? 1 : 0) || 1));
47
+ await this.makeReconciler(store, allowance).reconcile(job.id);
48
+ capacity -= allowance;
49
+ }
50
+ }
51
+ async reconcileKnown(discovered: ManagedJobOverview[]): Promise<void> { await this.reconcileDiscovered(discovered); }
52
+ async reconcile(): Promise<void> {
53
+ if (this.running) { this.pending = true; return; }
54
+ this.running = true;
55
+ try {
56
+ do {
57
+ this.pending = false;
58
+ await this.reconcileDiscovered(await aggregateManagedGitHubJobs(this.client, this.repositories, this.trustedAuthors));
59
+ } while (this.pending);
60
+ } finally { this.running = false; }
61
+ }
62
+ async reconcileTarget(repository: string, prNumber: number, jobId: string): Promise<void> {
63
+ if (!this.repositories.includes(repository)) return;
64
+ const store = new GitHubStore(this.client, repository, prNumber, this.trustedAuthors);
65
+ await this.makeReconciler(store, Math.max(1, this.concurrency)).reconcile(jobId);
66
+ }
67
+ async watch(bus: EventBus): Promise<() => Promise<void>> {
68
+ return bus.subscribeAll(async event => {
69
+ if (event.type === 'job.wake' || event.type === 'github.webhook') {
70
+ const repository = typeof event.data?.repository === 'string' ? event.data.repository : '';
71
+ const prNumber = Number(event.data?.prNumber);
72
+ if (repository && Number.isInteger(prNumber) && prNumber > 0) {
73
+ await this.reconcileTarget(repository, prNumber, event.job_id);
74
+ return;
75
+ }
76
+ }
77
+ if (event.type === 'job.wake' || event.type === 'github.webhook') await this.reconcile();
78
+ });
79
+ }
80
+ async webhookWake(bus: EventBus | undefined, job: Job, deliveryId: string, event: string, action: string | null): Promise<void> {
81
+ if (bus) {
82
+ await bus.publish(eventFor(job.id, 'github', null, 'github.webhook', 'queued', `GitHub webhook ${event}${action ? ':' + action : ''}`, 'orchestrator', { deliveryId, event, action, repository: job.repository, prNumber: job.prNumber }));
83
+ return;
84
+ }
85
+ await this.reconcileTarget(job.repository, job.prNumber, job.id);
86
+ }
87
+ }
@@ -0,0 +1,235 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { DurableStore } from './store.js';
3
+ import { Job, Task, EventRecord, Priority } from './types.js';
4
+ import { schedule, transitionTask } from './scheduler.js';
5
+ import { Execution, WorkerAdapter } from './adapters.js';
6
+ import { ContinuationAdapter } from './continuation.js';
7
+ import { EventBus, eventFor } from './events.js';
8
+ import { ObjectivePlanner, parsePlannerResult, plannerInput, PlannerTaskSpec } from './planner.js';
9
+
10
+ const PRIORITY_RANK: Record<Priority, number> = { P0: 0, P1: 1, P2: 2, P3: 3 };
11
+ export function compareTaskPriority(a: Task, b: Task, jobPriority: Priority = 'P2'): number { const ar = PRIORITY_RANK[a.priority ?? jobPriority]; const br = PRIORITY_RANK[b.priority ?? jobPriority]; return ar - br || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id); }
12
+
13
+ export type ReconcileOptions = { owner: string; now?: Date; leaseMs?: number; maxConcurrent?: number; plannerTimeoutMs?: number; adapters: WorkerAdapter[]; planner?: ObjectivePlanner; continuations?: ContinuationAdapter[]; eventBus?: EventBus; emit?: (event: EventRecord) => void | Promise<void> };
14
+ type LiveExecution = { taskId: string; execution: Execution; controller: AbortController; timer: NodeJS.Timeout };
15
+ type LivePlanner = { executionId: string; controller: AbortController; timer: NodeJS.Timeout };
16
+
17
+ export function managedWorkerInput(job: Job, task: Task): string {
18
+ if (!['codex', 'chatgpt'].includes(task.adapter) || !job.repository || job.prNumber <= 0) return task.input;
19
+ const prUrl = `https://github.com/${job.repository}/pull/${job.prNumber}`;
20
+ return `[Agents Relay managed task]\nPR: ${prUrl}\nJob: ${job.id}\nTask: ${task.id}\nParent task: ${task.parentTaskId ?? 'none'}\nProject: ${task.projectName ?? 'unspecified'}\n\n${task.input}`;
21
+ }
22
+
23
+ export class Reconciler {
24
+ private readonly live = new Map<string, LiveExecution>();
25
+ private readonly plannerLive = new Map<string, LivePlanner>();
26
+ private readonly continuationLive = new Set<string>();
27
+ private reconciling = false;
28
+ constructor(private readonly store: DurableStore, private readonly options: ReconcileOptions) {}
29
+ async watch(jobId: string): Promise<() => Promise<void>> { if (!this.options.eventBus) return async () => {}; try { return await this.options.eventBus.subscribe(jobId, async event => { if (event.type !== 'job.wake' && event.type !== 'github.webhook') return; await this.reconcile(jobId); }); } catch (error) { await this.reportTransportFailure(error); return async () => {}; } }
30
+ async idle(): Promise<void> { while (this.live.size > 0 || this.plannerLive.size > 0 || this.continuationLive.size > 0 || this.reconciling) await new Promise<void>(resolve => setTimeout(resolve, 25)); }
31
+ async wake(jobId: string): Promise<Job> { return this.reconcile(jobId); }
32
+ async reconcile(jobId: string): Promise<Job> {
33
+ if (this.reconciling) return this.store.load(jobId);
34
+ this.reconciling = true;
35
+ try {
36
+ const job = await this.store.load(jobId);
37
+ const previousState = job.state;
38
+ const prState = await this.store.pullRequestState?.() ?? 'OPEN';
39
+ const now = (this.options.now ?? new Date()).getTime();
40
+ await this.recoverFinishedWorkers(job);
41
+ if (prState !== 'OPEN') {
42
+ const reason = prState === 'MERGED' ? 'Pull request merged' : 'Pull request closed';
43
+ for (const task of job.tasks.filter(item => !['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(item.state))) {
44
+ this.live.get(task.id)?.execution.cancel(); this.plannerLive.get(task.id)?.controller.abort();
45
+ transitionTask(task, 'CANCELLED'); task.error = reason; task.leaseOwner = null; task.leaseExpiresAt = null; await this.store.saveTask(task);
46
+ }
47
+ }
48
+ await this.recoverLostExecutions(job, now);
49
+ for (const task of job.tasks) if (task.state === 'CANCELLED') { this.live.get(task.id)?.execution.cancel(); this.plannerLive.get(task.id)?.controller.abort(); }
50
+ let scheduled = schedule(await this.store.load(jobId));
51
+ if (prState === 'OPEN' && scheduled.executionMode === 'autonomous') {
52
+ scheduled = schedule(await this.maybeCreatePlanner(scheduled));
53
+ }
54
+ if (prState === 'MERGED') { scheduled.state = 'COMPLETED'; scheduled.updatedAt = new Date().toISOString(); }
55
+ if (prState === 'CLOSED') { scheduled.state = 'CANCELLED'; scheduled.updatedAt = new Date().toISOString(); }
56
+ if (scheduled.state !== previousState) await this.store.saveJob(scheduled);
57
+ if (prState === 'OPEN') {
58
+ for (const task of scheduled.tasks.filter(item => item.state === 'SUCCEEDED' && (item.continuation || scheduled.continuation) && !item.continuationDeliveredAt)) this.detach(this.deliverContinuation(scheduled, task), scheduled.id, task.id, 'continuation');
59
+ const capacity = Math.max(0, (this.options.maxConcurrent ?? 4) - this.live.size - this.plannerLive.size);
60
+ for (const task of scheduled.tasks.filter(t => t.state === 'READY').sort((a, b) => compareTaskPriority(a, b, scheduled.priority ?? 'P2')).slice(0, capacity)) {
61
+ if (task.kind === 'planner') await this.launchPlanner(scheduled, task);
62
+ else await this.launch(scheduled, task);
63
+ }
64
+ }
65
+ const finalState = schedule(await this.store.load(jobId));
66
+ if (prState === 'MERGED') { finalState.state = 'COMPLETED'; finalState.updatedAt = new Date().toISOString(); }
67
+ if (prState === 'CLOSED') { finalState.state = 'CANCELLED'; finalState.updatedAt = new Date().toISOString(); }
68
+ if (finalState.state !== scheduled.state || finalState.updatedAt !== scheduled.updatedAt) await this.store.saveJob(finalState);
69
+ if (finalState.state === 'COMPLETED' || prState !== 'OPEN') await this.cleanupThreads(finalState);
70
+ return this.store.load(jobId);
71
+ } finally { this.reconciling = false; }
72
+ }
73
+ private async recoverLostExecutions(job: Job, now: number): Promise<void> {
74
+ for (const task of job.tasks.filter(item => item.state === 'RUNNING')) {
75
+ const expired = task.leaseExpiresAt !== null && new Date(task.leaseExpiresAt).getTime() <= now;
76
+ const ownerLost = task.leaseOwner !== null && task.leaseOwner !== this.options.owner;
77
+ if (!expired && !ownerLost) continue;
78
+ this.live.get(task.id)?.execution.cancel();
79
+ const planner = this.plannerLive.get(task.id);
80
+ if (planner) { clearTimeout(planner.timer); planner.controller.abort(); this.plannerLive.delete(task.id); }
81
+ if (task.attempt < task.maxAttempts) {
82
+ transitionTask(task, 'READY'); task.leaseOwner = null; task.leaseExpiresAt = null; await this.store.saveTask(task);
83
+ } else {
84
+ transitionTask(task, 'FAILED'); task.error = expired ? 'Lease expired after maximum attempts' : 'Execution owner restarted after maximum attempts'; task.leaseOwner = null; task.leaseExpiresAt = null; await this.store.saveTask(task);
85
+ }
86
+ }
87
+ }
88
+ private async maybeCreatePlanner(job: Job): Promise<Job> {
89
+ if (job.executionMode !== 'autonomous') return job;
90
+ const planners = job.tasks.filter(task => task.kind === 'planner').sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
91
+ if (planners.some(task => ['QUEUED', 'READY', 'RUNNING', 'WAITING'].includes(task.state))) return job;
92
+ if (job.tasks.some(task => task.state === 'FAILED' || task.state === 'BLOCKED')) return job;
93
+ if (job.tasks.some(task => task.kind !== 'planner' && !['SUCCEEDED', 'CANCELLED'].includes(task.state))) return job;
94
+ const latest = planners.at(-1);
95
+ if (latest?.plannerResult?.objective_status === 'satisfied') return job;
96
+ const used = new Set(job.tasks.map(task => task.id));
97
+ let number = planners.length + 1;
98
+ let id = `planner-${number}`;
99
+ while (used.has(id)) { number += 1; id = `planner-${number}`; }
100
+ const now = new Date().toISOString();
101
+ const task: Task = { jobId: job.id, id, kind: 'planner', agentName: 'planner', parentTaskId: latest?.id ?? null, dependencies: [], capabilities: ['planner'], adapter: 'orchestrator', input: plannerInput({ job, tasks: job.tasks, objective: job.objective ?? job.description ?? job.title, previousPlannerTaskId: latest?.id ?? null }), continuation: null, continuationDeliveredAt: null, state: 'QUEUED', attempt: 0, maxAttempts: 3, leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, plannerResult: null, error: null, timeoutMs: this.options.plannerTimeoutMs ?? 300000, createdAt: now, updatedAt: now };
102
+ await this.store.appendTask(task);
103
+ await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'planner.created', 'queued', `Planner task ${task.id} created`, 'orchestrator'));
104
+ return this.store.load(job.id);
105
+ }
106
+ private async recoverFinishedWorkers(job: Job): Promise<void> {
107
+ for (const task of job.tasks.filter(item => item.state === 'RUNNING' && !this.live.has(item.id) && item.threadId)) {
108
+ const adapter = this.options.adapters.find(candidate => candidate.name === task.adapter);
109
+ if (!adapter?.recover) continue;
110
+ let result: Task['result'] | null = null;
111
+ try { result = await adapter.recover(task); } catch { result = null; }
112
+ if (!result) continue;
113
+ task.result = result; task.error = null; task.leaseOwner = null; task.leaseExpiresAt = null;
114
+ transitionTask(task, 'SUCCEEDED');
115
+ await this.store.saveTask(task);
116
+ await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'task.recovered', 'succeeded', `Recovered completed task ${task.id} from durable worker thread`, 'orchestrator', { threadId: task.threadId }));
117
+ }
118
+ }
119
+ private async launch(job: Job, task: Task): Promise<void> {
120
+ const adapter = this.options.adapters.find(x => x.name === task.adapter);
121
+ if (!adapter) { transitionTask(task, 'FAILED'); task.error = `No adapter ${task.adapter}`; await this.store.saveTask(task); return; }
122
+ const leaseMs = Math.max(this.options.leaseMs ?? 300000, task.timeoutMs); const startedAt = (this.options.now ?? new Date()).getTime();
123
+ transitionTask(task, 'RUNNING'); task.attempt += 1; task.leaseOwner = this.options.owner; task.leaseExpiresAt = new Date(startedAt + leaseMs).toISOString(); task.executionId = randomUUID(); await this.store.saveTask(task);
124
+ await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'task.started', 'running', `Task ${task.id} started`, 'user', { adapter: adapter.id, model: task.routing?.model }));
125
+ const controller = new AbortController(); let execution: Execution;
126
+ const workerTask = { ...task, input: managedWorkerInput(job, task) };
127
+ try { execution = adapter.launch(workerTask, controller.signal); } catch (error) { await this.finish(job.id, task.id, task.executionId, null, error instanceof Error ? error.message : String(error)); return; }
128
+ // A worker may reject before durable execution metadata finishes saving. Attach a guard immediately so Node never treats that race as an unhandled rejection; the durable completion handler below still records the outcome.
129
+ void execution.promise.catch(() => undefined);
130
+ task.executionId = execution.id; await this.store.saveTask(task);
131
+ let threadPersistence = Promise.resolve();
132
+ const persistThread = (threadId: string): void => {
133
+ threadPersistence = threadPersistence.then(() => this.persistThread(job.id, task.id, execution.id, threadId));
134
+ };
135
+ execution.onThreadStarted = persistThread;
136
+ if (execution.threadId) persistThread(execution.threadId);
137
+ const timer = setTimeout(() => { controller.abort(); execution.cancel(); }, task.timeoutMs);
138
+ this.live.set(task.id, { taskId: task.id, execution, controller, timer });
139
+ this.detach(execution.promise.then(
140
+ async result => { await threadPersistence; await this.finish(job.id, task.id, execution.id, result, null); },
141
+ async error => { await threadPersistence; await this.finish(job.id, task.id, execution.id, null, error instanceof Error ? error.message : String(error)); },
142
+ ), job.id, task.id, 'worker completion');
143
+ }
144
+ private async launchPlanner(job: Job, task: Task): Promise<void> {
145
+ const planner = this.options.planner;
146
+ if (!planner) { task.attempt = task.maxAttempts; task.error = 'No objective planner configured'; transitionTask(task, 'FAILED'); await this.store.saveTask(task); await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'planner.failed', 'failed', `Planner ${task.id}: ${task.error}`, 'user')); return; }
147
+ const startedAt = (this.options.now ?? new Date()).getTime();
148
+ transitionTask(task, 'RUNNING'); task.attempt += 1; task.leaseOwner = this.options.owner; task.leaseExpiresAt = new Date(startedAt + Math.max(this.options.leaseMs ?? 300000, task.timeoutMs)).toISOString(); task.executionId = randomUUID();
149
+ await this.store.saveTask(task);
150
+ await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'planner.started', 'running', `Planner task ${task.id} started`, 'orchestrator'));
151
+ const controller = new AbortController(); const executionId = task.executionId;
152
+ const timer = setTimeout(() => controller.abort(), task.timeoutMs);
153
+ this.plannerLive.set(task.id, { executionId: executionId as string, controller, timer });
154
+ let promise: Promise<import('./planner.js').PlannerResult>;
155
+ try { promise = planner.plan({ job, tasks: job.tasks, objective: job.objective ?? job.description ?? job.title, previousPlannerTaskId: task.parentTaskId }, controller.signal); }
156
+ catch (error) { await this.finishPlanner(job.id, task.id, executionId, error instanceof Error ? error : new Error(String(error))); return; }
157
+ this.detach(promise.then(result => this.finishPlanner(job.id, task.id, executionId, null, result), error => this.finishPlanner(job.id, task.id, executionId, error instanceof Error ? error : new Error(String(error)))), job.id, task.id, 'planner completion');
158
+ }
159
+ private async finishPlanner(jobId: string, taskId: string, executionId: string | null, error: Error | null, raw?: import('./planner.js').PlannerResult): Promise<void> {
160
+ const live = this.plannerLive.get(taskId);
161
+ if (live?.executionId === executionId) { clearTimeout(live.timer); this.plannerLive.delete(taskId); }
162
+ const job = await this.store.load(jobId); const task = job.tasks.find(item => item.id === taskId);
163
+ if (!task || (executionId !== null && task.executionId !== executionId)) return;
164
+ if (task.state === 'CANCELLED') return;
165
+ if (error) {
166
+ task.error = error.message; task.leaseOwner = null; task.leaseExpiresAt = null; transitionTask(task, task.attempt < task.maxAttempts ? 'READY' : 'FAILED'); await this.store.saveTask(task);
167
+ await this.emit(eventFor(job.id, task.id, task.parentTaskId, task.state === 'FAILED' ? 'planner.failed' : 'planner.retry', task.state === 'FAILED' ? 'failed' : 'queued', `Planner ${task.id}: ${error.message}`, 'user')); this.detach(this.reconcile(jobId), jobId, task.id, 'planner retry reconciliation'); return;
168
+ }
169
+ try {
170
+ const result = parsePlannerResult(raw);
171
+ for (const spec of result.next_tasks) {
172
+ const child = this.plannerChild(job, task, spec);
173
+ const existing = job.tasks.find(item => item.id === child.id);
174
+ if (!existing) await this.store.appendTask(child);
175
+ else if (!samePlannerChildDefinition(existing, child)) throw new Error(`Planner task ${child.id} already exists with a different definition`);
176
+ }
177
+ task.plannerResult = result; task.result = { summary: result.assessment, data: { objective_status: result.objective_status, next_tasks: result.next_tasks } }; task.error = null; task.leaseOwner = null; task.leaseExpiresAt = null; transitionTask(task, 'SUCCEEDED'); await this.store.saveTask(task);
178
+ await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'planner.completed', 'succeeded', `Planner ${task.id} completed: ${result.objective_status}`, 'orchestrator', { objective_status: result.objective_status }));
179
+ } catch (caught) {
180
+ const message = caught instanceof Error ? caught.message : String(caught); task.error = message; task.leaseOwner = null; task.leaseExpiresAt = null; transitionTask(task, task.attempt < task.maxAttempts ? 'READY' : 'FAILED'); await this.store.saveTask(task); await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'planner.failed', 'failed', `Planner ${task.id}: ${message}`, 'user'));
181
+ }
182
+ this.detach(this.reconcile(jobId), jobId, task.id, 'planner follow-up reconciliation');
183
+ }
184
+ private plannerChild(job: Job, planner: Task, spec: PlannerTaskSpec): Task {
185
+ const now = new Date().toISOString();
186
+ return { jobId: job.id, id: spec.id, kind: 'work', priority: spec.priority ?? job.priority, projectName: spec.projectName, agentName: spec.agentName, parentTaskId: planner.id, dependencies: spec.dependencies ?? [], capabilities: spec.capabilities ?? [], adapter: spec.adapter ?? 'shell', input: spec.input, routing: spec.routing, continuation: spec.continuation ?? null, continuationDeliveredAt: null, state: 'QUEUED', attempt: 0, maxAttempts: spec.maxAttempts ?? 3, leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, plannerResult: null, error: null, timeoutMs: spec.timeoutMs ?? 300000, createdAt: now, updatedAt: now };
187
+ }
188
+ private async persistThread(jobId: string, taskId: string, executionId: string, threadId: string): Promise<void> { const job = await this.store.load(jobId); const task = job.tasks.find(item => item.id === taskId); if (task?.executionId === executionId && task.state === 'RUNNING') { task.threadId = threadId; task.threadDeletedAt = null; task.threadCleanupError = null; await this.store.saveTask(task); await this.emit(eventFor(jobId, taskId, task.parentTaskId, 'thread.started', 'running', `Worker thread ${threadId} started`, 'orchestrator', { threadId })); } }
189
+ private async finish(jobId: string, taskId: string, executionId: string | null, result: Task['result'], error: string | null): Promise<void> {
190
+ const live = this.live.get(taskId); if (live?.execution.id === executionId) { clearTimeout(live.timer); this.live.delete(taskId); }
191
+ const job = await this.store.load(jobId); const task = job.tasks.find(item => item.id === taskId); if (!task || task.executionId !== executionId) return;
192
+ if (task.state === 'CANCELLED') { await this.store.saveTask(task); return; }
193
+ if (error === null) { task.result = result; task.error = null; task.leaseOwner = null; task.leaseExpiresAt = null; transitionTask(task, 'SUCCEEDED'); await this.store.saveTask(task); await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'task.completed', 'succeeded', `Task ${task.id} completed`)); await this.deliverContinuation(job, task); }
194
+ else { task.error = error; task.leaseOwner = null; task.leaseExpiresAt = null; transitionTask(task, task.attempt < task.maxAttempts ? 'READY' : 'FAILED'); await this.store.saveTask(task); await this.emit(eventFor(job.id, task.id, task.parentTaskId, task.state === 'FAILED' ? 'task.failed' : 'task.retry', task.state === 'FAILED' ? 'failed' : 'queued', `Task ${task.id}: ${error}`, task.state === 'FAILED' ? 'user' : 'orchestrator')); }
195
+ this.detach(this.reconcile(jobId), jobId, task.id, 'worker follow-up reconciliation');
196
+ }
197
+ private async cleanupThreads(job: Job): Promise<void> {
198
+ const adapter = this.options.adapters.find(candidate => candidate.name === 'chatgpt' && candidate.deleteThread);
199
+ if (!adapter?.deleteThread) return;
200
+ for (const task of job.tasks.filter(item => item.adapter === 'chatgpt' && item.threadId && !item.threadDeletedAt)) {
201
+ try {
202
+ await adapter.deleteThread(task.threadId as string);
203
+ task.threadDeletedAt = new Date().toISOString(); task.threadCleanupError = null; await this.store.saveTask(task);
204
+ await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'thread.deleted', 'succeeded', `Worker thread ${task.threadId} deleted`, 'orchestrator', { threadId: task.threadId }));
205
+ } catch (error) {
206
+ task.threadCleanupError = error instanceof Error ? error.message : String(error); await this.store.saveTask(task);
207
+ await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'thread.delete.failed', 'failed', `Worker thread cleanup failed: ${task.threadCleanupError}`, 'orchestrator', { threadId: task.threadId }));
208
+ }
209
+ }
210
+ }
211
+ private async deliverContinuation(job: Job, task: Task): Promise<void> {
212
+ const continuation = task.continuation ?? job.continuation;
213
+ if (!continuation || task.continuationDeliveredAt || this.continuationLive.has(task.id)) return;
214
+ const adapter = this.options.continuations?.find(candidate => candidate.kind === continuation.kind);
215
+ if (!adapter) { task.error = `No continuation adapter for ${continuation.kind}`; await this.store.saveTask(task); return; }
216
+ this.continuationLive.add(task.id);
217
+ try { await adapter.continue(task.result?.summary ?? '', continuation); const fresh = await this.store.load(job.id); const durable = fresh.tasks.find(item => item.id === task.id); if (durable?.state === 'SUCCEEDED' && !durable.continuationDeliveredAt) { durable.continuationDeliveredAt = new Date().toISOString(); await this.store.saveTask(durable); } }
218
+ catch (error) { await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'continuation.failed', 'failed', `Continuation failed: ${error instanceof Error ? error.message : String(error)}`, 'user')); }
219
+ finally { this.continuationLive.delete(task.id); }
220
+ }
221
+ private async emit(event: EventRecord): Promise<void> { try { if (this.options.emit) await this.options.emit(event); } catch { /* observability cannot block durable execution */ } try { if (this.options.eventBus) await this.options.eventBus.publish(event); } catch (error) { await this.reportTransportFailure(error); } }
222
+ private detach(promise: Promise<unknown>, jobId: string, taskId: string, context: string): void {
223
+ void promise.catch(error => this.reportDetachedFailure(jobId, taskId, context, error));
224
+ }
225
+ private async reportDetachedFailure(jobId: string, taskId: string, context: string, error: unknown): Promise<void> {
226
+ if (!this.options.emit) return;
227
+ const message = error instanceof Error ? error.message : String(error);
228
+ try { await this.options.emit(eventFor(jobId, taskId, null, 'runtime.persistence.failed', 'failed', `${context} failed: ${message}`, 'orchestrator')); } catch { /* detached failure reporting must never crash the daemon */ }
229
+ }
230
+ private async reportTransportFailure(error: unknown): Promise<void> { if (this.options.emit) { try { await this.options.emit(eventFor('system', 'event-bus', null, 'event.transport.failed', 'failed', `Event transport unavailable: ${error instanceof Error ? error.message : String(error)}`, 'orchestrator')); } catch { /* reporting is best effort */ } } }
231
+ }
232
+
233
+ function samePlannerChildDefinition(left: Task, right: Task): boolean {
234
+ return JSON.stringify({ id: left.id, input: left.input, priority: left.priority, projectName: left.projectName, agentName: left.agentName, dependencies: left.dependencies, capabilities: left.capabilities, adapter: left.adapter, routing: left.routing, continuation: left.continuation ?? null, maxAttempts: left.maxAttempts, timeoutMs: left.timeoutMs }) === JSON.stringify({ id: right.id, input: right.input, priority: right.priority, projectName: right.projectName, agentName: right.agentName, dependencies: right.dependencies, capabilities: right.capabilities, adapter: right.adapter, routing: right.routing, continuation: right.continuation ?? null, maxAttempts: right.maxAttempts, timeoutMs: right.timeoutMs });
235
+ }
@@ -0,0 +1,35 @@
1
+ import { AgentAvailability, AgentRegistration } from './types.js';
2
+
3
+ export type DiscoveryQuery = {
4
+ capabilities?: string[];
5
+ runtime?: string;
6
+ availability?: AgentAvailability[];
7
+ labels?: Record<string, string>;
8
+ trustDomain?: string;
9
+ taskKind?: string;
10
+ explore?: boolean;
11
+ };
12
+ export type AgentCandidate = { agent: AgentRegistration; reasons: string[]; evidence: AgentRegistration['evidence']; exploratory: boolean };
13
+
14
+ function hasAll(values: string[], required: string[]): boolean { return required.every(value => values.includes(value)); }
15
+ function matches(agent: AgentRegistration, query: DiscoveryQuery): boolean {
16
+ if (query.capabilities && !hasAll(agent.capabilities, query.capabilities)) return false;
17
+ if (query.runtime && agent.runtime.adapter !== query.runtime) return false;
18
+ if (query.availability && !query.availability.includes(agent.availability)) return false;
19
+ if (query.trustDomain && !agent.routing.trustDomains?.includes(query.trustDomain)) return false;
20
+ return !query.labels || Object.entries(query.labels).every(([key, value]) => agent.routing.labels?.[key] === value);
21
+ }
22
+ function reliability(agent: AgentRegistration): number { const { succeeded, failed, timedOut } = agent.evidence.outcomes; const total = succeeded + failed + timedOut; return total === 0 ? 0 : succeeded / total; }
23
+
24
+ export function discoverAgents(agents: AgentRegistration[], query: DiscoveryQuery = {}): AgentCandidate[] {
25
+ const candidates = agents.filter(agent => matches(agent, query)).map(agent => {
26
+ const evaluated = query.taskKind ? agent.evidence.evaluations.some(item => item.taskKind === query.taskKind) : agent.evidence.evaluations.length > 0;
27
+ const reasons = [query.capabilities?.length ? `capabilities: ${query.capabilities.join(', ')}` : 'capabilities matched', evaluated ? 'observed evidence available' : 'no observed evidence yet'];
28
+ return { agent, reasons, evidence: agent.evidence, exploratory: !evaluated };
29
+ });
30
+ return candidates.sort((left, right) => {
31
+ if (query.explore && left.exploratory !== right.exploratory) return left.exploratory ? -1 : 1;
32
+ if (left.agent.availability !== right.agent.availability) return left.agent.availability === 'available' ? -1 : 1;
33
+ return reliability(right.agent) - reliability(left.agent);
34
+ });
35
+ }