@orboto/mcp 0.211.2 → 0.212.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.
- package/dist/http-transport.js +3 -0
- package/dist/http-transport.test.js +33 -0
- package/dist/index.js +3 -1
- package/dist/manifest-size.test.js +2 -0
- package/dist/oauth-bootstrap.js +119 -35
- package/dist/oauth-bootstrap.test.js +118 -2
- package/dist/probe-session.js +3 -1
- package/dist/probe-session.test.js +11 -2
- package/dist/resources.js +8 -0
- package/dist/server.js +2 -0
- package/dist/tail-labels.js +30 -0
- package/dist/tool-parameters.test.js +5 -4
- package/dist/tools/agent-coordination.js +50 -8
- package/dist/tools/agent-coordination.test.js +42 -0
- package/dist/tools/agent-who.js +34 -0
- package/dist/tools/agent-who.test.js +31 -0
- package/dist/tools/bulk-writes.js +2 -2
- package/dist/tools/capacity-guide.js +46 -0
- package/dist/tools/capacity-session.js +109 -0
- package/dist/tools/capacity-session.test.js +179 -0
- package/dist/tools/capacity-tail.test.js +152 -0
- package/dist/tools/docs.js +4 -4
- package/dist/tools/help.js +4 -3
- package/dist/tools/live-transcript.js +93 -0
- package/dist/tools/live-transcript.test.js +49 -0
- package/dist/tools/session-check.js +20 -5
- package/dist/tools/session-check.test.js +3 -3
- package/dist/tools/session-start.js +3 -0
- package/dist/tools/session-start.test.js +1 -0
- package/dist/with-metrics.js +9 -5
- package/package.json +1 -1
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { z } from 'zod';
|
|
7
7
|
import { AgentInventoryEntrySchema, AgentInventoryKindSchema } from './agent-presence-schema.js';
|
|
8
|
+
import { OrbotoApiError } from '../orboto-client.js';
|
|
8
9
|
import { mcpInstanceToken } from './shared.js';
|
|
9
10
|
import { AgentSessionScopeSchema } from './agent-session-scope.js';
|
|
10
11
|
export const agentHeartbeatToolConfig = {
|
|
@@ -84,11 +85,32 @@ export function makeAgentPresenceHandler(client) {
|
|
|
84
85
|
}
|
|
85
86
|
/** ORB-2263 - mirror of AgentNotifyRoutedToSchema in @orboto/shared-schema (drift test in agent-coordination.test.ts). */
|
|
86
87
|
export const NOTIFY_ROUTED_TO = ['session', 'scope', 'account'];
|
|
88
|
+
/** ORB-2264 - the 409 `no_recipient` as a tool error naming who could receive it instead. */
|
|
89
|
+
export function noRecipientText(err) {
|
|
90
|
+
if (err.status !== 409)
|
|
91
|
+
return null;
|
|
92
|
+
let body;
|
|
93
|
+
try {
|
|
94
|
+
body = JSON.parse(err.body);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
if (body.errorKey !== 'errors.agents.no_recipient')
|
|
100
|
+
return null;
|
|
101
|
+
const p = body.errorParams ?? {};
|
|
102
|
+
const live = (p.liveSessions ?? []).map((s) => `${s.addresses[0] ?? s.email} (${s.email}, ${s.shortId})`);
|
|
103
|
+
return `not sent: no live session can receive a request to ${p.address ?? '?'} (${p.reason ?? 'no_recipient'}). `
|
|
104
|
+
+ (live.length ? `Live sessions: ${live.join('; ')}. ` : 'No live session declared the project. ')
|
|
105
|
+
+ 'Address one of them, or send again with queue: true and project to keep it until a session declares the project.';
|
|
106
|
+
}
|
|
87
107
|
export const agentNotifyToolConfig = {
|
|
88
108
|
title: 'Notify another agent / user',
|
|
89
|
-
description: 'Message
|
|
109
|
+
description: 'Message an agent or user. `to` takes a role address integrator@KEY (every live session with that role on the project, any account, each as addressed mail), an account email or a session id; targetEmail names the account (with a role address it narrows to that account). kind: request|error = work inside the scope the recipient holds (request needs a target + outcome); info = information; complete = result (needs outcome). payload: free-form JSON; threadId links a reply to the message it answers. toSessionRef (instance short id, session ref or id) reaches ONE session of the account; project scopes an account message to sessions declared on it (ORB-2136). A request no live session can receive is refused (no_recipient, naming the live sessions of the project; orboto_who lists them before sending) unless queue: true keeps it in the inbox until a session declares the project (ORB-2264). The answer names routedTo (session, scope or account); routingNote "no session declared KEY" means nothing wakes - escalate instead of assuming delivery (ORB-2263).',
|
|
90
110
|
inputSchema: z.object({
|
|
91
|
-
targetEmail: z.string().email(),
|
|
111
|
+
targetEmail: z.string().email().optional(),
|
|
112
|
+
to: z.string().min(3).max(200).optional(),
|
|
113
|
+
queue: z.boolean().optional(),
|
|
92
114
|
kind: z.enum(['info', 'request', 'complete', 'error']).default('info'),
|
|
93
115
|
subject: z.string().min(1).max(200),
|
|
94
116
|
outcome: z.string().min(10).max(1000).optional(),
|
|
@@ -104,6 +126,8 @@ export const agentNotifyToolConfig = {
|
|
|
104
126
|
toSessionId: z.string().uuid().nullable(),
|
|
105
127
|
routedTo: z.enum(NOTIFY_ROUTED_TO),
|
|
106
128
|
routingNote: z.string().optional(),
|
|
129
|
+
recipients: z.array(z.string()).optional(),
|
|
130
|
+
queued: z.boolean().optional(),
|
|
107
131
|
}).shape,
|
|
108
132
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
109
133
|
};
|
|
@@ -134,11 +158,13 @@ export function makeAgentBroadcastHandler(client) {
|
|
|
134
158
|
/** ORB-2223 - the message-kind definition this versioned sender enforces; the API only warns. */
|
|
135
159
|
export function notifyDefinitionError(args) {
|
|
136
160
|
const kind = args.kind ?? 'info';
|
|
161
|
+
if (!args.to && !args.targetEmail)
|
|
162
|
+
return 'Invalid arguments: name a recipient - `to` (integrator@KEY, an email or a session id) or `targetEmail`.';
|
|
137
163
|
if ((kind === 'request' || kind === 'complete') && !args.outcome?.trim()) {
|
|
138
164
|
return `Invalid arguments: \`outcome\` is required for a ${kind} - the outcome sentence (what must be true when it is done, or what was delivered).`;
|
|
139
165
|
}
|
|
140
|
-
if (kind === 'request' && !args.project && !args.toSessionRef) {
|
|
141
|
-
return 'Invalid arguments: a request needs a target - `project` (key or UUID) or `toSessionRef`.';
|
|
166
|
+
if (kind === 'request' && !args.project && !args.toSessionRef && !(args.to && !args.to.includes('.'))) {
|
|
167
|
+
return 'Invalid arguments: a request needs a target - a role address in `to` (integrator@KEY), `project` (key or UUID) or `toSessionRef`.';
|
|
142
168
|
}
|
|
143
169
|
return null;
|
|
144
170
|
}
|
|
@@ -148,13 +174,29 @@ export function makeAgentNotifyHandler(client) {
|
|
|
148
174
|
if (invalid)
|
|
149
175
|
return { isError: true, content: [{ type: 'text', text: invalid }] };
|
|
150
176
|
const senderRef = mcpInstanceToken(args.senderRef, extra);
|
|
151
|
-
|
|
152
|
-
|
|
177
|
+
let res;
|
|
178
|
+
try {
|
|
179
|
+
res = await client.post('/v1/agent/notify', { ...args, senderRef }, { instanceToken: mcpInstanceToken(undefined, extra) });
|
|
180
|
+
}
|
|
181
|
+
catch (err) {
|
|
182
|
+
const refused = err instanceof OrbotoApiError ? noRecipientText(err) : null;
|
|
183
|
+
if (refused)
|
|
184
|
+
return { isError: true, content: [{ type: 'text', text: refused }] };
|
|
185
|
+
throw err;
|
|
186
|
+
}
|
|
187
|
+
const who = args.to ?? args.targetEmail ?? '?';
|
|
188
|
+
const recipients = (res.recipients ?? []).map((r) => r.label);
|
|
189
|
+
const target = recipients.length > 1 ? recipients.join(', ') : res.toSessionId ? `${who} session ${res.toSessionId.slice(0, 8)}` : who;
|
|
153
190
|
const routedTo = res.routedTo ?? (res.toSessionId ? 'session' : 'account');
|
|
154
191
|
const note = res.routingNote ? ` - ${res.routingNote}` : '';
|
|
155
192
|
return {
|
|
156
|
-
content: [{ type: 'text', text:
|
|
157
|
-
structuredContent: {
|
|
193
|
+
content: [{ type: 'text', text: `${res.queued ? 'queued for' : 'notified'} ${target} (message ${res.messageId}, routed to ${routedTo}${note})` }],
|
|
194
|
+
structuredContent: {
|
|
195
|
+
ok: true, messageId: res.messageId, toSessionId: res.toSessionId ?? null, routedTo,
|
|
196
|
+
...(res.routingNote ? { routingNote: res.routingNote } : {}),
|
|
197
|
+
...(recipients.length ? { recipients } : {}),
|
|
198
|
+
...(res.queued ? { queued: true } : {}),
|
|
199
|
+
},
|
|
158
200
|
};
|
|
159
201
|
};
|
|
160
202
|
}
|
|
@@ -267,3 +267,45 @@ describe('ORB-2136 - session addressing', () => {
|
|
|
267
267
|
expect(result.structuredContent).toMatchObject({ scope: { role: 'integrator', projectKeys: ['ORB'] } });
|
|
268
268
|
});
|
|
269
269
|
});
|
|
270
|
+
describe('orboto_agent_notify role addresses (ORB-2264)', () => {
|
|
271
|
+
it('sends `to` as written, lists every recipient and needs no targetEmail', async () => {
|
|
272
|
+
const bodies = [];
|
|
273
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
|
|
274
|
+
bodies.push(JSON.parse(String(init.body)));
|
|
275
|
+
return {
|
|
276
|
+
ok: true, status: 200, statusText: 'OK', text: async () => '',
|
|
277
|
+
json: async () => ({
|
|
278
|
+
ok: true, messageId: '00000000-0000-4000-8000-000000000001', toSessionId: '00000000-0000-4000-8000-0000000000aa', routedTo: 'session',
|
|
279
|
+
messageIds: ['00000000-0000-4000-8000-000000000001', '00000000-0000-4000-8000-000000000002'],
|
|
280
|
+
recipients: [{ label: 'claude@orboto.io (integrator, aaaaaaaa)', sessionId: 'a' }, { label: 'claude+b@orboto.io (integrator, bbbbbbbb)', sessionId: 'b' }],
|
|
281
|
+
}),
|
|
282
|
+
};
|
|
283
|
+
});
|
|
284
|
+
const result = await makeAgentNotifyHandler(client)({ to: 'integrator@ORB', kind: 'request', subject: 'release 0.212.0', outcome: 'the release is tagged and deployed' });
|
|
285
|
+
expect(bodies[0]).toMatchObject({ to: 'integrator@ORB', kind: 'request' });
|
|
286
|
+
expect(bodies[0].targetEmail).toBeUndefined();
|
|
287
|
+
expect(result.structuredContent).toMatchObject({ routedTo: 'session', recipients: ['claude@orboto.io (integrator, aaaaaaaa)', 'claude+b@orboto.io (integrator, bbbbbbbb)'] });
|
|
288
|
+
});
|
|
289
|
+
it('turns 409 no_recipient into a tool error that names the live sessions and the queue option', async () => {
|
|
290
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async () => ({
|
|
291
|
+
ok: false, status: 409, statusText: 'Conflict', json: async () => ({}),
|
|
292
|
+
text: async () => JSON.stringify({
|
|
293
|
+
error: 'x', errorKey: 'errors.agents.no_recipient',
|
|
294
|
+
errorParams: { address: 'review@ORB', reason: 'no_recipient', liveSessions: [{ shortId: 'f072e1c5', email: 'claude@orboto.io', addresses: ['integrator@ORB'] }] },
|
|
295
|
+
}),
|
|
296
|
+
}));
|
|
297
|
+
const result = await makeAgentNotifyHandler(client)({ to: 'review@ORB', kind: 'request', subject: 'review ORB-1', outcome: 'ORB-1 is reviewed with findings' });
|
|
298
|
+
expect(result.isError).toBe(true);
|
|
299
|
+
const text = result.content[0].text;
|
|
300
|
+
expect(text).toContain('review@ORB');
|
|
301
|
+
expect(text).toContain('integrator@ORB (claude@orboto.io, f072e1c5)');
|
|
302
|
+
expect(text).toContain('queue: true');
|
|
303
|
+
});
|
|
304
|
+
it('refuses a message without any recipient before calling the API', async () => {
|
|
305
|
+
const fetch = vi.spyOn(globalThis, 'fetch');
|
|
306
|
+
const result = await makeAgentNotifyHandler(client)({ subject: 'nobody' });
|
|
307
|
+
expect(result.isError).toBe(true);
|
|
308
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
309
|
+
expect(agentNotifyToolConfig.description).toContain('integrator@KEY');
|
|
310
|
+
});
|
|
311
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** ORB-2264 - the live directory: who a sender can address before sending. */
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { AgentSessionRoleSchema } from './agent-session-scope.js';
|
|
4
|
+
export const agentWhoToolConfig = {
|
|
5
|
+
title: 'Who can receive agent mail',
|
|
6
|
+
description: 'Live agent sessions and the role addresses (integrator@KEY) they answer to. Each row names account, role, declared projects, last seen, wake channel and current ticket; address a role from here, never a session id copied from a log.',
|
|
7
|
+
inputSchema: z.object({
|
|
8
|
+
project: z.string().min(1).max(64).optional(),
|
|
9
|
+
role: AgentSessionRoleSchema.optional(),
|
|
10
|
+
}).shape,
|
|
11
|
+
outputSchema: z.object({ sessions: z.array(z.record(z.string(), z.unknown())) }).shape,
|
|
12
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
13
|
+
};
|
|
14
|
+
/** One directory row as a line: address, account, session, projects, channel, ticket. */
|
|
15
|
+
export function whoLine(s) {
|
|
16
|
+
const address = s.addresses.join(', ') || (s.role ? `${s.role} (no project)` : 'no scope');
|
|
17
|
+
const channel = s.channel.announced ? `${s.channel.announced}${s.channel.online ? ' online' : ' offline'}` : 'no channel';
|
|
18
|
+
const ticket = s.currentTicket ? `; on ${s.currentTicket.key ?? s.currentTicket.title}` : '';
|
|
19
|
+
return `- ${address} - ${s.email} session ${s.shortId}, ${s.status}, ${channel}, last seen ${s.lastSeenAt}${ticket}`;
|
|
20
|
+
}
|
|
21
|
+
export function makeAgentWhoHandler(client) {
|
|
22
|
+
return async (args = {}) => {
|
|
23
|
+
const qs = new URLSearchParams();
|
|
24
|
+
if (args.project)
|
|
25
|
+
qs.set('project', args.project);
|
|
26
|
+
if (args.role)
|
|
27
|
+
qs.set('role', args.role);
|
|
28
|
+
const res = await client.get(`/v1/agent/who${qs.size ? `?${qs.toString()}` : ''}`);
|
|
29
|
+
const lines = res.sessions.length
|
|
30
|
+
? [`${res.sessions.length} live session(s):`, ...res.sessions.map(whoLine)]
|
|
31
|
+
: ['No live session matches - a request sent now answers no_recipient unless it is queued.'];
|
|
32
|
+
return { content: [{ type: 'text', text: lines.join('\n') }], structuredContent: { sessions: res.sessions } };
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** ORB-2264 - orboto_who reads the live directory. */
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { OrbotoClient } from '../orboto-client.js';
|
|
4
|
+
import { agentWhoToolConfig, makeAgentWhoHandler, whoLine } from './agent-who.js';
|
|
5
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
6
|
+
const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_test' });
|
|
7
|
+
const entry = {
|
|
8
|
+
sessionId: 'f072e1c5-0000-4000-8000-000000000000', shortId: 'f072e1c5', email: 'claude@orboto.io', role: 'integrator',
|
|
9
|
+
projectKeys: ['ORB'], addresses: ['integrator@ORB'], lastSeenAt: '2026-09-30T08:00:00Z',
|
|
10
|
+
channel: { announced: 'claude-code', online: true }, currentTicket: { key: 'ORB-2264', title: 'mail' }, status: 'working',
|
|
11
|
+
};
|
|
12
|
+
describe('orboto_who', () => {
|
|
13
|
+
it('GETs /v1/agent/who with the filters and prints one line per session', async () => {
|
|
14
|
+
const urls = [];
|
|
15
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => {
|
|
16
|
+
urls.push(String(url));
|
|
17
|
+
return { ok: true, status: 200, statusText: 'OK', text: async () => '', json: async () => ({ sessions: [entry] }) };
|
|
18
|
+
});
|
|
19
|
+
const result = await makeAgentWhoHandler(client)({ project: 'ORB', role: 'integrator' });
|
|
20
|
+
expect(urls[0]).toBe('https://orboto.example.com/v1/agent/who?project=ORB&role=integrator');
|
|
21
|
+
expect(result.content[0].text).toContain('integrator@ORB - claude@orboto.io session f072e1c5, working, claude-code online');
|
|
22
|
+
expect(result.structuredContent).toMatchObject({ sessions: [expect.objectContaining({ shortId: 'f072e1c5' })] });
|
|
23
|
+
});
|
|
24
|
+
it('says a request would be refused when nobody matches, and is read-only', async () => {
|
|
25
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async () => ({ ok: true, status: 200, statusText: 'OK', text: async () => '', json: async () => ({ sessions: [] }) }));
|
|
26
|
+
const result = await makeAgentWhoHandler(client)({});
|
|
27
|
+
expect(result.content[0].text).toContain('no_recipient');
|
|
28
|
+
expect(agentWhoToolConfig.annotations.readOnlyHint).toBe(true);
|
|
29
|
+
expect(whoLine({ ...entry, addresses: [], role: null, channel: { announced: null, online: false }, currentTicket: null })).toContain('no scope');
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -15,10 +15,10 @@ const CATEGORY_TO_LEGACY = {
|
|
|
15
15
|
wont_fix: 'WONT_FIX',
|
|
16
16
|
};
|
|
17
17
|
const TICKET_KEY_ARRAY = z.array(z.string().min(3)).min(1).max(200).optional()
|
|
18
|
-
.describe('Ticket keys
|
|
18
|
+
.describe('Ticket keys, e.g. ["ACME-1"]; at most 200. Give ticketKeys or query.');
|
|
19
19
|
/** ORB-2054 - the target set can be an OQL query instead of a key list. */
|
|
20
20
|
const QUERY_SOURCE = z.string().min(1).max(8000).optional()
|
|
21
|
-
.describe('OQL
|
|
21
|
+
.describe('OQL selecting the tickets instead of ticketKeys (orboto_query grammar, your ACL); at most 200 matches.');
|
|
22
22
|
const QUERY_PROJECT = z.string().optional()
|
|
23
23
|
.describe('Project key that scopes `query` (prepended as project = KEY).');
|
|
24
24
|
/** Matches per call the MCP loop will process; above that the server-side bulk route is the right tool. */
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** ORB-2272, ORB-2274 - the build capacity recipes for an MCP agent, served by orboto_help topic "capacity". */
|
|
2
|
+
export function capacityGuideText() {
|
|
3
|
+
return [
|
|
4
|
+
'Build capacity (shared build hosts, CI runners, game build hosts) runs through orboto_api_call; the metrics label each call as orboto_capacity_list, _claim, _renew, _release, _windows or _stats.',
|
|
5
|
+
'Build capacity (shared build hosts, CI runners, game build hosts) runs through orboto_api_call; the metrics label each call as orboto_capacity_list, _claim, _renew, _release, _windows, _plan or _seed.',
|
|
6
|
+
'',
|
|
7
|
+
'Who holds a resource, its queue and windows:',
|
|
8
|
+
' orboto_api_call {"method":"GET","path":"/capacity/resources/build-host:runner-1"}',
|
|
9
|
+
'Every resource with used slots and queue length:',
|
|
10
|
+
' orboto_api_call {"method":"GET","path":"/capacity/resources"}',
|
|
11
|
+
'Your own active claims:',
|
|
12
|
+
' orboto_api_call {"method":"GET","path":"/capacity/claims","query":{"mine":true,"active":true}}',
|
|
13
|
+
'',
|
|
14
|
+
'Claim before a heavy run (granted when a slot is free, else queued by priority release > develop > pr > batch, then arrival):',
|
|
15
|
+
' orboto_api_call {"method":"POST","path":"/capacity/claims","body":{"resource":"build-host:runner-1","reason":"0.212.0 release build","expectedMinutes":45,"priority":"release"}}',
|
|
16
|
+
' The claim is held by this agent session. A queued answer names its position, what it waits for (waitingFor) and the holders.',
|
|
17
|
+
'Renew while the run lasts, and while queued, before the deadline in the claim:',
|
|
18
|
+
' orboto_api_call {"method":"POST","path":"/capacity/claims/<claimId>/renew","body":{}}',
|
|
19
|
+
'Poll a queued claim until state is granted:',
|
|
20
|
+
' orboto_api_call {"method":"GET","path":"/capacity/claims/<claimId>"}',
|
|
21
|
+
'Release when the run ends (idempotent):',
|
|
22
|
+
' orboto_api_call {"method":"POST","path":"/capacity/claims/<claimId>/release","body":{"reason":"build done"}}',
|
|
23
|
+
'Reserved windows of every resource:',
|
|
24
|
+
' orboto_api_call {"method":"GET","path":"/capacity/windows"}',
|
|
25
|
+
'Wait, hold, expirations, yields, overrun alerts and queue depth per resource over the last 24 hours and 7 days (needs admin:agents:read):',
|
|
26
|
+
' orboto_api_call {"method":"GET","path":"/capacity/metrics","query":{"resource":"build-host:runner-1"}}',
|
|
27
|
+
'',
|
|
28
|
+
'Plan: the earliest slot for a duration, around windows, holders, the queue and booked reservations (409 when nothing fits within 14 days):',
|
|
29
|
+
' orboto_api_call {"method":"GET","path":"/capacity/resources/build-host:runner-1/plan","query":{"minutes":45,"notBefore":"2026-10-01T18:00:00+02:00"}}',
|
|
30
|
+
'Book it: a reservation claim of this session that nothing else can take, granted at startsAt with a wake message; renew it within graceMinutes (default 15) of the grant or it expires:',
|
|
31
|
+
' orboto_api_call {"method":"POST","path":"/capacity/resources/build-host:runner-1/plan","body":{"minutes":45,"book":true,"reason":"0.212.0 release window","priority":"release"}}',
|
|
32
|
+
'Use it: renew once right after the grant (that sets usedAt), then while the run lasts, and release it when the run ends; from a shell, orboto capacity wait <claimId> --hold -- <command...> does all three.',
|
|
33
|
+
'An integrator books the release window this way (CLI: orboto capacity plan <resource> --for 45m --book --priority release) before pushing the release commit;',
|
|
34
|
+
'a release reservation takes no slot: it keeps every lower priority off the resource during its run while the release runs use every slot, so plan a develop run with "priority":"develop".',
|
|
35
|
+
'',
|
|
36
|
+
'Seed the nightly Unity build window on the instance that runs the Unity host (admin:capacity:write, idempotent; optional resource, default unity:build, and timezone, default the workspace zone):',
|
|
37
|
+
' orboto_api_call {"method":"POST","path":"/capacity/seeds/nightly-unity","body":{}}',
|
|
38
|
+
'',
|
|
39
|
+
'Automatic release: orboto_session_check (full toolset only: ?toolset=full or ORBOTO_MCP_TOOLSET=full) releases the granted claims of this session on may_stop,',
|
|
40
|
+
'except claims a live `orboto capacity claim --hold` or `orboto capacity wait --hold` process keeps (marker ~/.orboto/capacity-holds/<claimId>.json), and every claim with sessionEnd: true.',
|
|
41
|
+
'A curated agent has no orboto_session_check: it releases with orboto_api_call POST /capacity/claims/<claimId>/release, and the SessionEnd hook (`orboto session-check --session-end`) ends the rest.',
|
|
42
|
+
'The MCP server releases every claim when a probe or an HTTP client ends its session; a stdio stdin end (reconnect, harness restart) releases only the granted claims no live hold keeps.',
|
|
43
|
+
'A claim not renewed by its deadline expires and the next claim in line gets the slot. A yieldRequest on your claim asks you to release early; nothing is ever killed.',
|
|
44
|
+
'Resources and windows are administered with admin:capacity:write on the Capacity page or through the same REST routes.',
|
|
45
|
+
].join('\n');
|
|
46
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/** ORB-2272 - releases the capacity claims an agent session holds, on a may_stop completion check (granted only) and at session end (every active claim). */
|
|
2
|
+
import { readFileSync, statSync, unlinkSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { PROBE_SESSION_ENV } from '../probe-session.js';
|
|
6
|
+
const PAGE = 100;
|
|
7
|
+
const MAX_PAGES = 10;
|
|
8
|
+
async function activeClaims(client, sessionId, instanceToken) {
|
|
9
|
+
const rows = [];
|
|
10
|
+
let cursor = null;
|
|
11
|
+
for (let page = 0; page < MAX_PAGES; page += 1) {
|
|
12
|
+
const q = new URLSearchParams({ holderKind: 'agent-session', holderId: sessionId, active: 'true', limit: String(PAGE) });
|
|
13
|
+
if (cursor)
|
|
14
|
+
q.set('cursor', cursor);
|
|
15
|
+
const res = await client.get(`/capacity/claims?${q}`, instanceToken ? { instanceToken } : {});
|
|
16
|
+
rows.push(...res.items);
|
|
17
|
+
cursor = res.nextCursor;
|
|
18
|
+
if (!cursor)
|
|
19
|
+
break;
|
|
20
|
+
}
|
|
21
|
+
return rows;
|
|
22
|
+
}
|
|
23
|
+
/** The marker a live `orboto capacity claim --hold` process (ORB-2270) writes per claim: `~/.orboto/capacity-holds/<claimId>.json`, `{"pid":N}`, 0600 in a 0700 directory. */
|
|
24
|
+
export function capacityHoldPath(claimId) {
|
|
25
|
+
return join(process.env.HOME || process.env.USERPROFILE || homedir(), '.orboto', 'capacity-holds', `${claimId}.json`);
|
|
26
|
+
}
|
|
27
|
+
/** True while the marker names a live pid; a marker of a dead pid is removed, one another user owns is ignored. */
|
|
28
|
+
export function capacityHoldAlive(claimId) {
|
|
29
|
+
const path = capacityHoldPath(claimId);
|
|
30
|
+
let pid = 0;
|
|
31
|
+
try {
|
|
32
|
+
const info = statSync(path);
|
|
33
|
+
if (typeof process.getuid === 'function' && info.uid !== process.getuid())
|
|
34
|
+
return false;
|
|
35
|
+
pid = Number(JSON.parse(readFileSync(path, 'utf8')).pid);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
41
|
+
return false;
|
|
42
|
+
try {
|
|
43
|
+
process.kill(pid, 0);
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
if (err.code === 'EPERM')
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
unlinkSync(path);
|
|
52
|
+
}
|
|
53
|
+
catch { /* already gone */ }
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
/** `scope: 'granted'` on may_stop, `'all'` at session end; one failed release never stops the others. */
|
|
57
|
+
export async function releaseSessionCapacity(client, sessionId, scope, reason, instanceToken, opts = {}) {
|
|
58
|
+
const claims = (await activeClaims(client, sessionId, instanceToken))
|
|
59
|
+
.filter((c) => scope === 'all' || c.state === 'granted')
|
|
60
|
+
.filter((c) => scope === 'all' || !opts.respectHolds || !capacityHoldAlive(c.id));
|
|
61
|
+
const out = { released: [], failed: [] };
|
|
62
|
+
for (const claim of claims) {
|
|
63
|
+
try {
|
|
64
|
+
await client.post(`/capacity/claims/${claim.id}/release`, { reason }, instanceToken ? { instanceToken } : {});
|
|
65
|
+
out.released.push({ id: claim.id, resource: claim.resourceName, was: claim.state === 'queued' ? 'queued' : 'granted' });
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
out.failed.push({ id: claim.id, resource: claim.resourceName, error: err instanceof Error ? err.message.slice(0, 200) : String(err) });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
export const SESSION_END_RELEASE_DEADLINE_MS = 10_000;
|
|
74
|
+
/** Resolves the session behind the instance token, then releases every active claim (`all`) or only the granted claims no live hold keeps (`turn`); never throws and never outlasts the deadline. */
|
|
75
|
+
export async function releaseCapacityAtSessionEnd(client, instanceToken, deadlineMs = SESSION_END_RELEASE_DEADLINE_MS, mode = 'all') {
|
|
76
|
+
const release = (async () => {
|
|
77
|
+
const { sessionId } = await client.get('/agents/session/unfinished', { instanceToken });
|
|
78
|
+
return mode === 'all'
|
|
79
|
+
? releaseSessionCapacity(client, sessionId, 'all', 'agent session ended', instanceToken)
|
|
80
|
+
: releaseSessionCapacity(client, sessionId, 'granted', 'MCP connection ended while no process held the claim', instanceToken, { respectHolds: true });
|
|
81
|
+
})().catch(() => null);
|
|
82
|
+
let timer;
|
|
83
|
+
const deadline = new Promise((resolve) => { timer = setTimeout(() => resolve(null), deadlineMs); timer.unref?.(); });
|
|
84
|
+
try {
|
|
85
|
+
return await Promise.race([release, deadline]);
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
clearTimeout(timer);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** Stdio: a probe's stdin end ends its session and releases every claim; a real session outlives its stdin (reconnect, harness restart), so only granted claims no live hold keeps go, and the SessionEnd hook ends the rest. The runner is memoised so a probe exit awaits the same release. */
|
|
92
|
+
export function releaseCapacityOnStdinEnd(stdin, client, instanceToken, env = process.env) {
|
|
93
|
+
let running = null;
|
|
94
|
+
const mode = env[PROBE_SESSION_ENV] === '1' ? 'all' : 'turn';
|
|
95
|
+
const run = () => (running ??= releaseCapacityAtSessionEnd(client, instanceToken, SESSION_END_RELEASE_DEADLINE_MS, mode));
|
|
96
|
+
stdin.once('end', () => { void run(); });
|
|
97
|
+
stdin.once('close', () => { void run(); });
|
|
98
|
+
return run;
|
|
99
|
+
}
|
|
100
|
+
export function describeCapacityRelease(res) {
|
|
101
|
+
if (!res.released.length && !res.failed.length)
|
|
102
|
+
return '';
|
|
103
|
+
const done = res.released.map((c) => `${c.resource} (${c.was}, ${c.id})`).join(', ');
|
|
104
|
+
const failed = res.failed.map((c) => `${c.resource} (${c.id}): ${c.error}`).join('; ');
|
|
105
|
+
return [
|
|
106
|
+
done ? `Released capacity claims of this session: ${done}.` : '',
|
|
107
|
+
failed ? `Release failed, release by hand with orboto_api_call POST /capacity/claims/<id>/release: ${failed}.` : '',
|
|
108
|
+
].filter(Boolean).join(' ');
|
|
109
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/** ORB-2272 - the MCP releases the capacity claims of its agent session: granted ones on may_stop, every one with sessionEnd and at session end. */
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
7
|
+
import { OrbotoApiError } from '../orboto-client.js';
|
|
8
|
+
import { makeSessionCheckHandler } from './session-check.js';
|
|
9
|
+
import { mcpProcessInstance } from './shared.js';
|
|
10
|
+
import { releaseCapacityAtSessionEnd, releaseCapacityOnStdinEnd, releaseSessionCapacity } from './capacity-session.js';
|
|
11
|
+
const GRANTED = { id: 'c-granted', resourceName: 'build-host:runner-1', state: 'granted' };
|
|
12
|
+
const QUEUED = { id: 'c-queued', resourceName: 'unity:mac-1', state: 'queued' };
|
|
13
|
+
function fakeClient(verdict, opts = {}) {
|
|
14
|
+
const released = [];
|
|
15
|
+
const listed = [];
|
|
16
|
+
const get = vi.fn(async (path) => {
|
|
17
|
+
if (path === '/agents/session/unfinished')
|
|
18
|
+
return { sessionId: 'sess-uuid', verdict, reason: verdict, text: `verdict ${verdict}` };
|
|
19
|
+
if (path.startsWith('/capacity/claims?')) {
|
|
20
|
+
listed.push(path);
|
|
21
|
+
const cursor = new URLSearchParams(path.split('?')[1]).get('cursor');
|
|
22
|
+
return cursor ? { items: [QUEUED], nextCursor: null } : { items: [GRANTED], nextCursor: 'page-2' };
|
|
23
|
+
}
|
|
24
|
+
throw new Error(`unexpected GET ${path}`);
|
|
25
|
+
});
|
|
26
|
+
const post = vi.fn(async (path, body, o) => {
|
|
27
|
+
const id = path.split('/')[3];
|
|
28
|
+
if (id === opts.failRelease)
|
|
29
|
+
throw new OrbotoApiError(403, '{"error":"claim_not_yours"}', path);
|
|
30
|
+
released.push({ id, reason: body.reason, token: o?.instanceToken });
|
|
31
|
+
return { id, state: 'released' };
|
|
32
|
+
});
|
|
33
|
+
return { client: { get, post }, released, listed, get };
|
|
34
|
+
}
|
|
35
|
+
describe('ORB-2272 - orboto_session_check releases the session\'s capacity claims', () => {
|
|
36
|
+
it('may_stop releases the granted claim and keeps the queued one', async () => {
|
|
37
|
+
const api = fakeClient('may_stop');
|
|
38
|
+
const res = await makeSessionCheckHandler(api.client)({ action: 'check' }, { sessionId: 'http-1' });
|
|
39
|
+
expect(api.released.map((r) => r.id)).toEqual(['c-granted']);
|
|
40
|
+
expect(api.released[0]).toMatchObject({ reason: 'completion check: may_stop', token: 'mcp-http-1' });
|
|
41
|
+
expect(api.listed[0]).toContain('holderKind=agent-session');
|
|
42
|
+
expect(api.listed[0]).toContain('holderId=sess-uuid');
|
|
43
|
+
expect(api.listed[0]).toContain('active=true');
|
|
44
|
+
expect(res.content[0].text).toContain('build-host:runner-1 (granted, c-granted)');
|
|
45
|
+
expect(res.structuredContent).toMatchObject({ verdict: 'may_stop', capacityReleased: [{ id: 'c-granted', was: 'granted' }], capacityReleaseFailed: [] });
|
|
46
|
+
});
|
|
47
|
+
it('continue releases nothing and does not even list claims', async () => {
|
|
48
|
+
const api = fakeClient('continue');
|
|
49
|
+
const res = await makeSessionCheckHandler(api.client)({});
|
|
50
|
+
expect(api.released).toEqual([]);
|
|
51
|
+
expect(api.listed).toEqual([]);
|
|
52
|
+
expect(res.structuredContent).not.toHaveProperty('capacityReleased');
|
|
53
|
+
});
|
|
54
|
+
it('sessionEnd releases the granted and the queued claim, whatever the verdict', async () => {
|
|
55
|
+
const api = fakeClient('continue');
|
|
56
|
+
const res = await makeSessionCheckHandler(api.client)({ action: 'check', sessionEnd: true });
|
|
57
|
+
expect(api.released.map((r) => [r.id, r.reason])).toEqual([['c-granted', 'agent session ended'], ['c-queued', 'agent session ended']]);
|
|
58
|
+
expect(res.structuredContent).toMatchObject({ capacityReleased: [{ id: 'c-granted', was: 'granted' }, { id: 'c-queued', was: 'queued' }] });
|
|
59
|
+
});
|
|
60
|
+
it('a refused release is reported and never stops the others', async () => {
|
|
61
|
+
const api = fakeClient('may_stop', { failRelease: 'c-granted' });
|
|
62
|
+
const res = await releaseSessionCapacity(api.client, 'sess-uuid', 'all', 'agent session ended');
|
|
63
|
+
expect(res.released.map((r) => r.id)).toEqual(['c-queued']);
|
|
64
|
+
expect(res.failed).toEqual([expect.objectContaining({ id: 'c-granted', error: expect.stringContaining('403') })]);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
describe('ORB-2272 - the MCP session end releases every claim of the session', () => {
|
|
68
|
+
it('resolves the session behind the instance token and releases granted and queued claims', async () => {
|
|
69
|
+
const api = fakeClient('continue');
|
|
70
|
+
const res = await releaseCapacityAtSessionEnd(api.client, 'mcp-proc');
|
|
71
|
+
expect(api.get).toHaveBeenCalledWith('/agents/session/unfinished', { instanceToken: 'mcp-proc' });
|
|
72
|
+
expect(res?.released.map((r) => r.id)).toEqual(['c-granted', 'c-queued']);
|
|
73
|
+
expect(api.released.every((r) => r.token === 'mcp-proc')).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
it('gives up at the deadline instead of holding the session end open', async () => {
|
|
76
|
+
const get = vi.fn(() => new Promise(() => undefined));
|
|
77
|
+
const started = Date.now();
|
|
78
|
+
expect(await releaseCapacityAtSessionEnd({ get, post: vi.fn() }, 'mcp-proc', 20)).toBeNull();
|
|
79
|
+
expect(Date.now() - started).toBeLessThan(1_000);
|
|
80
|
+
});
|
|
81
|
+
it('never throws when the session is unknown', async () => {
|
|
82
|
+
const get = vi.fn().mockRejectedValue(new OrbotoApiError(404, 'instance_session_not_found', '/agents/session/unfinished'));
|
|
83
|
+
expect(await releaseCapacityAtSessionEnd({ get, post: vi.fn() }, 'mcp-proc')).toBeNull();
|
|
84
|
+
});
|
|
85
|
+
it('stdio: stdin end releases once, and the probe exit awaits the same run', async () => {
|
|
86
|
+
const api = fakeClient('continue');
|
|
87
|
+
const stdin = new EventEmitter();
|
|
88
|
+
const run = releaseCapacityOnStdinEnd(stdin, api.client, 'mcp-proc', { ORBOTO_PROBE_SESSION: '1' });
|
|
89
|
+
stdin.emit('end');
|
|
90
|
+
stdin.emit('close');
|
|
91
|
+
await run();
|
|
92
|
+
expect(api.released.map((r) => r.id)).toEqual(['c-granted', 'c-queued']);
|
|
93
|
+
expect(api.get.mock.calls.filter(([p]) => p === '/agents/session/unfinished')).toHaveLength(1);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
describe('ORB-2272 - a live CLI --hold marker keeps its claim on the stdio process token', () => {
|
|
97
|
+
let home;
|
|
98
|
+
const realHome = process.env.HOME;
|
|
99
|
+
const marker = (id) => join(home, '.orboto', 'capacity-holds', `${id}.json`);
|
|
100
|
+
const writeMarker = (id, pid) => {
|
|
101
|
+
mkdirSync(join(home, '.orboto', 'capacity-holds'), { recursive: true, mode: 0o700 });
|
|
102
|
+
writeFileSync(marker(id), JSON.stringify({ pid }), { mode: 0o600 });
|
|
103
|
+
};
|
|
104
|
+
const deadPid = () => {
|
|
105
|
+
let pid = 2_000_000;
|
|
106
|
+
for (; pid < 2_100_000; pid += 1) {
|
|
107
|
+
try {
|
|
108
|
+
process.kill(pid, 0);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return pid;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return pid;
|
|
115
|
+
};
|
|
116
|
+
beforeEach(() => {
|
|
117
|
+
home = mkdtempSync(join(tmpdir(), 'orboto-holds-'));
|
|
118
|
+
process.env.HOME = home;
|
|
119
|
+
});
|
|
120
|
+
afterEach(() => {
|
|
121
|
+
process.env.HOME = realHome;
|
|
122
|
+
rmSync(home, { recursive: true, force: true });
|
|
123
|
+
});
|
|
124
|
+
it('may_stop on the process token keeps a claim whose marker names a live pid, and the marker', async () => {
|
|
125
|
+
writeMarker('c-granted', process.pid);
|
|
126
|
+
const api = fakeClient('may_stop');
|
|
127
|
+
await makeSessionCheckHandler(api.client)({ action: 'check' });
|
|
128
|
+
expect(api.released).toEqual([]);
|
|
129
|
+
expect(existsSync(marker('c-granted'))).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
it('a marker with a dead pid is removed and the claim is released', async () => {
|
|
132
|
+
writeMarker('c-granted', deadPid());
|
|
133
|
+
const api = fakeClient('may_stop');
|
|
134
|
+
await makeSessionCheckHandler(api.client)({ action: 'check' });
|
|
135
|
+
expect(api.released.map((r) => r.id)).toEqual(['c-granted']);
|
|
136
|
+
expect(existsSync(marker('c-granted'))).toBe(false);
|
|
137
|
+
});
|
|
138
|
+
it('sessionEnd releases the held claim regardless and leaves the marker to its process', async () => {
|
|
139
|
+
writeMarker('c-granted', process.pid);
|
|
140
|
+
const api = fakeClient('continue');
|
|
141
|
+
await makeSessionCheckHandler(api.client)({ action: 'check', sessionEnd: true });
|
|
142
|
+
expect(api.released.map((r) => r.id)).toEqual(['c-granted', 'c-queued']);
|
|
143
|
+
});
|
|
144
|
+
it('an HTTP session token ignores the marker (it never shares a checkout)', async () => {
|
|
145
|
+
writeMarker('c-granted', process.pid);
|
|
146
|
+
const api = fakeClient('may_stop');
|
|
147
|
+
await makeSessionCheckHandler(api.client)({ action: 'check' }, { sessionId: 'http-1' });
|
|
148
|
+
expect(api.released.map((r) => r.id)).toEqual(['c-granted']);
|
|
149
|
+
});
|
|
150
|
+
it('a marker another process wrote without a pid does not keep the claim', async () => {
|
|
151
|
+
mkdirSync(join(home, '.orboto', 'capacity-holds'), { recursive: true });
|
|
152
|
+
writeFileSync(marker('c-granted'), '{"pid":"x"}');
|
|
153
|
+
const api = fakeClient('may_stop');
|
|
154
|
+
await makeSessionCheckHandler(api.client)({ action: 'check' });
|
|
155
|
+
expect(api.released.map((r) => r.id)).toEqual(['c-granted']);
|
|
156
|
+
});
|
|
157
|
+
it('a real stdio stdin end releases only the unheld granted claim, a probe end every claim', async () => {
|
|
158
|
+
const heldGranted = { ...GRANTED, id: 'c-held' };
|
|
159
|
+
writeMarker('c-held', process.pid);
|
|
160
|
+
const listing = (client) => {
|
|
161
|
+
client.get.mockImplementation(async (path) => {
|
|
162
|
+
if (path === '/agents/session/unfinished')
|
|
163
|
+
return { sessionId: 'sess-uuid', verdict: 'continue', reason: 'continue', text: 'x' };
|
|
164
|
+
return { items: [heldGranted, GRANTED, QUEUED], nextCursor: null };
|
|
165
|
+
});
|
|
166
|
+
};
|
|
167
|
+
const real = fakeClient('continue');
|
|
168
|
+
listing(real);
|
|
169
|
+
const stdin = new EventEmitter();
|
|
170
|
+
await (() => { const run = releaseCapacityOnStdinEnd(stdin, real.client, mcpProcessInstance(), {}); stdin.emit('end'); return run(); })();
|
|
171
|
+
expect(real.released.map((r) => r.id)).toEqual(['c-granted']);
|
|
172
|
+
expect(existsSync(marker('c-held'))).toBe(true);
|
|
173
|
+
const probe = fakeClient('continue');
|
|
174
|
+
listing(probe);
|
|
175
|
+
const probeStdin = new EventEmitter();
|
|
176
|
+
await (() => { const run = releaseCapacityOnStdinEnd(probeStdin, probe.client, mcpProcessInstance(), { ORBOTO_PROBE_SESSION: '1' }); probeStdin.emit('end'); return run(); })();
|
|
177
|
+
expect(probe.released.map((r) => r.id)).toEqual(['c-held', 'c-granted', 'c-queued']);
|
|
178
|
+
});
|
|
179
|
+
});
|