@peakflo/agent-harness 0.2.10-rc.1 → 0.2.10-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.
@@ -21,6 +21,7 @@
21
21
  */
22
22
  Object.defineProperty(exports, "__esModule", { value: true });
23
23
  exports.SessionRunner = void 0;
24
+ exports.approvalTimeoutMs = approvalTimeoutMs;
24
25
  exports.qualifiedModelId = qualifiedModelId;
25
26
  exports.describeProviderError = describeProviderError;
26
27
  exports.withoutGatewayForVendorBackend = withoutGatewayForVendorBackend;
@@ -47,6 +48,13 @@ const task_files_1 = require("./task-files");
47
48
  const FINAL_STEER_ACK_WAIT_MS = 5_000;
48
49
  /** How often the runner asks the adapter for new output when nothing pushes. */
49
50
  const POLL_INTERVAL_MS = 2_000;
51
+ const DEFAULT_APPROVAL_TIMEOUT_MS = 30 * 60_000;
52
+ function approvalTimeoutMs(value) {
53
+ const minutes = Number(value);
54
+ return Number.isFinite(minutes) && minutes > 0
55
+ ? Math.min(minutes * 60_000, 24 * 60 * 60_000)
56
+ : DEFAULT_APPROVAL_TIMEOUT_MS;
57
+ }
50
58
  /**
51
59
  * How long an idle status must persist before the turn is considered over.
52
60
  *
@@ -576,6 +584,7 @@ class SessionRunner {
576
584
  openApproval = null;
577
585
  /** The transcript identity of an ordinary agent question, if one is open. */
578
586
  openQuestionPartId = null;
