@animalabs/agent-framework 0.6.6 → 0.6.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/README.md +78 -0
- package/dist/src/agent.d.ts +5 -1
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +35 -1
- package/dist/src/agent.js.map +1 -1
- package/dist/src/api/mcp-server.js +0 -0
- package/dist/src/api/server.d.ts.map +1 -1
- package/dist/src/api/server.js +7 -2
- package/dist/src/api/server.js.map +1 -1
- package/dist/src/framework.d.ts +91 -3
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +988 -82
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +9 -2
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +3 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +3 -1
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +29 -15
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +36 -3
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +142 -28
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts +12 -1
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +661 -2
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/types.d.ts +4 -0
- package/dist/src/modules/workspace/types.d.ts.map +1 -1
- package/dist/src/modules/workspace/types.js.map +1 -1
- package/dist/src/recovery/discord-awareness-outbox.d.ts +99 -0
- package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -0
- package/dist/src/recovery/discord-awareness-outbox.js +334 -0
- package/dist/src/recovery/discord-awareness-outbox.js.map +1 -0
- package/dist/src/recovery/offline-branch.d.ts +45 -0
- package/dist/src/recovery/offline-branch.d.ts.map +1 -0
- package/dist/src/recovery/offline-branch.js +362 -0
- package/dist/src/recovery/offline-branch.js.map +1 -0
- package/dist/src/recovery/recover-cli.d.ts +3 -0
- package/dist/src/recovery/recover-cli.d.ts.map +1 -0
- package/dist/src/recovery/recover-cli.js +129 -0
- package/dist/src/recovery/recover-cli.js.map +1 -0
- package/dist/src/types/agent.d.ts +10 -0
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +14 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +31 -0
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +2 -0
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/test/agent-runtime-settings.test.js +200 -93
- package/dist/test/agent-runtime-settings.test.js.map +1 -1
- package/dist/test/busy-agent-scheduler.test.d.ts +2 -0
- package/dist/test/busy-agent-scheduler.test.d.ts.map +1 -0
- package/dist/test/busy-agent-scheduler.test.js +62 -0
- package/dist/test/busy-agent-scheduler.test.js.map +1 -0
- package/dist/test/channel-lifecycle-provenance.test.d.ts +2 -0
- package/dist/test/channel-lifecycle-provenance.test.d.ts.map +1 -0
- package/dist/test/channel-lifecycle-provenance.test.js +47 -0
- package/dist/test/channel-lifecycle-provenance.test.js.map +1 -0
- package/dist/test/discord-awareness-outbox.test.d.ts +2 -0
- package/dist/test/discord-awareness-outbox.test.d.ts.map +1 -0
- package/dist/test/discord-awareness-outbox.test.js +248 -0
- package/dist/test/discord-awareness-outbox.test.js.map +1 -0
- package/dist/test/maintenance-timer.test.js +100 -0
- package/dist/test/maintenance-timer.test.js.map +1 -1
- package/dist/test/mcpl-awareness-barrier.test.d.ts +2 -0
- package/dist/test/mcpl-awareness-barrier.test.d.ts.map +1 -0
- package/dist/test/mcpl-awareness-barrier.test.js +618 -0
- package/dist/test/mcpl-awareness-barrier.test.js.map +1 -0
- package/dist/test/mcpl-reconnect-reregistration.test.js +22 -3
- package/dist/test/mcpl-reconnect-reregistration.test.js.map +1 -1
- package/dist/test/mcpl-request-timeout.test.js +15 -0
- package/dist/test/mcpl-request-timeout.test.js.map +1 -1
- package/dist/test/offline-recovery-branch.test.d.ts +2 -0
- package/dist/test/offline-recovery-branch.test.d.ts.map +1 -0
- package/dist/test/offline-recovery-branch.test.js +226 -0
- package/dist/test/offline-recovery-branch.test.js.map +1 -0
- package/dist/test/present-while-acting.test.d.ts +3 -2
- package/dist/test/present-while-acting.test.d.ts.map +1 -1
- package/dist/test/present-while-acting.test.js +31 -3
- package/dist/test/present-while-acting.test.js.map +1 -1
- package/dist/test/refusal-single-call.test.d.ts +2 -0
- package/dist/test/refusal-single-call.test.d.ts.map +1 -0
- package/dist/test/refusal-single-call.test.js +177 -0
- package/dist/test/refusal-single-call.test.js.map +1 -0
- package/dist/test/same-round-think-policy.test.d.ts +2 -0
- package/dist/test/same-round-think-policy.test.d.ts.map +1 -0
- package/dist/test/same-round-think-policy.test.js +853 -0
- package/dist/test/same-round-think-policy.test.js.map +1 -0
- package/dist/test/workspace-read-image.test.d.ts +2 -0
- package/dist/test/workspace-read-image.test.d.ts.map +1 -0
- package/dist/test/workspace-read-image.test.js +518 -0
- package/dist/test/workspace-read-image.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -3
- package/src/agent.ts +39 -2
- package/src/api/server.ts +8 -2
- package/src/framework.ts +1180 -79
- package/src/gate/event-gate.ts +15 -3
- package/src/index.ts +20 -0
- package/src/mcpl/channel-registry.ts +31 -16
- package/src/mcpl/server-connection.ts +163 -28
- package/src/modules/workspace/index.ts +759 -2
- package/src/modules/workspace/types.ts +5 -0
- package/src/recovery/discord-awareness-outbox.ts +459 -0
- package/src/recovery/offline-branch.ts +510 -0
- package/src/recovery/recover-cli.ts +130 -0
- package/src/types/agent.ts +14 -0
- package/src/types/framework.ts +17 -0
- package/src/types/module.ts +33 -0
- package/src/types/trace.ts +2 -0
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { AgentFramework } from '../src/framework.js';
|
|
7
|
+
import { McplServerConnection } from '../src/mcpl/server-connection.js';
|
|
8
|
+
import { DiscordAwarenessOutbox } from '../src/recovery/discord-awareness-outbox.js';
|
|
9
|
+
import { MockMembrane } from './helpers/mock-membrane.js';
|
|
10
|
+
const FIXTURE = fileURLToPath(new URL('./fixtures/awareness-barrier-mcpl-server.mjs', import.meta.url));
|
|
11
|
+
const TEST_ROOT = join(process.cwd(), '.test-tmp');
|
|
12
|
+
function records(path) {
|
|
13
|
+
try {
|
|
14
|
+
return readFileSync(path, 'utf8')
|
|
15
|
+
.split('\n')
|
|
16
|
+
.filter(Boolean)
|
|
17
|
+
.map((line) => JSON.parse(line));
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
async function waitFor(description, predicate, timeoutMs = 3_000) {
|
|
24
|
+
const deadline = Date.now() + timeoutMs;
|
|
25
|
+
while (Date.now() < deadline) {
|
|
26
|
+
if (predicate())
|
|
27
|
+
return;
|
|
28
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
29
|
+
}
|
|
30
|
+
assert.fail(`timed out waiting for ${description}`);
|
|
31
|
+
}
|
|
32
|
+
function preparePending(outboxPath) {
|
|
33
|
+
const outbox = new DiscordAwarenessOutbox(outboxPath);
|
|
34
|
+
const batch = outbox.prepare({
|
|
35
|
+
agentName: 'assistant',
|
|
36
|
+
sourceBranch: 'source',
|
|
37
|
+
targetBranch: 'main',
|
|
38
|
+
activationPolicy: 'explicit',
|
|
39
|
+
refs: [{ serverId: 'discord', channelId: 'discord:guild:startup', messageId: 'message-1' }],
|
|
40
|
+
});
|
|
41
|
+
assert.ok(batch);
|
|
42
|
+
outbox.activate(batch.id);
|
|
43
|
+
return outbox;
|
|
44
|
+
}
|
|
45
|
+
function frameworkConfig(dir, extraEnv = {}, requestTimeoutMs = 1_000, serverOverrides = {}, awarenessDeadlineMs = 1_000) {
|
|
46
|
+
const outboxPath = join(dir, 'awareness.json');
|
|
47
|
+
return {
|
|
48
|
+
outboxPath,
|
|
49
|
+
config: {
|
|
50
|
+
storePath: join(dir, 'store'),
|
|
51
|
+
discordAwarenessOutboxPath: outboxPath,
|
|
52
|
+
discordAwarenessDeadlineMs: awarenessDeadlineMs,
|
|
53
|
+
membrane: new MockMembrane().asMembrane(),
|
|
54
|
+
agents: [{ name: 'assistant', model: 'test', systemPrompt: 'test' }],
|
|
55
|
+
modules: [],
|
|
56
|
+
mcplServers: [{
|
|
57
|
+
id: 'discord',
|
|
58
|
+
command: process.execPath,
|
|
59
|
+
args: [FIXTURE],
|
|
60
|
+
requestTimeoutMs,
|
|
61
|
+
env: extraEnv,
|
|
62
|
+
enabledFeatureSets: ['chat'],
|
|
63
|
+
...serverOverrides,
|
|
64
|
+
}],
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
function twoServerFrameworkConfig(dir, order, dataMethod, runtime = false) {
|
|
69
|
+
const outboxPath = join(dir, 'awareness.json');
|
|
70
|
+
const statusPath = join(dir, 'status.jsonl');
|
|
71
|
+
const releasePath = join(dir, 'release');
|
|
72
|
+
const listChangePath = join(dir, 'list-change');
|
|
73
|
+
const arrivalPath = join(dir, 'heartbeat-arrival');
|
|
74
|
+
const server = (id) => ({
|
|
75
|
+
id,
|
|
76
|
+
command: process.execPath,
|
|
77
|
+
args: [FIXTURE],
|
|
78
|
+
requestTimeoutMs: 0,
|
|
79
|
+
env: {
|
|
80
|
+
STATUS_PATH: statusPath,
|
|
81
|
+
LEDGER_PATH: outboxPath,
|
|
82
|
+
SERVER_ID: id,
|
|
83
|
+
DATA_METHOD: dataMethod,
|
|
84
|
+
...(id === 'discord'
|
|
85
|
+
? { RELEASE_PATH: releasePath, WAIT_FOR_ARRIVAL_PATH: arrivalPath }
|
|
86
|
+
: { ARRIVAL_PATH: arrivalPath }),
|
|
87
|
+
...(runtime && id === 'heartbeat' ? { LIST_CHANGE_PATH: listChangePath } : {}),
|
|
88
|
+
},
|
|
89
|
+
enabledFeatureSets: ['chat'],
|
|
90
|
+
});
|
|
91
|
+
const servers = order === 'heartbeat-first'
|
|
92
|
+
? [server('heartbeat'), server('discord')]
|
|
93
|
+
: [server('discord'), server('heartbeat')];
|
|
94
|
+
return {
|
|
95
|
+
outboxPath,
|
|
96
|
+
statusPath,
|
|
97
|
+
releasePath,
|
|
98
|
+
listChangePath,
|
|
99
|
+
config: {
|
|
100
|
+
storePath: join(dir, 'store'),
|
|
101
|
+
discordAwarenessOutboxPath: outboxPath,
|
|
102
|
+
discordAwarenessDeadlineMs: 1_000,
|
|
103
|
+
membrane: new MockMembrane().asMembrane(),
|
|
104
|
+
agents: [{ name: 'assistant', model: 'test', systemPrompt: 'test' }],
|
|
105
|
+
modules: [],
|
|
106
|
+
mcplServers: servers,
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
const DATA_METHODS = [
|
|
111
|
+
'push/event',
|
|
112
|
+
'inference/request',
|
|
113
|
+
'channels/incoming',
|
|
114
|
+
'host/command',
|
|
115
|
+
];
|
|
116
|
+
for (const order of ['heartbeat-first', 'discord-first']) {
|
|
117
|
+
for (const dataMethod of DATA_METHODS)
|
|
118
|
+
test(`startup globally gates ${dataMethod} with ${order} config`, async () => {
|
|
119
|
+
mkdirSync(TEST_ROOT, { recursive: true });
|
|
120
|
+
const dir = mkdtempSync(join(TEST_ROOT, 'awareness-startup-'));
|
|
121
|
+
const { outboxPath, statusPath, releasePath, config } = twoServerFrameworkConfig(dir, order, dataMethod);
|
|
122
|
+
const outbox = preparePending(outboxPath);
|
|
123
|
+
let framework;
|
|
124
|
+
let createSettled = false;
|
|
125
|
+
const creating = AgentFramework.create(config).then((created) => {
|
|
126
|
+
framework = created;
|
|
127
|
+
createSettled = true;
|
|
128
|
+
return created;
|
|
129
|
+
});
|
|
130
|
+
try {
|
|
131
|
+
await waitFor('registration and a second control response', () => {
|
|
132
|
+
const events = records(statusPath)
|
|
133
|
+
.filter((record) => record.serverId === 'discord')
|
|
134
|
+
.map((record) => record.event);
|
|
135
|
+
return events.includes('registration-response')
|
|
136
|
+
&& events.includes('control-response-during-barrier');
|
|
137
|
+
});
|
|
138
|
+
const discordOrdering = records(statusPath)
|
|
139
|
+
.filter((record) => record.serverId === 'discord')
|
|
140
|
+
.map((record) => record.event)
|
|
141
|
+
.filter((event) => [
|
|
142
|
+
'reaction-queued-before-registration',
|
|
143
|
+
'registration-response',
|
|
144
|
+
'reaction-call',
|
|
145
|
+
].includes(event));
|
|
146
|
+
assert.deepEqual(discordOrdering, [
|
|
147
|
+
'reaction-queued-before-registration',
|
|
148
|
+
'registration-response',
|
|
149
|
+
'reaction-call',
|
|
150
|
+
]);
|
|
151
|
+
const allStatus = records(statusPath);
|
|
152
|
+
const heartbeatArrival = allStatus.findIndex((record) => record.serverId === 'heartbeat' && record.event === 'data-request-sent');
|
|
153
|
+
const discordDrain = allStatus.findIndex((record) => record.serverId === 'discord' && record.event === 'reaction-queued-before-registration');
|
|
154
|
+
assert.ok(heartbeatArrival >= 0 && heartbeatArrival < discordDrain, 'non-Discord data arrives before Discord registration/drain starts');
|
|
155
|
+
assert.equal(createSettled, false, 'framework readiness must wait for the marker outcome');
|
|
156
|
+
assert.equal(records(statusPath).some((record) => record.serverId === 'heartbeat' && record.event === 'push-response'), false, `non-Discord ${dataMethod} must remain held while the Discord marker is pending`);
|
|
157
|
+
assert.equal(outbox.pending('discord').length, 1);
|
|
158
|
+
writeFileSync(releasePath, 'release');
|
|
159
|
+
await creating;
|
|
160
|
+
await waitFor('gated push response', () => records(statusPath).some((record) => record.serverId === 'heartbeat' && record.event === 'push-response'));
|
|
161
|
+
const status = records(statusPath);
|
|
162
|
+
const push = status.find((record) => record.serverId === 'heartbeat' && record.event === 'push-response');
|
|
163
|
+
assert.equal(push?.method, dataMethod);
|
|
164
|
+
assert.equal(push?.accepted, true);
|
|
165
|
+
assert.equal(push?.ledgerStatus, 'applied', 'push released only after durable success');
|
|
166
|
+
assert.equal(outbox.pending('discord').length, 0);
|
|
167
|
+
assert.equal(status.filter((record) => record.serverId === 'discord' && record.event === 'reaction-call').length, 1);
|
|
168
|
+
}
|
|
169
|
+
finally {
|
|
170
|
+
await framework?.stop();
|
|
171
|
+
rmSync(dir, { recursive: true, force: true });
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
test('mandatory awareness deadline survives requestTimeoutMs: 0 and durably releases startup', async () => {
|
|
176
|
+
mkdirSync(TEST_ROOT, { recursive: true });
|
|
177
|
+
const dir = mkdtempSync(join(TEST_ROOT, 'awareness-timeout-'));
|
|
178
|
+
const statusPath = join(dir, 'status.jsonl');
|
|
179
|
+
const { outboxPath, config } = frameworkConfig(dir, {
|
|
180
|
+
STATUS_PATH: statusPath,
|
|
181
|
+
LEDGER_PATH: join(dir, 'awareness.json'),
|
|
182
|
+
FAIL_REACTION: '1',
|
|
183
|
+
}, 0, {}, 60);
|
|
184
|
+
const outbox = preparePending(outboxPath);
|
|
185
|
+
let framework;
|
|
186
|
+
try {
|
|
187
|
+
framework = await AgentFramework.create(config);
|
|
188
|
+
await waitFor('push response after timeout accounting', () => records(statusPath).some((record) => record.event === 'push-response'));
|
|
189
|
+
const entry = outbox.batches()[0].refs[0];
|
|
190
|
+
assert.equal(entry.deliveryStatus, 'pending');
|
|
191
|
+
assert.equal(entry.attempts, 1);
|
|
192
|
+
assert.match(entry.lastError ?? '', /did not respond to tools\/call/);
|
|
193
|
+
assert.match(entry.lastError ?? '', /outcome is unknown/i);
|
|
194
|
+
assert.equal(records(statusPath).filter((record) => record.event === 'reaction-call').length, 1);
|
|
195
|
+
const push = records(statusPath).find((record) => record.event === 'push-response');
|
|
196
|
+
assert.equal(push?.ledgerStatus, 'pending', 'failure was durable before inference release');
|
|
197
|
+
}
|
|
198
|
+
finally {
|
|
199
|
+
await framework?.stop();
|
|
200
|
+
rmSync(dir, { recursive: true, force: true });
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
test('permanent marker failure is durably accounted before startup data is released', async () => {
|
|
204
|
+
mkdirSync(TEST_ROOT, { recursive: true });
|
|
205
|
+
const dir = mkdtempSync(join(TEST_ROOT, 'awareness-permanent-'));
|
|
206
|
+
const statusPath = join(dir, 'status.jsonl');
|
|
207
|
+
const { outboxPath, config } = frameworkConfig(dir, {
|
|
208
|
+
STATUS_PATH: statusPath,
|
|
209
|
+
LEDGER_PATH: join(dir, 'awareness.json'),
|
|
210
|
+
PERMANENT_REACTION: '1',
|
|
211
|
+
});
|
|
212
|
+
const outbox = preparePending(outboxPath);
|
|
213
|
+
let framework;
|
|
214
|
+
try {
|
|
215
|
+
framework = await AgentFramework.create(config);
|
|
216
|
+
await waitFor('data response after permanent failure accounting', () => records(statusPath).some((record) => record.event === 'push-response'));
|
|
217
|
+
const entry = outbox.batches()[0].refs[0];
|
|
218
|
+
assert.equal(entry.deliveryStatus, 'permanent-failure');
|
|
219
|
+
assert.equal(entry.attempts, 1);
|
|
220
|
+
assert.match(entry.lastError ?? '', /Unknown Message/);
|
|
221
|
+
const response = records(statusPath).find((record) => record.event === 'push-response');
|
|
222
|
+
assert.equal(response?.ledgerStatus, 'permanent-failure');
|
|
223
|
+
}
|
|
224
|
+
finally {
|
|
225
|
+
await framework?.stop();
|
|
226
|
+
rmSync(dir, { recursive: true, force: true });
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
test('initial reconnect stub durably records unavailable work, then retries behind a fresh gate', async () => {
|
|
230
|
+
mkdirSync(TEST_ROOT, { recursive: true });
|
|
231
|
+
const dir = mkdtempSync(join(TEST_ROOT, 'awareness-initial-unavailable-'));
|
|
232
|
+
const statusPath = join(dir, 'status.jsonl');
|
|
233
|
+
const releasePath = join(dir, 'release');
|
|
234
|
+
const generationPath = join(dir, 'generation');
|
|
235
|
+
const { outboxPath, config } = frameworkConfig(dir, {
|
|
236
|
+
STATUS_PATH: statusPath,
|
|
237
|
+
LEDGER_PATH: join(dir, 'awareness.json'),
|
|
238
|
+
RELEASE_PATH: releasePath,
|
|
239
|
+
GENERATION_PATH: generationPath,
|
|
240
|
+
FAIL_INITIAL_GENERATION: '1',
|
|
241
|
+
}, 1_000, {
|
|
242
|
+
reconnect: true,
|
|
243
|
+
reconnectIntervalMs: 100,
|
|
244
|
+
reconnectMaxIntervalMs: 100,
|
|
245
|
+
});
|
|
246
|
+
const outbox = preparePending(outboxPath);
|
|
247
|
+
let framework;
|
|
248
|
+
try {
|
|
249
|
+
framework = await AgentFramework.create(config);
|
|
250
|
+
const deferred = outbox.batches()[0].refs[0];
|
|
251
|
+
assert.equal(deferred.deliveryStatus, 'pending');
|
|
252
|
+
assert.equal(deferred.attempts, 1, 'ready state must include a durable unavailable attempt');
|
|
253
|
+
assert.match(deferred.lastError ?? '', /not attempted.*connection unavailable/i);
|
|
254
|
+
await waitFor('successful reconnect registration and awareness call', () => {
|
|
255
|
+
const second = records(statusPath).filter((record) => record.generation === 2);
|
|
256
|
+
return second.some((record) => record.event === 'registration-response')
|
|
257
|
+
&& second.some((record) => record.event === 'reaction-call');
|
|
258
|
+
});
|
|
259
|
+
assert.equal(records(statusPath).some((record) => record.generation === 2 && record.event === 'push-response'), false, 'reconnect data remains behind the newly installed awareness gate');
|
|
260
|
+
writeFileSync(releasePath, 'release');
|
|
261
|
+
await waitFor('reconnected data after durable marker success', () => records(statusPath).some((record) => record.generation === 2 && record.event === 'push-response'));
|
|
262
|
+
assert.equal(outbox.pending('discord').length, 0);
|
|
263
|
+
assert.equal(outbox.batches()[0].refs[0].attempts, 2);
|
|
264
|
+
}
|
|
265
|
+
finally {
|
|
266
|
+
await framework?.stop();
|
|
267
|
+
rmSync(dir, { recursive: true, force: true });
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
test('tools/list_changed installs the data gate before a following push', async () => {
|
|
271
|
+
mkdirSync(TEST_ROOT, { recursive: true });
|
|
272
|
+
const dir = mkdtempSync(join(TEST_ROOT, 'awareness-list-change-'));
|
|
273
|
+
const statusPath = join(dir, 'status.jsonl');
|
|
274
|
+
const releasePath = join(dir, 'release');
|
|
275
|
+
const listChangePath = join(dir, 'list-change');
|
|
276
|
+
const { outboxPath, config } = frameworkConfig(dir, {
|
|
277
|
+
STATUS_PATH: statusPath,
|
|
278
|
+
LEDGER_PATH: join(dir, 'awareness.json'),
|
|
279
|
+
RELEASE_PATH: releasePath,
|
|
280
|
+
LIST_CHANGE_PATH: listChangePath,
|
|
281
|
+
});
|
|
282
|
+
const outbox = new DiscordAwarenessOutbox(outboxPath);
|
|
283
|
+
let framework;
|
|
284
|
+
try {
|
|
285
|
+
framework = await AgentFramework.create(config);
|
|
286
|
+
await waitFor('initial zero-pending push response', () => records(statusPath).some((record) => record.event === 'push-response'));
|
|
287
|
+
const batch = outbox.prepare({
|
|
288
|
+
agentName: 'assistant',
|
|
289
|
+
sourceBranch: 'source',
|
|
290
|
+
targetBranch: 'main',
|
|
291
|
+
activationPolicy: 'explicit',
|
|
292
|
+
refs: [{ serverId: 'discord', channelId: 'discord:guild:startup', messageId: 'message-list' }],
|
|
293
|
+
});
|
|
294
|
+
assert.ok(batch);
|
|
295
|
+
outbox.activate(batch.id);
|
|
296
|
+
writeFileSync(listChangePath, 'change');
|
|
297
|
+
await waitFor('control response while list-change barrier is pending', () => {
|
|
298
|
+
const status = records(statusPath);
|
|
299
|
+
return status.some((record) => record.event === 'tools-list-changed-notification')
|
|
300
|
+
&& status.some((record) => record.event === 'control-response-during-barrier');
|
|
301
|
+
});
|
|
302
|
+
assert.equal(records(statusPath).some((record) => record.event === 'list-change-push-response'), false);
|
|
303
|
+
writeFileSync(releasePath, 'release');
|
|
304
|
+
await waitFor('list-change gated push response', () => records(statusPath).some((record) => record.event === 'list-change-push-response'));
|
|
305
|
+
const push = records(statusPath).find((record) => record.event === 'list-change-push-response');
|
|
306
|
+
assert.equal(push?.ledgerStatus, 'applied');
|
|
307
|
+
assert.equal(outbox.pending('discord').length, 0);
|
|
308
|
+
}
|
|
309
|
+
finally {
|
|
310
|
+
await framework?.stop();
|
|
311
|
+
rmSync(dir, { recursive: true, force: true });
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
for (const dataMethod of DATA_METHODS)
|
|
315
|
+
test(`runtime awareness generation globally gates non-Discord ${dataMethod}`, async () => {
|
|
316
|
+
mkdirSync(TEST_ROOT, { recursive: true });
|
|
317
|
+
const dir = mkdtempSync(join(TEST_ROOT, 'awareness-runtime-global-'));
|
|
318
|
+
const { outboxPath, statusPath, releasePath, listChangePath, config, } = twoServerFrameworkConfig(dir, 'discord-first', dataMethod, true);
|
|
319
|
+
const outbox = new DiscordAwarenessOutbox(outboxPath);
|
|
320
|
+
let framework;
|
|
321
|
+
try {
|
|
322
|
+
framework = await AgentFramework.create(config);
|
|
323
|
+
await waitFor('both initial data responses', () => {
|
|
324
|
+
const initial = records(statusPath).filter((record) => record.event === 'push-response');
|
|
325
|
+
return initial.some((record) => record.serverId === 'discord')
|
|
326
|
+
&& initial.some((record) => record.serverId === 'heartbeat');
|
|
327
|
+
});
|
|
328
|
+
const batch = outbox.prepare({
|
|
329
|
+
agentName: 'assistant',
|
|
330
|
+
sourceBranch: 'source',
|
|
331
|
+
targetBranch: 'main',
|
|
332
|
+
activationPolicy: 'explicit',
|
|
333
|
+
refs: [{
|
|
334
|
+
serverId: 'discord',
|
|
335
|
+
channelId: 'discord:guild:startup',
|
|
336
|
+
messageId: `runtime-global-${dataMethod}`,
|
|
337
|
+
}],
|
|
338
|
+
});
|
|
339
|
+
assert.ok(batch);
|
|
340
|
+
outbox.activate(batch.id);
|
|
341
|
+
writeFileSync(listChangePath, 'change');
|
|
342
|
+
await waitFor('Discord marker call for heartbeat generation', () => records(statusPath).some((record) => record.serverId === 'discord' && record.event === 'reaction-call'));
|
|
343
|
+
assert.equal(records(statusPath).some((record) => record.serverId === 'heartbeat' && record.event === 'list-change-push-response'), false, `fresh heartbeat ${dataMethod} must wait for Discord accounting`);
|
|
344
|
+
writeFileSync(releasePath, 'release');
|
|
345
|
+
await waitFor('globally gated runtime response', () => records(statusPath).some((record) => record.serverId === 'heartbeat' && record.event === 'list-change-push-response'));
|
|
346
|
+
const response = records(statusPath).find((record) => record.serverId === 'heartbeat' && record.event === 'list-change-push-response');
|
|
347
|
+
assert.equal(response?.method, dataMethod);
|
|
348
|
+
assert.equal(response?.ledgerStatus, 'applied');
|
|
349
|
+
assert.equal(outbox.pending('discord').length, 0);
|
|
350
|
+
}
|
|
351
|
+
finally {
|
|
352
|
+
await framework?.stop();
|
|
353
|
+
rmSync(dir, { recursive: true, force: true });
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
test('reconnect pauses fresh inbound data but permits registration and awareness tool service', async () => {
|
|
357
|
+
mkdirSync(TEST_ROOT, { recursive: true });
|
|
358
|
+
const dir = mkdtempSync(join(TEST_ROOT, 'awareness-reconnect-'));
|
|
359
|
+
const statusPath = join(dir, 'status.jsonl');
|
|
360
|
+
const releasePath = join(dir, 'release');
|
|
361
|
+
const crashPath = join(dir, 'crash');
|
|
362
|
+
const generationPath = join(dir, 'generation');
|
|
363
|
+
const { outboxPath, config } = frameworkConfig(dir, {
|
|
364
|
+
STATUS_PATH: statusPath,
|
|
365
|
+
LEDGER_PATH: join(dir, 'awareness.json'),
|
|
366
|
+
RELEASE_PATH: releasePath,
|
|
367
|
+
CRASH_PATH: crashPath,
|
|
368
|
+
GENERATION_PATH: generationPath,
|
|
369
|
+
}, 1_000, {
|
|
370
|
+
reconnect: true,
|
|
371
|
+
reconnectIntervalMs: 20,
|
|
372
|
+
reconnectMaxIntervalMs: 20,
|
|
373
|
+
});
|
|
374
|
+
const outbox = new DiscordAwarenessOutbox(outboxPath);
|
|
375
|
+
let framework;
|
|
376
|
+
try {
|
|
377
|
+
// No startup marker: this path must release push responders synchronously.
|
|
378
|
+
framework = await AgentFramework.create(config);
|
|
379
|
+
await waitFor('initial synchronous push response', () => records(statusPath).some((record) => record.generation === 1 && record.event === 'push-response'));
|
|
380
|
+
const batch = outbox.prepare({
|
|
381
|
+
agentName: 'assistant',
|
|
382
|
+
sourceBranch: 'source',
|
|
383
|
+
targetBranch: 'main',
|
|
384
|
+
activationPolicy: 'explicit',
|
|
385
|
+
refs: [{ serverId: 'discord', channelId: 'discord:guild:startup', messageId: 'message-2' }],
|
|
386
|
+
});
|
|
387
|
+
assert.ok(batch);
|
|
388
|
+
outbox.activate(batch.id);
|
|
389
|
+
writeFileSync(crashPath, 'crash');
|
|
390
|
+
await waitFor('reconnect control plane during pending barrier', () => {
|
|
391
|
+
const second = records(statusPath).filter((record) => record.generation === 2);
|
|
392
|
+
return second.some((record) => record.event === 'registration-response')
|
|
393
|
+
&& second.some((record) => record.event === 'control-response-during-barrier');
|
|
394
|
+
});
|
|
395
|
+
assert.equal(records(statusPath).some((record) => record.generation === 2 && record.event === 'push-response'), false, 'fresh reconnect data must not pass the new gate');
|
|
396
|
+
writeFileSync(releasePath, 'release');
|
|
397
|
+
await waitFor('reconnect gated push response', () => records(statusPath).some((record) => record.generation === 2 && record.event === 'push-response'));
|
|
398
|
+
const push = records(statusPath).find((record) => record.generation === 2 && record.event === 'push-response');
|
|
399
|
+
assert.equal(push?.ledgerStatus, 'applied');
|
|
400
|
+
assert.equal(outbox.pending('discord').length, 0);
|
|
401
|
+
}
|
|
402
|
+
finally {
|
|
403
|
+
await framework?.stop();
|
|
404
|
+
rmSync(dir, { recursive: true, force: true });
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
test('startup fails explicitly when awareness reconciliation throws', async () => {
|
|
408
|
+
mkdirSync(TEST_ROOT, { recursive: true });
|
|
409
|
+
const dir = mkdtempSync(join(TEST_ROOT, 'awareness-reconcile-failure-'));
|
|
410
|
+
const statusPath = join(dir, 'status.jsonl');
|
|
411
|
+
const { outboxPath, config } = frameworkConfig(dir, {
|
|
412
|
+
STATUS_PATH: statusPath,
|
|
413
|
+
LEDGER_PATH: join(dir, 'awareness.json'),
|
|
414
|
+
});
|
|
415
|
+
preparePending(outboxPath);
|
|
416
|
+
const prototype = DiscordAwarenessOutbox.prototype;
|
|
417
|
+
const original = prototype.reconcileForBranch;
|
|
418
|
+
let calls = 0;
|
|
419
|
+
prototype.reconcileForBranch = function (...args) {
|
|
420
|
+
calls++;
|
|
421
|
+
if (calls === 2)
|
|
422
|
+
throw new Error('injected reconcile failure');
|
|
423
|
+
return original.apply(this, args);
|
|
424
|
+
};
|
|
425
|
+
try {
|
|
426
|
+
await assert.rejects(AgentFramework.create(config), /awareness accounting failed.*injected reconcile failure/i);
|
|
427
|
+
assert.equal(records(statusPath).some((record) => record.event === 'push-response'), false, 'failed startup never reports a released data plane');
|
|
428
|
+
}
|
|
429
|
+
finally {
|
|
430
|
+
prototype.reconcileForBranch = original;
|
|
431
|
+
rmSync(dir, { recursive: true, force: true });
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
test('startup fails explicitly when the awareness ledger read throws', async () => {
|
|
435
|
+
mkdirSync(TEST_ROOT, { recursive: true });
|
|
436
|
+
const dir = mkdtempSync(join(TEST_ROOT, 'awareness-read-failure-'));
|
|
437
|
+
const { outboxPath, config } = frameworkConfig(dir);
|
|
438
|
+
preparePending(outboxPath);
|
|
439
|
+
const prototype = DiscordAwarenessOutbox.prototype;
|
|
440
|
+
const original = prototype.pending;
|
|
441
|
+
prototype.pending = () => { throw new Error('injected read failure'); };
|
|
442
|
+
try {
|
|
443
|
+
await assert.rejects(AgentFramework.create(config), /awareness accounting failed.*injected read failure/i);
|
|
444
|
+
}
|
|
445
|
+
finally {
|
|
446
|
+
prototype.pending = original;
|
|
447
|
+
rmSync(dir, { recursive: true, force: true });
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
for (const writeMethod of ['recordSuccess', 'recordFailure'])
|
|
451
|
+
test(`startup fails explicitly when ${writeMethod} cannot persist`, async () => {
|
|
452
|
+
mkdirSync(TEST_ROOT, { recursive: true });
|
|
453
|
+
const dir = mkdtempSync(join(TEST_ROOT, `awareness-${writeMethod}-failure-`));
|
|
454
|
+
const statusPath = join(dir, 'status.jsonl');
|
|
455
|
+
const { outboxPath, config } = frameworkConfig(dir, {
|
|
456
|
+
STATUS_PATH: statusPath,
|
|
457
|
+
LEDGER_PATH: join(dir, 'awareness.json'),
|
|
458
|
+
...(writeMethod === 'recordFailure' ? { PERMANENT_REACTION: '1' } : {}),
|
|
459
|
+
});
|
|
460
|
+
preparePending(outboxPath);
|
|
461
|
+
const prototype = DiscordAwarenessOutbox.prototype;
|
|
462
|
+
const original = prototype[writeMethod];
|
|
463
|
+
prototype[writeMethod] = () => { throw new Error(`injected ${writeMethod} write failure`); };
|
|
464
|
+
try {
|
|
465
|
+
await assert.rejects(AgentFramework.create(config), new RegExp(`awareness accounting failed.*injected ${writeMethod} write failure`, 'i'));
|
|
466
|
+
assert.equal(records(statusPath).some((record) => record.event === 'push-response'), false);
|
|
467
|
+
}
|
|
468
|
+
finally {
|
|
469
|
+
prototype[writeMethod] = original;
|
|
470
|
+
rmSync(dir, { recursive: true, force: true });
|
|
471
|
+
}
|
|
472
|
+
});
|
|
473
|
+
test('runtime reconciliation failure recycles the connection and keeps fresh data gated', async () => {
|
|
474
|
+
mkdirSync(TEST_ROOT, { recursive: true });
|
|
475
|
+
const dir = mkdtempSync(join(TEST_ROOT, 'awareness-runtime-accounting-'));
|
|
476
|
+
const statusPath = join(dir, 'status.jsonl');
|
|
477
|
+
const releasePath = join(dir, 'release');
|
|
478
|
+
const listChangePath = join(dir, 'list-change');
|
|
479
|
+
const generationPath = join(dir, 'generation');
|
|
480
|
+
const { outboxPath, config } = frameworkConfig(dir, {
|
|
481
|
+
STATUS_PATH: statusPath,
|
|
482
|
+
LEDGER_PATH: join(dir, 'awareness.json'),
|
|
483
|
+
RELEASE_PATH: releasePath,
|
|
484
|
+
LIST_CHANGE_PATH: listChangePath,
|
|
485
|
+
GENERATION_PATH: generationPath,
|
|
486
|
+
}, 1_000, {
|
|
487
|
+
reconnect: true,
|
|
488
|
+
reconnectIntervalMs: 20,
|
|
489
|
+
reconnectMaxIntervalMs: 20,
|
|
490
|
+
});
|
|
491
|
+
const outbox = new DiscordAwarenessOutbox(outboxPath);
|
|
492
|
+
let framework;
|
|
493
|
+
try {
|
|
494
|
+
framework = await AgentFramework.create(config);
|
|
495
|
+
await waitFor('initial response', () => records(statusPath).some((record) => record.event === 'push-response'));
|
|
496
|
+
const batch = outbox.prepare({
|
|
497
|
+
agentName: 'assistant',
|
|
498
|
+
sourceBranch: 'source',
|
|
499
|
+
targetBranch: 'main',
|
|
500
|
+
activationPolicy: 'explicit',
|
|
501
|
+
refs: [{ serverId: 'discord', channelId: 'discord:guild:startup', messageId: 'runtime-fail' }],
|
|
502
|
+
});
|
|
503
|
+
outbox.activate(batch.id);
|
|
504
|
+
const prototype = DiscordAwarenessOutbox.prototype;
|
|
505
|
+
const original = prototype.reconcileForBranch;
|
|
506
|
+
let injected = false;
|
|
507
|
+
prototype.reconcileForBranch = function (...args) {
|
|
508
|
+
if (!injected) {
|
|
509
|
+
injected = true;
|
|
510
|
+
throw new Error('injected runtime reconcile failure');
|
|
511
|
+
}
|
|
512
|
+
return original.apply(this, args);
|
|
513
|
+
};
|
|
514
|
+
try {
|
|
515
|
+
writeFileSync(listChangePath, 'change');
|
|
516
|
+
await waitFor('replacement connection awareness call', () => records(statusPath).some((record) => record.generation === 2 && record.event === 'reaction-call'));
|
|
517
|
+
}
|
|
518
|
+
finally {
|
|
519
|
+
prototype.reconcileForBranch = original;
|
|
520
|
+
}
|
|
521
|
+
assert.equal(records(statusPath).some((record) => record.generation === 2 && record.event === 'push-response'), false);
|
|
522
|
+
writeFileSync(releasePath, 'release');
|
|
523
|
+
await waitFor('replacement connection data release', () => records(statusPath).some((record) => record.generation === 2 && record.event === 'push-response'));
|
|
524
|
+
assert.equal(outbox.pending('discord').length, 0);
|
|
525
|
+
}
|
|
526
|
+
finally {
|
|
527
|
+
await framework?.stop();
|
|
528
|
+
rmSync(dir, { recursive: true, force: true });
|
|
529
|
+
}
|
|
530
|
+
});
|
|
531
|
+
function deferred() {
|
|
532
|
+
let resolve;
|
|
533
|
+
let reject;
|
|
534
|
+
const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
|
|
535
|
+
return { promise, resolve, reject };
|
|
536
|
+
}
|
|
537
|
+
test('a control flush that installs a newer generation cannot be released by the old barrier', async () => {
|
|
538
|
+
const oldDrain = deferred();
|
|
539
|
+
const newDrain = deferred();
|
|
540
|
+
const framework = Object.create(AgentFramework.prototype);
|
|
541
|
+
framework.discordAwarenessBarrier = null;
|
|
542
|
+
framework.discordAwarenessBarrierGeneration = 0;
|
|
543
|
+
const drains = [oldDrain.promise, newDrain.promise];
|
|
544
|
+
framework.beginDiscordAwarenessBarrier = () => ({
|
|
545
|
+
requiresBarrier: true,
|
|
546
|
+
promise: drains.shift(),
|
|
547
|
+
});
|
|
548
|
+
let readyCalls = 0;
|
|
549
|
+
let newer;
|
|
550
|
+
const connection = {
|
|
551
|
+
id: 'discord',
|
|
552
|
+
pauseDataPlane: () => { },
|
|
553
|
+
ready: () => { readyCalls++; },
|
|
554
|
+
readyControlPlane: () => {
|
|
555
|
+
if (newer)
|
|
556
|
+
return;
|
|
557
|
+
newer = framework.installMcplDataPlaneGate();
|
|
558
|
+
framework.releaseMcplDataPlaneGate(newer);
|
|
559
|
+
},
|
|
560
|
+
};
|
|
561
|
+
framework.mcplServerRegistry = { getAllServers: () => [connection] };
|
|
562
|
+
const older = framework.installMcplDataPlaneGate();
|
|
563
|
+
framework.releaseMcplDataPlaneGate(older);
|
|
564
|
+
assert.equal(newer.generation, older.generation + 1);
|
|
565
|
+
oldDrain.resolve({ status: 'delivered', delivered: 1, failed: 0 });
|
|
566
|
+
await older.promise;
|
|
567
|
+
assert.equal(framework.completeMcplDataPlaneGate(older), false);
|
|
568
|
+
assert.equal(readyCalls, 0, 'old completion leaves fresh data held');
|
|
569
|
+
newDrain.resolve({ status: 'delivered', delivered: 1, failed: 0 });
|
|
570
|
+
await newer.promise;
|
|
571
|
+
assert.equal(framework.completeMcplDataPlaneGate(newer), true);
|
|
572
|
+
assert.equal(readyCalls, 1);
|
|
573
|
+
});
|
|
574
|
+
test('overlapping reconnect/list-change generations release only the newest barrier', async () => {
|
|
575
|
+
const reconnectDrain = deferred();
|
|
576
|
+
const listChangeDrain = deferred();
|
|
577
|
+
const framework = Object.create(AgentFramework.prototype);
|
|
578
|
+
framework.discordAwarenessBarrier = null;
|
|
579
|
+
framework.discordAwarenessBarrierGeneration = 0;
|
|
580
|
+
const drains = [reconnectDrain.promise, listChangeDrain.promise];
|
|
581
|
+
framework.beginDiscordAwarenessBarrier = () => ({
|
|
582
|
+
requiresBarrier: true,
|
|
583
|
+
promise: drains.shift(),
|
|
584
|
+
});
|
|
585
|
+
let readyCalls = 0;
|
|
586
|
+
const connection = {
|
|
587
|
+
id: 'discord',
|
|
588
|
+
pauseDataPlane: () => { },
|
|
589
|
+
readyControlPlane: () => { },
|
|
590
|
+
ready: () => { readyCalls++; },
|
|
591
|
+
};
|
|
592
|
+
framework.mcplServerRegistry = { getAllServers: () => [connection] };
|
|
593
|
+
const reconnectBarrier = framework.installMcplDataPlaneGate();
|
|
594
|
+
framework.releaseMcplDataPlaneGate(reconnectBarrier);
|
|
595
|
+
const listChangeBarrier = framework.installMcplDataPlaneGate();
|
|
596
|
+
framework.releaseMcplDataPlaneGate(listChangeBarrier);
|
|
597
|
+
reconnectDrain.resolve({ status: 'delivered', delivered: 1, failed: 0 });
|
|
598
|
+
await reconnectBarrier.promise;
|
|
599
|
+
framework.completeMcplDataPlaneGate(reconnectBarrier);
|
|
600
|
+
assert.equal(readyCalls, 0, 'reconnect completion cannot bypass overlapping list-change work');
|
|
601
|
+
listChangeDrain.resolve({ status: 'delivered', delivered: 1, failed: 0 });
|
|
602
|
+
await listChangeBarrier.promise;
|
|
603
|
+
framework.completeMcplDataPlaneGate(listChangeBarrier);
|
|
604
|
+
assert.equal(readyCalls, 1);
|
|
605
|
+
});
|
|
606
|
+
test('synchronous zero-pending flush rechecks the gate after a nested list change', () => {
|
|
607
|
+
const connection = new McplServerConnection('discord', null, null);
|
|
608
|
+
let dataDeliveries = 0;
|
|
609
|
+
connection.on('tools-list-changed', () => connection.pauseDataPlane());
|
|
610
|
+
connection.on('push-event', () => { dataDeliveries++; });
|
|
611
|
+
connection.emit('tools-list-changed');
|
|
612
|
+
connection.emit('push-event', {});
|
|
613
|
+
connection.ready();
|
|
614
|
+
assert.equal(dataDeliveries, 0, 'the nested list-change pause must retain later items from the same ready() snapshot');
|
|
615
|
+
connection.ready();
|
|
616
|
+
assert.equal(dataDeliveries, 1);
|
|
617
|
+
});
|
|
618
|
+
//# sourceMappingURL=mcpl-awareness-barrier.test.js.map
|