Skip to content

Commit a3306ac

Browse files
authored
Merge pull request #4 from multiagentcoordinationprotocol/align-with-runtime
Align control plane with MACP spec d31ffb1 and runtime 168ab31
2 parents e3d9f5d + 43dde20 commit a3306ac

11 files changed

Lines changed: 91 additions & 210 deletions

src/config/app-config.service.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,10 @@ export class AppConfigService implements OnModuleInit {
5353
readonly runtimeUseDevHeader = readBoolean('RUNTIME_USE_DEV_HEADER', process.env.NODE_ENV === 'development');
5454
readonly runtimeRequestTimeoutMs = readNumber('RUNTIME_REQUEST_TIMEOUT_MS', 30000);
5555
readonly runtimeDevAgentId = process.env.RUNTIME_DEV_AGENT_ID ?? 'control-plane';
56+
/** @deprecated SessionWatch is no longer part of the base protocol. Kept for backward compat. */
5657
readonly runtimeStreamSubscriptionMessageType =
5758
process.env.RUNTIME_STREAM_SUBSCRIPTION_MESSAGE_TYPE ?? 'SessionWatch';
59+
/** @deprecated SessionWatch is no longer part of the base protocol. Kept for backward compat. */
5860
readonly runtimeStreamSubscriberId =
5961
process.env.RUNTIME_STREAM_SUBSCRIBER_ID ?? this.runtimeDevAgentId;
6062

src/contracts/runtime.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export interface RuntimeInitializeResult {
6565
};
6666
supportedModes: string[];
6767
capabilities?: RuntimeCapabilities;
68+
instructions?: string;
6869
}
6970

7071
export interface RuntimeStartSessionRequest {

src/errors/error-codes.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,6 @@ export enum ErrorCode {
1111
MODE_NOT_SUPPORTED = 'MODE_NOT_SUPPORTED',
1212
CIRCUIT_BREAKER_OPEN = 'CIRCUIT_BREAKER_OPEN',
1313
SIGNAL_DISPATCH_FAILED = 'SIGNAL_DISPATCH_FAILED',
14-
CONTEXT_UPDATE_FAILED = 'CONTEXT_UPDATE_FAILED'
14+
CONTEXT_UPDATE_FAILED = 'CONTEXT_UPDATE_FAILED',
15+
INVALID_SESSION_ID = 'INVALID_SESSION_ID'
1516
}

src/events/event-normalizer.service.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,8 @@ export class EventNormalizerService implements EventNormalizer {
190190
'TaskFail',
191191
'HandoffContext',
192192
'HandoffAccept',
193-
'HandoffDecline'
193+
'HandoffDecline',
194+
'Contribute'
194195
].includes(messageType)
195196
) {
196197
return 'proposal.updated';

src/runs/run-executor.service.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,10 @@ export class RunExecutorService {
267267
}
268268
);
269269

270+
if (initResult.instructions) {
271+
this.logger.log(`runtime instructions: ${initResult.instructions}`);
272+
}
273+
270274
if (
271275
initResult.supportedModes.length > 0 &&
272276
!initResult.supportedModes.includes(request.session.modeName)

src/runs/run-recovery.service.spec.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,8 @@ describe('RunRecoveryService', () => {
106106
runId: 'run-1',
107107
runtimeSessionId: 'sess-1',
108108
subscriberId: 'agent-1',
109-
resumeFromSeq: 42
109+
resumeFromSeq: 42,
110+
pollOnly: true
110111
})
111112
);
112113
});

src/runs/run-recovery.service.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,8 @@ export class RunRecoveryService implements OnApplicationBootstrap {
123123
runtimeKind: run.runtimeKind,
124124
runtimeSessionId,
125125
subscriberId,
126-
resumeFromSeq
126+
resumeFromSeq,
127+
pollOnly: true
127128
});
128129

129130
this.logger.log(`recovered run ${run.id} from seq ${run.lastEventSeq}`);