587
+ openQuestionStartedAt = null;
579
588
  /**
580
589
  * Expiry of the model token THIS RUNNING BACKEND is holding, when the
581
590
  * control plane reported one — see `LeaseResponse.modelTokenExpiresAt`.
@@ -1199,6 +1208,10 @@ class SessionRunner {
1199
1208
  }
1200
1209
  ]);
1201
1210
  }
1211
+ else if (drained.approvalTimedOut) {
1212
+ stopReason = 'approval_timeout';
1213
+ error = drained.approvalTimedOutMessage;
1214
+ }
1202
1215
  else if (drained.timedOut) {
1203
1216
  // THE LAST-LINE CEILING, and only reached when an operator has
1204
1217
  // actually configured `CODING_AGENT_MAX_TURN_MINUTES` — the default
@@ -1485,6 +1498,8 @@ class SessionRunner {
1485
1498
  let timedOut = false;
1486
1499
  let idleTimedOut = false;
1487
1500
  let idleTimedOutMessage;
1501
+ let approvalTimedOut = false;
1502
+ let approvalTimedOutMessage;
1488
1503
  const seenMessageIds = new Set();
1489
1504
  const seenPartIds = new Set(alreadySeenPartIds);
1490
1505
  const partContentLengths = new Map();
@@ -1529,6 +1544,61 @@ class SessionRunner {
1529
1544
  try {
1530
1545
  while (!signal.aborted) {
1531
1546
  const now = Date.now();
1547
+ const approvalLimitMs = approvalTimeoutMs(process.env.CODING_AGENT_APPROVAL_TIMEOUT_MINUTES);
1548
+ if (this.openApproval &&
1549
+ this.approvalWaitStartedAt !== null &&
1550
+ now - this.approvalWaitStartedAt >= approvalLimitMs) {
1551
+ const open = this.openApproval;
1552
+ const capable = adapter;
1553
+ // Close first so a late answer cannot approve the timed-out action.
1554
+ this.closeApproval(session.sessionId);
1555
+ let denialTimer;
1556
+ try {
1557
+ if (capable.respondToApproval) {
1558
+ await Promise.race([
1559
+ capable.respondToApproval(backendSessionId, false),
1560
+ new Promise((resolveTimeout) => {
1561
+ denialTimer = setTimeout(resolveTimeout, 5_000);
1562
+ })
1563
+ ]);
1564
+ }
1565
+ }
1566
+ catch (approvalError) {
1567
+ this.logger.warn(`[SessionRunner] approval timeout denial failed for ${session.sessionId}: ${String(approvalError)}`);
1568
+ }
1569
+ finally {
1570
+ if (denialTimer)
1571
+ clearTimeout(denialTimer);
1572
+ }
1573
+ approvalTimedOut = true;
1574
+ approvalTimedOutMessage = `Approval denied after ${formatMinutes(approvalLimitMs)} without an answer. Turn stopped.`;
1575
+ this.record(session.sessionId, [
1576
+ {
1577
+ id: `answer-${open.toolCallId}`,
1578
+ role: 'system',
1579
+ partType: 'text',
1580
+ content: `Denied: ${approvalTimedOutMessage}`
1581
+ }
1582
+ ]);
1583
+ break;
1584
+ }
1585
+ if (this.openQuestionPartId &&
1586
+ this.openQuestionStartedAt !== null &&
1587
+ now - this.openQuestionStartedAt >= approvalLimitMs) {
1588
+ const questionId = this.openQuestionPartId;
1589
+ this.closeQuestion(session.sessionId);
1590
+ approvalTimedOut = true;
1591
+ approvalTimedOutMessage = `Question closed after ${formatMinutes(approvalLimitMs)} without an answer. Turn stopped.`;
1592
+ this.record(session.sessionId, [
1593
+ {
1594
+ id: `answer-${questionId.replace(/^question-/, '')}`,
1595
+ role: 'system',
1596
+ partType: 'text',
1597
+ content: approvalTimedOutMessage
1598
+ }
1599
+ ]);
1600
+ break;
1601
+ }
1532
1602
  // Historical approval wait PLUS whatever approval is open right now.
1533
1603
  // Added to both the hard deadline and, below, the idle window — a
1534
1604
  // turn stopped to ask a person something is blocked on them, not
@@ -1644,7 +1714,8 @@ class SessionRunner {
1644
1714
  // Last one wins: a retry that also fails should report its own reason.
1645
1715
  providerError = status.message;
1646
1716
  }
1647
- const quiet = status.type === 'idle' || status.type === 'error';
1717
+ const quiet = (status.type === 'idle' || status.type === 'error') &&
1718
+ !this.isAwaitingApproval();
1648
1719
  idleSeen = quiet ? idleSeen + 1 : 0;
1649
1720
  if (idleSeen >= IDLE_CONFIRMATIONS) {
1650
1721
  break;
@@ -1667,20 +1738,16 @@ class SessionRunner {
1667
1738
  * and the turn cut as `no_progress` in the same tick, before the
1668
1739
  * person it was raised for ever sees it.
1669
1740
  *
1670
- * TODO(approval TTL): an approval left open suppresses BOTH this
1671
- * guard and the hard ceiling for as long as it stays open — by
1672
- * design, since a slow human must never read as a hung backend, but
1673
- * that means an approval nobody ever answers can hold a turn open
1674
- * indefinitely today. Pre-existing behaviour (unrelated to the LONG
1675
- * TURNS fix), not changed here; a bounded approval wait — after
1676
- * which the turn ends on its own rather than waiting forever — is a
1677
- * real follow-up, not implemented in this PR.
1741
+ * Approval time is excluded from the idle and hard limits. The
1742
+ * separate approval limit above ends an unanswered request.
1678
1743
  */
1679
1744
  const approvalWaitSinceProgress = this.approvalWaitMs -
1680
1745
  approvalWaitAtLastProgress +
1681
1746
  this.liveApprovalWaitMs();
1682
1747
  const idleElapsed = Date.now() - lastProgressAt - approvalWaitSinceProgress;
1683
- if (this.approvalWaitStartedAt === null && idleElapsed > idleLimitMs) {
1748
+ if (this.approvalWaitStartedAt === null &&
1749
+ this.openQuestionPartId === null &&
1750
+ idleElapsed > idleLimitMs) {
1684
1751
  this.logger.warn(`[SessionRunner] session ${session.sessionId} had no progress for ` +
1685
1752
  `${Math.round(idleElapsed / 1000)}s (limit ${Math.round(idleLimitMs / 1000)}s)`);
1686
1753
  idleTimedOut = true;
@@ -1720,7 +1787,7 @@ class SessionRunner {
1720
1787
  await this.flush(session.sessionId, this.pending.splice(0), undefined, false);
1721
1788
  }
1722
1789
  }
1723
- if (signal.aborted || timedOut || idleTimedOut) {
1790
+ if (signal.aborted || timedOut || idleTimedOut || approvalTimedOut) {
1724
1791
  try {
1725
1792
  await adapter.abortPrompt(backendSessionId, config);
1726
1793
  }
@@ -1733,7 +1800,9 @@ class SessionRunner {
1733
1800
  providerError,
1734
1801
  timedOut,
1735
1802
  idleTimedOut,
1736
- idleTimedOutMessage
1803
+ idleTimedOutMessage,
1804
+ approvalTimedOut,
1805
+ approvalTimedOutMessage
1737
1806
  };
1738
1807
  }
1739
1808
  /**
@@ -1783,9 +1852,8 @@ class SessionRunner {
1783
1852
  /* ---------------------------------------------------------------- */
1784
1853
  /** Wall-clock spent in the approval we are in RIGHT NOW, if any. */
1785
1854
  liveApprovalWaitMs() {
1786
- return this.approvalWaitStartedAt === null
1787
- ? 0
1788
- : Date.now() - this.approvalWaitStartedAt;
1855
+ const startedAt = this.approvalWaitStartedAt ?? this.openQuestionStartedAt;
1856
+ return startedAt === null ? 0 : Date.now() - startedAt;
1789
1857
  }
1790
1858
  /**
1791
1859
  * WIRE 2. Turn a pending backend permission into a `question` transcript part.
@@ -1978,7 +2046,11 @@ class SessionRunner {
1978
2046
  }
1979
2047
  closeQuestion(sessionId) {
1980
2048
  if (this.openQuestionPartId) {
2049
+ if (this.openQuestionStartedAt !== null) {
2050
+ this.approvalWaitMs += Date.now() - this.openQuestionStartedAt;
2051
+ }
1981
2052
  this.openQuestionPartId = null;
2053
+ this.openQuestionStartedAt = null;
1982
2054
  this.deps.onApprovalStateChange?.(sessionId, false);
1983
2055
  }
1984
2056
  }
@@ -2158,6 +2230,7 @@ class SessionRunner {
2158
2230
  part.id !== this.openApproval?.partId &&
2159
2231
  this.openQuestionPartId !== part.id) {
2160
2232
  this.openQuestionPartId = part.id;
2233
+ this.openQuestionStartedAt = Date.now();
2161
2234
  this.deps.onApprovalStateChange?.(sessionId, true);
2162
2235
  }
2163
2236
  }
@@ -0,0 +1,57 @@
1
+ type Platform = NodeJS.Platform;
2
+ type Run = (file: string, args: string[]) => string;
3
+ interface ServiceConfig {
4
+ startArgs: string[];
5
+ nodePath: string;
6
+ cliPath: string;
7
+ path: string;
8
+ }
9
+ export interface ServiceContext {
10
+ nodePath: string;
11
+ cliPath: string;
12
+ env: NodeJS.ProcessEnv;
13
+ home?: string;
14
+ platform?: Platform;
15
+ uid?: number;
16
+ run?: Run;
17
+ output?: (message: string) => void;
18
+ sleep?: (ms: number) => void;
19
+ portOccupied?: (port: number) => Promise<boolean>;
20
+ }
21
+ export declare function permanentServiceExitCode(platform: Platform, linuxCode: 2 | 78): number;
22
+ /** Only failures that a restart cannot repair belong in this list. */
23
+ export declare function permanentStartupFailure(error: unknown): string | null;
24
+ /** Existing service files use --stored-credential; new ones also use --service. */
25
+ export declare function isServiceStart(argv: string[]): boolean;
26
+ /** Parse only documented start options. A token can never enter service state. */
27
+ export declare function serviceStartArgs(input: string[], env: NodeJS.ProcessEnv, home: string): string[];
28
+ export declare function renderLaunchAgent(config: ServiceConfig, logDir: string): string;
29
+ export declare function renderSystemdUnit(config: ServiceConfig): string;
30
+ export declare function recordServiceFailure(reason: string, home?: string): void;
31
+ export declare function clearServiceFailure(home?: string): void;
32
+ export declare class ServiceManager {
33
+ private readonly context;
34
+ private readonly home;
35
+ private readonly platform;
36
+ private readonly run;
37
+ private readonly output;
38
+ private readonly stateFile;
39
+ private readonly serviceFile;
40
+ private readonly logDir;
41
+ constructor(context: ServiceContext);
42
+ private supported;
43
+ private readConfig;
44
+ private checkCredential;
45
+ private writeAndStart;
46
+ install(input: string[]): Promise<void>;
47
+ private checkNodeVersion;
48
+ repair(): void;
49
+ assertNoDuplicateRunner(options: {
50
+ workspaceRoot: string;
51
+ healthPort: number;
52
+ }): void;
53
+ status(): void;
54
+ uninstall(): void;
55
+ }
56
+ export declare function runServiceCommand(args: string[], context: ServiceContext): Promise<void>;
57
+ export {};