Skip to content

Commit 3b86d74

Browse files
NagyViktNagyVikt
andauthored
feat(coordination): open mode — advisory role gates, loud claim contention (new default) (#591)
* feat(coordination): open mode — advisory role gates, loud claim contention (new default) Unprofiled agents default to the executor role and could not propose; scouts could not claim; contended claims hard-failed. coordinationMode ('open' default | 'guarded') makes roles advisory: open lifts SCOUT_NO_CLAIM, EXECUTOR_CANNOT_PROPOSE, the scout proposal cap, and executor proposal filtering, and turns claim-conflict errors into successful responses carrying contention/warning/contention_detail (table ownership stays with the live owner). Queen-only approval, subtask completion ownership, evidence, and protected-branch rejection stay hard in both modes. task_plan_claim_subtask gains force:true with a plan-subtask-force-claim audit note. * test(coordination): force-claim coverage + open-mode handler tests; review fixes Audit note for force-claims now writes inside the claim transaction so lost races leave no false trail. filterReadyForExecutor mode param is required (no silent guarded default). --------- Co-authored-by: NagyVikt <nagy.viktordp@gmail.com>
1 parent 13ce3ad commit 3b86d74

20 files changed

Lines changed: 432 additions & 86 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@colony/mcp-server': minor
3+
'@colony/config': minor
4+
---
5+
6+
Open coordination mode (new default): role gates become advisory. `settings.coordinationMode: 'open' | 'guarded'` — under open, scouts can claim, any agent can propose (no cap), everyone sees all proposals, and contended `task_claim_file` calls succeed with loud contention info (`contention`, `contention_detail`, `warning`) instead of erroring; table ownership stays with the live owner. Queen-only approval, subtask completion ownership, evidence requirements, and protected-branch rejection stay hard in both modes. `task_plan_claim_subtask` gains `force: boolean` to override unmet deps with an audit note. Set `coordinationMode: 'guarded'` to restore strict behavior.

apps/mcp-server/src/handlers/claims.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,19 @@ export function actorRole(store: MemoryStore, ctx: ClaimActorContext): AgentRole
3131
}
3232

3333
export function enforceScoutNoClaim(store: MemoryStore, ctx: ClaimActorContext): void {
34+
// Open mode (default): roles are advisory hints, not capability walls.
35+
if (store.settings.coordinationMode === 'open') return;
3436
if (actorRole(store, ctx) !== 'scout') return;
3537
throw new ClaimsHandlerError(SCOUT_NO_CLAIM, 'scouts cannot claim files; propose instead');
3638
}
3739

