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/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 { BackgroundTaskCallbackOrigin, BackgroundTaskMeta, CommandSpec, Condition, TerminalResult } from "./types.js";
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 spawned = spawnCommand({ ...params, cwd, shell: params.shell ?? true }, logPath, true);
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: params.argv,
63
- shell: params.shell ?? true,
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.child.pid,
68
- pgid: spawned.pgid,
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
- spawned.child.unref();
74
- spawned.child.on("close", (exitCode, signal) => {
75
- stopLogRetention(id);
76
- const latest = readMeta(id);
77
- if (!latest) return;
78
- enforceLogRetention(latest);
79
- if (isTerminalStatus(latest.status)) return;
80
- latest.status = exitCode === 0 ? "succeeded" : "failed";
81
- latest.endedAt = Date.now();
82
- latest.lastExitCode = exitCode;
83
- latest.lastSignal = signal;
84
- latest.result = { exitCode, signal };
85
- writeMeta(latest);
86
- void notifyTerminal(pi, latest, getActiveSession);
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: params.argv,
117
- shell: params.shell ?? true,
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
- if (meta.kind === "process") scheduleLogRetention(meta.id);
153
- if (meta.kind === "process" && meta.pid && !processExists(meta.pid)) {
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.kind === "process" && meta.deadlineAt) scheduleProcessTimeout(pi, meta.id, meta.deadlineAt, getActiveSession);
389
+ if (meta.deadlineAt) scheduleProcessTimeout(pi, meta.id, meta.deadlineAt, getActiveSession);
163
390
  return meta;
164
391
  }
165
392
 
166
- export function stopTask(pi: ExtensionAPI, id: string, getActiveSession?: ActiveSessionProvider): BackgroundTaskMeta | undefined {
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
- meta.status = "cancelled";
171
- meta.endedAt = Date.now();
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
- stopLogRetention(id);
177
- if (meta.kind === "process" && meta.pid) {
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
- stopProcessGroup(meta.pid, meta.pgid);
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(pi: ExtensionAPI, id: string, delayMs: number, getActiveSession?: ActiveSessionProvider): void {
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(pi: ExtensionAPI, id: string, getActiveSession?: ActiveSessionProvider): Promise<void> {
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: "timeout" }, pi, getActiveSession);
526
+ finalize(meta, { status: "timed_out", reason: watchTimeoutReason(meta) }, pi, getActiveSession);
211
527
  return;
212
528
  }
213
- const result = await runCommandOnce(commandSpecFromMeta(meta));
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.intervalMs ?? 30_000, getActiveSession);
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
- finalize(meta, { status: "failed", reason: error instanceof Error ? error.message : String(error) }, pi, getActiveSession);
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
- const meta = readMeta(id);
287
- if (!meta || meta.status !== "running" || meta.kind !== "process") return;
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 { /* ignore */ }
712
+ try { stopProcessGroup(meta.pid, meta.pgid); } catch { /* best effort */ }
290
713
  }
291
- meta.status = "timed_out";
292
- meta.endedAt = Date.now();
293
- meta.result = { reason: "timeout" };
294
- writeMeta(meta);
295
- void notifyTerminal(pi, meta, getActiveSession);
296
- }, delay);
297
- timer.unref();
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(