@peakflo/agent-harness 0.2.5-rc.1 → 0.2.5-rc.3
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/adapters/coding-agent-adapter.d.ts +2 -0
- package/dist/adapters/pi-adapter.d.ts +3 -0
- package/dist/adapters/pi-adapter.js +113 -4
- package/dist/capabilities.d.ts +2 -0
- package/dist/capabilities.js +27 -2
- package/dist/contract.d.ts +1 -0
- package/dist/contract.js +1 -0
- package/dist/control-plane.d.ts +3 -0
- package/dist/control-plane.js +8 -0
- package/dist/harness.d.ts +14 -4
- package/dist/harness.js +107 -20
- package/dist/runtime/session-runner.d.ts +21 -1
- package/dist/runtime/session-runner.js +211 -7
- package/dist/types.d.ts +9 -1
- package/package.json +1 -1
|
@@ -186,6 +186,8 @@ export interface CodingAgentAdapter {
|
|
|
186
186
|
* not (the caller still has the transcript note either way).
|
|
187
187
|
*/
|
|
188
188
|
steerTurn?(sessionId: string, text: string, config: SessionConfig): Promise<boolean>;
|
|
189
|
+
/** A human message. Return true only once the live backend has accepted it. */
|
|
190
|
+
steerUserMessage?(sessionId: string, text: string, config: SessionConfig): Promise<boolean>;
|
|
189
191
|
/**
|
|
190
192
|
* Get current session status
|
|
191
193
|
*/
|
|
@@ -89,6 +89,7 @@ export declare class PiAdapter implements CodingAgentAdapter {
|
|
|
89
89
|
private consumeStdout;
|
|
90
90
|
private handleJsonlLine;
|
|
91
91
|
private rejectPending;
|
|
92
|
+
private refreshSteeringActivityAtIdle;
|
|
92
93
|
private handleEvent;
|
|
93
94
|
private handleExtensionUiRequest;
|
|
94
95
|
private uiRequestMessage;
|
|
@@ -110,6 +111,8 @@ export declare class PiAdapter implements CodingAgentAdapter {
|
|
|
110
111
|
* into an already-running turn must not reset them.
|
|
111
112
|
*/
|
|
112
113
|
steerTurn(sessionId: string, text: string, _config: SessionConfig): Promise<boolean>;
|
|
114
|
+
/** Resolve only when Pi emits the queued user message into the active turn. */
|
|
115
|
+
steerUserMessage(sessionId: string, text: string, _config: SessionConfig): Promise<boolean>;
|
|
113
116
|
getStatus(sessionId: string, _config: SessionConfig): Promise<SessionStatus>;
|
|
114
117
|
private settlePendingTurnError;
|
|
115
118
|
pollMessages(sessionId: string): Promise<MessagePart[]>;
|
|
@@ -30,6 +30,9 @@ const MINIMUM_PI_VERSION = [0, 80, 5];
|
|
|
30
30
|
const PI_PERMISSION_MODE_ENV = 'PEAKFLO_PI_PERMISSION_MODE';
|
|
31
31
|
exports.PI_DEV_SERVER_POLICY_ENV = 'PF_DEV_SERVER_POLICY_FILE';
|
|
32
32
|
const TERMINAL_ERROR_SETTLE_GRACE_MS = 1_000;
|
|
33
|
+
// A tool may be silent for the full progress-guard window. Turn finalization
|
|
34
|
+
// has its own short bound; this timer protects an active turn with no echo.
|
|
35
|
+
const STEER_CONSUMPTION_TIMEOUT_MS = 20 * 60_000;
|
|
33
36
|
const piInstanceIdCache = new Map();
|
|
34
37
|
/**
|
|
35
38
|
* Most model providers cap function/tool `name` at 64 characters. Pi forwards
|
|
@@ -228,6 +231,16 @@ function textFromContent(content) {
|
|
|
228
231
|
.map((block) => String(block.text ?? ''))
|
|
229
232
|
.join('');
|
|
230
233
|
}
|
|
234
|
+
function normalizeSteerText(text) {
|
|
235
|
+
return text.replace(/\r\n?/g, '\n').trim();
|
|
236
|
+
}
|
|
237
|
+
function safeSteeringError(error) {
|
|
238
|
+
// Pi may include the submitted prompt in an RPC error. Never log the error
|
|
239
|
+
// text here; even URL and token redaction cannot remove arbitrary user data.
|
|
240
|
+
return error instanceof Error && /timed out/i.test(error.message)
|
|
241
|
+
? 'rpc-timeout'
|
|
242
|
+
: 'rpc-error';
|
|
243
|
+
}
|
|
231
244
|
function resultText(result) {
|
|
232
245
|
if (!result || typeof result !== 'object')
|
|
233
246
|
return '';
|
|
@@ -471,6 +484,8 @@ class PiAdapter {
|
|
|
471
484
|
status: 'idle',
|
|
472
485
|
lastError: null,
|
|
473
486
|
pending: new Map(),
|
|
487
|
+
pendingSteers: [],
|
|
488
|
+
steeringSupported: true,
|
|
474
489
|
parts: [],
|
|
475
490
|
allMessages: [],
|
|
476
491
|
stdoutBuffer: '',
|
|
@@ -502,6 +517,17 @@ class PiAdapter {
|
|
|
502
517
|
};
|
|
503
518
|
}
|
|
504
519
|
async applySelection(session, config) {
|
|
520
|
+
// An older Pi can reject this RPC. Keep ordinary turns working and let the
|
|
521
|
+
// control plane queue any steer that this process cannot consume.
|
|
522
|
+
try {
|
|
523
|
+
await this.command(session, { type: 'set_steering_mode', mode: 'all' });
|
|
524
|
+
}
|
|
525
|
+
catch (error) {
|
|
526
|
+
console.warn(`[PiAdapter/${session.id}] Pi did not accept steering mode; this session will queue new messages: ${safeSteeringError(error)}`);
|
|
527
|
+
// We cannot prove that Pi applied the mode after a timeout. Treat both
|
|
528
|
+
// explicit rejection and transport failure as a queue fallback.
|
|
529
|
+
session.steeringSupported = false;
|
|
530
|
+
}
|
|
505
531
|
if (config.model && config.model !== 'default') {
|
|
506
532
|
const { provider, modelId } = this.splitModel(config.model);
|
|
507
533
|
if (!provider || !modelId) {
|
|
@@ -669,12 +695,41 @@ class PiAdapter {
|
|
|
669
695
|
}
|
|
670
696
|
}
|
|
671
697
|
rejectPending(session, error) {
|
|
698
|
+
for (const steer of session.pendingSteers.splice(0))
|
|
699
|
+
steer.resolve(false);
|
|
672
700
|
for (const request of session.pending.values()) {
|
|
673
701
|
clearTimeout(request.timer);
|
|
674
702
|
request.reject(error);
|
|
675
703
|
}
|
|
676
704
|
session.pending.clear();
|
|
677
705
|
}
|
|
706
|
+
async refreshSteeringActivityAtIdle(session) {
|
|
707
|
+
const observedTurn = session.turn;
|
|
708
|
+
try {
|
|
709
|
+
const state = await this.command(session, { type: 'get_state' });
|
|
710
|
+
if (session.turn !== observedTurn ||
|
|
711
|
+
session.status === 'error' ||
|
|
712
|
+
session.closing)
|
|
713
|
+
return;
|
|
714
|
+
if (Number(state.data?.pendingMessageCount ?? 0) > 0 ||
|
|
715
|
+
state.data?.isStreaming === true) {
|
|
716
|
+
session.status = 'busy';
|
|
717
|
+
}
|
|
718
|
+
else {
|
|
719
|
+
session.status = 'idle';
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
catch (error) {
|
|
723
|
+
console.warn(`[PiAdapter/${session.id}] Could not inspect steering queue at idle: ${safeSteeringError(error)}`);
|
|
724
|
+
if (session.turn === observedTurn &&
|
|
725
|
+
session.status !== 'error' &&
|
|
726
|
+
!session.closing)
|
|
727
|
+
session.status = 'idle';
|
|
728
|
+
}
|
|
729
|
+
// An empty queue is not proof of non-consumption: Pi can remove a message
|
|
730
|
+
// just before emitting message_start. The turn close requeues unconfirmed
|
|
731
|
+
// messages after its bounded wait.
|
|
732
|
+
}
|
|
678
733
|
handleEvent(session, event) {
|
|
679
734
|
if (event.type === 'response') {
|
|
680
735
|
const response = event;
|
|
@@ -728,10 +783,17 @@ class PiAdapter {
|
|
|
728
783
|
break;
|
|
729
784
|
case 'agent_settled': {
|
|
730
785
|
this.cancelPendingUiRequests(session, 'This request is no longer active.');
|
|
731
|
-
if (session.pendingTurnError)
|
|
786
|
+
if (session.pendingTurnError) {
|
|
732
787
|
this.settlePendingTurnError(session);
|
|
733
|
-
|
|
734
|
-
|
|
788
|
+
}
|
|
789
|
+
else {
|
|
790
|
+
session.status = session.pendingSteers.length ? 'busy' : 'idle';
|
|
791
|
+
// Pi can settle after a tool even with a steer still queued.
|
|
792
|
+
// Keep the session busy until this state read completes.
|
|
793
|
+
if (session.pendingSteers.length) {
|
|
794
|
+
void this.refreshSteeringActivityAtIdle(session);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
735
797
|
session.promptMayBeCommandOnly = false;
|
|
736
798
|
break;
|
|
737
799
|
}
|
|
@@ -739,6 +801,16 @@ class PiAdapter {
|
|
|
739
801
|
const message = event.message;
|
|
740
802
|
if (message?.role === 'assistant')
|
|
741
803
|
session.assistantMessageOrdinal++;
|
|
804
|
+
if (message?.role === 'user') {
|
|
805
|
+
const text = textFromContent(message.content);
|
|
806
|
+
// Pi gives no steer ID on this event. Match the oldest equal text.
|
|
807
|
+
// This assumes an equal user event came from Pi's prompt queue; an
|
|
808
|
+
// unrelated equal user event could confirm the steer early.
|
|
809
|
+
const index = session.pendingSteers.findIndex((steer) => normalizeSteerText(steer.text) === normalizeSteerText(text));
|
|
810
|
+
if (index >= 0) {
|
|
811
|
+
session.pendingSteers.splice(index, 1)[0].resolve(true);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
742
814
|
break;
|
|
743
815
|
}
|
|
744
816
|
case 'message_update':
|
|
@@ -1107,8 +1179,45 @@ class PiAdapter {
|
|
|
1107
1179
|
});
|
|
1108
1180
|
return true;
|
|
1109
1181
|
}
|
|
1110
|
-
catch {
|
|
1182
|
+
catch (error) {
|
|
1183
|
+
console.warn(`[PiAdapter/${session.id}] Pi could not accept a steer message: ${safeSteeringError(error)}`);
|
|
1184
|
+
return false;
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
/** Resolve only when Pi emits the queued user message into the active turn. */
|
|
1188
|
+
async steerUserMessage(sessionId, text, _config) {
|
|
1189
|
+
const session = this.sessions.get(sessionId);
|
|
1190
|
+
if (!session ||
|
|
1191
|
+
session.status !== 'busy' ||
|
|
1192
|
+
session.steeringSupported === false ||
|
|
1193
|
+
!text)
|
|
1111
1194
|
return false;
|
|
1195
|
+
let resolveSeen;
|
|
1196
|
+
const seen = new Promise((resolve) => {
|
|
1197
|
+
resolveSeen = resolve;
|
|
1198
|
+
});
|
|
1199
|
+
const waiter = { text, resolve: resolveSeen };
|
|
1200
|
+
session.pendingSteers.push(waiter);
|
|
1201
|
+
// A timeout can requeue text Pi already consumed if its user event was
|
|
1202
|
+
// lost. Durable exact-once recovery is tracked by task_1791540809105_ual6uq3.
|
|
1203
|
+
const timer = setTimeout(() => resolveSeen(false), STEER_CONSUMPTION_TIMEOUT_MS);
|
|
1204
|
+
timer.unref?.();
|
|
1205
|
+
try {
|
|
1206
|
+
await this.command(session, {
|
|
1207
|
+
type: 'steer',
|
|
1208
|
+
message: text
|
|
1209
|
+
});
|
|
1210
|
+
return await seen;
|
|
1211
|
+
}
|
|
1212
|
+
catch (error) {
|
|
1213
|
+
console.warn(`[PiAdapter/${session.id}] Pi could not accept a steer message: ${safeSteeringError(error)}`);
|
|
1214
|
+
return false;
|
|
1215
|
+
}
|
|
1216
|
+
finally {
|
|
1217
|
+
clearTimeout(timer);
|
|
1218
|
+
const index = session.pendingSteers.indexOf(waiter);
|
|
1219
|
+
if (index >= 0)
|
|
1220
|
+
session.pendingSteers.splice(index, 1);
|
|
1112
1221
|
}
|
|
1113
1222
|
}
|
|
1114
1223
|
async getStatus(sessionId, _config) {
|
package/dist/capabilities.d.ts
CHANGED
|
@@ -32,6 +32,8 @@ import { RunnerCapabilities } from './types';
|
|
|
32
32
|
* `knowledge_files_list` / `knowledge_file_download` (P9).
|
|
33
33
|
*/
|
|
34
34
|
export declare const HARNESS_FEATURES: readonly ["spaceFiles"];
|
|
35
|
+
/** Steering mode was verified with Pi 0.84.3. Older Pi keeps the queue path. */
|
|
36
|
+
export declare function supportsPiSteering(version: string | null): boolean;
|
|
35
37
|
export interface ProbeResult {
|
|
36
38
|
installed: boolean;
|
|
37
39
|
version: string | null;
|
package/dist/capabilities.js
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
*/
|
|
27
27
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
28
28
|
exports.HARNESS_FEATURES = void 0;
|
|
29
|
+
exports.supportsPiSteering = supportsPiSteering;
|
|
29
30
|
exports.probe = probe;
|
|
30
31
|
exports.probeCapabilities = probeCapabilities;
|
|
31
32
|
const node_child_process_1 = require("node:child_process");
|
|
@@ -42,6 +43,25 @@ const PROBE_TIMEOUT_MS = 10_000;
|
|
|
42
43
|
* `knowledge_files_list` / `knowledge_file_download` (P9).
|
|
43
44
|
*/
|
|
44
45
|
exports.HARNESS_FEATURES = ['spaceFiles'];
|
|
46
|
+
/** Steering mode was verified with Pi 0.84.3. Older Pi keeps the queue path. */
|
|
47
|
+
function supportsPiSteering(version) {
|
|
48
|
+
if (!version)
|
|
49
|
+
return false;
|
|
50
|
+
// Only a stable release was verified. A prerelease or custom build can
|
|
51
|
+
// change the RPC contract even when its base version meets the minimum.
|
|
52
|
+
const match = version.match(/^(\d+)\.(\d+)\.(\d+)$/);
|
|
53
|
+
if (!match)
|
|
54
|
+
return false;
|
|
55
|
+
const parts = match.slice(1, 4).map(Number);
|
|
56
|
+
const minimum = [0, 84, 3];
|
|
57
|
+
for (let index = 0; index < minimum.length; index++) {
|
|
58
|
+
if (parts[index] > minimum[index])
|
|
59
|
+
return true;
|
|
60
|
+
if (parts[index] < minimum[index])
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
45
65
|
/**
|
|
46
66
|
* The coding-agent backends this harness knows how to drive, and the command
|
|
47
67
|
* that reports each one's version.
|
|
@@ -105,7 +125,7 @@ function probe(command, args = ['--version']) {
|
|
|
105
125
|
const raw = String(stdout).trim();
|
|
106
126
|
// Same expression as detect.js: pick the first version-like token, and
|
|
107
127
|
// fall back to the first line when a tool prints something exotic.
|
|
108
|
-
const match = raw.match(/(\d+\.\d+[\w
|
|
128
|
+
const match = raw.match(/(\d+\.\d+[\w.+-]*)/);
|
|
109
129
|
finish({
|
|
110
130
|
installed: true,
|
|
111
131
|
version: match ? match[1] : raw.split('\n')[0] || null
|
|
@@ -165,7 +185,12 @@ async function probeCapabilities(options = {}) {
|
|
|
165
185
|
git: git.installed,
|
|
166
186
|
docker: docker.installed,
|
|
167
187
|
maxConcurrentSessions: options.maxConcurrentSessions ?? 1,
|
|
168
|
-
features: [
|
|
188
|
+
features: [
|
|
189
|
+
...exports.HARNESS_FEATURES,
|
|
190
|
+
...(backends.some((backend) => backend.name === 'pi' && supportsPiSteering(backend.version))
|
|
191
|
+
? ['steerMessages']
|
|
192
|
+
: [])
|
|
193
|
+
],
|
|
169
194
|
labels: options.labels
|
|
170
195
|
};
|
|
171
196
|
}
|
package/dist/contract.d.ts
CHANGED
|
@@ -196,6 +196,7 @@ export declare const ROUTES: {
|
|
|
196
196
|
readonly lease: (runnerId: string, waitSeconds?: number, protocolVersion?: number) => string;
|
|
197
197
|
/** Bearer: runner credential. Visibility-timeout renewal. */
|
|
198
198
|
readonly leaseRenew: (runnerId: string, leaseId: string) => string;
|
|
199
|
+
readonly steerAck: (runnerId: string, leaseId: string, turnId: string) => string;
|
|
199
200
|
/** Bearer: runner credential. Metadata of the knowledge files this turn may read. */
|
|
200
201
|
readonly knowledgeFiles: (runnerId: string, leaseId: string) => string;
|
|
201
202
|
/** Bearer: runner credential. A short-lived download URL for one knowledge file. */
|
package/dist/contract.js
CHANGED
|
@@ -236,6 +236,7 @@ exports.ROUTES = {
|
|
|
236
236
|
},
|
|
237
237
|
/** Bearer: runner credential. Visibility-timeout renewal. */
|
|
238
238
|
leaseRenew: (runnerId, leaseId) => `/api/runners/${encodeURIComponent(runnerId)}/lease/${encodeURIComponent(leaseId)}/renew`,
|
|
239
|
+
steerAck: (runnerId, leaseId, turnId) => `/api/runners/${encodeURIComponent(runnerId)}/lease/${encodeURIComponent(leaseId)}/steer/${encodeURIComponent(turnId)}/ack`,
|
|
239
240
|
/** Bearer: runner credential. Metadata of the knowledge files this turn may read. */
|
|
240
241
|
knowledgeFiles: (runnerId, leaseId) => `/api/runners/${encodeURIComponent(runnerId)}/lease/${encodeURIComponent(leaseId)}/knowledge/files`,
|
|
241
242
|
/** Bearer: runner credential. A short-lived download URL for one knowledge file. */
|
package/dist/control-plane.d.ts
CHANGED
|
@@ -150,6 +150,9 @@ export declare class ControlPlaneClient {
|
|
|
150
150
|
* idempotent.
|
|
151
151
|
*/
|
|
152
152
|
ingest(sessionId: string, request: Omit<IngestRequest, 'protocolVersion'>): Promise<IngestResponse>;
|
|
153
|
+
acknowledgeSteer(sessionId: string, leaseId: string, turnId: string, consumed: boolean): Promise<{
|
|
154
|
+
delivery: 'steered' | 'queued';
|
|
155
|
+
}>;
|
|
153
156
|
/**
|
|
154
157
|
* Read the session's model-facing conversation back from the control plane.
|
|
155
158
|
*
|
package/dist/control-plane.js
CHANGED
|
@@ -329,6 +329,14 @@ class ControlPlaneClient {
|
|
|
329
329
|
body: { protocolVersion: contract_1.PROTOCOL_VERSION, ...request }
|
|
330
330
|
}));
|
|
331
331
|
}
|
|
332
|
+
async acknowledgeSteer(sessionId, leaseId, turnId, consumed) {
|
|
333
|
+
return this.withRetry('steer-ack', () => this.send({
|
|
334
|
+
method: 'POST',
|
|
335
|
+
path: contract_1.ROUTES.steerAck(this.requireRunnerId(), leaseId, turnId),
|
|
336
|
+
token: this.requireCredential(),
|
|
337
|
+
body: { protocolVersion: contract_1.PROTOCOL_VERSION, sessionId, consumed }
|
|
338
|
+
}));
|
|
339
|
+
}
|
|
332
340
|
/* ------------------------------------------------------------------ */
|
|
333
341
|
/* 5b. session history — the runner's READ path */
|
|
334
342
|
/* ------------------------------------------------------------------ */
|
package/dist/harness.d.ts
CHANGED
|
@@ -174,10 +174,15 @@ export declare class Harness {
|
|
|
174
174
|
private releaseGitHubGuard;
|
|
175
175
|
/** sessionId -> the controller that aborts its turn. */
|
|
176
176
|
private readonly active;
|
|
177
|
+
private readonly activeLeaseIds;
|
|
177
178
|
/** Renewal workers that must receive a credential rotation in flight. */
|
|
178
179
|
private readonly activeRenewals;
|
|
179
180
|
/** commandIds already applied. `SessionCommand` is idempotent by contract. */
|
|
180
181
|
private readonly appliedCommands;
|
|
182
|
+
/** Prune lease-bound command IDs when their session turn ends. */
|
|
183
|
+
private readonly appliedCommandsBySession;
|
|
184
|
+
/** A steer can arrive on renewal while a leased runner is still starting. */
|
|
185
|
+
private readonly deferredSteers;
|
|
181
186
|
/** Session-end cleanup waits for an active turn to save its dirty work. */
|
|
182
187
|
private readonly pendingWorkspaceReleases;
|
|
183
188
|
/**
|
|
@@ -283,14 +288,19 @@ export declare class Harness {
|
|
|
283
288
|
private modelsNeedRefresh;
|
|
284
289
|
private scheduleHeartbeat;
|
|
285
290
|
private sendHeartbeat;
|
|
291
|
+
/** A bad command must not stop the others or fail a transcript ingest. */
|
|
292
|
+
private applyCommandsSafely;
|
|
293
|
+
private forgetAppliedCommand;
|
|
294
|
+
private clearAppliedCommandsForSession;
|
|
286
295
|
/**
|
|
287
|
-
* Apply a command that rode back on a heartbeat.
|
|
296
|
+
* Apply a command that rode back on a heartbeat or ingest response.
|
|
288
297
|
*
|
|
289
|
-
* Idempotent by `commandId`, because the
|
|
290
|
-
*
|
|
291
|
-
* know, since it cannot dial in and ask.
|
|
298
|
+
* Idempotent by `commandId`, because the control plane may repeat a command
|
|
299
|
+
* until it observes the effect; it cannot dial the harness to ask.
|
|
292
300
|
*/
|
|
293
301
|
private applyCommand;
|
|
302
|
+
/** Deliver commands received while the leased runner was still starting. */
|
|
303
|
+
private flushDeferredSteers;
|
|
294
304
|
private releaseSessionWorkspace;
|
|
295
305
|
/**
|
|
296
306
|
* A user's answer did not reach the backend. Do the two things that keep it
|
package/dist/harness.js
CHANGED
|
@@ -151,10 +151,15 @@ class Harness {
|
|
|
151
151
|
releaseGitHubGuard = null;
|
|
152
152
|
/** sessionId -> the controller that aborts its turn. */
|
|
153
153
|
active = new Map();
|
|
154
|
+
activeLeaseIds = new Map();
|
|
154
155
|
/** Renewal workers that must receive a credential rotation in flight. */
|
|
155
156
|
activeRenewals = new Set();
|
|
156
157
|
/** commandIds already applied. `SessionCommand` is idempotent by contract. */
|
|
157
158
|
appliedCommands = new Set();
|
|
159
|
+
/** Prune lease-bound command IDs when their session turn ends. */
|
|
160
|
+
appliedCommandsBySession = new Map();
|
|
161
|
+
/** A steer can arrive on renewal while a leased runner is still starting. */
|
|
162
|
+
deferredSteers = new Map();
|
|
158
163
|
/** Session-end cleanup waits for an active turn to save its dirty work. */
|
|
159
164
|
pendingWorkspaceReleases = new Set();
|
|
160
165
|
/**
|
|
@@ -339,6 +344,7 @@ class Harness {
|
|
|
339
344
|
}
|
|
340
345
|
this.stopping = true;
|
|
341
346
|
this.status = 'draining';
|
|
347
|
+
this.deferredSteers.clear();
|
|
342
348
|
if (this.heartbeatTimer) {
|
|
343
349
|
clearTimeout(this.heartbeatTimer);
|
|
344
350
|
this.heartbeatTimer = null;
|
|
@@ -507,9 +513,7 @@ class Harness {
|
|
|
507
513
|
}
|
|
508
514
|
this.observeHarnessPin(response.harnessVersion);
|
|
509
515
|
this.observeSandboxToolsPin(response.sandboxToolsVersion);
|
|
510
|
-
|
|
511
|
-
this.applyCommand(command);
|
|
512
|
-
}
|
|
516
|
+
this.applyCommandsSafely(response.sessionCommands ?? []);
|
|
513
517
|
log('Reused stored runner credential', {
|
|
514
518
|
runnerId: stored.runnerId,
|
|
515
519
|
tenantId: stored.tenantId
|
|
@@ -783,9 +787,7 @@ class Harness {
|
|
|
783
787
|
}
|
|
784
788
|
this.observeHarnessPin(response.harnessVersion);
|
|
785
789
|
this.observeSandboxToolsPin(response.sandboxToolsVersion);
|
|
786
|
-
|
|
787
|
-
this.applyCommand(command);
|
|
788
|
-
}
|
|
790
|
+
this.applyCommandsSafely(response.sessionCommands ?? []);
|
|
789
791
|
if (response.action === 'drain') {
|
|
790
792
|
this.status = 'draining';
|
|
791
793
|
log('Control plane asked the harness to drain');
|
|
@@ -823,21 +825,85 @@ class Harness {
|
|
|
823
825
|
}
|
|
824
826
|
this.scheduleHeartbeat();
|
|
825
827
|
}
|
|
828
|
+
/** A bad command must not stop the others or fail a transcript ingest. */
|
|
829
|
+
applyCommandsSafely(commands) {
|
|
830
|
+
for (const command of commands) {
|
|
831
|
+
try {
|
|
832
|
+
this.applyCommand(command);
|
|
833
|
+
}
|
|
834
|
+
catch (error) {
|
|
835
|
+
// Commands are a side channel. An application error must not reject
|
|
836
|
+
// the ingest of model output or skip the commands after this one.
|
|
837
|
+
this.forgetAppliedCommand(command);
|
|
838
|
+
log('Session command failed', {
|
|
839
|
+
sessionId: command.sessionId,
|
|
840
|
+
commandId: command.commandId,
|
|
841
|
+
command: command.command,
|
|
842
|
+
error: error instanceof Error ? error.message : String(error)
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
forgetAppliedCommand(command) {
|
|
848
|
+
this.appliedCommands.delete(command.commandId);
|
|
849
|
+
const ids = this.appliedCommandsBySession.get(command.sessionId);
|
|
850
|
+
ids?.delete(command.commandId);
|
|
851
|
+
if (ids?.size === 0)
|
|
852
|
+
this.appliedCommandsBySession.delete(command.sessionId);
|
|
853
|
+
}
|
|
854
|
+
clearAppliedCommandsForSession(sessionId) {
|
|
855
|
+
for (const id of this.appliedCommandsBySession.get(sessionId) ?? []) {
|
|
856
|
+
this.appliedCommands.delete(id);
|
|
857
|
+
}
|
|
858
|
+
this.appliedCommandsBySession.delete(sessionId);
|
|
859
|
+
}
|
|
826
860
|
/**
|
|
827
|
-
* Apply a command that rode back on a heartbeat.
|
|
861
|
+
* Apply a command that rode back on a heartbeat or ingest response.
|
|
828
862
|
*
|
|
829
|
-
* Idempotent by `commandId`, because the
|
|
830
|
-
*
|
|
831
|
-
* know, since it cannot dial in and ask.
|
|
863
|
+
* Idempotent by `commandId`, because the control plane may repeat a command
|
|
864
|
+
* until it observes the effect; it cannot dial the harness to ask.
|
|
832
865
|
*/
|
|
833
866
|
applyCommand(command) {
|
|
834
867
|
if (this.appliedCommands.has(command.commandId)) {
|
|
835
868
|
return;
|
|
836
869
|
}
|
|
837
870
|
this.appliedCommands.add(command.commandId);
|
|
871
|
+
const sessionCommands = this.appliedCommandsBySession.get(command.sessionId) ?? new Set();
|
|
872
|
+
sessionCommands.add(command.commandId);
|
|
873
|
+
this.appliedCommandsBySession.set(command.sessionId, sessionCommands);
|
|
874
|
+
if (command.command === 'steer') {
|
|
875
|
+
const runner = this.runners.get(command.sessionId);
|
|
876
|
+
if (!runner || !command.turnId || !command.leaseId || !command.input) {
|
|
877
|
+
if (!runner && command.turnId && command.leaseId && command.input) {
|
|
878
|
+
this.deferredSteers.set(command.commandId, command);
|
|
879
|
+
}
|
|
880
|
+
this.forgetAppliedCommand(command);
|
|
881
|
+
// The durable turn remains queued until Pi confirms consumption.
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
if (this.activeLeaseIds.get(command.sessionId) !== command.leaseId) {
|
|
885
|
+
this.forgetAppliedCommand(command);
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
runner.steerMessage({
|
|
889
|
+
sessionId: command.sessionId,
|
|
890
|
+
turnId: command.turnId,
|
|
891
|
+
input: command.input,
|
|
892
|
+
acknowledge: async (consumed) => (await this.client.acknowledgeSteer(command.sessionId, command.leaseId, command.turnId, consumed)).delivery
|
|
893
|
+
});
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
838
896
|
if (command.command === 'abort') {
|
|
839
897
|
const controller = this.active.get(command.sessionId);
|
|
840
|
-
|
|
898
|
+
const activeLeaseId = this.activeLeaseIds.get(command.sessionId);
|
|
899
|
+
if (!controller || (command.optionId && !activeLeaseId)) {
|
|
900
|
+
// A command can arrive between lease claim and local runner setup.
|
|
901
|
+
// Leave its id open so the control plane's safe abort redelivery can
|
|
902
|
+
// apply it once this exact lease is active.
|
|
903
|
+
this.forgetAppliedCommand(command);
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
if (!command.optionId || activeLeaseId === command.optionId) {
|
|
841
907
|
log('Aborting session by control-plane command', {
|
|
842
908
|
sessionId: command.sessionId
|
|
843
909
|
});
|
|
@@ -855,7 +921,7 @@ class Harness {
|
|
|
855
921
|
});
|
|
856
922
|
}
|
|
857
923
|
else {
|
|
858
|
-
void this.releaseSessionWorkspace(command.sessionId);
|
|
924
|
+
void this.releaseSessionWorkspace(command.sessionId).finally(() => this.forgetAppliedCommand(command));
|
|
859
925
|
}
|
|
860
926
|
return;
|
|
861
927
|
}
|
|
@@ -896,7 +962,7 @@ class Harness {
|
|
|
896
962
|
* all that can be said here; what this buys is that a redelivery would
|
|
897
963
|
* be attempted rather than discarded as a duplicate.
|
|
898
964
|
*/
|
|
899
|
-
this.
|
|
965
|
+
this.forgetAppliedCommand(command);
|
|
900
966
|
log('Answer arrived for a session this runner is no longer running', {
|
|
901
967
|
sessionId: command.sessionId,
|
|
902
968
|
commandId: command.commandId
|
|
@@ -964,6 +1030,16 @@ class Harness {
|
|
|
964
1030
|
});
|
|
965
1031
|
}
|
|
966
1032
|
}
|
|
1033
|
+
/** Deliver commands received while the leased runner was still starting. */
|
|
1034
|
+
flushDeferredSteers(sessionId, leaseId) {
|
|
1035
|
+
for (const [commandId, command] of this.deferredSteers) {
|
|
1036
|
+
if (command.sessionId !== sessionId)
|
|
1037
|
+
continue;
|
|
1038
|
+
this.deferredSteers.delete(commandId);
|
|
1039
|
+
if (command.leaseId === leaseId)
|
|
1040
|
+
this.applyCommandsSafely([command]);
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
967
1043
|
async releaseSessionWorkspace(sessionId) {
|
|
968
1044
|
try {
|
|
969
1045
|
const released = await this.workspace.release(sessionId, { force: true });
|
|
@@ -993,7 +1069,7 @@ class Harness {
|
|
|
993
1069
|
* backend.
|
|
994
1070
|
*/
|
|
995
1071
|
recoverFailedAnswer(runner, command, reason) {
|
|
996
|
-
this.
|
|
1072
|
+
this.forgetAppliedCommand(command);
|
|
997
1073
|
void runner.reportApprovalFailure(command.sessionId, reason).catch(() => {
|
|
998
1074
|
// If even that cannot be delivered, the log at the call site is all
|
|
999
1075
|
// there is. Never let reporting a failure become a second failure.
|
|
@@ -1230,15 +1306,14 @@ class Harness {
|
|
|
1230
1306
|
*/
|
|
1231
1307
|
const renewalCallbacks = {
|
|
1232
1308
|
leaseId: lease.leaseId,
|
|
1309
|
+
// A running turn does not poll the lease endpoint. Ingest returns
|
|
1310
|
+
// commands at each output boundary; a silent tool relies on heartbeat
|
|
1311
|
+
// or this normal renewal interval, which can take up to a minute.
|
|
1233
1312
|
intervalMs: this.deps.leaseRenewIntervalMs,
|
|
1234
1313
|
// While a permission prompt is open, renew — and therefore collect
|
|
1235
1314
|
// commands — every couple of seconds instead of every minute.
|
|
1236
1315
|
urgentIntervalMs: this.deps.approvalRenewIntervalMs,
|
|
1237
|
-
onCommands: (commands) =>
|
|
1238
|
-
for (const command of commands) {
|
|
1239
|
-
this.applyCommand(command);
|
|
1240
|
-
}
|
|
1241
|
-
},
|
|
1316
|
+
onCommands: (commands) => this.applyCommandsSafely(commands),
|
|
1242
1317
|
githubTokenExpiresAt: lease.githubCredential?.expiresAt,
|
|
1243
1318
|
onGithubCredential: (credential) => {
|
|
1244
1319
|
githubWorkspace.setCredential?.(sessionId, credential);
|
|
@@ -1395,7 +1470,9 @@ class Harness {
|
|
|
1395
1470
|
final,
|
|
1396
1471
|
modelMessages
|
|
1397
1472
|
})
|
|
1398
|
-
.then(() =>
|
|
1473
|
+
.then((response) => {
|
|
1474
|
+
this.applyCommandsSafely(response.sessionCommands ?? []);
|
|
1475
|
+
})
|
|
1399
1476
|
});
|
|
1400
1477
|
runnerRef = runner;
|
|
1401
1478
|
// Registered BEFORE the turn starts. A permission can be raised on the
|
|
@@ -1404,6 +1481,8 @@ class Harness {
|
|
|
1404
1481
|
// running" — which is indistinguishable, in the log, from a real
|
|
1405
1482
|
// mis-routing.
|
|
1406
1483
|
this.runners.set(sessionId, runner);
|
|
1484
|
+
this.activeLeaseIds.set(sessionId, lease.leaseId);
|
|
1485
|
+
this.flushDeferredSteers(sessionId, lease.leaseId);
|
|
1407
1486
|
try {
|
|
1408
1487
|
const result = await runner.run(lease, controller.signal);
|
|
1409
1488
|
log('Turn finished', {
|
|
@@ -1446,6 +1525,14 @@ class Harness {
|
|
|
1446
1525
|
// that arrives during the commit must defer, never delete under Git.
|
|
1447
1526
|
this.active.delete(sessionId);
|
|
1448
1527
|
this.runners.delete(sessionId);
|
|
1528
|
+
this.activeLeaseIds.delete(sessionId);
|
|
1529
|
+
// The control plane expires lease-bound commands at turn end. Keep IDs
|
|
1530
|
+
// only while their lease can redeliver them; a warm runner serves many turns.
|
|
1531
|
+
this.clearAppliedCommandsForSession(sessionId);
|
|
1532
|
+
for (const [commandId, command] of this.deferredSteers) {
|
|
1533
|
+
if (command.sessionId === sessionId)
|
|
1534
|
+
this.deferredSteers.delete(commandId);
|
|
1535
|
+
}
|
|
1449
1536
|
// Revoke ONLY this session's tokens. A process-wide revoke here blanked
|
|
1450
1537
|
// the credential of every concurrent turn still running.
|
|
1451
1538
|
this.tokens.revokeTargets(mcpTargetUrls);
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
import type { CodingAgentAdapter } from '../adapters';
|
|
22
22
|
import { SessionModelGateway } from '../adapters/model-gateway';
|
|
23
23
|
import { ArtifactInspector, ConversationStore, EventSink, ModelMessageRecord, Notifier, SecretProvider, Telemetry, TranscriptStore, WorkspaceProvider } from '../ports';
|
|
24
|
-
import { LeaseResponse, ModelMessageInput, TranscriptPartInput, TurnMetadata } from '../types';
|
|
24
|
+
import { LeaseResponse, LeasedTurnInput, ModelMessageInput, TranscriptPartInput, TurnMetadata } from '../types';
|
|
25
25
|
import { KnowledgeToolHost, type KnowledgeControl } from './knowledge-tools';
|
|
26
26
|
import { McpAuthProxy } from './mcp-auth-proxy';
|
|
27
27
|
export interface SessionRunnerDeps {
|
|
@@ -260,6 +260,25 @@ export declare class SessionRunner {
|
|
|
260
260
|
* three are what `answerPendingApproval` needs and cannot otherwise reach.
|
|
261
261
|
*/
|
|
262
262
|
private turnContext;
|
|
263
|
+
private readonly pendingSteers;
|
|
264
|
+
private readonly deferredSteers;
|
|
265
|
+
private steerDispatchChain;
|
|
266
|
+
private nextSteerOrder;
|
|
267
|
+
private steeredPrompts;
|
|
268
|
+
private readonly steerEchoes;
|
|
269
|
+
private steerTurnGeneration;
|
|
270
|
+
private steerAcknowledgementsClosed;
|
|
271
|
+
private waitForSteerAcknowledgements;
|
|
272
|
+
/** A command can arrive on heartbeat or renewal while run() is in drain(). */
|
|
273
|
+
steerMessage(command: {
|
|
274
|
+
sessionId: string;
|
|
275
|
+
turnId: string;
|
|
276
|
+
input: LeasedTurnInput;
|
|
277
|
+
acknowledge: (consumed: boolean) => Promise<'steered' | 'queued'>;
|
|
278
|
+
}): void;
|
|
279
|
+
private rejectLateSteer;
|
|
280
|
+
private forgetSteerEcho;
|
|
281
|
+
private consumesSteerEcho;
|
|
263
282
|
/** The approval currently shown to the user, if any. */
|
|
264
283
|
private openApproval;
|
|
265
284
|
/** The transcript identity of an ordinary agent question, if one is open. */
|
|
@@ -325,6 +344,7 @@ export declare class SessionRunner {
|
|
|
325
344
|
* from the echoed text must not slip past the prefix check on that alone.
|
|
326
345
|
*/
|
|
327
346
|
private isEchoOfTurnPrompt;
|
|
347
|
+
private isExactSteerEcho;
|
|
328
348
|
/**
|
|
329
349
|
* Poll the adapter until the turn goes quiet or the signal aborts.
|
|
330
350
|
*
|
|
@@ -37,6 +37,8 @@ const knowledge_tools_1 = require("./knowledge-tools");
|
|
|
37
37
|
const permission_mode_1 = require("./permission-mode");
|
|
38
38
|
const projection_1 = require("./projection");
|
|
39
39
|
const space_files_1 = require("./space-files");
|
|
40
|
+
/** A missing Pi consumption event must not hold the lease open for its full RPC timeout. */
|
|
41
|
+
const FINAL_STEER_ACK_WAIT_MS = 5_000;
|
|
40
42
|
/** How often the runner asks the adapter for new output when nothing pushes. */
|
|
41
43
|
const POLL_INTERVAL_MS = 2_000;
|
|
42
44
|
/**
|
|
@@ -382,6 +384,150 @@ class SessionRunner {
|
|
|
382
384
|
* three are what `answerPendingApproval` needs and cannot otherwise reach.
|
|
383
385
|
*/
|
|
384
386
|
turnContext = null;
|
|
387
|
+
pendingSteers = new Set();
|
|
388
|
+
deferredSteers = [];
|
|
389
|
+
steerDispatchChain = Promise.resolve();
|
|
390
|
+
nextSteerOrder = 0;
|
|
391
|
+
steeredPrompts = [];
|
|
392
|
+
steerEchoes = new Map();
|
|
393
|
+
steerTurnGeneration = 0;
|
|
394
|
+
steerAcknowledgementsClosed = false;
|
|
395
|
+
async waitForSteerAcknowledgements() {
|
|
396
|
+
const pending = [...this.pendingSteers];
|
|
397
|
+
if (pending.length === 0) {
|
|
398
|
+
this.steerAcknowledgementsClosed = true;
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
let timeout;
|
|
402
|
+
try {
|
|
403
|
+
const settled = await Promise.race([
|
|
404
|
+
Promise.allSettled(pending).then(() => true),
|
|
405
|
+
new Promise((resolve) => {
|
|
406
|
+
timeout = setTimeout(() => resolve(false), FINAL_STEER_ACK_WAIT_MS);
|
|
407
|
+
})
|
|
408
|
+
]);
|
|
409
|
+
if (!settled) {
|
|
410
|
+
// Final ingest releases the lease and requeues every steer that has
|
|
411
|
+
// not been acknowledged. A late acknowledgement cannot claim it.
|
|
412
|
+
this.logger.warn(`[SessionRunner] steer-final-wait-timeout session=${this.turnContext?.sessionId ?? 'unknown'} pending=${pending.length}`);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
finally {
|
|
416
|
+
this.steerAcknowledgementsClosed = true;
|
|
417
|
+
if (timeout)
|
|
418
|
+
clearTimeout(timeout);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
/** A command can arrive on heartbeat or renewal while run() is in drain(). */
|
|
422
|
+
steerMessage(command) {
|
|
423
|
+
const context = this.turnContext;
|
|
424
|
+
if (!context) {
|
|
425
|
+
if (this.steerAcknowledgementsClosed) {
|
|
426
|
+
this.rejectLateSteer(command);
|
|
427
|
+
}
|
|
428
|
+
else {
|
|
429
|
+
this.deferredSteers.push(command);
|
|
430
|
+
}
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
if (context.sessionId !== command.sessionId ||
|
|
434
|
+
!context.adapter.steerUserMessage) {
|
|
435
|
+
this.rejectLateSteer(command);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
if (this.steerAcknowledgementsClosed) {
|
|
439
|
+
this.rejectLateSteer(command);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
const generation = this.steerTurnGeneration;
|
|
443
|
+
const order = this.nextSteerOrder++;
|
|
444
|
+
// Preparation (not Pi consumption) is serial. Fast sends enter Pi's queue
|
|
445
|
+
// in transcript order, so steeringMode=all can use all at one boundary.
|
|
446
|
+
const prepared = this.steerDispatchChain.then(async () => {
|
|
447
|
+
let prompt = command.input.content;
|
|
448
|
+
if (context.workspaceDir &&
|
|
449
|
+
(command.input.attachments?.length ||
|
|
450
|
+
command.input.droppedAttachments?.length)) {
|
|
451
|
+
const sync = await (0, attachments_1.syncAttachmentsToWorkspace)(command.input.attachments, context.workspaceDir, {
|
|
452
|
+
fetchImpl: this.deps.fetchAttachment,
|
|
453
|
+
logger: this.logger,
|
|
454
|
+
turnId: command.turnId
|
|
455
|
+
});
|
|
456
|
+
for (const dropped of command.input.droppedAttachments ?? []) {
|
|
457
|
+
sync.failed.push({
|
|
458
|
+
name: (0, attachments_1.sanitizeAttachmentName)(dropped.name),
|
|
459
|
+
reason: dropped.reason
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
const note = (0, attachments_1.attachmentPromptNote)(sync);
|
|
463
|
+
if (note)
|
|
464
|
+
prompt = `${prompt}\n\n${note}`;
|
|
465
|
+
}
|
|
466
|
+
const seen = this.turnContext === context
|
|
467
|
+
? context.adapter.steerUserMessage(context.backendSessionId, prompt, context.config)
|
|
468
|
+
: Promise.resolve(false);
|
|
469
|
+
// Pi can emit the user echo before the consumption acknowledgement
|
|
470
|
+
// returns. Filter that echo as soon as the prompt enters its queue.
|
|
471
|
+
const echoes = this.steerEchoes.get(prompt) ?? [];
|
|
472
|
+
echoes.push(command.turnId);
|
|
473
|
+
this.steerEchoes.set(prompt, echoes);
|
|
474
|
+
return { prompt, seen };
|
|
475
|
+
});
|
|
476
|
+
this.steerDispatchChain = prepared.then(() => undefined, () => undefined);
|
|
477
|
+
const task = prepared
|
|
478
|
+
.then(async ({ prompt, seen: consumed }) => {
|
|
479
|
+
const seen = await consumed;
|
|
480
|
+
if (!seen ||
|
|
481
|
+
this.steerAcknowledgementsClosed ||
|
|
482
|
+
generation !== this.steerTurnGeneration) {
|
|
483
|
+
if (!seen)
|
|
484
|
+
this.logger.warn(`[SessionRunner] steer unconfirmed; queued for next turn (session=${command.sessionId}, turn=${command.turnId})`);
|
|
485
|
+
this.forgetSteerEcho(prompt, command.turnId);
|
|
486
|
+
// Pi rejected the steer, or its queue did not consume it. Reset the
|
|
487
|
+
// durable message now; release remains the fallback if this fails.
|
|
488
|
+
await command.acknowledge(false);
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
// Record Pi's consumption before the network ack. If the ack hangs,
|
|
492
|
+
// final ingest must still log what Pi saw in this turn. A queued
|
|
493
|
+
// fallback can replay that text under the at-least-once crash rule.
|
|
494
|
+
this.steeredPrompts.push({ order, text: prompt });
|
|
495
|
+
await command.acknowledge(true);
|
|
496
|
+
})
|
|
497
|
+
.catch((error) => {
|
|
498
|
+
this.logger.warn(`[SessionRunner] steer failed for ${command.sessionId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
499
|
+
});
|
|
500
|
+
this.pendingSteers.add(task);
|
|
501
|
+
void task.finally(() => this.pendingSteers.delete(task));
|
|
502
|
+
}
|
|
503
|
+
rejectLateSteer(command) {
|
|
504
|
+
void command.acknowledge(false).catch((error) => {
|
|
505
|
+
this.logger.warn(`[SessionRunner] late steer reset failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
forgetSteerEcho(text, turnId) {
|
|
509
|
+
const echoes = this.steerEchoes.get(text);
|
|
510
|
+
if (!echoes)
|
|
511
|
+
return;
|
|
512
|
+
const index = echoes.indexOf(turnId);
|
|
513
|
+
if (index >= 0)
|
|
514
|
+
echoes.splice(index, 1);
|
|
515
|
+
if (echoes.length === 0)
|
|
516
|
+
this.steerEchoes.delete(text);
|
|
517
|
+
}
|
|
518
|
+
consumesSteerEcho(part) {
|
|
519
|
+
for (const [text, echoes] of this.steerEchoes) {
|
|
520
|
+
if (!this.isExactSteerEcho(part, text))
|
|
521
|
+
continue;
|
|
522
|
+
// Each matching user part consumes one steer, even when several steers
|
|
523
|
+
// have exactly the same text.
|
|
524
|
+
echoes.shift();
|
|
525
|
+
if (echoes.length === 0)
|
|
526
|
+
this.steerEchoes.delete(text);
|
|
527
|
+
return true;
|
|
528
|
+
}
|
|
529
|
+
return false;
|
|
530
|
+
}
|
|
385
531
|
/** The approval currently shown to the user, if any. */
|
|
386
532
|
openApproval = null;
|
|
387
533
|
/** The transcript identity of an ordinary agent question, if one is open. */
|
|
@@ -439,10 +585,11 @@ class SessionRunner {
|
|
|
439
585
|
*/
|
|
440
586
|
async run(lease, signal) {
|
|
441
587
|
const { session } = lease;
|
|
442
|
-
const
|
|
588
|
+
const logColdStart = (line) => {
|
|
443
589
|
this.logger.log(line);
|
|
444
590
|
this.deps.coldStartLog?.(line);
|
|
445
|
-
}
|
|
591
|
+
};
|
|
592
|
+
const coldStart = new cold_start_steps_1.ColdStartSteps(session.sessionId, session.turnId, logColdStart);
|
|
446
593
|
const startedAt = new Date().toISOString();
|
|
447
594
|
const release = this.deps.notifier.keepAwake();
|
|
448
595
|
let workspaceDir = null;
|
|
@@ -469,6 +616,13 @@ class SessionRunner {
|
|
|
469
616
|
*/
|
|
470
617
|
let turnContent = session.input.content;
|
|
471
618
|
this.turnParts = [];
|
|
619
|
+
this.steerTurnGeneration++;
|
|
620
|
+
this.steerAcknowledgementsClosed = false;
|
|
621
|
+
this.pendingSteers.clear();
|
|
622
|
+
this.steeredPrompts = [];
|
|
623
|
+
this.steerDispatchChain = Promise.resolve();
|
|
624
|
+
this.nextSteerOrder = 0;
|
|
625
|
+
this.steerEchoes.clear();
|
|
472
626
|
this.modelTokenExpiresAt = lease.modelTokenExpiresAt ?? null;
|
|
473
627
|
this.modelKeyExpiryNoticeShown = false;
|
|
474
628
|
try {
|
|
@@ -476,13 +630,21 @@ class SessionRunner {
|
|
|
476
630
|
if (refusal)
|
|
477
631
|
throw new Error(refusal);
|
|
478
632
|
// The catalogue is advisory. Fetch it while the independent workspace
|
|
479
|
-
// is prepared
|
|
633
|
+
// is prepared. Pi can start with the selected lease model while this
|
|
634
|
+
// request fills the cache for a later turn. OpenCode still waits for
|
|
635
|
+
// the reported limits before it builds its provider config.
|
|
480
636
|
const endModels = coldStart.begin('model_catalogue');
|
|
637
|
+
let completedModels = null;
|
|
481
638
|
const gatewayModels = session.agent.model.gatewayUrl
|
|
482
639
|
? (0, model_gateway_1.fetchGatewayModels)({
|
|
483
640
|
baseUrl: session.agent.model.gatewayUrl,
|
|
484
641
|
apiKey: lease.modelToken
|
|
485
|
-
})
|
|
642
|
+
})
|
|
643
|
+
.then((models) => {
|
|
644
|
+
completedModels = models;
|
|
645
|
+
return models;
|
|
646
|
+
})
|
|
647
|
+
.finally(endModels)
|
|
486
648
|
: Promise.resolve([]);
|
|
487
649
|
if (!session.agent.model.gatewayUrl)
|
|
488
650
|
endModels();
|
|
@@ -610,7 +772,25 @@ class SessionRunner {
|
|
|
610
772
|
* stop a turn whose model is already known, so we fall back to exactly
|
|
611
773
|
* the previous behaviour.
|
|
612
774
|
*/
|
|
613
|
-
const
|
|
775
|
+
const useSelectedPiModel = session.agent.backend === 'pi' && !!session.agent.model.model;
|
|
776
|
+
// A Pi turn takes whichever catalogue result has arrived by now.
|
|
777
|
+
// This is deliberate: the selected model below is enough to start,
|
|
778
|
+
// while a later result fills the cache for the next turn.
|
|
779
|
+
if (useSelectedPiModel && completedModels === null) {
|
|
780
|
+
try {
|
|
781
|
+
logColdStart(JSON.stringify({
|
|
782
|
+
event: 'model_catalogue_deferred',
|
|
783
|
+
sessionId: session.sessionId,
|
|
784
|
+
turnId: session.turnId
|
|
785
|
+
}));
|
|
786
|
+
}
|
|
787
|
+
catch {
|
|
788
|
+
// A diagnostic sink must never stop the turn.
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
const fetched = useSelectedPiModel
|
|
792
|
+
? (completedModels ?? [])
|
|
793
|
+
: await gatewayModels;
|
|
614
794
|
const named = session.agent.model.model
|
|
615
795
|
? [
|
|
616
796
|
{
|
|
@@ -847,8 +1027,12 @@ class SessionRunner {
|
|
|
847
1027
|
sessionId: session.sessionId,
|
|
848
1028
|
backendSessionId,
|
|
849
1029
|
adapter,
|
|
850
|
-
config
|
|
1030
|
+
config,
|
|
1031
|
+
workspaceDir
|
|
851
1032
|
};
|
|
1033
|
+
for (const command of this.deferredSteers.splice(0)) {
|
|
1034
|
+
this.steerMessage(command);
|
|
1035
|
+
}
|
|
852
1036
|
const drained = await this.drain(adapter, backendSessionId, config, session, signal,
|
|
853
1037
|
// RESUMING REPLAYS THE WHOLE BACKEND SESSION. `pollMessages` is asked
|
|
854
1038
|
// for "everything not in these sets", and on a resumed session that is
|
|
@@ -946,6 +1130,7 @@ class SessionRunner {
|
|
|
946
1130
|
* turn happened to reset it.
|
|
947
1131
|
*/
|
|
948
1132
|
this.turnContext = null;
|
|
1133
|
+
this.deferredSteers.length = 0;
|
|
949
1134
|
this.closeApproval(session.sessionId);
|
|
950
1135
|
this.deps.knowledge?.host.bindTurn(session.sessionId, null);
|
|
951
1136
|
this.closeQuestion(session.sessionId);
|
|
@@ -1040,9 +1225,18 @@ class SessionRunner {
|
|
|
1040
1225
|
* but a conversation that silently doubles in size every turn, which is the
|
|
1041
1226
|
* kind of defect that looks fine for a week.
|
|
1042
1227
|
*/
|
|
1228
|
+
// Give Pi a short time to confirm consumption. Final ingest requeues any
|
|
1229
|
+
// unconfirmed steer; an absent event must not keep the lease alive.
|
|
1230
|
+
await this.waitForSteerAcknowledgements();
|
|
1043
1231
|
const spoken = (0, conversation_1.assistantTextFrom)(this.turnParts.filter((part) => !priorPartIdsForLog.has(part.partId)));
|
|
1044
1232
|
const modelMessages = [
|
|
1045
1233
|
{ role: 'user', content: turnContent },
|
|
1234
|
+
// This store keeps one assistant message per turn. Steers retain their
|
|
1235
|
+
// order, but their exact interleaving with streamed assistant text is
|
|
1236
|
+
// not represented in this compact replay log.
|
|
1237
|
+
...this.steeredPrompts
|
|
1238
|
+
.sort((a, b) => a.order - b.order)
|
|
1239
|
+
.map(({ text }) => ({ role: 'user', content: text })),
|
|
1046
1240
|
...(spoken ? [{ role: 'assistant', content: spoken }] : [])
|
|
1047
1241
|
];
|
|
1048
1242
|
// Store locally FIRST, for the same reason parts are: the local copy is
|
|
@@ -1118,6 +1312,12 @@ class SessionRunner {
|
|
|
1118
1312
|
return false;
|
|
1119
1313
|
return sentPromptText.trim().startsWith(echoed);
|
|
1120
1314
|
}
|
|
1315
|
+
isExactSteerEcho(part, steeredPrompt) {
|
|
1316
|
+
if (typeof part.role !== 'string' || part.role.toLowerCase() !== 'user')
|
|
1317
|
+
return false;
|
|
1318
|
+
const text = part.content ?? part.text;
|
|
1319
|
+
return typeof text === 'string' && text.trim() === steeredPrompt.trim();
|
|
1320
|
+
}
|
|
1121
1321
|
/**
|
|
1122
1322
|
* Poll the adapter until the turn goes quiet or the signal aborts.
|
|
1123
1323
|
*
|
|
@@ -1269,7 +1469,11 @@ class SessionRunner {
|
|
|
1269
1469
|
* are recorded by the answer path itself, with the `answer-…` part id.
|
|
1270
1470
|
*/
|
|
1271
1471
|
const freshParts = sentPromptText && sentPromptText.length > 0
|
|
1272
|
-
? parts.filter((part) =>
|
|
1472
|
+
? parts.filter((part) => {
|
|
1473
|
+
if (this.isEchoOfTurnPrompt(part, sentPromptText))
|
|
1474
|
+
return false;
|
|
1475
|
+
return !this.consumesSteerEcho(part);
|
|
1476
|
+
})
|
|
1273
1477
|
: parts;
|
|
1274
1478
|
/*
|
|
1275
1479
|
* WIRE 2: A PENDING PERMISSION BECOMES A TRANSCRIPT PART.
|
package/dist/types.d.ts
CHANGED
|
@@ -333,7 +333,7 @@ export interface SessionCommand {
|
|
|
333
333
|
* 'abort' stops the turn; 'answer' resolves a pending question part;
|
|
334
334
|
* 'release-workspace' removes a checkout after the session ends.
|
|
335
335
|
*/
|
|
336
|
-
command: 'abort' | 'answer' | 'release-workspace';
|
|
336
|
+
command: 'abort' | 'answer' | 'release-workspace' | 'steer';
|
|
337
337
|
/** Set when command is 'answer' — the partId of the question being answered. */
|
|
338
338
|
partId?: string;
|
|
339
339
|
/** Set when command is 'answer' — the user's reply. */
|
|
@@ -350,6 +350,12 @@ export interface SessionCommand {
|
|
|
350
350
|
* questions, and ids are none of those things.
|
|
351
351
|
*/
|
|
352
352
|
optionId?: string;
|
|
353
|
+
/** A steer targets only this live lease. */
|
|
354
|
+
leaseId?: string;
|
|
355
|
+
/** The durable queued turn that becomes the fallback if steering fails. */
|
|
356
|
+
turnId?: string;
|
|
357
|
+
/** The message, with signed attachment URLs, from the control plane. */
|
|
358
|
+
input?: LeasedTurnInput;
|
|
353
359
|
/** De-duplication key. The same commandId must be applied at most once. */
|
|
354
360
|
commandId: string;
|
|
355
361
|
}
|
|
@@ -788,6 +794,8 @@ export interface IngestResponse {
|
|
|
788
794
|
* "the lease expired while it was working" bug.
|
|
789
795
|
*/
|
|
790
796
|
leaseExpiresAt: string;
|
|
797
|
+
/** Commands collected at this model/tool boundary. Older APIs omit it. */
|
|
798
|
+
sessionCommands?: SessionCommand[];
|
|
791
799
|
}
|
|
792
800
|
export interface IngestRejection {
|
|
793
801
|
partId: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@peakflo/agent-harness",
|
|
3
|
-
"version": "0.2.5-rc.
|
|
3
|
+
"version": "0.2.5-rc.3",
|
|
4
4
|
"description": "Headless Peakflo coding-agent harness. One codebase, three hosting modes: a Daytona cloud sandbox, a customer VM, and the 20x desktop.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "commonjs",
|