3840
export function filterReadyForExecutor<T extends ProposalReadyRow>(
3941
rows: readonly T[],
4042
role: AgentRole,
43+
mode: 'open' | 'guarded',
4144
): T[] {
45+
// Open mode: every agent sees every proposal, approved or not.
46+
if (mode === 'open') return [...rows];
4247
if (role === 'scout') return [];
4348
if (role === 'executor') {
4449
return rows.filter((row) => row.proposal_status == null || row.proposal_status === 'approved');

apps/mcp-server/src/handlers/proposals.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,10 @@ export function handleTaskPropose(
8989
const db = rawDb(store);
9090
assertProposalSchema(db);
9191
const actor = loadActorProfile(store, ctx.agent);
92-
if (actor.role === 'executor') {
92+
// Role gate and cap apply only in guarded mode; the evidence requirement
93+
// below is data quality, not role policy, and holds in both modes.
94+
const guarded = store.settings.coordinationMode === 'guarded';
95+
if (guarded && actor.role === 'executor') {
9396
throw new TaskThreadError(
9497
TASK_THREAD_ERROR_CODES.EXECUTOR_CANNOT_PROPOSE,
9598
'executors cannot propose; scouts must provide evidence first',
@@ -101,7 +104,7 @@ export function handleTaskPropose(
101104
'observationEvidenceIds must contain at least one evidence id',
102105
);
103106
}
104-
if (actor.openProposalCount >= MAX_OPEN_PROPOSALS_PER_SCOUT) {
107+
if (guarded && actor.openProposalCount >= MAX_OPEN_PROPOSALS_PER_SCOUT) {
105108
throw new TaskThreadError(
106109
TASK_THREAD_ERROR_CODES.PROPOSAL_CAP_EXCEEDED,
107110
`scout ${ctx.agent} already has ${MAX_OPEN_PROPOSALS_PER_SCOUT} open proposals`,

apps/mcp-server/src/tools/plan.ts

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,10 @@ export function register(server: McpServer, ctx: ToolContext): void {
450450
agent: z.string().min(1),
451451
repo_root: z.string().min(1).optional(),
452452
file_scope: z.array(z.string().min(1)).optional(),
453+
force: z
454+
.boolean()
455+
.optional()
456+
.describe('Claim even when depends_on sub-tasks are incomplete; records an audit note.'),
453457
},
454458
wrapHandler('task_plan_claim_subtask', async (args) => {
455459
const result = attemptClaimPlanSubtask(store, args);
@@ -658,6 +662,8 @@ export type ClaimPlanSubtaskArgs = {
658662
session_id: string;
659663
agent: string;
660664
repo_root?: string | undefined;
665+
/** Skip the depends_on completeness check (records a force-claim note). */
666+
force?: boolean | undefined;
661667
};
662668

663669
export type ClaimPlanSubtaskResult =
@@ -725,16 +731,20 @@ export function attemptClaimPlanSubtask(
725731
.filter((s): s is SubtaskLookup => s !== null)
726732
.map((s) => s.info);
727733

734+
let forcedUnmetDeps: number[] = [];
728735
if (!areDepsMet(located.info, siblings)) {
729736
const unmet = located.info.depends_on.filter((idx) => {
730737
const dep = siblings.find((s) => s.subtask_index === idx);
731738
return dep?.status !== 'completed';
732739
});
733-
return {
734-
ok: false,
735-
code: 'PLAN_SUBTASK_DEPS_UNMET',
736-
message: `dependencies not met: sub-tasks [${unmet.join(', ')}] are not completed`,
737-
};
740+
if (!args.force) {
741+
return {
742+
ok: false,
743+
code: 'PLAN_SUBTASK_DEPS_UNMET',
744+
message: `dependencies not met: sub-tasks [${unmet.join(', ')}] are not completed`,
745+
};
746+
}
747+
forcedUnmetDeps = unmet;
738748
}
739749

740750
try {
@@ -781,6 +791,18 @@ export function attemptClaimPlanSubtask(
781791
};
782792
}
783793
}
794+
// Audit the dep override only once the claim is actually happening —
795+
// written outside this transaction it would record force-claims that
796+
// lost the race and never occurred.
797+
if (forcedUnmetDeps.length > 0) {
798+
store.addObservation({
799+
session_id: args.session_id,
800+
task_id: fresh.task_id,
801+
kind: 'note',
802+
content: `force-claimed sub-${args.subtask_index} of ${args.plan_slug} past unmet deps [${forcedUnmetDeps.join(', ')}]`,
803+
metadata: { kind: 'plan-subtask-force-claim', unmet_deps: forcedUnmetDeps },
804+
});
805+
}
784806
store.addObservation({
785807
session_id: args.session_id,
786808
task_id: fresh.task_id,

apps/mcp-server/src/tools/ready-queue.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,7 @@ export async function buildReadyForAgent(
465465
),
466466
),
467467
role,
468+
store.settings.coordinationMode,
468469
);
469470
const currentClaims = filterReadyForExecutor(
470471
plans.flatMap((plan) =>
@@ -491,6 +492,7 @@ export async function buildReadyForAgent(
491492
),
492493
),
493494
role,
495+
store.settings.coordinationMode,
494496
);
495497
const urgentTaskIds = blockingMessageTaskIds(store, {
496498
session_id: args.session_id,

apps/mcp-server/src/tools/task.ts

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -402,14 +402,16 @@ export function register(server: McpServer, ctx: ToolContext): void {
402402
session_id,
403403
file_path: normalizedFilePath,
404404
});
405-
if (guarded.status === 'takeover_recommended') {
406-
return mcpErrorResponse(
407-
'CLAIM_TAKEOVER_RECOMMENDED',
408-
guarded.recommendation ?? 'release or take over inactive claim before claiming',
409-
{ ...guarded },
410-
);
411-
}
412-
if (guarded.status === 'blocked_active_owner') {
405+
const contended =
406+
guarded.status === 'takeover_recommended' || guarded.status === 'blocked_active_owner';
407+
if (contended && settings.coordinationMode === 'guarded') {
408+
if (guarded.status === 'takeover_recommended') {
409+
return mcpErrorResponse(
410+
'CLAIM_TAKEOVER_RECOMMENDED',
411+
guarded.recommendation ?? 'release or take over inactive claim before claiming',
412+
{ ...guarded },
413+
);
414+
}
413415
return mcpErrorResponse(
414416
'CLAIM_HELD_BY_ACTIVE_OWNER',
415417
guarded.recommendation ?? 'request handoff or explicit takeover before claiming',
@@ -443,12 +445,20 @@ export function register(server: McpServer, ctx: ToolContext): void {
443445
const previousClaim = previous
444446
? compactPreviousClaim(previous, session_id, settings.claimStaleMinutes)
445447
: null;
448+
// Open mode lets contended claims through: the claim succeeds, but the
449+
// response carries the contention loudly so the agent coordinates
450+
// instead of silently clobbering a live owner.
446451
return jsonReply({
447452
observation_id: id,
448453
file_path: normalizedFilePath,
449454
claim_status: guarded.status,
450455
claim_task_id: guarded.claim_task_id ?? task_id,
451-
warning: null,
456+
contention: contended,
457+
contention_detail: contended ? { ...guarded } : null,
458+
warning: contended
459+
? (guarded.recommendation ??
460+
'another live session holds this file; coordinate via task_message before editing')
461+
: null,
452462
live_file_contentions: [],
453463
overlap: previousClaim?.overlap ?? 'none',
454464
previous_claim: previousClaim,

apps/mcp-server/test/handlers/claims.test.ts

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,16 @@ import {
1010
filterReadyForExecutor,
1111
} from '../../src/handlers/claims.js';
1212

13+
// These suites exercise the strict role/claim gates, which only apply in
14+
// guarded mode (open is the default since the coordinationMode change).
15+
const guardedSettings = { ...defaultSettings, coordinationMode: 'guarded' as const };
16+
1317
let dataDir: string;
1418
let store: MemoryStore;
1519

1620
beforeEach(() => {
1721
dataDir = mkdtempSync(join(tmpdir(), 'colony-claims-handler-'));
18-
store = new MemoryStore({ dbPath: join(dataDir, 'data.db'), settings: defaultSettings });
22+
store = new MemoryStore({ dbPath: join(dataDir, 'data.db'), settings: guardedSettings });
1923
});
2024

2125
afterEach(() => {
@@ -62,14 +66,43 @@ describe('filterReadyForExecutor', () => {
6266
];
6367

6468
it('hides proposal work from scout actors', () => {
65-
expect(filterReadyForExecutor(rows, 'scout')).toEqual([]);
69+
expect(filterReadyForExecutor(rows, 'scout', 'guarded')).toEqual([]);
6670
});
6771

6872
it('shows only normal and approved proposal work to executors', () => {
69-
expect(filterReadyForExecutor(rows, 'executor').map((row) => row.id)).toEqual([1, 3]);
73+
expect(filterReadyForExecutor(rows, 'executor', 'guarded').map((row) => row.id)).toEqual([
74+
1, 3,
75+
]);
7076
});
7177

7278
it('leaves all work visible for queen actors', () => {
73-
expect(filterReadyForExecutor(rows, 'queen')).toEqual(rows);
79+
expect(filterReadyForExecutor(rows, 'queen', 'guarded')).toEqual(rows);
80+
});
81+
});
82+
83+
describe('open coordination mode', () => {
84+
it('lets scouts claim and shows executors every proposal', () => {
85+
const openStore = new MemoryStore({
86+
dbPath: join(dataDir, 'open.db'),
87+
settings: { ...defaultSettings, coordinationMode: 'open' },
88+
});
89+
try {
90+
openStore.storage.upsertAgentProfile({
91+
agent: 'scout-open',
92+
capabilities: '{}',
93+
role: 'scout',
94+
updated_at: 1,
95+
});
96+
expect(() => enforceScoutNoClaim(openStore, { agent: 'scout-open' })).not.toThrow();
97+
const rows = [
98+
{ id: 1, proposal_status: null },
99+
{ id: 2, proposal_status: 'proposed' as const },
100+
{ id: 3, proposal_status: 'approved' as const },
101+
];
102+
expect(filterReadyForExecutor(rows, 'executor', 'open').map((r) => r.id)).toEqual([1, 2, 3]);
103+
expect(filterReadyForExecutor(rows, 'scout', 'open').map((r) => r.id)).toEqual([1, 2, 3]);
104+
} finally {
105+
openStore.close();
106+
}
74107
});
75108
});

apps/mcp-server/test/handlers/proposals.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ import {
1010
handleTaskPropose,
1111
} from '../../src/handlers/proposals.js';
1212

13+
// These suites exercise the strict role/claim gates, which only apply in
14+
// guarded mode (open is the default since the coordinationMode change).
15+
const guardedSettings = { ...defaultSettings, coordinationMode: 'guarded' as const };
16+
1317
interface SqlRunResult {
1418
changes: number;
1519
lastInsertRowid: number | bigint;
@@ -35,7 +39,7 @@ let db: SqlDatabase;
3539

3640
beforeEach(() => {
3741
dir = mkdtempSync(join(tmpdir(), 'colony-mcp-proposals-'));
38-
store = new MemoryStore({ dbPath: join(dir, 'data.db'), settings: defaultSettings });
42+
store = new MemoryStore({ dbPath: join(dir, 'data.db'), settings: guardedSettings });
3943
db = (store.storage as unknown as StorageWithDb).db;
4044
installProposalSchema();
4145
});

apps/mcp-server/test/plan.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -879,6 +879,41 @@ describe('task_plan_claim_subtask', () => {
879879
expect(err.code).toBe('PLAN_SUBTASK_DEPS_UNMET');
880880
});
881881

882+
it('force=true claims past unmet deps and records an audit note', async () => {
883+
await call<PublishResult>('task_plan_publish', basicPublishArgs());
884+
const claimed = await call<{ task_id: number }>('task_plan_claim_subtask', {
885+
plan_slug: 'add-widget-page',
886+
subtask_index: 1,
887+
session_id: 'B',
888+
agent: 'codex',
889+
force: true,
890+
});
891+
expect(claimed.task_id).toBeGreaterThan(0);
892+
const notes = store.storage
893+
.taskObservationsByKind(claimed.task_id, 'note', 10)
894+
.filter((o) => o.metadata?.includes('plan-subtask-force-claim'));
895+
expect(notes).toHaveLength(1);
896+
expect(notes[0]?.content).toContain('past unmet deps [0]');
897+
});
898+
899+
it('force=true does not bypass the already-claimed race check', async () => {
900+
await call<PublishResult>('task_plan_publish', basicPublishArgs());
901+
await call('task_plan_claim_subtask', {
902+
plan_slug: 'add-widget-page',
903+
subtask_index: 0,
904+
session_id: 'A',
905+
agent: 'claude',
906+
});
907+
const err = await callError('task_plan_claim_subtask', {
908+
plan_slug: 'add-widget-page',
909+
subtask_index: 0,
910+
session_id: 'B',
911+
agent: 'codex',
912+
force: true,
913+
});
914+
expect(err.code).toBe('PLAN_SUBTASK_NOT_AVAILABLE');
915+
});
916+
882917
it('rejects a second claim on an already-claimed sub-task (race)', async () => {
883918
// The load-bearing test for the lane: two agents racing on the same
884919
// available sub-task. The transaction-based scan-before-stamp inside the

apps/mcp-server/test/ready-queue.test.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -458,12 +458,17 @@ beforeEach(async () => {
458458
dataDir = mkdtempSync(join(tmpdir(), 'colony-ready-data-'));
459459
repoRoot = mkdtempSync(join(tmpdir(), 'colony-ready-repo-'));
460460
writeFileSync(join(repoRoot, 'SPEC.md'), '# SPEC\n', 'utf8');
461-
store = new MemoryStore({ dbPath: join(dataDir, 'data.db'), settings: defaultSettings });
461+
// Executor proposal filtering only applies in guarded mode (open is the
462+
// default since the coordinationMode change).
463+
store = new MemoryStore({
464+
dbPath: join(dataDir, 'data.db'),
465+
settings: { ...defaultSettings, coordinationMode: 'guarded' as const },
466+
});
462467
store.startSession({ id: 'planner', ide: 'claude-code', cwd: repoRoot });
463468
store.startSession({ id: 'queen', ide: 'queen', cwd: repoRoot });
464469
store.startSession({ id: 'agent-session', ide: 'codex', cwd: repoRoot });
465470
store.startSession({ id: 'other-session', ide: 'claude-code', cwd: repoRoot });
466-
const server = buildServer(store, defaultSettings);
471+
const server = buildServer(store, { ...defaultSettings, coordinationMode: 'guarded' as const });
467472
const [clientT, serverT] = InMemoryTransport.createLinkedPair();
468473
client = new Client({ name: 'test', version: '0.0.0' });
469474
await Promise.all([server.connect(serverT), client.connect(clientT)]);

0 commit comments

Comments
 (0)