@rjfan/dsh-presence 0.18.0 → 0.20.1

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/pool.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // The pool the plugin owns: one live connection per machine, plus the
2
2
  // terminals that belong to it.
3
3
 
4
+ import { randomUUID } from 'node:crypto';
4
5
  import { listHosts, readSshConfig } from './core/ssh-config.mjs';
5
6
  import { PresenceConnection } from './core/connection.mjs';
6
7
 
@@ -9,15 +10,50 @@ const MAX_TERMINAL_BUFFER = 256 * 1024;
9
10
  // started: give the wrapper a moment, then check the process group.
10
11
  const JOB_EARLY_CHECK_MS = 800;
11
12
  const JOB_EARLY_TAIL_LINES = 20;
12
- // Finished jobs pile up in the local registry for the life of the app. Prune
13
- // them at most once a minute from `listJobs`, keeping the newest entries and
14
- // anything that finished in the last six hours.
13
+ // Finished jobs pile up in the local registry and in the JSON store. Prune at
14
+ // most once a minute from `listJobs`; the 0.20 store keeps a month of finished
15
+ // jobs (a run is worth coming back to after a restart), bounded to 500 rows.
15
16
  const JOB_PRUNE_INTERVAL_MS = 60_000;
16
- const JOB_PRUNE_MAX_AGE_MS = 6 * 60 * 60 * 1000;
17
- const JOB_PRUNE_MAX_ENTRIES = 100;
17
+ const JOB_PRUNE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
18
+ const JOB_PRUNE_MAX_ENTRIES = 500;
19
+ // A transient connect failure is retried twice, with these gaps, and a dead
20
+ // cached connection is dropped before each retry.
21
+ const CONNECT_RETRY_BACKOFF_MS = [200, 600];
22
+ // How long a failure keeps its edge on the host state: recent enough to matter,
23
+ // old enough to clear itself once the connection has been healthy again.
24
+ const HOST_DEGRADED_WINDOW_MS = 60_000;
18
25
 
19
26
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
20
27
 
