Skip to content

Run each command in a systemd scope of its own, and keep what commands leave across restarts - #48

Open
Crokily wants to merge 1 commit into
mainfrom
scope-per-command
Open

Crokily wants to merge 1 commit into
mainfrom
scope-per-command

Conversation

@Crokily

@Crokily Crokily commented Nov 13, 2026 •

Copy link
Copy Markdown
Owner

On Linux with a systemd user manager, each command the agent runs (bash, screen, codemode's bash, and a routine's --run) now runs in its own transient systemd scope. When memory runs out, oomd or the kernel then stops the heavy command instead of the whole gateway. A gateway restart keeps what commands left running. This implements Q1 (A) and Q2 (丙) from the 2026-11-13 OOM decision page. Without a user manager (macOS, Windows, --system services, containers), commands run as before.

How a command runs (src/agent/scopes.ts)

  • ScopedExecutionEnv extends pi-durable's NodeExecutionEnv and changes only exec for shell strings. pi-durable is unchanged. The harness env (bash, screen, codemode, children, memory forks) and the routine runner both use it.
  • The command goes through a short bash wrapper: (exec systemd-run --user --scope --quiet --collect --slice=app-pots-<key>.slice --unit=pots-<key>-<bot>-$BASHPID-<token> -- bash -c …). systemd-run execs in place, so the command keeps the same PID, process group and pipes. Output, timeouts, interrupt and kill(-pgid) work as before.
  • If systemd-run cannot start the scope, the command runs without one. It never runs twice: the command signals the wrapper with USR1 when it starts. systemd-run's own messages go to /dev/null, and the command's stderr is passed through on fd 3.
  • The wrapper catches SIGTERM (a trap, which the command does not inherit). When systemd stops pots.service, the command therefore ends as an interrupted call, not as exit 143.
  • Availability is checked once per gateway with a probe that uses the same options as real commands. <key> is the first 8 hex digits of sha256(POTS_HOME), so two homes never share scopes. Measured cost: about 25 ms per command, plus 1–2 user-journal lines. The scope description is generic (Command of Pots bot <id>), so command text, which can contain secrets, stays out of the journal.

Restart lifecycle (Q2 丙)

  • Bot stops (restart, upgrade, bot removed): commands still in flight are aborted as before (pgid SIGKILL). Their whole scopes are then stopped too, which also ends setsid children. A command that ends aborted while the bot is closing counts as one Pots was waiting on.
  • Crash or oomd kill of the gateway: the scope name holds the command's own PID. At the next start, a scope whose main PID is still alive in that scope was in flight, so it is stopped. This runs before the agent or the routines can start anything.
  • Background programs: a scope whose command has returned but which still has processes keeps running across restarts. The next wake of any kind (once, in-process, like the memory note) lists each one with its start time, process count and oldest command line. The bot decides what to do with them.
  • Intentional stop: on SIGTERM, the gateway asks systemd whether its own user service has a stop job, not a restart job, and whether it is that service's MainPID. If so, it stops the home's slice after the bots stop. This covers systemctl --user stop pots, pots service uninstall (disable --now) and logout/shutdown. systemctl restart, pots service restart, pots service install and Restart=always keep the slice. Tested on systemd 255: PartOf= and StopPropagatedFrom= both also fire on restart and crash-restart, so neither fits.
  • Prompt: one fact line in "This computer", shown only when scopes are on. It gives the naming, what restarts and stops do, and the systemctl --user list-units/status/stop commands. It states facts only, no rules.

Unit

  • OOMPolicy=continue is added to the generated unit, both user and system.
  • Existing installs: pots service restart, which is the documented upgrade step, adds the line to a unit that Pots wrote and that lacks it, then runs daemon-reload. It never touches a unit Pots did not write.

Not done

  • The Quick Tunnel's cloudflared still dies with the gateway. Keeping it alive would need it detached in its own scope, logging to a file that Pots tails, and adopting a live process at start instead of stopLeftoverTunnel. That does not fit this change, so it is left as a follow-up.

Tests

  • New test/scopes.test.ts (runs only where systemd-run --user --scope works), with real scopes:
    • A bash command and a routine run in the bot's scope inside the home's slice, and the prompt line is present.
    • Crash: the in-flight command and its setsid child are stopped, and the left process survives and is listed once in the next wake.
    • Graceful stop: the same as the crash case.
    • The gateway as a transient systemd service: systemctl restart keeps the left process and stops the in-flight one; systemctl stop ends everything.
    • Without XDG_RUNTIME_DIR: commands run as before, with no prompt line and no note.
  • test/service.test.ts: the unit has OOMPolicy=continue; restart upgrades an older Pots unit once and leaves a foreign unit alone.
  • TestGateway: an env option, and dispose() stops the home's slice.

