@nextclaw/service 0.1.14 → 0.1.15

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.
@@ -15,7 +15,7 @@ async function listAvailableAgentRuntimes(params) {
15
15
  createRuntime: createUnusedRuntime
16
16
  });
17
17
  runtimeSourceByKind.set(DEFAULT_AGENT_RUNTIME_ENTRY_ID, { source: "builtin" });
18
- for (const provider of new BuiltinNarpRuntimeProviderService(() => config).createProviders()) runtimeRegistry.register(provider);
18
+ for (const provider of new BuiltinNarpRuntimeProviderService({ loadConfig: () => config }).createProviders()) runtimeRegistry.register(provider);
19
19
  runtimeSourceByKind.set("narp-http", { source: "builtin" });
20
20
  runtimeSourceByKind.set("narp-stdio", { source: "builtin" });
21
21
  const resolvedEntries = resolveAgentRuntimeEntries({ config });
@@ -30,7 +30,7 @@ function createCliHistoryInterface() {
30
30
  return rl;
31
31
  }
32
32
  async function runCliInteractiveLoop(params) {
33
- const { agentRunRequests, config, logo, metadata, sessionKey, sessionManager } = params;
33
+ const { agentRunRequests, config, logo, metadata, sessionKey } = params;
34
34
  console.log(`${logo} Interactive mode (type exit or Ctrl+C to quit)\n`);
35
35
  const rl = createCliHistoryInterface();
36
36
  let running = true;
@@ -44,7 +44,6 @@ async function runCliInteractiveLoop(params) {
44
44
  }
45
45
  printAgentResponse(await dispatchPromptOverNcp({
46
46
  config,
47
- sessionManager,
48
47
  agentRunRequests,
49
48
  sessionKey,
50
49
  content: trimmed,
@@ -54,7 +53,6 @@ async function runCliInteractiveLoop(params) {
54
53
  }
55
54
  async function runCliAgentCommand(params) {
56
55
  const { config, kernel, logo, opts } = params;
57
- const sessionManager = kernel.sessions;
58
56
  await kernel.extensions.load({ config });
59
57
  await kernel.start();
60
58
  try {
@@ -63,7 +61,6 @@ async function runCliAgentCommand(params) {
63
61
  if (opts.message) {
64
62
  printAgentResponse(await dispatchPromptOverNcp({
65
63
  config,
66
- sessionManager,
67
64
  agentRunRequests: kernel.agentRunRequestManager,
68
65
  sessionKey,
69
66
  content: opts.message,
@@ -74,7 +71,6 @@ async function runCliAgentCommand(params) {
74
71
  await runCliInteractiveLoop({
75
72
  logo,
76
73
  config,
77
- sessionManager,
78
74
  agentRunRequests: kernel.agentRunRequestManager,
79
75
  sessionKey,
80
76
  metadata: sharedMetadata
@@ -3,6 +3,7 @@ import { DoctorCommandOptions, StatusCommandOptions } from "../../../../shared/t
3
3
  //#region src/cli/commands/diagnostics/services/diagnostics-commands.service.d.ts
4
4
  declare class DiagnosticsCommands {
5
5
  private deps;
6
+ private readonly managedServiceSupervisor;
6
7
  constructor(deps: {
7
8
  logo: string;
8
9
  });
@@ -12,6 +13,7 @@ declare class DiagnosticsCommands {
12
13
  private readonly buildDoctorChecks;
13
14
  private readonly resolveDoctorExitCode;
14
15
  private readonly collectRuntimeStatus;
16
+ private readonly resolveManagedServiceStatus;
15
17
  private readonly probeApiHealth;
16
18
  private readonly listProviderStatuses;
17
19
  private readonly collectRuntimeIssues;
@@ -2,6 +2,7 @@ import { isProcessRunning, resolveUiApiBase, resolveUiConfig } from "../../../..
2
2
  import { managedServiceStateStore } from "../../../../shared/stores/managed-service-state.store.js";
3
3
  import { resolveNextclawRemoteStatusSnapshot } from "../../../../commands/remote/utils/remote-runtime-support.utils.js";
4
4
  import "../../../../commands/remote/index.js";
5
+ import { ManagedServiceSupervisor } from "../../../../shared/services/runtime/managed-service-supervisor.service.js";
5
6
  import { printDoctorReport, printStatusReport } from "../utils/diagnostics-render.utils.js";
6
7
  import { APP_NAME, getConfigPath, getWorkspacePath, hasSecretRef, loadConfig, resolveAppLogPath } from "@nextclaw/core";
7
8
  import { existsSync, readFileSync } from "node:fs";
@@ -9,6 +10,7 @@ import { createServer } from "node:net";
9
10
  import { listBuiltinProviders } from "@nextclaw/runtime";
10
11
  //#region src/cli/commands/diagnostics/services/diagnostics-commands.service.ts
11
12
  var DiagnosticsCommands = class {
13
+ managedServiceSupervisor = new ManagedServiceSupervisor();
12
14
  constructor(deps) {
13
15
  this.deps = deps;
14
16
  }
@@ -87,8 +89,8 @@ var DiagnosticsCommands = class {
87
89
  },
88
90
  {
89
91
  name: "service-state",
90
- status: report.process.staleState ? "fail" : report.process.running ? "pass" : "warn",
91
- detail: report.process.running ? `PID ${report.process.pid}` : report.process.staleState ? "state exists but process is not running" : "service not running"
92
+ status: report.process.staleState ? "fail" : report.process.running ? report.process.lease?.missing ? "warn" : "pass" : "warn",
93
+ detail: report.process.running ? `PID ${report.process.pid}${report.process.lease?.missing ? " (missing lease heartbeat)" : ""}` : report.process.staleState ? `state is stale (${report.process.staleReason ?? "unknown"})` : "service not running"
92
94
  },
93
95
  {
94
96
  name: "service-health",
@@ -117,16 +119,10 @@ var DiagnosticsCommands = class {
117
119
  const config = loadConfig();
118
120
  const workspacePath = getWorkspacePath(config.agents.defaults.workspace);
119
121
  const serviceStatePath = managedServiceStateStore.path;
120
- const fixActions = [];
121
- let serviceState = managedServiceStateStore.read();
122
- if (params.fix && serviceState && !isProcessRunning(serviceState.pid)) {
123
- managedServiceStateStore.clear();
124
- fixActions.push("Cleared stale service state file.");
125
- serviceState = managedServiceStateStore.read();
126
- }
122
+ const { fixActions, liveness, serviceState } = this.resolveManagedServiceStatus({ fix: params.fix });
127
123
  const managedByState = Boolean(serviceState);
128
- const running = Boolean(serviceState && isProcessRunning(serviceState.pid));
129
- const staleState = Boolean(serviceState && !running);
124
+ const running = Boolean(serviceState && liveness.running);
125
+ const staleState = Boolean(serviceState && liveness.staleState);
130
126
  const configuredUi = resolveUiConfig(config, {
131
127
  enabled: true,
132
128
  host: config.ui.host,
@@ -176,8 +172,15 @@ var DiagnosticsCommands = class {
176
172
  pid: serviceState?.pid ?? null,
177
173
  running,
178
174
  staleState,
175
+ staleReason: liveness.staleReason,
179
176
  orphanSuspected,
180
- startedAt: serviceState?.startedAt ?? null
177
+ startedAt: serviceState?.startedAt ?? null,
178
+ lease: serviceState ? {
179
+ heartbeatAt: liveness.lastHeartbeatAt,
180
+ expired: liveness.leaseExpired,
181
+ missing: liveness.leaseMissing
182
+ } : null,
183
+ lastExit: serviceState?.lastExit ?? null
181
184
  },
182
185
  endpoints: {
183
186
  uiUrl: managedUiUrl,
@@ -197,6 +200,22 @@ var DiagnosticsCommands = class {
197
200
  exitCode: 0
198
201
  };
199
202
  };
203
+ resolveManagedServiceStatus = (params) => {
204
+ const fixActions = [];
205
+ let serviceState = managedServiceStateStore.read();
206
+ let liveness = this.managedServiceSupervisor.resolveStateLiveness(serviceState);
207
+ if (params.fix && serviceState && liveness.staleState && !liveness.processExists) {
208
+ managedServiceStateStore.clear();
209
+ fixActions.push("Cleared stale service state file.");
210
+ serviceState = managedServiceStateStore.read();
211
+ liveness = this.managedServiceSupervisor.resolveStateLiveness(serviceState);
212
+ } else if (params.fix && serviceState && liveness.staleState && liveness.processExists) fixActions.push("Skipped clearing stale service state because the recorded PID still exists.");
213
+ return {
214
+ fixActions,
215
+ liveness,
216
+ serviceState
217
+ };
218
+ };
200
219
  probeApiHealth = async (url, timeoutMs = 1500) => {
201
220
  const controller = new AbortController();
202
221
  const timer = setTimeout(() => controller.abort(), timeoutMs);
@@ -266,13 +285,18 @@ var DiagnosticsCommands = class {
266
285
  recommendations.push(`Run ${APP_NAME} init to create workspace templates.`);
267
286
  }
268
287
  if (staleState) {
269
- issues.push("Service state is stale (state exists but process is not running).");
270
- recommendations.push(`Run ${APP_NAME} status --fix to clean stale state.`);
288
+ const staleDetail = serviceState?.lastExit ? ` Last exit: ${serviceState.lastExit.reason}${serviceState.lastExit.signal ? ` (${serviceState.lastExit.signal})` : ""} at ${serviceState.lastExit.exitedAt}.` : "";
289
+ issues.push(`Service state is stale (${params.serviceState ? "state no longer represents a live lease" : "state missing"}).${staleDetail}`);
290
+ recommendations.push(params.serviceState && isProcessRunning(params.serviceState.pid) ? `Run ${APP_NAME} restart to replace the stale leased process.` : `Run ${APP_NAME} status --fix to clean stale state.`);
271
291
  }
272
292
  if (running && managedHealth.state !== "ok") {
273
293
  issues.push(`Managed service health check failed: ${managedHealth.detail}`);
274
294
  recommendations.push(`Check logs at ${serviceState?.logPath ?? resolveAppLogPath("service")}.`);
275
295
  }
296
+ if (running && serviceState && !serviceState.lease) {
297
+ issues.push("Managed service state is missing a lease heartbeat.");
298
+ recommendations.push(`Run ${APP_NAME} restart to refresh the managed service state contract.`);
299
+ }
276
300
  if (running && serviceState?.startupState === "degraded" && managedHealth.state !== "ok") {
277
301
  const startupHint = serviceState.startupLastProbeError ? ` (${serviceState.startupLastProbeError})` : "";
278
302
  issues.push(`Service is in degraded startup state${startupHint}.`);
@@ -23,6 +23,12 @@ function printProcessSection(report) {
23
23
  console.log(`Process: ${processLabel}`);
24
24
  console.log(`State file: ${report.serviceStatePath} ${report.serviceStateExists ? "✓" : "✗"}`);
25
25
  if (report.process.startedAt) console.log(`Started: ${report.process.startedAt}`);
26
+ if (report.process.lease?.heartbeatAt) console.log(`Last heartbeat: ${report.process.lease.heartbeatAt}${report.process.lease.expired ? " (expired)" : ""}`);
27
+ if (report.process.staleReason) console.log(`Stale reason: ${report.process.staleReason}`);
28
+ if (report.process.lastExit) {
29
+ const exit = report.process.lastExit;
30
+ console.log(`Last exit: ${exit.reason}${exit.signal ? ` ${exit.signal}` : ""}${typeof exit.code === "number" ? ` code=${exit.code}` : ""} at ${exit.exitedAt}`);
31
+ }
26
32
  console.log(`Managed health: ${report.health.managed.state} (${report.health.managed.detail})`);
27
33
  if (!report.process.running) console.log(`Configured health: ${report.health.configured.state} (${report.health.configured.detail})`);
28
34
  }
@@ -1,11 +1,11 @@
1
1
  import { PluginRegistry } from "@nextclaw/openclaw-compat";
2
2
 
3
3
  //#region src/commands/plugin/plugin-command.utils.d.ts
4
- declare const RESERVED_PROVIDER_IDS: string[];
4
+ declare const RESERVED_PROVIDER_IDS: any;
5
5
  declare function buildReservedPluginLoadOptions(): {
6
6
  reservedToolNames: ("read_file" | "write_file" | "edit_file" | "list_dir" | "exec" | "web_search" | "web_fetch" | "message" | "spawn" | "sessions_list" | "sessions_history" | "memory_search" | "memory_get" | "subagents" | "gateway" | "cron")[];
7
7
  reservedChannelIds: string[];
8
- reservedProviderIds: string[];
8
+ reservedProviderIds: any;
9
9
  };
10
10
  declare function appendPluginCapabilityLines(lines: string[], plugin: PluginRegistry["plugins"][number]): void;
11
11
  //#endregion
@@ -1,66 +1,84 @@
1
- import { isProcessRunning, resolveUiApiBase, resolveUiConfig } from "../../../shared/utils/cli.utils.js";
1
+ import { resolveUiApiBase, resolveUiConfig } from "../../../shared/utils/cli.utils.js";
2
2
  import { managedServiceStateStore } from "../../../shared/stores/managed-service-state.store.js";
3
+ import { ManagedServiceSupervisor } from "../../../shared/services/runtime/managed-service-supervisor.service.js";
3
4
  import { getConfigPath, loadConfig } from "@nextclaw/core";
4
5
  import { spawn } from "node:child_process";
5
6
  //#region src/commands/remote/services/remote-service-control.service.ts
6
7
  const FORCED_PUBLIC_UI_HOST = "0.0.0.0";
7
- function resolveRemoteServiceView(currentUi) {
8
- if (currentUi) return {
9
- running: true,
10
- currentProcess: true,
11
- pid: process.pid,
12
- uiUrl: resolveUiApiBase(currentUi.host, currentUi.port),
13
- uiPort: currentUi.port
8
+ var RemoteServiceControlService = class {
9
+ managedServiceSupervisor = new ManagedServiceSupervisor();
10
+ resolveView = (currentUi) => {
11
+ if (currentUi) return {
12
+ running: true,
13
+ currentProcess: true,
14
+ pid: process.pid,
15
+ uiUrl: resolveUiApiBase(currentUi.host, currentUi.port),
16
+ uiPort: currentUi.port
17
+ };
18
+ const serviceState = managedServiceStateStore.read();
19
+ const liveness = this.managedServiceSupervisor.resolveStateLiveness(serviceState);
20
+ const serviceRunning = Boolean(serviceState && liveness.running);
21
+ return {
22
+ running: serviceRunning,
23
+ currentProcess: Boolean(serviceRunning && serviceState?.pid === process.pid),
24
+ ...serviceState?.pid ? { pid: serviceState.pid } : {},
25
+ ...serviceState?.uiUrl ? { uiUrl: serviceState.uiUrl } : {},
26
+ ...typeof serviceState?.uiPort === "number" ? { uiPort: serviceState.uiPort } : {}
27
+ };
14
28
  };
15
- const serviceState = managedServiceStateStore.read();
16
- const serviceRunning = Boolean(serviceState && isProcessRunning(serviceState.pid));
17
- return {
18
- running: serviceRunning,
19
- currentProcess: Boolean(serviceRunning && serviceState?.pid === process.pid),
20
- ...serviceState?.pid ? { pid: serviceState.pid } : {},
21
- ...serviceState?.uiUrl ? { uiUrl: serviceState.uiUrl } : {},
22
- ...typeof serviceState?.uiPort === "number" ? { uiPort: serviceState.uiPort } : {}
29
+ control = async (action, deps) => {
30
+ if (deps.remoteRuntimeController) return this.controlCurrentProcessRuntime(action, deps.remoteRuntimeController);
31
+ return this.controlManagedService(action, deps);
23
32
  };
24
- }
25
- async function controlRemoteService(action, deps) {
26
- if (deps.remoteRuntimeController) return controlCurrentProcessRuntime(action, deps.remoteRuntimeController);
27
- return controlManagedService(action, deps);
28
- }
29
- async function controlCurrentProcessRuntime(action, controller) {
30
- if (action === "start") {
31
- await controller.start();
33
+ controlCurrentProcessRuntime = async (action, controller) => {
34
+ if (action === "start") {
35
+ await controller.start();
36
+ return {
37
+ accepted: true,
38
+ action,
39
+ message: "Remote runtime started."
40
+ };
41
+ }
42
+ if (action === "stop") {
43
+ await controller.stop();
44
+ return {
45
+ accepted: true,
46
+ action,
47
+ message: "Remote runtime stopped."
48
+ };
49
+ }
50
+ await controller.restart();
32
51
  return {
33
52
  accepted: true,
34
53
  action,
35
- message: "Remote runtime started."
54
+ message: "Remote runtime restarted."
36
55
  };
37
- }
38
- if (action === "stop") {
39
- await controller.stop();
56
+ };
57
+ controlManagedService = async (action, deps) => {
58
+ const serviceState = this.resolveManagedServiceControlState();
59
+ const uiOverrides = this.resolveManagedUiOverrides();
60
+ if (action === "start") return this.startManagedService(action, deps, serviceState, uiOverrides);
61
+ if (!serviceState.running) return this.controlStoppedManagedService(action, deps, serviceState, uiOverrides);
62
+ if (serviceState.currentProcess) return this.controlCurrentManagedProcess(action, deps, uiOverrides);
63
+ return this.controlExternalManagedProcess(action, deps, uiOverrides);
64
+ };
65
+ resolveManagedServiceControlState = () => {
66
+ const state = managedServiceStateStore.read();
67
+ const liveness = this.managedServiceSupervisor.resolveStateLiveness(state);
68
+ const running = Boolean(state && liveness.running);
40
69
  return {
41
- accepted: true,
42
- action,
43
- message: "Remote runtime stopped."
70
+ currentProcess: Boolean(running && state?.pid === process.pid),
71
+ recordedProcessExists: Boolean(state && liveness.processExists),
72
+ running
44
73
  };
45
- }
46
- await controller.restart();
47
- return {
48
- accepted: true,
49
- action,
50
- message: "Remote runtime restarted."
51
74
  };
52
- }
53
- async function controlManagedService(action, deps) {
54
- const state = managedServiceStateStore.read();
55
- const running = Boolean(state && isProcessRunning(state.pid));
56
- const currentProcess = Boolean(running && state?.pid === process.pid);
57
- const uiOverrides = resolveManagedUiOverrides();
58
- if (action === "start") {
59
- if (running) return {
75
+ startManagedService = async (action, deps, serviceState, uiOverrides) => {
76
+ if (serviceState.running) return {
60
77
  accepted: true,
61
78
  action,
62
- message: currentProcess ? "Managed service is already running for this UI." : "Managed service is already running."
79
+ message: serviceState.currentProcess ? "Managed service is already running for this UI." : "Managed service is already running."
63
80
  };
81
+ if (serviceState.recordedProcessExists) return this.controlStaleManagedServiceProcess(action, deps, uiOverrides);
64
82
  await deps.serviceCommands.startService({
65
83
  uiOverrides,
66
84
  open: false
@@ -70,8 +88,9 @@ async function controlManagedService(action, deps) {
70
88
  action,
71
89
  message: "Managed service started."
72
90
  };
73
- }
74
- if (!running) {
91
+ };
92
+ controlStoppedManagedService = async (action, deps, serviceState, uiOverrides) => {
93
+ if (serviceState.recordedProcessExists) return this.controlStaleManagedServiceProcess(action, deps, uiOverrides);
75
94
  if (action === "restart") {
76
95
  await deps.serviceCommands.startService({
77
96
  uiOverrides,
@@ -88,49 +107,87 @@ async function controlManagedService(action, deps) {
88
107
  action,
89
108
  message: "No managed service is currently running."
90
109
  };
91
- }
92
- if (currentProcess) {
110
+ };
111
+ controlStaleManagedServiceProcess = async (action, deps, uiOverrides) => {
112
+ if (action === "stop") {
113
+ await deps.serviceCommands.stopService();
114
+ return {
115
+ accepted: true,
116
+ action,
117
+ message: "Stale managed service process stopped."
118
+ };
119
+ }
120
+ if (action === "restart") {
121
+ await deps.serviceCommands.stopService();
122
+ await deps.serviceCommands.startService({
123
+ uiOverrides,
124
+ open: false
125
+ });
126
+ return {
127
+ accepted: true,
128
+ action,
129
+ message: "Stale managed service process replaced."
130
+ };
131
+ }
132
+ await deps.serviceCommands.stopService();
133
+ await deps.serviceCommands.startService({
134
+ uiOverrides,
135
+ open: false
136
+ });
137
+ return {
138
+ accepted: true,
139
+ action,
140
+ message: "Stale managed service process replaced."
141
+ };
142
+ };
143
+ controlCurrentManagedProcess = async (action, deps, uiOverrides) => {
93
144
  if (action === "restart") await deps.requestManagedServiceRestart({ uiPort: uiOverrides.port ?? 55667 });
94
- else scheduleManagedSelfStop();
145
+ else launchManagedSelfControl();
95
146
  return {
96
147
  accepted: true,
97
148
  action,
98
149
  message: action === "restart" ? "Restart scheduled. This page may disconnect for a few seconds." : "Stop scheduled. This page will disconnect shortly."
99
150
  };
100
- }
101
- if (action === "stop") {
151
+ };
152
+ controlExternalManagedProcess = async (action, deps, uiOverrides) => {
153
+ if (action === "stop") {
154
+ await deps.serviceCommands.stopService();
155
+ return {
156
+ accepted: true,
157
+ action,
158
+ message: "Managed service stopped."
159
+ };
160
+ }
102
161
  await deps.serviceCommands.stopService();
162
+ await deps.serviceCommands.startService({
163
+ uiOverrides,
164
+ open: false
165
+ });
103
166
  return {
104
167
  accepted: true,
105
168
  action,
106
- message: "Managed service stopped."
169
+ message: "Managed service restarted."
107
170
  };
108
- }
109
- await deps.serviceCommands.stopService();
110
- await deps.serviceCommands.startService({
111
- uiOverrides,
112
- open: false
113
- });
114
- return {
115
- accepted: true,
116
- action,
117
- message: "Managed service restarted."
118
171
  };
119
- }
120
- function resolveManagedUiOverrides() {
121
- return {
122
- enabled: true,
123
- host: FORCED_PUBLIC_UI_HOST,
124
- open: false,
125
- port: resolveUiConfig(loadConfig(getConfigPath()), {
172
+ resolveManagedUiOverrides = () => {
173
+ return {
126
174
  enabled: true,
127
175
  host: FORCED_PUBLIC_UI_HOST,
128
- open: false
129
- }).port
176
+ open: false,
177
+ port: resolveUiConfig(loadConfig(getConfigPath()), {
178
+ enabled: true,
179
+ host: FORCED_PUBLIC_UI_HOST,
180
+ open: false
181
+ }).port
182
+ };
130
183
  };
184
+ };
185
+ const remoteServiceControlService = new RemoteServiceControlService();
186
+ function resolveRemoteServiceView(currentUi) {
187
+ return remoteServiceControlService.resolveView(currentUi);
131
188
  }
132
- function scheduleManagedSelfStop() {
133
- launchManagedSelfControl();
189
+ async function controlRemoteService(action, deps) {
190
+ return remoteServiceControlService.control(action, deps);
134
191
  }
135
192
  function launchManagedSelfControl(params = {}) {
136
193
  const script = [
@@ -17,7 +17,6 @@ function installPluginRuntimeBridge(gateway) {
17
17
  await dispatcherOptions.onReplyStart?.();
18
18
  const response = await dispatchPromptOverNcp({
19
19
  config: gateway.configManager.loadConfig(),
20
- sessionManager: gateway.sessionManager,
21
20
  agentRunRequests: gateway.kernel.agentRunRequestManager,
22
21
  ...request
23
22
  });
@@ -0,0 +1,84 @@
1
+ import { ManagedServiceLastExit, ManagedServiceState, ManagedServiceStateStore } from "../../stores/managed-service-state.store.js";
2
+ import { ManagedServiceSnapshot } from "./utils/managed-service-routing.utils.js";
3
+ import * as NextclawCore from "@nextclaw/core";
4
+ import { ChildProcess } from "node:child_process";
5
+
6
+ //#region src/shared/services/runtime/managed-service-supervisor.service.d.ts
7
+ type Config$1 = NextclawCore.Config;
8
+ type ManagedServiceStartup = {
9
+ child: ChildProcess;
10
+ logPath: string;
11
+ readinessTimeoutMs: number;
12
+ quickPhaseTimeoutMs: number;
13
+ extendedPhaseTimeoutMs: number;
14
+ snapshot: ManagedServiceSnapshot;
15
+ };
16
+ type ManagedServiceLiveness = {
17
+ processExists: boolean;
18
+ running: boolean;
19
+ staleState: boolean;
20
+ staleReason: "process-not-running" | "lease-expired" | null;
21
+ leaseExpired: boolean;
22
+ leaseMissing: boolean;
23
+ lastHeartbeatAt: string | null;
24
+ };
25
+ type ManagedServiceSupervisorOptions = {
26
+ stateStore?: ManagedServiceStateStore;
27
+ now?: () => Date;
28
+ isProcessRunningFn?: (pid: number) => boolean;
29
+ heartbeatIntervalMs?: number;
30
+ leaseTtlMs?: number;
31
+ };
32
+ declare class ManagedServiceSupervisor {
33
+ private readonly stateStore;
34
+ private readonly now;
35
+ private readonly isProcessRunningFn;
36
+ private readonly heartbeatIntervalMs;
37
+ private readonly leaseTtlMs;
38
+ private readonly serviceStartupLogger;
39
+ private heartbeatTimer;
40
+ private lifecycleTrackingInstalled;
41
+ private pendingExit;
42
+ constructor(options?: ManagedServiceSupervisorOptions);
43
+ spawnManagedService: (params: {
44
+ appName: string;
45
+ config: Config$1;
46
+ uiConfig: {
47
+ host: string;
48
+ port: number;
49
+ };
50
+ uiUrl: string;
51
+ apiUrl: string;
52
+ healthUrl: string;
53
+ startupTimeoutMs?: number;
54
+ resolveStartupTimeoutMs: (overrideTimeoutMs: number | undefined) => number;
55
+ appendStartupStage: (logPath: string, message: string) => void;
56
+ printStartupFailureDiagnostics: (params: {
57
+ uiUrl: string;
58
+ apiUrl: string;
59
+ healthUrl: string;
60
+ logPath: string;
61
+ lastProbeError: string | null;
62
+ }) => void;
63
+ resolveServiceLogPath?: () => string;
64
+ }) => ManagedServiceStartup | null;
65
+ writeReadyState: (params: {
66
+ readinessTimeoutMs: number;
67
+ readiness: {
68
+ ready: boolean;
69
+ lastProbeError: string | null;
70
+ };
71
+ snapshot: ManagedServiceSnapshot;
72
+ }) => ManagedServiceState;
73
+ installCurrentProcessLifecycleTracking: () => void;
74
+ startHeartbeatForCurrentProcess: (pid?: number) => void;
75
+ stopHeartbeatForCurrentProcess: () => void;
76
+ resolveStateLiveness: (state: ManagedServiceState | null) => ManagedServiceLiveness;
77
+ private writeHeartbeat;
78
+ recordCurrentProcessExit: (exit: ManagedServiceLastExit) => void;
79
+ private stopHeartbeat;
80
+ private readonly createLease;
81
+ private resolveLeaseStatus;
82
+ }
83
+ //#endregion
84
+ export { ManagedServiceSupervisor };
@@ -0,0 +1,269 @@
1
+ import { createTopLevelNextclawCommandEnv } from "../../utils/top-level-nextclaw-command-env.utils.js";
2
+ import { resolveCliSubcommandLaunch } from "../../utils/marketplace/cli-subcommand-launch.utils.js";
3
+ import { isProcessRunning, resolveServiceLogPath } from "../../utils/cli.utils.js";
4
+ import { managedServiceStateStore } from "../../stores/managed-service-state.store.js";
5
+ import { writeInitialManagedServiceState, writeReadyManagedServiceState } from "./utils/service-remote-runtime.utils.js";
6
+ import * as NextclawCore from "@nextclaw/core";
7
+ import { FileLogSink } from "@nextclaw/core";
8
+ import { mkdirSync } from "node:fs";
9
+ import { spawn } from "node:child_process";
10
+ import { dirname } from "node:path";
11
+ //#region src/shared/services/runtime/managed-service-supervisor.service.ts
12
+ const DEFAULT_HEARTBEAT_INTERVAL_MS = 2e3;
13
+ const DEFAULT_LEASE_TTL_MS = 1e4;
14
+ const SIGNAL_EXIT_CODES = {
15
+ SIGHUP: 129,
16
+ SIGINT: 130,
17
+ SIGTERM: 143
18
+ };
19
+ var ManagedServiceSupervisor = class {
20
+ stateStore;
21
+ now;
22
+ isProcessRunningFn;
23
+ heartbeatIntervalMs;
24
+ leaseTtlMs;
25
+ serviceStartupLogger = NextclawCore.getAppLogger("service.startup");
26
+ heartbeatTimer = null;
27
+ lifecycleTrackingInstalled = false;
28
+ pendingExit = null;
29
+ constructor(options = {}) {
30
+ this.stateStore = options.stateStore ?? managedServiceStateStore;
31
+ this.now = options.now ?? (() => /* @__PURE__ */ new Date());
32
+ this.isProcessRunningFn = options.isProcessRunningFn ?? isProcessRunning;
33
+ this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
34
+ this.leaseTtlMs = options.leaseTtlMs ?? DEFAULT_LEASE_TTL_MS;
35
+ }
36
+ spawnManagedService = (params) => {
37
+ const { appName, apiUrl, appendStartupStage, config, healthUrl, printStartupFailureDiagnostics, resolveStartupTimeoutMs, startupTimeoutMs, uiConfig, uiUrl } = params;
38
+ const logPath = (params.resolveServiceLogPath ?? resolveServiceLogPath)();
39
+ new FileLogSink({ serviceLogPath: logPath }).ensureReady();
40
+ mkdirSync(dirname(logPath), { recursive: true });
41
+ const readinessTimeoutMs = resolveStartupTimeoutMs(startupTimeoutMs);
42
+ const quickPhaseTimeoutMs = Math.min(8e3, readinessTimeoutMs);
43
+ const extendedPhaseTimeoutMs = Math.max(0, readinessTimeoutMs - quickPhaseTimeoutMs);
44
+ appendStartupStage(logPath, `start requested: ui=${uiConfig.host}:${uiConfig.port}, readinessTimeoutMs=${readinessTimeoutMs}`);
45
+ console.log(`Starting ${appName} background service (readiness timeout ${Math.ceil(readinessTimeoutMs / 1e3)}s)...`);
46
+ const cliLaunch = resolveCliSubcommandLaunch({
47
+ argvEntry: process.argv[1],
48
+ importMetaUrl: import.meta.url,
49
+ cliArgs: [
50
+ "serve",
51
+ "--ui-port",
52
+ String(uiConfig.port)
53
+ ],
54
+ nodePath: process.execPath
55
+ });
56
+ const childArgs = [...process.execArgv, ...cliLaunch.args];
57
+ appendStartupStage(logPath, `spawning background process: ${cliLaunch.command} ${childArgs.join(" ")}`);
58
+ const child = spawn(cliLaunch.command, childArgs, {
59
+ env: createTopLevelNextclawCommandEnv(process.env),
60
+ stdio: "ignore",
61
+ detached: true,
62
+ windowsHide: true
63
+ });
64
+ appendStartupStage(logPath, `spawned background process pid=${child.pid ?? "unknown"}`);
65
+ if (!child.pid) {
66
+ appendStartupStage(logPath, "spawn failed: child pid missing");
67
+ console.error("Error: Failed to start background service.");
68
+ printStartupFailureDiagnostics({
69
+ uiUrl,
70
+ apiUrl,
71
+ healthUrl,
72
+ logPath,
73
+ lastProbeError: null
74
+ });
75
+ return null;
76
+ }
77
+ const snapshot = {
78
+ pid: child.pid,
79
+ uiUrl,
80
+ apiUrl,
81
+ uiHost: uiConfig.host,
82
+ uiPort: uiConfig.port,
83
+ logPath
84
+ };
85
+ writeInitialManagedServiceState({
86
+ config,
87
+ lease: this.createLease(child.pid),
88
+ readinessTimeoutMs,
89
+ snapshot
90
+ });
91
+ this.serviceStartupLogger.info("runtime.process.started", {
92
+ runtimeKind: "managed-service",
93
+ childPid: child.pid,
94
+ uiUrl,
95
+ apiUrl,
96
+ uiHost: uiConfig.host,
97
+ uiPort: uiConfig.port,
98
+ entrypoint: `${cliLaunch.command} ${childArgs.join(" ")}`
99
+ });
100
+ this.serviceStartupLogger.info("service_state.written", {
101
+ runtimeKind: "managed-service",
102
+ childPid: child.pid,
103
+ statePath: this.stateStore.path,
104
+ uiUrl,
105
+ apiUrl
106
+ });
107
+ return {
108
+ child,
109
+ logPath,
110
+ readinessTimeoutMs,
111
+ quickPhaseTimeoutMs,
112
+ extendedPhaseTimeoutMs,
113
+ snapshot
114
+ };
115
+ };
116
+ writeReadyState = (params) => {
117
+ return writeReadyManagedServiceState({
118
+ ...params,
119
+ lease: this.createLease(params.snapshot.pid)
120
+ });
121
+ };
122
+ installCurrentProcessLifecycleTracking = () => {
123
+ if (this.lifecycleTrackingInstalled) return;
124
+ this.lifecycleTrackingInstalled = true;
125
+ this.startHeartbeatForCurrentProcess();
126
+ for (const signal of [
127
+ "SIGHUP",
128
+ "SIGINT",
129
+ "SIGTERM"
130
+ ]) process.once(signal, () => {
131
+ this.pendingExit = {
132
+ pid: process.pid,
133
+ reason: "signal",
134
+ exitedAt: this.now().toISOString(),
135
+ code: SIGNAL_EXIT_CODES[signal],
136
+ signal
137
+ };
138
+ this.recordCurrentProcessExit(this.pendingExit);
139
+ this.stopHeartbeat();
140
+ process.exit(SIGNAL_EXIT_CODES[signal]);
141
+ });
142
+ process.once("uncaughtExceptionMonitor", (error) => {
143
+ this.pendingExit = {
144
+ pid: process.pid,
145
+ reason: "uncaughtException",
146
+ exitedAt: this.now().toISOString(),
147
+ message: error instanceof Error ? error.message : String(error)
148
+ };
149
+ });
150
+ process.once("exit", (code) => {
151
+ this.recordCurrentProcessExit({
152
+ ...this.pendingExit ?? {
153
+ pid: process.pid,
154
+ reason: "exit",
155
+ exitedAt: this.now().toISOString()
156
+ },
157
+ code
158
+ });
159
+ this.stopHeartbeat();
160
+ });
161
+ };
162
+ startHeartbeatForCurrentProcess = (pid = process.pid) => {
163
+ if (this.heartbeatTimer) return;
164
+ if (!this.writeHeartbeat(pid)) return;
165
+ this.heartbeatTimer = setInterval(() => {
166
+ if (!this.writeHeartbeat(pid)) this.stopHeartbeat();
167
+ }, this.heartbeatIntervalMs);
168
+ this.heartbeatTimer.unref();
169
+ };
170
+ stopHeartbeatForCurrentProcess = () => {
171
+ this.stopHeartbeat();
172
+ };
173
+ resolveStateLiveness = (state) => {
174
+ if (!state) return {
175
+ processExists: false,
176
+ running: false,
177
+ staleState: false,
178
+ staleReason: null,
179
+ leaseExpired: false,
180
+ leaseMissing: false,
181
+ lastHeartbeatAt: null
182
+ };
183
+ const processExists = this.isProcessRunningFn(state.pid);
184
+ const leaseStatus = this.resolveLeaseStatus(state.lease);
185
+ if (!processExists) return {
186
+ processExists,
187
+ running: false,
188
+ staleState: true,
189
+ staleReason: "process-not-running",
190
+ leaseExpired: leaseStatus.expired,
191
+ leaseMissing: leaseStatus.missing,
192
+ lastHeartbeatAt: leaseStatus.heartbeatAt
193
+ };
194
+ if (leaseStatus.expired) return {
195
+ processExists,
196
+ running: false,
197
+ staleState: true,
198
+ staleReason: "lease-expired",
199
+ leaseExpired: true,
200
+ leaseMissing: false,
201
+ lastHeartbeatAt: leaseStatus.heartbeatAt
202
+ };
203
+ return {
204
+ processExists,
205
+ running: true,
206
+ staleState: false,
207
+ staleReason: null,
208
+ leaseExpired: false,
209
+ leaseMissing: leaseStatus.missing,
210
+ lastHeartbeatAt: leaseStatus.heartbeatAt
211
+ };
212
+ };
213
+ writeHeartbeat = (pid) => {
214
+ let wrote = false;
215
+ this.stateStore.update((state) => {
216
+ if (state.pid !== pid) return state;
217
+ wrote = true;
218
+ const next = {
219
+ ...state,
220
+ lease: {
221
+ ...state.lease ?? this.createLease(pid),
222
+ ownerPid: pid,
223
+ heartbeatAt: this.now().toISOString(),
224
+ heartbeatIntervalMs: this.heartbeatIntervalMs,
225
+ ttlMs: this.leaseTtlMs
226
+ }
227
+ };
228
+ delete next.lastExit;
229
+ return next;
230
+ });
231
+ return wrote;
232
+ };
233
+ recordCurrentProcessExit = (exit) => {
234
+ this.stateStore.update((state) => {
235
+ if (state.pid !== exit.pid) return state;
236
+ return {
237
+ ...state,
238
+ lastExit: exit
239
+ };
240
+ });
241
+ };
242
+ stopHeartbeat = () => {
243
+ if (!this.heartbeatTimer) return;
244
+ clearInterval(this.heartbeatTimer);
245
+ this.heartbeatTimer = null;
246
+ };
247
+ createLease = (ownerPid) => ({
248
+ ownerPid,
249
+ heartbeatAt: this.now().toISOString(),
250
+ heartbeatIntervalMs: this.heartbeatIntervalMs,
251
+ ttlMs: this.leaseTtlMs
252
+ });
253
+ resolveLeaseStatus = (lease) => {
254
+ if (!lease) return {
255
+ expired: false,
256
+ missing: true,
257
+ heartbeatAt: null
258
+ };
259
+ const heartbeatAtMs = Date.parse(lease.heartbeatAt);
260
+ const ttlMs = Number.isFinite(lease.ttlMs) ? lease.ttlMs : this.leaseTtlMs;
261
+ return {
262
+ expired: !Number.isFinite(heartbeatAtMs) || heartbeatAtMs + ttlMs < this.now().getTime(),
263
+ missing: false,
264
+ heartbeatAt: lease.heartbeatAt
265
+ };
266
+ };
267
+ };
268
+ //#endregion
269
+ export { ManagedServiceSupervisor };
@@ -11,6 +11,7 @@ declare class RuntimeCommandService {
11
11
  private loggingInstalled;
12
12
  private processExitLoggingInstalled;
13
13
  private readonly runtimeLogger;
14
+ private readonly managedServiceSupervisor;
14
15
  private readonly managedServiceCommandService;
15
16
  constructor(deps: {
16
17
  requestRestart: (params: RequestRestartParams) => Promise<void>;
@@ -1,6 +1,7 @@
1
1
  import { resolveCliSubcommandEntry } from "../../utils/marketplace/cli-subcommand-launch.utils.js";
2
2
  import { isLoopbackHost, resolvePublicIp, resolveUiStaticDir } from "../../utils/cli.utils.js";
3
3
  import { NextclawDistributionService } from "./nextclaw-distribution.service.js";
4
+ import { ManagedServiceSupervisor } from "./managed-service-supervisor.service.js";
4
5
  import { describeUnmanagedHealthyTargetMessage, inspectUiTarget } from "../../utils/service-port-probe.utils.js";
5
6
  import { ManagedServiceCommandService } from "./service-managed-startup.service.js";
6
7
  import { buildMarketplaceSkillInstallArgs, pickUserFacingCommandSummary } from "../../utils/marketplace/service-marketplace-helpers.utils.js";
@@ -14,6 +15,7 @@ var RuntimeCommandService = class {
14
15
  loggingInstalled = false;
15
16
  processExitLoggingInstalled = false;
16
17
  runtimeLogger = NextclawCore.getAppLogger("service.runtime");
18
+ managedServiceSupervisor = new ManagedServiceSupervisor();
17
19
  managedServiceCommandService = new ManagedServiceCommandService({
18
20
  startGateway: async (options) => await this.startGateway(options),
19
21
  printPublicUiUrls: async (host, port) => await this.printPublicUiUrls(host, port),
@@ -27,6 +29,7 @@ var RuntimeCommandService = class {
27
29
  startGateway = async (options = {}) => {
28
30
  this.ensureRuntimeLoggingInstalled();
29
31
  this.installProcessExitLogging();
32
+ this.managedServiceSupervisor.installCurrentProcessLifecycleTracking();
30
33
  this.runtimeLogger.info("runtime.process.started", {
31
34
  runtimeKind: "serve-process",
32
35
  pid: process.pid,
@@ -1,6 +1,5 @@
1
- import { ManagedServiceSnapshot, resolveManagedServiceReadySnapshot, resolveManagedServiceUiBinding, resolveSessionRouteCandidate } from "./utils/managed-service-routing.utils.js";
1
+ import { resolveManagedServiceReadySnapshot, resolveManagedServiceUiBinding, resolveSessionRouteCandidate } from "./utils/managed-service-routing.utils.js";
2
2
  import * as NextclawCore from "@nextclaw/core";
3
- import { spawn } from "node:child_process";
4
3
 
5
4
  //#region src/shared/services/runtime/service-managed-startup.service.d.ts
6
5
  type Config$1 = NextclawCore.Config;
@@ -9,35 +8,6 @@ type StartServiceOptions = {
9
8
  open: boolean;
10
9
  startupTimeoutMs?: number;
11
10
  };
12
- declare function spawnManagedService(params: {
13
- appName: string;
14
- config: NextclawCore.Config;
15
- uiConfig: {
16
- host: string;
17
- port: number;
18
- };
19
- uiUrl: string;
20
- apiUrl: string;
21
- healthUrl: string;
22
- startupTimeoutMs?: number;
23
- resolveStartupTimeoutMs: (overrideTimeoutMs: number | undefined) => number;
24
- appendStartupStage: (logPath: string, message: string) => void;
25
- printStartupFailureDiagnostics: (params: {
26
- uiUrl: string;
27
- apiUrl: string;
28
- healthUrl: string;
29
- logPath: string;
30
- lastProbeError: string | null;
31
- }) => void;
32
- resolveServiceLogPath: () => string;
33
- }): {
34
- child: ReturnType<typeof spawn>;
35
- logPath: string;
36
- readinessTimeoutMs: number;
37
- quickPhaseTimeoutMs: number;
38
- extendedPhaseTimeoutMs: number;
39
- snapshot: ManagedServiceSnapshot;
40
- } | null;
41
11
  declare function waitForManagedServiceReadiness(params: {
42
12
  appName: string;
43
13
  childPid: number;
@@ -85,6 +55,7 @@ declare class ManagedServiceCommandService {
85
55
  private readonly loggingRuntime;
86
56
  private readonly serviceLogger;
87
57
  private readonly startupLogger;
58
+ private readonly supervisor;
88
59
  constructor(deps: {
89
60
  startGateway: (options: {
90
61
  uiOverrides: Partial<Config$1["ui"]>;
@@ -120,4 +91,4 @@ declare class ManagedServiceCommandService {
120
91
  private printStartupFailureDiagnostics;
121
92
  }
122
93
  //#endregion
123
- export { ManagedServiceCommandService, StartServiceOptions, reportManagedServiceStart, resolveManagedServiceReadySnapshot, resolveManagedServiceUiBinding, resolveSessionRouteCandidate, spawnManagedService, waitForManagedServiceReadiness };
94
+ export { ManagedServiceCommandService, StartServiceOptions, reportManagedServiceStart, resolveManagedServiceReadySnapshot, resolveManagedServiceUiBinding, resolveSessionRouteCandidate, waitForManagedServiceReadiness };
@@ -1,98 +1,12 @@
1
- import { createTopLevelNextclawCommandEnv } from "../../utils/top-level-nextclaw-command-env.utils.js";
2
- import { resolveCliSubcommandLaunch } from "../../utils/marketplace/cli-subcommand-launch.utils.js";
3
1
  import { isProcessRunning, openBrowser, resolveServiceLogPath, resolveUiApiBase, resolveUiConfig, waitForExit } from "../../utils/cli.utils.js";
4
2
  import { managedServiceStateStore } from "../../stores/managed-service-state.store.js";
5
3
  import { localUiRuntimeStore } from "../../stores/local-ui-runtime.store.js";
6
- import { writeInitialManagedServiceState, writeReadyManagedServiceState } from "./utils/service-remote-runtime.utils.js";
4
+ import { ManagedServiceSupervisor } from "./managed-service-supervisor.service.js";
7
5
  import { resolveManagedServiceReadySnapshot, resolveManagedServiceUiBinding, resolveSessionRouteCandidate } from "./utils/managed-service-routing.utils.js";
8
6
  import { probeHealthEndpoint } from "../../utils/service-port-probe.utils.js";
9
7
  import * as NextclawCore from "@nextclaw/core";
10
- import { FileLogSink } from "@nextclaw/core";
11
- import { mkdirSync } from "node:fs";
12
- import { spawn } from "node:child_process";
13
- import { dirname } from "node:path";
14
8
  //#region src/shared/services/runtime/service-managed-startup.service.ts
15
9
  const { APP_NAME: APP_NAME$1, loadConfig: loadConfig$1 } = NextclawCore;
16
- const serviceStartupLogger = NextclawCore.getAppLogger("service.startup");
17
- function spawnManagedService(params) {
18
- const { appName, config, uiConfig, uiUrl, apiUrl, healthUrl, startupTimeoutMs, resolveStartupTimeoutMs, appendStartupStage, printStartupFailureDiagnostics, resolveServiceLogPath } = params;
19
- const logPath = resolveServiceLogPath();
20
- new FileLogSink({ serviceLogPath: logPath }).ensureReady();
21
- mkdirSync(dirname(logPath), { recursive: true });
22
- const readinessTimeoutMs = resolveStartupTimeoutMs(startupTimeoutMs);
23
- const quickPhaseTimeoutMs = Math.min(8e3, readinessTimeoutMs);
24
- const extendedPhaseTimeoutMs = Math.max(0, readinessTimeoutMs - quickPhaseTimeoutMs);
25
- appendStartupStage(logPath, `start requested: ui=${uiConfig.host}:${uiConfig.port}, readinessTimeoutMs=${readinessTimeoutMs}`);
26
- console.log(`Starting ${appName} background service (readiness timeout ${Math.ceil(readinessTimeoutMs / 1e3)}s)...`);
27
- const cliLaunch = resolveCliSubcommandLaunch({
28
- argvEntry: process.argv[1],
29
- importMetaUrl: import.meta.url,
30
- cliArgs: [
31
- "serve",
32
- "--ui-port",
33
- String(uiConfig.port)
34
- ],
35
- nodePath: process.execPath
36
- });
37
- const childArgs = [...process.execArgv, ...cliLaunch.args];
38
- appendStartupStage(logPath, `spawning background process: ${cliLaunch.command} ${childArgs.join(" ")}`);
39
- const child = spawn(cliLaunch.command, childArgs, {
40
- env: createTopLevelNextclawCommandEnv(process.env),
41
- stdio: "ignore",
42
- detached: true,
43
- windowsHide: true
44
- });
45
- appendStartupStage(logPath, `spawned background process pid=${child.pid ?? "unknown"}`);
46
- if (!child.pid) {
47
- appendStartupStage(logPath, "spawn failed: child pid missing");
48
- console.error("Error: Failed to start background service.");
49
- printStartupFailureDiagnostics({
50
- uiUrl,
51
- apiUrl,
52
- healthUrl,
53
- logPath,
54
- lastProbeError: null
55
- });
56
- return null;
57
- }
58
- const snapshot = {
59
- pid: child.pid,
60
- uiUrl,
61
- apiUrl,
62
- uiHost: uiConfig.host,
63
- uiPort: uiConfig.port,
64
- logPath
65
- };
66
- writeInitialManagedServiceState({
67
- config,
68
- readinessTimeoutMs,
69
- snapshot
70
- });
71
- serviceStartupLogger.info("runtime.process.started", {
72
- runtimeKind: "managed-service",
73
- childPid: child.pid,
74
- uiUrl,
75
- apiUrl,
76
- uiHost: uiConfig.host,
77
- uiPort: uiConfig.port,
78
- entrypoint: `${cliLaunch.command} ${childArgs.join(" ")}`
79
- });
80
- serviceStartupLogger.info("service_state.written", {
81
- runtimeKind: "managed-service",
82
- childPid: child.pid,
83
- statePath: managedServiceStateStore.path,
84
- uiUrl,
85
- apiUrl
86
- });
87
- return {
88
- child,
89
- logPath,
90
- readinessTimeoutMs,
91
- quickPhaseTimeoutMs,
92
- extendedPhaseTimeoutMs,
93
- snapshot
94
- };
95
- }
96
10
  async function waitForManagedServiceReadiness(params) {
97
11
  params.appendStartupStage(params.logPath, `health probe started: ${params.healthUrl} (phase=quick, timeoutMs=${params.quickPhaseTimeoutMs})`);
98
12
  let readiness = await params.waitForBackgroundServiceReady({
@@ -128,6 +42,7 @@ var ManagedServiceCommandService = class {
128
42
  loggingRuntime = NextclawCore.getLoggingRuntime();
129
43
  serviceLogger = this.loggingRuntime.getLogger("service");
130
44
  startupLogger = this.serviceLogger.child("startup");
45
+ supervisor = new ManagedServiceSupervisor();
131
46
  constructor(deps) {
132
47
  this.deps = deps;
133
48
  }
@@ -149,7 +64,8 @@ var ManagedServiceCommandService = class {
149
64
  const apiUrl = `${uiUrl}/api`;
150
65
  const staticDir = this.deps.resolveUiStaticDir();
151
66
  const existing = managedServiceStateStore.read();
152
- if (existing && isProcessRunning(existing.pid)) {
67
+ const existingLiveness = this.supervisor.resolveStateLiveness(existing);
68
+ if (existing && existingLiveness.running) {
153
69
  await this.handleExistingManagedService({
154
70
  existing,
155
71
  uiConfig,
@@ -157,7 +73,7 @@ var ManagedServiceCommandService = class {
157
73
  });
158
74
  return;
159
75
  }
160
- if (existing) managedServiceStateStore.clear();
76
+ if (existing && !existingLiveness.processExists) managedServiceStateStore.clear();
161
77
  if (!staticDir) {
162
78
  process.exitCode = 1, console.error(`Error: ${APP_NAME$1} UI frontend bundle not found. Reinstall or rebuild ${APP_NAME$1}. For dev-only overrides, set NEXTCLAW_UI_STATIC_DIR to a built frontend directory.`);
163
79
  return;
@@ -282,7 +198,7 @@ var ManagedServiceCommandService = class {
282
198
  };
283
199
  startNewManagedServiceTarget = async (params) => {
284
200
  const { apiUrl, config, healthUrl, startupTimeoutMs, uiConfig, uiUrl } = params;
285
- const startup = spawnManagedService({
201
+ const startup = this.supervisor.spawnManagedService({
286
202
  appName: APP_NAME$1,
287
203
  config,
288
204
  uiConfig,
@@ -336,7 +252,7 @@ var ManagedServiceCommandService = class {
336
252
  }
337
253
  startup.child.unref();
338
254
  const readySnapshot = resolveManagedServiceReadySnapshot({ snapshot: startup.snapshot });
339
- const state = writeReadyManagedServiceState({
255
+ const state = this.supervisor.writeReadyState({
340
256
  readinessTimeoutMs: startup.readinessTimeoutMs,
341
257
  readiness,
342
258
  snapshot: readySnapshot
@@ -420,4 +336,4 @@ var ManagedServiceCommandService = class {
420
336
  };
421
337
  };
422
338
  //#endregion
423
- export { ManagedServiceCommandService, reportManagedServiceStart, resolveManagedServiceReadySnapshot, resolveManagedServiceUiBinding, resolveSessionRouteCandidate, spawnManagedService, waitForManagedServiceReadiness };
339
+ export { ManagedServiceCommandService, reportManagedServiceStart, resolveManagedServiceReadySnapshot, resolveManagedServiceUiBinding, resolveSessionRouteCandidate, waitForManagedServiceReadiness };
@@ -1,4 +1,4 @@
1
- import { ManagedServiceState } from "../../../stores/managed-service-state.store.js";
1
+ import { ManagedServiceLease, ManagedServiceState } from "../../../stores/managed-service-state.store.js";
2
2
  import { Config } from "@nextclaw/core";
3
3
  import { RemoteRuntimeState, RemoteServiceModule } from "@nextclaw/remote";
4
4
 
@@ -40,10 +40,12 @@ declare function createManagedRemoteModuleForUi(params: {
40
40
  }): RemoteServiceModule | null;
41
41
  declare function writeInitialManagedServiceState(params: {
42
42
  config: Config;
43
+ lease?: ManagedServiceLease;
43
44
  readinessTimeoutMs: number;
44
45
  snapshot: ManagedServiceSnapshot;
45
46
  }): void;
46
47
  declare function writeReadyManagedServiceState(params: {
48
+ lease?: ManagedServiceLease;
47
49
  readinessTimeoutMs: number;
48
50
  readiness: {
49
51
  ready: boolean;
@@ -137,33 +137,37 @@ function createManagedRemoteModuleForUi(params) {
137
137
  });
138
138
  }
139
139
  function writeInitialManagedServiceState(params) {
140
+ const { config, lease, readinessTimeoutMs, snapshot } = params;
140
141
  managedServiceStateStore.write({
141
- pid: params.snapshot.pid,
142
+ pid: snapshot.pid,
142
143
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
143
- uiUrl: params.snapshot.uiUrl,
144
- apiUrl: params.snapshot.apiUrl,
145
- uiHost: params.snapshot.uiHost,
146
- uiPort: params.snapshot.uiPort,
147
- logPath: params.snapshot.logPath,
144
+ uiUrl: snapshot.uiUrl,
145
+ apiUrl: snapshot.apiUrl,
146
+ uiHost: snapshot.uiHost,
147
+ uiPort: snapshot.uiPort,
148
+ logPath: snapshot.logPath,
149
+ ...lease ? { lease } : {},
148
150
  startupLastProbeError: null,
149
- startupTimeoutMs: params.readinessTimeoutMs,
151
+ startupTimeoutMs: readinessTimeoutMs,
150
152
  startupCheckedAt: (/* @__PURE__ */ new Date()).toISOString(),
151
- ...params.config.remote.enabled ? { remote: buildNextclawConfiguredRemoteState(params.config) } : {}
153
+ ...config.remote.enabled ? { remote: buildNextclawConfiguredRemoteState(config) } : {}
152
154
  });
153
155
  }
154
156
  function writeReadyManagedServiceState(params) {
157
+ const { lease, readiness, readinessTimeoutMs, snapshot } = params;
155
158
  const currentState = managedServiceStateStore.read();
156
159
  const state = {
157
- pid: params.snapshot.pid,
160
+ pid: snapshot.pid,
158
161
  startedAt: currentState?.startedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
159
- uiUrl: params.snapshot.uiUrl,
160
- apiUrl: params.snapshot.apiUrl,
161
- uiHost: params.snapshot.uiHost,
162
- uiPort: params.snapshot.uiPort,
163
- logPath: params.snapshot.logPath,
164
- startupState: params.readiness.ready ? "ready" : "degraded",
165
- startupLastProbeError: params.readiness.lastProbeError,
166
- startupTimeoutMs: params.readinessTimeoutMs,
162
+ uiUrl: snapshot.uiUrl,
163
+ apiUrl: snapshot.apiUrl,
164
+ uiHost: snapshot.uiHost,
165
+ uiPort: snapshot.uiPort,
166
+ logPath: snapshot.logPath,
167
+ ...lease ? { lease } : currentState?.lease ? { lease: currentState.lease } : {},
168
+ startupState: readiness.ready ? "ready" : "degraded",
169
+ startupLastProbeError: readiness.lastProbeError,
170
+ startupTimeoutMs: readinessTimeoutMs,
167
171
  startupCheckedAt: (/* @__PURE__ */ new Date()).toISOString(),
168
172
  ...currentState?.remote ? { remote: currentState.remote } : {}
169
173
  };
@@ -1,6 +1,21 @@
1
1
  import { RemoteRuntimeState } from "@nextclaw/remote";
2
2
 
3
3
  //#region src/shared/stores/managed-service-state.store.d.ts
4
+ type ManagedServiceLease = {
5
+ ownerPid: number;
6
+ heartbeatAt: string;
7
+ heartbeatIntervalMs: number;
8
+ ttlMs: number;
9
+ };
10
+ type ManagedServiceExitReason = "exit" | "signal" | "uncaughtException";
11
+ type ManagedServiceLastExit = {
12
+ pid: number;
13
+ reason: ManagedServiceExitReason;
14
+ exitedAt: string;
15
+ code?: number | null;
16
+ signal?: string | null;
17
+ message?: string | null;
18
+ };
4
19
  type ManagedServiceState = {
5
20
  pid: number;
6
21
  startedAt: string;
@@ -13,6 +28,8 @@ type ManagedServiceState = {
13
28
  startupLastProbeError?: string | null;
14
29
  startupTimeoutMs?: number;
15
30
  startupCheckedAt?: string;
31
+ lease?: ManagedServiceLease;
32
+ lastExit?: ManagedServiceLastExit;
16
33
  remote?: RemoteRuntimeState;
17
34
  };
18
35
  declare class ManagedServiceStateStore {
@@ -25,4 +42,4 @@ declare class ManagedServiceStateStore {
25
42
  }
26
43
  declare const managedServiceStateStore: ManagedServiceStateStore;
27
44
  //#endregion
28
- export { ManagedServiceState, ManagedServiceStateStore, managedServiceStateStore };
45
+ export { ManagedServiceLastExit, ManagedServiceLease, ManagedServiceState, ManagedServiceStateStore, managedServiceStateStore };
@@ -250,8 +250,22 @@ type RuntimeStatusReport = {
250
250
  pid: number | null;
251
251
  running: boolean;
252
252
  staleState: boolean;
253
+ staleReason: "process-not-running" | "lease-expired" | null;
253
254
  orphanSuspected: boolean;
254
255
  startedAt: string | null;
256
+ lease: {
257
+ heartbeatAt: string | null;
258
+ expired: boolean;
259
+ missing: boolean;
260
+ } | null;
261
+ lastExit: {
262
+ pid: number;
263
+ reason: string;
264
+ exitedAt: string;
265
+ code?: number | null;
266
+ signal?: string | null;
267
+ message?: string | null;
268
+ } | null;
255
269
  };
256
270
  endpoints: {
257
271
  uiUrl: string | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nextclaw/service",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
4
4
  "private": false,
5
5
  "description": "NextClaw long-running service host and runtime lifecycle.",
6
6
  "type": "module",
@@ -35,24 +35,24 @@
35
35
  "chokidar": "^3.6.0",
36
36
  "commander": "^12.1.0",
37
37
  "jszip": "^3.10.1",
38
- "@nextclaw/channel-extension-feishu": "0.1.5",
39
- "@nextclaw/kernel": "0.1.11",
40
- "@nextclaw/mcp": "0.1.86",
41
- "@nextclaw/ncp": "0.5.14",
42
- "@nextclaw/core": "0.12.21",
43
- "@nextclaw/ncp-agent-runtime": "0.3.25",
44
- "@nextclaw/ncp-mcp": "0.1.88",
45
- "@nextclaw/ncp-toolkit": "0.5.19",
46
- "@nextclaw/channel-extension-weixin": "0.1.8",
47
- "@nextclaw/nextclaw-ncp-runtime-http-client": "0.1.13",
48
- "@nextclaw/nextclaw-ncp-runtime-stdio-client": "0.1.14",
49
- "@nextclaw/nextclaw-hermes-acp-bridge": "0.1.13",
50
- "@nextclaw/remote": "0.1.99",
51
- "@nextclaw/runtime": "0.2.53",
52
- "@nextclaw/shared": "0.1.8",
53
- "@nextclaw/server": "0.12.22",
54
- "@nextclaw/openclaw-compat": "1.0.21",
55
- "@nextclaw/channel-extension-qq": "0.1.2"
38
+ "@nextclaw/channel-extension-qq": "0.1.3",
39
+ "@nextclaw/channel-extension-feishu": "0.1.6",
40
+ "@nextclaw/channel-extension-weixin": "0.1.9",
41
+ "@nextclaw/core": "0.12.22",
42
+ "@nextclaw/kernel": "0.1.12",
43
+ "@nextclaw/mcp": "0.1.87",
44
+ "@nextclaw/ncp-agent-runtime": "0.3.26",
45
+ "@nextclaw/ncp": "0.5.15",
46
+ "@nextclaw/ncp-toolkit": "0.5.20",
47
+ "@nextclaw/ncp-mcp": "0.1.89",
48
+ "@nextclaw/nextclaw-ncp-runtime-http-client": "0.1.14",
49
+ "@nextclaw/nextclaw-hermes-acp-bridge": "0.1.14",
50
+ "@nextclaw/nextclaw-ncp-runtime-stdio-client": "0.1.15",
51
+ "@nextclaw/remote": "0.1.100",
52
+ "@nextclaw/openclaw-compat": "1.0.22",
53
+ "@nextclaw/runtime": "0.2.54",
54
+ "@nextclaw/shared": "0.1.9",
55
+ "@nextclaw/server": "0.12.23"
56
56
  },
57
57
  "devDependencies": {
58
58
  "@types/node": "^20.17.6",