Skip to content

feat(plugins): add session followup turn API and gateway-restart extension#60951

Closed
corbin-breton wants to merge 4 commits into
openclaw:mainfrom
corbin-breton:feat/gateway-restart-session-resume
Closed

feat(plugins): add session followup turn API and gateway-restart extension#60951
corbin-breton wants to merge 4 commits into
openclaw:mainfrom
corbin-breton:feat/gateway-restart-session-resume

Conversation

@corbin-breton

Copy link
Copy Markdown

Summary

Add a new runtime.followup.enqueueFollowupTurn() method to the plugin runtime, enabling plugins to schedule proactive agent turns for any session — including cold sessions with no active user interaction.

Problem

Plugins have no way to inject a user-role message into an existing session and trigger an agent turn. The available primitives (enqueueSystemEvent, subagent.run) either don't initiate turns or create new sessions instead of continuing the original conversation. This blocks use cases like:

  • Post-restart session resume — an agent restarts its own gateway and needs to automatically continue working after the restart completes
  • Proactive plugin notifications — a service detects an external event and needs to wake a sleeping session
  • Deferred task completion — a plugin schedules follow-up work after a long-running background operation

Solution

Core API: runtime.followup.enqueueFollowupTurn()

A new followup namespace on PluginRuntimeCore that:

  1. Reconstructs a full FollowupRun from the session store via buildFollowupRunForSession() — resolving agent identity, workspace, provider/model, session file, and delivery context from the persisted SessionEntry.

  2. Enqueues the run into the existing followup queue infrastructure with mode: "followup".

  3. Handles cold-start drain by creating a fresh followup runner with a no-op typing controller and registering it as the drain callback, so the turn executes immediately even when no prior drain loop exists for that session key.

// Plugin usage
const enqueued = await api.runtime.followup.enqueueFollowupTurn({
  sessionKey: "agent:my-agent:telegram:direct:12345",
  prompt: "Background task complete. Here are the results...",
  source: "my-plugin",
});

Bundled Extension: gateway-restart

A reference implementation that demonstrates the followup turn API:

  • Tool (gateway_restart): Agent calls this to write a restart marker, run optional allowlisted pre-commands, then exit the process. Systemd (or equivalent) auto-restarts the gateway.

  • Service (gateway-restart-watcher): On startup, checks for the restart marker. If found, calls enqueueFollowupTurn() to resume the original session with a completion message. The agent wakes up with full session context and continues working.

Disabled by default (enabledByDefault: false in manifest). Opt-in via plugins.entries.gateway-restart.enabled: true.

Files Changed

File Description
src/auto-reply/reply/queue/build-followup-run.ts New: cold-session FollowupRun builder
src/plugins/runtime/runtime-followup.ts New: plugin runtime followup namespace
src/plugins/runtime/types-core.ts Add followup type to PluginRuntimeCore
src/plugins/runtime/index.ts Wire createRuntimeFollowup()
extensions/gateway-restart/index.ts New: gateway-restart plugin
extensions/gateway-restart/openclaw.plugin.json New: plugin manifest
extensions/gateway-restart/package.json New: package metadata
test/helpers/plugins/plugin-runtime-mock.ts Add followup to mock

Testing

End-to-end verified on an isolated sandbox gateway (separate user, separate port, no channel conflicts):

  1. ✅ Plugin loads and registers gateway_restart tool
  2. ✅ Agent reads config, makes a change, calls gateway_restart
  3. ✅ Gateway exits, restarts, watcher service consumes restart marker
  4. enqueueFollowupTurn() enqueues and drains a cold-session followup turn
  5. ✅ Agent resumes with full session context, validates the config change, reports success
  6. ✅ All repo checks pass (tsgo, oxlint, commit hooks)

Design Decisions

  • Lazy imports in runtime-followup.ts — all heavy dependencies (buildFollowupRunForSession, createFollowupRunner, createTypingController) are dynamically imported to keep module load lightweight.
  • Session delivery context fallback — if originatingChannel/originatingTo aren't provided, the builder falls back to the session entry's lastChannel/lastTo, so replies route to wherever the session was last active.
  • senderIsOwner: true for plugin-initiated turns — these are trusted system actions, not external user messages.
  • Extension in extensions/ rather than src/plugins/bundled/ — follows the repo's plugin discovery convention.

@greptile-apps

