@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.
@@ -31,11 +31,14 @@ const node_crypto_1 = require("node:crypto");
31
31
  const model_gateway_1 = require("../adapters/model-gateway");
32
32
  const contract_1 = require("../contract");
33
33
  const attachments_1 = require("./attachments");
34
+ const cold_start_steps_1 = require("./cold-start-steps");
34
35
  const conversation_1 = require("./conversation");
35
36
  const knowledge_tools_1 = require("./knowledge-tools");
36
37
  const permission_mode_1 = require("./permission-mode");
37
38
  const projection_1 = require("./projection");
38
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;
39
42
  /** How often the runner asks the adapter for new output when nothing pushes. */
40
43
  const POLL_INTERVAL_MS = 2_000;
41
44
  /**
@@ -381,6 +384,150 @@ class SessionRunner {
381
384
  * three are what `answerPendingApproval` needs and cannot otherwise reach.
382
385
  */
383
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
+ }
384
531
  /** The approval currently shown to the user, if any. */
385
532
  openApproval = null;
386
533
  /** The transcript identity of an ordinary agent question, if one is open. */
@@ -438,6 +585,10 @@ class SessionRunner {
438
585
  */
439
586
  async run(lease, signal) {
440
587
  const { session } = lease;
588
+ const coldStart = new cold_start_steps_1.ColdStartSteps(session.sessionId, session.turnId, (line) => {
589
+ this.logger.log(line);
590
+ this.deps.coldStartLog?.(line);
591
+ });
441
592
  const startedAt = new Date().toISOString();
442
593
  const release = this.deps.notifier.keepAwake();
443
594
  let workspaceDir = null;
@@ -464,12 +615,31 @@ class SessionRunner {
464
615
  */
465
616
  let turnContent = session.input.content;
466
617
  this.turnParts = [];
618
+ this.steerTurnGeneration++;
619
+ this.steerAcknowledgementsClosed = false;
620
+ this.pendingSteers.clear();
621
+ this.steeredPrompts = [];
622
+ this.steerDispatchChain = Promise.resolve();
623
+ this.nextSteerOrder = 0;
624
+ this.steerEchoes.clear();
467
625
  this.modelTokenExpiresAt = lease.modelTokenExpiresAt ?? null;
468
626
  this.modelKeyExpiryNoticeShown = false;
469
627
  try {
470
628
  const refusal = modelGatewayRefusal(lease);
471
629
  if (refusal)
472
630
  throw new Error(refusal);
631
+ // The catalogue is advisory. Fetch it while the independent workspace
632
+ // is prepared, then install it before the backend process starts.
633
+ const endModels = coldStart.begin('model_catalogue');
634
+ const gatewayModels = session.agent.model.gatewayUrl
635
+ ? (0, model_gateway_1.fetchGatewayModels)({
636
+ baseUrl: session.agent.model.gatewayUrl,
637
+ apiKey: lease.modelToken
638
+ }).finally(endModels)
639
+ : Promise.resolve([]);
640
+ if (!session.agent.model.gatewayUrl)
641
+ endModels();
642
+ const endWorkspace = coldStart.begin('workspace_prepare');
473
643
  try {
474
644
  workspaceDir = await this.deps.workspace.prepare({
475
645
  sessionId: session.sessionId,
@@ -486,6 +656,7 @@ class SessionRunner {
486
656
  },
487
657
  refusalReason: session.workspace.refusalReason
488
658
  });
659
+ endWorkspace();
489
660
  const repositoryNames = session.workspace.requestedRepositories ??
490
661
  (session.workspace.repositories ?? []).map(({ fullName }) => fullName);
491
662
  for (const fullName of repositoryNames) {
@@ -495,6 +666,7 @@ class SessionRunner {
495
666
  }
496
667
  }
497
668
  catch (caught) {
669
+ endWorkspace();
498
670
  const reason = caught instanceof Error ? caught.message : String(caught);
499
671
  const status = session.workspace.refusalReason ? 'refused' : 'failed';
500
672
  const names = session.workspace.requestedRepositories ??
@@ -530,7 +702,13 @@ class SessionRunner {
530
702
  */
531
703
  const hasDroppedAttachments = (session.input.droppedAttachments?.length ?? 0) > 0;
532
704
  if (workspaceDir) {
533
- await this.syncSpaceFilesForTurn(session, workspaceDir);
705
+ const endSpaceFiles = coldStart.begin('space_files_sync');
706
+ try {
707
+ await this.syncSpaceFilesForTurn(session, workspaceDir);
708
+ }
709
+ finally {
710
+ endSpaceFiles();
711
+ }
534
712
  }
535
713
  if (workspaceDir && (hasLeasedAttachments || hasDroppedAttachments)) {
536
714
  const sync = await (0, attachments_1.syncAttachmentsToWorkspace)(session.input.attachments, workspaceDir, {
@@ -585,10 +763,7 @@ class SessionRunner {
585
763
  * stop a turn whose model is already known, so we fall back to exactly
586
764
  * the previous behaviour.
587
765
  */
588
- const fetched = await (0, model_gateway_1.fetchGatewayModels)({
589
- baseUrl: session.agent.model.gatewayUrl,
590
- apiKey: lease.modelToken
591
- });
766
+ const fetched = await gatewayModels;
592
767
  const named = session.agent.model.model
593
768
  ? [
594
769
  {
@@ -622,7 +797,13 @@ class SessionRunner {
622
797
  this.deps.modelGateway?.set(null);
623
798
  }
624
799
  adapter = this.deps.getAdapter(session.agent.backend);
625
- await adapter.initialize();
800
+ const endAdapter = coldStart.begin('adapter_initialize');
801
+ try {
802
+ await adapter.initialize();
803
+ }
804
+ finally {
805
+ endAdapter();
806
+ }
626
807
  secretToken = this.setUpSecrets(session);
627
808
  /*
628
809
  * THE KNOWLEDGE TOOLS FOR THIS TURN (P9). Bound to this lease and this
@@ -638,6 +819,7 @@ class SessionRunner {
638
819
  knowledgeUrl = await this.deps.knowledge.host.endpointFor(session.sessionId);
639
820
  }
640
821
  const config = this.buildSessionConfig(session, workspaceDir, secretToken, signal, knowledgeUrl);
822
+ config.coldStartLog = this.deps.coldStartLog;
641
823
  /*
642
824
  * REMEMBER THE CONVERSATION. This block is the fix for the defect that
643
825
  * made this a chat product that could not hold a conversation: the
@@ -749,7 +931,13 @@ class SessionRunner {
749
931
  }
750
932
  }
751
933
  if (!backendSessionId) {
752
- backendSessionId = await adapter.createSession(config);
934
+ const endBackend = coldStart.begin('backend_process_start');
935
+ try {
936
+ backendSessionId = await adapter.createSession(config);
937
+ }
938
+ finally {
939
+ endBackend();
940
+ }
753
941
  this.deps.telemetry.record('provider.session.started', {
754
942
  backend: session.agent.backend,
755
943
  runtime: session.runtime
@@ -789,10 +977,18 @@ class SessionRunner {
789
977
  : promptText;
790
978
  replayed = !resumed && memory.messages.length > 0;
791
979
  priorPartIdsForLog = priorPartIds;
792
- await adapter.sendPrompt(backendSessionId, [{ type: 'text', text: promptWithHostNote, role: 'user' }], config);
980
+ const endPrompt = coldStart.begin('model_prompt_send');
981
+ try {
982
+ await adapter.sendPrompt(backendSessionId, [{ type: 'text', text: promptWithHostNote, role: 'user' }], config);
983
+ }
984
+ finally {
985
+ endPrompt();
986
+ }
793
987
  this.deps.telemetry.record('provider.turn.sent', {
794
988
  backend: session.agent.backend
795
989
  });
990
+ const endFirstOutput = coldStart.begin('first_agent_output');
991
+ const endFirstIngest = coldStart.begin('first_transcript_ingest');
796
992
  /*
797
993
  * WIRE 3 NEEDS A WAY IN. `run` is one long await, and the user's answer
798
994
  * arrives on a control-plane command that the harness handles on a
@@ -804,8 +1000,12 @@ class SessionRunner {
804
1000
  sessionId: session.sessionId,
805
1001
  backendSessionId,
806
1002
  adapter,
807
- config
1003
+ config,
1004
+ workspaceDir
808
1005
  };
1006
+ for (const command of this.deferredSteers.splice(0)) {
1007
+ this.steerMessage(command);
1008
+ }
809
1009
  const drained = await this.drain(adapter, backendSessionId, config, session, signal,
810
1010
  // RESUMING REPLAYS THE WHOLE BACKEND SESSION. `pollMessages` is asked
811
1011
  // for "everything not in these sets", and on a resumed session that is
@@ -817,7 +1017,7 @@ class SessionRunner {
817
1017
  resumed ? priorPartIds : new Set(),
818
1018
  // THE PROMPT THIS RUNNER JUST SENT. Drain uses it to recognise the
819
1019
  // backend's own echo of it — see `drain`.
820
- promptWithHostNote);
1020
+ promptWithHostNote, () => endFirstOutput(), () => endFirstIngest());
821
1021
  partsProduced = drained.parts;
822
1022
  if (signal.aborted) {
823
1023
  stopReason = 'aborted';
@@ -903,6 +1103,7 @@ class SessionRunner {
903
1103
  * turn happened to reset it.
904
1104
  */
905
1105
  this.turnContext = null;
1106
+ this.deferredSteers.length = 0;
906
1107
  this.closeApproval(session.sessionId);
907
1108
  this.deps.knowledge?.host.bindTurn(session.sessionId, null);
908
1109
  this.closeQuestion(session.sessionId);
@@ -997,9 +1198,18 @@ class SessionRunner {
997
1198
  * but a conversation that silently doubles in size every turn, which is the
998
1199
  * kind of defect that looks fine for a week.
999
1200
  */
1201
+ // Give Pi a short time to confirm consumption. Final ingest requeues any
1202
+ // unconfirmed steer; an absent event must not keep the lease alive.
1203
+ await this.waitForSteerAcknowledgements();
1000
1204
  const spoken = (0, conversation_1.assistantTextFrom)(this.turnParts.filter((part) => !priorPartIdsForLog.has(part.partId)));
1001
1205
  const modelMessages = [
1002
1206
  { role: 'user', content: turnContent },
1207
+ // This store keeps one assistant message per turn. Steers retain their
1208
+ // order, but their exact interleaving with streamed assistant text is
1209
+ // not represented in this compact replay log.
1210
+ ...this.steeredPrompts
1211
+ .sort((a, b) => a.order - b.order)
1212
+ .map(({ text }) => ({ role: 'user', content: text })),
1003
1213
  ...(spoken ? [{ role: 'assistant', content: spoken }] : [])
1004
1214
  ];
1005
1215
  // Store locally FIRST, for the same reason parts are: the local copy is
@@ -1031,6 +1241,7 @@ class SessionRunner {
1031
1241
  * the drain. That has not been true since the error path was added.
1032
1242
  */
1033
1243
  await this.flush(session.sessionId, this.pending.splice(0), turn, true, modelMessages);
1244
+ coldStart.summary();
1034
1245
  this.deps.telemetry.record('provider.turn.memory', {
1035
1246
  resumed,
1036
1247
  replayed,
@@ -1074,6 +1285,12 @@ class SessionRunner {
1074
1285
  return false;
1075
1286
  return sentPromptText.trim().startsWith(echoed);
1076
1287
  }
1288
+ isExactSteerEcho(part, steeredPrompt) {
1289
+ if (typeof part.role !== 'string' || part.role.toLowerCase() !== 'user')
1290
+ return false;
1291
+ const text = part.content ?? part.text;
1292
+ return typeof text === 'string' && text.trim() === steeredPrompt.trim();
1293
+ }
1077
1294
  /**
1078
1295
  * Poll the adapter until the turn goes quiet or the signal aborts.
1079
1296
  *
@@ -1101,7 +1318,7 @@ class SessionRunner {
1101
1318
  */
1102
1319
  async drain(adapter, backendSessionId, config, session, signal, alreadySeenPartIds = new Set(),
1103
1320
  /** The exact text `sendPrompt` was given this turn, for echo filtering. */
1104
- sentPromptText = null) {
1321
+ sentPromptText = null, onFirstOutput, onFirstIngest) {
1105
1322
  let providerError;
1106
1323
  let timedOut = false;
1107
1324
  let idleTimedOut = false;
@@ -1110,6 +1327,7 @@ class SessionRunner {
1110
1327
  const seenPartIds = new Set(alreadySeenPartIds);
1111
1328
  const partContentLengths = new Map();
1112
1329
  let produced = 0;
1330
+ let firstOutputSeen = false;
1113
1331
  let idleSeen = 0;
1114
1332
  let wake = null;
1115
1333
  adapter.onDataAvailable = () => {
@@ -1224,7 +1442,11 @@ class SessionRunner {
1224
1442
  * are recorded by the answer path itself, with the `answer-…` part id.
1225
1443
  */
1226
1444
  const freshParts = sentPromptText && sentPromptText.length > 0
1227
- ? parts.filter((part) => !this.isEchoOfTurnPrompt(part, sentPromptText))
1445
+ ? parts.filter((part) => {
1446
+ if (this.isEchoOfTurnPrompt(part, sentPromptText))
1447
+ return false;
1448
+ return !this.consumesSteerEcho(part);
1449
+ })
1228
1450
  : parts;
1229
1451
  /*
1230
1452
  * WIRE 2: A PENDING PERMISSION BECOMES A TRANSCRIPT PART.
@@ -1236,8 +1458,14 @@ class SessionRunner {
1236
1458
  */
1237
1459
  produced += await this.emitPendingApproval(adapter, backendSessionId, session);
1238
1460
  if (freshParts.length > 0) {
1461
+ if (!firstOutputSeen) {
1462
+ firstOutputSeen = true;
1463
+ onFirstOutput?.();
1464
+ }
1239
1465
  produced += this.record(session.sessionId, freshParts);
1240
1466
  await this.flush(session.sessionId, this.pending.splice(0), undefined, false);
1467
+ if (firstOutputSeen)
1468
+ onFirstIngest?.();
1241
1469
  // Real output resets the quiet counter. A backend is idle BETWEEN
1242
1470
  // tool calls as well as at the end of a turn, so ending on the first
1243
1471
  // idle would truncate almost every transcript at the first tool.
@@ -2142,6 +2370,7 @@ class SessionRunner {
2142
2370
  // would have made them no longer "extracted unchanged", which is worth
2143
2371
  // more than the tidier name.
2144
2372
  taskId: session.sessionId,
2373
+ turnId: session.turnId,
2145
2374
  workspaceDir,
2146
2375
  devServerPolicy: session.agent.devServerPolicy,
2147
2376
  model: qualifiedModelId(session.agent.model),
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.4",
3
+ "version": "0.2.5-rc.2",
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",