On this host: npm run check passes. npm test passed 315, skipped 2, failed 0 (rebased onto 0.7.2 afterwards; that rebase changed only CHANGELOG and package.json). CI passes on all six jobs. GitHub's ubuntu runner has a user manager, so scopes.test.ts ran there too, including the transient-service test. The fallback is covered by the test without XDG_RUNTIME_DIR and by macOS and Windows, where the scope code never runs.

…s leave across restarts

On Linux with a systemd user manager, each command of bash, screen and a routine runs in a transient
scope of its own (systemd-run --user --scope), in one slice per Pots home (app-pots-<key>.slice).
When memory runs out, systemd-oomd or the kernel stops the command that uses it, not the gateway with
every bot in it. The command stays the gateway's child, in its own process group, with the same pipes,
so output, timeouts and interrupts work as before; where systemd-run cannot start a scope, the command
runs as before, never twice. Without a user manager (macOS, Windows, system services, containers),
nothing changes.

Restarts: a command Pots still waits on when a bot stops is stopped with its scope, also at the next
start after a crash (its own process still runs in its scope); what a command left running when it
returned keeps running, and the bot's next wake lists it. When the gateway's own user service is
stopped on purpose (a stop job, not a restart), the slice stops too. The prompt states these facts and
the scopes' names.

The service unit gets OOMPolicy=continue, and pots service restart adds it to a unit an older version
wrote.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 13, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-13T06:46:26.441046Z ec7502f PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ec7502ff3a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/agent/scopes.ts
const stopped = scopes.filter((scope) => commandRuns(scope));
// SIGTERM, and SIGKILL for what is left after the scope's stop timeout. One that ended meanwhile is not loaded any
// more: systemctl says so and stops the others.
if (stopped.length > 0) await systemctl(["stop", "--no-block", "--", ...stopped.map((scope) => scope.unit)]).catch(() => {});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Wait for recovered command scopes to stop before starting work

After a gateway crash and automatic service restart, an in-flight command that ignores SIGTERM or performs lengthy shutdown work can continue running after recover() returns. systemctl --help describes --no-block as “Do not wait until operation finished”, so this await only queues the stop; Bot.#open then proceeds to initialize the gateway and agent while the old command remains alive until termination or systemd's stop timeout. This allows old writers or resource holders to interfere with newly resumed work. Wait for the recovered scopes to become empty, or explicitly terminate them and verify completion, before allowing the bot to start commands.

Useful? React with 👍 / 👎.

Comment thread src/agent/scopes.ts
async stopInterrupted(): Promise<number> {
const tokens = [...this.#interrupted];
this.#interrupted.clear();
if (tokens.length > 0) await systemctl(["stop", "--no-block", "--", ...tokens.map((token) => `${this.prefix}-*-${token}.scope`)]).catch(() => {});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restrict interrupted-scope cleanup to the exact bot

With bots named a and a-1, stopping bot a can also terminate bot a-1's commands when their tokens coincide. Tokens use a timestamp and a per-bot counter, so commands started by different bots in the same millisecond can share one. The stop pattern pots-<key>-a-*-<token>.scope then matches both bots because its * also consumes 1-<pid>. The systemctl documentation confirms that “normal shell-style globbing rules are used”. Unlike #list(), this cleanup does not enforce the exact bot boundary, so removing one bot or restarting the gateway can kill another bot's background processes that should survive. Resolve and filter exact unit names before stopping them, or retain each interrupted command's full unit name.

Useful? React with 👍 / 👎.

Comment thread src/agent/scopes.ts
"exec 3>&2 2>/dev/null",
"trap 's=1' USR1",
"trap : TERM",
`(exec ${quote(systemdRun)} --user --scope --quiet --collect --slice=${quote(slice)} --unit="${this.prefix}-$BASHPID-$2" --description=${quote(`Command of Pots bot ${bot}`)} -- ${quote(bash)} -c ${quote(inner)} pots "$1")`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Disable systemd-run's environment expansion for shell commands

On systemd 258 and 259, this invocation expands environment references in the command argument before bash interprets it. The systemd 258 documentation describes expansion as “enabled (the default)” and specifies that scope expansion is performed by systemd-run itself. For example, value=ok; echo "${value}" becomes value=ok; echo "" when value is absent from the inherited environment; quoted script contents containing such references are also modified. This silently changes commands from bash, screen and routines on these systems, while the availability probe still succeeds. Explicitly pass --expand-environment=no so bash receives the original command unchanged.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant