@nonbot/cli 0.9.12 → 0.10.0
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/CHANGELOG.md +247 -1
- package/dist/commands/choir.js +3 -0
- package/dist/commands/daemon.js +377 -9
- package/dist/commands/logs.js +6 -1
- package/dist/index.js +1 -1
- package/dist/lib/activations.js +46 -6
- package/dist/lib/activity-log.js +6 -0
- package/dist/lib/choir/hub.js +116 -18
- package/dist/lib/cloud-repo.js +306 -0
- package/dist/lib/command-builders.js +32 -1
- package/dist/lib/completion.js +125 -1
- package/dist/lib/daemon-lifecycle.js +44 -0
- package/dist/lib/exit-transcript.js +74 -0
- package/dist/lib/machine.js +7 -0
- package/dist/lib/payload-validator.js +51 -0
- package/dist/lib/run-prompt.js +38 -17
- package/dist/lib/snapshot.js +14 -12
- package/dist/lib/terminal.js +10 -4
- package/dist/lib/update-check.js +99 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/commands/daemon.js
CHANGED
|
@@ -1,24 +1,40 @@
|
|
|
1
1
|
import { spawnSync as nodeSpawnSync } from 'node:child_process';
|
|
2
2
|
import { loadAuth, getActiveProfile } from '../lib/auth.js';
|
|
3
3
|
import * as activations from '../lib/activations.js';
|
|
4
|
-
import { fireActivation, makeHeadlessSpawner, } from '../lib/activations.js';
|
|
5
|
-
import { checkCompletions } from '../lib/completion.js';
|
|
4
|
+
import { fireActivation, makeHeadlessSpawner, spawnTerminalDefault, } from '../lib/activations.js';
|
|
5
|
+
import { checkCompletions, readExitCodeFile, removeExitFile, } from '../lib/completion.js';
|
|
6
6
|
import { evictOldestToCap } from '../lib/bounded-set.js';
|
|
7
|
-
import { deliverAnsweredPrompts, capturePane, parsePromptMenu, mintPromptId, reportPrompt, } from '../lib/run-prompt.js';
|
|
7
|
+
import { deliverAnsweredPrompts, capturePane, normalizeAnsweredPrompts, parsePromptMenu, mintPromptId, reportPrompt, } from '../lib/run-prompt.js';
|
|
8
8
|
import { serveSnapshotRequests } from '../lib/snapshot.js';
|
|
9
|
+
import { createUpdateChecker } from '../lib/update-check.js';
|
|
10
|
+
import { refreshPaneTails, flushExitTranscripts, exitTranscriptDisabled, } from '../lib/exit-transcript.js';
|
|
11
|
+
import { validateActivationId } from '../lib/payload-validator.js';
|
|
12
|
+
import { resolveRunTimeoutMinutes, wallClockExceededReason, } from '../lib/command-builders.js';
|
|
13
|
+
import { prepareCloneWorkspace as defaultPrepareCloneWorkspace, pushRunBranch as defaultPushRunBranch, reapStaleWorkspaces as defaultReapStaleWorkspaces, removeCloudWorkspace as defaultRemoveCloudWorkspace, shortCloneFailureReason, touchWorkspaceLiveness as defaultTouchWorkspaceLiveness, } from '../lib/cloud-repo.js';
|
|
9
14
|
import { loadOrCreateMachineId, resolveMachineName, shortMachineId } from '../lib/machine.js';
|
|
10
15
|
import { emitRunStage as defaultEmitRunStage, startRunHeartbeat as defaultStartRunHeartbeat, RUN_STAGE, } from '../lib/choir/run-progress.js';
|
|
11
16
|
import { groupBySession, launchCoordinatedSet, setIsIsolated, } from '../lib/choir/coordinated-set.js';
|
|
12
17
|
import { IsolatedSessionTracker, reconcileAndCleanup as defaultReconcileAndCleanup, resolveBaseBranch as defaultResolveBaseBranch, } from '../lib/choir/isolated-session.js';
|
|
13
18
|
import { applyPaneTitle, applyPaneState } from '../lib/pane-title.js';
|
|
14
19
|
import { installService, uninstallService } from '../lib/service.js';
|
|
15
|
-
import { probeTmuxAttached, postDaemonStopAck, requestRemoteDaemonStop, stopDaemonLocally, writePidFile, removePidFile, } from '../lib/daemon-lifecycle.js';
|
|
20
|
+
import { probeTmuxAttached, postDaemonStopAck, requestRemoteDaemonStop, stopDaemonLocally, attachDaemonSession, writePidFile, removePidFile, shutdownPredatesDaemon, } from '../lib/daemon-lifecycle.js';
|
|
16
21
|
import { detectTmuxSession, inTmuxSession, nonbotTmuxOptOut, resolveTerminal, } from '../lib/terminal.js';
|
|
17
22
|
import { VERSION } from '../version.js';
|
|
18
23
|
import { errorBlock, statusRow, daemonOpener, daemonCloser, activationCard, runSummary, formatElapsed, liveFooter, needsYouBanner, resumedLine, buildPaneBorderFormat, buildTmuxStatusLeft, buildTmuxStatusRight, WORDMARK_WIDTH, c, } from '../lib/output.js';
|
|
19
24
|
export const POLL_FAST_MS = 2000;
|
|
20
25
|
export const POLL_MAX_MS = 30000;
|
|
21
26
|
export const POLL_INTERVAL_MS = POLL_FAST_MS;
|
|
27
|
+
export const MAX_CONCURRENT_RUNS_DEFAULT = 4;
|
|
28
|
+
export function resolveMaxConcurrentRuns(env = process.env) {
|
|
29
|
+
const raw = env.NONBOT_MAX_CONCURRENT_RUNS;
|
|
30
|
+
if (typeof raw !== 'string' || raw.trim() === '')
|
|
31
|
+
return MAX_CONCURRENT_RUNS_DEFAULT;
|
|
32
|
+
const n = Number(raw.trim());
|
|
33
|
+
if (!Number.isInteger(n) || n < 1)
|
|
34
|
+
return MAX_CONCURRENT_RUNS_DEFAULT;
|
|
35
|
+
return n;
|
|
36
|
+
}
|
|
37
|
+
export const CONCURRENCY_DEFERRED_REASON = 'queued: concurrency limit';
|
|
22
38
|
const TERMINAL_KIND_BY_PROFILE_ID = {
|
|
23
39
|
'terminal': 'terminal.app',
|
|
24
40
|
'iterm': 'iterm',
|
|
@@ -66,6 +82,41 @@ export function sanitizeFailureReason(reason) {
|
|
|
66
82
|
return null;
|
|
67
83
|
return cleaned.slice(0, LAST_FAILURE_REASON_MAX);
|
|
68
84
|
}
|
|
85
|
+
async function readErrorCode(res) {
|
|
86
|
+
try {
|
|
87
|
+
if (typeof res.json !== 'function')
|
|
88
|
+
return '';
|
|
89
|
+
const body = (await res.json());
|
|
90
|
+
if (!body || typeof body !== 'object')
|
|
91
|
+
return '';
|
|
92
|
+
const code = body.code;
|
|
93
|
+
return typeof code === 'string' ? code.slice(0, 64) : '';
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return '';
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
export function describeRegistrationRejection(status, code) {
|
|
100
|
+
if (status === 409 && code === 'machine_in_use') {
|
|
101
|
+
return 'machine id already in use — set a distinct NONBOT_MACHINE_ID';
|
|
102
|
+
}
|
|
103
|
+
if (status === 400 && code === 'machine_id_mismatch') {
|
|
104
|
+
return 'machine id header rejected — upgrade @nonbot/cli on this runner';
|
|
105
|
+
}
|
|
106
|
+
if (status === 400 && code === 'origin_too_broad') {
|
|
107
|
+
return 'allowed repos too broad — scope NONBOT_ALLOWED_REPO_ORIGINS to an owner or repo';
|
|
108
|
+
}
|
|
109
|
+
return `HTTP ${status}`;
|
|
110
|
+
}
|
|
111
|
+
export function isRepoLessProbe(act) {
|
|
112
|
+
if (act.kind !== 'diagnostic')
|
|
113
|
+
return false;
|
|
114
|
+
const payload = act.payload;
|
|
115
|
+
if (!payload || typeof payload !== 'object')
|
|
116
|
+
return false;
|
|
117
|
+
const template = payload.template;
|
|
118
|
+
return template === 'diagnostic' || template === 'provider-test';
|
|
119
|
+
}
|
|
69
120
|
export function applyNonbotTmuxConfig(deps = {}) {
|
|
70
121
|
const spawnSync = deps.spawnSync ?? nodeSpawnSync;
|
|
71
122
|
const stdoutWrite = deps.stdoutWrite ?? ((s) => process.stdout.write(s));
|
|
@@ -182,6 +233,15 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
|
|
|
182
233
|
spawnImpl: deps.spawnSync,
|
|
183
234
|
});
|
|
184
235
|
}
|
|
236
|
+
if (args[0] === 'attach') {
|
|
237
|
+
const sessionFlag = args.indexOf('--session');
|
|
238
|
+
const sessionName = sessionFlag >= 0 ? args[sessionFlag + 1] : undefined;
|
|
239
|
+
return attachDaemonSession({
|
|
240
|
+
log: rawLog, errLog,
|
|
241
|
+
spawnImpl: deps.spawnSync,
|
|
242
|
+
...(sessionName ? { sessionName } : {}),
|
|
243
|
+
});
|
|
244
|
+
}
|
|
185
245
|
if (args.includes('--install')) {
|
|
186
246
|
const install = deps.installService ?? installService;
|
|
187
247
|
const res = await install();
|
|
@@ -211,11 +271,15 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
|
|
|
211
271
|
deps.spawnTerminal = makeHeadlessSpawner({ wait: false, log: rawLog, errLog });
|
|
212
272
|
}
|
|
213
273
|
}
|
|
274
|
+
const cloudMode = args.includes('--cloud');
|
|
214
275
|
const auth = await loader();
|
|
215
276
|
if (!auth) {
|
|
216
277
|
errLog(errorBlock('Not logged in', 'Run `nonbot login` to authenticate.', { stream: process.stderr }));
|
|
217
278
|
return 1;
|
|
218
279
|
}
|
|
280
|
+
if (cloudMode && !deps.spawnTerminal) {
|
|
281
|
+
deps.spawnTerminal = (a) => spawnTerminalDefault(a, auth, { cloud: true });
|
|
282
|
+
}
|
|
219
283
|
const detectTmux = deps.detectTmuxSession ?? detectTmuxSession;
|
|
220
284
|
const tmuxSessionName = detectTmux();
|
|
221
285
|
const machineId = deps.machineId ?? loadOrCreateMachineId();
|
|
@@ -244,6 +308,26 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
|
|
|
244
308
|
const reconcileBuildCommand = deps.reconcileBuildCommand ?? process.env.NONBOT_RECONCILE_BUILD_CMD ?? '';
|
|
245
309
|
const trackedPanes = new Map();
|
|
246
310
|
const killedByStop = new Set();
|
|
311
|
+
const cloudWorkspaces = deps.cloudWorkspaces ?? new Map();
|
|
312
|
+
const prepareCloneWorkspaceFn = deps.prepareCloneWorkspace ?? defaultPrepareCloneWorkspace;
|
|
313
|
+
const pushRunBranchFn = deps.pushRunBranch ?? defaultPushRunBranch;
|
|
314
|
+
const reapStaleWorkspacesFn = deps.reapStaleWorkspaces ?? defaultReapStaleWorkspaces;
|
|
315
|
+
const removeCloudWorkspaceFn = deps.removeCloudWorkspace ?? defaultRemoveCloudWorkspace;
|
|
316
|
+
const removeExitFileFn = deps.removeExitFile ?? removeExitFile;
|
|
317
|
+
const touchWorkspaceLivenessFn = deps.touchWorkspaceLiveness ?? defaultTouchWorkspaceLiveness;
|
|
318
|
+
const releaseCloudWorkspace = (activationId) => {
|
|
319
|
+
const ws = cloudWorkspaces.get(activationId);
|
|
320
|
+
cloudWorkspaces.delete(activationId);
|
|
321
|
+
if (!cloudMode)
|
|
322
|
+
return;
|
|
323
|
+
try {
|
|
324
|
+
if (ws)
|
|
325
|
+
removeCloudWorkspaceFn(ws.repoPath);
|
|
326
|
+
removeExitFileFn(activationId);
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
}
|
|
330
|
+
};
|
|
247
331
|
const injectedPrompts = new Set();
|
|
248
332
|
const servedSnapshots = new Set();
|
|
249
333
|
const reportedPrompts = new Set();
|
|
@@ -485,7 +569,184 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
|
|
|
485
569
|
}) + '\n');
|
|
486
570
|
log('\n');
|
|
487
571
|
pushTmuxStatus();
|
|
572
|
+
const cloudEnv = deps.env ?? process.env;
|
|
573
|
+
const allowedRepoOrigins = (cloudEnv.NONBOT_ALLOWED_REPO_ORIGINS ?? '')
|
|
574
|
+
.split(',')
|
|
575
|
+
.map((s) => s.trim())
|
|
576
|
+
.filter(Boolean);
|
|
577
|
+
const maxConcurrentRuns = deps.maxConcurrentRuns ?? resolveMaxConcurrentRuns(cloudEnv);
|
|
578
|
+
const runTimeoutMinutes = deps.runTimeoutMinutes ?? resolveRunTimeoutMinutes(cloudEnv);
|
|
579
|
+
const runTimeoutMs = runTimeoutMinutes * 60_000;
|
|
580
|
+
const nowMs = deps.now ?? Date.now;
|
|
581
|
+
const wallClockReason = wallClockExceededReason(runTimeoutMinutes);
|
|
582
|
+
const executeKillsFn = deps.executePendingKills ?? activations.executePendingKills;
|
|
583
|
+
const daemonStartedAt = nowMs();
|
|
584
|
+
let staleShutdownNoted = false;
|
|
585
|
+
const exitTranscriptsEnabled = !exitTranscriptDisabled(cloudEnv);
|
|
586
|
+
const paneTails = new Map();
|
|
587
|
+
const flushTranscripts = async (statusFor, opts = {}) => {
|
|
588
|
+
if (paneTails.size === 0)
|
|
589
|
+
return;
|
|
590
|
+
try {
|
|
591
|
+
await flushExitTranscripts({
|
|
592
|
+
tracked: opts.all ? new Map() : trackedPanes,
|
|
593
|
+
tails: paneTails,
|
|
594
|
+
statusFor,
|
|
595
|
+
profile: profileName,
|
|
596
|
+
now: nowMs,
|
|
597
|
+
log,
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
catch {
|
|
601
|
+
}
|
|
602
|
+
};
|
|
603
|
+
const updateChecker = createUpdateChecker({
|
|
604
|
+
env: cloudEnv,
|
|
605
|
+
fetchImpl: deps.updateCheckFetch === undefined
|
|
606
|
+
? (process.env.VITEST ? null : fetch)
|
|
607
|
+
: deps.updateCheckFetch,
|
|
608
|
+
now: nowMs,
|
|
609
|
+
log: (line) => log(c.amber(line) + '\n'),
|
|
610
|
+
});
|
|
611
|
+
void updateChecker.maybeCheck();
|
|
612
|
+
const postActivationFailure = async (activationId, failureReason) => {
|
|
613
|
+
try {
|
|
614
|
+
validateActivationId(activationId);
|
|
615
|
+
}
|
|
616
|
+
catch {
|
|
617
|
+
errLog(statusRow('⚠', 'failure report skipped', 'malformed activation id', {
|
|
618
|
+
stream: process.stderr,
|
|
619
|
+
}) + '\n');
|
|
620
|
+
return false;
|
|
621
|
+
}
|
|
622
|
+
try {
|
|
623
|
+
const res = await fetchImpl(`${auth.baseUrl}/api/portfolio/activations/${activationId}`, {
|
|
624
|
+
method: 'PATCH',
|
|
625
|
+
headers: {
|
|
626
|
+
Authorization: `Bearer ${auth.pat}`,
|
|
627
|
+
'Content-Type': 'application/json',
|
|
628
|
+
'X-Requested-With': 'ConradPM-Native',
|
|
629
|
+
},
|
|
630
|
+
body: JSON.stringify({ status: 'failed', failureReason: failureReason.slice(0, 200) }),
|
|
631
|
+
});
|
|
632
|
+
return res.ok || res.status === 409;
|
|
633
|
+
}
|
|
634
|
+
catch {
|
|
635
|
+
return false;
|
|
636
|
+
}
|
|
637
|
+
};
|
|
638
|
+
if (cloudMode) {
|
|
639
|
+
try {
|
|
640
|
+
const reaped = reapStaleWorkspacesFn({ activeIds: cloudWorkspaces.keys() });
|
|
641
|
+
if (reaped > 0) {
|
|
642
|
+
log(statusRow('✓', 'stale workspaces reaped', `${reaped} older than 24h`) + '\n');
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
catch {
|
|
646
|
+
}
|
|
647
|
+
try {
|
|
648
|
+
const res = await fetchImpl(`${auth.baseUrl}/api/cli/cloud-runners`, {
|
|
649
|
+
method: 'POST',
|
|
650
|
+
headers: {
|
|
651
|
+
Authorization: `Bearer ${auth.pat}`,
|
|
652
|
+
'Content-Type': 'application/json',
|
|
653
|
+
'X-Requested-With': 'ConradPM-Native',
|
|
654
|
+
'X-Machine-Id': machineId,
|
|
655
|
+
},
|
|
656
|
+
body: JSON.stringify({ machineId, kind: 'vps', allowedRepoOrigins }),
|
|
657
|
+
});
|
|
658
|
+
if (res.ok) {
|
|
659
|
+
log(statusRow('✓', 'cloud runner registration', shortMachineId(machineId)) + '\n');
|
|
660
|
+
}
|
|
661
|
+
else {
|
|
662
|
+
log(statusRow('⚠', 'cloud registration rejected', describeRegistrationRejection(res.status, await readErrorCode(res))) + '\n');
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
catch {
|
|
666
|
+
log(statusRow('⚠', 'cloud runner registration failed', 'continuing — server may be unreachable') + '\n');
|
|
667
|
+
}
|
|
668
|
+
try {
|
|
669
|
+
const spawnSyncImpl = deps.spawnSync ?? nodeSpawnSync;
|
|
670
|
+
spawnSyncImpl('tmux', ['set-environment', '-g', '-r', 'NONBOT_GIT_TOKEN'], {
|
|
671
|
+
timeout: 2000,
|
|
672
|
+
windowsHide: true,
|
|
673
|
+
});
|
|
674
|
+
log(statusRow('✓', 'tmux global env scrubbed', 'NONBOT_GIT_TOKEN') + '\n');
|
|
675
|
+
}
|
|
676
|
+
catch {
|
|
677
|
+
log(statusRow('⚠', 'tmux env scrub skipped', 'no tmux server reachable') + '\n');
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
const sweepLocalGuardrails = async () => {
|
|
681
|
+
if (cloudMode && cloudWorkspaces.size > 0) {
|
|
682
|
+
for (const ws of cloudWorkspaces.values()) {
|
|
683
|
+
try {
|
|
684
|
+
touchWorkspaceLivenessFn(ws.repoPath);
|
|
685
|
+
}
|
|
686
|
+
catch {
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
try {
|
|
691
|
+
const deadline = nowMs() - runTimeoutMs;
|
|
692
|
+
const overdue = [];
|
|
693
|
+
for (const [id, paneId] of trackedPanes) {
|
|
694
|
+
if (killedByStop.has(id))
|
|
695
|
+
continue;
|
|
696
|
+
const meta = trackedMeta.get(id);
|
|
697
|
+
if (!meta || meta.startedAt > deadline)
|
|
698
|
+
continue;
|
|
699
|
+
overdue.push({ activationId: id, tmuxPaneId: paneId });
|
|
700
|
+
}
|
|
701
|
+
for (const k of overdue) {
|
|
702
|
+
const meta = trackedMeta.get(k.activationId);
|
|
703
|
+
log(activationCard({
|
|
704
|
+
marker: '■',
|
|
705
|
+
color: 'amber',
|
|
706
|
+
id: k.activationId,
|
|
707
|
+
headerSuffix: 'WALL CLOCK',
|
|
708
|
+
kv: [
|
|
709
|
+
['STORY', meta?.story ?? 'unknown'],
|
|
710
|
+
['ELAPSED', meta ? formatElapsed(nowMs() - meta.startedAt) : 'unknown'],
|
|
711
|
+
['PANE', `${k.tmuxPaneId} · ^C -> 2s grace -> kill-pane`],
|
|
712
|
+
['REASON', wallClockReason],
|
|
713
|
+
],
|
|
714
|
+
}) + '\n');
|
|
715
|
+
if (cloudMode) {
|
|
716
|
+
const ws = cloudWorkspaces.get(k.activationId);
|
|
717
|
+
if (ws) {
|
|
718
|
+
try {
|
|
719
|
+
const push = pushRunBranchFn({
|
|
720
|
+
repoPath: ws.repoPath,
|
|
721
|
+
branch: ws.branch,
|
|
722
|
+
activationId: k.activationId,
|
|
723
|
+
spawnImpl: deps.spawnSync,
|
|
724
|
+
});
|
|
725
|
+
log(statusRow(push.pushed ? '✓' : '⚠', push.pushed ? 'run branch pushed' : 'run branch not pushed', ws.branch) + '\n');
|
|
726
|
+
}
|
|
727
|
+
catch {
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
killedByStop.add(k.activationId);
|
|
732
|
+
retitlePane('stopping', k.tmuxPaneId, meta?.story ?? '');
|
|
733
|
+
safeEmit(k.activationId, RUN_STAGE.FAILED);
|
|
734
|
+
stopHeartbeat(k.activationId, 'wall-clock');
|
|
735
|
+
failedCount++;
|
|
736
|
+
trackedMeta.delete(k.activationId);
|
|
737
|
+
await postActivationFailure(k.activationId, wallClockReason);
|
|
738
|
+
}
|
|
739
|
+
if (overdue.length > 0) {
|
|
740
|
+
void executeKillsFn(overdue, auth.baseUrl, auth.pat);
|
|
741
|
+
lastFailureReason = wallClockReason;
|
|
742
|
+
emitSummary();
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
catch {
|
|
746
|
+
}
|
|
747
|
+
};
|
|
488
748
|
while (running) {
|
|
749
|
+
void updateChecker.maybeCheck();
|
|
489
750
|
let firedThisPoll = false;
|
|
490
751
|
try {
|
|
491
752
|
const headers = {
|
|
@@ -525,6 +786,9 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
|
|
|
525
786
|
if (res.ok) {
|
|
526
787
|
lastPollOk = true;
|
|
527
788
|
const body = (await res.json());
|
|
789
|
+
const wireAnsweredPrompts = Array.isArray(body?.answeredPrompts)
|
|
790
|
+
? normalizeAnsweredPrompts(body.answeredPrompts)
|
|
791
|
+
: undefined;
|
|
528
792
|
const pendingKills = body?.pendingKills ?? [];
|
|
529
793
|
if (pendingKills.length > 0) {
|
|
530
794
|
for (const k of pendingKills) {
|
|
@@ -546,11 +810,19 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
|
|
|
546
810
|
safeEmit(k.activationId, RUN_STAGE.STOPPED);
|
|
547
811
|
stopHeartbeat(k.activationId);
|
|
548
812
|
}
|
|
549
|
-
void
|
|
813
|
+
void executeKillsFn(pendingKills, auth.baseUrl, auth.pat);
|
|
550
814
|
}
|
|
551
|
-
|
|
815
|
+
const shutdownSignal = body?.shutdown?.requested === true ? body.shutdown : null;
|
|
816
|
+
if (shutdownSignal && shutdownPredatesDaemon(shutdownSignal, daemonStartedAt)) {
|
|
817
|
+
if (!staleShutdownNoted) {
|
|
818
|
+
staleShutdownNoted = true;
|
|
819
|
+
const scopeLabel = shutdownSignal.scope === 'all' ? 'all machines' : 'this machine';
|
|
820
|
+
log(statusRow('ℹ', 'stale stop signal ignored', `issued before this daemon started · ${scopeLabel}`) + '\n');
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
else if (shutdownSignal) {
|
|
552
824
|
running = false;
|
|
553
|
-
const scopeLabel =
|
|
825
|
+
const scopeLabel = shutdownSignal.scope === 'all' ? 'all machines' : 'this machine';
|
|
554
826
|
log('\n' + statusRow('⚠', 'daemon stop received', `remote stop · ${scopeLabel}`) + '\n');
|
|
555
827
|
const kills = [...trackedPanes.entries()]
|
|
556
828
|
.filter(([id]) => !killedByStop.has(id))
|
|
@@ -564,12 +836,13 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
|
|
|
564
836
|
if (kills.length > 0) {
|
|
565
837
|
log(statusRow('⚠', `stopping ${kills.length} run${kills.length === 1 ? '' : 's'}`, '^C -> 2s grace -> kill-pane') + '\n');
|
|
566
838
|
try {
|
|
567
|
-
await
|
|
839
|
+
await executeKillsFn(kills, auth.baseUrl, auth.pat);
|
|
568
840
|
}
|
|
569
841
|
catch { }
|
|
570
842
|
}
|
|
571
843
|
for (const id of [...runHeartbeats.keys()])
|
|
572
844
|
stopHeartbeat(id);
|
|
845
|
+
await flushTranscripts(() => 'stopped', { all: true });
|
|
573
846
|
await postDaemonStopAck({ baseUrl: auth.baseUrl, pat: auth.pat, machineId, fetchImpl });
|
|
574
847
|
pushTmuxStatus(0, true);
|
|
575
848
|
removePidFile();
|
|
@@ -688,14 +961,60 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
|
|
|
688
961
|
emitSummary();
|
|
689
962
|
}
|
|
690
963
|
}
|
|
964
|
+
let deferredThisPoll = 0;
|
|
691
965
|
for (const act of singletons) {
|
|
692
966
|
if (!act?.id || seen.has(act.id))
|
|
693
967
|
continue;
|
|
968
|
+
if (trackedPanes.size >= maxConcurrentRuns) {
|
|
969
|
+
deferredThisPoll = singletons.filter((a) => a?.id && !seen.has(a.id)).length;
|
|
970
|
+
break;
|
|
971
|
+
}
|
|
694
972
|
seen.add(act.id);
|
|
695
973
|
firedThisPoll = true;
|
|
696
974
|
const metrics = act.kind === 'real' ? seqMetrics(act) : undefined;
|
|
697
975
|
if (act.kind === 'real')
|
|
698
976
|
safeEmit(act.id, RUN_STAGE.LAUNCHING, metrics);
|
|
977
|
+
if (cloudMode && !isRepoLessProbe(act)) {
|
|
978
|
+
if (!act.repoUrl) {
|
|
979
|
+
errLog(statusRow('⚠', `${act.id} · cloud workspace refused`, 'no clone url', {
|
|
980
|
+
stream: process.stderr,
|
|
981
|
+
}) + '\n');
|
|
982
|
+
failedCount++;
|
|
983
|
+
lastFailureReason = 'cloud runner requires a clone url';
|
|
984
|
+
if (act.kind === 'real')
|
|
985
|
+
safeEmit(act.id, RUN_STAGE.FAILED, metrics);
|
|
986
|
+
await postActivationFailure(act.id, 'cloud runner requires a clone url');
|
|
987
|
+
emitSummary();
|
|
988
|
+
continue;
|
|
989
|
+
}
|
|
990
|
+
try {
|
|
991
|
+
const ws = await prepareCloneWorkspaceFn({
|
|
992
|
+
repoUrl: act.repoUrl,
|
|
993
|
+
repoRef: act.repoRef ?? null,
|
|
994
|
+
activationId: act.id,
|
|
995
|
+
allowedOrigins: allowedRepoOrigins,
|
|
996
|
+
});
|
|
997
|
+
act.repoPath = ws.repoPath;
|
|
998
|
+
if (act.payload && typeof act.payload === 'object') {
|
|
999
|
+
;
|
|
1000
|
+
act.payload.repoPath = ws.repoPath;
|
|
1001
|
+
}
|
|
1002
|
+
cloudWorkspaces.set(act.id, ws);
|
|
1003
|
+
}
|
|
1004
|
+
catch (e) {
|
|
1005
|
+
const reason = shortCloneFailureReason(e);
|
|
1006
|
+
errLog(statusRow('⚠', `${act.id} · cloud workspace failed`, reason, {
|
|
1007
|
+
stream: process.stderr,
|
|
1008
|
+
}) + '\n');
|
|
1009
|
+
failedCount++;
|
|
1010
|
+
lastFailureReason = sanitizeFailureReason(reason) ?? 'clone failed';
|
|
1011
|
+
if (act.kind === 'real')
|
|
1012
|
+
safeEmit(act.id, RUN_STAGE.FAILED, metrics);
|
|
1013
|
+
await postActivationFailure(act.id, `clone failed: ${reason}`);
|
|
1014
|
+
emitSummary();
|
|
1015
|
+
continue;
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
699
1018
|
const outcome = await fireActivation(auth, act, deps, log, errLog);
|
|
700
1019
|
if (outcome.status === 'launched') {
|
|
701
1020
|
lastFailureReason = null;
|
|
@@ -742,6 +1061,10 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
|
|
|
742
1061
|
emitSummary();
|
|
743
1062
|
}
|
|
744
1063
|
}
|
|
1064
|
+
if (deferredThisPoll > 0) {
|
|
1065
|
+
log(statusRow('⚠', CONCURRENCY_DEFERRED_REASON, `${deferredThisPoll} waiting · ${trackedPanes.size}/${maxConcurrentRuns} running`) + '\n');
|
|
1066
|
+
lastFailureReason = CONCURRENCY_DEFERRED_REASON;
|
|
1067
|
+
}
|
|
745
1068
|
try {
|
|
746
1069
|
await deliverAnsweredPrompts({
|
|
747
1070
|
baseUrl: auth.baseUrl,
|
|
@@ -749,6 +1072,7 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
|
|
|
749
1072
|
injected: injectedPrompts,
|
|
750
1073
|
trackedPanes,
|
|
751
1074
|
machineId,
|
|
1075
|
+
...(wireAnsweredPrompts ? { prompts: wireAnsweredPrompts } : {}),
|
|
752
1076
|
fetchImpl,
|
|
753
1077
|
spawnImpl: deps.spawnSync,
|
|
754
1078
|
log,
|
|
@@ -761,6 +1085,14 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
|
|
|
761
1085
|
}
|
|
762
1086
|
catch {
|
|
763
1087
|
}
|
|
1088
|
+
if (exitTranscriptsEnabled && trackedPanes.size > 0) {
|
|
1089
|
+
try {
|
|
1090
|
+
refreshPaneTails({ tracked: trackedPanes, tails: paneTails, spawnImpl: spawnForTmux, now: nowMs });
|
|
1091
|
+
}
|
|
1092
|
+
catch {
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
const stoppedBeforeSweep = new Set(killedByStop);
|
|
764
1096
|
if (trackedPanes.size > 0) {
|
|
765
1097
|
const reported = await checkCompletions({
|
|
766
1098
|
tracked: trackedPanes,
|
|
@@ -788,12 +1120,39 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
|
|
|
788
1120
|
kv,
|
|
789
1121
|
}) + '\n';
|
|
790
1122
|
},
|
|
1123
|
+
readExitCode: cloudMode ? undefined : (id) => readExitCodeFile(id),
|
|
1124
|
+
cloud: cloudMode
|
|
1125
|
+
? {
|
|
1126
|
+
readExitCode: (id) => readExitCodeFile(id),
|
|
1127
|
+
pushWorkspace: (id) => {
|
|
1128
|
+
const ws = cloudWorkspaces.get(id);
|
|
1129
|
+
if (!ws)
|
|
1130
|
+
return { pushed: false, reason: 'no workspace', kind: 'no-workspace' };
|
|
1131
|
+
const result = pushRunBranchFn({
|
|
1132
|
+
repoPath: ws.repoPath,
|
|
1133
|
+
branch: ws.branch,
|
|
1134
|
+
activationId: id,
|
|
1135
|
+
spawnImpl: deps.spawnSync,
|
|
1136
|
+
});
|
|
1137
|
+
return {
|
|
1138
|
+
...result,
|
|
1139
|
+
branch: ws.branch,
|
|
1140
|
+
...(result.pushed ? {} : { kind: 'push-failed' }),
|
|
1141
|
+
};
|
|
1142
|
+
},
|
|
1143
|
+
forgetWorkspace: (id) => {
|
|
1144
|
+
releaseCloudWorkspace(id);
|
|
1145
|
+
},
|
|
1146
|
+
}
|
|
1147
|
+
: undefined,
|
|
791
1148
|
});
|
|
792
1149
|
if (reported.length > 0) {
|
|
793
1150
|
for (const id of reported) {
|
|
794
1151
|
doneCount++;
|
|
795
1152
|
trackedMeta.delete(id);
|
|
796
|
-
|
|
1153
|
+
releaseCloudWorkspace(id);
|
|
1154
|
+
const outcome = reported.outcomes?.get(id);
|
|
1155
|
+
safeEmit(id, outcome === 'failed' ? RUN_STAGE.FAILED : RUN_STAGE.FINISHED);
|
|
797
1156
|
stopHeartbeat(id, 'finished');
|
|
798
1157
|
try {
|
|
799
1158
|
const session = isolatedSessions.noteTerminal(id);
|
|
@@ -812,6 +1171,14 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
|
|
|
812
1171
|
}
|
|
813
1172
|
emitSummary();
|
|
814
1173
|
}
|
|
1174
|
+
await flushTranscripts((id) => {
|
|
1175
|
+
const outcome = reported.outcomes?.get(id);
|
|
1176
|
+
if (outcome)
|
|
1177
|
+
return outcome;
|
|
1178
|
+
if (reported.includes(id))
|
|
1179
|
+
return 'completed';
|
|
1180
|
+
return stoppedBeforeSweep.has(id) ? 'stopped' : 'untracked';
|
|
1181
|
+
});
|
|
815
1182
|
}
|
|
816
1183
|
if (!firedThisPoll && pendingKills.length === 0 && !tmuxChromeEnabled) {
|
|
817
1184
|
log(heartbeatLine() + '\n');
|
|
@@ -823,6 +1190,7 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
|
|
|
823
1190
|
lastPollOk = false;
|
|
824
1191
|
errLog(statusRow('⚠', 'poll error', e.message, { stream: process.stderr }) + '\n');
|
|
825
1192
|
}
|
|
1193
|
+
await sweepLocalGuardrails();
|
|
826
1194
|
if (options.oneShot)
|
|
827
1195
|
break;
|
|
828
1196
|
if (fixedInterval !== undefined) {
|
package/dist/commands/logs.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readActivityLog as readActivityLogDefault, } from '../lib/activity-log.js';
|
|
1
|
+
import { readActivityLog as readActivityLogDefault, isExitTranscriptEntry, } from '../lib/activity-log.js';
|
|
2
2
|
import { header, statusRow, eventRow } from '../lib/output.js';
|
|
3
3
|
const FOLLOW_INTERVAL_MS = 1000;
|
|
4
4
|
function parseArgs(args) {
|
|
@@ -49,6 +49,11 @@ function relativeTime(ts, now) {
|
|
|
49
49
|
return `${days}d ago`;
|
|
50
50
|
}
|
|
51
51
|
function formatEntry(e, now) {
|
|
52
|
+
if (isExitTranscriptEntry(e)) {
|
|
53
|
+
const sigil = e.status === 'completed' ? '✓' : e.status === 'failed' ? '✗' : '⚠';
|
|
54
|
+
const size = `${e.lines} lines${e.truncated ? ' (clamped)' : ''}`;
|
|
55
|
+
return eventRow([e.id, e.kind, `${e.status} · ${size}`, relativeTime(e.ts, now)], { status: sigil }) + '\n';
|
|
56
|
+
}
|
|
52
57
|
const status = e.status === 'launched' ? '✓' : '✗';
|
|
53
58
|
const when = relativeTime(e.ts, now);
|
|
54
59
|
const target = `${e.mode}/${e.target}`;
|
package/dist/index.js
CHANGED
|
@@ -21,7 +21,7 @@ const COMMANDS = [
|
|
|
21
21
|
},
|
|
22
22
|
{
|
|
23
23
|
name: 'daemon',
|
|
24
|
-
description: 'Long-running listener — polls /api/cli/activations/pending every 2s. `nonbot daemon stop` stops it (local; --machine <id> / --all go remote).',
|
|
24
|
+
description: 'Long-running listener — polls /api/cli/activations/pending every 2s. `nonbot daemon stop` stops it (local; --machine <id> / --all go remote); `nonbot daemon attach` reattaches a lost tmux window.',
|
|
25
25
|
run: (args) => runDaemonCommand(args),
|
|
26
26
|
},
|
|
27
27
|
{
|