src/runs/stream-consumer.service.spec.ts

Lines changed: 6 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -119,18 +119,8 @@ describe('StreamConsumerService', () => {
119119

120120
describe('start()', () => {
121121
it('should be idempotent — second call returns immediately without starting a new loop', async () => {
122-
// Create an async iterable that never resolves so the loop stays active
123-
const neverEndingIterable: AsyncIterable<any> = {
124-
[Symbol.asyncIterator]() {
125-
return {
126-
next: () => new Promise(() => {}), // never resolves
127-
};
128-
},
129-
};
130-
131122
const mockProvider = {
132-
streamSession: jest.fn().mockReturnValue(neverEndingIterable),
133-
getSession: jest.fn(),
123+
getSession: jest.fn().mockReturnValue(new Promise(() => {})), // never resolves, keeps loop active
134124
};
135125
runtimeRegistry.get.mockReturnValue(mockProvider as any);
136126

@@ -156,24 +146,15 @@ describe('StreamConsumerService', () => {
156146
// Second call should return immediately since 'run-1' is already active
157147
await service.start(params);
158148

159-
// streamSession should have been called only once (from the first start call)
160-
expect(mockProvider.streamSession).toHaveBeenCalledTimes(1);
149+
// getSession should have been called only once (from the first start call's poll loop)
150+
expect(mockProvider.getSession).toHaveBeenCalledTimes(1);
161151
});
162152
});
163153

164154
describe('stop()', () => {
165155
it('should set the aborted flag on the active stream marker', async () => {
166-
const neverEndingIterable: AsyncIterable<any> = {
167-
[Symbol.asyncIterator]() {
168-
return {
169-
next: () => new Promise(() => {}),
170-
};
171-
},
172-
};
173-
174156
const mockProvider = {
175-
streamSession: jest.fn().mockReturnValue(neverEndingIterable),
176-
getSession: jest.fn(),
157+
getSession: jest.fn().mockReturnValue(new Promise(() => {})),
177158
};
178159
runtimeRegistry.get.mockReturnValue(mockProvider as any);
179160

@@ -214,17 +195,8 @@ describe('StreamConsumerService', () => {
214195

215196
describe('onModuleDestroy()', () => {
216197
it('should abort all active streams', async () => {
217-
const neverEndingIterable: AsyncIterable<any> = {
218-
[Symbol.asyncIterator]() {
219-
return {
220-
next: () => new Promise(() => {}),
221-
};
222-
},
223-
};
224-
225198
const mockProvider = {
226-
streamSession: jest.fn().mockReturnValue(neverEndingIterable),
227-
getSession: jest.fn(),
199+
getSession: jest.fn().mockReturnValue(new Promise(() => {})),
228200
};
229201
runtimeRegistry.get.mockReturnValue(mockProvider as any);
230202

@@ -269,17 +241,8 @@ describe('StreamConsumerService', () => {
269241
});
270242

271243
it('should return false when a stream is active but not connected', async () => {
272-
const neverEndingIterable: AsyncIterable<any> = {
273-
[Symbol.asyncIterator]() {
274-
return {
275-
next: () => new Promise(() => {}),
276-
};
277-
},
278-
};
279-
280244
const mockProvider = {
281-
streamSession: jest.fn().mockReturnValue(neverEndingIterable),
282-
getSession: jest.fn(),
245+
getSession: jest.fn().mockReturnValue(new Promise(() => {})),
283246
};
284247
runtimeRegistry.get.mockReturnValue(mockProvider as any);
285248

src/runs/stream-consumer.service.ts

Lines changed: 36 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export class StreamConsumerService implements OnModuleDestroy {
4747
subscriberId: string;
4848
resumeFromSeq?: number;
4949
sessionHandle?: RuntimeSessionHandle;
50+
pollOnly?: boolean;
5051
}): Promise<void> {
5152
if (this.active.has(params.runId)) return;
5253
const marker: ActiveStream = {
@@ -116,6 +117,7 @@ export class StreamConsumerService implements OnModuleDestroy {
116117
runtimeSessionId: string;
117118
subscriberId: string;
118119
sessionHandle?: RuntimeSessionHandle;
120+
pollOnly?: boolean;
119121
}
120122
): Promise<void> {
121123
const provider = this.runtimeRegistry.get(params.runtimeKind);
@@ -125,31 +127,29 @@ export class StreamConsumerService implements OnModuleDestroy {
125127
runtimeSessionId: params.runtimeSessionId
126128
};
127129

128-
let retries = 0;
129130
const maxRetries = this.config.streamMaxRetries;
130-
let isFirstIteration = true;
131131

132-
while (!marker.aborted) {
132+
// If we have a session handle and not poll-only, consume the stream first
133+
if (params.sessionHandle && !params.pollOnly) {
133134
try {
134-
// First iteration: use the session handle's events if provided
135-
// Subsequent iterations (reconnection): fall back to streamSession()
136-
const iterable = (isFirstIteration && params.sessionHandle)
137-
? params.sessionHandle.events
138-
: provider.streamSession({
139-
runId: params.runId,
140-
runtimeSessionId: params.runtimeSessionId,
141-
modeName: params.execution.session.modeName,
142-
subscriberId: params.subscriberId
143-
});
144-
isFirstIteration = false;
145-
146-
for await (const raw of this.withIdleTimeout(iterable, this.config.streamIdleTimeoutMs)) {
135+
for await (const raw of this.withIdleTimeout(params.sessionHandle.events, this.config.streamIdleTimeoutMs)) {
147136
if (marker.aborted) return;
148137
await this.handleRawEvent(params.runId, raw, context, params.runtimeSessionId, marker);
149138
if (marker.finalized) return;
150-
retries = 0;
151139
}
140+
} catch (error) {
141+
marker.connected = false;
142+
this.logger.warn(`stream error for run ${params.runId}: ${error instanceof Error ? error.message : String(error)}`);
143+
}
144+
145+
// Stream ended — check if already finalized
146+
if (marker.finalized || marker.aborted) return;
147+
}
152148

149+
// Polling fallback: poll getSession() until terminal state or max retries
150+
let retries = 0;
151+
while (!marker.aborted && !marker.finalized) {
152+
try {
153153
const snapshot = await provider.getSession({
154154
runId: params.runId,
155155
runtimeSessionId: params.runtimeSessionId,
@@ -172,72 +172,28 @@ export class StreamConsumerService implements OnModuleDestroy {
172172
await this.finalizeRun(params.runId, marker, 'failed', new Error('runtime session expired'));
173173
return;
174174
}
175+
} catch (pollError) {
176+
this.logger.warn(
177+
`getSession poll failed for run ${params.runId}: ${pollError instanceof Error ? pollError.message : String(pollError)}`
178+
);
179+
}
175180

176-
retries += 1;
177-
if (retries > maxRetries) {
178-
await this.finalizeRun(params.runId, marker, 'failed', new Error('stream ended without terminal session state'));
179-
return;
180-
}
181-
182-
await this.eventService.emitControlPlaneEvents(params.runId, [
183-
{
184-
ts: new Date().toISOString(),
185-
type: 'session.stream.opened',
186-
source: { kind: 'control-plane', name: 'stream-consumer' },
187-
subject: { kind: 'session', id: params.runtimeSessionId },
188-
data: { status: 'reconnecting', detail: 'stream ended before terminal state; retrying' }
189-
}
190-
]);
191-
await new Promise((resolve) => setTimeout(resolve, this.backoffMs(retries)));
192-
} catch (error) {
193-
marker.connected = false;
194-
retries += 1;
195-
this.logger.warn(`stream error for run ${params.runId}: ${error instanceof Error ? error.message : String(error)}`);
196-
await this.eventService.emitControlPlaneEvents(params.runId, [
197-
{
198-
ts: new Date().toISOString(),
199-
type: 'session.stream.opened',
200-
source: { kind: 'control-plane', name: 'stream-consumer' },
201-
subject: { kind: 'session', id: params.runtimeSessionId },
202-
data: { status: 'reconnecting', detail: error instanceof Error ? error.message : String(error) }
203-
}
204-
]);
205-
206-
if (retries > maxRetries) {
207-
await this.finalizeRun(params.runId, marker, 'failed', error);
208-
return;
209-
}
181+
retries += 1;
182+
if (retries > maxRetries) {
183+
await this.finalizeRun(params.runId, marker, 'failed', new Error('polling exhausted without terminal session state'));
184+
return;
185+
}
210186

211-
try {
212-
const snapshot = await provider.getSession({
213-
runId: params.runId,
214-
runtimeSessionId: params.runtimeSessionId,
215-
requesterId: params.subscriberId
216-
});
217-
await this.handleRawEvent(
218-
params.runId,
219-
{ kind: 'session-snapshot', receivedAt: new Date().toISOString(), sessionSnapshot: snapshot },
220-
context,
221-
params.runtimeSessionId,
222-
marker
223-
);
224-
if (marker.finalized) return;
225-
if (snapshot.state === 'SESSION_STATE_RESOLVED') {
226-
await this.finalizeRun(params.runId, marker, 'completed');
227-
return;
228-
}
229-
if (snapshot.state === 'SESSION_STATE_EXPIRED') {
230-
await this.finalizeRun(params.runId, marker, 'failed', new Error('runtime session expired'));
231-
return;
232-
}
233-
} catch (snapshotError) {
234-
this.logger.warn(
235-
`reconciliation failed for run ${params.runId}: ${snapshotError instanceof Error ? snapshotError.message : String(snapshotError)}`
236-
);
187+
await this.eventService.emitControlPlaneEvents(params.runId, [
188+
{
189+
ts: new Date().toISOString(),
190+
type: 'session.stream.opened',
191+
source: { kind: 'control-plane', name: 'stream-consumer' },
192+
subject: { kind: 'session', id: params.runtimeSessionId },
193+
data: { status: 'reconnecting', detail: 'polling getSession for terminal state' }
237194
}
238-
239-
await new Promise((resolve) => setTimeout(resolve, this.backoffMs(retries)));
240-
}
195+
]);
196+
await new Promise((resolve) => setTimeout(resolve, this.backoffMs(retries)));
241197
}
242198
}
243199

src/runtime/proto-registry.service.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ const MESSAGE_TYPE_MAP: Record<string, Record<string, string>> = {
4242
Approve: 'macp.modes.quorum.v1.ApprovePayload',
4343
Reject: 'macp.modes.quorum.v1.RejectPayload',
4444
Abstain: 'macp.modes.quorum.v1.AbstainPayload'
45+
},
46+
'ext.multi_round.v1': {
47+
Contribute: '__json__'
4548
}
4649
};
4750

@@ -73,6 +76,7 @@ export class ProtoRegistryService implements OnModuleInit {
7376
const missingTypes: string[] = [];
7477
for (const [mode, types] of Object.entries(MESSAGE_TYPE_MAP)) {
7578
for (const [msgType, typeName] of Object.entries(types)) {
79+
if (typeName === '__json__') continue; // Extension modes use JSON, no proto type
7680
try {
7781
this.root.lookupType(typeName);
7882
} catch {
@@ -120,6 +124,10 @@ export class ProtoRegistryService implements OnModuleInit {
120124
if (!typeName) {
121125
return this.tryDecodeUtf8(payload);
122126
}
127+
// Extension modes using JSON payloads (no proto definition)
128+
if (typeName === '__json__') {
129+
return this.tryDecodeUtf8(payload);
130+
}
123131
return this.decodeMessage(typeName, payload);
124132
}
125133

0 commit comments

Comments
 (0)