@peakflo/agent-harness 0.2.4 → 0.2.5-rc.2

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/harness.js CHANGED
@@ -21,6 +21,7 @@ const sqlite_store_1 = require("./store/sqlite-store");
21
21
  const version_1 = require("./version");
22
22
  /** Backoff after a lease poll fails, so an unreachable control plane is not hammered. */
23
23
  const LEASE_ERROR_BACKOFF_MS = 5_000;
24
+ const LEASE_MIN_EMPTY_POLL_MS = 250;
24
25
  /**
25
26
  * How often the model catalogue is re-probed once the harness is running.
26
27
  *
@@ -150,10 +151,15 @@ class Harness {
150
151
  releaseGitHubGuard = null;
151
152
  /** sessionId -> the controller that aborts its turn. */
152
153
  active = new Map();
154
+ activeLeaseIds = new Map();
153
155
  /** Renewal workers that must receive a credential rotation in flight. */
154
156
  activeRenewals = new Set();
155
157
  /** commandIds already applied. `SessionCommand` is idempotent by contract. */
156
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();
157
163
  /** Session-end cleanup waits for an active turn to save its dirty work. */
158
164
  pendingWorkspaceReleases = new Set();
159
165
  /**
@@ -338,6 +344,7 @@ class Harness {
338
344
  }
339
345
  this.stopping = true;
340
346
  this.status = 'draining';
347
+ this.deferredSteers.clear();
341
348
  if (this.heartbeatTimer) {
342
349
  clearTimeout(this.heartbeatTimer);
343
350
  this.heartbeatTimer = null;
@@ -506,9 +513,7 @@ class Harness {
506
513
  }
507
514
  this.observeHarnessPin(response.harnessVersion);
508
515
  this.observeSandboxToolsPin(response.sandboxToolsVersion);
509
- for (const command of response.sessionCommands ?? []) {
510
- this.applyCommand(command);
511
- }
516
+ this.applyCommandsSafely(response.sessionCommands ?? []);
512
517
  log('Reused stored runner credential', {
513
518
  runnerId: stored.runnerId,
514
519
  tenantId: stored.tenantId
@@ -782,9 +787,7 @@ class Harness {
782
787
  }
783
788
  this.observeHarnessPin(response.harnessVersion);
784
789
  this.observeSandboxToolsPin(response.sandboxToolsVersion);
785
- for (const command of response.sessionCommands ?? []) {
786
- this.applyCommand(command);
787
- }
790
+ this.applyCommandsSafely(response.sessionCommands ?? []);
788
791
  if (response.action === 'drain') {
789
792
  this.status = 'draining';
790
793
  log('Control plane asked the harness to drain');
@@ -822,21 +825,85 @@ class Harness {
822
825
  }
823
826
  this.scheduleHeartbeat();
824
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
+ }
825
860
  /**
826
- * Apply a command that rode back on a heartbeat.
861
+ * Apply a command that rode back on a heartbeat or ingest response.
827
862
  *
828
- * Idempotent by `commandId`, because the contract lets the control plane
829
- * repeat a command until it observes the effect — it has no other way to
830
- * 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.
831
865
  */
832
866
  applyCommand(command) {
833
867
  if (this.appliedCommands.has(command.commandId)) {
834
868
  return;
835
869
  }
836
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
+ }
837
896
  if (command.command === 'abort') {
838
897
  const controller = this.active.get(command.sessionId);
839
- if (controller) {
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) {
840
907
  log('Aborting session by control-plane command', {
841
908
  sessionId: command.sessionId
842
909
  });
@@ -854,7 +921,7 @@ class Harness {
854
921
  });
855
922
  }
856
923
  else {
857
- void this.releaseSessionWorkspace(command.sessionId);
924
+ void this.releaseSessionWorkspace(command.sessionId).finally(() => this.forgetAppliedCommand(command));
858
925
  }
859
926
  return;
860
927
  }