28
+ // Auth, permission and unknown-host failures are configuration problems a retry
29
+ // cannot fix; everything network-shaped is worth another attempt.
30
+ const FATAL_CONNECTION_ERROR =
31
+ /no host "|authentication failed|authentication methods|passphrase-protected|password requested|permission denied|access denied|not authorized/i;
32
+ const TRANSIENT_CONNECTION_ERROR =
33
+ /ENOTFOUND|EAI_AGAIN|ECONNREFUSED|ECONNRESET|ETIMEDOUT|EPIPE|EHOSTUNREACH|ENETUNREACH|connection refused|could not be resolved|timed out|handshake|channel|closed|socket hang up|no route to host/i;
34
+
35
+ /**
36
+ * Whether a failed connection attempt is worth retrying.
37
+ *
38
+ * `transient` covers the network-shaped failures (DNS, refused, timeout,
39
+ * dropped channel, reset); `fatal` covers configuration errors a second attempt
40
+ * cannot fix (authentication, permissions, an alias that is not configured) and
41
+ * is also the default for an unrecognised error, so a retry never masks a real
42
+ * problem.
43
+ *
44
+ * @param {unknown} error
45
+ * @returns {'transient'|'fatal'}
46
+ */
47
+ export function classifyConnectionError(error) {
48
+ const message = String(error?.message ?? error ?? '');
49
+ const code = String(error?.code ?? '');
50
+ // ssh2 tags authentication failures with this level even before describeSshError.
51
+ if (String(error?.level ?? '') === 'client-authentication') return 'fatal';
52
+ if (FATAL_CONNECTION_ERROR.test(message)) return 'fatal';
53
+ if (TRANSIENT_CONNECTION_ERROR.test(`${code} ${message}`)) return 'transient';
54
+ return 'fatal';
55
+ }
56
+
21
57
  export class PresencePool {
22
58
  #settings;
23
59
  #connections = new Map();
@@ -25,18 +61,34 @@ export class PresencePool {
25
61
  #terminals = new Map();
26
62
  #tunnels = new Map();
27
63
  #jobs = new Map();
64
+ #jobStore = null;
28
65
  #lastUsed = new Map();
29
66
  #lastPruneAt = 0;
30
67
  #secrets = new Map();
31
68
  #connectionHandlers = new Set();
69
+ #hostStates = new Map();
32
70
  #hostsCache;
33
71
  #reaper;
34
72
  #counter = 0;
35
73
 
36
- constructor(settings) {
74
+ /**
75
+ * @param settings - resolved plugin config.
76
+ * @param options.jobStore - optional persistent job registry (`JobStore`, or
77
+ * any object with `list/upsert/remove/prune`). It is captured once and
78
+ * never replaced, so persistence cannot be attached halfway through a run.
79
+ */
80
+ constructor(settings, { jobStore = null } = {}) {
37
81
  this.#settings = settings;
82
+ this.#jobStore = jobStore;
83
+ this.#loadPersistedJobs();
38
84
  this.#reaper = setInterval(() => this.#reapIdle(), 60_000);
39
85
  this.#reaper.unref?.();
86
+ // A job started before a restart is `unknown` until the machine is asked;
87
+ // the first successful connection is exactly that chance. Fire-and-forget:
88
+ // a slow probe must never delay or fail `connection()`.
89
+ this.onConnectionEstablished((host) => {
90
+ this.#refreshPersistedJobs(host).catch(() => {});
91
+ });
40
92
  }
41
93
 
42
94
  get settings() {
@@ -50,20 +102,51 @@ export class PresencePool {
50
102
  this.#hostsCache = { at: now, list: listHosts() };
51
103
  }
52
104
  return this.#hostsCache.list.map((entry) => {
53
- const connection = this.#connections.get(entry.alias);
105
+ const state = this.hostState(entry.alias);
54
106
  return {
55
107
  alias: entry.alias,
56
108
  hostname: entry.hostname,
57
109
  port: entry.port,
58
110
  user: entry.user,
59
111
  source: entry.source ?? 'config',
60
- connected: Boolean(connection && !connection.closed),
112
+ connected: state.connected,
113
+ state: state.state,
114
+ lastError: state.lastError,
61
115
  identityFile: entry.identityFile?.[0],
62
116
  terminals: [...this.#terminals.values()].filter((terminal) => terminal.host === entry.alias).length,
63
117
  };
64
118
  });
65
119
  }
66
120
 
121
+ /**
122
+ * Current trust state of one machine.
123
+ *
124
+ * `connected` is the cached connection; `degraded` means that connection is
125
+ * usable but a failure happened in the last minute; `error` means the last
126
+ * attempt failed and nothing is cached; `idle` means no connection and no
127
+ * recorded failure. `retries` is how many retries the last attempt needed.
128
+ */
129
+ hostState(alias) {
130
+ const name = String(alias ?? '');
131
+ const connection = this.#connections.get(name);
132
+ const connected = Boolean(connection && !connection.closed);
133
+ const record = this.#hostStates.get(name) ?? null;
134
+ const failedAt = record?.lastErrorAt ?? null;
135
+ const recent = failedAt !== null && Date.now() - failedAt <= HOST_DEGRADED_WINDOW_MS;
136
+ let state;
137
+ if (connected) state = recent ? 'degraded' : 'connected';
138
+ else if (failedAt !== null) state = 'error';
139
+ else state = 'idle';
140
+ return {
141
+ alias: name,
142
+ connected,
143
+ state,
144
+ lastError: record?.lastError ?? null,
145
+ lastErrorAt: record?.lastErrorAt ?? null,
146
+ retries: record?.retries ?? 0,
147
+ };
148
+ }
149
+
67
150
  /** Resolve a live connection, opening one if needed. */
68
151
  async connection(host, { password, passphrase } = {}) {
69
152
  const existing = this.#connections.get(host);
@@ -71,6 +154,9 @@ export class PresencePool {
71
154
  this.#lastUsed.set(host, Date.now());
72
155
  return existing;
73
156
  }
157
+ // A cached connection that died between calls is not reusable: drop it (and
158
+ // anything that hung off it) before dialling again.
159
+ if (existing) this.#dropConnection(host);
74
160
  if (this.#connecting.has(host)) return this.#connecting.get(host);
75
161
 
76
162
  const entry = readSshConfig().get(host);
@@ -92,20 +178,72 @@ export class PresencePool {
92
178
  if (password || passphrase) this.#secrets.set(host, { password, passphrase });
93
179
  const secret = this.#secrets.get(host) ?? (envPassword ? { password: envPassword } : {});
94
180
 
95
- const pending = PresenceConnection.connect(
96
- { ...entry, maxChannels: this.#settings.maxChannels },
97
- { password: secret.password, passphrase: secret.passphrase, timeoutMs: this.#settings.connectTimeoutMs },
98
- )
99
- .then((connection) => {
181
+ // The whole retry sequence is one shared promise, so concurrent callers do
182
+ // not dial the same machine three times over.
183
+ const pending = this.#connectWithRetry(host, entry, secret).finally(() => this.#connecting.delete(host));
184
+ this.#connecting.set(host, pending);
185
+ return pending;
186
+ }
187
+
188
+ /**
189
+ * Open one connection, retrying transient failures. Auth/permission/unknown
190
+ * host errors are configuration problems and fail on the first attempt; a
191
+ * network-shaped failure is retried (200 ms, 600 ms apart) after dropping the
192
+ * dead cached connection, and the final error is the last one observed.
193
+ */
194
+ async #connectWithRetry(host, entry, secret) {
195
+ const previous = this.#hostStates.get(host) ?? {};
196
+ const state = {
197
+ lastError: previous.lastError ?? null,
198
+ lastErrorAt: previous.lastErrorAt ?? null,
199
+ retries: 0,
200
+ };
201
+ let lastError = null;
202
+ for (let attempt = 0; attempt <= CONNECT_RETRY_BACKOFF_MS.length; attempt += 1) {
203
+ if (attempt > 0) {
204
+ this.#dropConnection(host);
205
+ state.retries = attempt;
206
+ await sleep(CONNECT_RETRY_BACKOFF_MS[attempt - 1]);
207
+ }
208
+ try {
209
+ const connection = await PresenceConnection.connect(
210
+ { ...entry, maxChannels: this.#settings.maxChannels },
211
+ { password: secret.password, passphrase: secret.passphrase, timeoutMs: this.#settings.connectTimeoutMs },
212
+ );
100
213
  this.#connections.set(host, connection);
101
214
  this.#lastUsed.set(host, Date.now());
215
+ // Keep the failure that led here: a host that needed a retry is usable
216
+ // but degraded for the next minute.
217
+ this.#hostStates.set(host, state);
102
218
  this.#notifyConnectionEstablished(host, connection);
103
219
  return connection;
104
- })
105
- .finally(() => this.#connecting.delete(host));
220
+ } catch (error) {
221
+ lastError = error;
222
+ state.lastError = String(error?.message ?? error);
223
+ state.lastErrorAt = Date.now();
224
+ if (classifyConnectionError(error) !== 'transient' || attempt === CONNECT_RETRY_BACKOFF_MS.length) {
225
+ this.#hostStates.set(host, state);
226
+ throw error;
227
+ }
228
+ }
229
+ }
230
+ this.#hostStates.set(host, state);
231
+ throw lastError ?? new Error(`could not connect to ${host}`);
232
+ }
106
233
 
107
- this.#connecting.set(host, pending);
108
- return pending;
234
+ /** Forget a cached connection and close anything that hung off it. */
235
+ #dropConnection(host) {
236
+ const connection = this.#connections.get(host);
237
+ this.#connections.delete(host);
238
+ this.#lastUsed.delete(host);
239
+ if (!connection) return;
240
+ try {
241
+ connection.end();
242
+ } catch {
243
+ /* already gone */
244
+ }
245
+ // A forward whose SSH connection is gone still owns a local listener.
246
+ this.#closeTunnelsForHost(host).catch(() => {});
109
247
  }
110
248
 
111
249
  /**
@@ -141,6 +279,8 @@ export class PresencePool {
141
279
  const connection = this.#connections.get(host);
142
280
  this.#connections.delete(host);
143
281
  this.#lastUsed.delete(host);
282
+ // A deliberate disconnect is not a failure: the host goes back to `idle`.
283
+ this.#hostStates.delete(host);
144
284
  for (const terminal of [...this.#terminals.values()]) {
145
285
  if (terminal.host === host) this.#closeTerminal(terminal.id);
146
286
  }
@@ -249,12 +389,19 @@ export class PresencePool {
249
389
  if (!command) throw new Error('command is required');
250
390
  const connection = await this.connection(host);
251
391
  const started = await connection.startBackground(command, { cwd, logPath });
252
- const id = `j${(this.#counter += 1)}`;
392
+ const id = this.#nextJobId();
393
+ const pid = Number(started.pid);
253
394
  const job = {
254
395
  id,
255
396
  host,
256
- pid: started.pid,
397
+ pid,
398
+ pgid: Number.isFinite(Number(started.pgid)) ? Number(started.pgid) : pid,
257
399
  logPath: started.logPath,
400
+ // The 0.20 factory returns a distinct command-output log when the
401
+ // wrapper exposes one; the current kernel returns only `logPath`, which
402
+ // IS the command's stdout/stderr log. Keep the public field non-null and
403
+ // truthful either way (it equals logPath on today's kernels).
404
+ commandLogPath: started.commandLogPath ?? started.logPath ?? null,
258
405
  statusPath: started.statusPath,
259
406
  command,
260
407
  cwd,
@@ -268,13 +415,29 @@ export class PresencePool {
268
415
  logSize: null,
269
416
  logTail: '',
270
417
  stopped: false,
418
+ persisted: false,
419
+ lastPersistedJson: null,
271
420
  };
272
421
  this.#jobs.set(id, job);
422
+ // Persist before the early-death check: a job that dies instantly must
423
+ // still leave a record, otherwise a restart cannot explain it.
424
+ this.#syncJob(job);
273
425
 
274
426
  await sleep(JOB_EARLY_CHECK_MS);
275
427
  const alive = await this.#groupAlive(connection, job.pid);
276
428
  if (alive !== false) {
277
- return { id, host, pid: job.pid, logPath: job.logPath, statusPath: job.statusPath, label: job.label, sessionId: job.sessionId };
429
+ this.#syncJob(job);
430
+ return {
431
+ id,
432
+ host,
433
+ pid: job.pid,
434
+ pgid: job.pgid,
435
+ logPath: job.logPath,
436
+ commandLogPath: job.commandLogPath,
437
+ statusPath: job.statusPath,
438
+ label: job.label,
439
+ sessionId: job.sessionId,
440
+ };
278
441
  }
279
442
  const status = await connection.backgroundStatus(job.statusPath);
280
443
  const output = await this.#readOutput(connection, job, JOB_EARLY_TAIL_LINES);
@@ -283,11 +446,14 @@ export class PresencePool {
283
446
  job.exitedAt = Date.now();
284
447
  job.logSize = output.logSize;
285
448
  job.logTail = output.logTail;
449
+ this.#syncJob(job);
286
450
  return {
287
451
  id,
288
452
  host,
289
453
  pid: job.pid,
454
+ pgid: job.pgid,
290
455
  logPath: job.logPath,
456
+ commandLogPath: job.commandLogPath,
291
457
  statusPath: job.statusPath,
292
458
  label: job.label,
293
459
  sessionId: job.sessionId,
@@ -334,6 +500,7 @@ export class PresencePool {
334
500
  job.exitedAt ??= Date.now();
335
501
  job.logSize = output.logSize ?? job.logSize;
336
502
  job.logTail = output.logTail;
503
+ this.#syncJob(job);
337
504
  return this.#jobResult(job, {
338
505
  state: 'exited',
339
506
  exitCode: status.exitCode,
@@ -354,6 +521,7 @@ export class PresencePool {
354
521
  job.exitedAt ??= Date.now();
355
522
  job.logSize = output.logSize ?? job.logSize;
356
523
  job.logTail = output.logTail;
524
+ this.#syncJob(job);
357
525
  return this.#jobResult(job, {
358
526
  state: 'exited',
359
527
  exitCode: null,
@@ -396,7 +564,9 @@ export class PresencePool {
396
564
  id: job?.id ?? null,
397
565
  host: target,
398
566
  pid: numeric,
567
+ pgid: job?.pgid ?? numeric,
399
568
  logPath: job?.logPath ?? null,
569
+ commandLogPath: job?.commandLogPath ?? null,
400
570
  stopped: result.stopped,
401
571
  wasAlive: result.wasAlive,
402
572
  reason: result.reason ?? null,
@@ -412,7 +582,9 @@ export class PresencePool {
412
582
  id,
413
583
  host: job.host,
414
584
  pid: job.pid,
585
+ pgid: job.pgid ?? job.pid ?? null,
415
586
  logPath: job.logPath,
587
+ commandLogPath: job.commandLogPath ?? null,
416
588
  stopped: result.stopped,
417
589
  wasAlive: result.wasAlive,
418
590
  reason: result.reason ?? null,
@@ -454,6 +626,7 @@ export class PresencePool {
454
626
  job.exitedAt ??= Date.now();
455
627
  job.exitCode = result.exitCode ?? job.exitCode ?? null;
456
628
  }
629
+ this.#syncJob(job);
457
630
  }
458
631
  return {
459
632
  host,
@@ -469,10 +642,11 @@ export class PresencePool {
469
642
  }
470
643
 
471
644
  /**
472
- * Drop local bookkeeping for jobs that are finished and past their welcome:
473
- * older than `maxAgeMs`, or beyond the newest `maxEntries`. Only EXITED jobs
474
- * are dropped — a running (or still unknown) job keeps its slot no matter
475
- * how old it is, and the detached process on the machine is never touched.
645
+ * Drop bookkeeping for jobs that are finished and past their welcome: older
646
+ * than `maxAgeMs`, or beyond the newest `maxEntries`. Only EXITED jobs are
647
+ * dropped — a running (or still unknown) job keeps its slot no matter how old
648
+ * it is, and the detached process on the machine is never touched. The
649
+ * persisted store is bounded by the same limits.
476
650
  * Returns the ids that were forgotten.
477
651
  */
478
652
  async pruneJobs({ maxAgeMs = JOB_PRUNE_MAX_AGE_MS, maxEntries = JOB_PRUNE_MAX_ENTRIES } = {}) {
@@ -492,15 +666,42 @@ export class PresencePool {
492
666
  if (job.state === 'exited') dropped.add(job.id);
493
667
  }
494
668
  for (const id of dropped) this.#jobs.delete(id);
669
+ if (this.#jobStore) {
670
+ // The store applies the same age/count bounds; a record it drops beyond
671
+ // the local view is forgotten here too, so the two cannot drift apart.
672
+ let pruned = { removed: 0, ids: [] };
673
+ try {
674
+ pruned = this.#jobStore.prune({ maxAgeMs: age, maxEntries: limit }) ?? pruned;
675
+ } catch {
676
+ /* persistence is an optimization; a broken store must not fail a prune */
677
+ }
678
+ for (const id of Array.isArray(pruned.ids) ? pruned.ids : []) {
679
+ this.#jobs.delete(id);
680
+ dropped.add(id);
681
+ }
682
+ // Only records the store's own bound did not already drop need a second
683
+ // write; normally this set is empty (one save, not one per dropped job).
684
+ const prunedIds = new Set(Array.isArray(pruned.ids) ? pruned.ids : []);
685
+ for (const id of [...dropped]) {
686
+ if (prunedIds.has(id)) continue;
687
+ try {
688
+ this.#jobStore.remove(id);
689
+ } catch {
690
+ /* already gone */
691
+ }
692
+ }
693
+ }
495
694
  this.#lastPruneAt = now;
496
695
  return { removed: dropped.size, ids: [...dropped] };
497
696
  }
498
697
 
499
698
  /**
500
- * Jobs this pool started, optionally narrowed to one session, with liveness
501
- * read from the machines connected right now. `state` is 'unknown' when the
502
- * host cannot be asked: a detached job outlives its SSH connection, so no
503
- * answer is not the same as a dead job.
699
+ * Jobs this pool started, plus records inherited from the persisted store,
700
+ * optionally narrowed to one session, with liveness read from the machines
701
+ * connected right now. A record loaded from disk is `unknown` until a pid
702
+ * probe says otherwise: a detached job outlives its SSH connection, so no
703
+ * answer is not the same as a dead job. A record says `persisted: true` when
704
+ * it came from the JSON store rather than from this process.
504
705
  */
505
706
  async listJobs({ sessionId = undefined } = {}) {
506
707
  // Local bookkeeping only, so no round trip; at most once a minute so a
@@ -508,6 +709,9 @@ export class PresencePool {
508
709
  if (Date.now() - this.#lastPruneAt >= JOB_PRUNE_INTERVAL_MS) {
509
710
  await this.pruneJobs().catch(() => {});
510
711
  }
712
+ // A record written by another process (or by a previous run) joins the view
713
+ // as `unknown`; the per-host liveness pass below may resolve it.
714
+ this.#mergePersistedJobs();
511
715
  const jobs = [...this.#jobs.values()].filter((job) => sessionId === undefined || job.sessionId === sessionId);
512
716
  if (jobs.length === 0) return [];
513
717
 
@@ -516,90 +720,228 @@ export class PresencePool {
516
720
  if (!byHost.has(job.host)) byHost.set(job.host, []);
517
721
  byHost.get(job.host).push(job);
518
722
  }
723
+ await Promise.all([...byHost.entries()].map(([host, hostJobs]) => this.#probeHostJobs(host, hostJobs)));
724
+ return jobs.map((job) => this.#jobRow(job));
725
+ }
726
+
727
+ // ------------------------------------------------- job registry internals
519
728
 
729
+ /**
730
+ * Ask one host about a group of jobs: seed its process registry, read the
731
+ * whole process table once and every status file once. Live jobs keep their
732
+ * state; a record loaded from disk is only promoted to `running` when `ps`
733
+ * actually finds its process group.
734
+ */
735
+ async #probeHostJobs(host, hostJobs) {
736
+ const connection = this.#connections.get(host);
737
+ if (!connection || connection.closed) {
738
+ for (const job of hostJobs) if (job.state !== 'exited') job.state = 'unknown';
739
+ for (const job of hostJobs) this.#syncJob(job);
740
+ return;
741
+ }
742
+ // A job started before a reconnect is not in this connection's registry;
743
+ // its process group still exists on the machine, so seed it back in.
744
+ for (const job of hostJobs) {
745
+ if (job.stopped || job.state === 'exited' || job.pid === null || job.pid === undefined) continue;
746
+ connection.trackBackground({
747
+ pid: job.pid,
748
+ command: job.command,
749
+ cwd: job.cwd,
750
+ logPath: job.logPath,
751
+ statusPath: job.statusPath,
752
+ startedAt: job.startedAt,
753
+ });
754
+ }
755
+ let running;
756
+ try {
757
+ running = new Set((await connection.listBackground()).map((entry) => entry.pid));
758
+ } catch {
759
+ // Liveness is a convenience; a failed `ps` must not hide the job list.
760
+ running = null;
761
+ }
762
+ if (!running) {
763
+ for (const job of hostJobs) if (job.state !== 'exited') job.state = 'unknown';
764
+ for (const job of hostJobs) this.#syncJob(job);
765
+ return;
766
+ }
767
+ // One round trip for every status file on this host: the command may have
768
+ // finished while a child it spawned kept the group alive.
769
+ let statuses = null;
770
+ try {
771
+ statuses = await connection.backgroundStatuses(hostJobs.map((job) => job.statusPath ?? ''));
772
+ } catch {
773
+ // Liveness from `ps` is still worth reporting.
774
+ }
520
775
  await Promise.all(
521
- [...byHost.entries()].map(async ([host, hostJobs]) => {
522
- const connection = this.#connections.get(host);
523
- if (!connection || connection.closed) {
524
- for (const job of hostJobs) if (job.state !== 'exited') job.state = 'unknown';
525
- return;
526
- }
527
- // A job started before a reconnect is not in this connection's registry;
528
- // its process group still exists on the machine, so seed it back in.
529
- for (const job of hostJobs) {
530
- if (job.stopped || job.state === 'exited') continue;
531
- connection.trackBackground({
532
- pid: job.pid,
533
- command: job.command,
534
- cwd: job.cwd,
535
- logPath: job.logPath,
536
- statusPath: job.statusPath,
537
- startedAt: job.startedAt,
538
- });
539
- }
540
- let running;
541
- try {
542
- running = new Set((await connection.listBackground()).map((entry) => entry.pid));
543
- } catch {
544
- // Liveness is a convenience; a failed `ps` must not hide the job list.
545
- running = null;
776
+ hostJobs.map(async (job, index) => {
777
+ const status = statuses?.[index] ?? null;
778
+ if (job.stopped) {
779
+ job.state = 'exited';
780
+ job.exitedAt ??= Date.now();
781
+ } else if (status?.found) {
782
+ job.state = 'exited';
783
+ job.exitCode = status.exitCode;
784
+ job.exitedAt ??= Date.now();
785
+ } else if (job.state === 'exited') {
786
+ // Already reported finished; a recycled pgid must not resurrect it.
787
+ job.exitedAt ??= Date.now();
788
+ } else if (job.pid !== null && job.pid !== undefined && running.has(job.pid)) {
789
+ job.state = 'running';
790
+ } else {
791
+ // No wrapper left to write a status: it was killed.
792
+ job.state = 'exited';
793
+ job.exitCode = null;
794
+ job.exitedAt ??= Date.now();
546
795
  }
547
- if (!running) {
548
- for (const job of hostJobs) if (job.state !== 'exited') job.state = 'unknown';
549
- return;
550
- }
551
- // One round trip for every status file on this host: the command may
552
- // have finished while a child it spawned kept the group alive.
553
- let statuses = null;
554
- try {
555
- statuses = await connection.backgroundStatuses(hostJobs.map((job) => job.statusPath));
556
- } catch {
557
- // Liveness from `ps` is still worth reporting.
558
- }
559
- await Promise.all(
560
- hostJobs.map(async (job, index) => {
561
- const status = statuses?.[index] ?? null;
562
- if (job.stopped) {
563
- job.state = 'exited';
564
- job.exitedAt ??= Date.now();
565
- } else if (status?.found) {
566
- job.state = 'exited';
567
- job.exitCode = status.exitCode;
568
- job.exitedAt ??= Date.now();
569
- } else if (job.state === 'exited') {
570
- // Already reported finished; a recycled pgid must not resurrect it.
571
- job.exitedAt ??= Date.now();
572
- } else if (running.has(job.pid)) {
573
- job.state = 'running';
574
- } else {
575
- // No wrapper left to write a status: it was killed.
576
- job.state = 'exited';
577
- job.exitCode = null;
578
- job.exitedAt ??= Date.now();
579
- }
580
- const info = await connection.stat(job.logPath).catch(() => null);
581
- if (info?.exists) job.logSize = info.size;
582
- }),
583
- );
796
+ const info = job.logPath ? await connection.stat(job.logPath).catch(() => null) : null;
797
+ if (info?.exists) job.logSize = info.size;
798
+ this.#syncJob(job);
584
799
  }),
585
800
  );
801
+ }
586
802
 
587
- return jobs.map((job) => ({
803
+ /** One public job row (live view, no remote probe). */
804
+ #jobRow(job) {
805
+ return {
588
806
  id: job.id,
589
807
  host: job.host,
590
- pid: job.pid,
808
+ pid: job.pid ?? null,
809
+ pgid: job.pgid ?? job.pid ?? null,
591
810
  state: job.state ?? 'unknown',
592
811
  exitCode: job.exitCode ?? null,
593
812
  exitSignal: job.exitSignal ?? null,
813
+ exitedAt: job.exitedAt ?? null,
594
814
  label: job.label ?? null,
595
815
  sessionId: job.sessionId ?? null,
596
- startedAt: job.startedAt,
597
- logPath: job.logPath,
816
+ startedAt: job.startedAt ?? null,
817
+ logPath: job.logPath ?? null,
818
+ commandLogPath: job.commandLogPath ?? null,
598
819
  logSize: job.logSize ?? null,
820
+ persisted: job.persisted === true,
599
821
  // Kept for the existing jobs view, which still prints the command line.
600
- command: job.command,
601
- cwd: job.cwd,
602
- }));
822
+ command: job.command ?? null,
823
+ cwd: job.cwd ?? null,
824
+ };
825
+ }
826
+
827
+ /**
828
+ * Next job id. The plain `jN` counter keeps ids readable; when a store is
829
+ * attached a short random suffix keeps two Harness windows that share the
830
+ * same jobs.json from overwriting each other's records.
831
+ */
832
+ #nextJobId() {
833
+ this.#counter += 1;
834
+ const base = `j${this.#counter}`;
835
+ return this.#jobStore ? `${base}-${randomUUID().slice(0, 4)}` : base;
836
+ }
837
+
838
+ #numericSuffix(id) {
839
+ const match = /^j(\d+)/u.exec(String(id ?? ''));
840
+ return match ? Number(match[1]) : 0;
841
+ }
842
+
843
+ /** Turn one stored record into an in-memory job; never assumes it is running. */
844
+ #adoptPersistedRecord(record) {
845
+ if (!record || typeof record !== 'object' || !record.id || this.#jobs.has(record.id)) return null;
846
+ const pid = Number.isFinite(Number(record.pid)) ? Number(record.pid) : null;
847
+ const pgid = Number.isFinite(Number(record.pgid)) ? Number(record.pgid) : pid;
848
+ const job = {
849
+ id: record.id,
850
+ host: record.host,
851
+ pid,
852
+ pgid,
853
+ logPath: record.logPath ?? null,
854
+ commandLogPath: record.commandLogPath ?? null,
855
+ statusPath: record.statusPath ?? null,
856
+ command: record.command ?? null,
857
+ cwd: record.cwd ?? null,
858
+ label: record.label ?? null,
859
+ sessionId: record.sessionId ?? null,
860
+ startedAt: record.startedAt ?? null,
861
+ // A finished record stays finished; anything else is `unknown` until a
862
+ // liveness probe proves the process group still exists.
863
+ state: record.state === 'exited' ? 'exited' : 'unknown',
864
+ exitCode: record.exitCode ?? null,
865
+ exitSignal: record.exitSignal ?? null,
866
+ exitedAt: record.exitedAt ?? null,
867
+ logSize: null,
868
+ logTail: '',
869
+ stopped: false,
870
+ persisted: true,
871
+ lastPersistedJson: null,
872
+ };
873
+ this.#jobs.set(job.id, job);
874
+ this.#counter = Math.max(this.#counter, this.#numericSuffix(job.id));
875
+ return job;
876
+ }
877
+
878
+ #storeRecords() {
879
+ const store = this.#jobStore;
880
+ if (!store || typeof store.list !== 'function') return [];
881
+ try {
882
+ const records = store.list();
883
+ return Array.isArray(records) ? records : [];
884
+ } catch {
885
+ return [];
886
+ }
887
+ }
888
+
889
+ /** Load the registry at startup; records are visible but not trusted as live. */
890
+ #loadPersistedJobs() {
891
+ for (const record of this.#storeRecords()) this.#adoptPersistedRecord(record);
892
+ }
893
+
894
+ /** Pick up records a second process (or an earlier prune) wrote meanwhile. */
895
+ #mergePersistedJobs() {
896
+ for (const record of this.#storeRecords()) this.#adoptPersistedRecord(record);
897
+ }
898
+
899
+ /**
900
+ * First connection to a host after a restart: resolve that machine's
901
+ * persisted jobs instead of leaving them `unknown` until the next listing.
902
+ * Called fire-and-forget from the connection handler; never affects dialling.
903
+ */
904
+ async #refreshPersistedJobs(host) {
905
+ const pending = [...this.#jobs.values()].filter(
906
+ (job) => job.host === host && job.persisted === true && job.state !== 'exited',
907
+ );
908
+ if (pending.length === 0) return;
909
+ await this.#probeHostJobs(host, pending);
910
+ }
911
+
912
+ /**
913
+ * Write one job through to the JSON store, skipping unchanged records: the
914
+ * state poll would otherwise rewrite the file every couple of seconds.
915
+ */
916
+ #syncJob(job) {
917
+ const store = this.#jobStore;
918
+ if (!store || typeof store.upsert !== 'function') return;
919
+ const record = {
920
+ id: job.id,
921
+ host: job.host,
922
+ label: job.label ?? null,
923
+ command: job.command ?? null,
924
+ sessionId: job.sessionId ?? null,
925
+ pid: job.pid ?? null,
926
+ pgid: job.pgid ?? job.pid ?? null,
927
+ logPath: job.logPath ?? null,
928
+ commandLogPath: job.commandLogPath ?? null,
929
+ statusPath: job.statusPath ?? null,
930
+ cwd: job.cwd ?? null,
931
+ startedAt: job.startedAt ?? null,
932
+ exitedAt: job.exitedAt ?? null,
933
+ exitCode: job.exitCode ?? null,
934
+ exitSignal: job.exitSignal ?? null,
935
+ state: job.state ?? 'unknown',
936
+ };
937
+ const json = JSON.stringify(record);
938
+ if (json === job.lastPersistedJson) return;
939
+ try {
940
+ store.upsert(record);
941
+ job.lastPersistedJson = json;
942
+ } catch {
943
+ /* a read-only home must never fail a tool; the live job still works */
944
+ }
603
945
  }
604
946
 
605
947
  /**
@@ -632,7 +974,11 @@ export class PresencePool {
632
974
  id: job.id,
633
975
  host: job.host,
634
976
  pid: job.pid,
977
+ pgid: job.pgid ?? job.pid ?? null,
978
+ // The wrapper log (exit status) and, when the kernel exposes one, the
979
+ // command's own stdout/stderr log. Older kernels only have `logPath`.
635
980
  logPath: job.logPath,
981
+ commandLogPath: job.commandLogPath ?? null,
636
982
  statusPath: job.statusPath,
637
983
  state,
638
984
  exitCode,
@@ -814,5 +1160,6 @@ export class PresencePool {
814
1160
  this.#jobs.clear();
815
1161
  for (const connection of this.#connections.values()) connection.end();
816
1162
  this.#connections.clear();
1163
+ this.#hostStates.clear();
817
1164
  }
818
1165
  }