reslop 0.3.0 → 0.3.2

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.
Files changed (74) hide show
  1. package/CHANGELOG.md +29 -1
  2. package/README.md +21 -3
  3. package/lib/agents/acp.js +309 -0
  4. package/lib/agents/agents.js +166 -370
  5. package/lib/agents/allow.js +141 -52
  6. package/lib/agents/allow.json +16 -13
  7. package/lib/agents/models.js +250 -376
  8. package/lib/agents/registry.js +437 -0
  9. package/lib/agents/run.js +234 -0
  10. package/lib/agents/transcript.js +143 -0
  11. package/lib/cli.js +2 -1
  12. package/lib/common/process.js +26 -1
  13. package/lib/dashboard/git.js +11 -1
  14. package/lib/dashboard/reports.js +80 -0
  15. package/lib/dashboard/watch.js +43 -8
  16. package/lib/dashboard/workspace.js +19 -20
  17. package/lib/dashboard/worktrees.js +145 -0
  18. package/lib/git/deps.js +29 -0
  19. package/lib/git/dir.js +50 -0
  20. package/lib/git/git.js +1 -1
  21. package/lib/git/linked.js +152 -0
  22. package/lib/git/local.js +179 -0
  23. package/lib/git/status.js +126 -0
  24. package/lib/git/worktree.js +137 -31
  25. package/lib/input/actions.js +83 -2
  26. package/lib/input/editor.js +9 -0
  27. package/lib/input/keys.js +39 -3
  28. package/lib/input/line-edit.js +8 -3
  29. package/lib/render/agents.js +64 -36
  30. package/lib/render/branches.js +55 -27
  31. package/lib/render/field.js +19 -5
  32. package/lib/render/footer.js +14 -4
  33. package/lib/render/frame.js +16 -2
  34. package/lib/render/log.js +5 -4
  35. package/lib/render/menu.js +103 -6
  36. package/lib/render/name-edit.js +31 -0
  37. package/lib/render/repos.js +52 -8
  38. package/lib/render/status.js +22 -4
  39. package/lib/report/render.js +4 -7
  40. package/lib/report/run.js +16 -23
  41. package/lib/review/agent-instructions.md +13 -8
  42. package/lib/review/format.js +0 -6
  43. package/lib/runs/process.js +3 -3
  44. package/lib/runs/runs.js +27 -4
  45. package/lib/session/agents/agents.js +227 -194
  46. package/lib/session/agents/away.js +48 -0
  47. package/lib/session/agents/job.js +23 -86
  48. package/lib/session/agents/jobs.js +14 -27
  49. package/lib/session/agents/options.js +45 -101
  50. package/lib/session/agents/plans.js +53 -0
  51. package/lib/session/agents/view.js +34 -0
  52. package/lib/session/branches.js +18 -2
  53. package/lib/session/compose.js +20 -6
  54. package/lib/session/dispatch.js +43 -3
  55. package/lib/session/editor-pointer.js +98 -0
  56. package/lib/session/files-pane.js +3 -0
  57. package/lib/session/import.js +12 -1
  58. package/lib/session/lifecycle.js +70 -10
  59. package/lib/session/navigation.js +5 -0
  60. package/lib/session/npm/history.js +3 -1
  61. package/lib/session/npm/npm.js +111 -13
  62. package/lib/session/ops.js +3 -0
  63. package/lib/session/pointer.js +304 -46
  64. package/lib/session/session.js +17 -2
  65. package/lib/session/tasks.js +1 -1
  66. package/lib/session/terminal.js +2 -2
  67. package/lib/session/text-menu.js +50 -0
  68. package/lib/session/view.js +19 -2
  69. package/lib/session/workspace.js +179 -38
  70. package/lib/session/worktrees.js +280 -0
  71. package/lib/term/select.js +2 -1
  72. package/lib/term/themes/dark.json +4 -0
  73. package/lib/term/themes/light.json +4 -0
  74. package/package.json +2 -2
@@ -9,6 +9,7 @@ const { paintTile, composeBands, tileHits, pageFill } = tiles;
9
9
  const { segmentsWidth, clipSegments, blinkTone } = tiles;
10
10
  const { size } = require('../common/format.js');
11
11
  const { isHot } = require('./table.js');
12
+ const { paintNameEdit } = require('./name-edit.js');
12
13
 
13
14
  const REPO_MIN_H = 6;
14
15
  const MARGIN_MIN_H = 12;
@@ -40,11 +41,24 @@ const fitAside = (left, right, width) => {
40
41
  return [...clipped, seg(' '.repeat(gap)), ...right];
41
42
  };
42
43
 
44
+ // A linked worktree hangs under its main one, the branch tells them apart
45
+ const branchSegs = (row) => {
46
+ if (!row.branch && !row.head) return [];
47
+ const label = row.branch || `(${row.head})`;
48
+ const segs = [seg(` ⎇ ${label}`, 'sha')];
49
+ if (row.dirty) segs.push(seg('*', 'warn', true));
50
+ if (row.locked) segs.push(seg(' locked', 'muted'));
51
+ return segs;
52
+ };
53
+
43
54
  const titleLine = (row, ctx, selected, width) => {
44
55
  const hot = isHot(row.activityAt, ctx.now);
45
56
  const tone = hot ? blinkTone(ctx.frame) : 'text';
46
- const left = [seg(row.name, tone, selected || hot)];
57
+ const left = [];
58
+ if (row.linked) left.push(seg('↳ ', 'muted'));
59
+ left.push(seg(row.name, tone, selected || hot));
47
60
  if (hot) left.push(seg(' ●', tone, true));
61
+ left.push(...branchSegs(row));
48
62
  const right = [];
49
63
  if (rowPending(row)) right.push(spin(ctx.frame), seg(' '));
50
64
  const label = row.filesReady ? size(row.bytes) : '…';
@@ -90,7 +104,7 @@ const resultNote = (run) => {
90
104
  return [];
91
105
  };
92
106
 
93
- const runLine = (row, ctx) => {
107
+ const runState = (row, ctx) => {
94
108
  if (!row.runsReady) return [seg('run: ', 'muted'), spin(ctx.frame)];
95
109
  const run = row.run;
96
110
  if (!run) return [seg('run: ', 'muted'), seg('idle', 'muted')];
@@ -113,6 +127,17 @@ const runLine = (row, ctx) => {
113
127
  ];
114
128
  };
115
129
 
130
+ // The agents working in the directory go on the line of the run
131
+ const agentsNote = (row) => {
132
+ const count = row.agents ?? 0;
133
+ return [
134
+ seg(' agents active: ', 'muted'),
135
+ seg(`${count}`, count ? 'warn' : 'muted', count > 0),
136
+ ];
137
+ };
138
+
139
+ const runLine = (row, ctx) => [...runState(row, ctx), ...agentsNote(row)];
140
+
116
141
  const npmLine = (row, ctx) => {
117
142
  if (!row.npmReady) return [seg('deps: ', 'muted'), spin(ctx.frame)];
118
143
  const npm = row.npm;
@@ -191,14 +216,29 @@ const paintRepos = (model, bands, color) => {
191
216
  return painted;
192
217
  };
193
218
 
219
+ // One row of tiles above another list, paged sideways
220
+ const paintTreeBand = (model, width, height, color) => {
221
+ const grid = equalGrid(width, height, model.tiles.length, 34, height);
222
+ const page = pageWindow(model.tiles.length, model.cursor, grid.cap);
223
+ const shown = model.tiles.slice(page.start, page.end);
224
+ const bands = placeEqualTiles(shown, grid);
225
+ const painted = paintRepos(model, bands, color);
226
+ const rows = composeBands(bands, painted, width, height, color);
227
+ return { rows, bands, cols: grid.cols };
228
+ };
229
+
230
+ // The name of a new worktree is typed in a row above the tiles
194
231
  const paintBodyRepos = (view, width, color, bodyH, headerLines) => {
195
232
  const model = view.repos;
196
233
  if (!model || !model.tiles.length) {
197
234
  const blank = paintBodyFill(width, color, 'files');
198
235
  return paneResult({ body: new Array(bodyH).fill(blank) });
199
236
  }
200
- const margin = bodyH >= MARGIN_MIN_H ? 1 : 0;
201
- const height = Math.max(1, bodyH - margin * 2);
237
+ const naming = view.compose?.kind === 'worktree';
238
+ const edit = naming ? paintNameEdit(view.compose, width, color) : null;
239
+ const lead = edit ? 1 : 0;
240
+ const margin = bodyH - lead >= MARGIN_MIN_H ? 1 : 0;
241
+ const height = Math.max(1, bodyH - lead - margin * 2);
202
242
  const grid = equalGrid(width, height, model.tiles.length, 34, REPO_MIN_H);
203
243
  const page = pageWindow(model.tiles.length, model.cursor, grid.cap);
204
244
  const shown = model.tiles.slice(page.start, page.end);
@@ -206,9 +246,13 @@ const paintBodyRepos = (view, width, color, bodyH, headerLines) => {
206
246
  const painted = paintRepos(model, bands, color);
207
247
  const rows = composeBands(bands, painted, width, height, color);
208
248
  const pad = new Array(margin).fill(pageFill(width, color));
209
- const body = [...pad, ...rows, ...pad];
210
- const fileHits = tileHits(bands, headerLines + margin);
211
- return { ...paneResult({ body, fileHits }), gridCols: grid.cols };
249
+ const head = edit ? [edit.row] : [];
250
+ const body = [...head, ...pad, ...rows, ...pad];
251
+ const fileHits = tileHits(bands, headerLines + lead + margin);
252
+ const typing = edit ? { cursor: { ...edit.cursor, row: 0 } } : {};
253
+ const editHits = edit ? edit.edits.map((span) => ({ ...span, row: 0 })) : [];
254
+ const result = paneResult({ body, fileHits, editHits, ...typing });
255
+ return { ...result, gridCols: grid.cols };
212
256
  };
213
257
 
214
- module.exports = { paintBodyRepos };
258
+ module.exports = { paintBodyRepos, paintTreeBand };
@@ -9,6 +9,7 @@ const { npmStatus } = require('./dashboard/blocks.js');
9
9
  const { menuBoxWidth, dropBox, menuFaces } = require('./menu.js');
10
10
  const field = require('./field.js');
11
11
  const { paintPlanFace, paintImportStatus, paintFindStatus } = field;
12
+ const { fieldHit } = field;
12
13
  const { fileTotals } = files;
13
14
  const { CONFIRM, confirmPrefix, promptFromChoices } = actions;
14
15
  const { THEME, paint, visibleWidth } = ansi;
@@ -267,8 +268,8 @@ const paintTasksLeft = (view, text, color) => {
267
268
  const before = text.slice(0, place.at);
268
269
  const after = text.slice(place.at + place.face.length);
269
270
  const searching = Boolean(view.planOpen && view.planQuery);
270
- const comboFg = searching ? THEME.searchFg : THEME.shaFg;
271
- const comboBg = searching ? THEME.searchBg : THEME.chromeBg;
271
+ const comboFg = searching ? THEME.warnFg : THEME.shaFg;
272
+ const comboBg = THEME.chromeBg;
272
273
  const branch = paintBranchHead(before, view.branch, color);
273
274
  const head = branch ?? paintStatusLeft(before, color);
274
275
  const face = paintPlanFace(view, place.face, comboFg, comboBg, color);
@@ -293,7 +294,10 @@ const paintStatusLine = (view, status, width, color) => {
293
294
  const leftGap = ' '.repeat(layout.padLeft);
294
295
  const rightGap = ' '.repeat(layout.padRight);
295
296
  if (!color) {
296
- return layout.clipped + leftGap + layout.msg + rightGap + layout.right;
297
+ const left = view.planOpen
298
+ ? paintTasksLeft(view, layout.clipped, false)
299
+ : layout.clipped;
300
+ return left + leftGap + layout.msg + rightGap + layout.right;
297
301
  }
298
302
  const bgRgb = THEME.chromeBg;
299
303
  const head = statusHead(view, layout.clipped, color);
@@ -323,6 +327,19 @@ const statusPlanHit = (view, width, y) => {
323
327
  return { id: 'plan', y, x0, x1: x0 + visibleWidth(place.face) };
324
328
  };
325
329
 
330
+ const statusEditHits = (view, width, y) => {
331
+ const editor = view.import?.editor ?? view.find?.editor;
332
+ if (editor) {
333
+ const x = view.import ? 5 : 2;
334
+ return [fieldHit(editor, x, Math.max(0, width - x), y)];
335
+ }
336
+ if (!view.planOpen || !view.planEditor) return [];
337
+ const layout = statusLayout(view, view.status ?? '', width);
338
+ const place = planFace(view, layout.clipped);
339
+ if (!place) return [];
340
+ return [fieldHit(view.planEditor, place.x, visibleWidth(place.face), y)];
341
+ };
342
+
326
343
  const planMenu = (view, width, color, statusY) => {
327
344
  if (view.pane !== 'tasks' || !view.planOpen || statusY < 3) return null;
328
345
  const names = view.planNames ?? [];
@@ -333,7 +350,7 @@ const planMenu = (view, width, color, statusY) => {
333
350
  const room = Math.max(1, statusY - 2);
334
351
  const box = dropBox(names, view.planCursor, view.planScroll, room);
335
352
  const boxW = menuBoxWidth(names, visibleWidth(place.face), width - x);
336
- const faces = menuFaces(names, box, boxW, color);
353
+ const faces = menuFaces(names, box, boxW, color, view.planQuery);
337
354
  const lines = [];
338
355
  const hits = [];
339
356
  for (let i = 0; i < faces.length; i++) {
@@ -366,5 +383,6 @@ module.exports = {
366
383
  statusChoiceHits,
367
384
  statusBranchHit,
368
385
  statusPlanHit,
386
+ statusEditHits,
369
387
  planMenu,
370
388
  };
@@ -3,15 +3,12 @@
3
3
  const { unquote } = require('../common/utilities.js');
4
4
  const { formatTable } = require('../runs/output.js');
5
5
 
6
- const NOTICE_HEAD =
7
- 'reslop: reduced output; passing results are omitted ' +
8
- 'and similar problems are grouped';
6
+ const NOTICE_HEAD = 'Reduced output;';
9
7
 
10
8
  const notice = (rawFile) => {
11
- let target = 'the matching raw log under `.log/`';
12
- if (rawFile) target = `the raw log \`.log/${rawFile}\``;
13
- const hint = `Agent: if this report hides a needed detail, read ${target}.`;
14
- return `${NOTICE_HEAD}\n${hint}`;
9
+ let target = 'under `.log/`';
10
+ if (rawFile) target = `\`.log/${rawFile}\``;
11
+ return `${NOTICE_HEAD} the raw log if needed ${target}.`;
15
12
  };
16
13
 
17
14
  const addRow = (rows, key, value) => {
package/lib/report/run.js CHANGED
@@ -1,9 +1,8 @@
1
1
  'use strict';
2
2
 
3
3
  const path = require('node:path');
4
- const { spawn } = require('node:child_process');
5
4
 
6
- const { spawnBase } = require('../common/process.js');
5
+ const { spawnBase, spawnCmd, winCommand } = require('../common/process.js');
7
6
  const { withNewline } = require('../common/utilities.js');
8
7
  const { nextLogFile, saveLogs } = require('../runs/logs.js');
9
8
  const { toolHint, buildDocument } = require('./parse.js');
@@ -16,29 +15,12 @@ const {
16
15
 
17
16
  const OUTPUTS = ['md', 'raw'];
18
17
  const PIPE = ['ignore', 'pipe', 'pipe'];
19
- const CMD_SAFE = /^[\w./:=@+-]+$/;
20
-
21
- const quoteCmdArg = (arg) => {
22
- const text = `${arg}`;
23
- if (text && CMD_SAFE.test(text)) return text;
24
- const escaped = text.replaceAll('"', '""').replaceAll('%', '"%"');
25
- return `"${escaped}"`;
26
- };
27
-
28
- const winCommand = (program, args) => {
29
- const parts = [program, ...args].map(quoteCmdArg);
30
- return `"${parts.join(' ')}"`;
31
- };
18
+ const EXIT_DRAIN_MS = 100;
32
19
 
33
20
  const spawnCommand = (command, options) => {
34
21
  const { program, args } = command;
35
- if (process.platform !== 'win32') {
36
- return spawn(program, args, spawnBase(options));
37
- }
38
- const shell = process.env.comspec || 'cmd.exe';
39
- const line = winCommand(program, args);
40
- const opts = spawnBase({ ...options, windowsVerbatimArguments: true });
41
- return spawn(shell, ['/d', '/s', '/c', line], opts);
22
+ const shell = process.platform === 'win32';
23
+ return spawnCmd(program, args, spawnBase({ ...options, shell }));
42
24
  };
43
25
 
44
26
  const collect = (child, onOut, onErr) =>
@@ -46,9 +28,15 @@ const collect = (child, onOut, onErr) =>
46
28
  let out = '';
47
29
  let err = '';
48
30
  let settled = false;
49
- const finish = (status) => {
31
+ let exitTimer = null;
32
+ const finish = (status, release = false) => {
50
33
  if (settled) return;
51
34
  settled = true;
35
+ clearTimeout(exitTimer);
36
+ if (release) {
37
+ if (child.stdout) child.stdout.destroy();
38
+ if (child.stderr) child.stderr.destroy();
39
+ }
52
40
  resolve({ status, stdout: out, stderr: err });
53
41
  };
54
42
  if (child.stdout) {
@@ -69,6 +57,10 @@ const collect = (child, onOut, onErr) =>
69
57
  err += `${error.message}\n`;
70
58
  finish(1);
71
59
  });
60
+ child.on('exit', (status) => {
61
+ if (settled) return;
62
+ exitTimer = setTimeout(() => finish(status ?? 1, true), EXIT_DRAIN_MS);
63
+ });
72
64
  child.on('close', (status) => finish(status ?? 1));
73
65
  });
74
66
 
@@ -182,6 +174,7 @@ const runCapture = async (proc, deps = {}) => {
182
174
  };
183
175
 
184
176
  module.exports = {
177
+ collect,
185
178
  winCommand,
186
179
  runCapture,
187
180
  };
@@ -1,9 +1,14 @@
1
- - Execute reviews with `status` `editing, partial`
2
- - Do not start or change `done` review files
3
- - Work through unchecked items. After finishing one, mark it `[x]`
4
- - Mark `[x]` only near the end, after code changes are done and tests pass
5
- - For `code` items, replace added lines at that location
6
- - Set `status` to `partial` if some items remain, or `done` if all are `[x]`
1
+ Work through unchecked items.
2
+
3
+ - Run reviews with status `editing` or `partial`
4
+ - Do not start or change reviews with status `done`
5
+ - After finishing an item, mark it `[x]`
6
+ - Mark `[x]` only after the code changes are done and tests pass
7
+ - For `code` items, replace the added lines at the given location
8
+ - Set `status` to `partial` if any items remain, or `done` if all are `[x]`
7
9
  - Do not redo items already marked `[x]`
8
- - Run the full check with `npm t`
9
- - Run specific test files with `reslop t -- node --test <files>`
10
+ - Run `npm t` for lint and tests that do not start processes
11
+ - Run tests that start processes with `node --test <files>`
12
+ - An item prefixed with `F<number>:` gets its own commit in this repo
13
+ - Leave items without that prefix unstaged, do not commit or stage them
14
+ - Do not add `Authored-by` or `Co-authored-by` unless the issue asks for it
@@ -58,10 +58,6 @@ const TASK_TITLES = Object.fromEntries([
58
58
  ...TASK_KINDS.map((kind) => [kind.title, kind.id]),
59
59
  ]);
60
60
 
61
- const AGENT_BRIEF =
62
- 'Follow the agent instructions. Work through unchecked items. ' +
63
- 'Mark [x] only near exit after tests pass.';
64
-
65
61
  const AGENT_INSTRUCTIONS = resource(__dirname, 'agent-instructions.md').trim();
66
62
 
67
63
  const canonicalStatus = (raw) => {
@@ -179,8 +175,6 @@ const reviewHeader = (notes) => {
179
175
  '',
180
176
  '## Agent instructions',
181
177
  '',
182
- AGENT_BRIEF,
183
- '',
184
178
  AGENT_INSTRUCTIONS,
185
179
  '',
186
180
  ];
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const { spawn } = require('node:child_process');
4
- const { spawnBase } = require('../common/process.js');
4
+ const { spawnBase, spawnCmd } = require('../common/process.js');
5
5
 
6
6
  const signalPid = (pid, signal) => {
7
7
  try {
@@ -80,7 +80,7 @@ class RunProcess {
80
80
  class PipeProcess extends RunProcess {
81
81
  start(cmd, args, options) {
82
82
  try {
83
- this.child = spawn(cmd, args, options);
83
+ this.child = spawnCmd(cmd, args, options);
84
84
  } catch (error) {
85
85
  this.text = `${error.message}\n`;
86
86
  this.finish(1);
@@ -110,4 +110,4 @@ class PipeProcess extends RunProcess {
110
110
  }
111
111
  }
112
112
 
113
- module.exports = { commandEnv, RunProcess, PipeProcess };
113
+ module.exports = { killTree, commandEnv, RunProcess, PipeProcess };
package/lib/runs/runs.js CHANGED
@@ -13,6 +13,8 @@ const { nextLogFile } = require('./logs.js');
13
13
  const RUNS_REL = `${LOG_DIR}/.runs`;
14
14
  const RUNS_KEEP = 24;
15
15
  const WRITE_MS = 150;
16
+ const HEARTBEAT_MS = 1000;
17
+ const HEARTBEAT_STALE_MS = 5000;
16
18
  const FAILING_TEST = /^\s*(?:not ok \d+|[✖✗×]) /;
17
19
  const LIFECYCLE_EVENT = 'npm_lifecycle_event';
18
20
 
@@ -73,9 +75,19 @@ const listJson = (dir) => listDir(dir).filter((name) => name.endsWith('.json'));
73
75
  const isCommandRun = (record) =>
74
76
  Boolean(record && record.id && record.progress && !record.cliId);
75
77
 
76
- const settleRecord = (record, alive = pidAlive) => {
77
- if (record.status !== 'running' || alive(record.pid)) return record;
78
- return { ...record, status: 'aborted' };
78
+ const settleRecord = (record, alive = pidAlive, now = Date.now) => {
79
+ if (record.status !== 'running') return record;
80
+ const activeAt = Number(record.activeAt);
81
+ const current = Number(now());
82
+ const fresh =
83
+ record.v >= 2 &&
84
+ Number.isFinite(activeAt) &&
85
+ Number.isFinite(current) &&
86
+ current - activeAt <= HEARTBEAT_STALE_MS;
87
+ if (fresh && alive(record.pid)) return record;
88
+ const endedAt =
89
+ Number(record.endedAt) || activeAt || Number(record.startedAt) || 0;
90
+ return { ...record, status: 'aborted', endedAt };
79
91
  };
80
92
 
81
93
  const pushRuns = (records, dir, names) => {
@@ -198,6 +210,7 @@ class RunRecorder {
198
210
  this.tracker = new Tracker();
199
211
  this.lastWrite = 0;
200
212
  this.closed = false;
213
+ this.heartbeat = null;
201
214
  this.slot = Object.hasOwn(options, 'slot') ? options.slot : null;
202
215
  if (!Object.hasOwn(options, 'slot')) {
203
216
  try {
@@ -209,7 +222,7 @@ class RunRecorder {
209
222
  const startedAt = this.clock();
210
223
  const env = options.env || process.env;
211
224
  this.record = {
212
- v: 1,
225
+ v: 2,
213
226
  id: `${startedAt}-${process.pid}`,
214
227
  pid: process.pid,
215
228
  command,
@@ -217,6 +230,7 @@ class RunRecorder {
217
230
  cwd: root,
218
231
  startedAt,
219
232
  endedAt: 0,
233
+ activeAt: startedAt,
220
234
  status: 'running',
221
235
  exit: null,
222
236
  progress: { ...this.tracker.progress, expected: 0 },
@@ -231,10 +245,17 @@ class RunRecorder {
231
245
  pruneRuns(root);
232
246
  this.write();
233
247
  });
248
+ if (this.slot) {
249
+ this.heartbeat = setInterval(() => {
250
+ safely(() => this.write());
251
+ }, HEARTBEAT_MS);
252
+ this.heartbeat.unref();
253
+ }
234
254
  }
235
255
 
236
256
  write() {
237
257
  if (!this.slot) return;
258
+ if (!this.closed) this.record.activeAt = this.clock();
238
259
  Object.assign(this.record.progress, this.tracker.progress);
239
260
  writeRecord(this.slot.jsonFull, this.record);
240
261
  }
@@ -251,6 +272,8 @@ class RunRecorder {
251
272
  finish(status, result = null) {
252
273
  if (this.closed) return;
253
274
  this.closed = true;
275
+ clearInterval(this.heartbeat);
276
+ this.heartbeat = null;
254
277
  const record = this.record;
255
278
  record.endedAt = this.clock();
256
279
  record.exit = status;