greptile-apps Bot commented Apr 4, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a runtime.followup.enqueueFollowupTurn() API to the plugin runtime, enabling plugins to inject a user-role message into an existing (including cold) session and trigger a proactive agent turn. It ships alongside a new bundled gateway-restart extension that demonstrates the API by writing a restart marker, exiting the process, and resuming the original session on the next startup.

The core infrastructure changes (build-followup-run.ts, runtime-followup.ts, types-core.ts) are well-structured. Two logic bugs in the gateway-restart extension need attention before merging:

  • Stale restart marker on pre-command failure (extensions/gateway-restart/index.ts): The marker file is written to disk before execSync runs pre-commands. If any command fails and throws, the gateway keeps running but the marker persists. A future restart (for any reason) will then consume the stale marker and inject a spurious followup into the original session.

  • Marker deleted before enqueue is confirmed (extensions/gateway-restart/index.ts): The watcher unconditionally deletes the marker before awaiting enqueueFollowupTurn. If the enqueue returns false (session not found, store unavailable after restart, etc.), the resume opportunity is permanently lost with only a warning log.

Additionally, rememberFollowupDrainCallback in runtime-followup.ts unconditionally overwrites any existing drain callback, which can corrupt the runner of a hot (actively-draining) session if enqueueFollowupTurn is called from another plugin concurrently.

Confidence Score: 2/5

Not safe to merge — two logic bugs in the gateway-restart extension can cause spurious session resumes and unrecoverable failures.

The core API additions are solid. The bugs are in the gateway-restart extension: (1) the restart marker is written before pre-commands execute, so a failed command leaves a stale marker that triggers a spurious followup on the next real restart; (2) the marker is deleted before confirming the followup was enqueued, making failures non-retryable.

extensions/gateway-restart/index.ts — both the execute tool handler (marker-before-commands ordering) and the watcher service start() (delete-before-confirm ordering) need fixes before merging.

Prompt To Fix All With AI
This is a comment left during a code review.
Path: extensions/gateway-restart/index.ts
Line: 138-149

Comment:
**Stale restart marker left on disk when pre-command fails**

The restart marker is written to disk at line 138, and only then are the pre-commands executed via `execSync` (lines 142–147). `execSync` throws (synchronously) if a command exits with a non-zero status, propagating the error out of `execute`. Because `setTimeout(() => process.exit(0), 500)` is placed after the loop (line 149), the gateway never exits when a command fails — but the marker file is already on disk.

On the next gateway restart (for any reason), `gateway-restart-watcher` will find the stale marker, consume it, and inject a spurious followup turn into the original session.

The marker file write should be deferred until after all pre-commands have succeeded:

```ts
// Execute pre-commands first; abort with no marker if any fail
const commandOutputs: string[] = [];
for (const command of commands) {
  const output = execSync(command, {
    encoding: "utf8",
    stdio: ["ignore", "pipe", "pipe"],
  });
  commandOutputs.push(output);
}

// Only write the marker once we know we're committed to restarting
const marker: RestartMarker = { ... };
fs.mkdirSync(path.dirname(markerPath), { recursive: true });
fs.writeFileSync(markerPath, `${JSON.stringify(marker, null, 2)}\n`, "utf8");

setTimeout(() => process.exit(0), 500);
```

Alternatively, wrap `execSync` in a try/catch that removes the marker file on failure.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: extensions/gateway-restart/index.ts
Line: 200-213

Comment:
**Marker deleted before followup is confirmed enqueued**

The marker is unconditionally deleted (`fs.rmSync`) before `enqueueFollowupTurn` is awaited. While `enqueueFollowupTurn` wraps its internals in try/catch and always resolves to a boolean, if the function returns `false` (session not found, session store unavailable after restart, etc.), the marker is already gone and there is no way to retry. The warning logged at line 220 is the only signal — the session resume opportunity is permanently lost.

Consider only deleting the marker after a successful enqueue:

```ts
const enqueued = await followupRuntime.enqueueFollowupTurn({ ... });

if (enqueued) {
  fs.rmSync(markerPath, { force: true });
  ctx.logger.info(`[gateway-restart] Enqueued followup turn for session ${marker.sessionKey}.`);
} else {
  ctx.logger.warn(`[gateway-restart] Failed to enqueue followup turn for session ${marker.sessionKey}. Marker preserved for manual inspection.`);
}
```

If idempotent retries are intentionally not supported, at minimum document that the marker is consumed regardless of enqueue outcome.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/plugins/runtime/runtime-followup.ts
Line: 43-44

Comment:
**`rememberFollowupDrainCallback` unconditionally overwrites the active runner on hot sessions**

`rememberFollowupDrainCallback(params.sessionKey, runner)` always overwrites the stored drain callback with the freshly constructed noop-typing runner. When this is called for a session that is **currently being drained** by a channel-specific runner, the active `scheduleFollowupDrain` loop uses a local `effectiveRunFollowup` variable — but when the drain's `finally` block fires and calls `scheduleFollowupDrain(key, effectiveRunFollowup)` to restart, that function re-reads `FOLLOWUP_RUN_CALLBACKS.get(key)` (drain.ts line 86) and **picks up the noop runner** instead of the original.

The result is that subsequent turns in an active hot session would be executed with the noop typing controller, bypassing any channel-level typing/delivery logic.

This path is unlikely in the gateway-restart use case (the session is cold after a restart), but the API is general-purpose and will be called by other plugins. Consider guarding against overwriting an existing active callback:

```ts
if (!FOLLOWUP_RUN_CALLBACKS.has(params.sessionKey)) {
  rememberFollowupDrainCallback(params.sessionKey, runner);
}
kickFollowupDrainIfIdle(params.sessionKey);
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: extensions/gateway-restart/index.ts
Line: 62-64

Comment:
**Module-level mutable state creates re-registration hazard**

`followupRuntime`, `registeredStateDir`, and `registeredMarkerFileName` are module-level `let` variables that are overwritten every time `register()` is called. If the plugin is ever unloaded and re-registered (e.g., after a config reload), a second `register()` call silently replaces them.

Prefer closing over them inside `register` and passing them directly into the service `start()` callback, which already receives `ctx.stateDir`:

```ts
register(api) {
  const config = resolveConfig(api.pluginConfig);
  const localFollowupRuntime = api.runtime.followup;

  api.registerService({
    id: "gateway-restart-watcher",
    async start(ctx) {
      const markerPath = getMarkerPath(ctx.stateDir, config.markerFileName);
      // use localFollowupRuntime here
    },
  });
}
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: extensions/gateway-restart/index.ts
Line: 44-60

Comment:
**`FollowupRuntime` type is locally re-declared rather than derived from the SDK**

The `FollowupRuntime` type duplicates the shape of `PluginRuntimeCore["followup"]` verbatim. If the SDK type evolves (e.g., new optional parameters are added to `enqueueFollowupTurn`), this local copy will silently drift. Since `api.runtime.followup` is already typed on the `api` object provided by the framework, the module-level variable can carry the inferred type directly, avoiding the duplication.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: extensions/gateway-restart/package.json
Line: 7-11

Comment:
**Missing `openclaw.install.npmSpec` in package metadata**

Per the repo's CLAUDE.md bundled plugin naming rules, `openclaw.install.npmSpec` should equal the package name. The `openclaw` block here only has `extensions``install.npmSpec` is absent.

```suggestion
  "openclaw": {
    "extensions": [
      "./index.ts"
    ],
    "install": {
      "npmSpec": "@openclaw/gateway-restart-plugin"
    }
  }
```

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "feat(plugins): add session followup turn..." | Re-trigger Greptile

Comment thread extensions/gateway-restart/index.ts Outdated
Comment on lines +138 to +149
fs.writeFileSync(markerPath, `${JSON.stringify(marker, null, 2)}\n`, "utf8");

