@openagents-org/agent-launcher 0.2.191 → 0.2.193
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/package.json +1 -1
- package/src/adapters/base.js +55 -26
- package/src/adapters/codex.js +33 -2
- package/src/adapters/failure-streak.js +89 -0
- package/src/adapters/kimi.js +11 -0
- package/src/adapters/pi.js +2 -1
- package/src/config.js +107 -28
- package/src/daemon.js +30 -35
- package/src/installer.js +26 -4
- package/src/log-writer.js +351 -0
- package/src/node-pty-perms.js +111 -0
- package/src/workspace-client.js +44 -14
package/package.json
CHANGED
package/src/adapters/base.js
CHANGED
|
@@ -35,6 +35,7 @@ const {
|
|
|
35
35
|
classifyHeartbeatError,
|
|
36
36
|
httpStatusOf,
|
|
37
37
|
} = require('./health-status');
|
|
38
|
+
const { FailureStreak, isTransientNetworkError, formatDuration } = require('./failure-streak');
|
|
38
39
|
|
|
39
40
|
const DEFAULT_ENDPOINT = 'https://workspace-endpoint.openagents.org';
|
|
40
41
|
|
|
@@ -104,7 +105,7 @@ class BaseAdapter {
|
|
|
104
105
|
* @param {string} opts.agentName
|
|
105
106
|
* @param {string} [opts.endpoint]
|
|
106
107
|
*/
|
|
107
|
-
constructor({ workspaceId, channelName, token, agentName, endpoint, agentEnv, agentType, workingDir, onStatus }) {
|
|
108
|
+
constructor({ workspaceId, channelName, token, agentName, endpoint, agentEnv, agentType, workingDir, onStatus, logSink }) {
|
|
108
109
|
this.workspaceId = workspaceId;
|
|
109
110
|
this.channelName = channelName;
|
|
110
111
|
this.token = token;
|
|
@@ -198,10 +199,17 @@ class BaseAdapter {
|
|
|
198
199
|
// (e.g. after a `restart` IPC bounce) so uptime tracks "time since last
|
|
199
200
|
// restart" rather than the long-running daemon's process uptime.
|
|
200
201
|
this._startedAt = Date.now();
|
|
201
|
-
|
|
202
|
+
// Inside the daemon, `logSink` routes lines through its daemon.log writer
|
|
203
|
+
// (batching, repeat suppression, rotation). Standalone, they go to stdout.
|
|
204
|
+
const sink = typeof logSink === 'function' ? logSink : null;
|
|
205
|
+
this._log = (msg, level = 'info') => {
|
|
206
|
+
if (sink) return sink(level, msg);
|
|
202
207
|
const ts = new Date().toISOString();
|
|
203
|
-
console.log(`${ts}
|
|
208
|
+
console.log(`${ts} ${String(level).toUpperCase()} adapter [${this.agentName}]: ${msg}`);
|
|
204
209
|
};
|
|
210
|
+
// Runs of identical background failures (see failure-streak.js).
|
|
211
|
+
this._pollFailures = new FailureStreak();
|
|
212
|
+
this._heartbeatFailures = new FailureStreak();
|
|
205
213
|
}
|
|
206
214
|
|
|
207
215
|
// ------------------------------------------------------------------
|
|
@@ -337,7 +345,7 @@ class BaseAdapter {
|
|
|
337
345
|
this._log(`Synced model from workspace: ${this.workspaceModel}`);
|
|
338
346
|
}
|
|
339
347
|
} catch (e) {
|
|
340
|
-
this._log(`Warning: skill sync failed (non-fatal): ${e.message}
|
|
348
|
+
this._log(`Warning: skill sync failed (non-fatal): ${e.message}`, 'warn');
|
|
341
349
|
}
|
|
342
350
|
|
|
343
351
|
// Fast-path operations (control-event cursor + heartbeat + control poll)
|
|
@@ -352,7 +360,7 @@ class BaseAdapter {
|
|
|
352
360
|
try {
|
|
353
361
|
// Send initial heartbeat
|
|
354
362
|
try { await this._heartbeat(); } catch (e) {
|
|
355
|
-
this._log(`Heartbeat failed (non-fatal): ${e.message}
|
|
363
|
+
this._log(`Heartbeat failed (non-fatal): ${e.message}`, 'warn');
|
|
356
364
|
}
|
|
357
365
|
// Slow path: only the message-poll loop waits for this.
|
|
358
366
|
await this._skipExistingEvents();
|
|
@@ -412,7 +420,7 @@ class BaseAdapter {
|
|
|
412
420
|
} catch (e) {
|
|
413
421
|
if (!this._warnedCursorPersist) {
|
|
414
422
|
this._warnedCursorPersist = true;
|
|
415
|
-
this._log(`Could not persist poll cursor (${e.message}) — a restart will skip messages that arrive while offline
|
|
423
|
+
this._log(`Could not persist poll cursor (${e.message}) — a restart will skip messages that arrive while offline`, 'warn');
|
|
416
424
|
}
|
|
417
425
|
}
|
|
418
426
|
}
|
|
@@ -457,10 +465,14 @@ class BaseAdapter {
|
|
|
457
465
|
try {
|
|
458
466
|
await this.client.heartbeat(this.workspaceId, this.agentName, this.token, this._sessionId);
|
|
459
467
|
this._heartbeatFailStreak = 0;
|
|
468
|
+
const recovered = this._heartbeatFailures.succeed();
|
|
469
|
+
if (recovered) {
|
|
470
|
+
this._log(`Workspace heartbeat recovered after ${recovered.count} unsuccessful attempt(s) over ${formatDuration(recovered.ms)}`);
|
|
471
|
+
}
|
|
460
472
|
this._reportStatus(null); // alive → clear any prior connectivity error
|
|
461
473
|
} catch (e) {
|
|
462
474
|
if (e instanceof SessionRevokedError) {
|
|
463
|
-
this._log(`SESSION REVOKED: another client joined as '${this.agentName}'. Stopping adapter
|
|
475
|
+
this._log(`SESSION REVOKED: another client joined as '${this.agentName}'. Stopping adapter.`, 'warn');
|
|
464
476
|
// Terminal (not a user stop): record so the daemon can show why it ended.
|
|
465
477
|
this._setExitInfo(REASON.SESSION_REVOKED, 'Workspace session revoked — another client joined with the same agent name');
|
|
466
478
|
this._reportStatus(REASON.SESSION_REVOKED, 'Workspace session revoked');
|
|
@@ -471,7 +483,9 @@ class BaseAdapter {
|
|
|
471
483
|
// single transient blip (or an expected brief reconnect) isn't mislabeled.
|
|
472
484
|
this._heartbeatFailStreak++;
|
|
473
485
|
const { reason, message } = classifyHeartbeatError(e);
|
|
474
|
-
|
|
486
|
+
if (this._heartbeatFailures.fail()) {
|
|
487
|
+
this._log(`${message} (consecutive failures: ${this._heartbeatFailStreak})`, 'warn');
|
|
488
|
+
}
|
|
475
489
|
if (this._heartbeatFailStreak >= HEARTBEAT_ERROR_THRESHOLD) {
|
|
476
490
|
this._reportStatus(reason, message);
|
|
477
491
|
}
|
|
@@ -593,7 +607,7 @@ class BaseAdapter {
|
|
|
593
607
|
}
|
|
594
608
|
if (!channel) {
|
|
595
609
|
try { await this._stopSharedWork(); } catch (e) {
|
|
596
|
-
this._log(`Stop: shared work did not stop cleanly: ${e && e.message ? e.message : e}
|
|
610
|
+
this._log(`Stop: shared work did not stop cleanly: ${e && e.message ? e.message : e}`, 'warn');
|
|
597
611
|
}
|
|
598
612
|
}
|
|
599
613
|
for (const ch of channels) {
|
|
@@ -601,7 +615,7 @@ class BaseAdapter {
|
|
|
601
615
|
try {
|
|
602
616
|
outcome = await this._stopChannelWork(ch);
|
|
603
617
|
} catch (e) {
|
|
604
|
-
this._log(`Stop: ${ch} did not stop cleanly: ${e && e.message ? e.message : e}
|
|
618
|
+
this._log(`Stop: ${ch} did not stop cleanly: ${e && e.message ? e.message : e}`, 'warn');
|
|
605
619
|
outcome = 'failed';
|
|
606
620
|
}
|
|
607
621
|
// The pressed channel is always answered, so its UI settles even when
|
|
@@ -788,7 +802,7 @@ class BaseAdapter {
|
|
|
788
802
|
skillId, state: 'installing',
|
|
789
803
|
});
|
|
790
804
|
} catch (e) {
|
|
791
|
-
this._log(`skill.install: could not report 'installing' (non-fatal): ${e && e.message ? e.message : e}
|
|
805
|
+
this._log(`skill.install: could not report 'installing' (non-fatal): ${e && e.message ? e.message : e}`, 'warn');
|
|
792
806
|
}
|
|
793
807
|
|
|
794
808
|
try {
|
|
@@ -822,19 +836,19 @@ class BaseAdapter {
|
|
|
822
836
|
skillId, state: 'installed', path: result.path, partial: result.partial === true,
|
|
823
837
|
});
|
|
824
838
|
} catch (e) {
|
|
825
|
-
this._log(`skill.install: installed on disk but failed to report 'installed': ${e && e.message ? e.message : e}
|
|
839
|
+
this._log(`skill.install: installed on disk but failed to report 'installed': ${e && e.message ? e.message : e}`, 'warn');
|
|
826
840
|
}
|
|
827
841
|
this._log(`skill.install: SUCCESS "${skillId}" → ${result.path}${result.partial ? ' (partial)' : ''}`);
|
|
828
842
|
await this._onSkillsChanged();
|
|
829
843
|
} catch (e) {
|
|
830
844
|
const msg = e && e.message ? e.message : String(e);
|
|
831
|
-
this._log(`skill.install: FAILED "${skillId}": ${msg}
|
|
845
|
+
this._log(`skill.install: FAILED "${skillId}": ${msg}`, 'error');
|
|
832
846
|
try {
|
|
833
847
|
await this.client.reportSkillStatus(this.workspaceId, this.agentName, this.token, {
|
|
834
848
|
skillId, state: 'failed', error: msg,
|
|
835
849
|
});
|
|
836
850
|
} catch (e2) {
|
|
837
|
-
this._log(`skill.install: also failed to report 'failed': ${e2 && e2.message ? e2.message : e2}
|
|
851
|
+
this._log(`skill.install: also failed to report 'failed': ${e2 && e2.message ? e2.message : e2}`, 'warn');
|
|
838
852
|
}
|
|
839
853
|
}
|
|
840
854
|
}
|
|
@@ -863,12 +877,12 @@ class BaseAdapter {
|
|
|
863
877
|
skillId, state: 'uninstalled',
|
|
864
878
|
});
|
|
865
879
|
} catch (e) {
|
|
866
|
-
this._log(`skill.uninstall: failed to report status: ${e && e.message ? e.message : e}
|
|
880
|
+
this._log(`skill.uninstall: failed to report status: ${e && e.message ? e.message : e}`, 'warn');
|
|
867
881
|
}
|
|
868
882
|
await this._onSkillsChanged();
|
|
869
883
|
} catch (e) {
|
|
870
884
|
const msg = e && e.message ? e.message : String(e);
|
|
871
|
-
this._log(`skill.uninstall: FAILED "${skillId}": ${msg}
|
|
885
|
+
this._log(`skill.uninstall: FAILED "${skillId}": ${msg}`, 'error');
|
|
872
886
|
}
|
|
873
887
|
}
|
|
874
888
|
|
|
@@ -923,7 +937,7 @@ class BaseAdapter {
|
|
|
923
937
|
sessionId: this._sessionId,
|
|
924
938
|
});
|
|
925
939
|
} catch (e) {
|
|
926
|
-
this._log(`Status: failed to post: ${e && e.message ? e.message : e}
|
|
940
|
+
this._log(`Status: failed to post: ${e && e.message ? e.message : e}`, 'warn');
|
|
927
941
|
}
|
|
928
942
|
}
|
|
929
943
|
|
|
@@ -950,7 +964,7 @@ class BaseAdapter {
|
|
|
950
964
|
(r) => r.created_by === prefixed || r.created_by === this.agentName,
|
|
951
965
|
);
|
|
952
966
|
} catch (e) {
|
|
953
|
-
this._log(`Routines: failed to list: ${e && e.message ? e.message : e}
|
|
967
|
+
this._log(`Routines: failed to list: ${e && e.message ? e.message : e}`, 'warn');
|
|
954
968
|
try {
|
|
955
969
|
await this.client.sendMessage(
|
|
956
970
|
this.workspaceId, channel, this.token,
|
|
@@ -991,7 +1005,7 @@ class BaseAdapter {
|
|
|
991
1005
|
sessionId: this._sessionId,
|
|
992
1006
|
});
|
|
993
1007
|
} catch (e) {
|
|
994
|
-
this._log(`Routines: failed to post: ${e && e.message ? e.message : e}
|
|
1008
|
+
this._log(`Routines: failed to post: ${e && e.message ? e.message : e}`, 'warn');
|
|
995
1009
|
}
|
|
996
1010
|
}
|
|
997
1011
|
|
|
@@ -1049,11 +1063,26 @@ class BaseAdapter {
|
|
|
1049
1063
|
rawCursor = result.cursor;
|
|
1050
1064
|
composingActive = !!result.composing;
|
|
1051
1065
|
this._pollAuthFailStreak = 0;
|
|
1066
|
+
const recovered = this._pollFailures.succeed();
|
|
1067
|
+
if (recovered) {
|
|
1068
|
+
this._log(`Poll recovered after ${recovered.count} unsuccessful attempt(s) over ${formatDuration(recovered.ms)}`);
|
|
1069
|
+
}
|
|
1052
1070
|
if (pollCount <= 3 || pollCount % 20 === 0) {
|
|
1053
1071
|
this._log(`Poll #${pollCount}: ${messages.length} messages, cursor=${rawCursor || 'none'}${composingActive ? ' composing' : ''}`);
|
|
1054
1072
|
}
|
|
1055
1073
|
} catch (e) {
|
|
1056
|
-
|
|
1074
|
+
// A workspace that is down or slow fails every poll until it is back;
|
|
1075
|
+
// log the streak, not every attempt (see failure-streak.js).
|
|
1076
|
+
if (this._pollFailures.fail()) {
|
|
1077
|
+
const count = this._pollFailures.count;
|
|
1078
|
+
const streak = count > 1 ? ` (consecutive failures: ${count})` : '';
|
|
1079
|
+
if (isTransientNetworkError(e)) {
|
|
1080
|
+
this._log(`Poll #${pollCount} failed: ${e.message}${streak} — retrying every 5s`, 'warn');
|
|
1081
|
+
} else {
|
|
1082
|
+
const stack = count === 1 && e && e.stack ? `\nStack: ${e.stack}` : '';
|
|
1083
|
+
this._log(`Poll #${pollCount} failed: ${e && e.message}${streak}${stack}`, 'error');
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1057
1086
|
// A rejected credential never heals by retrying with the same token —
|
|
1058
1087
|
// it means the device was re-paired/unpaired and this adapter is
|
|
1059
1088
|
// running on a revoked one. After a few consecutive rejections (not
|
|
@@ -1249,7 +1278,7 @@ class BaseAdapter {
|
|
|
1249
1278
|
await this._handleMessage(current);
|
|
1250
1279
|
}
|
|
1251
1280
|
} catch (e) {
|
|
1252
|
-
this._log(`Error ${first ? 'in channel worker' : 'processing queued message'} for ${channel}: ${e.message}
|
|
1281
|
+
this._log(`Error ${first ? 'in channel worker' : 'processing queued message'} for ${channel}: ${e.message}`, 'error');
|
|
1253
1282
|
try { await this.sendError(channel, `Agent error: ${e.message}`); } catch {}
|
|
1254
1283
|
}
|
|
1255
1284
|
delete this._channelRunGeneration[channel];
|
|
@@ -1333,7 +1362,7 @@ class BaseAdapter {
|
|
|
1333
1362
|
delete cache[channel];
|
|
1334
1363
|
// Entry is gone — fall through to list+match below.
|
|
1335
1364
|
} else {
|
|
1336
|
-
this._log(`${label} read failed (${e.message}) — reusing last known state
|
|
1365
|
+
this._log(`${label} read failed (${e.message}) — reusing last known state`, 'warn');
|
|
1337
1366
|
return { available: true, state: 'found', entryId: cachedId, title: titles[0], content: null, error: true };
|
|
1338
1367
|
}
|
|
1339
1368
|
}
|
|
@@ -1369,7 +1398,7 @@ class BaseAdapter {
|
|
|
1369
1398
|
}
|
|
1370
1399
|
return { available: true, state: 'found', entryId: entry.id, title: matchedTitle, content: (full && full.content) || '', error: false };
|
|
1371
1400
|
} catch (e) {
|
|
1372
|
-
this._log(`${label} fetch failed (${e.message}) — reusing last known state
|
|
1401
|
+
this._log(`${label} fetch failed (${e.message}) — reusing last known state`, 'warn');
|
|
1373
1402
|
const knownId = cache[channel] || null;
|
|
1374
1403
|
return { available: true, state: knownId ? 'found' : 'unknown', entryId: knownId, title: knownId ? titles[0] : null, content: null, error: true };
|
|
1375
1404
|
}
|
|
@@ -1427,7 +1456,7 @@ class BaseAdapter {
|
|
|
1427
1456
|
}
|
|
1428
1457
|
this._pinnedContext[channel] = fresh;
|
|
1429
1458
|
} catch (e) {
|
|
1430
|
-
this._log(`Pinned-context fetch failed (${e && e.message ? e.message : e}) — reusing the last pinned context
|
|
1459
|
+
this._log(`Pinned-context fetch failed (${e && e.message ? e.message : e}) — reusing the last pinned context`, 'warn');
|
|
1431
1460
|
}
|
|
1432
1461
|
}
|
|
1433
1462
|
|
|
@@ -1475,7 +1504,7 @@ class BaseAdapter {
|
|
|
1475
1504
|
this._log(`Auto-titled channel: ${title}`);
|
|
1476
1505
|
}
|
|
1477
1506
|
} catch (e) {
|
|
1478
|
-
this._log(`Failed to auto-title channel: ${e.message}
|
|
1507
|
+
this._log(`Failed to auto-title channel: ${e.message}`, 'warn');
|
|
1479
1508
|
}
|
|
1480
1509
|
}
|
|
1481
1510
|
|
|
@@ -1625,7 +1654,7 @@ class BaseAdapter {
|
|
|
1625
1654
|
}
|
|
1626
1655
|
|
|
1627
1656
|
_onSessionRevoked() {
|
|
1628
|
-
this._log(`SESSION REVOKED: another client joined as '${this.agentName}'. Stopping adapter
|
|
1657
|
+
this._log(`SESSION REVOKED: another client joined as '${this.agentName}'. Stopping adapter.`, 'warn');
|
|
1629
1658
|
this._running = false;
|
|
1630
1659
|
}
|
|
1631
1660
|
|
package/src/adapters/codex.js
CHANGED
|
@@ -343,6 +343,13 @@ class CodexAdapter extends BaseAdapter {
|
|
|
343
343
|
|
|
344
344
|
if (result.responseText) {
|
|
345
345
|
await this.sendResponse(msgChannel, result.responseText);
|
|
346
|
+
// A run that said something and then died (turn.failed, non-zero
|
|
347
|
+
// exit) used to post only that opening line as the finished reply,
|
|
348
|
+
// so it read as "Codex says one sentence and stops" with the real
|
|
349
|
+
// error left in daemon.log. Say it stopped, and why.
|
|
350
|
+
if (CodexAdapter._endedEarly(result)) {
|
|
351
|
+
await this._sendRunFailure(msgChannel, result, { partial: true });
|
|
352
|
+
}
|
|
346
353
|
return;
|
|
347
354
|
} else if (result.exitCode !== 0 && threadId && attempt === 0) {
|
|
348
355
|
// Stale thread — clear and retry fresh
|
|
@@ -384,6 +391,7 @@ class CodexAdapter extends BaseAdapter {
|
|
|
384
391
|
let lineBuffer = '';
|
|
385
392
|
let stderrBuf = '';
|
|
386
393
|
let lastErrorMessage = '';
|
|
394
|
+
let turnFailed = false;
|
|
387
395
|
let _pendingLines = Promise.resolve();
|
|
388
396
|
|
|
389
397
|
if (proc.stderr) {
|
|
@@ -433,7 +441,11 @@ class CodexAdapter extends BaseAdapter {
|
|
|
433
441
|
this._log(`Command: ${cmdText} → exit ${exitCode}`);
|
|
434
442
|
} else if (item.type === 'file_change') {
|
|
435
443
|
hasToolUseSinceLastText = true;
|
|
436
|
-
|
|
444
|
+
// codex reports the edited files in `changes`; `filename` is the
|
|
445
|
+
// legacy shape and was always empty on current CLIs.
|
|
446
|
+
const filename = Array.isArray(item.changes) && item.changes.length
|
|
447
|
+
? item.changes.map((c) => c && c.path).filter(Boolean).join(', ')
|
|
448
|
+
: (item.filename || '');
|
|
437
449
|
try { await this.sendStatus(msgChannel, `**Editing:** \`${filename}\``); } catch {}
|
|
438
450
|
this._log(`File change: ${filename}`);
|
|
439
451
|
}
|
|
@@ -441,6 +453,7 @@ class CodexAdapter extends BaseAdapter {
|
|
|
441
453
|
const error = event.error || {};
|
|
442
454
|
const errMsg = error.message || JSON.stringify(error);
|
|
443
455
|
lastErrorMessage = errMsg;
|
|
456
|
+
turnFailed = true;
|
|
444
457
|
this._log(`Turn failed: ${errMsg}`);
|
|
445
458
|
} else if (eventType === 'error') {
|
|
446
459
|
const errMsg = event.message || JSON.stringify(event);
|
|
@@ -481,6 +494,7 @@ class CodexAdapter extends BaseAdapter {
|
|
|
481
494
|
exitCode: code,
|
|
482
495
|
stderr: stderrBuf,
|
|
483
496
|
errorMessage: lastErrorMessage,
|
|
497
|
+
turnFailed,
|
|
484
498
|
});
|
|
485
499
|
});
|
|
486
500
|
|
|
@@ -569,14 +583,31 @@ class CodexAdapter extends BaseAdapter {
|
|
|
569
583
|
* Post a user-visible failure carrying the actual reason a run produced no
|
|
570
584
|
* reply, instead of a generic "No response generated".
|
|
571
585
|
*/
|
|
572
|
-
async _sendRunFailure(msgChannel, result) {
|
|
586
|
+
async _sendRunFailure(msgChannel, result, { partial = false } = {}) {
|
|
573
587
|
const detail = CodexAdapter._failureDetail(result);
|
|
588
|
+
if (partial) {
|
|
589
|
+
const body = detail
|
|
590
|
+
? `It stopped before finishing this task.\n\n> ${detail}`
|
|
591
|
+
: 'It stopped before finishing this task. Please try again.';
|
|
592
|
+
await this.sendError(msgChannel, `⚠️ **Codex stopped partway** — ${body}`);
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
574
595
|
const body = detail
|
|
575
596
|
? `Codex failed to complete this run.\n\n> ${detail}`
|
|
576
597
|
: 'Codex finished without producing a reply. Please try again.';
|
|
577
598
|
await this.sendError(msgChannel, `⚠️ **Codex couldn't run** — ${body}`);
|
|
578
599
|
}
|
|
579
600
|
|
|
601
|
+
/**
|
|
602
|
+
* True when a run that produced some text did not actually finish. Only a
|
|
603
|
+
* turn.failed or a non-zero exit counts: codex also emits `error` events
|
|
604
|
+
* for retries it recovers from ("Reconnecting... 2/5"), and a run that
|
|
605
|
+
* reconnected and then completed is fine.
|
|
606
|
+
*/
|
|
607
|
+
static _endedEarly({ exitCode, turnFailed } = {}) {
|
|
608
|
+
return Boolean(turnFailed) || (exitCode !== 0 && exitCode !== undefined);
|
|
609
|
+
}
|
|
610
|
+
|
|
580
611
|
/**
|
|
581
612
|
* Pick the most informative failure detail from a run result:
|
|
582
613
|
* turn.failed / error event message → stderr tail → exit code. Redacted.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Logging policy for background calls that fail repeatedly — the message poll
|
|
5
|
+
* and the workspace heartbeat.
|
|
6
|
+
*
|
|
7
|
+
* Both retry forever, so an unreachable or slow workspace used to produce one
|
|
8
|
+
* entry per attempt: the poll wrote a full stack every ~20s (15s deadline + 5s
|
|
9
|
+
* back-off) at INFO, and the Logs page — inferring severity from "failed" plus
|
|
10
|
+
* the stack — showed a wall of red ERRORs for what was a network outage.
|
|
11
|
+
*
|
|
12
|
+
* A streak logs its 1st, 3rd, 10th, 30th and then every 100th failure, and one
|
|
13
|
+
* line when it ends. Network-level failures are expected in the field and are
|
|
14
|
+
* WARN without a stack; anything else is an ERROR, with its stack once.
|
|
15
|
+
*
|
|
16
|
+
* Pure and dependency-light, so it is unit-testable without an adapter.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const { httpStatusOf } = require('./health-status');
|
|
20
|
+
|
|
21
|
+
const TRANSIENT_CODES = new Set([
|
|
22
|
+
'ETIMEDOUT',
|
|
23
|
+
'ECONNRESET',
|
|
24
|
+
'ECONNREFUSED',
|
|
25
|
+
'ECONNABORTED',
|
|
26
|
+
'ENOTFOUND',
|
|
27
|
+
'EAI_AGAIN',
|
|
28
|
+
'ENETUNREACH',
|
|
29
|
+
'ENETDOWN',
|
|
30
|
+
'EHOSTUNREACH',
|
|
31
|
+
'EPIPE',
|
|
32
|
+
'UND_ERR_SOCKET',
|
|
33
|
+
'UND_ERR_CONNECT_TIMEOUT',
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* True for failures of the network or of an overloaded server — the kind a
|
|
38
|
+
* retry is expected to fix, not a bug or a rejected credential.
|
|
39
|
+
*/
|
|
40
|
+
function isTransientNetworkError(err) {
|
|
41
|
+
if (!err) return false;
|
|
42
|
+
if (err.code && TRANSIENT_CODES.has(err.code)) return true;
|
|
43
|
+
if (err.name === 'AbortError' || err.name === 'TimeoutError') return true;
|
|
44
|
+
const status = httpStatusOf(err);
|
|
45
|
+
if (typeof status === 'number') return status === 429 || status >= 500;
|
|
46
|
+
return /\b(timed out|socket hang up|network)\b/i.test(err.message || '');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Which failure counts in a streak get a log line. */
|
|
50
|
+
function isLogPoint(count) {
|
|
51
|
+
return count === 1 || count === 3 || count === 10 || count === 30 || count % 100 === 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
class FailureStreak {
|
|
55
|
+
constructor() {
|
|
56
|
+
this.count = 0;
|
|
57
|
+
this.since = 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Record one failure. True when this one should be logged. */
|
|
61
|
+
fail(now = Date.now()) {
|
|
62
|
+
this.count++;
|
|
63
|
+
if (this.count === 1) this.since = now;
|
|
64
|
+
return isLogPoint(this.count);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Record a success. Returns the streak it ended — `{ count, ms }` — or null
|
|
69
|
+
* when there was none.
|
|
70
|
+
*/
|
|
71
|
+
succeed(now = Date.now()) {
|
|
72
|
+
if (this.count === 0) return null;
|
|
73
|
+
const ended = { count: this.count, ms: now - this.since };
|
|
74
|
+
this.count = 0;
|
|
75
|
+
this.since = 0;
|
|
76
|
+
return ended;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** "45s" / "12m" / "3h" — for the line that closes a streak. */
|
|
81
|
+
function formatDuration(ms) {
|
|
82
|
+
const s = Math.max(0, Math.round(ms / 1000));
|
|
83
|
+
if (s < 120) return `${s}s`;
|
|
84
|
+
const m = Math.round(s / 60);
|
|
85
|
+
if (m < 120) return `${m}m`;
|
|
86
|
+
return `${Math.round(m / 60)}h`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = { FailureStreak, isTransientNetworkError, isLogPoint, formatDuration };
|
package/src/adapters/kimi.js
CHANGED
|
@@ -38,6 +38,7 @@ const { spawn } = require('../wsl');
|
|
|
38
38
|
const LlmDirectAdapter = require('./llm-direct');
|
|
39
39
|
const { formatAttachmentsForPrompt, SESSION_DEFAULT_RE, generateSessionTitle } = require('./utils');
|
|
40
40
|
const { defaultAgentWorkdir, whichBinary, whereBinary } = require('../paths');
|
|
41
|
+
const { fixSpawnHelpersFor } = require('../node-pty-perms');
|
|
41
42
|
const {
|
|
42
43
|
KimiStreamParser,
|
|
43
44
|
interpretKimiMessage,
|
|
@@ -70,6 +71,7 @@ const WATCHDOG_MAX = 20; // ~5 min of silence → kill
|
|
|
70
71
|
// don't re-spawn `kimi --version`, yet an install/upgrade is re-detected.
|
|
71
72
|
const VERSION_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
72
73
|
const _kimiVersionCache = new Map(); // binPath -> { version, product, at }
|
|
74
|
+
const _ptyFixedBins = new Set(); // binPaths whose node-pty spawn-helper was checked
|
|
73
75
|
|
|
74
76
|
/** Newest mtime (ms) of a directory and everything under it, `depth` levels deep. */
|
|
75
77
|
function newestMtimeMs(dir, depth) {
|
|
@@ -522,6 +524,15 @@ class KimiAdapter extends LlmDirectAdapter {
|
|
|
522
524
|
return;
|
|
523
525
|
}
|
|
524
526
|
|
|
527
|
+
// An install from before the installer restored node-pty's spawn-helper
|
|
528
|
+
// (or one the user made) still has it at 0644, and every terminal Kimi
|
|
529
|
+
// opens fails with "posix_spawnp failed." Once per binary per daemon.
|
|
530
|
+
if (!_ptyFixedBins.has(kimiBin)) {
|
|
531
|
+
_ptyFixedBins.add(kimiBin);
|
|
532
|
+
const fixed = fixSpawnHelpersFor(kimiBin);
|
|
533
|
+
if (fixed.length) this._log(`Made node-pty spawn-helper executable: ${fixed.join(', ')}`);
|
|
534
|
+
}
|
|
535
|
+
|
|
525
536
|
await this._handleMessageCli(msg, kimiBin);
|
|
526
537
|
}
|
|
527
538
|
|
package/src/adapters/pi.js
CHANGED
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
*
|
|
25
25
|
* Verified against pi 0.83.0 (engines.node >= 22.19.0). Because that engine
|
|
26
26
|
* floor is above what a user's ambient Node may satisfy, a resolved JS entry
|
|
27
|
-
* point is launched with the launcher's own portable Node (v22
|
|
27
|
+
* point is launched with the launcher's own portable Node (v24, or v22 on
|
|
28
|
+
* macOS before 13.5) and
|
|
28
29
|
* preflight refuses to start on anything older.
|
|
29
30
|
*/
|
|
30
31
|
|
package/src/config.js
CHANGED
|
@@ -180,48 +180,62 @@ class Config {
|
|
|
180
180
|
|
|
181
181
|
getLogs(agentName, lines = 200) {
|
|
182
182
|
try {
|
|
183
|
-
|
|
184
|
-
const content = fs.readFileSync(this.logFile, 'utf-8');
|
|
185
|
-
let allLines = content.split('\n').filter(Boolean);
|
|
186
|
-
if (agentName) {
|
|
187
|
-
allLines = allLines.filter(
|
|
188
|
-
(l) => l.includes(agentName) || l.includes('daemon') || l.includes('Daemon')
|
|
189
|
-
);
|
|
190
|
-
}
|
|
191
|
-
return allLines.slice(-lines);
|
|
183
|
+
return this.tailLogs({ agent: agentName, lines }).lines;
|
|
192
184
|
} catch {
|
|
193
185
|
return [];
|
|
194
186
|
}
|
|
195
187
|
}
|
|
196
188
|
|
|
197
189
|
/**
|
|
198
|
-
* Tail log file with optional filter. Returns { lines, size }.
|
|
190
|
+
* Tail log file with optional filter. Returns { lines, size, reset }.
|
|
191
|
+
* Reads only the end of the file (or what was appended since `offset`), so
|
|
192
|
+
* the cost tracks what is returned rather than how large daemon.log grew.
|
|
199
193
|
* @param {Object} opts - { agent, lines, offset }
|
|
200
194
|
* @param {string} [opts.agent] - Filter by agent name
|
|
201
195
|
* @param {number} [opts.lines=100] - Number of lines to return
|
|
202
196
|
* @param {number} [opts.offset=0] - Byte offset for incremental reads (0 = from end)
|
|
197
|
+
* @returns {{ lines: string[], size: number, reset: boolean }} `size` is the
|
|
198
|
+
* offset for the next call; `reset` means the file shrank (cleared or
|
|
199
|
+
* replaced) and `lines` replace what the caller had.
|
|
203
200
|
*/
|
|
204
201
|
tailLogs(opts = {}) {
|
|
205
202
|
const { agent, lines = 100, offset = 0 } = opts;
|
|
203
|
+
const keep = agent
|
|
204
|
+
? (l) => l.includes(agent) || l.includes('daemon') || l.includes('Daemon')
|
|
205
|
+
: () => true;
|
|
206
|
+
let size;
|
|
206
207
|
try {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
208
|
+
size = fs.statSync(this.logFile).size;
|
|
209
|
+
} catch {
|
|
210
|
+
return { lines: [], size: 0, reset: offset > 0 };
|
|
211
|
+
}
|
|
212
|
+
// Nothing appended since the last read.
|
|
213
|
+
if (offset > 0 && offset === size) return { lines: [], size, reset: false };
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
const fd = fs.openSync(this.logFile, 'r');
|
|
217
|
+
try {
|
|
218
|
+
if (offset > 0 && offset < size) {
|
|
219
|
+
const start = Math.max(offset, size - MAX_INCREMENT_BYTES);
|
|
220
|
+
const buf = readRange(fd, start, size);
|
|
221
|
+
const lastNewline = buf.lastIndexOf(0x0a);
|
|
222
|
+
// A line still being written is left for the next read.
|
|
223
|
+
if (lastNewline < 0) {
|
|
224
|
+
return { lines: [], size: start > offset ? size : offset, reset: false };
|
|
225
|
+
}
|
|
226
|
+
let body = buf.subarray(0, lastNewline + 1);
|
|
227
|
+
// Skipped part of the backlog: drop the fragment the read landed in.
|
|
228
|
+
if (start > offset) body = body.subarray(body.indexOf(0x0a) + 1);
|
|
229
|
+
return { lines: splitLines(body, keep), size: start + lastNewline + 1, reset: false };
|
|
218
230
|
}
|
|
219
|
-
|
|
231
|
+
// First read, or the file shrank under us (cleared, replaced).
|
|
232
|
+
const tail = readLastLines(fd, size, lines, keep);
|
|
233
|
+
return { lines: tail.lines, size: tail.end, reset: offset > 0 };
|
|
234
|
+
} finally {
|
|
235
|
+
fs.closeSync(fd);
|
|
220
236
|
}
|
|
221
|
-
// Full read, return last N
|
|
222
|
-
return { lines: this.getLogs(agent, lines), size: stat.size };
|
|
223
237
|
} catch {
|
|
224
|
-
return { lines: [], size: 0 };
|
|
238
|
+
return { lines: [], size: 0, reset: offset > 0 };
|
|
225
239
|
}
|
|
226
240
|
}
|
|
227
241
|
|
|
@@ -255,9 +269,18 @@ class Config {
|
|
|
255
269
|
const { keptLines, removed } = filterLogsByTimeRange(allLines, start, end);
|
|
256
270
|
|
|
257
271
|
const nextContent = keptLines.join('\n') + (hasTrailingNewline && keptLines.length > 0 ? '\n' : '');
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
272
|
+
// Rewrite in place, not write-temp + rename: the running daemon's stdout
|
|
273
|
+
// and stderr are append-mode handles to daemon.log. A rename leaves them
|
|
274
|
+
// writing into the replaced file (and fails outright on Windows while they
|
|
275
|
+
// are open); truncating in place keeps them appending to daemon.log.
|
|
276
|
+
const nextBytes = Buffer.from(nextContent, 'utf-8');
|
|
277
|
+
const fd = fs.openSync(this.logFile, 'r+');
|
|
278
|
+
try {
|
|
279
|
+
if (nextBytes.length > 0) fs.writeSync(fd, nextBytes, 0, nextBytes.length, 0);
|
|
280
|
+
fs.ftruncateSync(fd, nextBytes.length);
|
|
281
|
+
} finally {
|
|
282
|
+
fs.closeSync(fd);
|
|
283
|
+
}
|
|
261
284
|
|
|
262
285
|
return { removed, remaining: keptLines.length };
|
|
263
286
|
}
|
|
@@ -323,6 +346,62 @@ function parseYaml(text) {
|
|
|
323
346
|
return result;
|
|
324
347
|
}
|
|
325
348
|
|
|
349
|
+
/** Bytes read per step when walking back from the end of the log. */
|
|
350
|
+
const TAIL_CHUNK = 64 * 1024;
|
|
351
|
+
/** How far back a first read may walk looking for lines matching the agent filter. */
|
|
352
|
+
const MAX_SCAN_BYTES = 16 * 1024 * 1024;
|
|
353
|
+
/** The most an incremental read takes in one go; older backlog is skipped. */
|
|
354
|
+
const MAX_INCREMENT_BYTES = 4 * 1024 * 1024;
|
|
355
|
+
|
|
356
|
+
function readRange(fd, start, end) {
|
|
357
|
+
const buf = Buffer.alloc(end - start);
|
|
358
|
+
let read = 0;
|
|
359
|
+
while (read < buf.length) {
|
|
360
|
+
const n = fs.readSync(fd, buf, read, buf.length - read, start + read);
|
|
361
|
+
if (n === 0) break;
|
|
362
|
+
read += n;
|
|
363
|
+
}
|
|
364
|
+
return read === buf.length ? buf : buf.subarray(0, read);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function splitLines(buf, keep) {
|
|
368
|
+
return buf.toString('utf-8').split('\n').filter((l) => l && keep(l));
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* The last `count` matching lines, read backwards in chunks. Stops at the last
|
|
373
|
+
* newline: a line still being written is left for the next incremental read.
|
|
374
|
+
*/
|
|
375
|
+
function readLastLines(fd, size, count, keep) {
|
|
376
|
+
const lastChunk = readRange(fd, Math.max(0, size - TAIL_CHUNK), size);
|
|
377
|
+
const lastNewline = lastChunk.lastIndexOf(0x0a);
|
|
378
|
+
const end = lastNewline >= 0 ? size - lastChunk.length + lastNewline + 1 : size;
|
|
379
|
+
|
|
380
|
+
let lines = [];
|
|
381
|
+
let pos = end;
|
|
382
|
+
let carry = Buffer.alloc(0);
|
|
383
|
+
while (pos > 0 && lines.length < count && end - pos < MAX_SCAN_BYTES) {
|
|
384
|
+
const start = Math.max(0, pos - TAIL_CHUNK);
|
|
385
|
+
const block = Buffer.concat([readRange(fd, start, pos), carry]);
|
|
386
|
+
pos = start;
|
|
387
|
+
let body = block;
|
|
388
|
+
if (pos > 0) {
|
|
389
|
+
// The block starts mid-line; that fragment joins the next block.
|
|
390
|
+
const firstNewline = block.indexOf(0x0a);
|
|
391
|
+
if (firstNewline < 0) {
|
|
392
|
+
carry = block;
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
carry = block.subarray(0, firstNewline);
|
|
396
|
+
body = block.subarray(firstNewline + 1);
|
|
397
|
+
} else {
|
|
398
|
+
carry = Buffer.alloc(0);
|
|
399
|
+
}
|
|
400
|
+
lines = [...splitLines(body, keep), ...lines];
|
|
401
|
+
}
|
|
402
|
+
return { lines: lines.slice(-count), end };
|
|
403
|
+
}
|
|
404
|
+
|
|
326
405
|
function normalizeTimeValue(value) {
|
|
327
406
|
if (value instanceof Date) {
|
|
328
407
|
return Number.isNaN(value.getTime()) ? null : value;
|