@orboto/mcp 0.181.0 → 0.182.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/orboto-client.js +2 -2
- package/dist/required-rules-wrapper.test.js +113 -0
- package/dist/required-rules.js +92 -0
- package/dist/required-rules.test.js +110 -0
- package/dist/resources.js +3 -2
- package/dist/resources.test.js +12 -2
- package/dist/server.js +10 -9
- package/dist/session-nudge.js +9 -6
- package/dist/session-nudge.test.js +8 -3
- package/dist/session-nudge.transport.test.js +21 -3
- package/dist/tool-annotations.test.js +22 -0
- package/dist/tool-docs.test.js +1 -0
- package/dist/tools/git-health-reasons.js +10 -0
- package/dist/tools/session-start.js +9 -19
- package/dist/tools/session-start.test.js +8 -8
- package/dist/tools/ticket-writes.js +6 -5
- package/dist/tools/ticket-writes.test.js +6 -0
- package/dist/tools/work-next.js +208 -0
- package/dist/tools/work-sessions-next.test.js +33 -7
- package/dist/tools/work-sessions-shared.js +17 -0
- package/dist/tools/work-sessions-start.test.js +3 -0
- package/dist/tools/work-sessions.js +10 -192
- package/dist/with-metrics.js +11 -3
- package/package.json +1 -1
|
@@ -50,6 +50,8 @@
|
|
|
50
50
|
import { z } from 'zod';
|
|
51
51
|
import { resolveTicketByKey, applyAgentProfile } from './shared.js';
|
|
52
52
|
import { PROTECT_TEXT_META, storePayload } from '../response-budget.js';
|
|
53
|
+
import { loadRequiredRules } from '../required-rules.js';
|
|
54
|
+
import { GIT_HEALTH_REASON_TEXT } from './git-health-reasons.js';
|
|
53
55
|
export const sessionStartToolConfig = {
|
|
54
56
|
title: 'Load the rules you must follow + re-orient',
|
|
55
57
|
description: 'THE canonical way to LOAD the binding workspace rules you must follow as an agent. Run it as your FIRST action in a session and immediately AFTER any context compaction. Returns a one-line-per-rule INDEX of the binding working-rules plus their hash (or a compact "unchanged" ack on a repeat call within the same connection) - read the index, then call this tool again with `rulesOnly: true` to read the full text of the rules whenever you do not already hold that exact hash, and expand before acting on any rule whose title touches what you are about to do. Also returns your in-progress tickets - each flagged LANDED, IDLE when it has a linked commit but has not moved for days, i.e. finished work you never handed to review - your running timer, and a warning if a project\'s git connection looks unhealthy (commit ingestion may be stalled). Pass `ticketKey` to also get a one-shot bundle for that ticket: project primer, the full ticket with dependencies + checklists, that project\'s git health, and any other agent sessions currently on it - replacing several separate calls. (Do NOT use orboto_list_agent_instructions to read the rules - that tool MANAGES/edits rule blocks for admins; this one is what you read to know how to work.) Read-only; no side effects. '
|
|
@@ -72,18 +74,6 @@ export const sessionStartToolConfig = {
|
|
|
72
74
|
}).shape,
|
|
73
75
|
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
74
76
|
};
|
|
75
|
-
const GIT_HEALTH_REASON_TEXT = {
|
|
76
|
-
connection_inactive: 'connection is deactivated',
|
|
77
|
-
app_installation_suspended: 'GitHub App installation is suspended',
|
|
78
|
-
oauth_token_expired: 'OAuth token expired with no refresh path',
|
|
79
|
-
history_backfill_error: 'last history backfill failed',
|
|
80
|
-
// ORB-1638
|
|
81
|
-
awaiting_first_event: 'webhook installed but has never delivered an event',
|
|
82
|
-
outbound_unreachable: 'orboto cannot reach the provider',
|
|
83
|
-
// ORB-1785 - two derivations now: the provider reporting a failed
|
|
84
|
-
// delivery, and the probe inferring one from commits that never arrived.
|
|
85
|
-
delivery_failing: 'webhook deliveries from the provider are not arriving',
|
|
86
|
-
};
|
|
87
77
|
// Cap how many distinct projects we probe for git health - a session's
|
|
88
78
|
// in-progress work is capped at 20 tickets already, so this rarely
|
|
89
79
|
// exceeds a handful, but bound it defensively so a pathological account
|
|
@@ -241,10 +231,10 @@ export function makeSessionStartHandler(client) {
|
|
|
241
231
|
// rules from the API instead, costs one call, and skips the whole rest
|
|
242
232
|
// of the digest. Protected in both halves: it IS the rule text.
|
|
243
233
|
if (input.rulesOnly) {
|
|
244
|
-
const rules = await client
|
|
234
|
+
const rules = await loadRequiredRules(client, rulesPath);
|
|
245
235
|
if (rules.rulesHash)
|
|
246
236
|
lastKnownRulesHash = rules.rulesHash;
|
|
247
|
-
const text = rules.instructions
|
|
237
|
+
const text = rules.instructions ?? '';
|
|
248
238
|
return {
|
|
249
239
|
_meta: { [PROTECT_TEXT_META]: true },
|
|
250
240
|
content: [{
|
|
@@ -267,7 +257,7 @@ export function makeSessionStartHandler(client) {
|
|
|
267
257
|
}
|
|
268
258
|
const [me, rules, assigned, timer, inboxRaw] = await Promise.all([
|
|
269
259
|
client.get('/users/me').catch(() => null),
|
|
270
|
-
client.get(
|
|
260
|
+
loadRequiredRules(client, rulesPath, rulesParams.get('knownRulesHash') ?? undefined),
|
|
271
261
|
// ORB-1330 - a re-orientation briefing must only list OPEN work.
|
|
272
262
|
// Filter to in_progress + in_review so DONE tickets can't pose as
|
|
273
263
|
// "what you're working on" at the moment the agent has the least
|
|
@@ -282,8 +272,6 @@ export function makeSessionStartHandler(client) {
|
|
|
282
272
|
.catch(() => []),
|
|
283
273
|
]);
|
|
284
274
|
const pendingMessages = inboxRaw.map((m) => ({ id: m.id, fromUserId: m.fromUserId, kind: m.kind, subject: m.subject, createdAt: m.createdAt }));
|
|
285
|
-
if (rules.rulesHash)
|
|
286
|
-
lastKnownRulesHash = rules.rulesHash;
|
|
287
275
|
const tickets = Array.isArray(assigned) ? assigned : (assigned?.items ?? []);
|
|
288
276
|
// ORB-1605 - git-connection health for every project the caller has
|
|
289
277
|
// open work in right now. Cheap, computed, read-only (see
|
|
@@ -326,7 +314,7 @@ export function makeSessionStartHandler(client) {
|
|
|
326
314
|
// index - one line per rule + the hash + a handle; the default.
|
|
327
315
|
// full - the text inline (forceRules, or an API too old to send
|
|
328
316
|
// an index - never leave a caller without its rules).
|
|
329
|
-
const rulesText = rules.instructions
|
|
317
|
+
const rulesText = rules.instructions ?? '';
|
|
330
318
|
const rulesIndex = Array.isArray(rules.rulesIndex)
|
|
331
319
|
? rules.rulesIndex.map((e) => e?.title).filter((t) => typeof t === 'string' && t.length > 0)
|
|
332
320
|
: [];
|
|
@@ -391,7 +379,7 @@ export function makeSessionStartHandler(client) {
|
|
|
391
379
|
if (bundle)
|
|
392
380
|
lines.push(...bundle.lines);
|
|
393
381
|
lines.push('', 'Re-run this after any context compaction to re-sync.');
|
|
394
|
-
|
|
382
|
+
const result = {
|
|
395
383
|
// ORB-1818 - when the full rule text IS the payload, neither half
|
|
396
384
|
// may be cut; see PROTECT_TEXT_META. The index answer carries no
|
|
397
385
|
// rule text and is budgeted like any other response.
|
|
@@ -431,5 +419,7 @@ export function makeSessionStartHandler(client) {
|
|
|
431
419
|
...(bundle ? { ticketBundle: bundle.structured } : {}),
|
|
432
420
|
},
|
|
433
421
|
};
|
|
422
|
+
lastKnownRulesHash = rules.rulesHash;
|
|
423
|
+
return result;
|
|
434
424
|
};
|
|
435
425
|
}
|
|
@@ -44,7 +44,7 @@ describe('orboto_session_start (ORB-1093)', () => {
|
|
|
44
44
|
const calls = stubByPath({
|
|
45
45
|
'/users/me/assigned-tickets': { items: [{ ticketKey: 'ORB-42', title: 'Wire it up', statusName: 'In Progress' }] },
|
|
46
46
|
'/users/me': { email: 'dev@x.io', fullName: 'Dev', workspaceLocale: 'en' },
|
|
47
|
-
'/agent-instructions': { instructions: 'claim -> commit -> close' },
|
|
47
|
+
'/agent-instructions': { instructions: 'claim -> commit -> close', rulesHash: 'fixture' },
|
|
48
48
|
'/time/timer': { ticketId: 't1', ticketKey: 'ORB-42', startedAt: '2026-06-15T10:00:00Z' },
|
|
49
49
|
});
|
|
50
50
|
const res = await makeSessionStartHandler(client)();
|
|
@@ -65,7 +65,7 @@ describe('orboto_session_start (ORB-1093)', () => {
|
|
|
65
65
|
stubByPath({
|
|
66
66
|
'/users/me/assigned-tickets': { items: [] },
|
|
67
67
|
'/users/me': { email: 'dev@x.io', fullName: 'Dev' },
|
|
68
|
-
'/agent-instructions': { instructions: 'rules here' },
|
|
68
|
+
'/agent-instructions': { instructions: 'rules here', rulesHash: 'fixture' },
|
|
69
69
|
'/time/timer': {},
|
|
70
70
|
});
|
|
71
71
|
const res = await makeSessionStartHandler(client)();
|
|
@@ -82,7 +82,7 @@ describe('orboto_session_start (ORB-1093)', () => {
|
|
|
82
82
|
items: [{ ticketKey: 'ORB-42', title: 'Wire it up', statusName: 'In Review', projectId: 'proj-1' }],
|
|
83
83
|
},
|
|
84
84
|
'/users/me': { email: 'dev@x.io', fullName: 'Dev' },
|
|
85
|
-
'/agent-instructions': { instructions: 'rules here' },
|
|
85
|
+
'/agent-instructions': { instructions: 'rules here', rulesHash: 'fixture' },
|
|
86
86
|
'/time/timer': {},
|
|
87
87
|
'/projects/proj-1/git-health': {
|
|
88
88
|
connections: [{
|
|
@@ -109,7 +109,7 @@ describe('orboto_session_start (ORB-1093)', () => {
|
|
|
109
109
|
items: [{ ticketKey: 'ORB-42', title: 'Wire it up', statusName: 'In Review', projectId: 'proj-1' }],
|
|
110
110
|
},
|
|
111
111
|
'/users/me': { email: 'dev@x.io', fullName: 'Dev' },
|
|
112
|
-
'/agent-instructions': { instructions: 'rules here' },
|
|
112
|
+
'/agent-instructions': { instructions: 'rules here', rulesHash: 'fixture' },
|
|
113
113
|
'/time/timer': {},
|
|
114
114
|
'/projects/proj-1/git-health': {
|
|
115
115
|
connections: [{
|
|
@@ -298,7 +298,7 @@ describe('orboto_session_start - context cost (ORB-1697)', () => {
|
|
|
298
298
|
items: [{ ticketKey: 'ORB-42', title: 'Wire it up', statusName: 'In Review', projectId: 'proj-1' }],
|
|
299
299
|
},
|
|
300
300
|
'/users/me': { email: 'dev@x.io', fullName: 'Dev' },
|
|
301
|
-
'/agent-instructions': { instructions: 'rules here' },
|
|
301
|
+
'/agent-instructions': { instructions: 'rules here', rulesHash: 'fixture' },
|
|
302
302
|
'/time/timer': {},
|
|
303
303
|
'/projects/proj-1/git-health': {
|
|
304
304
|
connections: [{
|
|
@@ -332,7 +332,7 @@ describe('orboto_session_start - context cost (ORB-1697)', () => {
|
|
|
332
332
|
],
|
|
333
333
|
},
|
|
334
334
|
'/users/me': { email: 'dev@x.io', fullName: 'Dev' },
|
|
335
|
-
'/agent-instructions': { instructions: 'rules here' },
|
|
335
|
+
'/agent-instructions': { instructions: 'rules here', rulesHash: 'fixture' },
|
|
336
336
|
'/time/timer': {},
|
|
337
337
|
'/v1/agent/presence': [],
|
|
338
338
|
});
|
|
@@ -355,7 +355,7 @@ describe('orboto_session_start - context cost (ORB-1697)', () => {
|
|
|
355
355
|
],
|
|
356
356
|
},
|
|
357
357
|
'/users/me': { email: 'dev@x.io', fullName: 'Dev' },
|
|
358
|
-
'/agent-instructions': { instructions: 'rules here' },
|
|
358
|
+
'/agent-instructions': { instructions: 'rules here', rulesHash: 'fixture' },
|
|
359
359
|
'/time/timer': {},
|
|
360
360
|
});
|
|
361
361
|
const res = await makeSessionStartHandler(client)();
|
|
@@ -383,7 +383,7 @@ describe('orboto_session_start - a 200 with an unexpected body never kills the d
|
|
|
383
383
|
'/projects/proj-1/tickets/by-key/42': { id: 'tick-1', projectId: 'proj-1', ticketKey: 'ORB-42', title: 'Bug' },
|
|
384
384
|
'/users/me/assigned-tickets': { items: [] },
|
|
385
385
|
'/users/me': { email: 'dev@x.io', fullName: 'Dev' },
|
|
386
|
-
'/agent-instructions': { instructions: 'rules here' },
|
|
386
|
+
'/agent-instructions': { instructions: 'rules here', rulesHash: 'fixture' },
|
|
387
387
|
});
|
|
388
388
|
const res = await makeSessionStartHandler(client)({ ticketKey: 'ORB-42' });
|
|
389
389
|
const text = res.content[0].text;
|
|
@@ -428,19 +428,20 @@ export function makeDeleteTicketHandler(client) {
|
|
|
428
428
|
};
|
|
429
429
|
}
|
|
430
430
|
export const commentToolConfig = {
|
|
431
|
-
title: 'Post a comment
|
|
432
|
-
description: '
|
|
431
|
+
title: 'Post a comment',
|
|
432
|
+
description: 'Post Markdown. isInternal=true hides it from guests. Attachment drafts are claimed atomically with the comment; never use public ticket uploads for private comments.',
|
|
433
433
|
inputSchema: z.object({
|
|
434
434
|
ticketKey: z.string().min(3),
|
|
435
435
|
text: z.string().min(1),
|
|
436
|
-
isInternal: z.boolean().optional().describe('Default
|
|
436
|
+
isInternal: z.boolean().optional().describe('Default false: guests can read.'),
|
|
437
|
+
attachmentDraftIds: z.array(z.string().uuid()).max(20).optional().describe('Private /attachment-drafts/tickets/:ticketId uploads only.'),
|
|
437
438
|
}).shape,
|
|
438
439
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
439
440
|
};
|
|
440
441
|
export function makeCommentHandler(client) {
|
|
441
|
-
return async ({ ticketKey, text, isInternal }) => {
|
|
442
|
+
return async ({ ticketKey, text, isInternal, attachmentDraftIds }) => {
|
|
442
443
|
const ticket = await resolveTicketByKey(client, ticketKey);
|
|
443
|
-
const created = await client.post(`/tickets/${ticket.id}/comments`, { content: text, isInternal: isInternal ?? false });
|
|
444
|
+
const created = await client.post(`/tickets/${ticket.id}/comments`, { content: text, isInternal: isInternal ?? false, ...(attachmentDraftIds ? { attachmentDraftIds } : {}) });
|
|
444
445
|
return {
|
|
445
446
|
content: [{
|
|
446
447
|
type: 'text',
|
|
@@ -395,6 +395,12 @@ describe('orboto_delete_ticket', () => {
|
|
|
395
395
|
});
|
|
396
396
|
});
|
|
397
397
|
describe('orboto_comment', () => {
|
|
398
|
+
it('forwards private upload claims without dropping the internal flag', async () => {
|
|
399
|
+
const calls = stub([{ json: PROJ }, { json: TICKET }, { json: { id: 'c1', content: 'private image', isInternal: true, createdAt: 'now' } }]);
|
|
400
|
+
const ids = ['aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa'];
|
|
401
|
+
await makeCommentHandler(client)({ ticketKey: 'ACME-1', text: 'private image', isInternal: true, attachmentDraftIds: ids });
|
|
402
|
+
expect(calls[2].body).toEqual({ content: 'private image', isInternal: true, attachmentDraftIds: ids });
|
|
403
|
+
});
|
|
398
404
|
it('posts a regular (non-internal) comment by default', async () => {
|
|
399
405
|
const calls = stub([
|
|
400
406
|
{ json: PROJ },
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-1613 - `orboto_work_next`, the pull side of low-management dispatch
|
|
3
|
+
* (Wave 3 of ORB-1602), in its own module since ORB-1930 added the peek.
|
|
4
|
+
* The shapes it shares with `orboto_work_start` live in
|
|
5
|
+
* work-sessions-shared.ts.
|
|
6
|
+
*/
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { mcpInstanceToken } from './shared.js';
|
|
9
|
+
import { validateNextWorkEnvelope, validateRulesReceipt } from '../required-rules.js';
|
|
10
|
+
import { GIT_HEALTH_REASON_TEXT, ResourceClaimShape } from './work-sessions-shared.js';
|
|
11
|
+
/** ORB-1799 - rendered identically on every exit (reserved, empty, paused):
|
|
12
|
+
* an agent that just pulled fresh work is exactly the one about to forget
|
|
13
|
+
* the ticket it already finished. Empty array renders nothing. */
|
|
14
|
+
function landedIdleLines(rows) {
|
|
15
|
+
if (!rows || rows.length === 0)
|
|
16
|
+
return [];
|
|
17
|
+
return [
|
|
18
|
+
'',
|
|
19
|
+
'## Landed, idle - finished-looking work of yours awaiting the status move',
|
|
20
|
+
...rows.map((r) => `- ${r.ticketKey ?? r.ticketId} "${r.title}" - ${r.commitCount} commit(s) linked, no activity for ${r.idleWorkingDays} working day(s) (since ${r.lastActivityAt}).`),
|
|
21
|
+
'Verify each is actually finished, then move it into the review lane (orboto_work_finish with targetCategory "in_review", or orboto_move_ticket) - do NOT re-implement it.',
|
|
22
|
+
];
|
|
23
|
+
}
|
|
24
|
+
export const workNextToolConfig = {
|
|
25
|
+
title: 'Pull and reserve the next ready ticket (worker-pool dispatch)',
|
|
26
|
+
description: 'ORB-1613 - the pull side of low-management dispatch, sibling of orboto_work_start on the "I already know which ticket" side. Picks the highest-priority ticket in a project that is READY FOR THE REQUESTED ROLE (default role implementation pulls TODO tickets; `role: "review"` pulls tickets in the in_review status category instead, and NEVER offers a ticket the caller itself implemented - the review lane primitive, ORB-1777), unblocked (every dependency closed), not already leased under the requested role, and not blocked by a conflicting resourceClaim - then reserves it with the EXACT same guarantees as orboto_work_start (atomic lease acquire + the full context bundle: rules ack, primer, ticket, checklists, dependencies, git health, siblings) in the same response. Priority then ticket number, deterministic - never a coin flip on ties. Two workers calling this concurrently never receive the SAME ticket: the underlying reservation is the identical partial-unique-index INSERT orboto_work_start uses, just walked across an ordered candidate list - a collision just advances to the next candidate. Epics are never returned (they are containers, not directly implementable). When nothing is ready, the response is a STRUCTURED result (`reserved: null`) with a `reason` (`none-matching` = no todo tickets at all; `all-blocked` = candidates exist but all have open dependencies; `all-leased` = ready candidates exist but are all currently leased or claim-conflicted; `autonomy_paused` = ORB-1774, this agent identity or the whole workspace has autonomous pulls paused by an operator - idle and wait for an operator/notify wakeup instead of retrying) and, ONLY when derivable from an actual active lease, a `retryAfterSeconds` backoff hint (`earliestFreeAt` null and `retryAfterSeconds` null means no signal exists - never a fabricated constant). This is never an error - a worker pool should back off on the hint rather than poll hot. Prefer this over orboto_work_start whenever the caller does not care WHICH ticket it gets, only that it gets the best available one right now. Every response - reserved, empty or paused - also carries `landedIdle`: YOUR OWN tickets in this project that are still in_progress with a linked commit and no activity for days (ORB-1799). Those are finished-looking work nobody handed to the review lane; move them on instead of re-implementing them.',
|
|
27
|
+
inputSchema: z.object({
|
|
28
|
+
projectKey: z.string().min(1).describe('Project key (e.g. "ACME") or UUID.'),
|
|
29
|
+
agentTag: z.string().min(1).max(64).optional()
|
|
30
|
+
.describe('ORB-1772 - preferred-not-exclusive routing tag: tickets labeled `agent:<tag>` rank first for a matching caller, foreign `agent:*` tags rank last but stay eligible. Lowercased server-side. Set it to this worker\'s routing tag (often the model or fleet lane name).'),
|
|
31
|
+
role: z.enum(['implementation', 'review', 'preflight', 'integration']).optional()
|
|
32
|
+
.describe('Default `implementation`. The dispatcher only reserves a ticket whose (ticket, role) lease is free for THIS role.'),
|
|
33
|
+
leaseSeconds: z.number().int().min(60).max(86_400).optional()
|
|
34
|
+
.describe('How long the lease should hold without renewal. Default 900 (15 min).'),
|
|
35
|
+
startTimer: z.boolean().optional()
|
|
36
|
+
.describe('Defaults to true for `implementation`, false for the attach-only roles.'),
|
|
37
|
+
agentSessionToken: z.string().optional()
|
|
38
|
+
.describe('Stable per-agent-instance token. Omit to use this MCP connection\'s own instance id.'),
|
|
39
|
+
resourceClaims: z.array(z.object(ResourceClaimShape)).max(50).optional()
|
|
40
|
+
.describe('ORB-1610 - resource claims to acquire alongside the reservation, AND to filter candidates: a ticket already held under a conflicting GRANTED write claim elsewhere is skipped even when its (ticket, role) lease is free.'),
|
|
41
|
+
onConflict: z.enum(['reject', 'queue']).optional()
|
|
42
|
+
.describe('Only matters when `resourceClaims` is set on the WINNING candidate. Default `reject`.'),
|
|
43
|
+
peek: z.boolean().optional()
|
|
44
|
+
.describe('ORB-1930 - answer "is there work for me?" WITHOUT reserving anything: the same candidate walk (role pool, lane caps, labels, dependencies, leases), but no lease, no timer, no lane quota consumed. Returns `candidate` (the ticket a real pull would reserve right now) or the usual empty reason. Use it before spending a model turn on self-tasking.'),
|
|
45
|
+
}).shape,
|
|
46
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
47
|
+
};
|
|
48
|
+
export function makeWorkNextHandler(client) {
|
|
49
|
+
// Per-connection rules-hash cache, same pattern as orboto_work_start.
|
|
50
|
+
let lastKnownRulesHash;
|
|
51
|
+
return async (args, extra) => {
|
|
52
|
+
const token = mcpInstanceToken(args.agentSessionToken, extra);
|
|
53
|
+
const res = await client.post('/work-sessions/next', {
|
|
54
|
+
projectKey: args.projectKey,
|
|
55
|
+
...(args.peek ? { peek: true } : {}),
|
|
56
|
+
...(args.agentTag ? { agentTag: args.agentTag } : {}),
|
|
57
|
+
...(args.role ? { role: args.role } : {}),
|
|
58
|
+
...(args.leaseSeconds ? { leaseSeconds: args.leaseSeconds } : {}),
|
|
59
|
+
...(args.startTimer !== undefined ? { startTimer: args.startTimer } : {}),
|
|
60
|
+
...(args.resourceClaims && args.resourceClaims.length > 0 ? { resourceClaims: args.resourceClaims } : {}),
|
|
61
|
+
...(args.onConflict ? { onConflict: args.onConflict } : {}),
|
|
62
|
+
agentSessionToken: token,
|
|
63
|
+
...(lastKnownRulesHash ? { knownRulesHash: lastKnownRulesHash } : {}),
|
|
64
|
+
});
|
|
65
|
+
validateNextWorkEnvelope(res, args.peek);
|
|
66
|
+
if (!res.reserved) {
|
|
67
|
+
// ORB-1930 - a peek that found work: name it, reserve nothing.
|
|
68
|
+
if (args.peek && res.candidate) {
|
|
69
|
+
const c = res.candidate;
|
|
70
|
+
return {
|
|
71
|
+
content: [{
|
|
72
|
+
type: 'text',
|
|
73
|
+
text: [
|
|
74
|
+
`Work is ready in "${args.projectKey}": ${c.ticketKey ?? c.ticketId} - ${c.title}${c.priority ? ` (${c.priority})` : ''}.`,
|
|
75
|
+
'Nothing was reserved (peek). Call orboto_work_next without `peek` to take it.',
|
|
76
|
+
...landedIdleLines(res.landedIdle),
|
|
77
|
+
].join('\n'),
|
|
78
|
+
}],
|
|
79
|
+
structuredContent: {
|
|
80
|
+
reserved: null,
|
|
81
|
+
reason: null,
|
|
82
|
+
candidate: c,
|
|
83
|
+
candidatesConsidered: res.candidatesConsidered,
|
|
84
|
+
...(res.landedIdle && res.landedIdle.length > 0 ? { landedIdle: res.landedIdle } : {}),
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
// ORB-1774 - a pause is an operator decision, not a backoff situation:
|
|
89
|
+
// tell the agent to idle, not to poll for a free slot.
|
|
90
|
+
if (res.reason === 'autonomy_paused') {
|
|
91
|
+
return {
|
|
92
|
+
content: [{
|
|
93
|
+
type: 'text',
|
|
94
|
+
text: [
|
|
95
|
+
'Autonomous work is PAUSED for this agent (by an operator, per-bot or workspace-wide). Do not poll for new tickets - idle and wait for an operator instruction or an agent-notify wakeup. Explicitly assigned work via orboto_work_start still runs.',
|
|
96
|
+
...landedIdleLines(res.landedIdle),
|
|
97
|
+
].join('\n'),
|
|
98
|
+
}],
|
|
99
|
+
structuredContent: {
|
|
100
|
+
reserved: null,
|
|
101
|
+
reason: res.reason,
|
|
102
|
+
retryAfterSeconds: null,
|
|
103
|
+
earliestFreeAt: null,
|
|
104
|
+
candidatesConsidered: 0,
|
|
105
|
+
...(res.landedIdle && res.landedIdle.length > 0 ? { landedIdle: res.landedIdle } : {}),
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
const lines = [`No ready ticket in "${args.projectKey}" right now (${res.reason}).`];
|
|
110
|
+
lines.push(res.retryAfterSeconds != null
|
|
111
|
+
? `Retry in ~${res.retryAfterSeconds}s (earliest known free: ${res.earliestFreeAt}).`
|
|
112
|
+
: 'No derivable ETA - poll again later or check the project board.');
|
|
113
|
+
lines.push(`Candidates considered: ${res.candidatesConsidered}.`);
|
|
114
|
+
lines.push(...landedIdleLines(res.landedIdle));
|
|
115
|
+
return {
|
|
116
|
+
content: [{ type: 'text', text: lines.join('\n') }],
|
|
117
|
+
structuredContent: {
|
|
118
|
+
reserved: null,
|
|
119
|
+
reason: res.reason,
|
|
120
|
+
retryAfterSeconds: res.retryAfterSeconds,
|
|
121
|
+
earliestFreeAt: res.earliestFreeAt,
|
|
122
|
+
candidatesConsidered: res.candidatesConsidered,
|
|
123
|
+
...(res.landedIdle && res.landedIdle.length > 0 ? { landedIdle: res.landedIdle } : {}),
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
const r = res.reserved;
|
|
128
|
+
validateRulesReceipt(r, lastKnownRulesHash, 'rules');
|
|
129
|
+
const lines = [
|
|
130
|
+
r.reused
|
|
131
|
+
? `Renewed your existing ${r.session.role} session on ${r.ticket.ticketKey ?? r.session.ticketId}.`
|
|
132
|
+
: `Reserved ${r.ticket.ticketKey ?? r.session.ticketId} - started a ${r.session.role} work session.`,
|
|
133
|
+
`Session id: ${r.session.id} (pass this to orboto_work_session_finish / orboto_work_finish).`,
|
|
134
|
+
`Lease held until ${r.session.leaseUntil}; it renews automatically while you keep calling orboto.`,
|
|
135
|
+
r.session.activeTimerId ? 'Timer running.' : 'No timer started for this role.',
|
|
136
|
+
];
|
|
137
|
+
if (r.queued && r.queued.length > 0) {
|
|
138
|
+
lines.push('Queued (waiting for a conflicting writer to release):', ...r.queued.map((q) => ` - ${q.claim.kind}:${q.claim.value} - position ${q.position}`));
|
|
139
|
+
}
|
|
140
|
+
lines.push('', '## Working rules');
|
|
141
|
+
if (r.rulesUnchanged) {
|
|
142
|
+
lines.push(`Unchanged since your last call on this connection (hash ${r.rulesHash}). If these rules are not currently in your context, call orboto_session_start with rulesOnly: true before proceeding.`);
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
lines.push(r.rules?.trim() || '(no workspace rules configured)');
|
|
146
|
+
}
|
|
147
|
+
lines.push('', '## Project primer');
|
|
148
|
+
lines.push(r.primer.markdown.trim() || '(primer unavailable)');
|
|
149
|
+
lines.push('', `## Ticket: ${r.ticket.ticketKey ?? '?'}`);
|
|
150
|
+
lines.push(r.ticket.title);
|
|
151
|
+
lines.push(`Status: ${r.ticket.statusName ?? r.ticket.status} Priority: ${r.ticket.priority} Type: ${r.ticket.type}`);
|
|
152
|
+
if (r.ticket.description)
|
|
153
|
+
lines.push('', r.ticket.description);
|
|
154
|
+
lines.push('', '## Checklists');
|
|
155
|
+
if (r.checklists.length === 0) {
|
|
156
|
+
lines.push('(none)');
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
for (const cl of r.checklists) {
|
|
160
|
+
lines.push(`${cl.title} (${cl.progress.done}/${cl.progress.total})${cl.triggersDone ? ' - triggers done' : ''}`);
|
|
161
|
+
for (const i of cl.items) {
|
|
162
|
+
const link = i.linkedTicketKey ? ` -> [${i.linkedTicketKey}] (${i.linkedTicketStatusCategory ?? 'unknown'})` : '';
|
|
163
|
+
lines.push(`- [${i.effectiveCompleted ? 'x' : ' '}] ${i.content}${link}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// ORB-1614 - see the comment on the other fmtDeps above.
|
|
168
|
+
const fmtDeps = (edges) => edges.length === 0 ? '(none)' : edges.map((e) => `- [${e.ticketKey ?? '?'}] ${e.title ?? `External dependency (access restricted)${e.resolved ? ' - resolved' : ' - still open'}`}${e.statusName ? ` - ${e.statusName}` : ''}`).join('\n');
|
|
169
|
+
lines.push('', '## Dependencies');
|
|
170
|
+
lines.push('Blocked by:', fmtDeps(r.dependencies.blockedBy), 'Blocks:', fmtDeps(r.dependencies.blocks));
|
|
171
|
+
const unhealthy = r.gitHealth.filter((c) => !c.healthy);
|
|
172
|
+
if (unhealthy.length > 0) {
|
|
173
|
+
lines.push('', '## Git connection health - WARNING');
|
|
174
|
+
for (const c of unhealthy) {
|
|
175
|
+
lines.push(`- "${c.name}" (${c.provider}) - ${GIT_HEALTH_REASON_TEXT[c.reason ?? ''] ?? c.reason ?? 'unknown reason'}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
lines.push('', '## Other sessions on this ticket');
|
|
179
|
+
lines.push(r.siblingSessions.length === 0
|
|
180
|
+
? '(none)'
|
|
181
|
+
: r.siblingSessions.map((s) => ` - [${s.role}] ${s.userFullName ?? s.userEmail ?? 'unknown'} - lease until ${s.leaseUntil}`).join('\n'));
|
|
182
|
+
lines.push(...landedIdleLines(res.landedIdle));
|
|
183
|
+
lastKnownRulesHash = r.rulesHash;
|
|
184
|
+
return {
|
|
185
|
+
content: [{ type: 'text', text: lines.join('\n') }],
|
|
186
|
+
structuredContent: {
|
|
187
|
+
reserved: {
|
|
188
|
+
session: r.session,
|
|
189
|
+
reused: r.reused,
|
|
190
|
+
queued: r.queued ?? [],
|
|
191
|
+
rulesHash: r.rulesHash,
|
|
192
|
+
rulesUnchanged: r.rulesUnchanged,
|
|
193
|
+
primer: r.primer,
|
|
194
|
+
ticket: r.ticket,
|
|
195
|
+
checklists: r.checklists,
|
|
196
|
+
dependencies: r.dependencies,
|
|
197
|
+
gitHealth: r.gitHealth,
|
|
198
|
+
siblingSessions: r.siblingSessions,
|
|
199
|
+
},
|
|
200
|
+
reason: null,
|
|
201
|
+
retryAfterSeconds: null,
|
|
202
|
+
earliestFreeAt: null,
|
|
203
|
+
candidatesConsidered: res.candidatesConsidered,
|
|
204
|
+
...(res.landedIdle && res.landedIdle.length > 0 ? { landedIdle: res.landedIdle } : {}),
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
};
|
|
208
|
+
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
9
9
|
import { OrbotoClient } from '../orboto-client.js';
|
|
10
|
-
import { makeWorkNextHandler } from './work-
|
|
10
|
+
import { makeWorkNextHandler } from './work-next.js';
|
|
11
11
|
beforeEach(() => { vi.restoreAllMocks(); });
|
|
12
12
|
afterEach(() => { vi.restoreAllMocks(); });
|
|
13
13
|
function stub(responses) {
|
|
@@ -65,8 +65,31 @@ const RESERVED = {
|
|
|
65
65
|
siblingSessions: [],
|
|
66
66
|
};
|
|
67
67
|
describe('orboto_work_next', () => {
|
|
68
|
+
const peekResponse = {
|
|
69
|
+
reserved: null, reason: null, retryAfterSeconds: null, earliestFreeAt: null,
|
|
70
|
+
candidatesConsidered: 1, landedIdle: [],
|
|
71
|
+
candidate: { ticketId: 't1', ticketKey: 'ACME-42', title: 'Ready', priority: null },
|
|
72
|
+
};
|
|
73
|
+
it('validates a peek candidate without claiming rules delivery or a reservation', async () => {
|
|
74
|
+
const calls = stub([{ json: peekResponse }, { json: peekResponse }]);
|
|
75
|
+
const handler = makeWorkNextHandler(client);
|
|
76
|
+
const result = await handler({ projectKey: 'ACME', peek: true });
|
|
77
|
+
expect(result.structuredContent).toMatchObject({ reserved: null, candidate: peekResponse.candidate });
|
|
78
|
+
expect(calls[0].body).toMatchObject({ peek: true });
|
|
79
|
+
expect(calls[0].body).not.toHaveProperty('knownRulesHash');
|
|
80
|
+
await expect(handler({ projectKey: 'ACME' })).rejects.toMatchObject({ reason: 'invalid_response' });
|
|
81
|
+
});
|
|
82
|
+
it.each([
|
|
83
|
+
{ candidate: {} }, { candidate: { ticketId: '', ticketKey: null, title: '', priority: null } },
|
|
84
|
+
{ candidate: { ticketId: 't1', title: 'Ready', priority: null } },
|
|
85
|
+
{ candidate: { ticketId: 't1', ticketKey: null, title: 42, priority: null } },
|
|
86
|
+
{ reason: 'none-matching' }, { reserved: RESERVED }, { candidatesConsidered: -1 },
|
|
87
|
+
])('rejects malformed or contradictory peek responses %#', async (patch) => {
|
|
88
|
+
stub([{ json: { ...peekResponse, ...patch } }]);
|
|
89
|
+
await expect(makeWorkNextHandler(client)({ projectKey: 'ACME', peek: true })).rejects.toMatchObject({ reason: 'invalid_response' });
|
|
90
|
+
});
|
|
68
91
|
it('reserves the winning candidate and renders the full bundle, sending projectKey straight through', async () => {
|
|
69
|
-
const calls = stub([{ json: { reserved: RESERVED, reason: null, retryAfterSeconds: null, earliestFreeAt: null, candidatesConsidered: 3 } }]);
|
|
92
|
+
const calls = stub([{ json: { reserved: RESERVED, reason: null, retryAfterSeconds: null, earliestFreeAt: null, candidatesConsidered: 3, landedIdle: [] } }]);
|
|
70
93
|
const res = await makeWorkNextHandler(client)({ projectKey: 'ACME' });
|
|
71
94
|
expect(calls).toHaveLength(1);
|
|
72
95
|
expect(calls[0].method).toBe('POST');
|
|
@@ -87,25 +110,28 @@ describe('orboto_work_next', () => {
|
|
|
87
110
|
});
|
|
88
111
|
it('caches the rules hash across calls and renders "Renewed" on a reused session', async () => {
|
|
89
112
|
const handler = makeWorkNextHandler(client);
|
|
90
|
-
stub([{ json: { reserved: RESERVED, reason: null, retryAfterSeconds: null, earliestFreeAt: null, candidatesConsidered: 1 } }]);
|
|
113
|
+
stub([{ json: { reserved: RESERVED, reason: null, retryAfterSeconds: null, earliestFreeAt: null, candidatesConsidered: 1, landedIdle: [] } }]);
|
|
91
114
|
await handler({ projectKey: 'ACME' });
|
|
92
115
|
const calls2 = stub([{
|
|
93
116
|
json: {
|
|
94
117
|
reserved: { ...RESERVED, reused: true, rulesUnchanged: true, rules: undefined },
|
|
95
|
-
reason: null, retryAfterSeconds: null, earliestFreeAt: null, candidatesConsidered: 1,
|
|
118
|
+
reason: null, retryAfterSeconds: null, earliestFreeAt: null, candidatesConsidered: 1, landedIdle: [],
|
|
96
119
|
},
|
|
97
120
|
}]);
|
|
98
121
|
const res2 = await handler({ projectKey: 'ACME' });
|
|
99
122
|
expect(calls2[0].body?.knownRulesHash).toBe('abc123');
|
|
100
123
|
const text2 = res2.content[0].text;
|
|
101
124
|
expect(text2).toContain('Unchanged since your last call');
|
|
125
|
+
expect(text2).toContain('not currently in your context');
|
|
126
|
+
expect(text2).toContain('rulesOnly: true');
|
|
127
|
+
expect(text2).not.toContain('keep following what you already loaded');
|
|
102
128
|
expect(text2).toContain('Renewed your existing');
|
|
103
129
|
});
|
|
104
130
|
it('renders a structured empty result with a retry hint (all-leased)', async () => {
|
|
105
131
|
stub([{
|
|
106
132
|
json: {
|
|
107
133
|
reserved: null, reason: 'all-leased', retryAfterSeconds: 420,
|
|
108
|
-
earliestFreeAt: '2026-07-24T10:15:00Z', candidatesConsidered: 4,
|
|
134
|
+
earliestFreeAt: '2026-07-24T10:15:00Z', candidatesConsidered: 4, landedIdle: [],
|
|
109
135
|
},
|
|
110
136
|
}]);
|
|
111
137
|
const res = await makeWorkNextHandler(client)({ projectKey: 'ACME' });
|
|
@@ -124,7 +150,7 @@ describe('orboto_work_next', () => {
|
|
|
124
150
|
stub([{
|
|
125
151
|
json: {
|
|
126
152
|
reserved: null, reason: 'none-matching', retryAfterSeconds: null,
|
|
127
|
-
earliestFreeAt: null, candidatesConsidered: 0,
|
|
153
|
+
earliestFreeAt: null, candidatesConsidered: 0, landedIdle: [],
|
|
128
154
|
},
|
|
129
155
|
}]);
|
|
130
156
|
const res = await makeWorkNextHandler(client)({ projectKey: 'ACME' });
|
|
@@ -134,7 +160,7 @@ describe('orboto_work_next', () => {
|
|
|
134
160
|
expect(text).not.toContain('Retry in');
|
|
135
161
|
});
|
|
136
162
|
it('forwards role, leaseSeconds, and resourceClaims to the API', async () => {
|
|
137
|
-
const calls = stub([{ json: { reserved: RESERVED, reason: null, retryAfterSeconds: null, earliestFreeAt: null, candidatesConsidered: 1 } }]);
|
|
163
|
+
const calls = stub([{ json: { reserved: RESERVED, reason: null, retryAfterSeconds: null, earliestFreeAt: null, candidatesConsidered: 1, landedIdle: [] } }]);
|
|
138
164
|
await makeWorkNextHandler(client)({
|
|
139
165
|
projectKey: 'ACME',
|
|
140
166
|
role: 'review',
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-1609 / ORB-1613 - the wire shapes the work-session tools share:
|
|
3
|
+
* resource claims, the start bundle `orboto_work_start` and
|
|
4
|
+
* `orboto_work_next` both return, and the git-health reason texts.
|
|
5
|
+
* Extracted so `orboto_work_next` can live in its own module (file-size
|
|
6
|
+
* ratchet, ORB-1863) without a cycle back into work-sessions.ts.
|
|
7
|
+
*/
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
export const ResourceClaimShape = {
|
|
10
|
+
kind: z.enum(['path', 'named']),
|
|
11
|
+
value: z.string().min(1).max(500),
|
|
12
|
+
mode: z.enum(['read', 'write']),
|
|
13
|
+
};
|
|
14
|
+
/** Shared 409-body reader: the endpoint's body carries EITHER `holder`
|
|
15
|
+
* (a lease conflict) OR `claimConflicts` (a resource-claim conflict) -
|
|
16
|
+
* never both, since the lease is checked before claims are applied. */
|
|
17
|
+
export { GIT_HEALTH_REASON_TEXT } from './git-health-reasons.js';
|
|
@@ -122,6 +122,9 @@ describe('orboto_work_start', () => {
|
|
|
122
122
|
expect(post2.body?.knownRulesHash).toBe('abc123');
|
|
123
123
|
const text2 = res2.content[0].text;
|
|
124
124
|
expect(text2).toContain('Unchanged since your last work-start');
|
|
125
|
+
expect(text2).toContain('not currently in your context');
|
|
126
|
+
expect(text2).toContain('rulesOnly: true');
|
|
127
|
+
expect(text2).not.toContain('keep following what you already loaded');
|
|
125
128
|
expect(text2).toContain('Renewed your existing');
|
|
126
129
|
});
|
|
127
130
|
it('surfaces the lease holder on a 409 instead of throwing', async () => {
|