@@ -895,7 +962,7 @@ class Harness {
895
962
  * all that can be said here; what this buys is that a redelivery would
896
963
  * be attempted rather than discarded as a duplicate.
897
964
  */
898
- this.appliedCommands.delete(command.commandId);
965
+ this.forgetAppliedCommand(command);
899
966
  log('Answer arrived for a session this runner is no longer running', {
900
967
  sessionId: command.sessionId,
901
968
  commandId: command.commandId
@@ -963,6 +1030,16 @@ class Harness {
963
1030
  });
964
1031
  }
965
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
+ }
966
1043
  async releaseSessionWorkspace(sessionId) {
967
1044
  try {
968
1045
  const released = await this.workspace.release(sessionId, { force: true });
@@ -992,7 +1069,7 @@ class Harness {
992
1069
  * backend.
993
1070
  */
994
1071
  recoverFailedAnswer(runner, command, reason) {
995
- this.appliedCommands.delete(command.commandId);
1072
+ this.forgetAppliedCommand(command);
996
1073
  void runner.reportApprovalFailure(command.sessionId, reason).catch(() => {
997
1074
  // If even that cannot be delivered, the log at the call site is all
998
1075
  // there is. Never let reporting a failure become a second failure.
@@ -1031,6 +1108,7 @@ class Harness {
1031
1108
  continue;
1032
1109
  }
1033
1110
  let lease = null;
1111
+ const pollStartedAt = Date.now();
1034
1112
  try {
1035
1113
  lease = await this.client.lease(this.leaseWaitSeconds);
1036
1114
  }
@@ -1050,12 +1128,54 @@ class Harness {
1050
1128
  continue;
1051
1129
  }
1052
1130
  if (!lease) {
1053
- // 204: no work. Poll again immediately — the wait already happened
1054
- // server-side, which is the entire point of a bounded long-poll.
1131
+ // The server normally long-polls. A 204 returned immediately during
1132
+ // a fence or by a faulty proxy must not make this process spin.
1133
+ const waitLeft = LEASE_MIN_EMPTY_POLL_MS - (Date.now() - pollStartedAt);
1134
+ if (waitLeft > 0)
1135
+ await sleep(waitLeft);
1055
1136
  continue;
1056
1137
  }
1138
+ const leaseReceived = JSON.stringify({
1139
+ event: 'cold_start_step',
1140
+ sessionId: lease.session.sessionId,
1141
+ turnId: lease.session.turnId,
1142
+ step: 'lease_poll_to_pickup',
1143
+ startedAt: new Date(pollStartedAt).toISOString(),
1144
+ endedAt: new Date().toISOString(),
1145
+ durationMs: Date.now() - pollStartedAt
1146
+ });
1147
+ log(leaseReceived);
1148
+ void this.client.reportColdStart(leaseReceived).catch(() => undefined);
1057
1149
  this.observeHarnessPin(lease.harnessVersion);
1058
1150
  this.observeSandboxToolsPin(lease.sandboxToolsVersion);
1151
+ // Protocol 3 uses a short visibility window until the runner confirms
1152
+ // receipt. A dropped GET response is then reclaimed within nine seconds.
1153
+ // Do not start work unless the acknowledgement extends this lease.
1154
+ let leaseAcknowledged = false;
1155
+ for (let attempt = 0; attempt < 3; attempt += 1) {
1156
+ try {
1157
+ const acknowledged = await this.client.acknowledgeLease(lease.leaseId);
1158
+ if (!acknowledged.renewed) {
1159
+ log('Lease acknowledgement refused', { leaseId: lease.leaseId });
1160
+ break;
1161
+ }
1162
+ leaseAcknowledged = true;
1163
+ break;
1164
+ }
1165
+ catch (error) {
1166
+ log('Lease acknowledgement failed', {
1167
+ leaseId: lease.leaseId,
1168
+ attempt: attempt + 1,
1169
+ error: error instanceof Error ? error.message : String(error)
1170
+ });
1171
+ if (attempt < 2)
1172
+ await sleep(500);
1173
+ }
1174
+ }
1175
+ if (!leaseAcknowledged) {
1176
+ await sleep(LEASE_ERROR_BACKOFF_MS);
1177
+ continue;
1178
+ }
1059
1179
  void this.runLeasedTurn(lease);
1060
1180
  }
1061
1181
  await this.stop();
@@ -1186,15 +1306,14 @@ class Harness {
1186
1306
  */
1187
1307
  const renewalCallbacks = {
1188
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.
1189
1312
  intervalMs: this.deps.leaseRenewIntervalMs,
1190
1313
  // While a permission prompt is open, renew — and therefore collect
1191
1314
  // commands — every couple of seconds instead of every minute.
1192
1315
  urgentIntervalMs: this.deps.approvalRenewIntervalMs,
1193
- onCommands: (commands) => {
1194
- for (const command of commands) {
1195
- this.applyCommand(command);
1196
- }
1197
- },
1316
+ onCommands: (commands) => this.applyCommandsSafely(commands),
1198
1317
  githubTokenExpiresAt: lease.githubCredential?.expiresAt,
1199
1318
  onGithubCredential: (credential) => {
1200
1319
  githubWorkspace.setCredential?.(sessionId, credential);
@@ -1306,6 +1425,9 @@ class Harness {
1306
1425
  secrets: this.secrets,
1307
1426
  events: this.events,
1308
1427
  telemetry: this.telemetry,
1428
+ coldStartLog: (line) => {
1429
+ void this.client.reportColdStart(line).catch(() => undefined);
1430
+ },
1309
1431
  notifier: this.notifier,
1310
1432
  artifacts: this.artifacts,
1311
1433
  conversation: this.conversation,
@@ -1348,7 +1470,9 @@ class Harness {
1348
1470
  final,
1349
1471
  modelMessages
1350
1472
  })
1351
- .then(() => undefined)
1473
+ .then((response) => {
1474
+ this.applyCommandsSafely(response.sessionCommands ?? []);
1475
+ })
1352
1476
  });
1353
1477
  runnerRef = runner;
1354
1478
  // Registered BEFORE the turn starts. A permission can be raised on the
@@ -1357,6 +1481,8 @@ class Harness {
1357
1481
  // running" — which is indistinguishable, in the log, from a real
1358
1482
  // mis-routing.
1359
1483
  this.runners.set(sessionId, runner);
1484
+ this.activeLeaseIds.set(sessionId, lease.leaseId);
1485
+ this.flushDeferredSteers(sessionId, lease.leaseId);
1360
1486
  try {
1361
1487
  const result = await runner.run(lease, controller.signal);
1362
1488
  log('Turn finished', {
@@ -1399,6 +1525,14 @@ class Harness {
1399
1525
  // that arrives during the commit must defer, never delete under Git.
1400
1526
  this.active.delete(sessionId);
1401
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
+ }
1402
1536
  // Revoke ONLY this session's tokens. A process-wide revoke here blanked
1403
1537
  // the credential of every concurrent turn still running.
1404
1538
  this.tokens.revokeTargets(mcpTargetUrls);
@@ -0,0 +1,11 @@
1
+ /** Timing for one leased turn. Values in this log contain no credentials. */
2
+ export declare class ColdStartSteps {
3
+ private readonly sessionId;
4
+ private readonly turnId;
5
+ private readonly log;
6
+ private readonly startedAt;
7
+ private readonly steps;
8
+ constructor(sessionId: string, turnId: string, log: (line: string) => void);
9
+ begin(step: string): () => void;
10
+ summary(): void;
11
+ }
@@ -0,0 +1,57 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ColdStartSteps = void 0;
4
+ /** Timing for one leased turn. Values in this log contain no credentials. */
5
+ class ColdStartSteps {
6
+ sessionId;
7
+ turnId;
8
+ log;
9
+ startedAt = Date.now();
10
+ steps = {};
11
+ constructor(sessionId, turnId, log) {
12
+ this.sessionId = sessionId;
13
+ this.turnId = turnId;
14
+ this.log = log;
15
+ }
16
+ begin(step) {
17
+ const startedAt = Date.now();
18
+ let ended = false;
19
+ return () => {
20
+ if (ended)
21
+ return;
22
+ ended = true;
23
+ const endedAt = Date.now();
24
+ const durationMs = Math.max(0, endedAt - startedAt);
25
+ this.steps[step] = durationMs;
26
+ try {
27
+ this.log(JSON.stringify({
28
+ event: 'cold_start_step',
29
+ sessionId: this.sessionId,
30
+ turnId: this.turnId,
31
+ step,
32
+ startedAt: new Date(startedAt).toISOString(),
33
+ endedAt: new Date(endedAt).toISOString(),
34
+ durationMs
35
+ }));
36
+ }
37
+ catch {
38
+ // A diagnostic sink must never stop an agent turn.
39
+ }
40
+ };
41
+ }
42
+ summary() {
43
+ try {
44
+ this.log(JSON.stringify({
45
+ event: 'cold_start_summary',
46
+ sessionId: this.sessionId,
47
+ turnId: this.turnId,
48
+ durationMs: Math.max(0, Date.now() - this.startedAt),
49
+ steps: this.steps
50
+ }));
51
+ }
52
+ catch {
53
+ // A diagnostic sink must never stop an agent turn.
54
+ }
55
+ }
56
+ }
57
+ exports.ColdStartSteps = ColdStartSteps;
@@ -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 {
@@ -40,6 +40,8 @@ export interface SessionRunnerDeps {
40
40
  secrets: SecretProvider;
41
41
  events: EventSink;
42
42
  telemetry: Telemetry;
43
+ /** Send a structured timing event to the control plane without waiting. */
44
+ coldStartLog?: (line: string) => void;
43
45
  notifier: Notifier;
44
46
  artifacts: ArtifactInspector;
45
47
  /**
@@ -258,6 +260,25 @@ export declare class SessionRunner {
258
260
  * three are what `answerPendingApproval` needs and cannot otherwise reach.
259
261
  */
260
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;
261
282
  /** The approval currently shown to the user, if any. */
262
283
  private openApproval;
263
284
  /** The transcript identity of an ordinary agent question, if one is open. */
@@ -323,6 +344,7 @@ export declare class SessionRunner {
323
344
  * from the echoed text must not slip past the prefix check on that alone.
324
345
  */
325
346
  private isEchoOfTurnPrompt;
347
+ private isExactSteerEcho;
326
348
  /**
327
349
  * Poll the adapter until the turn goes quiet or the signal aborts.
328
350
  *