pi-better-background-tasks 0.1.18 → 0.2.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/README.md +25 -0
- package/package.json +2 -1
- package/src/logs.ts +6 -0
- package/src/navigator-provider.ts +10 -3
- package/src/process.ts +25 -3
- package/src/registry.ts +5 -1
- package/src/remote-task-preset.ts +631 -0
- package/src/runtime.ts +483 -55
- package/src/tools.ts +49 -18
- package/src/types.ts +43 -0
package/src/runtime.ts
CHANGED
|
@@ -1,17 +1,34 @@
|
|
|
1
1
|
import { statSync } from "node:fs";
|
|
2
2
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { appendLine, appendWatchResult, retainLogTail, resolveMaxLogBytes } from "./logs.js";
|
|
3
|
+
import { appendLine, appendTaskOutput, appendWatchResult, retainLogTail, resolveMaxLogBytes } from "./logs.js";
|
|
4
4
|
import { evaluateCondition } from "./conditions.js";
|
|
5
5
|
import { processExists, runCommandOnce, spawnCommand, stopProcessGroup } from "./process.js";
|
|
6
|
+
import { DEFAULT_TMUX_BOOTSTRAP_TIMEOUT_MS, expandSshRemoteTaskPreset } from "./remote-task-preset.js";
|
|
7
|
+
import type { RemoteRunner, ResolvedSshRemoteTask } from "./remote-task-preset.js";
|
|
6
8
|
import { ensureTaskDir, logPathFor, nextTaskId, readMeta, writeMeta } from "./registry.js";
|
|
7
9
|
import { getCallbackBatcher } from "./shared-callback-batcher.js";
|
|
8
|
-
import type {
|
|
10
|
+
import type {
|
|
11
|
+
BackgroundTaskCallbackOrigin,
|
|
12
|
+
BackgroundTaskMeta,
|
|
13
|
+
CommandResult,
|
|
14
|
+
CommandSpec,
|
|
15
|
+
Condition,
|
|
16
|
+
RemoteTaskParams,
|
|
17
|
+
SshConnectionParams,
|
|
18
|
+
TerminalResult,
|
|
19
|
+
} from "./types.js";
|
|
9
20
|
import { isTerminalStatus } from "./types.js";
|
|
10
21
|
|
|
11
22
|
const watcherTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
23
|
+
const remoteSessionTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
24
|
+
const processTimeoutTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
25
|
+
const activeProcessTimeouts = new Set<string>();
|
|
26
|
+
const activeRemoteTasks = new Map<string, ResolvedSshRemoteTask>();
|
|
27
|
+
const remoteSessionStarts = new Map<string, Promise<CommandResult>>();
|
|
12
28
|
const activePolls = new Set<string>();
|
|
13
29
|
const logRetentionTimers = new Map<string, ReturnType<typeof setInterval>>();
|
|
14
30
|
const LOG_RETENTION_CHECK_MS = 1000;
|
|
31
|
+
const REMOTE_SESSION_POLL_MS = 100;
|
|
15
32
|
|
|
16
33
|
export const DEFAULT_WATCH_TIMEOUT_SECONDS = 15 * 60;
|
|
17
34
|
|
|
@@ -22,6 +39,8 @@ export interface SpawnTaskParams extends CommandSpec {
|
|
|
22
39
|
callback?: boolean;
|
|
23
40
|
timeout_seconds?: number;
|
|
24
41
|
max_log_bytes?: number;
|
|
42
|
+
ssh?: SshConnectionParams;
|
|
43
|
+
remote?: RemoteTaskParams;
|
|
25
44
|
}
|
|
26
45
|
|
|
27
46
|
export interface WatchTaskParams extends CommandSpec {
|
|
@@ -32,20 +51,46 @@ export interface WatchTaskParams extends CommandSpec {
|
|
|
32
51
|
max_log_bytes?: number;
|
|
33
52
|
success_when: Condition;
|
|
34
53
|
failure_when?: Condition;
|
|
54
|
+
ssh?: SshConnectionParams;
|
|
55
|
+
remote?: RemoteTaskParams;
|
|
35
56
|
}
|
|
36
57
|
|
|
58
|
+
export interface TaskRuntimeDependencies {
|
|
59
|
+
remoteRunner?: RemoteRunner;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
type WatchPollRunner = (timeoutMs?: number) => Promise<CommandResult>;
|
|
63
|
+
|
|
37
64
|
export function spawnTask(
|
|
38
65
|
pi: ExtensionAPI,
|
|
39
66
|
params: SpawnTaskParams,
|
|
40
67
|
defaultCwd: string,
|
|
41
68
|
callbackOrigin?: BackgroundTaskCallbackOrigin,
|
|
42
69
|
getActiveSession?: ActiveSessionProvider,
|
|
70
|
+
dependencies: TaskRuntimeDependencies = {},
|
|
43
71
|
): BackgroundTaskMeta {
|
|
44
72
|
const id = nextTaskId();
|
|
45
73
|
const cwd = params.cwd ?? defaultCwd;
|
|
46
74
|
const logPath = logPathFor(id);
|
|
47
75
|
ensureTaskDir(id);
|
|
48
|
-
const
|
|
76
|
+
const remoteTask = params.ssh
|
|
77
|
+
? expandSshRemoteTaskPreset({
|
|
78
|
+
operation: "spawn",
|
|
79
|
+
taskId: id,
|
|
80
|
+
command: params.command,
|
|
81
|
+
cwd,
|
|
82
|
+
env: params.env,
|
|
83
|
+
ssh: params.ssh,
|
|
84
|
+
remote: params.remote,
|
|
85
|
+
}, dependencies.remoteRunner)
|
|
86
|
+
: undefined;
|
|
87
|
+
const commandSpec: CommandSpec = remoteTask?.commandSpec ?? { ...params, cwd, shell: params.shell ?? true };
|
|
88
|
+
const tmuxBacked = remoteTask?.metadata.remote.session === "tmux";
|
|
89
|
+
const spawned = tmuxBacked
|
|
90
|
+
? undefined
|
|
91
|
+
: remoteTask
|
|
92
|
+
? remoteTask.spawn(logPath, true)
|
|
93
|
+
: spawnCommand(commandSpec, logPath, true);
|
|
49
94
|
const now = Date.now();
|
|
50
95
|
const meta: BackgroundTaskMeta = {
|
|
51
96
|
id,
|
|
@@ -59,47 +104,213 @@ export function spawnTask(
|
|
|
59
104
|
callback: params.callback,
|
|
60
105
|
callbackOrigin,
|
|
61
106
|
command: params.command,
|
|
62
|
-
argv:
|
|
63
|
-
shell:
|
|
107
|
+
argv: commandSpec.argv,
|
|
108
|
+
shell: commandSpec.shell,
|
|
64
109
|
cwd,
|
|
65
110
|
env: params.env,
|
|
66
111
|
maxLogBytes: resolveMaxLogBytes(params.max_log_bytes),
|
|
67
|
-
pid: spawned
|
|
68
|
-
pgid: spawned
|
|
112
|
+
pid: spawned?.child.pid,
|
|
113
|
+
pgid: spawned?.pgid,
|
|
69
114
|
spawnPid: process.pid,
|
|
115
|
+
ssh: remoteTask?.metadata.ssh,
|
|
116
|
+
remote: remoteTask?.metadata.remote,
|
|
70
117
|
};
|
|
71
118
|
writeMeta(meta);
|
|
72
119
|
scheduleLogRetention(id);
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
120
|
+
if (tmuxBacked && remoteTask) {
|
|
121
|
+
activeRemoteTasks.set(id, remoteTask);
|
|
122
|
+
appendLine(logPath, `--- remote tmux bootstrap ${new Date(now).toISOString()} target=${remoteTask.metadata.ssh.target} session=${remoteTask.metadata.remote.sessionName} ---`);
|
|
123
|
+
void launchRemoteTmux(pi, id, remoteTask, getActiveSession);
|
|
124
|
+
} else if (spawned) {
|
|
125
|
+
spawned.child.unref();
|
|
126
|
+
spawned.child.on("close", (exitCode, signal) => {
|
|
127
|
+
clearProcessTimeout(id);
|
|
128
|
+
stopLogRetention(id);
|
|
129
|
+
const latest = readMeta(id);
|
|
130
|
+
if (!latest) return;
|
|
131
|
+
enforceLogRetention(latest);
|
|
132
|
+
if (isTerminalStatus(latest.status)) return;
|
|
133
|
+
latest.status = exitCode === 0 ? "succeeded" : "failed";
|
|
134
|
+
latest.endedAt = Date.now();
|
|
135
|
+
latest.lastExitCode = exitCode;
|
|
136
|
+
latest.lastSignal = signal;
|
|
137
|
+
latest.result = { exitCode, signal };
|
|
138
|
+
writeMeta(latest);
|
|
139
|
+
void notifyTerminal(pi, latest, getActiveSession);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
88
142
|
if (meta.deadlineAt) scheduleProcessTimeout(pi, id, meta.deadlineAt, getActiveSession);
|
|
89
143
|
return meta;
|
|
90
144
|
}
|
|
91
145
|
|
|
146
|
+
async function launchRemoteTmux(
|
|
147
|
+
pi: ExtensionAPI,
|
|
148
|
+
id: string,
|
|
149
|
+
remoteTask: ResolvedSshRemoteTask,
|
|
150
|
+
getActiveSession?: ActiveSessionProvider,
|
|
151
|
+
): Promise<void> {
|
|
152
|
+
try {
|
|
153
|
+
const beforeBootstrap = readMeta(id);
|
|
154
|
+
const remainingMs = remainingDeadlineMs(beforeBootstrap?.deadlineAt);
|
|
155
|
+
const bootstrap = await remoteTask.bootstrapTmux(remainingMs === undefined
|
|
156
|
+
? undefined
|
|
157
|
+
: { timeoutMs: Math.min(DEFAULT_TMUX_BOOTSTRAP_TIMEOUT_MS, remainingMs) });
|
|
158
|
+
const latest = readMeta(id);
|
|
159
|
+
if (!latest || latest.status !== "running" || latest.stopRequestedAt) return;
|
|
160
|
+
latest.remote = {
|
|
161
|
+
...latest.remote!,
|
|
162
|
+
bootstrapStatus: bootstrap.status,
|
|
163
|
+
bootstrapMessage: bootstrap.message,
|
|
164
|
+
tmuxInstalled: bootstrap.status === "installed",
|
|
165
|
+
};
|
|
166
|
+
appendLine(latest.logPath, `--- remote setup: ${bootstrap.message} ---`);
|
|
167
|
+
writeMeta(latest);
|
|
168
|
+
if (bootstrap.status !== "present" && bootstrap.status !== "installed") {
|
|
169
|
+
latest.error = bootstrap.message;
|
|
170
|
+
finalize(latest, { status: "failed", reason: bootstrap.message }, pi, getActiveSession);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const startAttempt = remoteTask.startTmuxSession(bootstrap.tmuxPath);
|
|
175
|
+
remoteSessionStarts.set(id, startAttempt);
|
|
176
|
+
let started: CommandResult;
|
|
177
|
+
try {
|
|
178
|
+
started = await startAttempt;
|
|
179
|
+
} finally {
|
|
180
|
+
if (remoteSessionStarts.get(id) === startAttempt) remoteSessionStarts.delete(id);
|
|
181
|
+
}
|
|
182
|
+
const afterStart = readMeta(id);
|
|
183
|
+
if (!afterStart || afterStart.status !== "running" || afterStart.stopRequestedAt) return;
|
|
184
|
+
if (started.exitCode !== 0) {
|
|
185
|
+
const detail = started.stderr.trim() || started.stdout.trim() || "remote tmux returned no diagnostic";
|
|
186
|
+
const reason = `Could not create remote tmux session ${afterStart.remote?.sessionName} on ${afterStart.ssh?.target} (exit ${started.exitCode ?? "unknown"}): ${detail}`;
|
|
187
|
+
afterStart.error = reason;
|
|
188
|
+
finalize(afterStart, { status: "failed", reason, commandResult: started }, pi, getActiveSession);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
appendLine(afterStart.logPath, `--- remote tmux session ${afterStart.remote?.sessionName} started on ${afterStart.ssh?.target} ---`);
|
|
192
|
+
afterStart.remote = { ...afterStart.remote!, sessionStarted: true };
|
|
193
|
+
afterStart.lastProgressAt = Date.now();
|
|
194
|
+
writeMeta(afterStart);
|
|
195
|
+
scheduleRemoteSessionPoll(pi, id, 0, getActiveSession);
|
|
196
|
+
} catch (error) {
|
|
197
|
+
failRemoteTask(pi, id, error, getActiveSession);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function scheduleRemoteSessionPoll(
|
|
202
|
+
pi: ExtensionAPI,
|
|
203
|
+
id: string,
|
|
204
|
+
delayMs: number,
|
|
205
|
+
getActiveSession?: ActiveSessionProvider,
|
|
206
|
+
): void {
|
|
207
|
+
clearRemoteSessionTimer(id);
|
|
208
|
+
const timer = setTimeout(() => void pollRemoteSession(pi, id, getActiveSession), delayMs);
|
|
209
|
+
timer.unref();
|
|
210
|
+
remoteSessionTimers.set(id, timer);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function clearRemoteSessionTimer(id: string): void {
|
|
214
|
+
const timer = remoteSessionTimers.get(id);
|
|
215
|
+
if (timer) clearTimeout(timer);
|
|
216
|
+
remoteSessionTimers.delete(id);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function pollRemoteSession(
|
|
220
|
+
pi: ExtensionAPI,
|
|
221
|
+
id: string,
|
|
222
|
+
getActiveSession?: ActiveSessionProvider,
|
|
223
|
+
): Promise<void> {
|
|
224
|
+
if (activePolls.has(id)) return;
|
|
225
|
+
activePolls.add(id);
|
|
226
|
+
try {
|
|
227
|
+
const meta = readMeta(id);
|
|
228
|
+
const remoteTask = activeRemoteTasks.get(id);
|
|
229
|
+
if (!meta || meta.status !== "running" || meta.remote?.session !== "tmux" || !remoteTask) return;
|
|
230
|
+
const poll = await remoteTask.pollTmuxSession(meta.remote.logOffset ?? 0, remainingDeadlineMs(meta.deadlineAt));
|
|
231
|
+
appendTaskOutput(meta.logPath, poll.output);
|
|
232
|
+
if (poll.status === "timed_out") {
|
|
233
|
+
clearProcessTimeout(id);
|
|
234
|
+
await timeoutProcess(pi, id, getActiveSession);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const latest = readMeta(id);
|
|
238
|
+
if (!latest || latest.status !== "running") return;
|
|
239
|
+
latest.remote = { ...latest.remote!, logOffset: poll.logSize };
|
|
240
|
+
latest.lastCheckedAt = poll.commandResult.endedAt;
|
|
241
|
+
if (poll.output) latest.lastProgressAt = poll.commandResult.endedAt;
|
|
242
|
+
enforceLogRetention(latest);
|
|
243
|
+
if (latest.stopRequestedAt) {
|
|
244
|
+
writeMeta(latest);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (poll.status === "running") {
|
|
248
|
+
writeMeta(latest);
|
|
249
|
+
scheduleRemoteSessionPoll(pi, id, REMOTE_SESSION_POLL_MS, getActiveSession);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (poll.status === "missing") {
|
|
253
|
+
const reason = `Remote tmux session ${latest.remote?.sessionName} disappeared on ${latest.ssh?.target} before an exit status was captured.`;
|
|
254
|
+
latest.error = reason;
|
|
255
|
+
finalize(latest, { status: "failed", reason }, pi, getActiveSession);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const commandResult = { ...poll.commandResult, exitCode: poll.status, stdout: poll.output };
|
|
259
|
+
latest.lastExitCode = poll.status;
|
|
260
|
+
finalize(latest, {
|
|
261
|
+
status: poll.status === 0 ? "succeeded" : "failed",
|
|
262
|
+
reason: `remote command exited with code ${poll.status}`,
|
|
263
|
+
commandResult,
|
|
264
|
+
}, pi, getActiveSession);
|
|
265
|
+
} catch (error) {
|
|
266
|
+
const latest = readMeta(id);
|
|
267
|
+
if (latest?.status === "running" && latest.deadlineAt !== undefined && Date.now() >= latest.deadlineAt) {
|
|
268
|
+
clearProcessTimeout(id);
|
|
269
|
+
await timeoutProcess(pi, id, getActiveSession);
|
|
270
|
+
} else {
|
|
271
|
+
failRemoteTask(pi, id, error, getActiveSession);
|
|
272
|
+
}
|
|
273
|
+
} finally {
|
|
274
|
+
activePolls.delete(id);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function failRemoteTask(
|
|
279
|
+
pi: ExtensionAPI,
|
|
280
|
+
id: string,
|
|
281
|
+
error: unknown,
|
|
282
|
+
getActiveSession?: ActiveSessionProvider,
|
|
283
|
+
): void {
|
|
284
|
+
const meta = readMeta(id);
|
|
285
|
+
if (!meta || meta.status !== "running" || meta.stopRequestedAt) return;
|
|
286
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
287
|
+
meta.error = reason;
|
|
288
|
+
finalize(meta, { status: "failed", reason }, pi, getActiveSession);
|
|
289
|
+
}
|
|
290
|
+
|
|
92
291
|
export function startWatchTask(
|
|
93
292
|
pi: ExtensionAPI,
|
|
94
293
|
params: WatchTaskParams,
|
|
95
294
|
defaultCwd: string,
|
|
96
295
|
callbackOrigin?: BackgroundTaskCallbackOrigin,
|
|
97
296
|
getActiveSession?: ActiveSessionProvider,
|
|
297
|
+
dependencies: TaskRuntimeDependencies = {},
|
|
98
298
|
): BackgroundTaskMeta {
|
|
99
299
|
const id = nextTaskId();
|
|
100
300
|
const cwd = params.cwd ?? defaultCwd;
|
|
101
301
|
const now = Date.now();
|
|
102
302
|
const timeoutSeconds = resolveWatchTimeoutSeconds(params.timeout_seconds);
|
|
303
|
+
const remoteTask = params.ssh
|
|
304
|
+
? expandSshRemoteTaskPreset({
|
|
305
|
+
operation: "watch",
|
|
306
|
+
command: params.command,
|
|
307
|
+
cwd,
|
|
308
|
+
env: params.env,
|
|
309
|
+
ssh: params.ssh,
|
|
310
|
+
remote: params.remote,
|
|
311
|
+
}, dependencies.remoteRunner)
|
|
312
|
+
: undefined;
|
|
313
|
+
const commandSpec: CommandSpec = remoteTask?.commandSpec ?? { ...params, cwd, shell: params.shell ?? true };
|
|
103
314
|
const meta: BackgroundTaskMeta = {
|
|
104
315
|
id,
|
|
105
316
|
name: params.name,
|
|
@@ -113,8 +324,8 @@ export function startWatchTask(
|
|
|
113
324
|
callback: params.callback,
|
|
114
325
|
callbackOrigin,
|
|
115
326
|
command: params.command,
|
|
116
|
-
argv:
|
|
117
|
-
shell:
|
|
327
|
+
argv: commandSpec.argv,
|
|
328
|
+
shell: commandSpec.shell,
|
|
118
329
|
cwd,
|
|
119
330
|
env: params.env,
|
|
120
331
|
maxLogBytes: resolveMaxLogBytes(params.max_log_bytes),
|
|
@@ -122,11 +333,15 @@ export function startWatchTask(
|
|
|
122
333
|
successWhen: params.success_when,
|
|
123
334
|
failureWhen: params.failure_when,
|
|
124
335
|
notifyOn: "terminal",
|
|
336
|
+
ssh: remoteTask?.metadata.ssh,
|
|
337
|
+
remote: remoteTask?.metadata.remote,
|
|
125
338
|
};
|
|
126
339
|
ensureTaskDir(id);
|
|
127
340
|
appendLine(meta.logPath, `--- watch ${new Date(now).toISOString()} interval_ms=${meta.intervalMs} ---`);
|
|
128
341
|
writeMeta(meta);
|
|
129
|
-
scheduleWatch(pi, id, 0, getActiveSession
|
|
342
|
+
scheduleWatch(pi, id, 0, getActiveSession, remoteTask
|
|
343
|
+
? (timeoutMs) => remoteTask.runOnce(undefined, timeoutMs)
|
|
344
|
+
: undefined);
|
|
130
345
|
return meta;
|
|
131
346
|
}
|
|
132
347
|
|
|
@@ -140,17 +355,29 @@ export function resumeRunningTask(
|
|
|
140
355
|
pi: ExtensionAPI,
|
|
141
356
|
meta: BackgroundTaskMeta,
|
|
142
357
|
getActiveSession?: ActiveSessionProvider,
|
|
358
|
+
dependencies: TaskRuntimeDependencies = {},
|
|
143
359
|
): BackgroundTaskMeta {
|
|
144
360
|
if (meta.status !== "running") {
|
|
145
361
|
void notifyTerminal(pi, meta, getActiveSession);
|
|
146
362
|
return meta;
|
|
147
363
|
}
|
|
364
|
+
|
|
365
|
+
const remoteTask = resolvePersistedRemoteTask(meta, dependencies.remoteRunner);
|
|
148
366
|
if (meta.kind === "command_watch") {
|
|
149
|
-
scheduleWatch(pi, meta.id, 0, getActiveSession
|
|
367
|
+
scheduleWatch(pi, meta.id, 0, getActiveSession, remoteTask
|
|
368
|
+
? (timeoutMs) => remoteTask.runOnce(undefined, timeoutMs)
|
|
369
|
+
: undefined);
|
|
150
370
|
return meta;
|
|
151
371
|
}
|
|
152
|
-
|
|
153
|
-
|
|
372
|
+
|
|
373
|
+
scheduleLogRetention(meta.id);
|
|
374
|
+
if (meta.remote?.session === "tmux" && meta.remote.sessionStarted !== false && remoteTask) {
|
|
375
|
+
activeRemoteTasks.set(meta.id, remoteTask);
|
|
376
|
+
scheduleRemoteSessionPoll(pi, meta.id, 0, getActiveSession);
|
|
377
|
+
if (meta.deadlineAt) scheduleProcessTimeout(pi, meta.id, meta.deadlineAt, getActiveSession);
|
|
378
|
+
return meta;
|
|
379
|
+
}
|
|
380
|
+
if (meta.pid && !processExists(meta.pid)) {
|
|
154
381
|
meta.status = "failed";
|
|
155
382
|
meta.endedAt = Date.now();
|
|
156
383
|
meta.error = "process is no longer alive; exit result was not captured by this pi session";
|
|
@@ -159,36 +386,120 @@ export function resumeRunningTask(
|
|
|
159
386
|
void notifyTerminal(pi, meta, getActiveSession);
|
|
160
387
|
return meta;
|
|
161
388
|
}
|
|
162
|
-
if (meta.
|
|
389
|
+
if (meta.deadlineAt) scheduleProcessTimeout(pi, meta.id, meta.deadlineAt, getActiveSession);
|
|
163
390
|
return meta;
|
|
164
391
|
}
|
|
165
392
|
|
|
166
|
-
|
|
393
|
+
function resolvePersistedRemoteTask(
|
|
394
|
+
meta: BackgroundTaskMeta,
|
|
395
|
+
remoteRunner?: RemoteRunner,
|
|
396
|
+
): ResolvedSshRemoteTask | undefined {
|
|
397
|
+
if (!meta.ssh || !meta.remote?.session) return undefined;
|
|
398
|
+
return expandSshRemoteTaskPreset({
|
|
399
|
+
operation: meta.kind === "command_watch" ? "watch" : "spawn",
|
|
400
|
+
taskId: meta.id,
|
|
401
|
+
sessionName: meta.remote.sessionName,
|
|
402
|
+
command: meta.remote.command || meta.command,
|
|
403
|
+
cwd: meta.cwd,
|
|
404
|
+
env: meta.env,
|
|
405
|
+
ssh: {
|
|
406
|
+
host: meta.ssh.host,
|
|
407
|
+
user: meta.ssh.user,
|
|
408
|
+
port: meta.ssh.port,
|
|
409
|
+
identity_file: meta.ssh.identityFile,
|
|
410
|
+
jump: meta.ssh.jump,
|
|
411
|
+
options: meta.ssh.options,
|
|
412
|
+
},
|
|
413
|
+
remote: {
|
|
414
|
+
session: meta.remote.session,
|
|
415
|
+
install_tmux: meta.remote.installTmux,
|
|
416
|
+
workdir: meta.remote.workdir,
|
|
417
|
+
},
|
|
418
|
+
}, remoteRunner);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export async function stopTask(
|
|
422
|
+
pi: ExtensionAPI,
|
|
423
|
+
id: string,
|
|
424
|
+
getActiveSession?: ActiveSessionProvider,
|
|
425
|
+
): Promise<BackgroundTaskMeta | undefined> {
|
|
167
426
|
const meta = readMeta(id);
|
|
168
427
|
if (!meta) return undefined;
|
|
169
428
|
if (isTerminalStatus(meta.status)) return meta;
|
|
170
|
-
|
|
171
|
-
meta.
|
|
172
|
-
meta.stopRequestedAt = meta.endedAt;
|
|
173
|
-
meta.result = { reason: "cancelled" };
|
|
429
|
+
|
|
430
|
+
meta.stopRequestedAt = Date.now();
|
|
174
431
|
writeMeta(meta);
|
|
175
432
|
clearWatchTimer(id);
|
|
176
|
-
|
|
177
|
-
|
|
433
|
+
clearRemoteSessionTimer(id);
|
|
434
|
+
clearProcessTimeout(id);
|
|
435
|
+
|
|
436
|
+
const remoteStartAttempt = remoteSessionStarts.get(id);
|
|
437
|
+
const remote = meta.remote;
|
|
438
|
+
const remoteSessionMayExist = remote?.session === "tmux"
|
|
439
|
+
&& (remote.sessionStarted !== false || remoteStartAttempt !== undefined);
|
|
440
|
+
if (remoteStartAttempt) {
|
|
441
|
+
try { await remoteStartAttempt; } catch { /* A failed SSH result can still leave the detached session running. */ }
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (remoteSessionMayExist) {
|
|
445
|
+
const remoteTask = activeRemoteTasks.get(id);
|
|
446
|
+
if (!remoteTask) {
|
|
447
|
+
meta.stopRequestedAt = undefined;
|
|
448
|
+
meta.error = `Cannot stop remote tmux session ${remote.sessionName}: its active SSH controller is unavailable.`;
|
|
449
|
+
writeMeta(meta);
|
|
450
|
+
scheduleRemoteSessionPoll(pi, id, REMOTE_SESSION_POLL_MS, getActiveSession);
|
|
451
|
+
return meta;
|
|
452
|
+
}
|
|
178
453
|
try {
|
|
179
|
-
|
|
454
|
+
const stopped = await remoteTask.killTmuxSession();
|
|
455
|
+
if (stopped.exitCode !== 0) {
|
|
456
|
+
const detail = stopped.stderr.trim() || stopped.stdout.trim() || "remote tmux returned no diagnostic";
|
|
457
|
+
meta.stopRequestedAt = undefined;
|
|
458
|
+
meta.error = `Could not kill remote tmux session ${remote.sessionName} on ${meta.ssh?.target} (exit ${stopped.exitCode ?? "unknown"}): ${detail}`;
|
|
459
|
+
writeMeta(meta);
|
|
460
|
+
scheduleRemoteSessionPoll(pi, id, REMOTE_SESSION_POLL_MS, getActiveSession);
|
|
461
|
+
return meta;
|
|
462
|
+
}
|
|
463
|
+
remote.stopMessage = `Killed remote tmux session ${remote.sessionName} on ${meta.ssh?.target}.`;
|
|
464
|
+
appendLine(meta.logPath, `--- ${remote.stopMessage} ---`);
|
|
180
465
|
} catch (error) {
|
|
466
|
+
meta.stopRequestedAt = undefined;
|
|
181
467
|
meta.error = error instanceof Error ? error.message : String(error);
|
|
182
468
|
writeMeta(meta);
|
|
469
|
+
scheduleRemoteSessionPoll(pi, id, REMOTE_SESSION_POLL_MS, getActiveSession);
|
|
470
|
+
return meta;
|
|
471
|
+
}
|
|
472
|
+
} else if (meta.kind === "process" && meta.pid) {
|
|
473
|
+
try {
|
|
474
|
+
stopProcessGroup(meta.pid, meta.pgid);
|
|
475
|
+
} catch (error) {
|
|
476
|
+
meta.error = error instanceof Error ? error.message : String(error);
|
|
183
477
|
}
|
|
184
478
|
}
|
|
479
|
+
|
|
480
|
+
meta.status = "cancelled";
|
|
481
|
+
meta.endedAt = Date.now();
|
|
482
|
+
meta.result = {
|
|
483
|
+
reason: meta.remote?.session === "direct"
|
|
484
|
+
? "cancelled local SSH client; the remote process may still be running"
|
|
485
|
+
: "cancelled",
|
|
486
|
+
};
|
|
487
|
+
writeMeta(meta);
|
|
488
|
+
stopLogRetention(id);
|
|
489
|
+
activeRemoteTasks.delete(id);
|
|
185
490
|
void notifyTerminal(pi, meta, getActiveSession);
|
|
186
491
|
return meta;
|
|
187
492
|
}
|
|
188
493
|
|
|
189
|
-
function scheduleWatch(
|
|
494
|
+
function scheduleWatch(
|
|
495
|
+
pi: ExtensionAPI,
|
|
496
|
+
id: string,
|
|
497
|
+
delayMs: number,
|
|
498
|
+
getActiveSession?: ActiveSessionProvider,
|
|
499
|
+
runOnce?: WatchPollRunner,
|
|
500
|
+
): void {
|
|
190
501
|
clearWatchTimer(id);
|
|
191
|
-
const timer = setTimeout(() => void pollWatch(pi, id, getActiveSession), delayMs);
|
|
502
|
+
const timer = setTimeout(() => void pollWatch(pi, id, getActiveSession, runOnce), delayMs);
|
|
192
503
|
timer.unref();
|
|
193
504
|
watcherTimers.set(id, timer);
|
|
194
505
|
}
|
|
@@ -199,7 +510,12 @@ function clearWatchTimer(id: string): void {
|
|
|
199
510
|
watcherTimers.delete(id);
|
|
200
511
|
}
|
|
201
512
|
|
|
202
|
-
async function pollWatch(
|
|
513
|
+
async function pollWatch(
|
|
514
|
+
pi: ExtensionAPI,
|
|
515
|
+
id: string,
|
|
516
|
+
getActiveSession?: ActiveSessionProvider,
|
|
517
|
+
runOnce?: WatchPollRunner,
|
|
518
|
+
): Promise<void> {
|
|
203
519
|
if (activePolls.has(id)) return;
|
|
204
520
|
activePolls.add(id);
|
|
205
521
|
try {
|
|
@@ -207,10 +523,13 @@ async function pollWatch(pi: ExtensionAPI, id: string, getActiveSession?: Active
|
|
|
207
523
|
if (!meta || meta.status !== "running" || meta.kind !== "command_watch") return;
|
|
208
524
|
const now = Date.now();
|
|
209
525
|
if (meta.deadlineAt && now >= meta.deadlineAt) {
|
|
210
|
-
finalize(meta, { status: "timed_out", reason:
|
|
526
|
+
finalize(meta, { status: "timed_out", reason: watchTimeoutReason(meta) }, pi, getActiveSession);
|
|
211
527
|
return;
|
|
212
528
|
}
|
|
213
|
-
const
|
|
529
|
+
const timeoutMs = remainingDeadlineMs(meta.deadlineAt);
|
|
530
|
+
const result = runOnce
|
|
531
|
+
? await runOnce(timeoutMs)
|
|
532
|
+
: await runCommandOnce(commandSpecFromMeta(meta), undefined, timeoutMs);
|
|
214
533
|
appendWatchResult(meta.logPath, result);
|
|
215
534
|
const latest = readMeta(id);
|
|
216
535
|
if (!latest || latest.status !== "running") return;
|
|
@@ -221,6 +540,11 @@ async function pollWatch(pi: ExtensionAPI, id: string, getActiveSession?: Active
|
|
|
221
540
|
latest.lastSignal = result.signal;
|
|
222
541
|
latest.lastState = extractLastState(result);
|
|
223
542
|
|
|
543
|
+
if (result.timedOut) {
|
|
544
|
+
finalize(latest, { status: "timed_out", reason: watchTimeoutReason(latest), commandResult: result }, pi, getActiveSession);
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
|
|
224
548
|
if (latest.failureWhen) {
|
|
225
549
|
const failure = evaluateCondition(latest.failureWhen, result);
|
|
226
550
|
if (failure.matched) {
|
|
@@ -229,6 +553,13 @@ async function pollWatch(pi: ExtensionAPI, id: string, getActiveSession?: Active
|
|
|
229
553
|
}
|
|
230
554
|
}
|
|
231
555
|
|
|
556
|
+
const transportFailure = sshTransportFailure(latest, result);
|
|
557
|
+
if (transportFailure) {
|
|
558
|
+
latest.error = transportFailure;
|
|
559
|
+
finalize(latest, { status: "failed", reason: transportFailure, commandResult: result }, pi, getActiveSession);
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
|
|
232
563
|
if (latest.successWhen) {
|
|
233
564
|
const success = evaluateCondition(latest.successWhen, result);
|
|
234
565
|
if (success.matched) {
|
|
@@ -238,11 +569,17 @@ async function pollWatch(pi: ExtensionAPI, id: string, getActiveSession?: Active
|
|
|
238
569
|
}
|
|
239
570
|
|
|
240
571
|
writeMeta(latest);
|
|
241
|
-
scheduleWatch(pi, id, latest
|
|
572
|
+
scheduleWatch(pi, id, nextWatchDelayMs(latest), getActiveSession, runOnce);
|
|
242
573
|
} catch (error) {
|
|
243
574
|
const meta = readMeta(id);
|
|
244
575
|
if (meta && meta.status === "running") {
|
|
245
|
-
|
|
576
|
+
const detail = readableError(error);
|
|
577
|
+
const reason = meta.ssh ? `SSH poll to ${meta.ssh.target} failed: ${detail}` : detail;
|
|
578
|
+
if (meta.ssh) {
|
|
579
|
+
meta.error = reason;
|
|
580
|
+
appendLine(meta.logPath, `--- poll error ${new Date().toISOString()} ---\n${reason}`);
|
|
581
|
+
}
|
|
582
|
+
finalize(meta, { status: "failed", reason }, pi, getActiveSession);
|
|
246
583
|
}
|
|
247
584
|
} finally {
|
|
248
585
|
activePolls.delete(id);
|
|
@@ -271,30 +608,121 @@ function finalize(
|
|
|
271
608
|
}
|
|
272
609
|
writeMeta(meta);
|
|
273
610
|
clearWatchTimer(meta.id);
|
|
611
|
+
clearRemoteSessionTimer(meta.id);
|
|
612
|
+
clearProcessTimeout(meta.id);
|
|
613
|
+
activeRemoteTasks.delete(meta.id);
|
|
274
614
|
stopLogRetention(meta.id);
|
|
275
615
|
void notifyTerminal(pi, meta, getActiveSession);
|
|
276
616
|
}
|
|
277
617
|
|
|
618
|
+
function sshTransportFailure(meta: BackgroundTaskMeta, result: CommandResult): string | undefined {
|
|
619
|
+
if (!meta.ssh || result.exitCode !== 255) return undefined;
|
|
620
|
+
const detail = readableError(result.stderr || result.stdout);
|
|
621
|
+
return `SSH poll to ${meta.ssh.target} failed with exit 255${detail ? `: ${detail}` : ""}`;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function readableError(error: unknown): string {
|
|
625
|
+
const value = error instanceof Error ? error.message : String(error);
|
|
626
|
+
return value.replace(/\s+/g, " ").trim().slice(0, 500);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function watchTimeoutReason(meta: BackgroundTaskMeta): string {
|
|
630
|
+
return meta.ssh ? `timeout waiting for SSH watch condition on ${meta.ssh.target}` : "timeout";
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function nextWatchDelayMs(meta: BackgroundTaskMeta): number {
|
|
634
|
+
const intervalMs = meta.intervalMs ?? 30_000;
|
|
635
|
+
if (!meta.deadlineAt) return intervalMs;
|
|
636
|
+
return Math.max(0, Math.min(intervalMs, meta.deadlineAt - Date.now()));
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function remainingDeadlineMs(deadlineAt: number | undefined): number | undefined {
|
|
640
|
+
if (deadlineAt === undefined) return undefined;
|
|
641
|
+
return Math.max(1, deadlineAt - Date.now());
|
|
642
|
+
}
|
|
643
|
+
|
|
278
644
|
function scheduleProcessTimeout(
|
|
279
645
|
pi: ExtensionAPI,
|
|
280
646
|
id: string,
|
|
281
647
|
deadlineAt: number,
|
|
282
648
|
getActiveSession?: ActiveSessionProvider,
|
|
283
649
|
): void {
|
|
650
|
+
clearProcessTimeout(id);
|
|
284
651
|
const delay = Math.max(0, deadlineAt - Date.now());
|
|
285
|
-
const timer = setTimeout(() =>
|
|
286
|
-
|
|
287
|
-
|
|
652
|
+
const timer = setTimeout(() => void timeoutProcess(pi, id, getActiveSession), delay);
|
|
653
|
+
timer.unref();
|
|
654
|
+
processTimeoutTimers.set(id, timer);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function clearProcessTimeout(id: string): void {
|
|
658
|
+
const timer = processTimeoutTimers.get(id);
|
|
659
|
+
if (timer) clearTimeout(timer);
|
|
660
|
+
processTimeoutTimers.delete(id);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
async function timeoutProcess(
|
|
664
|
+
pi: ExtensionAPI,
|
|
665
|
+
id: string,
|
|
666
|
+
getActiveSession?: ActiveSessionProvider,
|
|
667
|
+
): Promise<void> {
|
|
668
|
+
if (activeProcessTimeouts.has(id)) return;
|
|
669
|
+
activeProcessTimeouts.add(id);
|
|
670
|
+
try {
|
|
671
|
+
await finalizeProcessTimeout(pi, id, getActiveSession);
|
|
672
|
+
} finally {
|
|
673
|
+
activeProcessTimeouts.delete(id);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
async function finalizeProcessTimeout(
|
|
678
|
+
pi: ExtensionAPI,
|
|
679
|
+
id: string,
|
|
680
|
+
getActiveSession?: ActiveSessionProvider,
|
|
681
|
+
): Promise<void> {
|
|
682
|
+
const meta = readMeta(id);
|
|
683
|
+
if (!meta || meta.status !== "running" || meta.kind !== "process") return;
|
|
684
|
+
|
|
685
|
+
let reason = "timeout";
|
|
686
|
+
if (meta.remote?.session === "tmux" && meta.remote.sessionStarted !== true) {
|
|
687
|
+
reason = `timeout before remote tmux session ${meta.remote.sessionName} started on ${meta.ssh?.target}`;
|
|
688
|
+
} else if (meta.remote?.session === "tmux") {
|
|
689
|
+
const remoteTask = activeRemoteTasks.get(id);
|
|
690
|
+
if (!remoteTask) {
|
|
691
|
+
reason = `timeout; remote tmux session ${meta.remote.sessionName} on ${meta.ssh?.target} could not be killed because its SSH controller is unavailable`;
|
|
692
|
+
meta.error = reason;
|
|
693
|
+
} else {
|
|
694
|
+
try {
|
|
695
|
+
const stopped = await remoteTask.killTmuxSession();
|
|
696
|
+
if (stopped.exitCode === 0) {
|
|
697
|
+
meta.remote.stopMessage = `Killed remote tmux session ${meta.remote.sessionName} on ${meta.ssh?.target} after timeout.`;
|
|
698
|
+
appendLine(meta.logPath, `--- ${meta.remote.stopMessage} ---`);
|
|
699
|
+
reason = `timeout; killed remote tmux session ${meta.remote.sessionName} on ${meta.ssh?.target}`;
|
|
700
|
+
} else {
|
|
701
|
+
const detail = stopped.stderr.trim() || stopped.stdout.trim() || "remote tmux returned no diagnostic";
|
|
702
|
+
reason = `timeout; could not kill remote tmux session ${meta.remote.sessionName} on ${meta.ssh?.target} (exit ${stopped.exitCode ?? "unknown"}): ${detail}`;
|
|
703
|
+
meta.error = reason;
|
|
704
|
+
}
|
|
705
|
+
} catch (error) {
|
|
706
|
+
reason = `timeout; could not kill remote tmux session ${meta.remote.sessionName} on ${meta.ssh?.target}: ${readableError(error)}`;
|
|
707
|
+
meta.error = reason;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
} else {
|
|
288
711
|
if (meta.pid) {
|
|
289
|
-
try { stopProcessGroup(meta.pid, meta.pgid); } catch { /*
|
|
712
|
+
try { stopProcessGroup(meta.pid, meta.pgid); } catch { /* best effort */ }
|
|
290
713
|
}
|
|
291
|
-
meta.
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
714
|
+
if (meta.remote?.session === "direct") {
|
|
715
|
+
reason = "timeout; terminated local SSH client, but the remote process may still be running";
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const latest = readMeta(id);
|
|
720
|
+
if (!latest || latest.status !== "running") return;
|
|
721
|
+
latest.remote = latest.remote && meta.remote
|
|
722
|
+
? { ...latest.remote, stopMessage: meta.remote.stopMessage }
|
|
723
|
+
: meta.remote;
|
|
724
|
+
latest.error = meta.error;
|
|
725
|
+
finalize(latest, { status: "timed_out", reason }, pi, getActiveSession);
|
|
298
726
|
}
|
|
299
727
|
|
|
300
728
|
async function notifyTerminal(
|