const commandOutputs: string[] = [];
for (const command of commands) {
const output = execSync(command, {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
commandOutputs.push(output);
}

setTimeout(() => process.exit(0), 500);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P0 Stale restart marker left on disk when pre-command fails

The restart marker is written to disk at line 138, and only then are the pre-commands executed via execSync (lines 142–147). execSync throws (synchronously) if a command exits with a non-zero status, propagating the error out of execute. Because setTimeout(() => process.exit(0), 500) is placed after the loop (line 149), the gateway never exits when a command fails — but the marker file is already on disk.

On the next gateway restart (for any reason), gateway-restart-watcher will find the stale marker, consume it, and inject a spurious followup turn into the original session.

The marker file write should be deferred until after all pre-commands have succeeded:

// Execute pre-commands first; abort with no marker if any fail
const commandOutputs: string[] = [];
for (const command of commands) {
  const output = execSync(command, {
    encoding: "utf8",
    stdio: ["ignore", "pipe", "pipe"],
  });
  commandOutputs.push(output);
}

// Only write the marker once we know we're committed to restarting
const marker: RestartMarker = { ... };
fs.mkdirSync(path.dirname(markerPath), { recursive: true });
fs.writeFileSync(markerPath, `${JSON.stringify(marker, null, 2)}\n`, "utf8");

setTimeout(() => process.exit(0), 500);

Alternatively, wrap execSync in a try/catch that removes the marker file on failure.

Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/gateway-restart/index.ts
Line: 138-149

Comment:
**Stale restart marker left on disk when pre-command fails**

The restart marker is written to disk at line 138, and only then are the pre-commands executed via `execSync` (lines 142–147). `execSync` throws (synchronously) if a command exits with a non-zero status, propagating the error out of `execute`. Because `setTimeout(() => process.exit(0), 500)` is placed after the loop (line 149), the gateway never exits when a command fails — but the marker file is already on disk.

On the next gateway restart (for any reason), `gateway-restart-watcher` will find the stale marker, consume it, and inject a spurious followup turn into the original session.

The marker file write should be deferred until after all pre-commands have succeeded:

```ts
// Execute pre-commands first; abort with no marker if any fail
const commandOutputs: string[] = [];
for (const command of commands) {
  const output = execSync(command, {
    encoding: "utf8",
    stdio: ["ignore", "pipe", "pipe"],
  });
  commandOutputs.push(output);
}

// Only write the marker once we know we're committed to restarting
const marker: RestartMarker = { ... };
fs.mkdirSync(path.dirname(markerPath), { recursive: true });
fs.writeFileSync(markerPath, `${JSON.stringify(marker, null, 2)}\n`, "utf8");

setTimeout(() => process.exit(0), 500);
```

Alternatively, wrap `execSync` in a try/catch that removes the marker file on failure.

How can I resolve this? If you propose a fix, please make it concise.

Comment thread extensions/gateway-restart/index.ts Outdated
Comment on lines +200 to +213
fs.rmSync(markerPath, { force: true });

const requestedAtMs = Date.parse(marker.requestedAt);
const durationSeconds = Number.isFinite(requestedAtMs)
? ((Date.now() - requestedAtMs) / 1000).toFixed(1)
: "unknown";
const completionText =
`[gateway-restart] Gateway restart complete (took ${durationSeconds}s). ${marker.message ?? ""}`.trim();

const enqueued = await followupRuntime.enqueueFollowupTurn({
sessionKey: marker.sessionKey,
prompt: `${completionText} Continue with any remaining work.`,
source: "gateway-restart",
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Marker deleted before followup is confirmed enqueued

The marker is unconditionally deleted (fs.rmSync) before enqueueFollowupTurn is awaited. While enqueueFollowupTurn wraps its internals in try/catch and always resolves to a boolean, if the function returns false (session not found, session store unavailable after restart, etc.), the marker is already gone and there is no way to retry. The warning logged at line 220 is the only signal — the session resume opportunity is permanently lost.

Consider only deleting the marker after a successful enqueue:

const enqueued = await followupRuntime.enqueueFollowupTurn({ ... });

if (enqueued) {
  fs.rmSync(markerPath, { force: true });
  ctx.logger.info(`[gateway-restart] Enqueued followup turn for session ${marker.sessionKey}.`);
} else {
  ctx.logger.warn(`[gateway-restart] Failed to enqueue followup turn for session ${marker.sessionKey}. Marker preserved for manual inspection.`);
}

If idempotent retries are intentionally not supported, at minimum document that the marker is consumed regardless of enqueue outcome.

Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/gateway-restart/index.ts
Line: 200-213

Comment:
**Marker deleted before followup is confirmed enqueued**

The marker is unconditionally deleted (`fs.rmSync`) before `enqueueFollowupTurn` is awaited. While `enqueueFollowupTurn` wraps its internals in try/catch and always resolves to a boolean, if the function returns `false` (session not found, session store unavailable after restart, etc.), the marker is already gone and there is no way to retry. The warning logged at line 220 is the only signal — the session resume opportunity is permanently lost.

Consider only deleting the marker after a successful enqueue:

```ts
const enqueued = await followupRuntime.enqueueFollowupTurn({ ... });

if (enqueued) {
  fs.rmSync(markerPath, { force: true });
  ctx.logger.info(`[gateway-restart] Enqueued followup turn for session ${marker.sessionKey}.`);
} else {
  ctx.logger.warn(`[gateway-restart] Failed to enqueue followup turn for session ${marker.sessionKey}. Marker preserved for manual inspection.`);
}
```

If idempotent retries are intentionally not supported, at minimum document that the marker is consumed regardless of enqueue outcome.

How can I resolve this? If you propose a fix, please make it concise.

Comment thread src/plugins/runtime/runtime-followup.ts Outdated
Comment on lines +43 to +44
rememberFollowupDrainCallback(params.sessionKey, runner);
kickFollowupDrainIfIdle(params.sessionKey);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 rememberFollowupDrainCallback unconditionally overwrites the active runner on hot sessions

rememberFollowupDrainCallback(params.sessionKey, runner) always overwrites the stored drain callback with the freshly constructed noop-typing runner. When this is called for a session that is currently being drained by a channel-specific runner, the active scheduleFollowupDrain loop uses a local effectiveRunFollowup variable — but when the drain's finally block fires and calls scheduleFollowupDrain(key, effectiveRunFollowup) to restart, that function re-reads FOLLOWUP_RUN_CALLBACKS.get(key) (drain.ts line 86) and picks up the noop runner instead of the original.

The result is that subsequent turns in an active hot session would be executed with the noop typing controller, bypassing any channel-level typing/delivery logic.

This path is unlikely in the gateway-restart use case (the session is cold after a restart), but the API is general-purpose and will be called by other plugins. Consider guarding against overwriting an existing active callback:

if (!FOLLOWUP_RUN_CALLBACKS.has(params.sessionKey)) {
  rememberFollowupDrainCallback(params.sessionKey, runner);
}
kickFollowupDrainIfIdle(params.sessionKey);
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/plugins/runtime/runtime-followup.ts
Line: 43-44

Comment:
**`rememberFollowupDrainCallback` unconditionally overwrites the active runner on hot sessions**

`rememberFollowupDrainCallback(params.sessionKey, runner)` always overwrites the stored drain callback with the freshly constructed noop-typing runner. When this is called for a session that is **currently being drained** by a channel-specific runner, the active `scheduleFollowupDrain` loop uses a local `effectiveRunFollowup` variable — but when the drain's `finally` block fires and calls `scheduleFollowupDrain(key, effectiveRunFollowup)` to restart, that function re-reads `FOLLOWUP_RUN_CALLBACKS.get(key)` (drain.ts line 86) and **picks up the noop runner** instead of the original.

The result is that subsequent turns in an active hot session would be executed with the noop typing controller, bypassing any channel-level typing/delivery logic.

This path is unlikely in the gateway-restart use case (the session is cold after a restart), but the API is general-purpose and will be called by other plugins. Consider guarding against overwriting an existing active callback:

```ts
if (!FOLLOWUP_RUN_CALLBACKS.has(params.sessionKey)) {
  rememberFollowupDrainCallback(params.sessionKey, runner);
}
kickFollowupDrainIfIdle(params.sessionKey);
```

How can I resolve this? If you propose a fix, please make it concise.

Comment thread extensions/gateway-restart/index.ts Outdated
Comment on lines +62 to +64
let followupRuntime: FollowupRuntime | null = null;
let registeredStateDir: string | null = null;
let registeredMarkerFileName = "restart-pending.json";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Module-level mutable state creates re-registration hazard

followupRuntime, registeredStateDir, and registeredMarkerFileName are module-level let variables that are overwritten every time register() is called. If the plugin is ever unloaded and re-registered (e.g., after a config reload), a second register() call silently replaces them.

Prefer closing over them inside register and passing them directly into the service start() callback, which already receives ctx.stateDir:

register(api) {
  const config = resolveConfig(api.pluginConfig);
  const localFollowupRuntime = api.runtime.followup;

  api.registerService({
    id: "gateway-restart-watcher",
    async start(ctx) {
      const markerPath = getMarkerPath(ctx.stateDir, config.markerFileName);
      // use localFollowupRuntime here
    },
  });
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/gateway-restart/index.ts
Line: 62-64

Comment:
**Module-level mutable state creates re-registration hazard**

`followupRuntime`, `registeredStateDir`, and `registeredMarkerFileName` are module-level `let` variables that are overwritten every time `register()` is called. If the plugin is ever unloaded and re-registered (e.g., after a config reload), a second `register()` call silently replaces them.

Prefer closing over them inside `register` and passing them directly into the service `start()` callback, which already receives `ctx.stateDir`:

```ts
register(api) {
  const config = resolveConfig(api.pluginConfig);
  const localFollowupRuntime = api.runtime.followup;

  api.registerService({
    id: "gateway-restart-watcher",
    async start(ctx) {
      const markerPath = getMarkerPath(ctx.stateDir, config.markerFileName);
      // use localFollowupRuntime here
    },
  });
}
```

How can I resolve this? If you propose a fix, please make it concise.

Comment thread extensions/gateway-restart/index.ts Outdated
Comment on lines +44 to +60
type FollowupRuntime = {
enqueueFollowupTurn: (params: {
sessionKey: string;
prompt: string;
agentId?: string;
originatingChannel?: string;
originatingTo?: string;
originatingAccountId?: string;
originatingThreadId?: string | number;
originatingChatType?: string;
model?: string;
provider?: string;
extraSystemPrompt?: string;
timeoutMs?: number;
source?: string;
}) => Promise<boolean>;
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 FollowupRuntime type is locally re-declared rather than derived from the SDK

The FollowupRuntime type duplicates the shape of PluginRuntimeCore["followup"] verbatim. If the SDK type evolves (e.g., new optional parameters are added to enqueueFollowupTurn), this local copy will silently drift. Since api.runtime.followup is already typed on the api object provided by the framework, the module-level variable can carry the inferred type directly, avoiding the duplication.

Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/gateway-restart/index.ts
Line: 44-60

Comment:
**`FollowupRuntime` type is locally re-declared rather than derived from the SDK**

The `FollowupRuntime` type duplicates the shape of `PluginRuntimeCore["followup"]` verbatim. If the SDK type evolves (e.g., new optional parameters are added to `enqueueFollowupTurn`), this local copy will silently drift. Since `api.runtime.followup` is already typed on the `api` object provided by the framework, the module-level variable can carry the inferred type directly, avoiding the duplication.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +7 to +11
"openclaw": {
"extensions": [
"./index.ts"
]
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Missing openclaw.install.npmSpec in package metadata

Per the repo's CLAUDE.md bundled plugin naming rules, openclaw.install.npmSpec should equal the package name. The openclaw block here only has extensionsinstall.npmSpec is absent.

Suggested change
"openclaw": {
"extensions": [
"./index.ts"
]
}
"openclaw": {
"extensions": [
"./index.ts"
],
"install": {
"npmSpec": "@openclaw/gateway-restart-plugin"
}
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/gateway-restart/package.json
Line: 7-11

Comment:
**Missing `openclaw.install.npmSpec` in package metadata**

Per the repo's CLAUDE.md bundled plugin naming rules, `openclaw.install.npmSpec` should equal the package name. The `openclaw` block here only has `extensions``install.npmSpec` is absent.

```suggestion
  "openclaw": {
    "extensions": [
      "./index.ts"
    ],
    "install": {
      "npmSpec": "@openclaw/gateway-restart-plugin"
    }
  }
```

How can I resolve this? If you propose a fix, please make it concise.

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

Copy link
Copy Markdown
Contributor

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: 3d268aa213

ℹ️ 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 on lines +67 to +69
const sessionFile =
sessionEntry.sessionFile?.trim() ||
resolveSessionFilePath(sessionEntry.sessionId, sessionEntry, { agentId });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Badge Resolve followup session file from the active store path

buildFollowupRunForSession falls back to resolveSessionFilePath(..., { agentId }), which ignores the configured session.store path used to load the session entry. When users keep sessions in a custom store location and an entry lacks sessionFile, the follow-up run will target the default agent sessions directory instead of the existing transcript, so the resumed turn can lose prior context and write to the wrong file.

Useful? React with 👍 / 👎.

Comment thread extensions/gateway-restart/index.ts Outdated
Comment on lines +138 to +142
fs.writeFileSync(markerPath, `${JSON.stringify(marker, null, 2)}\n`, "utf8");

const commandOutputs: string[] = [];
for (const command of commands) {
const output = execSync(command, {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Badge Persist restart marker only after pre-commands succeed

The marker file is written before executing pre-restart commands, so any execSync failure leaves a stale marker behind while the gateway stays up. On a later unrelated restart, the watcher will treat that stale marker as a completed restart request and enqueue an incorrect follow-up for the old session. This should be written only after commands succeed (or explicitly cleaned up on command failure).

Useful? React with 👍 / 👎.

@corbin-breton
corbin-breton force-pushed the feat/gateway-restart-session-resume branch from 3d268aa to cbabb08 Compare April 4, 2026 16:53

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

Copy link
Copy Markdown
Contributor

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: cbabb08e27

ℹ️ 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 on lines +99 to +100
provider,
model,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Badge Propagate auth profile override to followup runs

buildFollowupRunForSession reconstructs provider/model from the stored session entry, but it never sets run.authProfileId/authProfileIdSource. For sessions that were pinned to a non-default auth profile (for example via model/profile switching), plugin-initiated followups will run with default credentials instead, which can hit the wrong account or fail entirely when only the overridden profile has valid keys. This regresses behavior compared with normal queued turns that preserve session auth-profile selection.

Useful? React with 👍 / 👎.

@corbin-breton
corbin-breton force-pushed the feat/gateway-restart-session-resume branch from cbabb08 to 1171cf1 Compare April 4, 2026 18:54

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

Copy link
Copy Markdown
Contributor

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: 1171cf1520

ℹ️ 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 on lines +99 to +103
provider,
model,
timeoutMs,
blockReplyBreak: "text_end",
authProfileId: sessionEntry.authProfileOverride?.trim() || undefined,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Badge Preserve session thinking level in followup runs

buildFollowupRunForSession rebuilds run from the stored session but does not carry over sessionEntry.thinkingLevel, so plugin-enqueued followups run with thinkLevel unset; downstream, embedded runs default missing think level to "off" (src/agents/pi-embedded-runner/run.ts), which changes behavior for sessions that were explicitly using /think high (or other non-default levels). This causes restart/proactive turns to resume with different model-thinking settings than the active session and can materially change answer quality and latency.

Useful? React with 👍 / 👎.

@corbin-breton
corbin-breton force-pushed the feat/gateway-restart-session-resume branch from 1171cf1 to ab4846b Compare April 4, 2026 19:51

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

Copy link
Copy Markdown
Contributor

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: ab4846b4e4

ℹ️ 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/plugins/runtime/runtime-followup.ts Outdated
const enqueued = enqueueFollowupRun(
params.sessionKey,
followupRun,
{ mode: "followup" },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Badge Keep queue mode when enqueueing plugin followups

enqueueFollowupTurn hardcodes { mode: "followup" } for enqueueFollowupRun, and getFollowupQueue reapplies incoming settings to any existing queue. That means if a busy session is currently using another queue mode (for example collect/interrupt from session config), this plugin followup call silently flips the queue mode mid-drain and changes debounce/drop behavior for already queued user turns. Preserve the existing queue mode (or resolve from the session entry) instead of forcing followup here.

Useful? React with 👍 / 👎.

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

Copy link
Copy Markdown
Contributor

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: fc31b24293

ℹ️ 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/plugins/runtime/runtime-followup.ts Outdated
Comment on lines +41 to +45
const runner = createFollowupRunner({
typing: noopTyping,
typingMode: "never",
defaultModel: followupRun.run.model ?? DEFAULT_MODEL,
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Badge Pass session metadata into cold followup runner

createRuntimeFollowup builds a new runner without sessionKey/storePath/sessionStore, so createFollowupRunner skips session persistence and queue session-refresh paths (see persistRunSessionUsage and refreshQueuedFollowupSession guards in src/auto-reply/reply/followup-runner.ts). In cold-session plugin followups, if the first queued turn triggers compaction/session rotation, later queued turns can continue with stale sessionId/sessionFile and stale runtime accounting instead of following the rotated session.

Useful? React with 👍 / 👎.

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

Copy link
Copy Markdown
Contributor

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: 8c55696ce0

ℹ️ 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".

import { execSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { Type } from "@sinclair/typebox";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Badge Declare TypeBox as gateway-restart runtime dependency

gateway-restart imports @sinclair/typebox here, but extensions/gateway-restart/package.json does not declare any dependencies. In production plugin installs (npm install --omit=dev inside the plugin directory), this package will not be present unless it is explicitly listed, so loading the plugin can fail with a module resolution error before registration.

Useful? React with 👍 / 👎.

const enqueued = enqueueFollowupRun(
params.sessionKey,
followupRun,
{ mode: existingMode ?? "followup" },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Badge Apply queue policy settings for cold followup enqueues

This enqueue path only passes { mode: ... }, so when no queue exists yet it creates a new queue with default debounce/cap/drop policy instead of the session-configured queue settings. As a result, burst plugin followups on idle sessions can be debounced/dropped/summarized differently from normal turns that use resolveQueueSettings, which changes behavior for sessions that rely on custom queueDebounceMs, queueCap, or queueDrop.

Useful? React with 👍 / 👎.

Keats added 4 commits April 5, 2026 19:32
…nsion

Add a new `runtime.followup.enqueueFollowupTurn()` method to the plugin
runtime, enabling plugins to schedule proactive agent turns for any
session — including cold sessions with no active user interaction.

## Problem

Plugins have no way to inject a user-role message into an existing
session and trigger an agent turn. The available primitives
(`enqueueSystemEvent`, `subagent.run`) either don't initiate turns or
create new sessions instead of continuing the original conversation.

## Solution

### Core API: `runtime.followup.enqueueFollowupTurn()`

A new namespace on `PluginRuntimeCore` that:

1. Reconstructs a full `FollowupRun` from the session store via a new
   `buildFollowupRunForSession()` helper — resolving agent identity,
   workspace, provider/model, session file, and delivery context from
   the persisted `SessionEntry`.

2. Enqueues the run into the existing followup queue infrastructure.

3. Handles the cold-start drain problem by creating a fresh followup
   runner with a no-op typing controller and registering it as the
   drain callback, so the turn executes immediately even when no
   prior drain loop exists for that session.

### Bundled Extension: `gateway-restart`

A reference implementation and practical tool that demonstrates the
followup turn API:

- **Tool** (`gateway_restart`): Agent calls this to write a restart
  marker, run optional allowlisted pre-commands, then exit the process.
  Systemd (or equivalent) auto-restarts the gateway.

- **Service** (`gateway-restart-watcher`): On startup, checks for the
  restart marker. If found, calls `enqueueFollowupTurn()` to resume the
  original session with a completion message. The agent wakes up with
  full session context and continues working.

## Files

- `src/auto-reply/reply/queue/build-followup-run.ts` — Cold-session
  FollowupRun builder
- `src/plugins/runtime/runtime-followup.ts` — Plugin runtime namespace
- `src/plugins/runtime/types-core.ts` — Type surface addition
- `src/plugins/runtime/index.ts` — Wiring
- `extensions/gateway-restart/` — Bundled extension (tool + service)
- `test/helpers/plugins/plugin-runtime-mock.ts` — Test mock update

## Testing

End-to-end verified on an isolated sandbox gateway:

1. Agent reads config, makes a change, calls `gateway_restart`
2. Gateway exits, restarts, watcher consumes marker
3. `enqueueFollowupTurn()` fires, agent resumes with full context
4. Agent validates the config change persisted and reports success
…rve queue mode

buildFollowupRunForSession now passes the resolved storePath through
resolveSessionFilePathOptions so custom session.store locations produce
the correct transcript path on the fallback branch.

enqueueFollowupTurn reads the existing queue mode before enqueueing
instead of unconditionally forcing mode=followup, which could clobber
a hot session's collect/interrupt/steer mode mid-drain.

Targeted tests added for both fixes.
@corbin-breton
corbin-breton force-pushed the feat/gateway-restart-session-resume branch from 8c55696 to f7f8943 Compare April 5, 2026 23:43

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

Copy link
Copy Markdown
Contributor

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: f7f89435fc

ℹ️ 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".

} as FollowupRun["run"]["execOverrides"])
: undefined,
// Message provider for reply routing
messageProvider: sessionEntry.lastChannel ?? undefined,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Badge Preserve session account context in followup runs

buildFollowupRunForSession rebuilds routing state but never sets run.agentAccountId, so cold/plugin followups lose the account binding that normal turns carry. Downstream, createFollowupRunner forwards queued.run.agentAccountId into runEmbeddedPiAgent, and message-tool execution uses that value as the fallback account selector; in multi-account sessions this can route tool actions through the wrong account (or fail auth) even though the session was originally tied to a specific account.

Useful? React with 👍 / 👎.

@corbin-breton

Copy link
Copy Markdown
Author

Superseded by #63330 — rebased onto latest main with all bug fixes included and CI passing.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant