testJobsOutliveTheirServer

tcmts.cli.test.e2e.mcp.testJobsOutliveTheirServer · #j7rdghcl · term

A background job outlives the server that started it: when tcm is replaced (a new known-good verified) and the supervisor starts the server again, the new one lists the session's jobs, follows one to its end (all it printed, and its exit code), stops one, and reads what a tcm_merge started before the restart did. An id the session never gave is said so.

import { okIn, tcmBin, tempDir, withChildren } from "#codebase/tcmts/cli/test/e2e/harness";
import { startedSession } from "#codebase/tcmts/cli/test/e2e/mcp";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";

export const testJobsOutliveTheirServer = () =>
  withChildren(async (keep) => {
    const dir = tempDir("mcp jobs restart");
    const tcm = (...args: string[]) => okIn(dir, ...args);
    tcm("init");
    fs.rmSync(path.join(dir, "scratch.ts"), { force: true });
    fs.writeFileSync(
      path.join(dir, "jobs.ts"),
      `export const slow = async (ms: string) => {\n  console.log("started");\n  await new Promise((r) => setTimeout(r, Number(ms)));\n  console.log("finished");\n  process.exit(3);\n};\n`,
    );
    tcm("add", "jobs.ts", "--into", "lib");
    const bin = path.join(dir, "tcm-bin.mjs");
    fs.writeFileSync(bin, `import ${JSON.stringify(pathToFileURL(tcmBin()).href)};\n`);
    const s = await startedSession(dir, keep, {}, [bin, "mcp", "--branch", "agent/a"]);

    const started = await s.call("tcm_run", { name: "lib.slow", args: ["4000"], background: true });
    assert.match(started.text, /^started job 1: tcm run lib\.slow -- 4000/, started.text);
    assert.match(
      (await s.call("tcm_run", { name: "lib.slow", args: ["600000"], background: true })).text,
      /^started job 2/,
    );
    // work to land, landed by a job the restart comes in the middle of
    assert.match(
      (await s.call("tcm_add", { code: "export const added = 1;\n", namespace: "lib" })).text,
      /lib\.added/,
    );
    assert.match(
      (await s.call("tcm_merge", { approved: true, message: "lib.added", background: true })).text,
      /^started job 3: tcm_merge/,
    );

    // tcm replaced: the supervisor starts the server again
    fs.writeFileSync(bin, `${fs.readFileSync(bin, "utf8")}// v2\n`);
    for (
      const until = Date.now() + 30_000;
      !s.notes.some((n) => n.method === "notifications/tools/list_changed");
      await new Promise((r) => setTimeout(r, 100))
    )
      if (Date.now() > until) assert.fail("the server wasn't started again");

    const listed = (await s.call("tcm_job")).text;
    assert.match(listed, /^1  tcm run lib\.slow -- 4000  .*started by an earlier server$/m, listed);
    assert.match(
      listed,
      /^2  tcm run lib\.slow -- 600000  running for \d+s, started by an earlier server$/m,
      listed,
    );
    assert.match(listed, /^3  tcm_merge  /m, listed);

    // followed to its end: all it printed, and its exit code
    const one = await s.call("tcm_job", { id: 1, wait: 60 });
    assert.equal(one.isError, true, one.text);
    assert.match(
      one.text,
      /^job 1 \(tcm run lib\.slow -- 4000\) failed \(exit 3\) after \d+s, started by an earlier server:/,
      one.text,
    );
    assert.match(one.text, /started\nfinished/, one.text);

    // stopped, with what it runs
    const record = JSON.parse(
      fs.readFileSync(path.join(dir, ".tcm", "sessions", "agent_a", "jobs", "2.json"), "utf8"),
    ) as { pid: number };
    const stopped = await s.call("tcm_job", { id: 2, stop: true });
    assert.match(
      stopped.text,
      /^stopped job 2 \(tcm run lib\.slow -- 600000\) after \d+s/,
      stopped.text,
    );
    assert.match(stopped.text, /started/);
    // (its process group, the command in it, gone)
    const gone = () => {
      try {
        process.kill(-record.pid, 0);
        return false;
      } catch {
        return true;
      }
    };
    for (const until = Date.now() + 10_000; !gone(); await new Promise((r) => setTimeout(r, 100)))
      if (Date.now() > until) assert.fail("what job 2 ran is still running");
    assert.match((await s.call("tcm_job", { id: 2 })).text, /^job 2 \(.*\) was stopped after \d+s/);
    assert.match((await s.call("tcm_job")).text, /^2  .*  stopped after \d+s/m);

    // the merge landed all the same, and what it said is there to read
    const merged = await s.call("tcm_job", { id: 3, wait: 120 });
    assert.equal(merged.isError, false, merged.text);
    assert.match(merged.text, /agent\/a is on main/, merged.text);
    assert.match(tcm("view", "lib.added"), /added = 1/);

    // an id it never gave
    const unknown = await s.call("tcm_job", { id: 9 });
    assert.equal(unknown.isError, true);
    assert.match(
      unknown.text,
      /^no job 9 in this session: its jobs are numbered 1 to 3/,
      unknown.text,
    );
    s.close();
  })

uses