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
@@ -0,0 +1,303 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp, rm, writeFile, chmod } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { tmpdir } from 'node:os';
6
+ import { buildCodexArgs, CodexAdapter } from '../dist/adapters.js';
7
+
8
+ test('buildCodexArgs applies provider, model, reasoning, cwd, and profile in order', () => {
9
+ const args = buildCodexArgs({
10
+ provider: 'zai',
11
+ model: 'glm-5.3-flash',
12
+ reasoning: 'medium',
13
+ cwd: '/workspace/project',
14
+ profile: 'relay',
15
+ }, 'inspect runtime');
16
+ assert.deepEqual(args, [
17
+ 'exec',
18
+ '--json',
19
+ '-p', 'relay',
20
+ '-m', 'glm-5.3-flash',
21
+ '-c', 'model_provider=zai',
22
+ '-c', 'model_reasoning_effort=medium',
23
+ '-C', '/workspace/project',
24
+ '--', 'inspect runtime',
25
+ ]);
26
+ });
27
+
28
+ test('buildCodexArgs omits absent optional routing fields', () => {
29
+ const args = buildCodexArgs({ provider: 'zai', model: 'glm-5.3-flash' }, 'prompt');
30
+ assert.deepEqual(args, [
31
+ 'exec',
32
+ '--json',
33
+ '-m', 'glm-5.3-flash',
34
+ '-c', 'model_provider=zai',
35
+ '--', 'prompt',
36
+ ]);
37
+ });
38
+
39
+ test('CodexAdapter buffers a thread id emitted before callback attachment', async () => {
40
+ const root = await mkdtemp(join(tmpdir(), 'agents-relay-codex-'));
41
+ try {
42
+ const command = join(root, 'codex');
43
+ await writeFile(command, [
44
+ '#!/bin/sh',
45
+ `printf '%s\\n' '${JSON.stringify({ type: 'thread.started', thread_id: 'early-thread' })}'`,
46
+ 'while true; do sleep 0.1; done',
47
+ ].join('\n'), { mode: 0o700 });
48
+ await chmod(command, 0o700);
49
+ const adapter = new CodexAdapter(command);
50
+ const execution = adapter.launch({
51
+ id: 'task',
52
+ input: 'prompt',
53
+ routing: { provider: 'zai', model: 'glm-5.3-flash' },
54
+ }, new AbortController().signal);
55
+ let observedThreadId;
56
+ execution.onThreadStarted = threadId => { observedThreadId = threadId; };
57
+ execution.promise.catch(() => {});
58
+ for (let attempts = 0; attempts < 100 && observedThreadId === undefined; attempts += 1) {
59
+ await new Promise(resolve => setTimeout(resolve, 10));
60
+ }
61
+ assert.equal(execution.threadId, 'early-thread');
62
+ assert.equal(observedThreadId, 'early-thread');
63
+ execution.cancel();
64
+ await execution.promise.catch(() => {});
65
+ } finally {
66
+ await rm(root, { recursive: true, force: true });
67
+ }
68
+ });
69
+
70
+ test('CodexAdapter persists only the final assistant message from large JSON output', async () => {
71
+ const root = await mkdtemp(join(tmpdir(), 'agents-relay-codex-summary-'));
72
+ try {
73
+ const command = join(root, 'codex');
74
+ const noise = 'x'.repeat(70000);
75
+ const final = 'concise final result';
76
+ await writeFile(command, [
77
+ '#!/bin/sh',
78
+ `printf '%s\\n' '${JSON.stringify({ type: 'thread.started', thread_id: 'summary-thread' })}'`,
79
+ `printf '%s\\n' '${JSON.stringify({ type: 'item.completed', item: { type: 'command_execution', text: noise } })}'`,
80
+ `printf '%s\\n' '${JSON.stringify({ type: 'item.completed', item: { type: 'agent_message', text: final } })}'`,
81
+ ].join('\n'), { mode: 0o700 });
82
+ await chmod(command, 0o700);
83
+ const adapter = new CodexAdapter(command);
84
+ const result = await adapter.launch({
85
+ id: 'task', input: 'prompt', routing: { provider: 'zai', model: 'glm-5.3-flash' },
86
+ }, new AbortController().signal).promise;
87
+ assert.equal(result.summary, final);
88
+ assert.ok(Buffer.byteLength(result.summary) < 1024);
89
+ } finally {
90
+ await rm(root, { recursive: true, force: true });
91
+ }
92
+ });
93
+
94
+ test('CodexAdapter bounds stderr retained for failed executions', async () => {
95
+ const root = await mkdtemp(join(tmpdir(), 'agents-relay-codex-error-'));
96
+ try {
97
+ const command = join(root, 'codex');
98
+ await writeFile(command, [
99
+ '#!/bin/sh',
100
+ "python3 - <<'PY' >&2",
101
+ "print('e' * 70000)",
102
+ 'PY',
103
+ 'exit 1',
104
+ ].join('\n'), { mode: 0o700 });
105
+ await chmod(command, 0o700);
106
+ const adapter = new CodexAdapter(command);
107
+ await assert.rejects(adapter.launch({
108
+ id: 'task', input: 'prompt', routing: { provider: 'zai', model: 'glm-5.3-flash' },
109
+ }, new AbortController().signal).promise, error => {
110
+ assert.ok(Buffer.byteLength(error.message) <= 16384);
111
+ return true;
112
+ });
113
+ } finally {
114
+ await rm(root, { recursive: true, force: true });
115
+ }
116
+ });
117
+
118
+ test('ChatGptAdapter treats configured MacBridge URL as an origin', async () => {
119
+ let requestedUrl = '';
120
+ const root = await mkdtemp(join(tmpdir(), 'agents-relay-origin-'));
121
+ const tokenFile = join(root, 'token'); await writeFile(tokenFile, 'test-token');
122
+ const { ChatGptAdapter } = await import('../dist/adapters.js');
123
+ const adapter = new ChatGptAdapter({
124
+ endpoint: 'http://127.0.0.1:8788/v1/responses',
125
+ tokenFile,
126
+ fetch: async input => {
127
+ requestedUrl = String(input);
128
+ return new Response(JSON.stringify({ complete: true, conversation_id: 'conv-origin', assistant_text: 'ok' }), { status: 200 });
129
+ },
130
+ });
131
+ const task = { id: 'origin', input: 'test', routing: { provider: 'openai', model: 'gpt-5.6-sol' } };
132
+ await adapter.launch(task, new AbortController().signal).promise;
133
+ assert.equal(requestedUrl, 'http://127.0.0.1:8788/experimental/chatgpt/conversation');
134
+ await rm(root, { recursive: true, force: true });
135
+ });
136
+
137
+ test('ChatGptAdapter launches through MacBridge and exposes conversation id as thread id', async () => {
138
+ const root = await mkdtemp(join(tmpdir(), 'agents-relay-chatgpt-'));
139
+ try {
140
+ const tokenFile = join(root, 'http-token');
141
+ await writeFile(tokenFile, 'secret-token\n', { mode: 0o600 });
142
+ let request;
143
+ const adapter = new (await import('../dist/adapters.js')).ChatGptAdapter({
144
+ endpoint: 'http://127.0.0.1:9999',
145
+ tokenFile,
146
+ fetch: async (input, init) => {
147
+ request = { input: String(input), init };
148
+ return new Response(JSON.stringify({ complete: true, conversation_id: 'chat-thread-1', assistant_text: 'finished work' }), {
149
+ status: 200,
150
+ headers: { 'content-type': 'application/json' },
151
+ });
152
+ },
153
+ });
154
+ const execution = adapter.launch({
155
+ id: 'chat-task',
156
+ input: 'build the feature',
157
+ timeoutMs: 120000,
158
+ threadId: null,
159
+ routing: { provider: 'openai', model: 'gpt-5-6-sol', reasoning: 'high', projectId: 'g-p-12345678' },
160
+ }, new AbortController().signal);
161
+ let observedThreadId;
162
+ execution.onThreadStarted = threadId => { observedThreadId = threadId; };
163
+ const result = await execution.promise;
164
+ assert.equal(request.input, 'http://127.0.0.1:9999/experimental/chatgpt/conversation');
165
+ assert.equal(request.init.headers.authorization, 'Bearer secret-token');
166
+ const body = JSON.parse(request.init.body);
167
+ assert.deepEqual(body, {
168
+ prompt: 'build the feature',
169
+ model: 'gpt-5-6-sol',
170
+ thinking_effort: 'high',
171
+ max_runtime_seconds: 120,
172
+ project_id: 'g-p-12345678',
173
+ });
174
+ assert.equal(execution.threadId, 'chat-thread-1');
175
+ assert.equal(observedThreadId, 'chat-thread-1');
176
+ assert.equal(result.summary, 'finished work');
177
+ assert.equal(result.data.conversationId, 'chat-thread-1');
178
+ } finally {
179
+ await rm(root, { recursive: true, force: true });
180
+ }
181
+ });
182
+
183
+ test('ChatGptAdapter continues an existing conversation on retry and normalizes medium reasoning', async () => {
184
+ const root = await mkdtemp(join(tmpdir(), 'agents-relay-chatgpt-retry-'));
185
+ try {
186
+ const tokenFile = join(root, 'http-token');
187
+ await writeFile(tokenFile, 'token', { mode: 0o600 });
188
+ let body;
189
+ const { ChatGptAdapter } = await import('../dist/adapters.js');
190
+ const adapter = new ChatGptAdapter({
191
+ endpoint: 'http://127.0.0.1:9999/experimental/chatgpt/conversation',
192
+ tokenFile,
193
+ fetch: async (_input, init) => {
194
+ body = JSON.parse(init.body);
195
+ return new Response(JSON.stringify({ complete: true, conversation_id: 'existing-thread', assistant_text: 'continued' }), { status: 200 });
196
+ },
197
+ });
198
+ const result = await adapter.launch({
199
+ id: 'retry-task', input: 'continue', timeoutMs: 10000, threadId: 'existing-thread',
200
+ routing: { provider: 'openai', model: 'gpt-5-6', reasoning: 'medium' },
201
+ }, new AbortController().signal).promise;
202
+ assert.equal(body.conversation_id, 'existing-thread');
203
+ assert.equal(body.thinking_effort, 'standard');
204
+ assert.equal(body.max_runtime_seconds, 30);
205
+ assert.equal(result.summary, 'continued');
206
+ } finally {
207
+ await rm(root, { recursive: true, force: true });
208
+ }
209
+ });
210
+
211
+ test('ChatGptAdapter exposes conversation id before the response body completes', async () => {
212
+ const root = await mkdtemp(join(tmpdir(), 'agents-relay-chatgpt-early-thread-'));
213
+ try {
214
+ const tokenFile = join(root, 'http-token');
215
+ await writeFile(tokenFile, 'token', { mode: 0o600 });
216
+ let finishResponse;
217
+ const { ChatGptAdapter } = await import('../dist/adapters.js');
218
+ const adapter = new ChatGptAdapter({
219
+ endpoint: 'http://127.0.0.1:9999',
220
+ tokenFile,
221
+ fetch: async () => new Response(new ReadableStream({
222
+ start(controller) {
223
+ controller.enqueue(new TextEncoder().encode('{"conversation_id":"early-chat-thread","complete":'));
224
+ finishResponse = () => {
225
+ controller.enqueue(new TextEncoder().encode('true,"assistant_text":"done"}'));
226
+ controller.close();
227
+ };
228
+ },
229
+ }), { status: 200 }),
230
+ });
231
+ const execution = adapter.launch({
232
+ id: 'early-chat-task', input: 'work', timeoutMs: 10000, threadId: null,
233
+ routing: { provider: 'openai', model: 'gpt-5-6' },
234
+ }, new AbortController().signal);
235
+ let observedThreadId;
236
+ execution.onThreadStarted = threadId => { observedThreadId = threadId; };
237
+ for (let attempts = 0; attempts < 100 && observedThreadId === undefined; attempts += 1) {
238
+ await new Promise(resolve => setTimeout(resolve, 5));
239
+ }
240
+ assert.equal(execution.threadId, 'early-chat-thread');
241
+ assert.equal(observedThreadId, 'early-chat-thread');
242
+ assert.equal(typeof finishResponse, 'function');
243
+ finishResponse();
244
+ assert.equal((await execution.promise).summary, 'done');
245
+ } finally {
246
+ await rm(root, { recursive: true, force: true });
247
+ }
248
+ });
249
+
250
+ test('ChatGptAdapter keeps an exposed conversation id when response transport fails later', async () => {
251
+ const root = await mkdtemp(join(tmpdir(), 'agents-relay-chatgpt-transport-fail-'));
252
+ try {
253
+ const tokenFile = join(root, 'http-token');
254
+ await writeFile(tokenFile, 'token', { mode: 0o600 });
255
+ let failResponse;
256
+ const { ChatGptAdapter } = await import('../dist/adapters.js');
257
+ const adapter = new ChatGptAdapter({
258
+ endpoint: 'http://127.0.0.1:9999',
259
+ tokenFile,
260
+ fetch: async () => new Response(new ReadableStream({
261
+ start(controller) {
262
+ controller.enqueue(new TextEncoder().encode('{"conversation_id":"durable-chat-thread","complete":'));
263
+ failResponse = () => controller.error(new Error('transport lost after thread creation'));
264
+ },
265
+ }), { status: 200 }),
266
+ });
267
+ const execution = adapter.launch({
268
+ id: 'failed-chat-task', input: 'work', timeoutMs: 10000, threadId: null,
269
+ routing: { provider: 'openai', model: 'gpt-5-6' },
270
+ }, new AbortController().signal);
271
+ let observedThreadId;
272
+ execution.onThreadStarted = threadId => { observedThreadId = threadId; };
273
+ for (let attempts = 0; attempts < 100 && observedThreadId === undefined; attempts += 1) {
274
+ await new Promise(resolve => setTimeout(resolve, 5));
275
+ }
276
+ assert.equal(observedThreadId, 'durable-chat-thread');
277
+ assert.equal(typeof failResponse, 'function');
278
+ failResponse();
279
+ await assert.rejects(execution.promise, /transport lost after thread creation/);
280
+ assert.equal(execution.threadId, 'durable-chat-thread');
281
+ } finally {
282
+ await rm(root, { recursive: true, force: true });
283
+ }
284
+ });
285
+
286
+ test('ChatGptAdapter deletes a durable conversation through the MacBridge wrapper', async () => {
287
+ const root = await mkdtemp(join(tmpdir(), 'agents-relay-chatgpt-delete-'));
288
+ try {
289
+ const tokenFile = join(root, 'http-token');
290
+ await writeFile(tokenFile, 'delete-token', { mode: 0o600 });
291
+ let request;
292
+ const { ChatGptAdapter } = await import('../dist/adapters.js');
293
+ const adapter = new ChatGptAdapter({
294
+ endpoint: 'http://127.0.0.1:9999', tokenFile,
295
+ fetch: async (input, init) => { request={input:String(input),init}; return new Response(JSON.stringify({ deleted: true }), { status: 200 }); },
296
+ });
297
+ await adapter.deleteThread('chat-thread-delete');
298
+ assert.equal(request.input,'http://127.0.0.1:9999/experimental/chatgpt/conversation');
299
+ assert.equal(request.init.method,'DELETE');
300
+ assert.equal(request.init.headers.authorization,'Bearer delete-token');
301
+ assert.deepEqual(JSON.parse(request.init.body),{conversation_id:'chat-thread-delete'});
302
+ } finally { await rm(root,{recursive:true,force:true}); }
303
+ });
@@ -0,0 +1,119 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { mkdtemp, writeFile, chmod, rm } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { tmpdir } from 'node:os';
6
+ import { InMemoryStore } from '../dist/store.js';
7
+ import { marker, JOB_MARKER, TASK_MARKER, parseJob, parseTask, renderJobComment } from '../dist/markers.js';
8
+ import { AgentObjectivePlanner, parsePlannerOutput, parsePlannerResult } from '../dist/planner.js';
9
+ import { Reconciler } from '../dist/reconciler.js';
10
+ import { schedule } from '../dist/scheduler.js';
11
+ import { executionMode } from '../dist/cli.js';
12
+ import { dashboardHtml } from '../dist/dashboard.js';
13
+
14
+ const now = () => new Date().toISOString();
15
+ const task = (id, state = 'QUEUED') => ({ jobId: 'auto', id, kind: 'work', parentTaskId: null, dependencies: [], capabilities: [], adapter: 'shell', input: 'true', state, attempt: 0, maxAttempts: 1, leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, error: null, timeoutMs: 1000, createdAt: now(), updatedAt: now(), continuation: null, continuationDeliveredAt: null });
16
+ const job = (tasks = [], executionMode = 'autonomous') => ({ id: 'auto', title: 'Objective', objective: 'finish the objective', executionMode, prNumber: 0, repository: '', state: 'OPEN', continuation: null, createdAt: now(), updatedAt: now(), tasks });
17
+ const adapter = { name: 'shell', id: 'test-shell', capabilities: ['shell'], launch: current => ({ id: `exec-${current.id}`, promise: Promise.resolve({ summary: 'done' }), cancel: () => {} }) };
18
+ async function settle(reconciler, count = 8) { for (let index = 0; index < count; index += 1) { await reconciler.reconcile('auto'); await reconciler.idle(); } }
19
+
20
+ test('execution mode and planner result markers round-trip, with legacy jobs fixed by default', () => {
21
+ const autonomous = job();
22
+ assert.equal(parseJob(renderJobComment(autonomous)).executionMode, 'autonomous');
23
+ const legacy = { ...autonomous }; delete legacy.executionMode;
24
+ assert.equal(parseJob(marker(JOB_MARKER, legacy)).executionMode, 'fixed');
25
+ const planner = { ...task('planner-1'), kind: 'planner', plannerResult: { objective_status: 'in_progress', assessment: 'need work', next_tasks: [{ id: 'work-1', input: 'true' }] } };
26
+ assert.equal(parseTask(marker(TASK_MARKER, planner)).plannerResult.next_tasks[0].id, 'work-1');
27
+ assert.equal(parsePlannerOutput('```json\n{"objective_status":"satisfied","assessment":"done","next_tasks":[]}\n```').objective_status, 'satisfied');
28
+ assert.throws(() => parsePlannerResult({ objective_status: 'satisfied', assessment: 'bad', next_tasks: [{ id: 'duplicate', input: 'x' }, { id: 'duplicate', input: 'y' }] }), /duplicate/);
29
+ assert.equal(executionMode(['--mode', 'autonomous']), 'autonomous');
30
+ assert.equal(executionMode([]), 'fixed');
31
+ assert.match(dashboardHtml, /Execution mode/);
32
+ assert.match(dashboardHtml, /executionMode\|\|'fixed'/);
33
+ });
34
+
35
+ test('default planner agent loads markdown instructions and returns typed planning JSON', async () => {
36
+ const root = await mkdtemp(join(tmpdir(), 'agents-relay-planner-agent-'));
37
+ try {
38
+ const command = join(root, 'model-runtime');
39
+ const agentFile = join(root, 'planner.agent.md');
40
+ await writeFile(agentFile, '# Planner Agent\nReturn JSON only.\n');
41
+ await writeFile(command, [
42
+ '#!/bin/sh',
43
+ "printf '%s\\n' '{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"{\\\"objective_status\\\":\\\"satisfied\\\",\\\"assessment\\\":\\\"done\\\",\\\"next_tasks\\\":[]}\"}}'",
44
+ ].join('\n'), { mode: 0o700 });
45
+ await chmod(command, 0o700);
46
+ const planner = new AgentObjectivePlanner(command, agentFile);
47
+ const result = await planner.plan({ job: job(), tasks: [], objective: 'finish', previousPlannerTaskId: null }, new AbortController().signal);
48
+ assert.deepEqual(result, { objective_status: 'satisfied', assessment: 'done', next_tasks: [] });
49
+ } finally {
50
+ await rm(root, { recursive: true, force: true });
51
+ }
52
+ });
53
+
54
+ test('fixed jobs remain backward compatible and never create planner tasks', async () => {
55
+ const fixed = job([task('fixed-work')], 'fixed');
56
+ const store = new InMemoryStore(fixed);
57
+ let plannerCalls = 0;
58
+ const reconciler = new Reconciler(store, { owner: 'fixed-test', adapters: [adapter], planner: { plan: async () => { plannerCalls += 1; return { objective_status: 'satisfied', assessment: 'unexpected', next_tasks: [] }; } } });
59
+ await settle(reconciler, 2);
60
+ const result = await store.load('auto');
61
+ assert.equal(result.executionMode, 'fixed');
62
+ assert.equal(result.tasks.some(item => item.kind === 'planner'), false);
63
+ assert.equal(plannerCalls, 0);
64
+ assert.equal(result.tasks[0].state, 'SUCCEEDED');
65
+ });
66
+
67
+ test('autonomous reconciliation grows the real parent/subtask tree and completes after satisfaction', async () => {
68
+ const store = new InMemoryStore(job());
69
+ let calls = 0;
70
+ const planner = { plan: async () => { calls += 1; return calls === 1 ? { objective_status: 'in_progress', assessment: 'work remains', next_tasks: [{ id: 'work-1', input: 'true' }] } : { objective_status: 'satisfied', assessment: 'objective complete', next_tasks: [] }; } };
71
+ const reconciler = new Reconciler(store, { owner: 'auto-test', adapters: [adapter], planner });
72
+ await settle(reconciler);
73
+ const result = await store.load('auto');
74
+ const plannerOne = result.tasks.find(item => item.id === 'planner-1');
75
+ const work = result.tasks.find(item => item.id === 'work-1');
76
+ const plannerTwo = result.tasks.find(item => item.id === 'planner-2');
77
+ assert.equal(result.state, 'COMPLETED');
78
+ assert.equal(calls, 2);
79
+ assert.equal(plannerOne?.kind, 'planner');
80
+ assert.equal(plannerOne?.agentName, 'planner');
81
+ assert.equal(work?.parentTaskId, 'planner-1');
82
+ assert.equal(plannerTwo?.parentTaskId, 'planner-1');
83
+ assert.equal(plannerTwo?.plannerResult?.objective_status, 'satisfied');
84
+ });
85
+
86
+ test('autonomous planner creation and stable next task IDs are idempotent', async () => {
87
+ const store = new InMemoryStore(job());
88
+ let calls = 0;
89
+ const planner = { plan: async () => { calls += 1; return calls === 1 ? { objective_status: 'in_progress', assessment: 'work remains', next_tasks: [{ id: 'stable-work', input: 'true' }] } : { objective_status: 'satisfied', assessment: 'done', next_tasks: [{ id: 'stable-work', input: 'true' }] }; } };
90
+ const reconciler = new Reconciler(store, { owner: 'idempotency-test', adapters: [adapter], planner });
91
+ await settle(reconciler, 3);
92
+ const first = await store.load('auto');
93
+ await settle(reconciler, 3);
94
+ const second = await store.load('auto');
95
+ assert.equal(calls, 2);
96
+ assert.equal(second.tasks.filter(item => item.id === 'stable-work').length, 1);
97
+ assert.equal(first.tasks.filter(item => item.kind === 'planner').length, second.tasks.filter(item => item.kind === 'planner').length);
98
+ });
99
+
100
+ test('restart recovery reclaims a durable planner lease without relaunching duplicate work', async () => {
101
+ const plannerTask = { ...task('planner-1', 'RUNNING'), kind: 'planner', parentTaskId: null, attempt: 1, maxAttempts: 2, leaseOwner: 'old-daemon', leaseExpiresAt: new Date(Date.now() + 600000).toISOString(), executionId: 'old-execution', input: '{}', capabilities: ['planner'] };
102
+ const store = new InMemoryStore({ ...job([plannerTask]), state: 'RUNNING' });
103
+ let calls = 0;
104
+ const reconciler = new Reconciler(store, { owner: 'new-daemon', adapters: [adapter], planner: { plan: async () => { calls += 1; return { objective_status: 'satisfied', assessment: 'recovered', next_tasks: [] }; } } });
105
+ await settle(reconciler, 3);
106
+ const result = await store.load('auto');
107
+ assert.equal(calls, 1);
108
+ assert.equal(result.state, 'COMPLETED');
109
+ assert.equal(result.tasks.filter(item => item.kind === 'planner').length, 1);
110
+ assert.equal(result.tasks[0].attempt, 2);
111
+ });
112
+
113
+ test('planner satisfaction cannot bypass failed or blocked completion gates', () => {
114
+ const satisfied = { ...task('planner-1', 'SUCCEEDED'), kind: 'planner', plannerResult: { objective_status: 'satisfied', assessment: 'done', next_tasks: [] } };
115
+ const failed = schedule({ ...job([satisfied, { ...task('failed', 'FAILED'), error: 'unresolved' }]), state: 'RUNNING' });
116
+ assert.equal(failed.state, 'FAILED');
117
+ const blocked = schedule({ ...job([satisfied, { ...task('blocked', 'BLOCKED') }]), state: 'RUNNING' });
118
+ assert.equal(blocked.state, 'BLOCKED');
119
+ });