@peakflo/agent-harness 0.2.10-rc.1 → 0.2.10-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/README.md +109 -13
- package/dist/adapters/acp-adapter.js +26 -38
- package/dist/adapters/claude-code-adapter.d.ts +4 -14
- package/dist/adapters/claude-code-adapter.js +86 -22
- package/dist/adapters/codex-adapter.js +17 -6
- package/dist/adapters/codex-app-server-adapter.js +9 -9
- package/dist/adapters/cursor-acp.d.ts +2 -3
- package/dist/adapters/cursor-acp.js +2 -3
- package/dist/adapters/pi-adapter.js +3 -0
- package/dist/adapters/vendor-environment.d.ts +12 -0
- package/dist/adapters/vendor-environment.js +186 -0
- package/dist/cli.js +55 -1
- package/dist/github-command-guards.js +5 -0
- package/dist/github-workspace.js +30 -3
- package/dist/harness.d.ts +4 -0
- package/dist/harness.js +32 -7
- package/dist/options.js +18 -2
- package/dist/runtime/permission-mode.d.ts +2 -3
- package/dist/runtime/permission-mode.js +2 -5
- package/dist/runtime/session-runner.d.ts +2 -0
- package/dist/runtime/session-runner.js +124 -15
- package/dist/service.d.ts +57 -0
- package/dist/service.js +535 -0
- package/dist/types.d.ts +4 -0
- package/package.json +1 -1
|
@@ -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;
|
|
@@ -43,10 +44,36 @@ const permission_mode_1 = require("./permission-mode");
|
|
|
43
44
|
const projection_1 = require("./projection");
|
|
44
45
|
const space_files_1 = require("./space-files");
|
|
45
46
|
const task_files_1 = require("./task-files");
|
|
47
|
+
/** The virtual key keeps spend out of URLs and logs. */
|
|
48
|
+
async function readGatewaySpend(baseUrl, key) {
|
|
49
|
+
try {
|
|
50
|
+
const response = await fetch(new URL('/key/info', baseUrl), {
|
|
51
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
52
|
+
signal: AbortSignal.timeout(5_000)
|
|
53
|
+
});
|
|
54
|
+
if (!response.ok)
|
|
55
|
+
return null;
|
|
56
|
+
const data = (await response.json());
|
|
57
|
+
const spend = data.info?.spend;
|
|
58
|
+
return typeof spend === 'number' && Number.isFinite(spend) && spend >= 0
|
|
59
|
+
? spend
|
|
60
|
+
: null;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
46
66
|
/** A missing Pi consumption event must not hold the lease open for its full RPC timeout. */
|
|
47
67
|
const FINAL_STEER_ACK_WAIT_MS = 5_000;
|
|
48
68
|
/** How often the runner asks the adapter for new output when nothing pushes. */
|
|
49
69
|
const POLL_INTERVAL_MS = 2_000;
|
|
70
|
+
const DEFAULT_APPROVAL_TIMEOUT_MS = 30 * 60_000;
|
|
71
|
+
function approvalTimeoutMs(value) {
|
|
72
|
+
const minutes = Number(value);
|
|
73
|
+
return Number.isFinite(minutes) && minutes > 0
|
|
74
|
+
? Math.min(minutes * 60_000, 24 * 60 * 60_000)
|
|
75
|
+
: DEFAULT_APPROVAL_TIMEOUT_MS;
|
|
76
|
+
}
|
|
50
77
|
/**
|
|
51
78
|
* How long an idle status must persist before the turn is considered over.
|
|
52
79
|
*
|
|
@@ -576,6 +603,7 @@ class SessionRunner {
|
|
|
576
603
|
openApproval = null;
|
|
577
604
|
/** The transcript identity of an ordinary agent question, if one is open. */
|
|
578
605
|
openQuestionPartId = null;
|
|
606
|
+
openQuestionStartedAt = null;
|
|
579
607
|
/**
|
|
580
608
|
* Expiry of the model token THIS RUNNING BACKEND is holding, when the
|
|
581
609
|
* control plane reported one — see `LeaseResponse.modelTokenExpiresAt`.
|
|
@@ -649,6 +677,7 @@ class SessionRunner {
|
|
|
649
677
|
let partsProduced = 0;
|
|
650
678
|
let stopReason = 'completed';
|
|
651
679
|
let error;
|
|
680
|
+
let gatewaySpendBefore = null;
|
|
652
681
|
let repositoryStatuses = [];
|
|
653
682
|
/** True when the backend session was resumed rather than created. */
|
|
654
683
|
let resumed = false;
|
|
@@ -680,6 +709,11 @@ class SessionRunner {
|
|
|
680
709
|
const refusal = modelGatewayRefusal(lease);
|
|
681
710
|
if (refusal)
|
|
682
711
|
throw new Error(refusal);
|
|
712
|
+
if (session.agent.model.gatewayUrl && lease.modelToken) {
|
|
713
|
+
gatewaySpendBefore = await readGatewaySpend(session.agent.model.gatewayUrl, lease.modelToken);
|
|
714
|
+
if (gatewaySpendBefore === null)
|
|
715
|
+
this.logger.warn(`[coding-agent] Gateway spend start unavailable for turn ${session.turnId}; this turn cannot be debited from gateway usage`);
|
|
716
|
+
}
|
|
683
717
|
// The catalogue is advisory. Fetch it while the independent workspace
|
|
684
718
|
// is prepared. Pi can start with the selected lease model while this
|
|
685
719
|
// request fills the cache for a later turn. OpenCode still waits for
|
|
@@ -1199,6 +1233,10 @@ class SessionRunner {
|
|
|
1199
1233
|
}
|
|
1200
1234
|
]);
|
|
1201
1235
|
}
|
|
1236
|
+
else if (drained.approvalTimedOut) {
|
|
1237
|
+
stopReason = 'approval_timeout';
|
|
1238
|
+
error = drained.approvalTimedOutMessage;
|
|
1239
|
+
}
|
|
1202
1240
|
else if (drained.timedOut) {
|
|
1203
1241
|
// THE LAST-LINE CEILING, and only reached when an operator has
|
|
1204
1242
|
// actually configured `CODING_AGENT_MAX_TURN_MINUTES` — the default
|
|
@@ -1318,6 +1356,16 @@ class SessionRunner {
|
|
|
1318
1356
|
* that cannot remember the previous sentence.
|
|
1319
1357
|
*/
|
|
1320
1358
|
}
|
|
1359
|
+
let costUsd;
|
|
1360
|
+
if (gatewaySpendBefore !== null &&
|
|
1361
|
+
session.agent.model.gatewayUrl &&
|
|
1362
|
+
lease.modelToken) {
|
|
1363
|
+
const spendAfter = await readGatewaySpend(session.agent.model.gatewayUrl, lease.modelToken);
|
|
1364
|
+
if (spendAfter === null)
|
|
1365
|
+
this.logger.warn(`[coding-agent] Gateway spend end unavailable for turn ${session.turnId}; this turn cannot be debited from gateway usage`);
|
|
1366
|
+
else
|
|
1367
|
+
costUsd = Math.max(0, spendAfter - gatewaySpendBefore);
|
|
1368
|
+
}
|
|
1321
1369
|
const turn = {
|
|
1322
1370
|
turnId: session.turnId,
|
|
1323
1371
|
startedAt,
|
|
@@ -1325,6 +1373,7 @@ class SessionRunner {
|
|
|
1325
1373
|
model: session.agent.model.model,
|
|
1326
1374
|
stopReason,
|
|
1327
1375
|
error,
|
|
1376
|
+
costUsd,
|
|
1328
1377
|
repositories: repositoryStatuses.length > 0 ? repositoryStatuses : undefined,
|
|
1329
1378
|
// The checkpoint travels on the final ingest because the final ingest is
|
|
1330
1379
|
// the ONE message guaranteed to be sent — it is what releases the lease,
|
|
@@ -1485,6 +1534,8 @@ class SessionRunner {
|
|
|
1485
1534
|
let timedOut = false;
|
|
1486
1535
|
let idleTimedOut = false;
|
|
1487
1536
|
let idleTimedOutMessage;
|
|
1537
|
+
let approvalTimedOut = false;
|
|
1538
|
+
let approvalTimedOutMessage;
|
|
1488
1539
|
const seenMessageIds = new Set();
|
|
1489
1540
|
const seenPartIds = new Set(alreadySeenPartIds);
|
|
1490
1541
|
const partContentLengths = new Map();
|
|
@@ -1529,6 +1580,61 @@ class SessionRunner {
|
|
|
1529
1580
|
try {
|
|
1530
1581
|
while (!signal.aborted) {
|
|
1531
1582
|
const now = Date.now();
|
|
1583
|
+
const approvalLimitMs = approvalTimeoutMs(process.env.CODING_AGENT_APPROVAL_TIMEOUT_MINUTES);
|
|
1584
|
+
if (this.openApproval &&
|
|
1585
|
+
this.approvalWaitStartedAt !== null &&
|
|
1586
|
+
now - this.approvalWaitStartedAt >= approvalLimitMs) {
|
|
1587
|
+
const open = this.openApproval;
|
|
1588
|
+
const capable = adapter;
|
|
1589
|
+
// Close first so a late answer cannot approve the timed-out action.
|
|
1590
|
+
this.closeApproval(session.sessionId);
|
|
1591
|
+
let denialTimer;
|
|
1592
|
+
try {
|
|
1593
|
+
if (capable.respondToApproval) {
|
|
1594
|
+
await Promise.race([
|
|
1595
|
+
capable.respondToApproval(backendSessionId, false),
|
|
1596
|
+
new Promise((resolveTimeout) => {
|
|
1597
|
+
denialTimer = setTimeout(resolveTimeout, 5_000);
|
|
1598
|
+
})
|
|
1599
|
+
]);
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
catch (approvalError) {
|
|
1603
|
+
this.logger.warn(`[SessionRunner] approval timeout denial failed for ${session.sessionId}: ${String(approvalError)}`);
|
|
1604
|
+
}
|
|
1605
|
+
finally {
|
|
1606
|
+
if (denialTimer)
|
|
1607
|
+
clearTimeout(denialTimer);
|
|
1608
|
+
}
|
|
1609
|
+
approvalTimedOut = true;
|
|
1610
|
+
approvalTimedOutMessage = `Approval denied after ${formatMinutes(approvalLimitMs)} without an answer. Turn stopped.`;
|
|
1611
|
+
this.record(session.sessionId, [
|
|
1612
|
+
{
|
|
1613
|
+
id: `answer-${open.toolCallId}`,
|
|
1614
|
+
role: 'system',
|
|
1615
|
+
partType: 'text',
|
|
1616
|
+
content: `Denied: ${approvalTimedOutMessage}`
|
|
1617
|
+
}
|
|
1618
|
+
]);
|
|
1619
|
+
break;
|
|
1620
|
+
}
|
|
1621
|
+
if (this.openQuestionPartId &&
|
|
1622
|
+
this.openQuestionStartedAt !== null &&
|
|
1623
|
+
now - this.openQuestionStartedAt >= approvalLimitMs) {
|
|
1624
|
+
const questionId = this.openQuestionPartId;
|
|
1625
|
+
this.closeQuestion(session.sessionId);
|
|
1626
|
+
approvalTimedOut = true;
|
|
1627
|
+
approvalTimedOutMessage = `Question closed after ${formatMinutes(approvalLimitMs)} without an answer. Turn stopped.`;
|
|
1628
|
+
this.record(session.sessionId, [
|
|
1629
|
+
{
|
|
1630
|
+
id: `answer-${questionId.replace(/^question-/, '')}`,
|
|
1631
|
+
role: 'system',
|
|
1632
|
+
partType: 'text',
|
|
1633
|
+
content: approvalTimedOutMessage
|
|
1634
|
+
}
|
|
1635
|
+
]);
|
|
1636
|
+
break;
|
|
1637
|
+
}
|
|
1532
1638
|
// Historical approval wait PLUS whatever approval is open right now.
|
|
1533
1639
|
// Added to both the hard deadline and, below, the idle window — a
|
|
1534
1640
|
// turn stopped to ask a person something is blocked on them, not
|
|
@@ -1644,7 +1750,8 @@ class SessionRunner {
|
|
|
1644
1750
|
// Last one wins: a retry that also fails should report its own reason.
|
|
1645
1751
|
providerError = status.message;
|
|
1646
1752
|
}
|
|
1647
|
-
const quiet = status.type === 'idle' || status.type === 'error'
|
|
1753
|
+
const quiet = (status.type === 'idle' || status.type === 'error') &&
|
|
1754
|
+
!this.isAwaitingApproval();
|
|
1648
1755
|
idleSeen = quiet ? idleSeen + 1 : 0;
|
|
1649
1756
|
if (idleSeen >= IDLE_CONFIRMATIONS) {
|
|
1650
1757
|
break;
|
|
@@ -1667,20 +1774,16 @@ class SessionRunner {
|
|
|
1667
1774
|
* and the turn cut as `no_progress` in the same tick, before the
|
|
1668
1775
|
* person it was raised for ever sees it.
|
|
1669
1776
|
*
|
|
1670
|
-
*
|
|
1671
|
-
*
|
|
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.
|
|
1777
|
+
* Approval time is excluded from the idle and hard limits. The
|
|
1778
|
+
* separate approval limit above ends an unanswered request.
|
|
1678
1779
|
*/
|
|
1679
1780
|
const approvalWaitSinceProgress = this.approvalWaitMs -
|
|
1680
1781
|
approvalWaitAtLastProgress +
|
|
1681
1782
|
this.liveApprovalWaitMs();
|
|
1682
1783
|
const idleElapsed = Date.now() - lastProgressAt - approvalWaitSinceProgress;
|
|
1683
|
-
if (this.approvalWaitStartedAt === null &&
|
|
1784
|
+
if (this.approvalWaitStartedAt === null &&
|
|
1785
|
+
this.openQuestionPartId === null &&
|
|
1786
|
+
idleElapsed > idleLimitMs) {
|
|
1684
1787
|
this.logger.warn(`[SessionRunner] session ${session.sessionId} had no progress for ` +
|
|
1685
1788
|
`${Math.round(idleElapsed / 1000)}s (limit ${Math.round(idleLimitMs / 1000)}s)`);
|
|
1686
1789
|
idleTimedOut = true;
|
|
@@ -1720,7 +1823,7 @@ class SessionRunner {
|
|
|
1720
1823
|
await this.flush(session.sessionId, this.pending.splice(0), undefined, false);
|
|
1721
1824
|
}
|
|
1722
1825
|
}
|
|
1723
|
-
if (signal.aborted || timedOut || idleTimedOut) {
|
|
1826
|
+
if (signal.aborted || timedOut || idleTimedOut || approvalTimedOut) {
|
|
1724
1827
|
try {
|
|
1725
1828
|
await adapter.abortPrompt(backendSessionId, config);
|
|
1726
1829
|
}
|
|
@@ -1733,7 +1836,9 @@ class SessionRunner {
|
|
|
1733
1836
|
providerError,
|
|
1734
1837
|
timedOut,
|
|
1735
1838
|
idleTimedOut,
|
|
1736
|
-
idleTimedOutMessage
|
|
1839
|
+
idleTimedOutMessage,
|
|
1840
|
+
approvalTimedOut,
|
|
1841
|
+
approvalTimedOutMessage
|
|
1737
1842
|
};
|
|
1738
1843
|
}
|
|
1739
1844
|
/**
|
|
@@ -1783,9 +1888,8 @@ class SessionRunner {
|
|
|
1783
1888
|
/* ---------------------------------------------------------------- */
|
|
1784
1889
|
/** Wall-clock spent in the approval we are in RIGHT NOW, if any. */
|
|
1785
1890
|
liveApprovalWaitMs() {
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
: Date.now() - this.approvalWaitStartedAt;
|
|
1891
|
+
const startedAt = this.approvalWaitStartedAt ?? this.openQuestionStartedAt;
|
|
1892
|
+
return startedAt === null ? 0 : Date.now() - startedAt;
|
|
1789
1893
|
}
|
|
1790
1894
|
/**
|
|
1791
1895
|
* WIRE 2. Turn a pending backend permission into a `question` transcript part.
|
|
@@ -1978,7 +2082,11 @@ class SessionRunner {
|
|
|
1978
2082
|
}
|
|
1979
2083
|
closeQuestion(sessionId) {
|
|
1980
2084
|
if (this.openQuestionPartId) {
|
|
2085
|
+
if (this.openQuestionStartedAt !== null) {
|
|
2086
|
+
this.approvalWaitMs += Date.now() - this.openQuestionStartedAt;
|
|
2087
|
+
}
|
|
1981
2088
|
this.openQuestionPartId = null;
|
|
2089
|
+
this.openQuestionStartedAt = null;
|
|
1982
2090
|
this.deps.onApprovalStateChange?.(sessionId, false);
|
|
1983
2091
|
}
|
|
1984
2092
|
}
|
|
@@ -2158,6 +2266,7 @@ class SessionRunner {
|
|
|
2158
2266
|
part.id !== this.openApproval?.partId &&
|
|
2159
2267
|
this.openQuestionPartId !== part.id) {
|
|
2160
2268
|
this.openQuestionPartId = part.id;
|
|
2269
|
+
this.openQuestionStartedAt = Date.now();
|
|
2161
2270
|
this.deps.onApprovalStateChange?.(sessionId, true);
|
|
2162
2271
|
}
|
|
2163
2272
|
}
|
|
@@ -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 {};
|